feat(acp): opt-in warm connection reuse across turns (OQ2)

Keep a warm bridge connection + ACP session across turns of one
conversation (gated by FUSION_CLAUDE_ACP_REUSE=1, default OFF), so
multi-turn lanes skip the cold bridge/claude spawn and session/new
round-trip and send only the latest-turn delta (buildResumePrompt).
A stable router indirection serves each turn's handlers.

Addresses the adversarial review of the reuse path:
- P0: a warm-child death routes failure to the CURRENT owner turn via
  router.fail, so a reuse turn fails fast instead of hanging until the
  30-min inactivity timeout.
- P1: eviction is cache-identity-aware (evictCachedAcpConn only deletes
  the map key when it still points at the entry), so a concurrent cold
  turn / stale close handler / idle timer can't evict or kill a newer
  live entry's child.
- P1: an empty resume delta cold-starts instead of issuing an empty
  prompt that could hang.
- P2: a per-turn token drops cross-turn stray updates on the shared
  warm connection.
- The idle reaper is unref'd so it never pins the process.

Default OFF → the cold path is functionally unchanged (reviewer-verified).
Adds multi-turn tests: reuse skips spawn+session/new, flag-off spawns
fresh, fail-fast on warm-child death, empty-resume cold fallback.
346/346 pass, tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-15 14:38:19 -07:00
parent 031a5470bb
commit b0bb39aa39
3 changed files with 330 additions and 42 deletions

View File

@@ -7,5 +7,6 @@ Route Fusion's Claude CLI path through the ACP bridge (`claude-code-cli-acp`) in
- **U10** — forward `mcpServers` on ACP `session/new` through the runtime contract (`AgentRuntimeOptions.mcpServers` + the plugin's `newAcpSession`); defaults to `[]` so existing read-only ACP "ask" turns are unchanged.
- **U11** — `streamViaAcp`: the `pi-claude-cli` provider can drive Claude through the bundled ACP bridge, returning the same `AssistantMessageEventStream` as the `-p` path. Dispatched only when `FUSION_CLAUDE_ACP=1` and a bridge path are present, so the live `-p` path is byte-for-byte untouched by default. Full-history prompting, schema-only MCP forwarding with break-early on pi-known tools, control-char/size sanitization, env allow-list, process-registry registration, and inactivity timeout.
- **KTD10** — the ACP runtime plugin publishes its identity-pinned bundled bridge path on load so the kill-switch needs no manual path; it does not enable the transport.
- **OQ2** — opt-in connection reuse (`FUSION_CLAUDE_ACP_REUSE=1`, default OFF): a warm bridge connection + ACP session is kept across turns of one conversation (keyed by `sessionId`), so multi-turn lanes skip the cold bridge/`claude` spawn and `session/new` round-trip and send only the latest-turn delta (`buildResumePrompt`). A stable `router` indirection serves each turn's handlers; a warm-child death routes failure to the current owner turn (no 30-min inactivity hang), eviction is cache-identity-aware (a concurrent cold turn can't kill a newer entry's child), an empty resume cold-starts instead of issuing an empty prompt, and a per-turn token drops cross-turn stray updates. The idle reaper is `unref`'d. Default OFF → the cold path is functionally unchanged.
The Claude-via-pi OAuth path is unchanged. Live verification confirmed the bridge gates tool execution behind `session/request_permission` (forwarded MCP tools and native tools do not execute when cancelled). Remaining for a follow-up: picker/auth/status surface (U12), workflow `model`-node verification (U13), and production rollout.

View File

@@ -5,6 +5,9 @@ import { PassThrough } from "node:stream";
// Synthetic ACP session/update sequence the mocked prompt() will replay.
let scriptedUpdates: Array<Record<string, unknown>> = [];
let scriptedUsage: Record<string, number> | undefined;
// When set, prompt() never resolves — simulates a turn waiting on the bridge so
// only an out-of-band event (child death / abort) can end it.
let scriptedHang = false;
// Driver validates the bridge path with existsSync — make the fake path "exist".
// writeFileSync/unlinkSync back the R17 auth-failure signal (spied).
@@ -33,6 +36,7 @@ vi.mock("@agentclientprotocol/sdk", () => ({
this.initialize = vi.fn(async () => ({ protocolVersion: 1 }));
this.newSession = vi.fn(async () => ({ sessionId: "s1" }));
this.prompt = vi.fn(async () => {
if (scriptedHang) return new Promise(() => {}); // never resolves
for (const u of scriptedUpdates) await handler.sessionUpdate({ update: u });
return { stopReason: "end_turn", usage: scriptedUsage };
});
@@ -54,6 +58,8 @@ vi.mock("@earendil-works/pi-ai", () => ({
calculateCost: vi.fn(),
}));
import { spawn } from "node:child_process";
import { ClientSideConnection } from "@agentclientprotocol/sdk";
import { streamViaAcp, buildBridgeEnv } from "../acp-driver.js";
const MODEL = { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" } as never;
@@ -66,7 +72,7 @@ function eventsOf(stream: { _events: Array<Record<string, unknown>> }) {
const flush = () => new Promise((r) => setTimeout(r, 30));
describe("streamViaAcp — ACP→pi translation (U11)", () => {
beforeEach(() => { scriptedUpdates = []; scriptedUsage = undefined; });
beforeEach(() => { scriptedUpdates = []; scriptedUsage = undefined; scriptedHang = false; });
it("feeds ACP token usage (incl. cache tokens) into the done message (item 2)", async () => {
scriptedUsage = { inputTokens: 11, outputTokens: 22, cachedReadTokens: 5, cachedWriteTokens: 3 };
@@ -194,6 +200,123 @@ describe("streamViaAcp — ACP→pi translation (U11)", () => {
});
});
describe("connection reuse (item 1) — gated by FUSION_CLAUDE_ACP_REUSE", () => {
const savedReuse = process.env.FUSION_CLAUDE_ACP_REUSE;
beforeEach(() => {
scriptedUpdates = [];
scriptedUsage = undefined;
scriptedHang = false;
vi.mocked(spawn).mockClear();
vi.mocked(ClientSideConnection).mockClear();
});
afterEach(() => {
if (savedReuse === undefined) delete process.env.FUSION_CLAUDE_ACP_REUSE;
else process.env.FUSION_CLAUDE_ACP_REUSE = savedReuse;
});
it("reuses one warm bridge connection across turns; turn 2 skips spawn + session/new", async () => {
process.env.FUSION_CLAUDE_ACP_REUSE = "1";
const reuseOpts = { ...OPTS, sessionId: "conv-reuse-1" };
// Turn 1 (cold): needs >1 message so reuseKey activates and the connection caches.
const ctx1 = { messages: [{ role: "user", content: "hi" }, { role: "assistant", content: "hello" }] } as never;
scriptedUpdates = [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "turn one" } }];
streamViaAcp(MODEL, ctx1, reuseOpts);
await flush();
expect(vi.mocked(spawn)).toHaveBeenCalledTimes(1);
expect(vi.mocked(ClientSideConnection)).toHaveBeenCalledTimes(1);
// Turn 2 (warm): same sessionId → no new spawn, no new connection.
const ctx2 = { messages: [...(ctx1 as unknown as { messages: unknown[] }).messages, { role: "user", content: "again" }] } as never;
scriptedUpdates = [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "turn two" } }];
const s2 = streamViaAcp(MODEL, ctx2, reuseOpts) as unknown as { _events: Array<Record<string, unknown>> };
await flush();
expect(vi.mocked(spawn)).toHaveBeenCalledTimes(1); // no second spawn
expect(vi.mocked(ClientSideConnection)).toHaveBeenCalledTimes(1); // no second connection
// The single warm connection's prompt() ran once per turn.
const conn = vi.mocked(ClientSideConnection).mock.instances[0] as unknown as { prompt: ReturnType<typeof vi.fn>; newSession: ReturnType<typeof vi.fn> };
expect(conn.prompt).toHaveBeenCalledTimes(2);
expect(conn.newSession).toHaveBeenCalledTimes(1); // session/new only on the cold turn
const done = s2._events.find((e) => e.type === "done");
expect(done!.reason).toBe("stop");
// Cleanup: evict the warm connection + clear its (unref'd) idle timer.
(vi.mocked(spawn).mock.results[0].value as EventEmitter).emit("close", 0);
});
it("fails a reuse turn FAST when the warm child dies mid-prompt (P0: no 30min hang)", async () => {
process.env.FUSION_CLAUDE_ACP_REUSE = "1";
const reuseOpts = { ...OPTS, sessionId: "conv-death" };
// Turn 1 (cold) caches the warm connection.
const ctx1 = { messages: [{ role: "user", content: "hi" }, { role: "assistant", content: "hello" }] } as never;
scriptedUpdates = [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "one" } }];
streamViaAcp(MODEL, ctx1, reuseOpts);
await flush();
const child = vi.mocked(spawn).mock.results[0].value as EventEmitter;
// Turn 2 (warm) hangs on prompt() — only the child-death path can end it.
scriptedHang = true;
const ctx2 = { messages: [...(ctx1 as unknown as { messages: unknown[] }).messages, { role: "user", content: "again" }] } as never;
const s2 = streamViaAcp(MODEL, ctx2, reuseOpts) as unknown as { _events: Array<Record<string, unknown>> };
await flush();
expect(s2._events.some((e) => e.type === "done")).toBe(false); // still waiting
// The warm child dies. The cold turn's close handler routes failure to the
// CURRENT (reuse) turn via router.fail, so it ends immediately.
child.emit("close", 1);
await flush();
const done = s2._events.find((e) => e.type === "done") as { reason?: string; message?: { content?: Array<{ text?: string }> } };
expect(done).toBeDefined();
expect(done!.reason).toBe("stop");
expect(JSON.stringify(done!.message?.content)).toContain("Error");
// Cache was evicted: a subsequent turn cold-spawns a fresh bridge.
scriptedHang = false;
scriptedUpdates = [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "fresh" } }];
const ctx3 = { messages: [...(ctx2 as unknown as { messages: unknown[] }).messages, { role: "assistant", content: "" }, { role: "user", content: "q3" }] } as never;
streamViaAcp(MODEL, ctx3, reuseOpts);
await flush();
expect(vi.mocked(spawn)).toHaveBeenCalledTimes(2); // turn 1 + the post-death cold restart
});
it("cold-starts (no warm reuse) when the resume delta is empty (P1: no empty-prompt hang)", async () => {
process.env.FUSION_CLAUDE_ACP_REUSE = "1";
const reuseOpts = { ...OPTS, sessionId: "conv-empty" };
// Turn 1 (cold) caches.
const ctx1 = { messages: [{ role: "user", content: "hi" }, { role: "assistant", content: "hello" }] } as never;
scriptedUpdates = [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "one" } }];
streamViaAcp(MODEL, ctx1, reuseOpts);
await flush();
expect(vi.mocked(spawn)).toHaveBeenCalledTimes(1);
// Turn 2 whose context ends in an assistant message → buildResumePrompt is
// empty → must NOT take the warm path (would hang); cold-starts instead.
const ctx2 = { messages: [...(ctx1 as unknown as { messages: unknown[] }).messages, { role: "user", content: "x" }, { role: "assistant", content: "y" }] } as never;
scriptedUpdates = [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "two" } }];
const s2 = streamViaAcp(MODEL, ctx2, reuseOpts) as unknown as { _events: Array<Record<string, unknown>> };
await flush();
expect(vi.mocked(spawn)).toHaveBeenCalledTimes(2); // empty resume → fresh spawn
const done = s2._events.find((e) => e.type === "done");
expect(done!.reason).toBe("stop"); // produced a normal turn, did not hang
});
it("does NOT reuse when the flag is off (default): each turn spawns a fresh bridge", async () => {
delete process.env.FUSION_CLAUDE_ACP_REUSE;
const reuseOpts = { ...OPTS, sessionId: "conv-off" };
const ctx = { messages: [{ role: "user", content: "hi" }, { role: "assistant", content: "x" }] } as never;
scriptedUpdates = [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "a" } }];
streamViaAcp(MODEL, ctx, reuseOpts);
await flush();
streamViaAcp(MODEL, ctx, reuseOpts);
await flush();
expect(vi.mocked(spawn)).toHaveBeenCalledTimes(2);
expect(vi.mocked(ClientSideConnection)).toHaveBeenCalledTimes(2);
});
});
describe("buildBridgeEnv — R17 auth opt-in (item 3)", () => {
const saved = {
flag: process.env.FUSION_CLAUDE_ACP_FORWARD_AUTH,

View File

@@ -45,7 +45,7 @@ import {
} from "@agentclientprotocol/sdk";
import { AssistantMessageEventStream } from "@earendil-works/pi-ai";
import type { Api, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
import { buildPrompt, buildSystemPrompt, type PiContext } from "./prompt-builder.js";
import { buildPrompt, buildResumePrompt, buildSystemPrompt, type PiContext } from "./prompt-builder.js";
import { createEventBridge } from "./event-bridge.js";
import { registerProcess, captureStderr } from "./process-manager.js";
import { isPiKnownClaudeTool } from "./tool-mapping.js";
@@ -190,6 +190,61 @@ function toAcpPromptBlocks(
return out;
}
/**
* FNXC:ClaudeAcp 2026-06-15-14:10:
* Connection-reuse cache (item 1 / OQ2). Gated behind `FUSION_CLAUDE_ACP_REUSE`
* (default OFF). When on, a live bridge connection + ACP session is kept warm
* across turns of one conversation (keyed by the stable `options.sessionId`), so
* multi-turn lanes skip the cold bridge+claude spawn, the `session/new`
* round-trip, AND the full-history resend — sending only `buildResumePrompt`
* (delta) on reuse. A stable `router` indirection lets the long-lived connection
* handler serve each turn's fresh per-call state. Default OFF → the cold path
* below is functionally unchanged.
*/
const REUSE_IDLE_MS = 5 * 60_000;
interface AcpRouter {
onUpdate: ((p: { update?: Record<string, unknown> } & Record<string, unknown>) => Promise<void>) | null;
onPermission: ((p: Record<string, unknown>) => Promise<{ outcome: { outcome: "cancelled" } }>) | null;
// Liveness: invoked when the warm child dies so the turn CURRENTLY owning the
// connection fails fast instead of hanging until the inactivity timeout. The
// long-lived `child.on("close")` is bound to the cold turn's closure, so
// without this a reuse turn's death would never reach its own `failWith`.
// Repointed to each turn's `failWith`; nulled on release (idle → just evict).
fail: ((msg: string) => void) | null;
}
interface CachedAcpConn {
conn: ClientSideConnection;
child: ChildProcess;
acpSessionId: string;
cwd: string;
inUse: boolean;
router: AcpRouter;
idleTimer?: ReturnType<typeof setTimeout>;
// Monotonic id of the turn currently owning the connection. A stray
// session/update from a finished turn is dropped when it no longer matches.
activeTurn: number;
}
const acpSessionCache = new Map<string, CachedAcpConn>();
let acpTurnCounter = 0;
function acpReuseEnabled(): boolean {
return process.env.FUSION_CLAUDE_ACP_REUSE === "1";
}
/**
* Kill a cached connection's child and evict it — but only delete the map key
* if it STILL points at this exact entry. A concurrent cold turn may have
* replaced the entry under the same key; a stale close handler / idle timer
* must not evict (or kill the child of) that newer, live entry. The passed
* entry's own child is always killed (it is the dead/finished one).
*/
function evictCachedAcpConn(key: string, entry: CachedAcpConn): void {
if (acpSessionCache.get(key) === entry) acpSessionCache.delete(key);
if (entry.idleTimer) { clearTimeout(entry.idleTimer); entry.idleTimer = undefined; }
entry.router.onUpdate = null;
entry.router.onPermission = null;
entry.router.fail = null;
try { entry.child.kill("SIGKILL"); } catch { /* registry SIGKILL is authoritative */ }
}
/**
* Stream a Claude response via the ACP bridge as an `AssistantMessageEventStream`.
* Mirrors `streamViaCli`'s contract (start → deltas → done; break-early on tools).
@@ -205,6 +260,12 @@ export function streamViaAcp(
const bridge = createEventBridge(stream, model);
(async () => {
const cwd = options.cwd ?? process.cwd();
const reuseKey =
acpReuseEnabled() && options.sessionId && context.messages.length > 1
? options.sessionId
: undefined;
let child: ChildProcess | undefined;
let getStderr: (() => string) | undefined;
let ended = false;
@@ -214,11 +275,44 @@ export function streamViaAcp(
let sawToolCall = false;
let inactivity: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
// The cache entry this turn is bound to (set on reuse, or after a cold turn
// caches its connection). Identity-checked against the map before release.
let cacheEntry: CachedAcpConn | undefined;
// This turn's monotonic id, stamped onto the shared cache entry when the
// turn acquires it. Handlers drop updates once the entry moves to a newer
// turn (defends the warm connection against cross-turn content bleed).
let myTurn = 0;
const cleanup = () => {
// End the turn. `destroy` kills+evicts the connection; otherwise a cached
// connection is released (kept warm for the next turn) and a one-shot
// (non-reuse) connection is killed.
const endTurn = (destroy: boolean) => {
if (inactivity) { clearTimeout(inactivity); inactivity = undefined; }
if (onAbort && options.signal) options.signal.removeEventListener("abort", onAbort);
try { child?.kill("SIGKILL"); } catch { /* registry SIGKILL is authoritative */ }
const entry = cacheEntry;
const keepWarm =
!destroy && entry !== undefined && reuseKey !== undefined &&
acpSessionCache.get(reuseKey) === entry;
if (keepWarm) {
// Release the warm connection: drop this turn's handlers (so a late
// update can't reach a finished turn or the liveness hook fire stale),
// mark idle, and arm an unref'd reaper bound to THIS entry.
entry!.router.onUpdate = null;
entry!.router.onPermission = null;
entry!.router.fail = null;
entry!.inUse = false;
if (entry!.idleTimer) clearTimeout(entry!.idleTimer);
const idle = setTimeout(() => evictCachedAcpConn(reuseKey!, entry!), REUSE_IDLE_MS);
idle.unref?.(); // a warm-connection idle timer must not keep the process alive
entry!.idleTimer = idle;
return;
}
if (entry !== undefined && reuseKey !== undefined) {
// Kills this turn's child; evicts the map key only if still current.
evictCachedAcpConn(reuseKey, entry);
} else {
try { child?.kill("SIGKILL"); } catch { /* registry SIGKILL is authoritative */ }
}
};
const armInactivity = () => {
if (inactivity) clearTimeout(inactivity);
@@ -251,7 +345,7 @@ export function streamViaAcp(
bridge.handleEvent({ type: "message_delta", delta: { stop_reason: effective === "tool_use" ? "tool_use" : "end_turn" } } as ClaudeApiEvent);
stream.push({ type: "done", reason: effective === "tool_use" ? "toolUse" : "stop", message: bridge.getOutput() });
stream.end();
cleanup();
endTurn(false); // clean turn → keep a cached connection warm for next turn
};
const failWith = (msg: string) => {
@@ -268,7 +362,7 @@ export function streamViaAcp(
},
});
stream.end();
cleanup();
endTurn(true); // failed turn → destroy the connection (never reuse a broken one)
};
const openBlock = (kind: "text" | "thinking") => {
@@ -291,9 +385,11 @@ export function streamViaAcp(
finish("tool_use");
};
const clientHandler = {
async sessionUpdate(params: { update?: Record<string, unknown> } & Record<string, unknown>) {
const handleUpdate = async (params: { update?: Record<string, unknown> } & Record<string, unknown>): Promise<void> => {
if (ended) return;
// The warm connection is shared across turns; ignore a stray update once
// the entry has been handed to a newer turn (cross-turn bleed guard).
if (cacheEntry && cacheEntry.activeTurn !== myTurn) return;
armInactivity();
const u = (params.update ?? params) as Record<string, unknown>;
const kind = u.sessionUpdate as string;
@@ -320,13 +416,14 @@ export function streamViaAcp(
surfaceToolAndBreak(claudeName, (u.toolCallId as string) ?? `acp_${blockIndex + 1}`, u.rawInput ?? u.input);
}
}
},
async requestPermission(params: Record<string, unknown>) {
};
const handlePermission = async (params: Record<string, unknown>): Promise<{ outcome: { outcome: "cancelled" } }> => {
// A permission request means the bridge is about to EXECUTE a tool. For a
// pi-known tool, surface it to pi and break early (pi executes it); deny
// by default otherwise. We always return cancelled so the bridge never
// executes Fusion's tools itself.
if (!ended) {
if (!ended && !(cacheEntry && cacheEntry.activeTurn !== myTurn)) {
const tc = (params.toolCall ?? {}) as Record<string, unknown>;
const claudeName = ((tc._meta as { claudeCode?: { toolName?: string } } | undefined)?.claudeCode?.toolName) ?? (tc.title as string) ?? "";
if (isPiKnownClaudeTool(claudeName)) {
@@ -334,19 +431,92 @@ export function streamViaAcp(
}
}
return { outcome: { outcome: "cancelled" as const } };
},
};
// Usage emission (OQ3) — shared by the cold + reuse paths. Coerces the
// untrusted bridge usage payload to finite, non-negative numbers.
const emitUsage = (res: unknown): void => {
if (sawToolCall) return;
const u = (res as { usage?: Record<string, unknown> }).usage;
if (!u) return;
const num = (x: unknown): number | undefined =>
typeof x === "number" && Number.isFinite(x) && x >= 0 ? x : undefined;
bridge.handleEvent({
type: "message_delta",
delta: {},
usage: {
input_tokens: num(u.inputTokens),
output_tokens: num(u.outputTokens),
cache_read_input_tokens: num(u.cachedReadTokens),
cache_creation_input_tokens: num(u.cachedWriteTokens),
},
} as ClaudeApiEvent);
};
try {
const withTimeout = <T>(p: Promise<T>, label: string) =>
Promise.race([p, new Promise<never>((_, rej) => setTimeout(() => rej(new Error(`ACP ${label} timeout`)), INITIALIZE_TIMEOUT_MS))]);
// ── Reuse path: a warm connection for this conversation exists ──────────
// Skip spawn + initialize + session/new, and send ONLY the latest-turn
// delta (`buildResumePrompt`) because the warm `claude` session already
// holds the prior turns server-side (sending full history would duplicate
// it). Gated by `reuseKey`, which is undefined unless reuse is enabled.
let warm = reuseKey ? acpSessionCache.get(reuseKey) : undefined;
// Never reuse a busy connection or one bound to a different cwd.
if (warm && (warm.inUse || warm.cwd !== cwd)) warm = undefined;
// A reuse turn sends only the delta; if there's nothing new to send, an
// empty prompt to the warm session could hang. Drop the warm connection
// and cold-start with full history instead.
let resumeBlocks: ReturnType<typeof toAcpPromptBlocks> | undefined;
if (warm && reuseKey) {
const resume = buildResumePrompt(context);
const resumeEmpty = typeof resume === "string" ? resume.trim() === "" : resume.length === 0;
if (resumeEmpty) { evictCachedAcpConn(reuseKey, warm); warm = undefined; }
else resumeBlocks = toAcpPromptBlocks(resume as string | Array<Record<string, unknown>>);
}
if (warm && reuseKey && resumeBlocks) {
cacheEntry = warm;
myTurn = ++acpTurnCounter;
warm.activeTurn = myTurn;
warm.inUse = true;
if (warm.idleTimer) { clearTimeout(warm.idleTimer); warm.idleTimer = undefined; }
warm.router.onUpdate = handleUpdate;
warm.router.onPermission = handlePermission;
warm.router.fail = failWith; // a warm-child death now fails THIS turn fast
child = warm.child;
onAbort = () => failWith("aborted");
if (options.signal) options.signal.addEventListener("abort", onAbort, { once: true });
armInactivity();
// ACP ContentBlock[] — text/image shapes match; cast through unknown.
const res = await warm.conn.prompt({ sessionId: warm.acpSessionId, prompt: resumeBlocks as unknown as Parameters<typeof warm.conn.prompt>[0]["prompt"] });
if (ended) return;
emitUsage(res);
if (!sawToolCall) finish("stop");
return;
}
// ── Cold path: spawn the bridge and open a fresh ACP session ───────────
if (!isAbsolute(options.bridgePath) || !existsSync(options.bridgePath)) {
failWith(`ACP bridge path invalid (must be an absolute, existing binary): ${options.bridgePath}`);
return;
}
child = spawn(options.bridgePath, [], { stdio: ["pipe", "pipe", "pipe"], cwd: options.cwd ?? process.cwd(), env: buildBridgeEnv(options.bridgeEnv) });
child = spawn(options.bridgePath, [], { stdio: ["pipe", "pipe", "pipe"], cwd, env: buildBridgeEnv(options.bridgeEnv) });
registerProcess(child);
getStderr = captureStderr(child);
child.on("error", (e) => failWith(`ACP bridge spawn failed: ${e.message}`));
child.on("close", (code) => { if (!ended) failWith(`ACP bridge exited (code ${code ?? "?"})${getStderr ? `: ${getStderr().slice(-500)}` : ""}`); });
// Stable router indirection: the long-lived connection + child handlers
// always dispatch to whichever turn currently owns the connection. On
// reuse we repoint `router.*` at the new turn; `router.fail` lets a
// warm-child death fail the CURRENT owner (not the cold turn it spawned).
const router: AcpRouter = { onUpdate: handleUpdate, onPermission: handlePermission, fail: failWith };
child.on("error", (e) => router.fail?.(`ACP bridge spawn failed: ${e.message}`));
child.on("close", (code) => {
const msg = `ACP bridge exited (code ${code ?? "?"})${getStderr ? `: ${getStderr().slice(-500)}` : ""}`;
const fail = router.fail; // capture before evict nulls it
if (reuseKey && cacheEntry) evictCachedAcpConn(reuseKey, cacheEntry); // a dead child can never be reused
fail?.(msg); // fail the owning turn (no-op if idle / already ended)
});
onAbort = () => failWith("aborted");
if (options.signal) options.signal.addEventListener("abort", onAbort, { once: true });
armInactivity();
@@ -355,10 +525,15 @@ export function streamViaAcp(
Writable.toWeb(child.stdin!) as unknown as WritableStream<Uint8Array>,
Readable.toWeb(child.stdout!) as unknown as ReadableStream<Uint8Array>,
);
const conn = new ClientSideConnection(() => clientHandler, acpStream);
const withTimeout = <T>(p: Promise<T>, label: string) =>
Promise.race([p, new Promise<never>((_, rej) => setTimeout(() => rej(new Error(`ACP ${label} timeout`)), INITIALIZE_TIMEOUT_MS))]);
const conn = new ClientSideConnection(
() => ({
sessionUpdate: (p) => router.onUpdate?.(p as Parameters<NonNullable<AcpRouter["onUpdate"]>>[0]) ?? Promise.resolve(),
requestPermission: (p) =>
router.onPermission?.(p as Parameters<NonNullable<AcpRouter["onPermission"]>>[0]) ??
Promise.resolve({ outcome: { outcome: "cancelled" as const } }),
}),
acpStream,
);
const init = await withTimeout(
conn.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } } }),
@@ -367,10 +542,17 @@ export function streamViaAcp(
if (ended) return;
if (init.protocolVersion !== PROTOCOL_VERSION) { failWith(`incompatible ACP protocol ${init.protocolVersion}`); return; }
const opened = await withTimeout(conn.newSession({ cwd: options.cwd ?? process.cwd(), mcpServers: options.mcpServers ?? [] }), "newSession");
const opened = await withTimeout(conn.newSession({ cwd, mcpServers: options.mcpServers ?? [] }), "newSession");
if (ended) return;
const cwd = options.cwd ?? process.cwd();
// Cache the warm connection so the next turn of this conversation reuses
// it. Only when reuse is enabled (reuseKey set) and the child is live.
if (reuseKey) {
myTurn = ++acpTurnCounter;
cacheEntry = { conn, child, acpSessionId: opened.sessionId, cwd, inUse: true, router, activeTurn: myTurn };
acpSessionCache.set(reuseKey, cacheEntry);
}
const systemPrompt = buildSystemPrompt(context, cwd);
const blocks = [
...(systemPrompt ? [{ type: "text" as const, text: `${systemPrompt}\n\n` }] : []),
@@ -379,30 +561,12 @@ export function streamViaAcp(
// ACP ContentBlock[] — text/image shapes match; cast through unknown.
const res = await conn.prompt({ sessionId: opened.sessionId, prompt: blocks as unknown as Parameters<typeof conn.prompt>[0]["prompt"] });
if (ended) return;
// Feed token usage (experimental ACP field) into the bridge BEFORE finish()
// so it lands in the `done` message. Tool-use turns break early and never
// resolve here, so they inherently report zero usage. Zero-when-absent safe.
if (!sawToolCall) {
const u = (res as { usage?: Record<string, unknown> }).usage;
// The bridge is untrusted (see BRIDGE_ENV_ALLOWLIST): coerce its usage
// payload to finite, non-negative numbers only so a malformed value
// (string/NaN/negative) can't corrupt totalTokens / cost downstream.
const num = (x: unknown): number | undefined =>
typeof x === "number" && Number.isFinite(x) && x >= 0 ? x : undefined;
if (u) {
bridge.handleEvent({
type: "message_delta",
delta: {},
usage: {
input_tokens: num(u.inputTokens),
output_tokens: num(u.outputTokens),
cache_read_input_tokens: num(u.cachedReadTokens),
cache_creation_input_tokens: num(u.cachedWriteTokens),
},
} as ClaudeApiEvent);
}
finish("stop");
}
emitUsage(res);
if (!sawToolCall) finish("stop");
} catch (err) {
failWith(err instanceof Error ? err.message : String(err));
}