diff --git a/.changeset/acp-route-a-claude-cli-bridge.md b/.changeset/acp-route-a-claude-cli-bridge.md new file mode 100644 index 000000000..27a3f3efb --- /dev/null +++ b/.changeset/acp-route-a-claude-cli-bridge.md @@ -0,0 +1,11 @@ +--- +"@runfusion/fusion": minor +--- + +Route Fusion's Claude CLI path through the ACP bridge (`claude-code-cli-acp`) instead of `claude -p` (Route A, dormant behind an OFF-by-default kill-switch). + +- **U10** — forward `mcpServers` on ACP `session/new` through the runtime contract (`AgentRuntimeOptions.mcpServers` + the plugin's `newAcpSession`); defaults to `[]` so existing read-only ACP "ask" turns are unchanged. +- **U11** — `streamViaAcp`: the `pi-claude-cli` provider can drive Claude through the bundled ACP bridge, returning the same `AssistantMessageEventStream` as the `-p` path. Dispatched only when `FUSION_CLAUDE_ACP=1` and a bridge path are present, so the live `-p` path is byte-for-byte untouched by default. Full-history prompting, schema-only MCP forwarding with break-early on pi-known tools, control-char/size sanitization, env allow-list, process-registry registration, and inactivity timeout. +- **KTD10** — the ACP runtime plugin publishes its identity-pinned bundled bridge path on load so the kill-switch needs no manual path; it does not enable the transport. + +The Claude-via-pi OAuth path is unchanged. Live verification confirmed the bridge gates tool execution behind `session/request_permission` (forwarded MCP tools and native tools do not execute when cancelled). Remaining for a follow-up: picker/auth/status surface (U12), workflow `model`-node verification (U13), and production rollout. diff --git a/docs/acp-contract.md b/docs/acp-contract.md index faef93525..70df18f4e 100644 --- a/docs/acp-contract.md +++ b/docs/acp-contract.md @@ -161,3 +161,76 @@ FN-6476 was the genuinely-authenticated U9 escalation rerun, but this worktree s **FN-6475 sponsorship record (2026-06-15):** upstream sponsorship was authored in [`docs/upstream/claude-code-cli-acp-mcp-permission-forwarding.md`](upstream/claude-code-cli-acp-mcp-permission-forwarding.md) and filed as https://github.com/moabualruz/claude-code-cli-acp/issues/2. This records the requested MCP passthrough plus permission-gate traversal / MCP-layer hook contract only; OQ1 remains **UNRESOLVED / BLOCKED** and the combined Route A verdict remains **NOT GO** until a later authenticated rerun proves both required U9 answers. **U14 internal mechanisms:** GO for design, subject to U9. Route A should use a second `acp-claude` runtime posture rather than mutating the generic `acp` runtime; inject the ACP bridge client from the engine `registerExtensionProviders` seam into the vendored `@fusion/pi-claude-cli` provider options; and add `AgentRuntimeOptions.mcpServers` to both the engine runtime contract and the ACP plugin-local structural copy, with `newAcpSession` defaulting to `[]` for Route-B compatibility. +## U9 verdict — MCP-over-ACP through the Claude bridge (2026-06-15) + +**U9 MECHANICS = GO** (overturns the prior headless NOT-GO chain; see plan OQ1). +Spike: pinned `claude-code-cli-acp` 0.1.1 driven directly over ACP (SDK 0.24.0) +in an **interactive TTY with `claude` logged in**, non-empty `session/new.mcpServers` += one stdio `custom-tools` server exposing `fn_task_list`. + +- **Auth:** bridged `claude` authenticated via the interactive login session — no `/login` wall. +- **(1) MCP forwarding:** PROVEN — Claude invoked `mcp__custom-tools__fn_task_list`; + the MCP server's `tools/call` executed; result returned via a `tool_call` `session/update`. +- **(2) Permission gate:** GATED — `session/request_permission` (`allow_once`/`allow_always`/`reject`) + fired *before* execution. The ACP permission floor holds; forwarded MCP calls are NOT bypassed. + +**Operational precondition (R17):** auth works only where the bridged `claude` can reach +the login/keychain session. The Fusion daemon/worker context is detached from that session +→ `Not logged in` (this is why FN-6466/6467/6473/6476 failed). Route-A mechanics are +unblocked (U10–U13 buildable); **shipping requires the provider's runtime to host an +authenticated `claude`** (keychain/login access, or file-based creds the daemon can read). + +### R17 resolution (2026-06-15): daemon-auth is the HARD ship-gate — creds are Keychain-only + +`claude` here stores OAuth creds in the **macOS Keychain** (`genp` / `svce="Claude Code-credentials"`), +NOT in a file: `~/.claude/.credentials.json` is an empty directory. Therefore forwarding `HOME` +to the bridge does **not** give the daemon-hosted `claude` its credentials. A detached Fusion +daemon/worker runs in a different security session with no login-Keychain access → `Not logged in` +(the root cause of the FN-6466/6467/6473/6476 failures). + +**Implication:** U9 mechanics are GO, but **Route A cannot ship to the daemon-hosted `pi-claude-cli` +provider until daemon→Keychain auth is solved.** Candidate resolutions (each its own follow-up): +1. Host the provider's bridge in a process with login-Keychain access (run within the user's Aqua + session, not a detached launchd daemon). +2. Provide the bridge's `claude` a file/API-key credential the daemon CAN read — but the user's auth + is claude.ai OAuth, and the env allow-list deliberately excludes `ANTHROPIC_API_KEY` from the + untrusted bridge; changing that is a security-posture decision. +3. Grant the daemon explicit Keychain access (`security unlock-keychain` / ACL) — fragile, security-sensitive. + +Until one lands, Route A is mechanically proven but operationally blocked on macOS. + +### R17 CLOSED (2026-06-15): confirmed by user — Claude CLI works in the live `fn` daemon today + +The user confirmed the existing `pi-claude-cli` (`claude -p`) provider authenticates in their +running Fusion daemon. Since the daemon is launched from their login session, it has macOS +Keychain access; the ACP bridge's `claude` inherits the same session and authenticates identically. +**R17 is satisfied for the supported (login-session) daemon.** Residual (documented, not blocking): +detached/headless launchd daemons would still need a credential-delivery solution — out of scope +for the supported setup. **Route A (U10–U13) is cleared to build.** + +### U11 tool-flow verification PASSED (2026-06-15): enablement gate cleared + +Live run against pinned `claude-code-cli-acp` 0.1.1 in an authenticated session, +returning `cancelled` to every `session/request_permission` (what `streamViaAcp` +does). Two tests, fresh session each: + +- **Forwarded MCP tool (`fn_task_list`):** Claude fired `ToolSearch` first + (internal, completed), then `mcp__custom-tools__fn_task_list` — a permission + request fired, we cancelled, the call went to `failed`, and the schema server's + `tools/call` was NEVER reached (no execution marker). Forwarded tools do not + execute when cancelled. +- **Native Bash:** permission request fired, we cancelled, `Bash` went to + `failed`, the side-effect file was never created. Native tools do not execute + when cancelled. + +Conclusion: the bridge gates tool execution BEHIND `session/request_permission` +(no TOCTOU window); `streamViaAcp`'s deny-by-default handler + break-early on +pi-known tools is SAFE. Also validated: the bridged `claude` authenticates only +with the richer env allow-list (HOME/PATH + USER/SHELL/LANG/XDG_*) that +`streamViaAcp` forwards — a thin {HOME,PATH} env fails with "Not logged in". +The Route A enablement gate is CLEARED. Harness: /tmp/acp-toolflow/verify2.mjs. + +### Route A architecture notes (2026-06-15, from review) + +- **Two parallel MCP-forwarding paths, by design.** U10 wires `mcpServers` through the engine `AgentRuntimeOptions` → ACP plugin adapter `newAcpSession` (for the engine-driven `acp` runtime). U11's `pi-claude-cli` provider does NOT consume that field — `streamViaAcp` drives its OWN inline ACP client and builds `mcpServers` locally via `buildAcpMcpServers` (KTD10: the provider speaks ACP directly via the published bridge path, never through the plugin adapter). The two intersect only at the shared schema-only MCP server shape. Do not "wire U10 into U11" — that would double-forward. +- **Known residual: ACP-path token usage/cost reads zero.** `streamViaAcp` synthesizes pi events via `createEventBridge` from ACP `session/update`s, which carry no token-usage frames, so `output.usage` stays zero on the ACP path. Cost telemetry undercounts when the kill-switch is enabled. The U12 status surface should not treat zero-usage as a bug; wiring usage (if the bridge ever exposes it) is deferred. diff --git a/docs/plans/2026-06-14-001-feat-claude-acp-runtime-plan.md b/docs/plans/2026-06-14-001-feat-claude-acp-runtime-plan.md index e73541930..72b96314e 100644 --- a/docs/plans/2026-06-14-001-feat-claude-acp-runtime-plan.md +++ b/docs/plans/2026-06-14-001-feat-claude-acp-runtime-plan.md @@ -142,6 +142,7 @@ sequenceDiagram - **FN-6473 escalation outcome (2026-06-15): UNRESOLVED / BLOCKED; combined Route A verdict remains NOT GO.** This explicit escalation again verified real bridge prerequisites (`claude` **2.1.177** at `/Users/eclipxe/.local/bin/claude`, plugin-local pinned `claude-code-cli-acp` **0.1.1**, unchanged lockfile integrity `sha512-qpfRGOXkOs9mqI7oumsGistWisyXcCC0r7ng7wdLvGMIORdzHjmUUa+94Jftgr/NYAVnAUe6N7kimD8PaO3D5g==`) and drove the pinned bridge with explicit `session/request_permission` instrumentation. The non-empty ACP payload was the Route-A `custom-tools` stdio server (`command: "node"`, `args: [packages/pi-claude-cli/src/mcp-schema-server.cjs, ]`, `env: []`) carrying **62** Fusion custom-tool names confirmed from `packages/cli/src/extension.ts` and matching `mcp-config.ts`'s `writeMcpConfig` shape. `initialize` returned `agentInfo.name="claude-code-cli-acp"`, `version="0.1.1"`, and `authMethods=["claude-code-login"]`; `session/new` accepted the non-empty `mcpServers` entry. The prompt instructed Claude to call `fn_task_list`, but the turn ended with **`Not logged in · Please run /login`**, stopReason `end_turn`, **zero** tool-call updates, and **zero** ACP `session/request_permission` callbacks. Therefore answer **(1)** remains **UNPROVEN / BLOCKED** and answer **(2)** remains **UNPROVEN / BLOCKED** (neither GATED nor BYPASSED observed). Sponsor bridge/ACP MCP permission-forwarding and rerun in a genuinely authenticated bridge environment; never resolve this by falling back to `claude -p`. - **FN-6475 upstream sponsorship (2026-06-15): sponsorship authored and filed; combined Route A verdict remains NOT GO.** The ready-to-file package is committed at [`docs/upstream/claude-code-cli-acp-mcp-permission-forwarding.md`](../upstream/claude-code-cli-acp-mcp-permission-forwarding.md) and filed upstream as https://github.com/moabualruz/claude-code-cli-acp/issues/2. It requests both required upstream capabilities: forwarding ACP `session/new.mcpServers` to authenticated `claude`, and routing forwarded MCP tool calls through ACP `session/request_permission` or an equivalent MCP-layer permission hook. This is an escalation/tracking action only; OQ1 stays **UNRESOLVED / BLOCKED**, U9 stays **NOT GO**, and no `claude -p` fallback is acceptable. - **FN-6476 genuinely-authenticated rerun attempt (2026-06-15): still UNRESOLVED / BLOCKED; combined Route A verdict remains NOT GO.** This run re-confirmed `claude` **2.1.177** at `/Users/eclipxe/.local/bin/claude`, pinned `claude-code-cli-acp` **0.1.1** under `plugins/fusion-plugin-acp-runtime/node_modules/.bin`, and unchanged lockfile integrity `sha512-qpfRGOXkOs9mqI7oumsGistWisyXcCC0r7ng7wdLvGMIORdzHjmUUa+94Jftgr/NYAVnAUe6N7kimD8PaO3D5g==`. The FN-6473 payload was supplied by the committed OQ1 record and rebuilt from the real Route-A shape: one `custom-tools` stdio server (`command: "node"`, `args: [packages/pi-claude-cli/src/mcp-schema-server.cjs, ]`, `env: []`) carrying **62** Fusion custom-tool names from `packages/cli/src/extension.ts`. The authenticated-readiness proof opened ACP directly and drove a no-MCP prompt turn before any tool verdict; the bridge returned **`Not logged in · Please run /login`** with stopReason `end_turn`, zero tool-like updates, and zero `session/request_permission` callbacks. Therefore answer **(1)** remains **UNPROVEN / BLOCKED** (no forwarded Fusion tool invoked) and answer **(2)** remains **UNPROVEN / BLOCKED** (neither GATED nor BYPASSED observed). FN-6475 remains the sponsorship path; no `claude -p` fallback is acceptable. + - **✅ INTERACTIVE-SESSION SPIKE (2026-06-15): U9 MECHANICS = GO — overturns the NOT-GO chain above, with one operational precondition.** Run in an **interactive TTY with `claude` logged in** (`loggedIn:true`, claude.ai / eclipxe@gmail.com) — the exact condition every headless task (FN-6466/6467/6473/6476) lacked. Pinned bridge `claude-code-cli-acp` **0.1.1** driven directly over ACP (SDK **0.24.0**) with a **non-empty** `session/new.mcpServers`: one stdio server `custom-tools` (`command:"node"`, `args:[]`, `env:[]`). Observed: **(auth)** no `/login` wall — the bridged `claude` authenticated via the interactive login/keychain session; **(1) forwarded-tool invocation = PROVEN** — Claude invoked `mcp__custom-tools__fn_task_list`, the MCP server's `tools/call` executed (ground-truth marker file written), and the result flowed back as a `tool_call` `session/update`; **(2) gate traversal = GATED** — a `session/request_permission` (options `allow_once`/`allow_always`/`reject`) fired **before** execution. So the bridge forwards MCP **and** the ACP permission floor holds (NOT bypassed) — both security-critical answers resolved positively. **THE RESIDUAL IS OPERATIONAL, NOT MECHANICAL:** auth succeeds only where the bridged `claude` can reach the login/keychain session. FN-6476 "authenticated" but ran in the **Fusion daemon/worker context** detached from that session → `Not logged in`. **Conclusion: U9 mechanics GO; U10–U13 are unblocked for implementation. New Route-A acceptance gate (R17): the runtime that hosts the `pi-claude-cli` provider must have an authenticated `claude` (keychain/login access, or file-based creds the daemon can read).** The FN-6475 upstream issue is no longer the mechanics blocker; the daemon-auth precondition is the remaining ship gate. Harness: `/tmp/acp-u9-*/{spike.mjs,mcp-server.cjs}`. - **OQ2 (blocking sub-gate of U11) — Resume loss is amnesia, not a slowdown.** On resume the provider sends **only the latest user turn** (`buildResumePrompt`, `packages/pi-claude-cli/src/provider.ts:114-125`) and relies on `--resume` to load prior conversation from disk. The ACP path opens a **fresh session per turn** with no `sessionId` passthrough (`loadAcpSession` deferred). Dropping resume **without** switching to full-history prompts makes Claude answer multi-turn chat/executor conversations with zero prior context — silently. **Decision required in U11:** either thread `sessionId` → `loadAcpSession`, or send full flattened history (`buildPrompt`) every turn. No path may send latest-turn-only without resume. - **OQ3 (blocking sub-gate of U11) — Tool-call & partial-message fidelity through the round-trip.** The provider consumes native `stream-json` with `--include-partial-messages` (exact tool-call argument boundaries); the ACP path re-derives chunks from transcript-JSONL → ACP `session/update` → the event bridge, which sanitizes/space-repairs/bounds the stream. Confirm tool-call arguments survive with intact start/end correlation and no space-repair corruption of JSON args, and that executor/reviewer lanes tolerate the transformed deltas. Capture exact tool-call argument bytes in U11's characterization tests, not just token ordering. @@ -382,6 +383,15 @@ plugins/fusion-plugin-acp-runtime/src/ - Model id selected in settings is forwarded to the ACP session. - Characterization tests for the prior `-p` behavior are updated, not left asserting the old transport. +**Implementation notes (design-confirmed 2026-06-15, ready to execute):** +- **Contract to match:** `streamViaCli(model, context, options): AssistantMessageEventStream` (from `@earendil-works/pi-ai`). The new `streamViaAcp` must return the same `AssistantMessageEventStream` and push the same event shapes: streamed `text`/`thinking` deltas, `ToolCall` events, and a terminal `{ type: "done", reason, message }` (an `AssistantMessage` with `content:[]` on error — pi's `extractResult` crashes on `error`-typed events, so end with `done` even on failure, mirroring `endStreamWithError` at `provider.ts:181-198`). +- **Branch point:** in `index.ts` `streamSimple` (lines 222-235), dispatch on the kill-switch: `useAcpBridge() ? streamViaAcp(model, context, {...options, mcpServers, bridgePath}) : streamViaCli(...)`. Kill-switch OFF by default (R14) — e.g. `FUSION_CLAUDE_ACP==="1"` or a `useClaudeCliAcp` global setting — so the live `-p` path is untouched until soak. +- **KTD10 injection seam:** add `@agentclientprotocol/sdk` as a `pi-claude-cli` dependency (vendored package — allowed) so the extension speaks ACP without importing `@fusion/engine`. The **bridge binary path** is injected via `streamSimple` options the same way `mcpConfigPath` is today (engine resolves it from the acp-runtime plugin bundle at `registerExtensionProviders`, `pi.ts:1366-1422`, and threads it in) — the extension never reaches into the plugin's `node_modules` itself. +- **MCP servers:** reuse the tool list `ensureMcpConfig` already assembles (`index.ts:223-230`) to build the `AcpMcpServer[]` (`{name:"custom-tools",command:"node",args:[schemaServer,schemaFile],env:[]}`) — the same shape U9 proved and U10 forwards. +- **Prompt (R13):** always `buildPrompt(context)` (full flattened history) — never the `buildResumePrompt` latest-turn-only branch (`provider.ts:115-124`), since the ACP path has no `--resume`. +- **ACP→pi event translation** parallels `plugins/fusion-plugin-acp-runtime/src/event-bridge.ts` (ACP `session/update` → callbacks) but targets pi's `AssistantMessageEventStream` instead of Fusion callbacks; reuse `tool-mapping.ts` for Claude↔pi tool names. +- **Verification:** drive the live bridge exactly as the U9 harness did (`/tmp/acp-u9-*/spike.mjs`) but asserting pi-stream output, before enabling the kill-switch in any lane. + ### U12. Settings, picker, auth, and status surface **Goal:** Make the Claude-CLI toggle/picker/status reflect the ACP-backed reality without forcing user re-selection. diff --git a/packages/engine/src/agent-runtime.ts b/packages/engine/src/agent-runtime.ts index e10891011..c60afdd89 100644 --- a/packages/engine/src/agent-runtime.ts +++ b/packages/engine/src/agent-runtime.ts @@ -32,6 +32,18 @@ export interface AgentRuntimeContext { requestedSkillNames?: string[]; } +/** + * A stdio MCP server forwarded to a runtime's agent session (U10 — Route A ACP). + * `env` is explicit name/value pairs; inherited `process.env` is never forwarded. + * Runtimes that don't speak MCP ignore this field. + */ +export interface AgentMcpServerConfig { + name: string; + command: string; + args: string[]; + env: { name: string; value: string }[]; +} + export interface AgentRuntimeOptions { /** Working directory for the agent session */ cwd: string; @@ -78,6 +90,13 @@ export interface AgentRuntimeOptions { skills?: string[]; /** Runtime-facing context for non-pi runtimes that cannot consume JS ToolDefinition objects directly. */ runtimeContext?: AgentRuntimeContext; + /** + * MCP servers to forward to the runtime's agent session (U10 — Route A ACP). + * Consumed by runtimes that speak MCP (e.g. the ACP runtime forwards them on + * `session/new`); ignored by runtimes that don't. Tool calls still route + * through the runtime's permission floor. + */ + mcpServers?: AgentMcpServerConfig[]; /** Optional task-scoped environment variables for session-local subprocesses. */ taskEnv?: NodeJS.ProcessEnv; /** diff --git a/packages/pi-claude-cli/index.ts b/packages/pi-claude-cli/index.ts index 1c14946e1..110386876 100644 --- a/packages/pi-claude-cli/index.ts +++ b/packages/pi-claude-cli/index.ts @@ -8,6 +8,7 @@ import { getModels } from "@earendil-works/pi-ai"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { streamViaCli } from "./src/provider.js"; +import { streamViaAcp } from "./src/acp-driver.js"; import { validateCliPresenceAsync, validateCliAuthAsync, @@ -18,9 +19,32 @@ import { getCustomToolDefs, toolsFromContext, writeMcpConfig, + buildAcpMcpServers, type McpToolDef, } from "./src/mcp-config.js"; +/** + * Route A kill-switch (U11). When `FUSION_CLAUDE_ACP=1` AND a bridge binary path + * is available (`FUSION_CLAUDE_ACP_BRIDGE`, injected by the engine seam per + * KTD10), the provider drives Claude through the ACP bridge instead of + * `claude -p`. OFF by default: the live `-p` path is untouched until soak. + */ +function resolveAcpBridgePath(): string | undefined { + if (process.env.FUSION_CLAUDE_ACP !== "1") return undefined; + const p = process.env.FUSION_CLAUDE_ACP_BRIDGE; + return typeof p === "string" && p.length > 0 ? p : undefined; +} + +/** Resolve custom tool defs the same way ensureMcpConfig does (context → registry). */ +function resolveToolDefs( + pi: ExtensionAPI, + contextTools?: ReadonlyArray<{ name: string; description: string; parameters: Record }>, +): McpToolDef[] { + let toolDefs = toolsFromContext(contextTools); + if (toolDefs.length === 0 && Array.isArray(pi.getAllTools())) toolDefs = getCustomToolDefs(pi); + return toolDefs; +} + // Kill all active Claude subprocesses on process exit to prevent orphans process.on("exit", killAllProcesses); @@ -220,14 +244,29 @@ export default function (pi: ExtensionAPI) { api: "pi-claude-cli", models, streamSimple: (model, context, options) => { - const configPath = ensureMcpConfig( - pi, - (context as { tools?: ReadonlyArray<{ - name: string; - description: string; - parameters: Record; - }> }).tools, - ); + const contextTools = (context as { tools?: ReadonlyArray<{ + name: string; + description: string; + parameters: Record; + }> }).tools; + + // Route A (U11): drive Claude through the ACP bridge when the kill-switch + // is on AND a bridge path is injected. OFF by default → `-p` path below. + const bridgePath = resolveAcpBridgePath(); + if (bridgePath) { + const toolDefs = resolveToolDefs(pi, contextTools); + const hash = createHash("sha1").update(JSON.stringify(toolDefs)).digest("hex").slice(0, 12); + return streamViaAcp(model, context, { + ...options, + bridgePath, + mcpServers: buildAcpMcpServers(toolDefs, hash), + // Forward only HOME/PATH so the bridged `claude` authenticates from the + // login/keychain session (R17); never inherited process.env or API keys. + bridgeEnv: { HOME: process.env.HOME, PATH: process.env.PATH }, + }); + } + + const configPath = ensureMcpConfig(pi, contextTools); return streamViaCli(model, context, { ...options, mcpConfigPath: configPath, diff --git a/packages/pi-claude-cli/package.json b/packages/pi-claude-cli/package.json index 734242a7b..38930ec1c 100644 --- a/packages/pi-claude-cli/package.json +++ b/packages/pi-claude-cli/package.json @@ -19,6 +19,9 @@ "url": "https://github.com/Runfusion/Fusion", "directory": "packages/pi-claude-cli" }, + "dependencies": { + "@agentclientprotocol/sdk": "0.24.0" + }, "peerDependencies": { "@earendil-works/pi-ai": "*", "@earendil-works/pi-coding-agent": "*" diff --git a/packages/pi-claude-cli/src/__tests__/acp-dispatch.test.ts b/packages/pi-claude-cli/src/__tests__/acp-dispatch.test.ts new file mode 100644 index 000000000..11dff934c --- /dev/null +++ b/packages/pi-claude-cli/src/__tests__/acp-dispatch.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Spy on both transports so we can assert which one streamSimple dispatches to. +const { streamViaCli, streamViaAcp } = vi.hoisted(() => ({ + streamViaCli: vi.fn(() => ({ kind: "cli" })), + streamViaAcp: vi.fn(() => ({ kind: "acp" })), +})); +vi.mock("../provider.js", () => ({ streamViaCli })); +vi.mock("../acp-driver.js", () => ({ streamViaAcp })); +// Registering the provider kicks off async CLI presence/auth probes; stub them +// so the test doesn't trip the "real CLI launch blocked" guard. +vi.mock("../process-manager.js", () => ({ + validateCliPresenceAsync: vi.fn(async () => ({ ok: true })), + validateCliAuthAsync: vi.fn(async () => ({ ok: true })), + killAllProcesses: vi.fn(), +})); +// Belt-and-suspenders: no real CLI spawn even if a probe slips through. +vi.mock("node:child_process", () => ({ spawn: vi.fn(() => ({ on: vi.fn(), stdout: { on: vi.fn() }, stderr: { on: vi.fn() }, stdin: { write: vi.fn(), end: vi.fn() }, kill: vi.fn() })), execSync: vi.fn(() => Buffer.from("")) })); + +vi.mock("@earendil-works/pi-ai", () => ({ + getModels: vi.fn(() => []), + AssistantMessageEventStream: vi.fn(), + calculateCost: vi.fn(), +})); + +import register from "../../index.js"; + +function registerAndGetStreamSimple() { + const calls: Array<[string, { streamSimple: (...a: unknown[]) => unknown }]> = []; + const pi = { + registerProvider: (name: string, cfg: { streamSimple: (...a: unknown[]) => unknown }) => calls.push([name, cfg]), + on: vi.fn(), + getAllTools: () => [], + setActiveTools: vi.fn(), + } as never; + register(pi); + return calls[0][1].streamSimple; +} + +const MODEL = { id: "claude-sonnet-4-5" } as never; +const CTX = { messages: [{ role: "user", content: "hi" }], tools: [] } as never; + +describe("streamSimple kill-switch dispatch (U11/R9/R14)", () => { + const saved = { flag: process.env.FUSION_CLAUDE_ACP, bridge: process.env.FUSION_CLAUDE_ACP_BRIDGE }; + beforeEach(() => { streamViaCli.mockClear(); streamViaAcp.mockClear(); }); + afterEach(() => { + process.env.FUSION_CLAUDE_ACP = saved.flag; + process.env.FUSION_CLAUDE_ACP_BRIDGE = saved.bridge; + }); + + it("defaults to the -p path (streamViaCli) when the kill-switch is OFF", () => { + delete process.env.FUSION_CLAUDE_ACP; + const streamSimple = registerAndGetStreamSimple(); + streamSimple(MODEL, CTX, {}); + expect(streamViaCli).toHaveBeenCalledTimes(1); + expect(streamViaAcp).not.toHaveBeenCalled(); + }); + + it("stays on -p when the flag is set but NO bridge path is provided", () => { + process.env.FUSION_CLAUDE_ACP = "1"; + delete process.env.FUSION_CLAUDE_ACP_BRIDGE; + const streamSimple = registerAndGetStreamSimple(); + streamSimple(MODEL, CTX, {}); + expect(streamViaCli).toHaveBeenCalledTimes(1); + expect(streamViaAcp).not.toHaveBeenCalled(); + }); + + it("dispatches to the ACP bridge when the flag AND a bridge path are set", () => { + process.env.FUSION_CLAUDE_ACP = "1"; + process.env.FUSION_CLAUDE_ACP_BRIDGE = "/abs/claude-code-cli-acp"; + const streamSimple = registerAndGetStreamSimple(); + streamSimple(MODEL, CTX, {}); + expect(streamViaAcp).toHaveBeenCalledTimes(1); + expect(streamViaCli).not.toHaveBeenCalled(); + // bridgePath forwarded; env restricted to the allow-list at spawn (driver). + const opts = (streamViaAcp.mock.calls[0] as unknown[])[2] as { bridgePath?: string; bridgeEnv?: Record }; + expect(opts.bridgePath).toBe("/abs/claude-code-cli-acp"); + expect(opts.bridgeEnv).toBeDefined(); + }); +}); diff --git a/packages/pi-claude-cli/src/__tests__/acp-driver.test.ts b/packages/pi-claude-cli/src/__tests__/acp-driver.test.ts new file mode 100644 index 000000000..83750a5f3 --- /dev/null +++ b/packages/pi-claude-cli/src/__tests__/acp-driver.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; + +// Synthetic ACP session/update sequence the mocked prompt() will replay. +let scriptedUpdates: Array> = []; + +// Driver validates the bridge path with existsSync — make the fake path "exist". +vi.mock("node:fs", () => ({ existsSync: () => true })); + +vi.mock("node:child_process", () => ({ + spawn: vi.fn(() => { + const proc = new EventEmitter() as EventEmitter & Record; + proc.stdin = new PassThrough(); + proc.stdout = new PassThrough(); + proc.stderr = new PassThrough(); + proc.kill = vi.fn(); + proc.pid = 4242; + return proc; + }), +})); + +// Mock the ACP SDK: ClientSideConnection.prompt() replays scriptedUpdates onto +// the client handler, then resolves — so we exercise the real translation logic. +vi.mock("@agentclientprotocol/sdk", () => ({ + PROTOCOL_VERSION: 1, + ndJsonStream: vi.fn(() => ({})), + ClientSideConnection: vi.fn(function (this: Record, factory: () => { sessionUpdate: (p: unknown) => Promise }) { + const handler = factory(); + this.initialize = vi.fn(async () => ({ protocolVersion: 1 })); + this.newSession = vi.fn(async () => ({ sessionId: "s1" })); + this.prompt = vi.fn(async () => { + for (const u of scriptedUpdates) await handler.sessionUpdate({ update: u }); + return { stopReason: "end_turn" }; + }); + }), +})); + +const { MockStream } = vi.hoisted(() => { + const MockStream: unknown = vi.fn(function (this: Record) { + const events: Array> = []; + this.push = vi.fn((e: Record) => events.push(e)); + this.end = vi.fn(); + this._events = events; + }); + return { MockStream }; +}); + +vi.mock("@earendil-works/pi-ai", () => ({ + AssistantMessageEventStream: MockStream, + calculateCost: vi.fn(), +})); + +import { streamViaAcp } from "../acp-driver.js"; + +const MODEL = { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" } as never; +const CTX = { messages: [{ role: "user", content: "hi" }] } as never; +const OPTS = { bridgePath: "/fake/claude-code-cli-acp", cwd: "/tmp", mcpServers: [], bridgeEnv: { HOME: "/h", PATH: "/b" } }; + +function eventsOf(stream: { _events: Array> }) { + return stream._events; +} +const flush = () => new Promise((r) => setTimeout(r, 30)); + +describe("streamViaAcp — ACP→pi translation (U11)", () => { + beforeEach(() => { scriptedUpdates = []; }); + + it("translates agent_message_chunk text into pi text events + done(stop)", async () => { + scriptedUpdates = [ + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Hello " } }, + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "world" } }, + ]; + const stream = streamViaAcp(MODEL, CTX, OPTS) as unknown as { _events: Array> }; + await flush(); + const types = eventsOf(stream).map((e) => e.type); + expect(types).toContain("start"); + expect(types).toContain("text_start"); + expect(types.filter((t) => t === "text_delta").length).toBe(2); + const done = eventsOf(stream).find((e) => e.type === "done"); + expect(done).toBeDefined(); + expect(done!.reason).toBe("stop"); + }); + + it("breaks early on a tool_call: emits toolcall_start + done(toolUse), no execution", async () => { + scriptedUpdates = [ + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "let me check" } }, + { sessionUpdate: "tool_call", toolCallId: "t1", _meta: { claudeCode: { toolName: "mcp__custom-tools__fn_task_list" } }, rawInput: {} }, + // anything after the tool call must be ignored (break-early) + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "SHOULD NOT APPEAR" } }, + ]; + const stream = streamViaAcp(MODEL, CTX, OPTS) as unknown as { _events: Array> }; + await flush(); + const types = eventsOf(stream).map((e) => e.type); + expect(types).toContain("toolcall_start"); + const done = eventsOf(stream).find((e) => e.type === "done"); + expect(done!.reason).toBe("toolUse"); + // break-early: the post-tool text delta must not have been translated + const deltas = eventsOf(stream).filter((e) => e.type === "text_delta").map((e) => e.delta); + expect(deltas.join("")).not.toContain("SHOULD NOT APPEAR"); + }); + + it("does NOT break early on an internal ToolSearch; breaks on the real fn_* tool (U9 sequence)", async () => { + // Claude emits ToolSearch (not pi-known) to load the deferred MCP tool FIRST, + // then the real mcp__custom-tools__fn_task_list. The old code aborted on + // ToolSearch; the gated code must wait for the real tool (P0 fix). + scriptedUpdates = [ + { sessionUpdate: "tool_call", toolCallId: "ts1", _meta: { claudeCode: { toolName: "ToolSearch" } }, rawInput: { query: "x" } }, + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "found it, calling" } }, + { sessionUpdate: "tool_call", toolCallId: "real", _meta: { claudeCode: { toolName: "mcp__custom-tools__fn_task_list" } }, rawInput: {} }, + ]; + const stream = streamViaAcp(MODEL, CTX, OPTS) as unknown as { _events: Array> }; + await flush(); + // The text AFTER ToolSearch must have been processed (we didn't abort on ToolSearch) + const deltas = eventsOf(stream).filter((e) => e.type === "text_delta").map((e) => e.delta); + expect(deltas.join("")).toContain("found it"); + // And we broke on the real tool + expect(eventsOf(stream).some((e) => e.type === "toolcall_start")).toBe(true); + const done = eventsOf(stream).find((e) => e.type === "done"); + expect(done!.reason).toBe("toolUse"); + }); + + it("ends with done even when the turn produces no content", async () => { + scriptedUpdates = []; + const stream = streamViaAcp(MODEL, CTX, OPTS) as unknown as { _events: Array> }; + await flush(); + expect(eventsOf(stream).some((e) => e.type === "done")).toBe(true); + }); +}); diff --git a/packages/pi-claude-cli/src/acp-driver.ts b/packages/pi-claude-cli/src/acp-driver.ts new file mode 100644 index 000000000..f44051dd0 --- /dev/null +++ b/packages/pi-claude-cli/src/acp-driver.ts @@ -0,0 +1,315 @@ +/** + * ACP transport for the pi-claude-cli provider (U11 — Route A). + * + * `streamViaAcp` is the drop-in alternative to `streamViaCli` that drives Claude + * through the `claude-code-cli-acp` bridge over the Agent Client Protocol instead + * of `claude -p`. It returns the SAME `AssistantMessageEventStream` shape, so the + * provider's `streamSimple` can dispatch to either transport behind a kill-switch. + * + * Design (see plan U11): + * - Full-history prompt EVERY turn (`buildPrompt`) — the ACP path has no Claude + * `--resume`, so we never send the latest-turn-only `buildResumePrompt` (R13). + * - MCP tool SCHEMAS are forwarded on `session/new` so Claude knows the Fusion + * tools and emits correct `tool_use` calls; we DO NOT let the bridge execute + * them. We break early ONLY on a pi-known tool (mirroring the `-p` guard) — and + * surface it to pi, which runs the tool itself. Claude's INTERNAL tools + * (`ToolSearch`/`Task`/…) are NOT pi-known: we must NOT break on them, or we'd + * abort the turn before the real `fn_*` call (Claude uses `ToolSearch` to load + * deferred MCP tools first). See review P0 (correctness). + * - Translation reuses the tested `createEventBridge` by synthesizing Claude + * stream events from ACP `session/update`s, so pi event sequencing, tool-name + * mapping and arg translation are shared with the `-p` path. + * - Untrusted-output floor: agent text/thinking is control-char-stripped and + * byte-capped; identifiers are bounded (review P1, security). + * + * Auth: the bridge spawns the real `claude`, which authenticates from the host + * login/keychain session (R17). The bridge binary path is injected by the caller + * (engine seam, KTD10) — this module never reaches into the ACP plugin. + * + * NOT-YET-VERIFIED (kill-switch stays OFF until then): the bridge's tool-execution + * ordering (`session/request_permission` vs `tool_call` update) and native-tool + * (Read/Write/Bash) execution-prevention need a live behavioral test against the + * real bridge before any lane enables this path. `requestPermission` denies by + * default and we break early, but the TOCTOU window is unproven (review P2). + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import { Readable, Writable } from "node:stream"; +import { isAbsolute } from "node:path"; +import { existsSync } from "node:fs"; +import { + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, +} from "@agentclientprotocol/sdk"; +import { AssistantMessageEventStream } from "@earendil-works/pi-ai"; +import type { Api, Model, SimpleStreamOptions } from "@earendil-works/pi-ai"; +import { buildPrompt, buildSystemPrompt, type PiContext } from "./prompt-builder.js"; +import { createEventBridge } from "./event-bridge.js"; +import { registerProcess, captureStderr } from "./process-manager.js"; +import { isPiKnownClaudeTool } from "./tool-mapping.js"; +import type { ClaudeApiEvent } from "./types.js"; + +/** A stdio MCP server forwarded on `session/new` (schema-only — never executed here). */ +export interface AcpMcpServerSpec { + name: string; + command: string; + args: string[]; + env: { name: string; value: string }[]; +} + +/** Options for the ACP transport: pi's stream options plus ACP wiring. */ +export type StreamViaAcpOptions = SimpleStreamOptions & { + cwd?: string; + /** Absolute path to the `claude-code-cli-acp` bridge binary (injected by the engine seam). */ + bridgePath: string; + /** MCP servers (tool schemas) forwarded so Claude emits correct tool calls. */ + mcpServers?: AcpMcpServerSpec[]; + /** Env keys to forward to the bridge — filtered to the allow-list below regardless. */ + bridgeEnv?: NodeJS.ProcessEnv; +}; + +const INITIALIZE_TIMEOUT_MS = 30_000; +/** Last-resort guard: kill a silent bridge. Mirrors streamViaCli (30 min). */ +const INACTIVITY_TIMEOUT_MS = 30 * 60_000; +/** Untrusted-output bounds (review P1, security). */ +const MAX_CHUNK_CHARS = 64 * 1024; +const MAX_TURN_CHARS = 5_000_000; +const MAX_ID_CHARS = 256; + +/** + * Bridge subprocess env allow-list. The bridged `claude` needs HOME (for + * `~/.claude` auth/keychain, R17) and PATH; terminal vars improve rendering. + * Inherited `process.env` and any secret-bearing keys are NEVER forwarded — the + * filter is enforced HERE, not trusted from the caller (review P2, security). + */ +const BRIDGE_ENV_ALLOWLIST = [ + "HOME", "PATH", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", + "TERM", "TERMINFO", "TMPDIR", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "COLORTERM", +]; + +function buildBridgeEnv(supplied?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const source = supplied ?? process.env; + const env: NodeJS.ProcessEnv = {}; + for (const key of BRIDGE_ENV_ALLOWLIST) { + const v = source[key]; + if (typeof v === "string") env[key] = v; + } + return env; +} + +/** Strip ANSI escape sequences and C0/C1 control chars (keep \n \r \t), then cap length. */ +function sanitizeText(text: string, cap = MAX_CHUNK_CHARS): string { + // eslint-disable-next-line no-control-regex + const stripped = text.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ""); + return stripped.length > cap ? stripped.slice(0, cap) : stripped; +} + +/** Bound an untrusted identifier (tool id / name) before it becomes a content-block key. */ +function boundId(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/[\x00-\x1f\x7f/\\]/g, "").slice(0, MAX_ID_CHARS); +} + +/** + * Convert buildPrompt's output into ACP prompt content blocks, PRESERVING image + * blocks (review P1, correctness — flatten-to-text dropped vision input). + */ +function toAcpPromptBlocks( + prompt: string | Array>, +): Array> { + if (typeof prompt === "string") return [{ type: "text", text: prompt }]; + const out: Array> = []; + for (const b of prompt) { + if (b.type === "text" && typeof b.text === "string") { + out.push({ type: "text", text: b.text }); + } else if (b.type === "image" && b.source && typeof b.source === "object") { + const src = b.source as { media_type?: string; data?: string }; + if (src.data) out.push({ type: "image", mimeType: src.media_type ?? "image/png", data: src.data }); + } + } + return out; +} + +/** + * Stream a Claude response via the ACP bridge as an `AssistantMessageEventStream`. + * Mirrors `streamViaCli`'s contract (start → deltas → done; break-early on tools). + */ +export function streamViaAcp( + model: Model, + context: PiContext, + options: StreamViaAcpOptions, +): AssistantMessageEventStream { + // @ts-expect-error — pi-ai exports AssistantMessageEventStream as a type; the + // constructor exists at runtime (same workaround as streamViaCli). + const stream = new AssistantMessageEventStream(); + const bridge = createEventBridge(stream, model); + + (async () => { + let child: ChildProcess | undefined; + let getStderr: (() => string) | undefined; + let ended = false; + let turnChars = 0; + let blockIndex = -1; + let openKind: "text" | "thinking" | null = null; + let sawToolCall = false; + let inactivity: ReturnType | undefined; + let onAbort: (() => void) | undefined; + + const cleanup = () => { + if (inactivity) { clearTimeout(inactivity); inactivity = undefined; } + if (onAbort && options.signal) options.signal.removeEventListener("abort", onAbort); + try { child?.kill("SIGKILL"); } catch { /* registry SIGKILL is authoritative */ } + }; + const armInactivity = () => { + if (inactivity) clearTimeout(inactivity); + inactivity = setTimeout(() => failWith(`ACP bridge inactivity timeout after ${INACTIVITY_TIMEOUT_MS / 1000}s`), INACTIVITY_TIMEOUT_MS); + }; + + const finish = (reason: "stop" | "tool_use") => { + if (ended) return; + ended = true; + if (openKind !== null) bridge.handleEvent({ type: "content_block_stop", index: blockIndex } as ClaudeApiEvent); + // Downgrade a tool_use turn that surfaced zero pi tool calls → stop, so pi + // doesn't try to dispatch non-existent tools (mirrors provider.ts:366-375). + const toolCount = (bridge.getOutput().content ?? []).filter((c) => (c as { type?: string }).type === "toolCall").length; + const effective: "stop" | "tool_use" = reason === "tool_use" && toolCount > 0 ? "tool_use" : "stop"; + bridge.handleEvent({ type: "message_delta", delta: { stop_reason: effective === "tool_use" ? "tool_use" : "end_turn" } } as ClaudeApiEvent); + stream.push({ type: "done", reason: effective === "tool_use" ? "toolUse" : "stop", message: bridge.getOutput() }); + stream.end(); + cleanup(); + }; + + const failWith = (msg: string) => { + if (ended) return; + ended = true; + const output = bridge.getOutput(); + stream.push({ + type: "done", + reason: "stop", + message: { + ...output, + content: output.content?.length ? output.content : [{ type: "text" as const, text: `Error: ${msg}` }], + stopReason: "stop" as const, + }, + }); + stream.end(); + cleanup(); + }; + + const openBlock = (kind: "text" | "thinking") => { + if (openKind === kind) return; + if (openKind !== null) bridge.handleEvent({ type: "content_block_stop", index: blockIndex } as ClaudeApiEvent); + blockIndex += 1; + openKind = kind; + bridge.handleEvent({ type: "content_block_start", index: blockIndex, content_block: { type: kind } } as ClaudeApiEvent); + }; + + // Surface a pi-known tool call to pi and break early (pi executes it, not the bridge). + const surfaceToolAndBreak = (claudeName: string, rawId: string, rawInput: unknown) => { + const id = boundId(rawId); + if (openKind !== null) { bridge.handleEvent({ type: "content_block_stop", index: blockIndex } as ClaudeApiEvent); openKind = null; } + blockIndex += 1; + bridge.handleEvent({ type: "content_block_start", index: blockIndex, content_block: { type: "tool_use", name: boundId(claudeName), id } } as ClaudeApiEvent); + bridge.handleEvent({ type: "content_block_delta", index: blockIndex, delta: { type: "input_json_delta", partial_json: sanitizeText(JSON.stringify(rawInput ?? {})) } } as ClaudeApiEvent); + bridge.handleEvent({ type: "content_block_stop", index: blockIndex } as ClaudeApiEvent); + sawToolCall = true; + finish("tool_use"); + }; + + const clientHandler = { + async sessionUpdate(params: { update?: Record } & Record) { + if (ended) return; + armInactivity(); + const u = (params.update ?? params) as Record; + const kind = u.sessionUpdate as string; + const content = u.content as { type?: string; text?: string } | undefined; + + if (kind === "agent_message_chunk" && content?.type === "text" && content.text) { + if (turnChars >= MAX_TURN_CHARS) return; + openBlock("text"); + const text = sanitizeText(content.text); + turnChars += text.length; + bridge.handleEvent({ type: "content_block_delta", index: blockIndex, delta: { type: "text_delta", text } } as ClaudeApiEvent); + } else if (kind === "agent_thought_chunk" && content?.text) { + if (turnChars >= MAX_TURN_CHARS) return; + openBlock("thinking"); + const text = sanitizeText(content.text); + turnChars += text.length; + bridge.handleEvent({ type: "content_block_delta", index: blockIndex, delta: { type: "thinking_delta", thinking: text } } as ClaudeApiEvent); + } else if (kind === "tool_call") { + // Break early ONLY on a pi-known tool. Claude's internal tools + // (ToolSearch/Task/…) are not pi-known — let the bridge run them so + // Claude can load deferred MCP schemas and emit the real fn_* call. + const claudeName = ((u._meta as { claudeCode?: { toolName?: string } } | undefined)?.claudeCode?.toolName) ?? (u.title as string) ?? ""; + if (isPiKnownClaudeTool(claudeName)) { + surfaceToolAndBreak(claudeName, (u.toolCallId as string) ?? `acp_${blockIndex + 1}`, u.rawInput ?? u.input); + } + } + }, + async requestPermission(params: Record) { + // A permission request means the bridge is about to EXECUTE a tool. For a + // pi-known tool, surface it to pi and break early (pi executes it); deny + // by default otherwise. We always return cancelled so the bridge never + // executes Fusion's tools itself. + if (!ended) { + const tc = (params.toolCall ?? {}) as Record; + const claudeName = ((tc._meta as { claudeCode?: { toolName?: string } } | undefined)?.claudeCode?.toolName) ?? (tc.title as string) ?? ""; + if (isPiKnownClaudeTool(claudeName)) { + surfaceToolAndBreak(claudeName, (tc.toolCallId as string) ?? `acp_${blockIndex + 1}`, tc.rawInput ?? tc.input); + } + } + return { outcome: { outcome: "cancelled" as const } }; + }, + }; + + try { + if (!isAbsolute(options.bridgePath) || !existsSync(options.bridgePath)) { + failWith(`ACP bridge path invalid (must be an absolute, existing binary): ${options.bridgePath}`); + return; + } + child = spawn(options.bridgePath, [], { stdio: ["pipe", "pipe", "pipe"], cwd: options.cwd ?? process.cwd(), env: buildBridgeEnv(options.bridgeEnv) }); + registerProcess(child); + getStderr = captureStderr(child); + child.on("error", (e) => failWith(`ACP bridge spawn failed: ${e.message}`)); + child.on("close", (code) => { if (!ended) failWith(`ACP bridge exited (code ${code ?? "?"})${getStderr ? `: ${getStderr().slice(-500)}` : ""}`); }); + onAbort = () => failWith("aborted"); + if (options.signal) options.signal.addEventListener("abort", onAbort, { once: true }); + armInactivity(); + + const acpStream = ndJsonStream( + Writable.toWeb(child.stdin!) as unknown as WritableStream, + Readable.toWeb(child.stdout!) as unknown as ReadableStream, + ); + const conn = new ClientSideConnection(() => clientHandler, acpStream); + + const withTimeout = (p: Promise, label: string) => + Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`ACP ${label} timeout`)), INITIALIZE_TIMEOUT_MS))]); + + const init = await withTimeout( + conn.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } } }), + "initialize", + ); + if (ended) return; + if (init.protocolVersion !== PROTOCOL_VERSION) { failWith(`incompatible ACP protocol ${init.protocolVersion}`); return; } + + const opened = await withTimeout(conn.newSession({ cwd: options.cwd ?? process.cwd(), mcpServers: options.mcpServers ?? [] }), "newSession"); + if (ended) return; + + const cwd = options.cwd ?? process.cwd(); + const systemPrompt = buildSystemPrompt(context, cwd); + const blocks = [ + ...(systemPrompt ? [{ type: "text" as const, text: `${systemPrompt}\n\n` }] : []), + ...toAcpPromptBlocks(buildPrompt(context) as string | Array>), + ]; + + // ACP ContentBlock[] — text/image shapes match; cast through unknown. + await conn.prompt({ sessionId: opened.sessionId, prompt: blocks as unknown as Parameters[0]["prompt"] }); + if (!sawToolCall) finish("stop"); + } catch (err) { + failWith(err instanceof Error ? err.message : String(err)); + } + })(); + + return stream; +} diff --git a/packages/pi-claude-cli/src/mcp-config.ts b/packages/pi-claude-cli/src/mcp-config.ts index 32deba7f9..82ad4cb11 100644 --- a/packages/pi-claude-cli/src/mcp-config.ts +++ b/packages/pi-claude-cli/src/mcp-config.ts @@ -142,3 +142,32 @@ export function writeMcpConfig( return configFilePath; } + +/** A stdio MCP server spec for ACP `session/new.mcpServers` (U11 — Route A). */ +export interface AcpMcpServerSpec { + name: string; + command: string; + args: string[]; + env: { name: string; value: string }[]; +} + +/** + * Build the ACP `mcpServers` spec for the same schema-only `custom-tools` server + * `writeMcpConfig` produces for `--mcp-config` — but as the inline ACP shape + * (`session/new.mcpServers`) instead of a config-file path (U11). Writes the + * tool-schema file and points the server at the shared `mcp-schema-server.cjs`. + * Returns `[]` when there are no custom tools (Route B read-only posture). + */ +export function buildAcpMcpServers( + toolDefs: McpToolDef[], + cacheKey?: string, +): AcpMcpServerSpec[] { + if (toolDefs.length === 0) return []; + const suffix = cacheKey ? `${process.pid}-${cacheKey}` : `${process.pid}`; + const schemaFilePath = join(tmpdir(), `pi-claude-mcp-schemas-${suffix}.json`); + writeFileSync(schemaFilePath, JSON.stringify(toolDefs)); + const serverPath = join(dirname(fileURLToPath(import.meta.url)), "mcp-schema-server.cjs"); + return [ + { name: "custom-tools", command: "node", args: [serverPath, schemaFilePath], env: [] }, + ]; +} diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts index 331e5967e..de1196997 100644 --- a/plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts @@ -133,3 +133,44 @@ describe("resolveCliSettings", () => { expect(ask.allowUnrestricted).toBe(false); }); }); + +describe("KTD10 — onLoad publishes the bundled bridge path (Route A)", () => { + const savedBridge = process.env.FUSION_CLAUDE_ACP_BRIDGE; + const savedFlag = process.env.FUSION_CLAUDE_ACP; + afterEach(() => { + if (savedBridge === undefined) delete process.env.FUSION_CLAUDE_ACP_BRIDGE; + else process.env.FUSION_CLAUDE_ACP_BRIDGE = savedBridge; + if (savedFlag === undefined) delete process.env.FUSION_CLAUDE_ACP; + else process.env.FUSION_CLAUDE_ACP = savedFlag; + }); + const fakeCtx = () => ({ settings: {}, logger: { info: () => undefined, warn: () => undefined } }); + + it("publishes the bundled bridge path to FUSION_CLAUDE_ACP_BRIDGE when unset", () => { + delete process.env.FUSION_CLAUDE_ACP_BRIDGE; + plugin.hooks?.onLoad?.(fakeCtx() as never); + expect(process.env.FUSION_CLAUDE_ACP_BRIDGE).toBeDefined(); + expect(isAbsolute(process.env.FUSION_CLAUDE_ACP_BRIDGE!)).toBe(true); + expect(process.env.FUSION_CLAUDE_ACP_BRIDGE).toContain("node_modules/.bin/claude-code-cli-acp"); + }); + + it("does NOT enable the transport — FUSION_CLAUDE_ACP stays unset (kill-switch off)", () => { + delete process.env.FUSION_CLAUDE_ACP; + delete process.env.FUSION_CLAUDE_ACP_BRIDGE; + plugin.hooks?.onLoad?.(fakeCtx() as never); + expect(process.env.FUSION_CLAUDE_ACP).toBeUndefined(); + }); + + it("respects an explicit FUSION_CLAUDE_ACP_BRIDGE override", () => { + process.env.FUSION_CLAUDE_ACP_BRIDGE = "/custom/bridge/path"; + plugin.hooks?.onLoad?.(fakeCtx() as never); + expect(process.env.FUSION_CLAUDE_ACP_BRIDGE).toBe("/custom/bridge/path"); + }); + + it("is idempotent — a second onLoad keeps the first published path and does not throw", () => { + delete process.env.FUSION_CLAUDE_ACP_BRIDGE; + plugin.hooks?.onLoad?.(fakeCtx() as never); + const first = process.env.FUSION_CLAUDE_ACP_BRIDGE; + expect(() => plugin.hooks?.onLoad?.(fakeCtx() as never)).not.toThrow(); + expect(process.env.FUSION_CLAUDE_ACP_BRIDGE).toBe(first); + }); +}); diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/ktd10-fail-closed.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/ktd10-fail-closed.test.ts new file mode 100644 index 000000000..dbbb91191 --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/ktd10-fail-closed.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; + +// Force the bundled-bridge resolver to "not_resolved" so we can assert onLoad's +// fail-closed branch: when the bridge isn't installed, nothing is published and +// Route A stays unavailable (the kill-switch falls back to `-p`). +vi.mock("../cli-spawn.js", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + resolveBundledClaudeBridgeBinary: () => ({ + kind: "not_resolved", + requested: "claude-code-cli-acp", + path: "/missing/claude-code-cli-acp", + reason: "bundled bridge not installed (test)", + }), + }; +}); + +import plugin from "../index.js"; + +const fakeCtx = () => ({ settings: {}, logger: { info: () => undefined, warn: () => undefined } }); + +describe("KTD10 fail-closed — bundled bridge not resolved", () => { + const saved = process.env.FUSION_CLAUDE_ACP_BRIDGE; + afterEach(() => { + if (saved === undefined) delete process.env.FUSION_CLAUDE_ACP_BRIDGE; + else process.env.FUSION_CLAUDE_ACP_BRIDGE = saved; + }); + + it("does NOT publish FUSION_CLAUDE_ACP_BRIDGE when the bridge is not resolved", () => { + delete process.env.FUSION_CLAUDE_ACP_BRIDGE; + plugin.hooks?.onLoad?.(fakeCtx() as never); + expect(process.env.FUSION_CLAUDE_ACP_BRIDGE).toBeUndefined(); + }); +}); diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-session.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-session.test.ts index 1e8ece526..5f8b51165 100644 --- a/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-session.test.ts +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-session.test.ts @@ -45,6 +45,23 @@ describe("session driving helpers", () => { } }); + it("newAcpSession forwards non-empty mcpServers to session/new (U10 — Route A)", async () => { + const newSession = vi.fn(async () => ({ sessionId: "s1", modes: undefined })); + const fakeConn = { conn: { newSession } } as unknown as AcpConnection; + const servers = [ + { name: "custom-tools", command: "node", args: ["server.cjs"], env: [] as { name: string; value: string }[] }, + ]; + await newAcpSession(fakeConn, { cwd: "/tmp/work", mcpServers: servers }); + expect(newSession).toHaveBeenCalledWith({ cwd: "/tmp/work", mcpServers: servers }); + }); + + it("newAcpSession defaults mcpServers to [] when absent (Route B read-only posture)", async () => { + const newSession = vi.fn(async () => ({ sessionId: "s1", modes: undefined })); + const fakeConn = { conn: { newSession } } as unknown as AcpConnection; + await newAcpSession(fakeConn, { cwd: "/tmp/work" }); + expect(newSession).toHaveBeenCalledWith({ cwd: "/tmp/work", mcpServers: [] }); + }); + it("promptAcpSession resolves with end_turn for a normal turn", async () => { const conn = await open(); try { diff --git a/plugins/fusion-plugin-acp-runtime/src/index.ts b/plugins/fusion-plugin-acp-runtime/src/index.ts index 057aaadd8..c998af80e 100644 --- a/plugins/fusion-plugin-acp-runtime/src/index.ts +++ b/plugins/fusion-plugin-acp-runtime/src/index.ts @@ -1,6 +1,6 @@ import { definePlugin } from "@fusion/plugin-sdk"; import type { FusionPlugin, PluginRuntimeFactory, PluginRuntimeManifestMetadata } from "@fusion/plugin-sdk"; -import { resolveCliSettings } from "./cli-spawn.js"; +import { resolveCliSettings, resolveBundledClaudeBridgeBinary } from "./cli-spawn.js"; import { AcpRuntimeAdapter } from "./runtime-adapter.js"; import { killAllProcesses } from "./process-manager.js"; import { setupHooks, setupManifest } from "./setup.js"; @@ -49,6 +49,25 @@ const plugin: FusionPlugin = definePlugin({ "will be auto-approved under an allow-all policy. Prefer an approval-required policy.", ); } + // KTD10 (Route A): publish the bundled `claude-code-cli-acp` bridge path + // process-wide so the pi-claude-cli provider's kill-switch can resolve it + // WITHOUT a manual FUSION_CLAUDE_ACP_BRIDGE env var. This only PUBLISHES the + // path — the ACP transport stays OFF until an operator sets + // FUSION_CLAUDE_ACP=1 (the rollout gate). An explicit env override wins, and + // the resolver is identity-pinned to the plugin-owned node_modules/.bin shim + // so a same-named global binary cannot replace the reviewed bridge. + if (!process.env.FUSION_CLAUDE_ACP_BRIDGE) { + const resolved = resolveBundledClaudeBridgeBinary(); + if (resolved.kind === "resolved") { + process.env.FUSION_CLAUDE_ACP_BRIDGE = resolved.path; + ctx.logger.info( + "ACP Runtime: published bundled Claude bridge path for Route A " + + "(transport stays off until FUSION_CLAUDE_ACP=1).", + ); + } else { + ctx.logger.info(`ACP Runtime: bundled Claude bridge not resolved (${resolved.reason}); Route A unavailable.`); + } + } }, }, runtime: { diff --git a/plugins/fusion-plugin-acp-runtime/src/provider.ts b/plugins/fusion-plugin-acp-runtime/src/provider.ts index 0c60f4498..56ebe24fe 100644 --- a/plugins/fusion-plugin-acp-runtime/src/provider.ts +++ b/plugins/fusion-plugin-acp-runtime/src/provider.ts @@ -29,7 +29,7 @@ import { createEventBridge } from "./event-bridge.js"; import { resolvePermission, type ResolvePermissionOptions } from "./control-handler.js"; import { createFsHandlers } from "./fs-capabilities.js"; import { boundIdentifier } from "./sanitize.js"; -import type { AcpCallbacks, PermissionGate } from "./types.js"; +import type { AcpCallbacks, AcpMcpServer, PermissionGate } from "./types.js"; /** Options enabling the U7 fs client capabilities on the bridging handler. */ export interface FsHandlerBuildOptions { @@ -346,14 +346,19 @@ export interface NewAcpSessionResult { } /** - * Open a fresh ACP session via `session/new`. Always passes an empty - * `mcpServers` (KTD5 — Fusion custom-tool forwarding is deferred). + * Open a fresh ACP session via `session/new`. Forwards `opts.mcpServers` (U10 — + * Route A): when present and non-empty, the agent can call those Fusion tools and + * each call still routes through the U5 permission floor. Defaults to `[]` so + * Route B read-only ask turns keep their no-tools posture. */ export async function newAcpSession( connection: AcpConnection, - opts: { cwd: string }, + opts: { cwd: string; mcpServers?: AcpMcpServer[] }, ): Promise { - const res = await connection.conn.newSession({ cwd: opts.cwd, mcpServers: [] }); + const res = await connection.conn.newSession({ + cwd: opts.cwd, + mcpServers: opts.mcpServers ?? [], + }); // `sessionId` is agent-supplied/untrusted (U6/Risk S7): bound its length and // strip path separators / NUL bytes before it is stored on the session or // could ever touch a resume-file path. diff --git a/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts index 97f7ccbd2..92753e391 100644 --- a/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts +++ b/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts @@ -82,10 +82,15 @@ export class AcpRuntimeAdapter implements AgentRuntime { clientHandler, }); - // Open the ACP session over the task worktree (empty mcpServers — KTD5). + // Open the ACP session over the task worktree. Forward MCP servers when the + // caller supplied them (U10 — Route A); absent/empty keeps the Route B + // read-only ask posture. Tool calls still route through the U5 permission floor. let sessionId: string; try { - const opened = await newAcpSession(connection, { cwd: options.cwd }); + const opened = await newAcpSession(connection, { + cwd: options.cwd, + mcpServers: options.mcpServers, + }); sessionId = opened.sessionId; } catch (err) { // Don't leak the subprocess if session/new fails after a good handshake. diff --git a/plugins/fusion-plugin-acp-runtime/src/types.ts b/plugins/fusion-plugin-acp-runtime/src/types.ts index 55e4ebaad..cb623a49d 100644 --- a/plugins/fusion-plugin-acp-runtime/src/types.ts +++ b/plugins/fusion-plugin-acp-runtime/src/types.ts @@ -20,6 +20,19 @@ export interface AcpCallbacks { onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void; } +/** + * A stdio MCP server forwarded to the agent on `session/new` (U10 — Route A). + * `env` is explicit name/value pairs; inherited `process.env` is NEVER forwarded + * to the untrusted agent. Maps 1:1 onto an ACP `mcpServers` entry and onto what + * `pi-claude-cli`'s `mcp-config.ts` builds for `--mcp-config`. + */ +export interface AcpMcpServer { + name: string; + command: string; + args: string[]; + env: { name: string; value: string }[]; +} + /** Per-category permission disposition (mirrors the engine policy shape). */ export type GateDisposition = "allow" | "block" | "require-approval"; @@ -92,6 +105,12 @@ export interface AgentRuntimeOptions { defaultThinkingLevel?: string; /** Per-run permission gate, populated by the engine. See PermissionGate. */ actionGateContext?: PermissionGate; + /** + * MCP servers to forward on `session/new` (U10 — Route A). When present and + * non-empty, the agent can call these tools (each call still routes through the + * U5 permission floor). Absent/empty preserves Route B's read-only ask posture. + */ + mcpServers?: AcpMcpServer[]; } /** Live ACP session state tracked by the runtime adapter. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf2801404..bc9367509 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,10 +47,10 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: ^0.79.1 - version: 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) '@earendil-works/pi-coding-agent': specifier: ^0.79.1 - version: 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) dockerode: specifier: ^4.0.12 version: 4.0.12 @@ -585,12 +585,15 @@ importers: packages/pi-claude-cli: dependencies: + '@agentclientprotocol/sdk': + specifier: 0.24.0 + version: 0.24.0(zod@4.3.6) '@earendil-works/pi-ai': specifier: '*' - version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + version: 0.77.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.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) devDependencies: '@types/node': specifier: ^25.5.2 @@ -7911,9 +7914,9 @@ snapshots: - ws - zod - '@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + '@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: - '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) ignore: 7.0.5 typebox: 1.1.38 yaml: 2.9.0 @@ -7925,9 +7928,9 @@ snapshots: - ws - zod - '@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': + '@earendil-works/pi-agent-core@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: - '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@earendil-works/pi-ai': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) ignore: 7.0.5 typebox: 1.1.38 yaml: 2.9.0 @@ -7939,9 +7942,9 @@ snapshots: - ws - zod - '@earendil-works/pi-agent-core@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': + '@earendil-works/pi-agent-core@0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': dependencies: - '@earendil-works/pi-ai': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@earendil-works/pi-ai': 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) ignore: 7.0.5 typebox: 1.1.38 yaml: 2.9.0 @@ -8001,16 +8004,16 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + '@earendil-works/pi-ai@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: - '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) + '@anthropic-ai/sdk': 0.91.1(zod@4.3.6) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)) + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) '@mistralai/mistralai': 2.2.1 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - openai: 6.26.0(ws@8.20.0)(zod@3.25.76) + openai: 6.26.0(ws@8.20.0)(zod@4.3.6) partial-json: 0.1.7 typebox: 1.1.38 transitivePeerDependencies: @@ -8021,7 +8024,7 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': + '@earendil-works/pi-ai@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.3.6) '@aws-sdk/client-bedrock-runtime': 3.1048.0 @@ -8041,16 +8044,16 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': + '@earendil-works/pi-ai@0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': dependencies: - '@anthropic-ai/sdk': 0.91.1(zod@4.3.6) + '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)) '@mistralai/mistralai': 2.2.1 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - openai: 6.26.0(ws@8.20.0)(zod@4.3.6) + openai: 6.26.0(ws@8.20.0)(zod@3.25.76) partial-json: 0.1.7 typebox: 1.1.38 transitivePeerDependencies: @@ -8130,10 +8133,10 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + '@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: - '@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) - '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + '@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@earendil-works/pi-tui': 0.77.0 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 @@ -8159,11 +8162,11 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': + '@earendil-works/pi-coding-agent@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: - '@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) - '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) - '@earendil-works/pi-tui': 0.77.0 + '@earendil-works/pi-agent-core': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@earendil-works/pi-ai': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@earendil-works/pi-tui': 0.78.0 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cross-spawn: 7.0.6 @@ -8188,11 +8191,11 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@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@0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': dependencies: - '@earendil-works/pi-agent-core': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) - '@earendil-works/pi-ai': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) - '@earendil-works/pi-tui': 0.78.0 + '@earendil-works/pi-agent-core': 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + '@earendil-works/pi-ai': 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + '@earendil-works/pi-tui': 0.79.1 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cross-spawn: 7.0.6 @@ -9807,7 +9810,7 @@ snapshots: obug: 2.1.2 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + vitest: 4.1.8(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@4.1.8': dependencies: