diff --git a/plugins/fusion-plugin-acp-runtime/manifest.json b/plugins/fusion-plugin-acp-runtime/manifest.json new file mode 100644 index 0000000000..b7a01d6c27 --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/manifest.json @@ -0,0 +1,13 @@ +{ + "id": "fusion-plugin-acp-runtime", + "name": "ACP Runtime Plugin", + "version": "0.1.0", + "description": "Drives any external Agent Client Protocol (ACP) agent for Fusion", + "author": "Fusion Team", + "runtime": { + "runtimeId": "acp", + "name": "ACP Runtime", + "description": "Drives any external ACP-compatible agent over JSON-RPC/stdio", + "version": "0.1.0" + } +} diff --git a/plugins/fusion-plugin-acp-runtime/package.json b/plugins/fusion-plugin-acp-runtime/package.json new file mode 100644 index 0000000000..73e53beb74 --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/package.json @@ -0,0 +1,41 @@ +{ + "name": "@fusion-plugin-examples/acp-runtime", + "version": "0.1.0", + "type": "module", + "description": "ACP (Agent Client Protocol) runtime plugin for Fusion — drives any ACP-compatible agent over JSON-RPC/stdio", + "keywords": [ + "fusion-plugin", + "acp", + "agent-client-protocol", + "runtime" + ], + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./probe": { + "types": "./src/probe.ts", + "import": "./src/probe.ts" + } + }, + "private": true, + "scripts": { + "build": "tsc", + "test": "vitest run --silent=passed-only --reporter=dot", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@agentclientprotocol/sdk": "0.24.0", + "@fusion/plugin-sdk": "workspace:*" + }, + "peerDependencies": { + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*" + }, + "devDependencies": { + "@types/node": "^25.5.2", + "typescript": "^5.7.0", + "vitest": "^3.2.4" + } +} diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts new file mode 100644 index 0000000000..82beb63767 --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import plugin, { AcpRuntimeAdapter, acpRuntimeFactory, acpRuntimeMetadata, resolveCliSettings } from "../index.js"; +import { ACP_NOT_IMPLEMENTED } from "../runtime-adapter.js"; +import type { AgentRuntime } from "../types.js"; + +describe("fusion-plugin-acp-runtime", () => { + it("declares the acp runtime in its manifest", () => { + expect(plugin.manifest.id).toBe("fusion-plugin-acp-runtime"); + expect(plugin.manifest.runtime?.runtimeId).toBe("acp"); + expect(acpRuntimeMetadata.runtimeId).toBe("acp"); + }); + + it("factory returns an AgentRuntime conforming object", async () => { + const runtime = (await acpRuntimeFactory({ settings: {} } as never)) as AgentRuntime; + expect(runtime).toBeTruthy(); + expect(runtime.id).toBe("acp"); + expect(typeof runtime.name).toBe("string"); + expect(typeof runtime.createSession).toBe("function"); + expect(typeof runtime.promptWithFallback).toBe("function"); + // describeModel is required by the contract — the adapter must implement it. + expect(typeof runtime.describeModel).toBe("function"); + }); + + it("describeModel returns the session's model description", () => { + const runtime = new AcpRuntimeAdapter({ acpModel: "gemini-2.0" }); + const desc = runtime.describeModel({ lastModelDescription: "acp/gemini-2.0" } as never); + expect(desc).toBe("acp/gemini-2.0"); + }); + + it("session-driving stubs reject with the not-implemented marker (until U2/U3)", async () => { + const runtime = new AcpRuntimeAdapter({}); + await expect( + runtime.createSession({ cwd: "/tmp", systemPrompt: "" } as never), + ).rejects.toThrow(ACP_NOT_IMPLEMENTED); + await expect(runtime.promptWithFallback({} as never, "hi")).rejects.toThrow(ACP_NOT_IMPLEMENTED); + }); +}); + +describe("resolveCliSettings", () => { + it("returns conservative defaults for undefined settings", () => { + const s = resolveCliSettings(undefined); + expect(s.binaryPath).toBe("acp-agent"); + expect(s.args).toEqual([]); + // fs capabilities are opt-in (KTD6) — default OFF. + expect(s.fsRead).toBe(false); + expect(s.fsWrite).toBe(false); + // env allow-list empty by default (KTD6b) — no inherited process.env. + expect(s.envAllowList).toEqual([]); + }); + + it("honors explicit binary, args, and capability toggles", () => { + const s = resolveCliSettings({ + acpBinaryPath: "gemini", + acpArgs: ["--acp"], + acpModel: "gemini-2.0", + acpFsRead: true, + acpEnvAllowList: ["HOME", "PATH"], + }); + expect(s.binaryPath).toBe("gemini"); + expect(s.args).toEqual(["--acp"]); + expect(s.model).toBe("gemini-2.0"); + expect(s.fsRead).toBe(true); + expect(s.fsWrite).toBe(false); + expect(s.envAllowList).toEqual(["HOME", "PATH"]); + }); +}); diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/sdk-smoke.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/sdk-smoke.test.ts new file mode 100644 index 0000000000..5d1eea82a6 --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/sdk-smoke.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import * as acp from "@agentclientprotocol/sdk"; + +// U1 gating verification (KTD2): the integration is built on a day-old SDK. +// These assertions fail the build if a load-bearing export is missing or +// reshaped, surfacing a breaking change at U1 rather than deep in U2. +describe("@agentclientprotocol/sdk export surface", () => { + it("exposes ClientSideConnection as a constructable", () => { + expect(typeof acp.ClientSideConnection).toBe("function"); + }); + + it("exposes ndJsonStream as a function", () => { + expect(typeof acp.ndJsonStream).toBe("function"); + }); + + it("exposes PROTOCOL_VERSION as the integer 1", () => { + expect(typeof acp.PROTOCOL_VERSION).toBe("number"); + expect(acp.PROTOCOL_VERSION).toBe(1); + }); + + it("exposes the client/agent method maps used for routing", () => { + expect(acp.CLIENT_METHODS).toBeDefined(); + expect(acp.AGENT_METHODS).toBeDefined(); + }); +}); diff --git a/plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts b/plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts new file mode 100644 index 0000000000..e8716d308f --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts @@ -0,0 +1,50 @@ +// Resolves the ACP agent launch configuration from plugin settings. +// +// Unlike the Claude/Droid CLIs (one fixed binary per plugin), ACP is a protocol: +// the user points this runtime at *any* ACP-compatible agent binary plus the +// flag that puts it in ACP mode (e.g. `gemini --acp`). Settings therefore carry +// an arbitrary binary + args, plus the conservative-by-default fs capability +// toggles (KTD6: writes default OFF) and an env allow-list (KTD6b). + +export interface AcpCliSettings { + /** Agent binary to spawn (e.g. "gemini", "npx", an absolute path). */ + binaryPath: string; + /** Arguments that launch the agent in ACP/stdio mode (e.g. ["--acp"]). */ + args: string[]; + /** Optional model identifier reported via describeModel. */ + model?: string; + /** Advertise `fs/read_text_file` capability. Default: false (opt-in). */ + fsRead: boolean; + /** Advertise `fs/write_text_file` capability. Default: false (opt-in, KTD6). */ + fsWrite: boolean; + /** + * Environment variables to forward to the agent subprocess (KTD6b allow-list). + * The agent is untrusted; inherited `process.env` is NOT forwarded. Empty by + * default — callers opt specific vars in by name. + */ + envAllowList: string[]; +} + +function asTrimmedString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function asStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const out = value.filter((v): v is string => typeof v === "string"); + return out.length === value.length ? out : undefined; +} + +function asBool(value: unknown): boolean { + return value === true; +} + +export function resolveCliSettings(settings?: Record): AcpCliSettings { + const binaryPath = asTrimmedString(settings?.acpBinaryPath) ?? "acp-agent"; + const args = asStringArray(settings?.acpArgs) ?? []; + const model = asTrimmedString(settings?.acpModel); + const fsRead = asBool(settings?.acpFsRead); + const fsWrite = asBool(settings?.acpFsWrite); + const envAllowList = asStringArray(settings?.acpEnvAllowList) ?? []; + return { binaryPath, args, model, fsRead, fsWrite, envAllowList }; +} diff --git a/plugins/fusion-plugin-acp-runtime/src/index.ts b/plugins/fusion-plugin-acp-runtime/src/index.ts new file mode 100644 index 0000000000..3c79b2c16e --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/src/index.ts @@ -0,0 +1,46 @@ +import { definePlugin } from "@fusion/plugin-sdk"; +import type { FusionPlugin, PluginRuntimeFactory, PluginRuntimeManifestMetadata } from "@fusion/plugin-sdk"; +import { resolveCliSettings } from "./cli-spawn.js"; +import { AcpRuntimeAdapter } from "./runtime-adapter.js"; + +export const ACP_RUNTIME_ID = "acp"; +const ACP_RUNTIME_VERSION = "0.1.0"; + +export const acpRuntimeMetadata: PluginRuntimeManifestMetadata = { + runtimeId: ACP_RUNTIME_ID, + name: "ACP Runtime", + description: "Drives any external ACP-compatible agent over JSON-RPC/stdio", + version: ACP_RUNTIME_VERSION, +}; + +export const acpRuntimeFactory: PluginRuntimeFactory = async (ctx) => + new AcpRuntimeAdapter(ctx.settings as Record | undefined); + +const plugin: FusionPlugin = definePlugin({ + manifest: { + id: "fusion-plugin-acp-runtime", + name: "ACP Runtime Plugin", + version: ACP_RUNTIME_VERSION, + description: "Drives any external ACP-compatible agent over JSON-RPC/stdio", + runtime: acpRuntimeMetadata, + }, + state: "installed", + hooks: { + onLoad: (ctx) => { + const settings = resolveCliSettings(ctx.settings as Record); + ctx.logger.info( + `ACP Runtime Plugin loaded — binary=${settings.binaryPath} args=[${settings.args.join(" ")}] ` + + `fsRead=${settings.fsRead} fsWrite=${settings.fsWrite}`, + ); + }, + }, + runtime: { + metadata: acpRuntimeMetadata, + factory: acpRuntimeFactory, + }, +}); + +export default plugin; +export { AcpRuntimeAdapter }; +export { resolveCliSettings } from "./cli-spawn.js"; +export type { AcpCliSettings } from "./cli-spawn.js"; diff --git a/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts new file mode 100644 index 0000000000..57d1b833b9 --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts @@ -0,0 +1,63 @@ +// AgentRuntime adapter for the ACP runtime. +// +// U1 scaffold: implements the full `AgentRuntime` contract shape (including the +// required `describeModel`) with stubs that throw `not_implemented` until the +// session driver lands in U2/U3. The skeleton exists so the plugin loads, +// registers as `runtimeId: "acp"`, and conforms to the interface the engine +// resolves via `getRuntimeById`. + +import { resolveCliSettings, type AcpCliSettings } from "./cli-spawn.js"; +import type { + AgentRuntime, + AgentRuntimeOptions, + AgentSession, + AgentSessionResult, + AcpSession, +} from "./types.js"; + +export const ACP_NOT_IMPLEMENTED = "acp_not_implemented"; + +export class AcpRuntimeAdapter implements AgentRuntime { + readonly id = "acp"; + readonly name = "ACP Runtime"; + private readonly settings: AcpCliSettings; + + constructor(settings?: Record) { + this.settings = resolveCliSettings(settings); + } + + async createSession(options: AgentRuntimeOptions): Promise { + // Session establishment (spawn + initialize + session/new) lands in U2/U3. + // The skeleton constructs the session shell so the contract is observable. + const model = this.settings.model ?? options.defaultModelId ?? "acp"; + const session: AcpSession = { + model, + systemPrompt: options.systemPrompt, + sessionId: "", + cwd: options.cwd, + lastModelDescription: `acp/${model}`, + callbacks: { + onText: options.onText, + onThinking: options.onThinking, + onToolStart: options.onToolStart, + onToolEnd: options.onToolEnd, + }, + gate: options.actionGateContext, + dispose: () => undefined, + }; + throw new Error(`${ACP_NOT_IMPLEMENTED}: createSession lands in U2/U3 (session=${session.lastModelDescription})`); + } + + async promptWithFallback(_session: AgentSession, _prompt: string, _options?: unknown): Promise { + throw new Error(`${ACP_NOT_IMPLEMENTED}: promptWithFallback lands in U3`); + } + + describeModel(session: AgentSession): string { + return session.lastModelDescription || "acp"; + } + + async dispose(session: AgentSession): Promise { + // Best-effort teardown; the authoritative kill is the process registry (KTD4a). + session.dispose(); + } +} diff --git a/plugins/fusion-plugin-acp-runtime/src/types.ts b/plugins/fusion-plugin-acp-runtime/src/types.ts new file mode 100644 index 0000000000..35d4976418 --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/src/types.ts @@ -0,0 +1,86 @@ +// Local types for the ACP (Agent Client Protocol) runtime plugin. +// +// The wire protocol types come from `@agentclientprotocol/sdk` (the `schema` +// namespace). These local types describe (a) the Fusion `AgentRuntime` contract +// this plugin implements and (b) the ACP session state this plugin tracks. +// +// The `AgentRuntimeOptions` here is a plugin-local structural copy of the engine +// contract (`packages/engine/src/agent-runtime.ts`). It deliberately includes +// only the fields this runtime reads. `actionGateContext` is the engine-populated +// per-run permission gate — see `PermissionGate` below, the narrow structural +// view this plugin couples to instead of importing `@fusion/engine` internals. + +/** Callbacks the engine wires to surface streamed agent output into Fusion's UI/logs. */ +export interface AcpCallbacks { + onText?: (text: string) => void; + onThinking?: (text: string) => void; + onToolStart?: (toolName: string, args?: unknown) => void; + onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void; +} + +/** + * Narrow structural view of the engine's `AgentActionGateContext` + * (`packages/engine/src/agent-action-gate.ts`). The plugin reads only these + * members; typing them locally avoids a hard dependency on `@fusion/engine`. + * + * All HITL closures are optional: when absent, the permission floor (U5) + * default-denies `require-approval` categories rather than throwing. + */ +export interface PermissionGate { + permissionPolicy?: unknown; + evaluate?: (toolName: string, args: unknown) => unknown; + resolveGateOutcome?: (evaluation: unknown) => unknown; + createApprovalRequest?: (...args: unknown[]) => Promise | unknown; + findApprovalByDedupeKey?: (...args: unknown[]) => Promise | unknown; + pauseForApproval?: (...args: unknown[]) => Promise | unknown; + markApprovalCompleted?: (...args: unknown[]) => Promise | unknown; +} + +/** Plugin-local copy of the engine's AgentRuntimeOptions (subset this runtime reads). */ +export interface AgentRuntimeOptions { + cwd: string; + systemPrompt: string; + tools?: "coding" | "readonly"; + onText?: (text: string) => void; + onThinking?: (text: string) => void; + onToolStart?: (toolName: string, args?: unknown) => void; + onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void; + defaultProvider?: string; + defaultModelId?: string; + defaultThinkingLevel?: string; + /** Per-run permission gate, populated by the engine. See PermissionGate. */ + actionGateContext?: PermissionGate; +} + +/** Live ACP session state tracked by the runtime adapter. */ +export interface AcpSession { + /** Model/agent identifier resolved for this session. */ + model: string; + systemPrompt: string; + /** ACP session id returned by `session/new` (empty until established). */ + sessionId: string; + /** Working directory the agent operates over (the task worktree). */ + cwd: string; + lastModelDescription: string; + callbacks: AcpCallbacks; + /** Per-run permission gate captured at createSession (U5/U7 read this). */ + gate?: PermissionGate; + dispose(): void; +} + +export type AgentSession = AcpSession; + +export interface AgentSessionResult { + session: AgentSession; + sessionFile?: string; +} + +/** The Fusion runtime contract this plugin implements (mirrors the engine interface). */ +export interface AgentRuntime { + id: string; + name: string; + createSession(options: AgentRuntimeOptions): Promise; + promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise; + describeModel(session: AgentSession): string; + dispose?(session: AgentSession): Promise; +} diff --git a/plugins/fusion-plugin-acp-runtime/tsconfig.json b/plugins/fusion-plugin-acp-runtime/tsconfig.json new file mode 100644 index 0000000000..a5a86f4738 --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*"] +} diff --git a/plugins/fusion-plugin-acp-runtime/vitest.config.ts b/plugins/fusion-plugin-acp-runtime/vitest.config.ts new file mode 100644 index 0000000000..7f8fb8a972 --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; +import { computeMaxWorkers } from "../../packages/core/src/__test-utils__/vitest-workers"; + +const maxWorkers = computeMaxWorkers(); + +export default defineConfig({ + resolve: { + alias: { + "@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)), + "@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)), + }, + }, + test: { + include: ["src/**/*.test.ts"], + setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))], + globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))], + pool: "threads", + maxWorkers, + poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 659232f24d..abddb9841e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -627,6 +627,31 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.7.0)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.9.0) + plugins/fusion-plugin-acp-runtime: + dependencies: + '@agentclientprotocol/sdk': + specifier: 0.24.0 + version: 0.24.0(zod@4.3.6) + '@earendil-works/pi-ai': + specifier: '*' + version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@earendil-works/pi-coding-agent': + specifier: '*' + version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@fusion/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + devDependencies: + '@types/node': + specifier: ^25.5.2 + version: 25.5.2 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.7.0)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.9.0) + plugins/fusion-plugin-agent-browser: dependencies: '@fusion/plugin-sdk': @@ -971,6 +996,11 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@agentclientprotocol/sdk@0.24.0': + resolution: {integrity: sha512-vvu9appvGvfYstBj19C6NCepV6SvUhY5VRv60KUZ4XzhTah/olOYul5Zo4C+x2enyshMSvgB2mm/OEmrsHaSmA==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@alcalzone/ansi-tokenize@0.2.5': resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} engines: {node: '>=18'} @@ -6439,6 +6469,10 @@ snapshots: '@adobe/css-tools@4.4.4': {} + '@agentclientprotocol/sdk@0.24.0(zod@4.3.6)': + dependencies: + zod: 4.3.6 + '@alcalzone/ansi-tokenize@0.2.5': dependencies: ansi-styles: 6.2.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9ef1e5d982..3e8e923766 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ packages: - "plugins/fusion-plugin-openclaw-runtime" - "plugins/fusion-plugin-hermes-runtime" - "plugins/fusion-plugin-droid-runtime" + - "plugins/fusion-plugin-acp-runtime" - "plugins/fusion-plugin-cursor-runtime" - "plugins/fusion-plugin-agent-browser" - "plugins/fusion-plugin-whatsapp-chat"