diff --git a/CONCEPTS.md b/CONCEPTS.md index ff41d90561..69b0b6ade2 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -82,6 +82,12 @@ The core board entity: a unit of work that moves through columns (triage, todo, ### Workflow Runtime The authoritative task lifecycle runtime. It resolves a Task to workflow IR, walks the graph, routes node outcomes, and invokes runtime primitives for side effects. The engine substrate still owns scheduling, routing claims, persistence, concurrency, process supervision, storage, and audit plumbing; lifecycle policy lives in workflow nodes and built-in workflow IR. +### ACP Ask Path +A one-turn read-only model ask routed through the ACP runtime rather than a CLI print mode. The runner accumulates streamed prose, may recover a trailing JSON object for structured seams, and treats abnormal ACP stop reasons as incomplete answers for validator use. + +### Claude Bridge +The pinned `claude-code-cli-acp` subprocess bundled with the ACP runtime plugin. It speaks ACP over stdio to Fusion while driving the real interactive `claude` through a PTY, and is resolved from the plugin-owned `node_modules` tree rather than PATH. + ### Runtime Primitive A named, injected operation a workflow node can call to perform side effects without depending on `executor.ts` lifecycle branches. Examples include planning session, coding session, step execution/reset, review, verification, workflow step, transition, merge request, abort, and audit. Primitives are the boundary between workflow policy and engine substrate. diff --git a/docs/acp-contract.md b/docs/acp-contract.md index 2e40f2cdfa..cd2a7f7b88 100644 --- a/docs/acp-contract.md +++ b/docs/acp-contract.md @@ -23,6 +23,42 @@ agent over JSON-RPC/stdio. Mirrors the shape of `docs/cursor-cli-contract.md`. - The subprocess environment is built from the `acpEnvAllowList` allow-list only (inherited `process.env` is **not** forwarded — the agent is untrusted). +## Claude bridge ask profile (Route B) + +Route-B planning and validator asks use the `acp` runtime with the bundled +`claude-code-cli-acp` bridge instead of `claude -p`: + +- `claude-code-cli-acp@0.1.1` is pinned under the ACP runtime plugin and the + sentinel binary name resolves to this plugin's own `node_modules/.bin` shim, + not to a PATH-selected substitute. +- The read-only ask posture uses `tools: "readonly"`, `acpArgs: []`, and leaves + `acpFsRead` / `acpFsWrite` off. Route A's tool-bearing provider path remains + deferred and is not implied by this profile. +- The Claude bridge env allow-list is intentionally narrow: `HOME` is forwarded + so the underlying `claude` can read `~/.claude` auth/session state, and `PATH` + is forwarded for sub-executable resolution. `ANTHROPIC_API_KEY`, + `ANTHROPIC_AUTH_TOKEN`, and inherited `process.env` are not forwarded. +- `checkSetup` treats the bridge as installed only when the resolved binary is + plugin-owned, the ACP handshake succeeds, and no Claude auth hint is returned. + Auth-needed statuses tell the operator to run `claude` once to authenticate. + +## `askAcpOnce` prose → JSON recovery contract + +The engine-side `askAcpOnce` runner creates one readonly ACP session, accumulates +all `onText` deltas into `text`, runs one `promptWithFallback` turn, optionally +recovers the trailing JSON object via `extractJsonObjects`, and disposes the +session in `finally`. Its shape is deliberately close to the old one-shot result: + +- Success: `{ ok: true, text, parsed?, stopReason? }`. +- Failure: `{ ok: false, reason, message, text?, stopReason? }` for session + creation errors, turn errors, timeouts, and abnormal stops. +- `promptWithFallback` surfaces ACP `stopReason` to the runner. Planning tolerates + an absent stop reason, but validation treats abnormal/truncated stops such as + `max_tokens` and `cancelled` as `error` regardless of any recovered JSON. +- Validator prose fallback is constrained: prose can infer `fail` or `blocked`, + but never `pass`. A pass requires clean structured JSON (`verdict:"pass"` or + `passed:true`) from a clean turn. + ## Readiness = the `initialize` handshake There is no `--version` probe. Readiness is the protocol handshake itself: 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 new file mode 100644 index 0000000000..de04b695ca --- /dev/null +++ b/docs/plans/2026-06-14-001-feat-claude-acp-runtime-plan.md @@ -0,0 +1,465 @@ +--- +title: "feat: Route Claude ask-paths through ACP runtime + claude-code-cli-acp bridge (replace claude -p)" +type: feat +status: active +date: 2026-06-14 +depth: deep +--- + +# feat: Route Claude through the ACP runtime + `claude-code-cli-acp` bridge (replace `claude -p`) + +## Summary + +Fusion invokes Claude through `claude -p` on **two independent routes**, and both must move off `-p` onto the **already-shipped** `fusion-plugin-acp-runtime` (runtimeId `acp`) pointed at the external **`claude-code-cli-acp`** bridge — a Rust ACP server that drives the real interactive `claude` through a PTY and reads the transcript JSONL, exposing it over JSON-RPC/stdio: + +- **Route A — the `pi-claude-cli` provider (PRIMARY, highest traffic).** A vendored pi provider (`@fusion/pi-claude-cli`) registered whenever `useClaudeCli` is on. Selecting it as the model/provider makes *every* AI lane — chat, executor, validator, reviewer, **workflow `model` nodes**, title summarization, reflection, merger — spawn `claude -p --input-format stream-json --output-format stream-json --mcp-config …` (`packages/pi-claude-cli/src/process-manager.ts:37-101`). This is the bulk of real `-p` traffic and is **MCP-tool-bearing** (Fusion injects its tools). +- **Route B — the one-shot seams (planning, validator).** `runOneShotSession` launches `claude -p` and scrapes a `--output-format json` frame for `PlanningResponse` / `ValidatorVerdict`. These are dependency-injected seams with **no production caller today**. + +The bridge is pinned as a dependency of the ACP runtime plugin so it ships with Fusion. For Route B, a thin engine-side "ask once" runner drives a single ACP turn and returns the existing `{ ok, text, parsed }` shape so the rewires stay small. For Route A, the provider's `streamSimple` is re-pointed from `spawnClaude` to an ACP-bridge client. + +**Success criterion: `-p` removal is mandatory, not best-effort.** Removing `claude -p` is the whole point — including for Route A, which is the *bulk* of `-p` traffic. So "leave the provider on `-p`" is **not** an acceptable outcome. If the Route A feasibility gates (U9 external MCP passthrough, U14 internal blockers) return no-go, the response is to **block the feature and sponsor the missing capability upstream** (bridge MCP passthrough and/or the ACP `mcpServers` forwarding), not to ship with Claude still on `-p`. Route B may still ship first as independent progress, but the feature is not "done" until Route A is off `-p` too. + +**Scope is Claude only.** codex/droid/pi keep their existing `exec`/`--print` non-interactive forms (no ACP bridge exists for them); converting them is explicitly deferred. + +--- + +## Problem Frame + +**Why `-p` is being removed.** Per the request, Claude must be driven through an interactive PTY session, not `claude -p`. Investigation showed the cleanest way to get this without re-implementing PTY-spawn + transcript-tailing ourselves is to reuse the existing ACP runtime and an external bridge that already does exactly that PTY+transcript work and speaks ACP. + +**Two independent `-p` routes — do not conflate them.** Investigation found Claude is spawned with `-p` from two unrelated code paths: + +- **Route A — `pi-claude-cli` provider.** Provider id `"pi-claude-cli"` (`packages/pi-claude-cli/index.ts:27,217`), registered into the pi `ModelRegistry` by `registerExtensionProviders` (`packages/engine/src/pi.ts:1366-1422`) inside the shared `createFnAgent` session factory used by **all** lanes. When selected, `streamSimple` → `streamViaCli` → `spawnClaude` → `spawn("claude", ["-p", "--input-format","stream-json","--output-format","stream-json", …, "--mcp-config", …])` (`packages/pi-claude-cli/src/process-manager.ts:37-101`). Gated by `GlobalSettings.useClaudeCli` (`packages/core/src/types.ts:2993`) and surfaced/hidden in model pickers accordingly (`packages/dashboard/src/routes/register-model-routes.ts:140-174`). **This is the high-traffic route and the one the user means by "the claude cli model type used for workflow execution and anywhere else models are used."** +- **Route B — one-shot seams.** `runOneShotSession`/`runCliAgentValidation`/`runCliAgentPlanning` have **no production call site** — they are dependency-injection seams exercised only by tests; the CE orchestrator's `cli-agent` branch is explicitly "not yet wired." Replacing `-p` here = change each seam's injected runner + delete the Claude one-shot branches; there is no live `-p` traffic to cut over, and this plan does **not** make these lanes actually run in production (pre-existing TODO). + +**The MCP-tool dependency (Route A's hard problem).** The `pi-claude-cli` provider injects Fusion's tools into Claude via `--mcp-config` and maps Claude↔pi tool names (`packages/pi-claude-cli/src/{mcp-config.ts,tool-mapping.ts}`). The ACP runtime opens sessions with **empty `mcpServers`** (MCP custom-tool forwarding was explicitly deferred in the ACP plugin — see `plugins/fusion-plugin-acp-runtime` scope and the ACP learning doc). Until ACP forwards MCP servers *and* the bridge passes them through to the underlying `claude`, routing Route A to the bridge would strip Fusion's tool-calling — almost certainly unacceptable for executor/workflow lanes. **This makes ACP MCP forwarding a prerequisite of Route A, not an optional extra (OQ1).** + +**The core technical tension — prose vs structured JSON.** `claude -p --output-format json` returns a structured envelope (`{ type: "result", result, is_error }`); the validator parses it (`OneShotResult.parsed`). ACP delivers the assistant message as **streamed prose** via the `onText` callback; `promptWithFallback` resolves `void` and even the terminal `stopReason` is currently discarded by the adapter (`plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts:143`). So structured parsing must move caller-side: the "ask once" runner accumulates the prose, and the validator coaxes a trailing JSON object out of the model and recovers it. + +**The bridge is young.** `claude-code-cli-acp` is v0.1.1 (Apache-2.0, 11 stars, 2 releases). It is pinned at an exact version and isolated behind the existing ACP security floor (per-category permission gating, env allow-list, realpath path-jail). It still requires `claude` to be installed and authenticated separately. + +--- + +## Requirements + +**Shared foundation** +- **R2** — `claude-code-cli-acp` is pinned as a dependency of `fusion-plugin-acp-runtime`, resolved to an absolute path inside the plugin's own `node_modules` (never a PATH-resolved substitute), with integrity recorded against a source-reviewed pinned commit. +- **R3a** — A read-only ACP ask posture (fs OFF) is available for Route B turns. +- **R3b** — A tool-bearing ACP posture pinned to the bridge (the `acp-claude` runtime, KTD9) is available for the Route A provider, without altering the generic `acp` runtime's "any ACP agent" contract. +- **R8** — The bridge's absence/auth failure surfaces as a typed, actionable error (probe taxonomy), not a hang or opaque crash. +- **R16** — Every bridge subprocess env is built from an explicit allow-list (never inherited `process.env`); the Claude profile's allow-list is enumerated with per-entry justification (`HOME`, `PATH` in; `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN` deliberately out — `claude` uses its `~/.claude` session token). + +**Route A — `pi-claude-cli` provider (primary)** +- **R9** — When `useClaudeCli` is on and Claude CLI is the selected provider, AI lanes (chat, executor, validator, reviewer, workflow `model` nodes, summarization) invoke Claude via the ACP bridge, not `claude -p`. Existing persisted `defaultProvider="pi-claude-cli"` selections continue to work (re-routed under the hood; no forced re-selection). +- **R10** — Fusion's MCP tools remain available to Claude over the ACP path (or the route is explicitly gated off until MCP forwarding lands — see OQ1). Tool-name mapping (Claude↔pi) is preserved. +- **R11** — Streaming fidelity is preserved: token/thinking/tool-call deltas reach the lane callbacks with tool-call argument integrity and start/end correlation intact (OQ3). +- **R12** — The picker/auth/status surface (`/auth/claude-cli`, `/providers/claude-cli/status`, `claude-cli-probe`, picker filtering) reflects the ACP-backed reality, with probe `detail` sanitized (no internal paths/OS error strings) before HTTP exposure. +- **R13** — Multi-turn lanes (chat, executor, workflow) preserve conversation context over ACP — via session resume or full-history prompts. No path sends the latest turn only without resume (OQ2). +- **R14** — Route A retains a config-only rollback to `claude -p`: `spawnClaude`/`buildClaudeSpawnArgs` stay behind a runtime kill-switch (not deleted) until the ACP provider path has soaked in production. +- **R15** — The validator never infers `pass` from prose on the ACP path, and an abnormal/truncated stop (`max_tokens`, `cancelled`) maps to `error`. The prose backstop may only ever yield `fail`/`blocked`/`error`. + +**Route B — one-shot seams** +- **R1** — Claude planning/validator ask-paths no longer use `claude -p`; they route through the `acp` runtime driving `claude-code-cli-acp`. +- **R4** — A reusable engine-side "ask once" runner drives a single ACP turn and returns `{ ok, text, parsed }` (plus a typed failure on connection/turn error) so seam consumers change minimally and the validator's "never a silent pass" rule is preserved. +- **R5** — The planning seam (`runCliAgentPlanning`) produces a `PlanningResponse` from ACP prose with no contract change. +- **R6** — The validator seam (`runCliAgentValidation`) produces a `ValidatorVerdict` from ACP prose, keeping the `ValidatorVerdict` contract and never degrading an undecidable result to a silent pass. +- **R7** — The Claude-specific `-p` branches in the one-shot machinery and their tests are deleted; codex/droid/pi one-shot paths remain intact. + +--- + +## High-Level Technical Design + +The ask path crosses three processes. The engine resolves the `acp` runtime, which spawns the bridge subprocess, which in turn drives the real `claude` over a PTY. + +```mermaid +flowchart LR + subgraph Engine["@fusion/engine"] + SEAM["planning / validator seam"] + ASK["askAcpOnce runner (U4)"] + RES["runtime-resolution\ngetRuntimeById('acp')"] + SEAM --> ASK --> RES + end + subgraph Plugin["fusion-plugin-acp-runtime"] + ADP["AcpRuntimeAdapter\ncreateSession / promptWithFallback"] + SPAWN["process-manager spawn\n(env allow-list, path-jail)"] + ADP --> SPAWN + end + subgraph Bridge["claude-code-cli-acp (pinned dep, U1)"] + ACPSRV["ACP server (JSON-RPC/stdio)"] + PTY["claude via PTY + transcript JSONL"] + ACPSRV --> PTY + end + RES -->|runtimeHint acp| ADP + SPAWN -->|stdio| ACPSRV +``` + +One ask turn (the runner's control flow — directional, not implementation spec): + +```mermaid +sequenceDiagram + participant Seam + participant Ask as askAcpOnce + participant ACP as AcpRuntimeAdapter + participant Bridge as claude-code-cli-acp + Seam->>Ask: ask(prompt, {model, cwd, readonly}) + Ask->>ACP: createSession({tools:"readonly", onText: d=>text+=d}) + ACP->>Bridge: spawn + initialize + session/new + Ask->>ACP: promptWithFallback(session, prompt) + ACP->>Bridge: session/prompt + Bridge-->>ACP: session/update (text deltas) + ACP-->>Ask: onText(delta) ... (accumulate) + Bridge-->>ACP: stopReason (turn end) + ACP-->>Ask: promptWithFallback resolves (void) + Ask->>Ask: parsed = recoverJson(text) %% validator only + Ask->>ACP: dispose(session) %% finally + Ask-->>Seam: { ok, text, parsed } +``` + +--- + +## Key Technical Decisions + +- **KTD1 — Reuse the ACP runtime, do not bolt ACP onto the PTY `claude-code` adapter.** The cli-agent `CliAgentAdapter` contract is a PTY byte-stream (readiness detector, injection). ACP is JSON-RPC. The `acp` runtime (`AgentRuntime`) already models ACP correctly. "Have the Claude cli adapter use this" is satisfied by routing Claude through the ACP runtime, not by changing `claude-code.ts`. +- **KTD2 — Pin the bridge as a plugin dependency** (`claude-code-cli-acp@0.1.1` in `plugins/fusion-plugin-acp-runtime/package.json`), resolved to an absolute path from the plugin's `node_modules/.bin` so spawn never depends on global PATH. Chosen over user-installed-probe per the dependency decision; the probe/setup is still added (U3) for the `claude`-binary + auth preconditions the bridge itself needs. +- **KTD3 — Caller-side structured recovery.** The runner accumulates `onText` (the only channel for assistant text — established idiom: `packages/engine/src/evaluator.ts:151-165`). For the validator, the system prompt instructs Claude to end its turn with a single JSON object; the runner recovers it via the existing `extractJsonObjects` (`packages/engine/src/cli-agent/one-shot-session.ts:189`) into `parsed`, so `mapParsedToVerdict` works unchanged off `verdict`/`passed`/`blocked`. The claude-`-p`-specific `is_error` tier becomes dead and is removed. +- **KTD4 — Read-only ask posture.** Ask turns set `tools: "readonly"` and leave fs capabilities OFF (the ACP defaults), so the bridge never trips a gated permission category and no `actionGateContext` is required. This matches the existing read-only posture of validator/planning one-shots. +- **KTD5 — Keep the `OneShotResult` machinery for codex/droid/pi.** Only the `claude-code` branches are deleted (`buildOneShotSettings` lines 67-69, `parseOneShotOutput` lines 139-148). The generic runner and other adapters' non-interactive forms survive. +- **KTD6 — Surface `stopReason` from the adapter (required for the validator path).** `promptWithFallback` returns `void` and discards the SDK `stopReason` (`runtime-adapter.ts:143`; `promptAcpSession` does return it at `provider.ts:374`). Surfacing it is an `AgentRuntime` interface change (a new optional return/callback on `promptWithFallback`, consumed engine-side) — a real cost, but **justified and required for U6**: without it a `max_tokens` truncation that leaves a parseable trailing `{...}` passes silently, violating the validator's cardinal rule (R15). It stays *optional* for U5 (planning tolerates prose). The "JSON-presence-only" fallback is explicitly **rejected for the validator** — it's the exact gap that breaks no-silent-pass. +- **KTD7 — Route A re-points the provider internally; keep the `pi-claude-cli` provider key.** The smallest-blast option is to leave the provider id `"pi-claude-cli"` and `useClaudeCli` semantics intact and replace `spawnClaude`'s NDJSON subprocess inside `@fusion/pi-claude-cli` with an ACP-bridge client — so persisted selections and pickers need no migration (R9). Rejected alternative: register a new ACP-backed provider key and migrate all saved `defaultProvider`/`executionProvider` values (larger blast radius, user-visible churn). +- **KTD8 — Route A is gated on ACP MCP forwarding (prerequisite, not optional).** Fusion's tools reach Claude today via the provider's `--mcp-config`. The ACP runtime opens `session/new` with empty `mcpServers` (hardcoded `mcpServers: []` at `plugins/fusion-plugin-acp-runtime/src/provider.ts:356`, KTD5-deferred). Route A therefore requires: (1) the ACP runtime to forward Fusion's MCP server(s) on `session/new` (U10), **and** (2) the `claude-code-cli-acp` bridge to pass those through to the underlying interactive `claude` **with tool calls still traversing the ACP permission gate** (verified by U9). Because `-p` removal is mandatory (see Summary), a no-go on either does **not** license staying on `-p`: it blocks the feature and triggers upstream work to add the missing capability. This is the plan's central open question (OQ1). +- **KTD9 — Per-route ACP posture needs a real mechanism (the runtime is a single global instance).** `acpRuntimeFactory` builds one `AcpRuntimeAdapter` from a frozen settings blob (`plugins/fusion-plugin-acp-runtime/src/index.ts:22-23`); `binaryPath`/`args`/fs-toggles/`model` are fixed at construction, and per-call `AgentRuntimeOptions` carries only cwd/tools/callbacks/gate. Route A (tool-bearing, Claude bridge) and Route B (read-only ask) cannot both draw distinct postures from one shared constructor. **Decision:** register a second runtime id `acp-claude` pinned to the bridge with tool-bearing defaults, leaving the generic `acp` runtime's "any ACP agent" contract intact — rather than hard-binding the global `acp` default to the bridge. (Resolved in U14; supersedes the earlier U2 framing of "default the `acp` runtime to the bridge.") +- **KTD10 — The pi extension reaches ACP via an injected client, never by importing engine internals.** `@fusion/pi-claude-cli` declares no dependency on `@fusion/engine`/the ACP plugin and cannot resolve `getRuntimeById('acp')` itself. **Decision:** the engine constructs an ACP-bridge client/driver at provider-registration time (`packages/engine/src/pi.ts:1366-1422`) and threads it into the provider's `streamSimple` options — mirroring how `mcpConfigPath` is already passed via `StreamViaCliOptions` — so the vendored fork stays dependency-clean. (Designed in U14, consumed in U11.) +- **KTD11 — `AgentRuntimeOptions` gains an `mcpServers` field (engine + plugin-local copy).** Forwarding MCP is a multi-layer contract change, not a local edit: a new optional field on the engine `AgentRuntimeOptions` (`packages/engine/src/agent-runtime.ts`) and its structural copy (`plugins/fusion-plugin-acp-runtime/src/types.ts`), a new `newAcpSession` signature, the `createSession` call-site, with back-compat default `[]` for Route B. The stdio MCP server shape `mcp-config.ts` already builds (`{ command, args }`) maps directly onto ACP's `mcpServers` entry. + +--- + +## Open Questions + +- **OQ1 (blocking for Route A; resolved by U9) — Can Fusion's MCP tools traverse the ACP bridge to Claude, *through the permission gate*?** Two parts: (a) does `claude-code-cli-acp` plumb `session/new` `mcpServers` to the underlying `claude` (its README does not mention MCP); and (b) **do the resulting tool calls surface as ACP `session/request_permission` (gated), or does `claude` invoke them autonomously inside the bridge, bypassing the gate?** U9 must test **(b) with the real Fusion MCP config** that `mcp-config.ts` builds — not a trivial stub — and record both answers. If tool calls bypass the gate, a separate control (MCP-layer hooks, or excluding sensitive-category tools from forwarding) is required before U10. Mandatory-`-p` means a no-go escalates to upstream work, not a `-p` fallback. +- **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. + +--- + +## Output Structure + +New files (everything else is edits to existing files): + +``` +packages/engine/src/ + cli-agent-ask.ts # U4: askAcpOnce runner + typed result + __tests__/cli-agent-ask.test.ts # U4 tests (fake AgentRuntime) +plugins/fusion-plugin-acp-runtime/src/ + setup.ts # U3: PluginSetupManifest + checkSetup (bridge + claude/auth probe) + __tests__/setup.test.ts # U3 tests +``` + +--- + +## Implementation Units + +### U1. Pin and resolve the `claude-code-cli-acp` bridge + +**Goal:** Ship the bridge with the ACP plugin and resolve its binary to an absolute path for spawn. +**Requirements:** R2. +**Dependencies:** none. +**Files:** +- `plugins/fusion-plugin-acp-runtime/package.json` (add `claude-code-cli-acp@0.1.1` to `dependencies`) +- `plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts` (binary resolution) +- `pnpm-lock.yaml`, `pnpm-workspace.yaml` (as needed for the new dep) +- `plugins/fusion-plugin-acp-runtime/README.md` + `AGENTS.md` external-integration evidence (pin version, integrity, repo URL, license) + +**Approach:** Add the pinned npm dependency. In `resolveCliSettings`, when `acpBinaryPath` is unset (or set to the sentinel `claude-code-cli-acp`), resolve the binary's absolute path from the plugin's own `node_modules/.bin` (via `require.resolve` of the package's bin, or the cli-printing-press `executorRuntimeEnv` PATH-prepend pattern at `plugins/fusion-plugin-cli-printing-press/src/runtime/executor-runtime-env.ts:15-75`). Record the bridge version + sha integrity in the external-integration evidence block per `AGENTS.md`. +**Patterns to follow:** existing dep pinning of `@agentclientprotocol/sdk@0.24.0`; bundled-binary PATH exposure in cli-printing-press. +**Test scenarios:** +- Resolves to an absolute, existing path when the dep is installed (happy path). +- Falls back / errors clearly when the binary is absent from `node_modules/.bin` (deferred to U3's probe for the user-facing message — here assert the resolver returns a deterministic path or a typed "not resolved" signal, not a throw mid-spawn). +- An explicit user-supplied `acpBinaryPath` still overrides the bundled default (keeps the "any ACP agent" capability — Covers R2). + +### U2. Read-only Claude ask profile + bridge env allow-list + +**Goal:** Provide a read-only ACP ask posture pinned to the bridge (for Route B) with a justified env allow-list. (The tool-bearing Route A posture is a *separate* registered runtime — see U14/KTD9 — not a mutation of the global `acp` default.) +**Requirements:** R3a, R16. +**Dependencies:** U1. +**Files:** +- `plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts` (`resolveCliSettings`: bridge binary resolution for the ask profile, `acpModel` forwarding, env allow-list default) +- `plugins/fusion-plugin-acp-runtime/src/process-manager.ts` (`buildSpawnEnv` — confirm allow-list discipline) +- `plugins/fusion-plugin-acp-runtime/src/index.ts` (`onLoad` logging) +- `plugins/fusion-plugin-acp-runtime/src/__tests__/` (extend `cli-spawn`/process-manager tests) + +**Approach:** Resolve the bridge binary (U1) for the ask profile, `acpArgs` `[]`, fs toggles OFF (read-only), forward `acpModel` to the adapter's `defaultModelId`/`settings.model` seam (`runtime-adapter.ts:39`). **Enumerate the Claude env allow-list:** `HOME` (required — bridge reads `~/.claude` auth/session), `PATH` (sub-executable resolution); **exclude** `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN` (documented: `claude` uses its stored `~/.claude` token; adding them is an extra leakage surface). Do **not** weaken the security floor (`acpAllowUnrestricted` stays default-false). Note: the existing default `acpBinaryPath` is `"acp-agent"` (`cli-spawn.ts:53`), not the bridge — the ask profile overrides it; the generic default stays for the "any ACP agent" contract. +**Patterns to follow:** existing `resolveCliSettings` defaults; `buildSpawnEnv` allow-list (`process-manager.ts:79-86`). +**Test scenarios:** +- Ask profile resolves the bridge binary + empty args + fs OFF. +- `acpModel` is forwarded so the adapter resolves that model (Covers R3a). +- The bridge subprocess env contains exactly the allow-list keys (`HOME`/`PATH`) and **never** `ANTHROPIC_API_KEY`/inherited `process.env` (Covers R16). +- A bridge spawned without `HOME` fails with a typed, actionable error (ties to U3 probe), not a hang. +- `acpAllowUnrestricted` remains false by default; setting it still logs the warning. + +### U3. Bridge readiness probe + setup manifest + +**Goal:** Detect the bridge binary and the `claude`/auth preconditions it depends on, and surface a typed, actionable status. +**Requirements:** R8. +**Dependencies:** U1. +**Files:** +- `plugins/fusion-plugin-acp-runtime/src/probe.ts` (extend `probeAcpReadiness` to target the bridge) +- `plugins/fusion-plugin-acp-runtime/src/setup.ts` (new — `PluginSetupManifest` + `checkSetup`) +- `plugins/fusion-plugin-acp-runtime/src/index.ts` (export setup hooks) +- `plugins/fusion-plugin-acp-runtime/src/__tests__/setup.test.ts` (new), extend `probe.test.ts` + +**Approach:** Reuse the existing probe taxonomy (`probe.ts:16-22`) against the bridge binary. **Note the latent gap:** today `probeAcpReadiness` returns `ok: true` with `authRequired: true` when auth methods are present (`probe.ts:54`) — it never emits `reason: "unauthenticated"`. U3 must either (a) emit `reason: "unauthenticated"` when the bridge reports it can't reach an authenticated `claude`, or (b) map `authRequired: true` (on an `ok` status) to the setup hint. Pick one and make the test assert the actual shape. Add a `PluginSetupManifest` + `checkSetup` following `plugins/fusion-plugin-agent-browser/src/setup.ts:5-31`, mapping `missing_binary` → "install `claude-code-cli-acp`" and the auth signal → "run `claude` to authenticate." Add a **binary-identity check**: the resolved bridge path must be inside the plugin's own `node_modules` (reject a PATH-resolved substitute). Before merging U1, **spot-review the bridge source at the pinned commit** and record that commit hash in the AGENTS.md evidence block. +**Patterns to follow:** `agent-browser/src/setup.ts`; existing `probeAcpReadiness`. +**Test scenarios:** +- `ok` when the bridge handshakes (use the existing echo-agent fixture style). +- `missing_binary` (ENOENT) → setup reports not-installed with the install hint. +- `handshake_timeout` and `incompatible_protocol` map to distinct, non-`ok` statuses. +- The auth-needed signal surfaces the claude-auth hint **in the shape the probe actually returns** (Covers R8); the test fails if it asserts a `reason` the probe never emits. +- A bridge resolved from outside `node_modules` is rejected by the identity check. Use a fake/fixture agent; do **not** spawn the real bridge in CI. + +### U4. `askAcpOnce` reusable runner + +**Goal:** Drive a single ACP ask turn and return `{ ok, text, parsed }` with typed failures. +**Requirements:** R4, R6 (no silent pass). +**Dependencies:** U2 (so a resolved `acp` runtime drives the bridge); does not require U5/U6. +**Execution note:** Implement test-first against a fake `AgentRuntime` — the prose-accumulation + JSON-recovery + dispose-on-failure contract is the crux and is fully unit-testable without a real bridge. +**Files:** +- `packages/engine/src/cli-agent-ask.ts` (new) +- `packages/engine/src/__tests__/cli-agent-ask.test.ts` (new) +- optionally `plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts` (KTD6: surface `stopReason`) +- optionally `plugins/fusion-plugin-acp-runtime/src/__tests__/runtime-adapter.test.ts` + +**Approach:** A function taking a resolved `AgentRuntime` (dependency-injected, mirroring the existing seam style) plus `{ prompt, cwd, model, systemPrompt, timeoutMs, recoverJson? }`. Flow: `createSession({ tools: "readonly", defaultModelId: model, systemPrompt, onText: d => text += d })` → `promptWithFallback(session, prompt)` → on resolve, optionally `parsed = recoverJson(text)` via `extractJsonObjects` → `dispose` in a `finally`. Map a spawn/handshake/turn error or abnormal `stopReason` to a typed failure (`ok: false, reason, message`) so the validator's error path keeps working. Enforce `timeoutMs` by racing the prompt and disposing on timeout. Result shape mirrors `OneShotResult` enough that seams change minimally. +**Patterns to follow:** `packages/engine/src/evaluator.ts:146-173` (accumulate-onText + dispose-in-finally idiom); `OneShotResult`/`OneShotFailure` typing in `one-shot-session.ts`. +**Test scenarios:** +- Happy path: fake runtime streams `"hello"` deltas → `{ ok: true, text: "hello" }`. +- Multi-delta accumulation concatenates in order. +- `recoverJson` extracts a trailing `{ ... }` object embedded in prose → populates `parsed`; absent JSON → `parsed` undefined, `ok` still true. +- Error path: `createSession` throws → typed `ok: false` failure; session never leaks (dispose still attempted / not created). +- Turn error: `promptWithFallback` rejects → typed failure, `dispose` called in `finally`. +- Timeout: prompt that never resolves is killed at `timeoutMs` → typed failure (Covers R4). +- KTD6 (if implemented): abnormal `stopReason` (`max_tokens`) is reflected so the caller can refuse to treat a truncated answer as complete. + +### U5. Rewire the planning seam onto ACP + +**Goal:** `runCliAgentPlanning` produces a `PlanningResponse` from ACP prose. +**Requirements:** R1, R5. +**Dependencies:** U4. +**Files:** +- `packages/engine/src/interactive-ai-session.ts` +- `packages/engine/src/__tests__/interactive-ai-session.test.ts` + +**Approach:** Lowest churn — `parseAgentResponse` (lines 153-188) already extracts JSON from prose. Swap the injected `run` for `askAcpOnce`, feed `result.text` (with `rawOutput` as the same accumulated text) into the existing parser. Translate the prior `opts.settings.model` into the ACP model seam. Keep the existing throw-on-failure behavior. +**Patterns to follow:** existing `runCliAgentPlanning` signature + `parseAgentResponse`. +**Test scenarios:** +- ACP prose containing a `{type:"question",data:{...}}` block parses to a `question` response. +- ACP prose containing `{type:"complete",data:{...}}` parses to `complete`. +- Runner failure → throws the existing planning-failure error. +- Prose with no decodable `{type,data}` → throws the parse error (Covers R5). Update `fakeRun` to return the ACP-shaped `{ ok, text }`. + +### U6. Rewire the validator seam onto ACP + +**Goal:** `runCliAgentValidation` produces a `ValidatorVerdict` from ACP prose without ever silently passing. +**Requirements:** R1, R6. +**Dependencies:** U4. +**Execution note:** This is the contract-sensitive unit — preserve the "never a silent pass" invariant; an undecidable result must map to `error`, never `pass`. +**Files:** +- `packages/engine/src/cli-agent-validator.ts` +- `packages/engine/src/__tests__/cli-agent-validator.test.ts` + +**Approach:** Add a validator system prompt instructing Claude to end its turn with a single JSON object (`{ "verdict": "pass|fail|blocked|error", "summary": "...", "assertions": [...] }`). Drive via `askAcpOnce` with `recoverJson` so `result.parsed` is populated; `mapParsedToVerdict` (lines 65-119) then works off `verdict`/`passed`/`blocked`. Remove the claude-`-p`-specific `is_error` tier (line 78). **Close the silent-pass hole (R15):** (1) **`stopReason` surfacing (KTD6) is REQUIRED for this path** (not optional) — an abnormal/truncated stop (`max_tokens`, `cancelled`) forces `error` regardless of recovered prose, because a truncated answer can leave a syntactically-complete trailing `{...}` that would otherwise parse as authoritative. (2) **`inferVerdictFromProse` may only return `fail`/`blocked`/`error` on the ACP path — never `pass`.** A `pass` requires a recovered structured `verdict:"pass"`/`passed:true` from a clean `end_turn`; absent that, the result is `error`. Map runner failure → `status:"error"` (preserve `oneShotResultToVerdict` 157-166). +**Patterns to follow:** `mapParsedToVerdict`, `parseAssertions`; `inferVerdictFromProse` constrained to non-pass outcomes. +**Test scenarios:** +- Parsed `{verdict:"pass", assertions:[...]}` from a clean `end_turn` → `pass` with assertions. +- Parsed `{verdict:"fail"}` / `{passed:false}` → `fail`; `{blocked:true, reason}` → `blocked`. +- **Truncated stop** (`max_tokens`) with a parseable trailing `{verdict:"pass"}` → `error`, NOT `pass` (Covers R15). +- Prose "all assertions pass" with no recovered JSON → `error`, never `pass` (Covers R15). +- Empty/undecidable ACP prose → `error` (cardinal rule — Covers R6). +- Prose "this fails / blocked" with no JSON → `fail`/`blocked` via the constrained backstop. +- Runner failure → `error` with bounded message in summary. + +### U7. Delete the Claude `-p` branches + +**Goal:** Remove Claude's non-interactive print path and its now-dead parsing/tests. +**Requirements:** R7. +**Dependencies:** U5, U6 (delete only after the replacements are green). +**Files:** +- `packages/engine/src/cli-agent/one-shot-session.ts` (`buildOneShotSettings` claude-code branch lines 67-69; `parseOneShotOutput` claude-code case lines 139-148) +- `packages/engine/src/cli-agent/__tests__/one-shot-session.test.ts` (drop claude `{type:"result"}` shape tests) + +**Approach:** Remove the `case "claude-code"` arms in both helpers, leaving codex/droid/pi/generic intact. Keep `runOneShotSession`, `OneShotResult`, and `extractJsonObjects` (the latter is reused by U4/U6). Verify no remaining reference assumes a claude one-shot branch. +**Patterns to follow:** the surrounding switch arms that remain. +**Test scenarios:** +- `Test expectation: none for new behavior` — this is deletion. Verification is that the codex/droid/pi one-shot tests still pass and no test references the removed claude branch. +- Add a guard test asserting `buildOneShotSettings("claude-code", ...)` is no longer a supported path (throws or routes to generic) so a future caller can't silently re-introduce `-p`. + +### U9. Spike: external MCP-over-ACP feasibility through the bridge (Route A gate 1 of 2) + +**Goal:** Resolve OQ1 — prove (or disprove) that Fusion's MCP tools reach Claude through `claude-code-cli-acp` **and** that tool calls remain gated. +**Requirements:** R10 (feasibility). +**Dependencies:** U1. +**Files:** investigation only; the deliverable is a recorded go/no-go in this plan's **Open Questions (OQ1)** + `docs/acp-contract.md`, committed before U10 starts. +**Approach:** Drive the bridge over ACP with a non-empty `session/new` `mcpServers` carrying **the real Fusion MCP config that `mcp-config.ts` builds today** (not a trivial stub — size, server count, and stdio transport assumptions must be exercised). Verify two things and record both: (1) Claude can invoke a real forwarded Fusion tool; (2) **whether that invocation surfaces as an ACP `session/request_permission` (gated) or is invoked autonomously inside the bridge (gate bypassed)** — this is the security-critical answer (OQ1/security F3). If `mcpServers` is ignored, OR tool calls bypass the gate with no mitigation, Route A is blocked → escalate to upstream bridge/ACP work (mandatory-`-p`: no `-p` fallback). This is a hard go/no-go gate; it is **necessary but not sufficient** — see U14 for the internal blockers. +**Test scenarios:** `Test expectation: none -- spike; the deliverable is a recorded go/no-go decision (with the gate-traversal answer), not shipped code.` + +### U14. Design-confirmation: resolve Route A's internal blockers (Route A gate 2 of 2) + +**Goal:** Resolve the internal blockers that no spike screens — knowable today — before committing U10–U13. **KTD9, KTD10, KTD11.** +**Requirements:** R3b, R11 (enablement). +**Dependencies:** U4 (engine ACP-driver patterns), U9 (go). +**Files:** design note in this plan + `docs/acp-contract.md`; no shipped code (the mechanisms land in U10/U11). +**Approach:** Produce and record concrete mechanisms for three blockers the feasibility review surfaced: +1. **pi-extension injection seam (KTD10):** name the engine file/seam (`packages/engine/src/pi.ts:1366-1422`, `registerExtensionProviders`) that constructs an ACP-bridge client and threads it into the provider's `streamSimple` options (mirroring `mcpConfigPath` in `StreamViaCliOptions`), so `@fusion/pi-claude-cli` never imports engine/plugin internals. +2. **`AgentRuntimeOptions.mcpServers` contract (KTD11):** specify the new field on the engine type + the plugin-local structural copy, the `newAcpSession` signature change, and the `[]` back-compat default. +3. **Per-route posture (KTD9):** confirm the `acp-claude` second runtime id (bridge-pinned, tool-bearing) vs. a per-call override, and how lanes select it (model-id/`useClaudeCli` → `runtimeHint`). +**Test scenarios:** `Test expectation: none -- design gate; deliverable is the recorded mechanisms that unblock U10/U11.` + +### U10. ACP MCP-server forwarding in the runtime (Route A enabler) + +**Goal:** Forward Fusion's MCP server(s) on `session/new` so the agent can call Fusion tools — implementing the contract change KTD11 specifies. +**Requirements:** R10. +**Dependencies:** U9 (external go), U14 (internal mechanisms). +**Files:** +- `packages/engine/src/agent-runtime.ts` (new optional `mcpServers` on `AgentRuntimeOptions`) +- `plugins/fusion-plugin-acp-runtime/src/types.ts` (matching field on the structural copy) +- `plugins/fusion-plugin-acp-runtime/src/provider.ts` (`newAcpSession` signature + populate `mcpServers`; today hardcoded `[]` at line 356) +- `plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts` (`createSession` call-site threads the field) +- `plugins/fusion-plugin-acp-runtime/src/__tests__/provider-session.test.ts` + +**Approach:** Implement the multi-layer `session/new mcpServers` forwarding (KTD11) within the existing security floor. Source the config the way `pi-claude-cli` builds `--mcp-config` (`packages/pi-claude-cli/src/mcp-config.ts` — its `{ command, args }` stdio shape maps directly to an ACP `mcpServers` entry). **Permission-gate caveat (from U9):** the per-category gate protects ACP `session/request_permission` calls; it only covers MCP tool calls **if** U9 confirmed they traverse that path. If U9 found tool calls bypass the gate, U10 must additionally restrict which tools are forwarded (exclude sensitive categories) or add an MCP-layer permission hook — do **not** claim "security floor unchanged" until that is settled. +**Patterns to follow:** existing `newAcpSession` + the permission-gate wiring in `createBridgingClientHandler`. +**Test scenarios:** +- `session/new` is opened with the forwarded `mcpServers` when provided; `[]` when not (back-compat for Route B ask turns). +- A gated tool call is classified per-category (Covers R10), per the U9-confirmed path. +- If forwarding restricts sensitive tools, a sensitive-category tool is absent from the forwarded set. +- Malformed/oversized MCP config is rejected without crashing the turn. + +### U11. Re-point the `pi-claude-cli` provider onto the ACP bridge + +**Goal:** Point the provider's `streamSimple` at the ACP bridge while keeping the provider key, preserving context and streaming fidelity. **This is the highest-risk unit in the plan** — the transport translation (not the provider-key stability) is the load-bearing part; do not treat it as "lowest churn." +**Requirements:** R9, R10, R11, R13, R14. **KTD7, KTD10.** +**Dependencies:** U10, U14 (and U9 go). +**Execution note:** Characterize the existing NDJSON stream/tool-mapping behavior — capturing **exact tool-call argument bytes**, not just token ordering — BEFORE swapping the transport. This provider feeds executor/reviewer/workflow lanes. +**Files:** +- `packages/pi-claude-cli/src/provider.ts` (`streamViaCli`/`streamSimple` → ACP client driver injected per KTD10; resume/history branch at lines 114-125) +- `packages/pi-claude-cli/src/process-manager.ts` (`spawnClaude`/`buildClaudeSpawnArgs` **kept behind a runtime kill-switch — NOT deleted** — for config-only rollback per R14) +- `packages/pi-claude-cli/index.ts` (provider registration; `streamSimple` dispatch) +- `packages/pi-claude-cli/src/{tool-mapping.ts,stream-parser.ts,event-bridge.ts,thinking-config.ts}` (adapt to ACP delta/tool shape) +- `packages/pi-claude-cli/src/__tests__/*` + +**Approach:** Drive the bridge (createSession → promptWithFallback with streaming callbacks) from inside `streamSimple` via the injected ACP client (KTD10), translating ACP `session/update` deltas + tool events into the pi stream-chunk shape. Preserve Claude↔pi tool-name mapping. **Context (R13/OQ2):** since the ACP path has no Claude-side resume, send **full flattened history (`buildPrompt`) every turn** — never `buildResumePrompt` (latest-turn-only) without a real resume. **Rollback (R14):** gate the transport behind a kill-switch so `useClaudeCli` can fall back to `spawnClaude`-`-p` without a code revert until soak completes. **Fidelity (R11/OQ3):** verify tool-call argument integrity survives the transcript→ACP→event-bridge round-trip (no space-repair corruption, intact start/end correlation). Forward the selected model id as the ACP `defaultModelId`. +**Patterns to follow:** existing `streamViaCli` stream-chunk emission; the ACP event bridge (`plugins/fusion-plugin-acp-runtime/src/event-bridge.ts`). +**Test scenarios:** +- Token deltas from a fake ACP turn surface as pi text chunks in order (Covers R11). +- Thinking deltas and tool-start/tool-end events map to the pi shapes lanes consume. +- A tool call round-trips through the Claude↔pi name mapping **with byte-exact arguments** (Covers R11). +- **2nd-turn call carries prior-turn context** (full history sent); no path sends latest-turn-only without resume (Covers R13). +- Kill-switch off → `streamSimple` uses the `-p` `spawnClaude` path unchanged (Covers R14). +- Turn/connection failure surfaces as the provider's existing error-chunk shape (no silent truncation). +- 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. + +### 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. +**Requirements:** R9, R12. +**Dependencies:** U11. +**Files:** +- `packages/dashboard/src/routes/register-model-routes.ts` (picker filtering / `configuredProviders` — lines 140-174) +- `packages/dashboard/src/routes/register-auth-routes.ts` (`/auth/claude-cli`, `/providers/claude-cli/status` — lines 336,344,450-502,579-592) +- `packages/dashboard/src/claude-cli-probe.ts` (probe now targets the ACP bridge + `claude` auth) +- `packages/core/src/types.ts` (`useClaudeCli` doc), `packages/core/src/settings-schema.ts` (`claude-code` entry line 580) +- `packages/cli/src/commands/{claude-cli-extension.ts,provider-auth.ts}` as needed +- corresponding dashboard/core tests + +**Approach:** Keep `useClaudeCli` as the enable flag (KTD7) but make its readiness check go through the U3 ACP/bridge probe (and `claude` auth). Picker continues to show Claude CLI models when enabled; status reports bridge+auth health. No migration of persisted provider selections (re-routed under the hood). **Sanitize the status response (R12):** strip internal file paths / OS error strings from the probe `detail`/`reason` and bound its length before returning it from `/providers/claude-cli/status` (match the redaction the existing `ClaudeCliBinaryStatus.reason` applies). +**Patterns to follow:** existing claude-cli probe/status wiring. +**Test scenarios:** +- Picker shows `pi-claude-cli` models iff `useClaudeCli` is on (unchanged behavior). +- `/providers/claude-cli/status` reports healthy when bridge+auth probe is `ok`, and the specific failure when not (Covers R12). +- A `spawn_error` `detail` containing an absolute path is sanitized before it appears in the HTTP body (Covers R12). +- `useClaudeCli` toggle on/off flips `configuredProviders` correctly. +- A persisted `defaultProvider="pi-claude-cli"` resolves and runs via ACP with no re-selection (Covers R9). + +### U13. Workflow `model`-node verification + +**Goal:** Confirm workflow execution `model` nodes using Claude CLI run over ACP end-to-end (the surface the user explicitly named). +**Requirements:** R9. +**Dependencies:** U11. +**Files:** +- engine workflow executor path (`packages/engine/src/executor.ts` prompt-mode lane) — likely no change beyond U11; this unit is verification + regression tests +- workflow executor tests under `packages/engine/src/__tests__/` + +**Approach:** Since workflow `model` nodes go through `createFnAgent` → the pi registry, U11 should cover them automatically. This unit adds a regression test asserting a workflow `model` step with `pi-claude-cli` selected drives the ACP path (via a fake runtime) and does not spawn `claude -p`. **Also assess Route A multi-turn latency:** with per-turn fresh ACP sessions (no resume) every workflow `model` node pays a cold bridge+`claude` spawn; record a rough budget (turns/workflow × spawn cost) and confirm it's tolerable, or flag session-reuse as a Route-A follow-up blocker (ties to OQ2). +**Patterns to follow:** existing workflow-executor model-node tests. +**Test scenarios:** +- A workflow `model` node with `pi-claude-cli` selected produces streamed output via the ACP path. +- No `claude -p` spawn occurs on this path when the kill-switch is on (guard against regression — Covers R9). +- A multi-step workflow's per-node spawn cost is measured/recorded (latency budget note, not a hard assertion). + +### U8. Docs, scope notes, and CONCEPTS + +**Goal:** Record the new Claude→ACP path and the deferred surfaces. +**Requirements:** Documents the outcomes of R1/R9 (discoverability only — U5/U6/U11 own the functional routing, not this unit). +**Dependencies:** U1, U2, U3, U4, U5, U6, U7. +**Files:** +- `docs/acp-contract.md` (note the Claude bridge profile + ask-once contract) +- `plugins/fusion-plugin-acp-runtime/CHANGELOG.md`, root `CHANGELOG.md` +- `.changeset/*.md` (feature changeset per repo convention) +- `CONCEPTS.md` (only if it exists — add "ACP ask path" / "Claude bridge" if the terms are project-canonical) + +**Approach:** Document the runtimeHint `acp` + bridge profile, the prose→JSON recovery contract, and the deferred follow-ups. Add a changeset. +**Test scenarios:** `Test expectation: none -- documentation only.` + +--- + +## Scope Boundaries + +**In scope:** Both `-p` routes moving to the ACP runtime + pinned bridge — **Route A** the `pi-claude-cli` provider (all lanes incl. workflow `model` nodes); **Route B** the planning + validator one-shot seams + reusable ask-once runner. Plus the shared bridge dependency, probe/setup, MCP forwarding, per-route ACP posture, picker/auth/status surface, and deletion of Claude one-shot branches. **`-p` removal is mandatory for both routes** (see Summary) — the feature is not done while any Claude path still uses `-p`. + +### Sequencing +Route B (U1–U7) is independent and ships first as committed progress. Route A is gated by **two** hard go/no-go checks before U10–U13: **U9** (external — bridge MCP passthrough *and* permission-gate traversal, tested with the real config) and **U14** (internal — pi-extension injection seam, `mcpServers` contract, per-route posture). Both must return go. Because `-p` removal is mandatory, a no-go does **not** drop Route A to a `-p` fallback — it blocks the feature and escalates the missing capability (bridge MCP support / ACP forwarding) to upstream work. Record the gate outcomes in OQ1/U14. + +### Deferred to Follow-Up Work +- **CE orchestrator on ACP.** The CE orchestrator never used one-shot (`plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts:118-127`, "not yet wired"). Routing CE onto the ACP *interactive* runtime is a separate unit; the `CeSessionExecutor` type is untouched by `-p` removal. +- **Production wiring of planning/validator.** These seams have no production caller today; making them actually run is pre-existing TODO, unchanged by this plan. +- **codex / droid / pi off non-interactive mode.** No ACP bridge exists for these agents; their `exec`/`--print` forms stay. +- **Claude-side session continuity over ACP (OQ2).** `loadAcpSession` resume is deferred; if lanes regress without `--resume`, that is follow-up work. +- **OS-level sandboxing of the bridge subprocess.** The ACP runtime does not sandbox the agent's own syscalls (documented v1 residual). + +### Non-Goals +- Changing the cli-agent PTY `claude-code` adapter's interactive task-execution path (it stays as-is for `execute`/`chat`). +- Weakening the ACP security floor (per-category gating, env allow-list, path-jail, `acpAllowUnrestricted` default-false). + +--- + +## Alternatives Considered + +The user directed the bridge-via-ACP direction; this records the rejected options and why, for honest grounding (the bridge does *not* avoid PTY+transcript scraping — it relocates it into a young external process). + +- **Build a thin in-tree PTY+JSONL "ask" ourselves (no external dep).** We already own most pieces (`claude-code.ts` Stop→done hooks + `ClaudeTranscriptTailer`; the legacy `pi-claude-cli` PTY/transcript code). Rejected per user direction in favor of reusing the shipped ACP runtime — but it remains the fallback if the bridge proves unmaintained, and it avoids the supply-chain and path-dependency costs. Recorded so the trade is explicit, not hidden behind "without re-implementing it ourselves." +- **Route the provider through the existing cli-agent PTY `claude-code` adapter.** That adapter already drives interactive Claude over a PTY (the literal "interactive, not `-p`" ask) for execute/chat. Rejected because its `CliAgentAdapter` contract is a raw byte-stream (readiness/injection), not the structured streaming + tool-call/permission surface the provider lanes need; bending it into a model-provider transport is a larger, mismatched change than the ACP path. Noted because "we already have a PTY Claude driver" is a fair challenge to adopting a new dependency. +- **Register a brand-new ACP-backed provider key + migrate saved selections.** Rejected (KTD7) in favor of keeping `pi-claude-cli` and re-routing under the hood — smaller blast radius, no user-visible churn (R9). The cost (a behaviorally-different Claude under a stable label) is mitigated by R11/R13 fidelity bars. + +## Risks & Dependencies + +- **MCP tool-forwarding is the make-or-break for Route A (highest risk).** The high-traffic provider depends on Fusion tools via `--mcp-config`; ACP forwards no MCP servers today and the bridge's MCP passthrough is unconfirmed. If tools can't traverse the bridge, executor/workflow lanes would run tool-less Claude — unacceptable. Mitigation: U9 is a hard go/no-go spike before any Route A build; Route B is fully independent of this. +- **Highest-traffic path swap.** Re-pointing `pi-claude-cli` touches the lane behind chat/executor/reviewer/workflow. Mitigation: characterization tests before the transport swap (U11 execution note); keep the provider key + `useClaudeCli` semantics (KTD7) so selections/migrations don't move; ship Route B first to de-risk the ACP plumbing. +- **Young external dependency (v0.1.1, 11 stars), and rollback is NOT config-only for Route A.** Reverting `acp` config restores the "any ACP agent" default for Route B / the bridge dependency — but once U11 swaps the provider transport, falling back to `-p` requires the U11 **kill-switch** (R14), not a config flip. The young-dep mitigations (exact-version pin + lockfile integrity + source-review at the pinned commit + isolation behind the security floor) reduce but don't remove the bet on one maintainer's project for Fusion's primary Claude path. +- **Supply-chain: the bridge reads `~/.claude` directly.** Unlike other ACP agents constrained by the path-jail, the bridge reads Claude transcript JSONL outside any `fs/*` ACP call — a compromised bridge could exfiltrate historical session content. Mitigation: lockfile SHA + source-review the pinned commit (U1/U3) + binary-identity check (resolved path must be inside `node_modules`). +- **Resume loss is a correctness regression, not a slowdown (R13/OQ2).** The provider relies on `--resume` for multi-turn context; the ACP path has none. Mitigation: U11 sends full history every turn; a 2nd-turn-context test guards it. Residual cost: larger prompts + cold spawns (latency below). +- **Prose↔JSON brittleness for the validator.** Mitigation: explicit system prompt + `extractJsonObjects` recovery + **required** stopReason (KTD6, R15) + a prose backstop constrained to never yield `pass`. The "JSON-presence only" fallback is rejected for the validator. +- **Per-call spawn latency — worse for Route A than Route B.** Fresh handshake + cold `claude` spawn per turn. For Route B (low-frequency planning/validation) it's acceptable; for Route A's multi-turn chat/executor/workflow lanes it compounds (no warm resume). Mitigation: U13 records a per-node budget; session-reuse (`loadAcpSession`) is a Route-A follow-up blocker if the budget is exceeded. +- **`claude` auth/install is a precondition** the bridge needs but cannot satisfy. Mitigation: U3 probe maps the auth/missing-binary signals to actionable setup status (in the shape the probe actually returns). + +--- + +## Sources & Research + +- Existing ACP runtime: `plugins/fusion-plugin-acp-runtime/` (`runtime-adapter.ts`, `provider.ts`, `event-bridge.ts`, `cli-spawn.ts`, `process-manager.ts`, `probe.ts`, `index.ts`, `manifest.json`, `package.json`); shipped via PR #1354, plan `docs/plans/2026-06-02-002-feat-acp-client-integration-plan.md`, learning `docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md`. +- Engine runtime seam: `packages/engine/src/{agent-runtime.ts,runtime-resolution.ts,plugin-runner.ts,agent-session-helpers.ts,evaluator.ts}`. +- Route B consumers: `packages/engine/src/interactive-ai-session.ts`, `packages/engine/src/cli-agent-validator.ts`, `plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts`. +- Route A — `pi-claude-cli` provider: `packages/pi-claude-cli/index.ts` (provider id `pi-claude-cli`, registration), `packages/pi-claude-cli/src/{provider.ts,process-manager.ts,mcp-config.ts,tool-mapping.ts,stream-parser.ts,event-bridge.ts,thinking-config.ts}`; engine registration `packages/engine/src/pi.ts:1366-1422` (+ `resolveModelSelection` 1019-1046); `packages/core/src/{types.ts:2993 (useClaudeCli),pi-extensions.ts:319-350,settings-schema.ts:580}`; workflow node kind `packages/core/src/workflow-ir-types.ts:80`; dashboard `packages/dashboard/src/routes/{register-model-routes.ts:140-174,register-auth-routes.ts}`, `packages/dashboard/src/claude-cli-probe.ts`; CLI `packages/cli/src/commands/{claude-cli-extension.ts,provider-auth.ts}`. +- One-shot machinery being trimmed: `packages/engine/src/cli-agent/one-shot-session.ts` and its tests. +- Pattern refs: `plugins/fusion-plugin-agent-browser/src/setup.ts` (setup manifest + probe), `plugins/fusion-plugin-cli-printing-press/src/runtime/executor-runtime-env.ts` (bundled-binary PATH exposure). +- External bridge: `claude-code-cli-acp` — https://github.com/moabualruz/claude-code-cli-acp (v0.1.1, Apache-2.0; npm `claude-code-cli-acp`; "runs `claude` through a PTY, reads transcript JSONL, exposes an ACP server over stdio"; requires `@anthropic-ai/claude-code` installed + authenticated). +- ACP protocol: https://agentclientprotocol.com — SDK `@agentclientprotocol/sdk@0.24.0`. diff --git a/packages/engine/src/__tests__/cli-agent-ask.test.ts b/packages/engine/src/__tests__/cli-agent-ask.test.ts new file mode 100644 index 0000000000..30c52ea5b0 --- /dev/null +++ b/packages/engine/src/__tests__/cli-agent-ask.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AgentSession } from "@earendil-works/pi-coding-agent"; +import type { AgentRuntime, AgentRuntimeOptions, AgentSessionResult } from "../agent-runtime.js"; +import { askAcpOnce } from "../cli-agent-ask.js"; + +interface FakeRuntimeOptions { + createError?: Error; + promptError?: Error; + deltas?: string[]; + stopReason?: string; + neverResolve?: boolean; +} + +function makeRuntime(options: FakeRuntimeOptions = {}) { + const session = { dispose: vi.fn() } as unknown as AgentSession; + const createdOptions: AgentRuntimeOptions[] = []; + const runtime: AgentRuntime = { + id: "acp", + name: "ACP Runtime", + async createSession(opts: AgentRuntimeOptions): Promise { + createdOptions.push(opts); + if (options.createError) throw options.createError; + return { session }; + }, + async promptWithFallback(): Promise<{ stopReason?: string } | void> { + if (options.promptError) throw options.promptError; + for (const delta of options.deltas ?? []) { + createdOptions[0]?.onText?.(delta); + } + if (options.neverResolve) { + await new Promise(() => undefined); + } + return options.stopReason ? { stopReason: options.stopReason } : undefined; + }, + describeModel() { + return "acp/test"; + }, + }; + return { runtime, session, createdOptions }; +} + +describe("askAcpOnce", () => { + it("streams a happy path response through readonly ACP options", async () => { + const { runtime, session, createdOptions } = makeRuntime({ deltas: ["hello"] }); + const result = await askAcpOnce(runtime, { + prompt: "say hi", + cwd: "/repo", + model: "claude-sonnet-4", + systemPrompt: "system", + }); + expect(result).toEqual({ ok: true, text: "hello" }); + expect(createdOptions[0]).toMatchObject({ + cwd: "/repo", + systemPrompt: "system", + tools: "readonly", + defaultModelId: "claude-sonnet-4", + }); + expect(session.dispose).toHaveBeenCalledOnce(); + }); + + it("accumulates multiple deltas in order", async () => { + const { runtime } = makeRuntime({ deltas: ["hel", "lo", "!"] }); + await expect(askAcpOnce(runtime, { prompt: "p", cwd: "/repo" })).resolves.toEqual({ ok: true, text: "hello!" }); + }); + + it("recovers the trailing JSON object when requested", async () => { + const { runtime } = makeRuntime({ deltas: ["prose\n", "{\"verdict\":\"pass\"}"] }); + const result = await askAcpOnce(runtime, { prompt: "p", cwd: "/repo", recoverJson: true }); + expect(result).toMatchObject({ ok: true, parsed: { verdict: "pass" } }); + }); + + it("leaves parsed undefined when JSON recovery finds no object", async () => { + const { runtime } = makeRuntime({ deltas: ["plain prose"] }); + const result = await askAcpOnce(runtime, { prompt: "p", cwd: "/repo", recoverJson: true }); + expect(result).toEqual({ ok: true, text: "plain prose" }); + }); + + it("maps createSession errors to typed failures without leaking a session", async () => { + const { runtime, session } = makeRuntime({ createError: new Error("spawn failed") }); + const result = await askAcpOnce(runtime, { prompt: "p", cwd: "/repo" }); + expect(result).toMatchObject({ ok: false, reason: "create_session_failed", message: "spawn failed" }); + expect(session.dispose).not.toHaveBeenCalled(); + }); + + it("maps prompt errors to typed failures and disposes", async () => { + const { runtime, session } = makeRuntime({ promptError: new Error("turn failed") }); + const result = await askAcpOnce(runtime, { prompt: "p", cwd: "/repo" }); + expect(result).toMatchObject({ ok: false, reason: "turn_failed", message: "turn failed" }); + expect(session.dispose).toHaveBeenCalledOnce(); + }); + + it("times out a never-resolving prompt and disposes", async () => { + const { runtime, session } = makeRuntime({ neverResolve: true }); + const result = await askAcpOnce(runtime, { prompt: "p", cwd: "/repo", timeoutMs: 5 }); + expect(result).toMatchObject({ ok: false, reason: "timeout" }); + expect(session.dispose).toHaveBeenCalledOnce(); + }); + + it("reflects an abnormal stopReason as a typed failure", async () => { + const { runtime } = makeRuntime({ deltas: ["{\"verdict\":\"pass\"}"], stopReason: "max_tokens" }); + const result = await askAcpOnce(runtime, { prompt: "p", cwd: "/repo", recoverJson: true }); + expect(result).toMatchObject({ ok: false, reason: "abnormal_stop", stopReason: "max_tokens" }); + }); +}); diff --git a/packages/engine/src/__tests__/cli-agent-validator.test.ts b/packages/engine/src/__tests__/cli-agent-validator.test.ts index de2a4b1a8e..c987c65718 100644 --- a/packages/engine/src/__tests__/cli-agent-validator.test.ts +++ b/packages/engine/src/__tests__/cli-agent-validator.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { mapParsedToVerdict, oneShotResultToVerdict, @@ -6,10 +6,9 @@ import { inferVerdictFromProse, runCliAgentValidation, } from "../cli-agent-validator.js"; -import type { - OneShotResult, - RunOneShotOptions, -} from "../cli-agent/one-shot-session.js"; +import type { OneShotResult } from "../cli-agent/one-shot-session.js"; +import type { AgentRuntime, AgentRuntimeOptions, AgentSessionResult } from "../agent-runtime.js"; +import type { AgentSession } from "@earendil-works/pi-coding-agent"; function success(parsed: Record, text = ""): OneShotResult { return { ok: true, sessionId: "s1", parsed, text, rawOutput: JSON.stringify(parsed) }; @@ -26,13 +25,13 @@ describe("verdict token normalization", () => { }); describe("mapParsedToVerdict — per-adapter shapes → verdicts", () => { - it("claude-shaped pass (is_error:false + verdict)", () => { - const v = mapParsedToVerdict({ type: "result", verdict: "pass", is_error: false }, ""); + it("structured pass verdict is authoritative", () => { + const v = mapParsedToVerdict({ verdict: "pass" }, ""); expect(v.status).toBe("pass"); }); - it("claude-shaped error flag is authoritative", () => { - const v = mapParsedToVerdict({ is_error: true, result: "crashed" }, ""); + it("undecidable parsed object maps to error", () => { + const v = mapParsedToVerdict({ result: "crashed" }, ""); expect(v.status).toBe("error"); }); @@ -64,10 +63,10 @@ describe("mapParsedToVerdict — per-adapter shapes → verdicts", () => { expect(v.assertions[1]).toEqual({ assertionId: "a2", passed: false, message: "nope" }); }); - it("prose-only pass inference", () => { - expect(inferVerdictFromProse("All assertions pass.")).toBe("pass"); + it("prose-only pass wording is not authoritative", () => { + expect(inferVerdictFromProse("All assertions pass.")).toBeNull(); const v = mapParsedToVerdict({}, "All assertions pass."); - expect(v.status).toBe("pass"); + expect(v.status).toBe("error"); }); it("MALFORMED / undecidable → error, NEVER pass", () => { @@ -107,47 +106,89 @@ describe("oneShotResultToVerdict — failures map to error", () => { }); }); -describe("runCliAgentValidation — seam threads purpose:validator and maps verdict", () => { - it("invokes runner with validator purpose and returns the verdict", async () => { - let seenPurpose: string | undefined; - const fakeRun = async (opts: RunOneShotOptions): Promise => { - seenPurpose = opts.purpose; - return success({ verdict: "pass", summary: "looks good" }, "looks good"); - }; - const verdict = await runCliAgentValidation( - { - manager: {} as RunOneShotOptions["manager"], - adapterId: "claude-code", - projectId: "p", - prompt: "validate", - cwd: "/tmp", - }, - fakeRun as never, +function validatorRuntime( + text: string, + options: { stopReason?: string; promptError?: Error; createError?: Error } = {}, +) { + const createOptions: AgentRuntimeOptions[] = []; + const session = { dispose: vi.fn() } as unknown as AgentSession; + const runtime: AgentRuntime = { + id: "acp", + name: "ACP Runtime", + async createSession(opts: AgentRuntimeOptions): Promise { + createOptions.push(opts); + if (options.createError) throw options.createError; + return { session }; + }, + async promptWithFallback(): Promise<{ stopReason?: string } | void> { + if (options.promptError) throw options.promptError; + createOptions[0]?.onText?.(text); + return options.stopReason ? { stopReason: options.stopReason } : { stopReason: "end_turn" }; + }, + describeModel() { + return "acp/test"; + }, + }; + return { runtime, createOptions }; +} + +describe("runCliAgentValidation — ACP seam preserves no-silent-pass", () => { + it("parsed pass verdict from clean end_turn returns pass with assertions", async () => { + const { runtime, createOptions } = validatorRuntime( + 'done {"verdict":"pass","summary":"looks good","assertions":[{"assertionId":"a1","passed":true}]}', ); - expect(seenPurpose).toBe("validator"); + const verdict = await runCliAgentValidation(runtime, { + prompt: "validate", + cwd: "/tmp", + settings: { model: "claude-sonnet-4" }, + }); expect(verdict.status).toBe("pass"); - expect(verdict.summary).toBe("looks good"); + expect(verdict.assertions).toEqual([{ assertionId: "a1", passed: true, message: undefined }]); + expect(createOptions[0]).toMatchObject({ tools: "readonly", defaultModelId: "claude-sonnet-4" }); + }); + + it.each([ + ['{"verdict":"fail","summary":"missing tests"}', "fail"], + ['{"passed":false,"summary":"missing tests"}', "fail"], + ['{"blocked":true,"reason":"needs creds"}', "blocked"], + ])("maps structured %s", async (json, status) => { + const { runtime } = validatorRuntime(json); + const verdict = await runCliAgentValidation(runtime, { prompt: "validate", cwd: "/tmp" }); + expect(verdict.status).toBe(status); + }); + + it("truncated max_tokens stop with trailing pass JSON maps to error, not pass", async () => { + const { runtime } = validatorRuntime('partial answer {"verdict":"pass"}', { stopReason: "max_tokens" }); + const verdict = await runCliAgentValidation(runtime, { prompt: "validate", cwd: "/tmp" }); + expect(verdict.status).toBe("error"); + expect(verdict.summary).toContain("stopReason=max_tokens"); + }); + + it("prose all-pass with no JSON maps to error", async () => { + const { runtime } = validatorRuntime("All assertions pass."); + const verdict = await runCliAgentValidation(runtime, { prompt: "validate", cwd: "/tmp" }); + expect(verdict.status).toBe("error"); + }); + + it("empty or undecidable prose maps to error", async () => { + const { runtime } = validatorRuntime(""); + const verdict = await runCliAgentValidation(runtime, { prompt: "validate", cwd: "/tmp" }); + expect(verdict.status).toBe("error"); + }); + + it.each([ + ["This fails because the build is red.", "fail"], + ["Validation blocked by missing credentials.", "blocked"], + ])("uses constrained prose backstop for %s", async (text, status) => { + const { runtime } = validatorRuntime(text); + const verdict = await runCliAgentValidation(runtime, { prompt: "validate", cwd: "/tmp" }); + expect(verdict.status).toBe(status); }); it("runner failure surfaces as error verdict", async () => { - const fakeRun = async (): Promise => ({ - ok: false, - reason: "spawn-failed", - sessionId: null, - exitCode: null, - stderr: "", - message: "ENOENT claude", - }); - const verdict = await runCliAgentValidation( - { - manager: {} as RunOneShotOptions["manager"], - adapterId: "claude-code", - projectId: "p", - prompt: "validate", - cwd: "/tmp", - }, - fakeRun as never, - ); + const { runtime } = validatorRuntime("", { promptError: new Error("ENOENT claude") }); + const verdict = await runCliAgentValidation(runtime, { prompt: "validate", cwd: "/tmp" }); expect(verdict.status).toBe("error"); + expect(verdict.summary).toContain("ENOENT claude"); }); }); diff --git a/packages/engine/src/__tests__/interactive-ai-session.test.ts b/packages/engine/src/__tests__/interactive-ai-session.test.ts index 86cf97e3f5..207c89042e 100644 --- a/packages/engine/src/__tests__/interactive-ai-session.test.ts +++ b/packages/engine/src/__tests__/interactive-ai-session.test.ts @@ -6,55 +6,67 @@ import { type InteractiveAgentResult, type InteractiveAgentSession, } from "../interactive-ai-session.js"; -import type { - OneShotResult, - RunOneShotOptions, -} from "../cli-agent/one-shot-session.js"; +import type { AgentRuntime, AgentRuntimeOptions, AgentSessionResult } from "../agent-runtime.js"; +import type { AgentSession } from "@earendil-works/pi-coding-agent"; -describe("runCliAgentPlanning (U9 one-shot planning seam)", () => { - const baseOpts = { - manager: {} as RunOneShotOptions["manager"], - adapterId: "claude-code", - projectId: "p", - prompt: "plan it", - cwd: "/tmp", +function planningRuntime(text: string, options: { throwCreate?: Error; throwPrompt?: Error } = {}) { + const createOptions: AgentRuntimeOptions[] = []; + const session = { dispose: vi.fn() } as unknown as AgentSession; + const runtime: AgentRuntime = { + id: "acp", + name: "ACP Runtime", + async createSession(opts: AgentRuntimeOptions): Promise { + createOptions.push(opts); + if (options.throwCreate) throw options.throwCreate; + return { session }; + }, + async promptWithFallback(): Promise { + if (options.throwPrompt) throw options.throwPrompt; + createOptions[0]?.onText?.(text); + }, + describeModel() { + return "acp/test"; + }, }; + return { runtime, createOptions }; +} - it("maps one-shot output to the SAME PlanningResponse shape a model run produces", async () => { - let seenPurpose: string | undefined; - const fakeRun = async (opts: RunOneShotOptions): Promise => { - seenPurpose = opts.purpose; - const summary = { - title: "Do X", - description: "Plan to do X", - suggestedSize: "M", - suggestedDependencies: [], - keyDeliverables: ["X"], - }; - return { - ok: true, - sessionId: "s1", - parsed: {}, - text: JSON.stringify({ type: "complete", data: summary }), - rawOutput: "", - }; +describe("runCliAgentPlanning (ACP planning seam)", () => { + it("maps ACP prose with complete JSON to the SAME PlanningResponse shape a model run produces", async () => { + const summary = { + title: "Do X", + description: "Plan to do X", + suggestedSize: "M", + suggestedDependencies: [], + keyDeliverables: ["X"], }; - const resp: PlanningResponse = await runCliAgentPlanning(baseOpts, fakeRun as never); - expect(seenPurpose).toBe("planning"); + const { runtime, createOptions } = planningRuntime(`Here is the plan:\n${JSON.stringify({ type: "complete", data: summary })}`); + const resp: PlanningResponse = await runCliAgentPlanning(runtime, { + prompt: "plan it", + cwd: "/tmp", + settings: { model: "claude-sonnet-4" }, + }); expect(resp.type).toBe("complete"); if (resp.type === "complete") expect(resp.data.title).toBe("Do X"); + expect(createOptions[0]).toMatchObject({ tools: "readonly", defaultModelId: "claude-sonnet-4" }); }); - it("throws on a failed one-shot (never returns a fabricated plan)", async () => { - const fakeRun = async (): Promise => ({ - ok: false, - reason: "unparseable", - sessionId: "s1", - exitCode: 0, - stderr: "", - message: "no result", - }); - await expect(runCliAgentPlanning(baseOpts, fakeRun as never)).rejects.toThrow(/planning/i); + it("maps ACP prose with question JSON to a PlanningResponse question", async () => { + const question: PlanningQuestion = { id: "q1", type: "text", question: "What is the goal?" }; + const { runtime } = planningRuntime(`Need input: ${JSON.stringify({ type: "question", data: question })}`); + const resp = await runCliAgentPlanning(runtime, { prompt: "plan it", cwd: "/tmp" }); + expect(resp.type).toBe("question"); + if (resp.type === "question") expect(resp.data.id).toBe("q1"); + }); + + it("throws on a failed ACP ask (never returns a fabricated plan)", async () => { + const { runtime } = planningRuntime("", { throwPrompt: new Error("transport failed") }); + await expect(runCliAgentPlanning(runtime, { prompt: "plan it", cwd: "/tmp" })).rejects.toThrow(/planning ACP ask failed/i); + }); + + it("throws when ACP prose has no decodable planning JSON", async () => { + const { runtime } = planningRuntime("no structured answer"); + await expect(runCliAgentPlanning(runtime, { prompt: "plan it", cwd: "/tmp" })).rejects.toThrow(/no valid JSON/i); }); }); diff --git a/packages/engine/src/agent-runtime.ts b/packages/engine/src/agent-runtime.ts index a5db84f35c..e10891011a 100644 --- a/packages/engine/src/agent-runtime.ts +++ b/packages/engine/src/agent-runtime.ts @@ -108,6 +108,10 @@ export interface AgentRuntimeOptions { /** * Result of creating an agent session. */ +export interface AgentPromptResult { + stopReason?: string; +} + export interface AgentSessionResult { /** The created agent session */ session: AgentSession; @@ -153,7 +157,7 @@ export interface AgentRuntime { * @param prompt - The prompt text * @param options - Optional prompt options (e.g., images for vision) */ - promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise; + promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise; /** * Get a human-readable model description from a session. diff --git a/packages/engine/src/cli-agent-ask.ts b/packages/engine/src/cli-agent-ask.ts new file mode 100644 index 0000000000..fadcc31693 --- /dev/null +++ b/packages/engine/src/cli-agent-ask.ts @@ -0,0 +1,120 @@ +import type { AgentSession } from "@earendil-works/pi-coding-agent"; +import type { AgentRuntime } from "./agent-runtime.js"; +import { extractJsonObjects } from "./cli-agent/one-shot-session.js"; + +export type AskAcpOnceFailureReason = + | "create_session_failed" + | "turn_failed" + | "timeout" + | "abnormal_stop" + | "dispose_failed"; + +export type AskAcpOnceResult = + | { ok: true; text: string; parsed?: Record; stopReason?: string } + | { ok: false; reason: AskAcpOnceFailureReason; message: string; text?: string; stopReason?: string }; + +export interface AskAcpOnceOptions { + prompt: string; + cwd: string; + model?: string; + systemPrompt?: string; + timeoutMs?: number; + recoverJson?: boolean; +} + +function messageFromError(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function recoverTrailingJson(text: string): Record | undefined { + const objects = extractJsonObjects(text); + return objects.length > 0 ? objects[objects.length - 1] : undefined; +} + +function isCleanStop(stopReason: string | undefined): boolean { + return stopReason === undefined || stopReason === "end_turn"; +} + +async function disposeSession( + runtime: AgentRuntime, + session: AgentSession | undefined, +): Promise { + if (!session) return; + const runtimeWithDispose = runtime as AgentRuntime & { dispose?: (session: AgentSession) => Promise | void }; + if (typeof runtimeWithDispose.dispose === "function") { + await runtimeWithDispose.dispose(session); + return; + } + session.dispose(); +} + +export async function askAcpOnce(runtime: AgentRuntime, opts: AskAcpOnceOptions): Promise { + /* + FNXC:ACP-RouteB 2026-06-14-20:11: + Planning and validator Route-B seams need a one-turn ACP runner that preserves the previous one-shot shape while using readonly tools only. Accumulate streamed prose, optionally recover a trailing JSON object, and always dispose the ACP session. + */ + let text = ""; + let session: AgentSession | undefined; + try { + const created = await runtime.createSession({ + cwd: opts.cwd, + systemPrompt: opts.systemPrompt ?? "", + tools: "readonly", + defaultModelId: opts.model, + runtimeContext: { sessionPurpose: "cli-agent-ask", toolMode: "readonly" }, + onText: (delta) => { + text += delta; + }, + }); + session = created.session; + } catch (err) { + return { ok: false, reason: "create_session_failed", message: messageFromError(err), text }; + } + + let timeout: NodeJS.Timeout | undefined; + let timedOut = false; + try { + const promptPromise = runtime.promptWithFallback(session, opts.prompt).catch((err: unknown) => { + if (timedOut) return undefined; + throw err; + }); + const result = opts.timeoutMs && opts.timeoutMs > 0 + ? await Promise.race([ + promptPromise, + new Promise<"timeout">((resolve) => { + timeout = setTimeout(() => resolve("timeout"), opts.timeoutMs); + }), + ]) + : await promptPromise; + + if (result === "timeout") { + timedOut = true; + return { ok: false, reason: "timeout", message: `ACP prompt timed out after ${opts.timeoutMs}ms`, text }; + } + + const stopReason = typeof result === "object" && result && "stopReason" in result + ? String((result as { stopReason?: unknown }).stopReason ?? "") || undefined + : undefined; + if (!isCleanStop(stopReason)) { + return { + ok: false, + reason: "abnormal_stop", + message: `ACP prompt ended with stopReason=${stopReason}`, + text, + stopReason, + }; + } + + const parsed = opts.recoverJson ? recoverTrailingJson(text) : undefined; + return { ok: true, text, ...(parsed ? { parsed } : {}), ...(stopReason ? { stopReason } : {}) }; + } catch (err) { + return { ok: false, reason: "turn_failed", message: messageFromError(err), text }; + } finally { + if (timeout) clearTimeout(timeout); + try { + await disposeSession(runtime, session); + } catch { + // The turn result is more useful than a best-effort disposal error. Runtimes also own process registries. + } + } +} diff --git a/packages/engine/src/cli-agent-validator.ts b/packages/engine/src/cli-agent-validator.ts index 8f950f2dfb..398b6a5990 100644 --- a/packages/engine/src/cli-agent-validator.ts +++ b/packages/engine/src/cli-agent-validator.ts @@ -10,11 +10,9 @@ * downstream from a model-executed validation run. */ -import type { - OneShotResult, - RunOneShotOptions, - runOneShotSession as RunOneShotFn, -} from "./cli-agent/one-shot-session.js"; +import type { OneShotResult } from "./cli-agent/one-shot-session.js"; +import type { AgentRuntime } from "./agent-runtime.js"; +import { askAcpOnce } from "./cli-agent-ask.js"; /** The validator verdict contract shared with model-executed runs. */ export interface ValidatorVerdict { @@ -36,7 +34,6 @@ interface ParsedVerdictShape { result?: unknown; passed?: unknown; blocked?: unknown; - is_error?: unknown; summary?: unknown; reason?: unknown; assertions?: unknown; @@ -58,9 +55,8 @@ export function normalizeVerdictToken(token: string): ValidatorVerdict["status"] * Precedence: * 1. explicit `verdict`/`status` string token (normalized) * 2. boolean `passed` (true→pass, false→fail) and `blocked === true` - * 3. `is_error === true` → error (claude-shaped) - * 4. prose inference from the result text - * 5. nothing decodable → error (NEVER pass) + * 3. prose inference from the result text (fail/blocked only; never pass) + * 4. nothing decodable → error (NEVER pass) */ export function mapParsedToVerdict( parsed: Record, @@ -74,11 +70,6 @@ export function mapParsedToVerdict( ""; const assertions = parseAssertions(p.assertions); - // 3 (early): an adapter error flag is authoritative. - if (p.is_error === true) { - return { status: "error", assertions, summary: summary || "Adapter reported an error" }; - } - // 2: explicit blocked flag. if (p.blocked === true) { return { @@ -106,11 +97,15 @@ export function mapParsedToVerdict( return { status: p.passed ? "pass" : "fail", assertions, summary }; } - // 4: prose inference. + // 3: prose inference. R15: prose may never infer pass. + /* + FNXC:ACP-RouteB 2026-06-14-20:28: + Route-B validation cannot silently pass from prose. A pass is authoritative only when recovered structured JSON says verdict=pass or passed=true; prose fallback is limited to fail/blocked signals and undecidable text maps to error. + */ const inferred = inferVerdictFromProse(text); if (inferred) return { status: inferred, assertions, summary: summary || text }; - // 5: undecidable → error, never a silent pass. + // 4: undecidable → error, never a silent pass. return { status: "error", assertions, @@ -145,7 +140,6 @@ export function inferVerdictFromProse(text: string): ValidatorVerdict["status"] const t = text.toLowerCase(); if (/\bblocked\b/.test(t)) return "blocked"; if (/\b(revise|revision requested|does not (pass|meet)|fail(s|ed)?\b)/.test(t)) return "fail"; - if (/\b(all (assertions|checks) pass|validation pass(ed)?|approve(d)?)\b/.test(t)) return "pass"; return null; } @@ -174,10 +168,39 @@ export function oneShotResultToVerdict(result: OneShotResult): ValidatorVerdict * a live PTY (tests pass a stubbed runner; production passes * `runOneShotSession`). */ -export async function runCliAgentValidation( - opts: Omit, - run: typeof RunOneShotFn, -): Promise { - const result = await run({ ...opts, purpose: "validator" }); - return oneShotResultToVerdict(result); +export interface CliAgentValidationOptions { + prompt: string; + cwd: string; + settings?: { model?: string }; + systemPrompt?: string; + timeoutMs?: number; +} + +const VALIDATOR_SYSTEM_PROMPT = [ + "You are a strict Fusion validation agent.", + "Evaluate the requested assertions and end your response with exactly one JSON object:", + '{ "verdict": "pass|fail|blocked|error", "summary": "...", "assertions": [] }', + "Do not report pass unless every required assertion is satisfied.", +].join("\n"); + +export async function runCliAgentValidation( + runtime: AgentRuntime, + opts: CliAgentValidationOptions, +): Promise { + const result = await askAcpOnce(runtime, { + prompt: opts.prompt, + cwd: opts.cwd, + model: opts.settings?.model, + systemPrompt: opts.systemPrompt ?? VALIDATOR_SYSTEM_PROMPT, + timeoutMs: opts.timeoutMs, + recoverJson: true, + }); + if (!result.ok) { + return { + status: "error", + assertions: [], + summary: `${result.message}${result.text ? `\n--- output tail ---\n${result.text.slice(-4000)}` : ""}`, + }; + } + return mapParsedToVerdict(result.parsed ?? {}, result.text); } diff --git a/packages/engine/src/cli-agent/__tests__/one-shot-session.test.ts b/packages/engine/src/cli-agent/__tests__/one-shot-session.test.ts index 9577d7c149..a0ae5e6b8c 100644 --- a/packages/engine/src/cli-agent/__tests__/one-shot-session.test.ts +++ b/packages/engine/src/cli-agent/__tests__/one-shot-session.test.ts @@ -163,8 +163,7 @@ async function runWith( } describe("one-shot session output parsing", () => { - it("buildOneShotSettings carries each adapter's documented non-interactive args", () => { - expect(buildOneShotSettings("claude-code", "P").oneShotArgs).toEqual(["-p", "P"]); + it("buildOneShotSettings carries each supported adapter's documented non-interactive args", () => { expect(buildOneShotSettings("codex", "P").oneShotArgs).toEqual(["exec", "--json", "P"]); expect(buildOneShotSettings("droid", "P").oneShotArgs).toEqual([ "exec", @@ -181,11 +180,8 @@ describe("one-shot session output parsing", () => { expect(extractJsonObjects("no json here")).toEqual([]); }); - it("parseOneShotOutput picks the claude result frame", () => { - const out = '{"type":"system"}\n{"type":"result","result":"done","is_error":false}'; - const parsed = parseOneShotOutput("claude-code", out); - expect(parsed?.text).toBe("done"); - expect(parsed?.parsed.type).toBe("result"); + it("claude-code no longer has a supported -p one-shot path", () => { + expect(buildOneShotSettings("claude-code", "P").oneShotArgs).toEqual([]); }); it("boundedStderrTail caps very long output", () => { @@ -213,12 +209,12 @@ describe("one-shot session lifecycle", () => { } it("creates a read-only session record, streams terminal output, reaps on completion", async () => { - const h = newHarness(["claude-code"]); + const h = newHarness(["codex"]); let captured: CliSession | null = null; const result = await (async () => { const promise = runOneShotSession({ manager: h.manager, - adapterId: "claude-code", + adapterId: "codex", projectId: "proj-1", purpose: "validator", prompt: "p", @@ -229,7 +225,7 @@ describe("one-shot session lifecycle", () => { // While live, the session record exists and is read-only, terminal streams. const sessions = h.store.listSessions({ projectId: "proj-1" }); captured = sessions[0] ?? null; - pty.emitData('{"type":"result","result":"ok","is_error":false}'); + pty.emitData('{"text":"ok"}'); pty.emitExit(0); return promise; })(); diff --git a/packages/engine/src/cli-agent/one-shot-session.ts b/packages/engine/src/cli-agent/one-shot-session.ts index 7d9c58270f..7753d6e8bf 100644 --- a/packages/engine/src/cli-agent/one-shot-session.ts +++ b/packages/engine/src/cli-agent/one-shot-session.ts @@ -2,8 +2,8 @@ * One-shot CLI agent sessions (CLI Agent Executor, U9). * * A *one-shot* session runs an adapter's NON-INTERACTIVE invocation - * (`claude -p`, `codex exec --json`, `droid exec --output-format json`, - * `pi --print`) to completion in a working directory, streams its output to a + * (`codex exec --json`, `droid exec --output-format json`, `pi --print`) to + * completion in a working directory, streams its output to a * read-only terminal (so U10's attach surface works exactly as for interactive * sessions — but with input disabled server-side), collects the output, parses * the adapter's structured (JSON) result, and returns a typed result. @@ -64,9 +64,6 @@ export function buildOneShotSettings( // The non-interactive arg sets are documented in each adapter file. We carry // them as explicit extraArgs so the session manager forwards them to spawn. switch (adapterId) { - case "claude-code": - settings.oneShotArgs = ["-p", prompt]; - break; case "codex": settings.oneShotArgs = ["exec", "--json", prompt]; break; @@ -136,16 +133,6 @@ export function parseOneShotOutput( if (objects.length === 0) return null; switch (adapterId) { - case "claude-code": { - // `claude -p --output-format json` (or stream-json) → a result object - // with `{ type: "result", result | text, is_error }`. Prefer the final - // result frame. - const result = - objects.find((o) => o.type === "result") ?? objects[objects.length - 1]; - const text = - pickString(result, ["result", "text", "content", "message"]) ?? ""; - return { parsed: result, text }; - } case "codex": { // `codex exec --json` emits a stream of JSON events; the final // agent/assistant message carries the answer. diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index c5fde1d995..406b20e196 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -611,7 +611,13 @@ export { type RunCodeNodeOptions, } from "./code-node-runner.js"; // Agent runtime abstraction -export { type AgentRuntime, type AgentRuntimeOptions, type AgentSessionResult } from "./agent-runtime.js"; +export { + type AgentPromptResult, + type AgentRuntime, + type AgentRuntimeOptions, + type AgentSessionResult, +} from "./agent-runtime.js"; +export { askAcpOnce, type AskAcpOnceOptions, type AskAcpOnceResult } from "./cli-agent-ask.js"; export { resolveRuntime, getDefaultPiRuntime, diff --git a/packages/engine/src/interactive-ai-session.ts b/packages/engine/src/interactive-ai-session.ts index 890c5b78da..b0aefb8376 100644 --- a/packages/engine/src/interactive-ai-session.ts +++ b/packages/engine/src/interactive-ai-session.ts @@ -21,6 +21,8 @@ import type { PlanningQuestion, PlanningResponse, } from "@fusion/core"; +import type { AgentRuntime } from "./agent-runtime.js"; +import { askAcpOnce } from "./cli-agent-ask.js"; /** Minimal shape of an agent session we depend on (subset of pi's AgentSession). */ export interface InteractiveAgentSession { @@ -203,22 +205,31 @@ export function parseAgentResponse(text: string): PlanningResponse { * loop's executor resolution remains TODO when planning gains a CLI executor * selector. */ +export interface CliAgentPlanningOptions { + prompt: string; + cwd: string; + settings?: { model?: string }; + systemPrompt?: string; + timeoutMs?: number; +} + export async function runCliAgentPlanning( - opts: Omit< - import("./cli-agent/one-shot-session.js").RunOneShotOptions, - "purpose" - >, - run: typeof import("./cli-agent/one-shot-session.js").runOneShotSession, + runtime: AgentRuntime, + opts: CliAgentPlanningOptions, ): Promise { - const result = await run({ ...opts, purpose: "planning" }); + const result = await askAcpOnce(runtime, { + prompt: opts.prompt, + cwd: opts.cwd, + model: opts.settings?.model, + systemPrompt: opts.systemPrompt, + timeoutMs: opts.timeoutMs, + }); if (!result.ok) { - throw new Error( - `CLI-agent planning one-shot failed (${result.reason}): ${result.message}`, - ); + throw new Error(`CLI-agent planning ACP ask failed (${result.reason}): ${result.message}`); } // Map to the planning flow's shape exactly as a model run would: parse the - // adapter's textual result through the canonical planning parser. - return parseAgentResponse(result.text || result.rawOutput); + // ACP prose through the canonical planning parser. + return parseAgentResponse(result.text); } /** Extract text from the last assistant message (string | text blocks | thinking fallback). */ diff --git a/plugins/fusion-plugin-acp-runtime/AGENTS.md b/plugins/fusion-plugin-acp-runtime/AGENTS.md new file mode 100644 index 0000000000..65b893813b --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/AGENTS.md @@ -0,0 +1,14 @@ +# ACP Runtime Plugin Notes + +## External Integration Evidence + +`claude-code-cli-acp` is a bundled third-party bridge used by the Claude Route-B ask path. + +- Canonical upstream repo URL: https://github.com/moabualruz/claude-code-cli-acp +- Docs / homepage URL: https://github.com/moabualruz/claude-code-cli-acp#readme +- Release / download URL: npm package `claude-code-cli-acp` (version `0.1.1`) — https://www.npmjs.com/package/claude-code-cli-acp +- Binary / CLI name: `claude-code-cli-acp` +- Checksum: `sha512-qpfRGOXkOs9mqI7oumsGistWisyXcCC0r7ng7wdLvGMIORdzHjmUUa+94Jftgr/NYAVnAUe6N7kimD8PaO3D5g==` (from `pnpm-lock.yaml` for `claude-code-cli-acp@0.1.1`) +- Pinned-commit spot-review: tag `v0.1.1` points to commit `c93f4f4ca449f451d9f3b7db536caf4060883da9` (annotated tag `ca33404fc1128d6a88a55b248f042f70b4bc9f9a`, unsigned). License Apache-2.0; reviewed behavior is that the bridge runs `claude` through a PTY, reads transcript JSONL, exposes an ACP server over stdio, and requires `@anthropic-ai/claude-code` installed + authenticated. + +Do not replace this with a PATH-resolved binary for the bundled Claude profile; tests and setup should reject substitutes outside the plugin-owned `node_modules` tree. diff --git a/plugins/fusion-plugin-acp-runtime/CHANGELOG.md b/plugins/fusion-plugin-acp-runtime/CHANGELOG.md index 3c175a3de0..91b77e176b 100644 --- a/plugins/fusion-plugin-acp-runtime/CHANGELOG.md +++ b/plugins/fusion-plugin-acp-runtime/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion-plugin-examples/acp-runtime +## Next + +### Minor Changes + +- Pin the bundled `claude-code-cli-acp@0.1.1` bridge for Route-B readonly Claude asks, add setup/probe guidance, and surface ACP `stopReason` for validator no-silent-pass enforcement. + ## 0.1.6 ### Patch Changes diff --git a/plugins/fusion-plugin-acp-runtime/README.md b/plugins/fusion-plugin-acp-runtime/README.md index aab2331b72..e48de0dae9 100644 --- a/plugins/fusion-plugin-acp-runtime/README.md +++ b/plugins/fusion-plugin-acp-runtime/README.md @@ -59,8 +59,15 @@ Per `AGENTS.md` (External-integration evidence): - **Pinned release:** `0.24.0` (Apache-2.0) - **Tarball:** https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.24.0.tgz - **Integrity (sha512):** `sha512-vvu9appvGvfYstBj19C6NCepV6SvUhY5VRv60KUZ4XzhTah/olOYul5Zo4C+x2enyshMSvgB2mm/OEmrsHaSmA==` -- **Agent binaries driven:** user-supplied ACP agents (e.g. `gemini --acp`, the - `@agentclientprotocol/claude-agent-acp` adapter). These are configured by the - user at runtime, not bundled — `upstream-pending-verification` per agent. +- **Agent binaries driven:** user-supplied ACP agents (e.g. `gemini --acp`) and the bundled Claude bridge below. User-configured agents remain `upstream-pending-verification` per agent. + +### Bundled Claude ACP bridge evidence + +- **Canonical upstream repo URL:** https://github.com/moabualruz/claude-code-cli-acp +- **Docs / homepage URL:** https://github.com/moabualruz/claude-code-cli-acp#readme +- **Release / download URL:** npm package `claude-code-cli-acp` (version `0.1.1`) — https://www.npmjs.com/package/claude-code-cli-acp +- **Binary / CLI name:** `claude-code-cli-acp` +- **Checksum:** `sha512-qpfRGOXkOs9mqI7oumsGistWisyXcCC0r7ng7wdLvGMIORdzHjmUUa+94Jftgr/NYAVnAUe6N7kimD8PaO3D5g==` (from `pnpm-lock.yaml` for `claude-code-cli-acp@0.1.1`) +- **Pinned-commit spot-review:** tag `v0.1.1` points to commit `c93f4f4ca449f451d9f3b7db536caf4060883da9` (annotated tag `ca33404fc1128d6a88a55b248f042f70b4bc9f9a`, unsigned). License: Apache-2.0. Behavior reviewed for this integration: runs `claude` through a PTY, reads transcript JSONL, exposes an ACP server over stdio, and requires `@anthropic-ai/claude-code` installed + authenticated. See `docs/acp-contract.md` for the launch/readiness contract and failure taxonomy. diff --git a/plugins/fusion-plugin-acp-runtime/package.json b/plugins/fusion-plugin-acp-runtime/package.json index 6747d63de9..cee17ccb96 100644 --- a/plugins/fusion-plugin-acp-runtime/package.json +++ b/plugins/fusion-plugin-acp-runtime/package.json @@ -28,7 +28,8 @@ "dependencies": { "@agentclientprotocol/sdk": "0.24.0", "@fusion/core": "workspace:*", - "@fusion/plugin-sdk": "workspace:*" + "@fusion/plugin-sdk": "workspace:*", + "claude-code-cli-acp": "0.1.1" }, "peerDependencies": { "@earendil-works/pi-ai": "*", 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 6a9cda345e..331e5967e1 100644 --- a/plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts @@ -1,5 +1,14 @@ import { describe, it, expect, afterEach } from "vitest"; -import plugin, { AcpRuntimeAdapter, acpRuntimeFactory, acpRuntimeMetadata, resolveCliSettings } from "../index.js"; +import { isAbsolute } from "node:path"; +import plugin, { + AcpRuntimeAdapter, + CLAUDE_CODE_CLI_ACP_BINARY, + acpRuntimeFactory, + acpRuntimeMetadata, + resolveBundledClaudeBridgeBinary, + resolveClaudeBridgeAskSettings, + resolveCliSettings, +} from "../index.js"; import { killAllProcesses } from "../process-manager.js"; import type { AgentRuntime } from "../types.js"; @@ -59,6 +68,7 @@ describe("resolveCliSettings", () => { expect(s.fsWrite).toBe(false); // env allow-list empty by default (KTD6b) — no inherited process.env. expect(s.envAllowList).toEqual([]); + expect(s.requiredEnv).toEqual([]); // Risk S1 acknowledgement is off by default (safe). expect(s.allowUnrestricted).toBe(false); }); @@ -82,5 +92,44 @@ describe("resolveCliSettings", () => { expect(s.fsRead).toBe(true); expect(s.fsWrite).toBe(false); expect(s.envAllowList).toEqual(["HOME", "PATH"]); + expect(s.requiredEnv).toEqual([]); + }); + + it("resolves the bundled Claude ACP bridge sentinel to an absolute plugin binary", () => { + const s = resolveCliSettings({ acpBinaryPath: CLAUDE_CODE_CLI_ACP_BINARY }); + expect(s.binaryResolution).toMatchObject({ kind: "resolved", requested: CLAUDE_CODE_CLI_ACP_BINARY }); + expect(s.binaryPath).toContain("plugins/fusion-plugin-acp-runtime/node_modules/.bin/claude-code-cli-acp"); + expect(isAbsolute(s.binaryPath)).toBe(true); + }); + + it("reports a deterministic missing bundled bridge without throwing mid-spawn", () => { + const resolution = resolveBundledClaudeBridgeBinary({ + pluginRoot: "/tmp/fusion-plugin-acp-runtime-missing", + exists: () => false, + }); + expect(resolution).toMatchObject({ kind: "not_resolved", requested: CLAUDE_CODE_CLI_ACP_BINARY }); + expect(resolution.path).toContain("node_modules/.bin/claude-code-cli-acp"); + }); + + it("does not replace an explicit ACP binary override with the bundled bridge", () => { + const s = resolveCliSettings({ acpBinaryPath: "/opt/acp/custom-agent", acpArgs: ["--stdio"] }); + expect(s.binaryPath).toBe("/opt/acp/custom-agent"); + expect(s.binaryResolution).toBeUndefined(); + expect(s.args).toEqual(["--stdio"]); + }); + + it("builds a read-only Claude bridge ask profile without changing generic ACP defaults", () => { + const generic = resolveCliSettings(undefined); + const ask = resolveClaudeBridgeAskSettings({ acpModel: "claude-sonnet-4" }); + + expect(generic.binaryPath).toBe("acp-agent"); + expect(ask.binaryPath).toContain("plugins/fusion-plugin-acp-runtime/node_modules/.bin/claude-code-cli-acp"); + expect(ask.args).toEqual([]); + expect(ask.fsRead).toBe(false); + expect(ask.fsWrite).toBe(false); + expect(ask.model).toBe("claude-sonnet-4"); + expect(ask.envAllowList).toEqual(["HOME", "PATH"]); + expect(ask.requiredEnv).toEqual(["HOME"]); + expect(ask.allowUnrestricted).toBe(false); }); }); diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/process-manager.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/process-manager.test.ts index 9944ffe7d3..f74c6678db 100644 --- a/plugins/fusion-plugin-acp-runtime/src/__tests__/process-manager.test.ts +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/process-manager.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, afterEach } from "vitest"; import { spawn, type ChildProcess } from "node:child_process"; import { + MissingAcpEnvError, buildSpawnEnv, redactSecrets, captureStderr, @@ -47,15 +48,44 @@ describe("buildSpawnEnv (KTD6b allow-list)", () => { it("copies only allow-listed vars and excludes secret vars", () => { process.env.ACP_TEST_ALLOWED = "ok"; process.env.ACP_TEST_SECRET = "leak-me"; + process.env.ANTHROPIC_API_KEY = "do-not-forward"; + process.env.ANTHROPIC_AUTH_TOKEN = "do-not-forward"; try { const env = buildSpawnEnv(["ACP_TEST_ALLOWED"]); - expect(env.ACP_TEST_ALLOWED).toBe("ok"); + expect(env).toEqual({ ACP_TEST_ALLOWED: "ok" }); expect(env.ACP_TEST_SECRET).toBeUndefined(); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); } finally { delete process.env.ACP_TEST_ALLOWED; delete process.env.ACP_TEST_SECRET; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_AUTH_TOKEN; } }); + + it("builds the Claude bridge env from exactly HOME and PATH", () => { + const env = buildSpawnEnv(["HOME", "PATH"], { + required: ["HOME"], + sourceEnv: { + HOME: "/Users/tester", + PATH: "/usr/bin", + ANTHROPIC_API_KEY: "do-not-forward", + ANTHROPIC_AUTH_TOKEN: "do-not-forward", + EXTRA_SECRET: "do-not-forward", + }, + }); + expect(env).toEqual({ HOME: "/Users/tester", PATH: "/usr/bin" }); + }); + + it("rejects the Claude bridge env when HOME is missing", () => { + expect(() => + buildSpawnEnv(["HOME", "PATH"], { + required: ["HOME"], + sourceEnv: { PATH: "/usr/bin" }, + }), + ).toThrow(MissingAcpEnvError); + }); }); describe("redactSecrets (Risk S8)", () => { diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/runtime-adapter.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/runtime-adapter.test.ts index b62f8e534f..94c1b63742 100644 --- a/plugins/fusion-plugin-acp-runtime/src/__tests__/runtime-adapter.test.ts +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/runtime-adapter.test.ts @@ -58,11 +58,11 @@ describe("AcpRuntimeAdapter (U3)", () => { } }); - it("promptWithFallback drives a full turn to completion", async () => { + it("promptWithFallback drives a full turn to completion and surfaces stopReason", async () => { const adapter = makeAdapter(); const { session } = await adapter.createSession(makeOptions()); try { - await expect(adapter.promptWithFallback(session, "hello")).resolves.toBeUndefined(); + await expect(adapter.promptWithFallback(session, "hello")).resolves.toEqual({ stopReason: "end_turn" }); } finally { await adapter.dispose(session); } diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/setup.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/setup.test.ts new file mode 100644 index 0000000000..fd2fb73fc0 --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/setup.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { join } from "node:path"; +import { checkSetup, setupManifest, validateBundledBridgeIdentity } from "../setup.js"; +import { CLAUDE_CODE_CLI_ACP_BINARY, bundledClaudeBridgeBinPath } from "../cli-spawn.js"; +import type { AcpProbeStatus, ProbeOptions } from "../probe.js"; + +function ctx(settings: Record = {}) { + return { settings } as never; +} + +function probe(status: AcpProbeStatus) { + return async (_opts: ProbeOptions) => status; +} + +describe("ACP setup manifest", () => { + it("describes the bundled Claude bridge", () => { + expect(setupManifest.binaryName).toBe(CLAUDE_CODE_CLI_ACP_BINARY); + expect(setupManifest.channel).toBe("beta"); + }); +}); + +describe("validateBundledBridgeIdentity", () => { + it("accepts the plugin-owned node_modules bin shim", () => { + expect(validateBundledBridgeIdentity(bundledClaudeBridgeBinPath())).toBeUndefined(); + }); + + it("rejects a PATH-resolved substitute outside plugin node_modules", () => { + const pluginRoot = "/repo/plugins/fusion-plugin-acp-runtime"; + const err = validateBundledBridgeIdentity("/usr/local/bin/claude-code-cli-acp", pluginRoot); + expect(err).toContain("must come from this plugin's node_modules"); + }); + + it("accepts nested package files inside plugin node_modules", () => { + const pluginRoot = "/repo/plugins/fusion-plugin-acp-runtime"; + const packageBin = join(pluginRoot, "node_modules", "claude-code-cli-acp", "bin", "claude-code-cli-acp.js"); + expect(validateBundledBridgeIdentity(packageBin, pluginRoot)).toBeUndefined(); + }); +}); + +describe("checkSetup", () => { + it("reports installed when the bridge handshakes", async () => { + const result = await checkSetup(ctx(), { probe: probe({ ok: true, reason: "ok", authRequired: false }) }); + expect(result.status).toBe("installed"); + expect(result.binaryPath).toContain("claude-code-cli-acp"); + }); + + it("maps missing_binary to a not-installed install hint", async () => { + const result = await checkSetup(ctx(), { + probe: probe({ ok: false, reason: "missing_binary", detail: "ENOENT" }), + }); + expect(result.status).toBe("not-installed"); + expect(result.error).toContain("Install bundled dependency"); + }); + + it("maps authRequired ok status to the claude auth hint", async () => { + const result = await checkSetup(ctx(), { probe: probe({ ok: true, reason: "ok", authRequired: true }) }); + expect(result.status).toBe("error"); + expect(result.error).toContain("run `claude` once to authenticate"); + }); + + it("maps handshake_timeout and incompatible_protocol to distinct errors", async () => { + const timeout = await checkSetup(ctx(), { + probe: probe({ ok: false, reason: "handshake_timeout", detail: "initialize timed out" }), + }); + const incompatible = await checkSetup(ctx(), { + probe: probe({ ok: false, reason: "incompatible_protocol", detail: "protocol 999", protocolVersion: 999 }), + }); + expect(timeout).toMatchObject({ status: "error", error: "initialize timed out" }); + expect(incompatible).toMatchObject({ status: "error", error: "protocol 999" }); + }); +}); diff --git a/plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts b/plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts index f1aad70cb8..79d4372ef5 100644 --- a/plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts +++ b/plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts @@ -6,6 +6,19 @@ // an arbitrary binary + args, plus the conservative-by-default fs capability // toggles (KTD6: writes default OFF) and an env allow-list (KTD6b). +import { existsSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const CLAUDE_CODE_CLI_ACP_BINARY = "claude-code-cli-acp"; + +export interface AcpBinaryResolution { + kind: "resolved" | "not_resolved"; + requested: string; + path?: string; + reason?: string; +} + export interface AcpCliSettings { /** Agent binary to spawn (e.g. "gemini", "npx", an absolute path). */ binaryPath: string; @@ -23,6 +36,8 @@ export interface AcpCliSettings { * default — callers opt specific vars in by name. */ envAllowList: string[]; + /** Env allow-list entries that must be present before spawning this profile. */ + requiredEnv: string[]; /** * Risk S1 acknowledgement. The shipped default permission policy is * `unrestricted` (every category → allow). Because the ACP agent is an @@ -33,6 +48,8 @@ export interface AcpCliSettings { * Default: false (safe). */ allowUnrestricted: boolean; + /** Bundled bridge resolution status when `acpBinaryPath` asks for it. */ + binaryResolution?: AcpBinaryResolution; } function asTrimmedString(value: unknown): string | undefined { @@ -49,13 +66,87 @@ function asBool(value: unknown): boolean { return value === true; } +function pluginRootDir(): string { + return resolve(dirname(fileURLToPath(import.meta.url)), ".."); +} + +export interface ResolveBundledClaudeBridgeOptions { + pluginRoot?: string; + exists?: (path: string) => boolean; +} + +export function bundledClaudeBridgeBinPath(pluginRoot = pluginRootDir()): string { + const extension = process.platform === "win32" ? ".cmd" : ""; + return join(pluginRoot, "node_modules", ".bin", `${CLAUDE_CODE_CLI_ACP_BINARY}${extension}`); +} + +export function resolveBundledClaudeBridgeBinary( + options: ResolveBundledClaudeBridgeOptions = {}, +): AcpBinaryResolution { + const root = options.pluginRoot ?? pluginRootDir(); + const exists = options.exists ?? existsSync; + const candidate = bundledClaudeBridgeBinPath(root); + /* + FNXC:ACP-RouteB 2026-06-14-19:47: + The Claude ACP bridge is a pinned plugin dependency, not a PATH-selected executable. Resolve the sentinel to the plugin-owned node_modules/.bin shim so a same-named global binary cannot replace the reviewed bridge. + */ + if (!exists(candidate)) { + return { + kind: "not_resolved", + requested: CLAUDE_CODE_CLI_ACP_BINARY, + path: candidate, + reason: `Bundled ${CLAUDE_CODE_CLI_ACP_BINARY} binary was not found at ${candidate}`, + }; + } + if (!isAbsolute(candidate)) { + return { + kind: "not_resolved", + requested: CLAUDE_CODE_CLI_ACP_BINARY, + path: candidate, + reason: `Bundled ${CLAUDE_CODE_CLI_ACP_BINARY} path is not absolute`, + }; + } + return { kind: "resolved", requested: CLAUDE_CODE_CLI_ACP_BINARY, path: candidate }; +} + export function resolveCliSettings(settings?: Record): AcpCliSettings { - const binaryPath = asTrimmedString(settings?.acpBinaryPath) ?? "acp-agent"; + const requestedBinaryPath = asTrimmedString(settings?.acpBinaryPath); + let binaryPath = requestedBinaryPath ?? "acp-agent"; + let binaryResolution: AcpBinaryResolution | undefined; + if (requestedBinaryPath === CLAUDE_CODE_CLI_ACP_BINARY) { + binaryResolution = resolveBundledClaudeBridgeBinary(); + if (binaryResolution.kind === "resolved" && binaryResolution.path) { + binaryPath = binaryResolution.path; + } + } 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) ?? []; const allowUnrestricted = asBool(settings?.acpAllowUnrestricted); - return { binaryPath, args, model, fsRead, fsWrite, envAllowList, allowUnrestricted }; + return { + binaryPath, + args, + model, + fsRead, + fsWrite, + envAllowList, + requiredEnv: [], + allowUnrestricted, + binaryResolution, + }; +} + +export function resolveClaudeBridgeAskSettings(settings?: Record): AcpCliSettings { + const resolved = resolveCliSettings({ + ...settings, + acpBinaryPath: CLAUDE_CODE_CLI_ACP_BINARY, + acpArgs: [], + acpFsRead: false, + acpFsWrite: false, + acpEnvAllowList: ["HOME", "PATH"], + acpAllowUnrestricted: false, + }); + return { ...resolved, requiredEnv: ["HOME"] }; } diff --git a/plugins/fusion-plugin-acp-runtime/src/index.ts b/plugins/fusion-plugin-acp-runtime/src/index.ts index 19468c9b2d..057aaadd84 100644 --- a/plugins/fusion-plugin-acp-runtime/src/index.ts +++ b/plugins/fusion-plugin-acp-runtime/src/index.ts @@ -3,6 +3,7 @@ import type { FusionPlugin, PluginRuntimeFactory, PluginRuntimeManifestMetadata import { resolveCliSettings } from "./cli-spawn.js"; import { AcpRuntimeAdapter } from "./runtime-adapter.js"; import { killAllProcesses } from "./process-manager.js"; +import { setupHooks, setupManifest } from "./setup.js"; // Reap any live agent subprocesses on hard process exit so none are orphaned // (KTD4 — the registry SIGKILL is the authoritative no-orphan guarantee). Scoped @@ -54,9 +55,20 @@ const plugin: FusionPlugin = definePlugin({ metadata: acpRuntimeMetadata, factory: acpRuntimeFactory, }, + setup: { + manifest: setupManifest, + hooks: setupHooks, + }, }); export default plugin; export { AcpRuntimeAdapter }; -export { resolveCliSettings } from "./cli-spawn.js"; -export type { AcpCliSettings } from "./cli-spawn.js"; +export { checkSetup, setupHooks, setupManifest, validateBundledBridgeIdentity } from "./setup.js"; +export { + CLAUDE_CODE_CLI_ACP_BINARY, + bundledClaudeBridgeBinPath, + resolveBundledClaudeBridgeBinary, + resolveClaudeBridgeAskSettings, + resolveCliSettings, +} from "./cli-spawn.js"; +export type { AcpBinaryResolution, AcpCliSettings } from "./cli-spawn.js"; diff --git a/plugins/fusion-plugin-acp-runtime/src/process-manager.ts b/plugins/fusion-plugin-acp-runtime/src/process-manager.ts index 8638c48a87..3d75e68dd8 100644 --- a/plugins/fusion-plugin-acp-runtime/src/process-manager.ts +++ b/plugins/fusion-plugin-acp-runtime/src/process-manager.ts @@ -69,6 +69,19 @@ export function killAllProcesses(): void { activeProcesses.clear(); } +export class MissingAcpEnvError extends Error { + readonly code = "ACP_MISSING_ENV"; + constructor(readonly missingKeys: string[]) { + super(`Missing required ACP environment variable(s): ${missingKeys.join(", ")}`); + this.name = "MissingAcpEnvError"; + } +} + +export interface BuildSpawnEnvOptions { + required?: string[]; + sourceEnv?: NodeJS.ProcessEnv; +} + /** * Build the subprocess environment from an explicit allow-list (KTD6b). * @@ -76,12 +89,21 @@ export function killAllProcesses(): void { * never inherited — the agent is untrusted and must not receive secret-bearing * vars. Returns an empty env by default (empty allow-list). */ -export function buildSpawnEnv(allowList: string[]): NodeJS.ProcessEnv { +export function buildSpawnEnv(allowList: string[], options: BuildSpawnEnvOptions = {}): NodeJS.ProcessEnv { + /* + FNXC:ACP-RouteB 2026-06-14-19:52: + Claude bridge subprocesses may receive HOME so the real `claude` can read ~/.claude auth and PATH so the bridge can locate sub-executables. Do not forward ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, or inherited process.env because the bridge is an untrusted external process. + */ + const sourceEnv = options.sourceEnv ?? process.env; const env: NodeJS.ProcessEnv = {}; for (const key of allowList) { - const value = process.env[key]; + const value = sourceEnv[key]; if (typeof value === "string") env[key] = value; } + const missing = (options.required ?? []).filter((key) => typeof env[key] !== "string"); + if (missing.length > 0) { + throw new MissingAcpEnvError(missing); + } return env; } diff --git a/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts index bafea4ff3a..97f7ccbd2c 100644 --- a/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts +++ b/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts @@ -77,7 +77,7 @@ export class AcpRuntimeAdapter implements AgentRuntime { binaryPath: this.settings.binaryPath, args: this.settings.args, cwd: options.cwd, - env: buildSpawnEnv(this.settings.envAllowList), + env: buildSpawnEnv(this.settings.envAllowList, { required: this.settings.requiredEnv }), advertiseFs: { read: this.settings.fsRead, write: this.settings.fsWrite }, clientHandler, }); @@ -125,7 +125,7 @@ export class AcpRuntimeAdapter implements AgentRuntime { session: AgentSession, prompt: string, _options?: unknown, - ): Promise { + ): Promise<{ stopReason?: string }> { const acp = session as AcpSession; if (!acp.connection) { throw new Error("ACP session has no live connection (createSession not completed)"); @@ -140,7 +140,12 @@ export class AcpRuntimeAdapter implements AgentRuntime { // session/update notifications for the turn before reporting the stopReason. // The bridging client handler installed at createSession (U4) has already // surfaced streamed text/thinking/tool updates onto session.callbacks. - await promptAcpSession(acp.connection, acp.sessionId, blocks); + /* + FNXC:ACP-RouteB 2026-06-14-20:09: + Route-B validation must distinguish clean end_turn answers from truncated or cancelled turns. Surface ACP stopReason to the engine runner instead of discarding it so callers can reject syntactically complete JSON recovered from incomplete output. + */ + const stopReason = await promptAcpSession(acp.connection, acp.sessionId, blocks); + return { stopReason }; } describeModel(session: AgentSession): string { diff --git a/plugins/fusion-plugin-acp-runtime/src/setup.ts b/plugins/fusion-plugin-acp-runtime/src/setup.ts new file mode 100644 index 0000000000..d8ca4730b5 --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/src/setup.ts @@ -0,0 +1,104 @@ +import { dirname, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { PluginContext, PluginSetupCheckResult, PluginSetupHooks, PluginSetupManifest } from "@fusion/plugin-sdk"; +import { CLAUDE_CODE_CLI_ACP_BINARY, bundledClaudeBridgeBinPath, resolveClaudeBridgeAskSettings } from "./cli-spawn.js"; +import { buildSpawnEnv } from "./process-manager.js"; +import { probeAcpReadiness, type AcpProbeStatus, type ProbeOptions } from "./probe.js"; + +export const setupManifest: PluginSetupManifest = { + binaryName: CLAUDE_CODE_CLI_ACP_BINARY, + description: "Claude Code ACP bridge used by Fusion's read-only ask path", + channel: "beta", + defaultTimeoutMs: 30_000, +}; + +export interface CheckAcpSetupDeps { + probe?: (opts: ProbeOptions) => Promise; + pluginRoot?: string; +} + +const MAX_PROBE_TIMEOUT_MS = 30_000; + +function isInside(parent: string, child: string): boolean { + const rel = relative(resolve(parent), resolve(child)); + return rel === "" || (!rel.startsWith("..") && !rel.includes(`..${sep}`)); +} + +function defaultPluginRoot(): string { + return resolve(dirname(fileURLToPath(import.meta.url)), ".."); +} + +export function validateBundledBridgeIdentity(binaryPath: string, pluginRoot = defaultPluginRoot()): string | undefined { + const expectedBin = resolve(bundledClaudeBridgeBinPath(pluginRoot)); + const expectedNodeModules = resolve(pluginRoot, "node_modules"); + if (resolve(binaryPath) !== expectedBin && !isInside(expectedNodeModules, binaryPath)) { + return `Resolved ${CLAUDE_CODE_CLI_ACP_BINARY} must come from this plugin's node_modules, got ${binaryPath}`; + } + return undefined; +} + +function statusFromProbe(probe: AcpProbeStatus, binaryPath: string): PluginSetupCheckResult { + if (probe.ok) { + if (probe.authRequired) { + return { + status: "error", + binaryPath, + error: "Claude authentication required: run `claude` once to authenticate before using the ACP bridge.", + }; + } + return { status: "installed", binaryPath }; + } + + if (probe.reason === "missing_binary") { + return { + status: "not-installed", + error: `Install bundled dependency ${CLAUDE_CODE_CLI_ACP_BINARY}@0.1.1 and run pnpm install for this plugin.`, + }; + } + if (probe.reason === "unauthenticated") { + return { + status: "error", + binaryPath, + error: "Claude authentication required: run `claude` once to authenticate before using the ACP bridge.", + }; + } + return { status: "error", binaryPath, error: probe.detail ?? `ACP readiness failed: ${probe.reason}` }; +} + +export async function checkSetup( + ctx: PluginContext, + deps: CheckAcpSetupDeps = {}, +): Promise { + const settings = resolveClaudeBridgeAskSettings(ctx.settings as Record | undefined); + if (settings.binaryResolution?.kind === "not_resolved") { + return { + status: "not-installed", + error: settings.binaryResolution.reason ?? `Install ${CLAUDE_CODE_CLI_ACP_BINARY}@0.1.1`, + }; + } + + const identityError = validateBundledBridgeIdentity(settings.binaryPath, deps.pluginRoot); + if (identityError) { + return { status: "error", error: identityError, binaryPath: settings.binaryPath }; + } + + let env: NodeJS.ProcessEnv; + try { + env = buildSpawnEnv(settings.envAllowList, { required: settings.requiredEnv }); + } catch (err) { + return { status: "error", binaryPath: settings.binaryPath, error: err instanceof Error ? err.message : String(err) }; + } + + const probe = await (deps.probe ?? probeAcpReadiness)({ + binaryPath: settings.binaryPath, + args: settings.args, + cwd: process.cwd(), + env, + timeoutMs: MAX_PROBE_TIMEOUT_MS, + }); + return statusFromProbe(probe, settings.binaryPath); +} + +export const setupHooks: PluginSetupHooks = { + checkSetup, +}; diff --git a/plugins/fusion-plugin-acp-runtime/src/types.ts b/plugins/fusion-plugin-acp-runtime/src/types.ts index 3343c80928..55e4ebaade 100644 --- a/plugins/fusion-plugin-acp-runtime/src/types.ts +++ b/plugins/fusion-plugin-acp-runtime/src/types.ts @@ -123,6 +123,10 @@ export interface AcpSession { export type AgentSession = AcpSession; +export interface AgentPromptResult { + stopReason?: string; +} + export interface AgentSessionResult { session: AgentSession; sessionFile?: string; @@ -133,7 +137,7 @@ export interface AgentRuntime { id: string; name: string; createSession(options: AgentRuntimeOptions): Promise; - promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise; + promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise; describeModel(session: AgentSession): string; dispose?(session: AgentSession): Promise; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7fab322e4..bf2801404f 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@3.25.76))(ws@8.20.0)(zod@3.25.76) + version: 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@earendil-works/pi-coding-agent': specifier: ^0.79.1 - version: 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + version: 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) dockerode: specifier: ^4.0.12 version: 4.0.12 @@ -587,10 +587,10 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: '*' - version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 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': specifier: '*' - version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) devDependencies: '@types/node': specifier: ^25.5.2 @@ -715,6 +715,9 @@ importers: '@fusion/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk + claude-code-cli-acp: + specifier: 0.1.1 + version: 0.1.1 devDependencies: '@types/node': specifier: ^25.5.2 @@ -3708,6 +3711,53 @@ packages: classcat@5.0.5: resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + claude-code-cli-acp-darwin-arm64@0.1.1: + resolution: {integrity: sha512-FG+Y+SJsZo8SG0JsOxwpXopN1xdCFBUBF8ekFcgO/FbAljlaP5Z6uSzHYaa5WXgNy5DxKEKpJqg+NynnscHtyA==} + engines: {node: '>=18.0.0'} + cpu: [arm64] + os: [darwin] + hasBin: true + + claude-code-cli-acp-darwin-x64@0.1.1: + resolution: {integrity: sha512-ekFa15FywMqxIh/w72RBJ2Beeu5b/0aF/T8hdI5tszcqYJXwoYjQxuLuo0LkA+bYXCeQT3WzGahvlRhTtVkzNQ==} + engines: {node: '>=18.0.0'} + cpu: [x64] + os: [darwin] + hasBin: true + + claude-code-cli-acp-linux-arm64@0.1.1: + resolution: {integrity: sha512-PEN8qEQhowHSMk89ACGb5TWc1yrhLsSKHanArLiiBolOAkava4lPDOdev5OT3apeIYcae4B7O2RqawvijTswqQ==} + engines: {node: '>=18.0.0'} + cpu: [arm64] + os: [linux] + hasBin: true + + claude-code-cli-acp-linux-x64@0.1.1: + resolution: {integrity: sha512-AGLZVigHSH/cq2uYqlqsJwIn6YwemHHIFI7gQOeD9+3j9uhPlMpZCSwNklU+m486mEiKpl0/FGfME0NSoVYa+w==} + engines: {node: '>=18.0.0'} + cpu: [x64] + os: [linux] + hasBin: true + + claude-code-cli-acp-win32-arm64@0.1.1: + resolution: {integrity: sha512-I56PV4cDr1H+ouk+/8sETuFlG6ck4j0m9UepR2eX7oVwFiik9O9OKe67wfq9+CXnFtP9WstSPQwVoP1MEM+ftg==} + engines: {node: '>=18.0.0'} + cpu: [arm64] + os: [win32] + hasBin: true + + claude-code-cli-acp-win32-x64@0.1.1: + resolution: {integrity: sha512-pFEm2UWT2CDsBAFTiCDUCnL4k9jz2LxsFdiCcUcq8MVZf5Eutg2q7+kt8+tRAGqLKrNUYme8aIFFlmn3iu1okw==} + engines: {node: '>=18.0.0'} + cpu: [x64] + os: [win32] + hasBin: true + + claude-code-cli-acp@0.1.1: + resolution: {integrity: sha512-qpfRGOXkOs9mqI7oumsGistWisyXcCC0r7ng7wdLvGMIORdzHjmUUa+94Jftgr/NYAVnAUe6N7kimD8PaO3D5g==} + engines: {node: '>=18.0.0'} + hasBin: true + cli-boxes@4.0.1: resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} engines: {node: '>=18.20 <19 || >=20.10'} @@ -7861,6 +7911,20 @@ 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)': + 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) + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - 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)': 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) @@ -7889,20 +7953,6 @@ snapshots: - ws - zod - '@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.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 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@earendil-works/pi-agent-core@0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-ai': 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -7951,6 +8001,26 @@ 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)': + dependencies: + '@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@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@3.25.76) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - 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)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.3.6) @@ -7991,26 +8061,6 @@ snapshots: - ws - zod - '@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@3.25.76) - '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@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@3.25.76) - partial-json: 0.1.7 - typebox: 1.1.38 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@earendil-works/pi-ai@0.79.1(@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) @@ -8080,6 +8130,35 @@ 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)': + 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-tui': 0.77.0 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cross-spawn: 7.0.6 + diff: 8.0.4 + glob: 13.0.6 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + jiti: 2.7.0 + minimatch: 10.2.5 + proper-lockfile: 4.1.2 + typebox: 1.1.38 + undici: 8.3.0 + yaml: 2.9.0 + optionalDependencies: + '@mariozechner/clipboard': 0.3.9 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - 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)': 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) @@ -8138,35 +8217,6 @@ snapshots: - ws - zod - '@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.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 - diff: 8.0.4 - glob: 13.0.6 - highlight.js: 10.7.3 - hosted-git-info: 9.0.3 - ignore: 7.0.5 - jiti: 2.7.0 - minimatch: 10.2.5 - proper-lockfile: 4.1.2 - typebox: 1.1.38 - undici: 8.3.0 - yaml: 2.9.0 - optionalDependencies: - '@mariozechner/clipboard': 0.3.9 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@earendil-works/pi-coding-agent@0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-agent-core': 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -9757,7 +9807,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.8.3)) + 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/expect@4.1.8': dependencies: @@ -10408,6 +10458,33 @@ snapshots: classcat@5.0.5: {} + claude-code-cli-acp-darwin-arm64@0.1.1: + optional: true + + claude-code-cli-acp-darwin-x64@0.1.1: + optional: true + + claude-code-cli-acp-linux-arm64@0.1.1: + optional: true + + claude-code-cli-acp-linux-x64@0.1.1: + optional: true + + claude-code-cli-acp-win32-arm64@0.1.1: + optional: true + + claude-code-cli-acp-win32-x64@0.1.1: + optional: true + + claude-code-cli-acp@0.1.1: + optionalDependencies: + claude-code-cli-acp-darwin-arm64: 0.1.1 + claude-code-cli-acp-darwin-x64: 0.1.1 + claude-code-cli-acp-linux-arm64: 0.1.1 + claude-code-cli-acp-linux-x64: 0.1.1 + claude-code-cli-acp-win32-arm64: 0.1.1 + claude-code-cli-acp-win32-x64: 0.1.1 + cli-boxes@4.0.1: {} cli-cursor@3.1.0: