@runfusion/fusion 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/dist/bin.js +8264 -1823
  2. package/dist/client/.vite/manifest.json +277 -266
  3. package/dist/client/assets/AgentDetailView-CanIeYHg.css +1 -0
  4. package/dist/client/assets/{AgentDetailView-FUKwQxBP.js → AgentDetailView-DxV8pFHX.js} +3 -3
  5. package/dist/client/assets/{AgentPermissionPolicyEditor-DMDUhF1n.js → AgentPermissionPolicyEditor--YS4iMUY.js} +2 -2
  6. package/dist/client/assets/AgentsView-BVILQjdS.css +1 -0
  7. package/dist/client/assets/{AgentsView-BrQLd-yI.js → AgentsView-CTzbcvhK.js} +3 -3
  8. package/dist/client/assets/ChatView-COygcnyk.js +6 -0
  9. package/dist/client/assets/CommandCenter-CTaopaC0.css +1 -0
  10. package/dist/client/assets/CommandCenter-DoKX2HWm.js +56 -0
  11. package/dist/client/assets/DevServerView-Cyevyvqx.js +1 -0
  12. package/dist/client/assets/{DirectoryPicker-DTKtd8L2.css → DirectoryPicker-0rspvcMz.css} +1 -1
  13. package/dist/client/assets/{DirectoryPicker-BFot4CVM.js → DirectoryPicker-DBdyXEhR.js} +1 -1
  14. package/dist/client/assets/DocumentsView-BzSMyrns.js +1 -0
  15. package/dist/client/assets/{EvalsView-DAXqDqn2.js → EvalsView-ikz00o9g.js} +1 -1
  16. package/dist/client/assets/ExperimentalAgentOnboardingModal-CZ_jbgep.css +1 -0
  17. package/dist/client/assets/{ExperimentalAgentOnboardingModal-B4aZ1y0n.js → ExperimentalAgentOnboardingModal-D-rzCCET.js} +10 -10
  18. package/dist/client/assets/GoalsView-Bfu1Oy2-.js +2 -0
  19. package/dist/client/assets/GoalsView-rg9VSIza.css +1 -0
  20. package/dist/client/assets/{InsightsView-D5fzFy-F.js → InsightsView-Cgmf5nES.js} +1 -1
  21. package/dist/client/assets/InsightsView-LMv1M1dU.css +1 -0
  22. package/dist/client/assets/MemoryView-DkEuDKQs.js +2 -0
  23. package/dist/client/assets/{NodesView-BA3pKMZu.js → NodesView-B_KYfS4o.js} +3 -3
  24. package/dist/client/assets/{PiExtensionsManager-C4I11iKm.js → PiExtensionsManager-Dz69kkKs.js} +2 -2
  25. package/dist/client/assets/PluginManager-DSiHizRD.css +1 -0
  26. package/dist/client/assets/{PluginManager-BzTzTMXZ.js → PluginManager-LHCVcduP.js} +1 -1
  27. package/dist/client/assets/PullRequestView-3AdbS2a1.css +1 -0
  28. package/dist/client/assets/{PullRequestView-11xJ565q.js → PullRequestView-DBJutLLl.js} +2 -2
  29. package/dist/client/assets/{ResearchView-BHiXfjzE.js → ResearchView-gpn1XcVy.js} +1 -1
  30. package/dist/client/assets/{SecretsView-CC5OYoCn.js → SecretsView-B7YqIowt.js} +1 -1
  31. package/dist/client/assets/SecretsView-Djo1rahi.css +1 -0
  32. package/dist/client/assets/SessionTerminal-9l4bdKcn.js +2 -0
  33. package/dist/client/assets/SessionTerminal-BNgtmXb3.css +1 -0
  34. package/dist/client/assets/SettingsModal-BGEK9_9A.js +31 -0
  35. package/dist/client/assets/{SettingsModal-DpAxDq6M.css → SettingsModal-CKv0TJfM.css} +1 -1
  36. package/dist/client/assets/SettingsModal-CXogw-Er.css +1 -0
  37. package/dist/client/assets/{SettingsModal-BKEbtdqR.js → SettingsModal-DIIoHGUH.js} +1 -1
  38. package/dist/client/assets/{SetupWizardModal-CBfItlP6.js → SetupWizardModal-BcVeMxeX.js} +1 -1
  39. package/dist/client/assets/{SkillsView-DbcW87-E.js → SkillsView-BxSJ4PHL.js} +1 -1
  40. package/dist/client/assets/{StashRecoveryView-CH9RPtzX.js → StashRecoveryView-BhFQ2FsP.js} +1 -1
  41. package/dist/client/assets/{TodoView-ToviJ0SV.js → TodoView-BZpewjy0.js} +2 -2
  42. package/dist/client/assets/WorkflowNodeEditor-Yn1FRF2v.css +1 -0
  43. package/dist/client/assets/WorkflowNodeEditor-tWsgCdVt.js +8 -0
  44. package/dist/client/assets/{addon-fit-B4jkcKk4.js → addon-fit-SnYDEvGm.js} +1 -1
  45. package/dist/client/assets/{addon-unicode11-Cdne2Sj4.js → addon-unicode11-s5GoZCpi.js} +1 -1
  46. package/dist/client/assets/{addon-web-links-CRNLzj7m.js → addon-web-links-jjt2f_oV.js} +1 -1
  47. package/dist/client/assets/{addon-webgl-CeCSULF1.js → addon-webgl-Icn-RnEx.js} +1 -1
  48. package/dist/client/assets/{app-sEkTyT-w.js → app-BmGA0Hiz.js} +3 -3
  49. package/dist/client/assets/{app-BffGU_HQ.js → app-CEVYlVuJ.js} +3 -3
  50. package/dist/client/assets/{app-C8AHhs5N.js → app-CbBFax2C.js} +3 -3
  51. package/dist/client/assets/{app-B2ZxMfJT.js → app-DORKrGAz.js} +3 -3
  52. package/dist/client/assets/{app-Ri9Te2Dv.js → app-DQibuQ9z.js} +3 -3
  53. package/dist/client/assets/app-k76GrQP0.js +13 -0
  54. package/dist/client/assets/{createLucideIcon-BYvOGRiO.js → createLucideIcon-DmVITl5d.js} +1 -1
  55. package/dist/client/assets/{dashboard-view-C3SsjXo1.js → dashboard-view-B4LXCv5H.js} +4 -4
  56. package/dist/client/assets/{dashboard-view-DlKsVySD.js → dashboard-view-BFoyhTYF.js} +1 -1
  57. package/dist/client/assets/{dashboard-view-DtgLZ_LM.js → dashboard-view-Cxzm_eqc.js} +1 -1
  58. package/dist/client/assets/dashboard-view-_8yfNwju.css +1 -0
  59. package/dist/client/assets/{folder-open-BlPyIWyf.js → folder-open-33WGVnbp.js} +1 -1
  60. package/dist/client/assets/index-Bglw05rM.js +791 -0
  61. package/dist/client/assets/index-FYgnflWD.css +1 -0
  62. package/dist/client/assets/shield-alert-CqeGWBUK.js +6 -0
  63. package/dist/client/assets/{upload-B-qmm1NH.js → upload-C11J2diI.js} +1 -1
  64. package/dist/client/assets/{users-D6C5TXf2.js → users-a-ELbyyZ.js} +1 -1
  65. package/dist/client/assets/{vendor-i18n-CV0mwX_-.js → vendor-i18n-DYlSUfot.js} +1 -1
  66. package/dist/client/assets/vendor-react-BTgM5bbZ.js +49 -0
  67. package/dist/client/assets/{vendor-reactflow-CRZcPCXF.js → vendor-reactflow-ZasuEfF9.js} +3 -3
  68. package/dist/client/assets/{vendor-xterm-tcedd2Jt.js → vendor-xterm-ea7-K05z.js} +1 -1
  69. package/dist/client/assets/{x-CqNX_9E2.js → x-PItWc-wQ.js} +1 -1
  70. package/dist/client/assets/{zoom-out-CSTHbJgD.js → zoom-out-CzlHv0Kj.js} +1 -1
  71. package/dist/client/icons/icon-192.png +0 -0
  72. package/dist/client/icons/icon-512.png +0 -0
  73. package/dist/client/index.html +6 -6
  74. package/dist/client/sw.js +1 -1
  75. package/dist/client/theme-data.css +288 -285
  76. package/dist/client/version.json +1 -1
  77. package/dist/droid-cli/package.json +1 -1
  78. package/dist/droid-cli/src/__tests__/execsync-guard.test.ts +17 -0
  79. package/dist/droid-cli/src/__tests__/process-manager.test.ts +1 -45
  80. package/dist/extension.js +4946 -820
  81. package/dist/pi-claude-cli/index.ts +47 -8
  82. package/dist/pi-claude-cli/package.json +4 -1
  83. package/dist/pi-claude-cli/src/__tests__/acp-dispatch.test.ts +80 -0
  84. package/dist/pi-claude-cli/src/__tests__/acp-driver.test.ts +423 -0
  85. package/dist/pi-claude-cli/src/__tests__/execsync-guard.test.ts +14 -0
  86. package/dist/pi-claude-cli/src/__tests__/process-manager.test.ts +1 -45
  87. package/dist/pi-claude-cli/src/__tests__/provider.test.ts +0 -1
  88. package/dist/pi-claude-cli/src/acp-driver.ts +585 -0
  89. package/dist/pi-claude-cli/src/event-bridge.ts +7 -0
  90. package/dist/pi-claude-cli/src/mcp-config.ts +29 -0
  91. package/dist/pi-claude-cli/src/process-manager.ts +6 -38
  92. package/dist/plugin-sdk/index.d.ts +5181 -5144
  93. package/dist/plugins/fusion-plugin-acp-runtime/bundled.js +263 -19
  94. package/dist/plugins/fusion-plugin-acp-runtime/package.json +1 -1
  95. package/dist/plugins/fusion-plugin-cli-printing-press/package.json +1 -1
  96. package/dist/plugins/fusion-plugin-compound-engineering/bundled.js +37 -7
  97. package/dist/plugins/fusion-plugin-compound-engineering/manifest.json +4 -4
  98. package/dist/plugins/fusion-plugin-compound-engineering/package.json +1 -1
  99. package/dist/plugins/fusion-plugin-cursor-runtime/package.json +1 -1
  100. package/dist/plugins/fusion-plugin-dependency-graph/package.json +1 -1
  101. package/dist/plugins/fusion-plugin-droid-runtime/bundled.js +1 -1
  102. package/dist/plugins/fusion-plugin-droid-runtime/package.json +1 -1
  103. package/dist/plugins/fusion-plugin-hermes-runtime/package.json +1 -1
  104. package/dist/plugins/fusion-plugin-openclaw-runtime/package.json +1 -1
  105. package/dist/plugins/fusion-plugin-paperclip-runtime/package.json +1 -1
  106. package/dist/plugins/fusion-plugin-reports/package.json +1 -1
  107. package/dist/plugins/fusion-plugin-roadmap/package.json +1 -1
  108. package/dist/plugins/fusion-plugin-whatsapp-chat/package.json +1 -1
  109. package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts +5 -1
  110. package/package.json +1 -1
  111. package/skill/fusion/references/engine-tools.md +6 -4
  112. package/dist/client/assets/AgentDetailView-DiWELtb1.css +0 -1
  113. package/dist/client/assets/AgentsView-DfznJDek.css +0 -1
  114. package/dist/client/assets/ChatView-Wz_0goif.js +0 -6
  115. package/dist/client/assets/DevServerView-DRqQpHsc.js +0 -1
  116. package/dist/client/assets/DocumentsView-hamhUcvL.js +0 -1
  117. package/dist/client/assets/ExperimentalAgentOnboardingModal-BNpveCEk.css +0 -1
  118. package/dist/client/assets/GoalsView-2QOp9DA0.js +0 -2
  119. package/dist/client/assets/GoalsView-DI66UsjT.css +0 -1
  120. package/dist/client/assets/InsightsView-C9-mqVvf.css +0 -1
  121. package/dist/client/assets/MemoryView-B082KoR-.js +0 -2
  122. package/dist/client/assets/PluginManager-UHVLvWWl.css +0 -1
  123. package/dist/client/assets/PullRequestView-WQIS0zUC.css +0 -1
  124. package/dist/client/assets/ReliabilityView-CpELpGqw.js +0 -1
  125. package/dist/client/assets/ReliabilityView-eoKBhBc4.css +0 -1
  126. package/dist/client/assets/SecretsView-D5bHZ-wM.css +0 -1
  127. package/dist/client/assets/SessionTerminal-BiigPP2l.css +0 -1
  128. package/dist/client/assets/SessionTerminal-G70Jwkzs.js +0 -2
  129. package/dist/client/assets/SettingsModal-C0M1CqOu.js +0 -31
  130. package/dist/client/assets/SettingsModal-nF_GaeeK.css +0 -1
  131. package/dist/client/assets/WorkflowNodeEditor-BTu0D9c5.js +0 -8
  132. package/dist/client/assets/WorkflowNodeEditor-BokhW3de.css +0 -1
  133. package/dist/client/assets/app-CUu1sm3x.js +0 -13
  134. package/dist/client/assets/dashboard-view-Du53JbmQ.css +0 -1
  135. package/dist/client/assets/index-DqhCiU35.css +0 -1
  136. package/dist/client/assets/index-bZCpHtGq.js +0 -793
  137. package/dist/client/assets/vendor-react-D37HfzIf.js +0 -49
@@ -16,7 +16,6 @@ vi.mock("node:child_process", () => ({
16
16
  proc.pid = 12345;
17
17
  return proc;
18
18
  }),
19
- execSync: vi.fn(),
20
19
  }));
21
20
 
22
21
  const mocks = vi.hoisted(() => ({
@@ -38,15 +37,13 @@ vi.mock("node:os", () => ({
38
37
  tmpdir: mocks.tmpdir,
39
38
  }));
40
39
 
41
- import { spawn, execSync } from "node:child_process";
40
+ import { spawn } from "node:child_process";
42
41
  import {
43
42
  spawnClaude,
44
43
  buildClaudeSpawnArgs,
45
44
  writeUserMessage,
46
45
  cleanupProcess,
47
46
  captureStderr,
48
- validateCliPresence,
49
- validateCliAuth,
50
47
  validateCliPresenceAsync,
51
48
  validateCliAuthAsync,
52
49
  forceKillProcess,
@@ -368,47 +365,6 @@ describe("captureStderr", () => {
368
365
  });
369
366
  });
370
367
 
371
- describe("validateCliPresence", () => {
372
- it("does not throw when claude --version succeeds", () => {
373
- (execSync as any).mockReturnValue(Buffer.from("1.0.0"));
374
- expect(() => validateCliPresence()).not.toThrow();
375
- });
376
-
377
- it("throws with install instructions when claude --version fails", () => {
378
- (execSync as any).mockImplementation(() => {
379
- throw new Error("command not found");
380
- });
381
-
382
- expect(() => validateCliPresence()).toThrow();
383
- try {
384
- validateCliPresence();
385
- } catch (e: any) {
386
- expect(e.message).toContain("Claude Code CLI not found");
387
- expect(e.message).toContain("npm install");
388
- }
389
- });
390
- });
391
-
392
- describe("validateCliAuth", () => {
393
- it("returns true when claude auth status succeeds", () => {
394
- (execSync as any).mockReturnValue(Buffer.from("Logged in"));
395
- expect(validateCliAuth()).toBe(true);
396
- });
397
-
398
- it("returns false and warns when claude auth status fails", () => {
399
- (execSync as any).mockImplementation(() => {
400
- throw new Error("not authenticated");
401
- });
402
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
403
-
404
- expect(validateCliAuth()).toBe(false);
405
- expect(warnSpy).toHaveBeenCalledWith(
406
- expect.stringContaining("not authenticated"),
407
- );
408
- warnSpy.mockRestore();
409
- });
410
- });
411
-
412
368
  describe("validateCliPresenceAsync", () => {
413
369
  beforeEach(() => {
414
370
  vi.clearAllMocks();
@@ -20,7 +20,6 @@ vi.mock("node:child_process", () => ({
20
20
  (proc as any).pid = 99999;
21
21
  return proc;
22
22
  }),
23
- execSync: vi.fn(() => Buffer.from("1.0.0")),
24
23
  }));
25
24
 
26
25
  // Mock @earendil-works/pi-ai
@@ -0,0 +1,585 @@
1
+ /**
2
+ * ACP transport for the pi-claude-cli provider (U11 — Route A).
3
+ *
4
+ * `streamViaAcp` is the drop-in alternative to `streamViaCli` that drives Claude
5
+ * through the `claude-code-cli-acp` bridge over the Agent Client Protocol instead
6
+ * of `claude -p`. It returns the SAME `AssistantMessageEventStream` shape, so the
7
+ * provider's `streamSimple` can dispatch to either transport behind a kill-switch.
8
+ *
9
+ * Design (see plan U11):
10
+ * - Full-history prompt EVERY turn (`buildPrompt`) — the ACP path has no Claude
11
+ * `--resume`, so we never send the latest-turn-only `buildResumePrompt` (R13).
12
+ * - MCP tool SCHEMAS are forwarded on `session/new` so Claude knows the Fusion
13
+ * tools and emits correct `tool_use` calls; we DO NOT let the bridge execute
14
+ * them. We break early ONLY on a pi-known tool (mirroring the `-p` guard) — and
15
+ * surface it to pi, which runs the tool itself. Claude's INTERNAL tools
16
+ * (`ToolSearch`/`Task`/…) are NOT pi-known: we must NOT break on them, or we'd
17
+ * abort the turn before the real `fn_*` call (Claude uses `ToolSearch` to load
18
+ * deferred MCP tools first). See review P0 (correctness).
19
+ * - Translation reuses the tested `createEventBridge` by synthesizing Claude
20
+ * stream events from ACP `session/update`s, so pi event sequencing, tool-name
21
+ * mapping and arg translation are shared with the `-p` path.
22
+ * - Untrusted-output floor: agent text/thinking is control-char-stripped and
23
+ * byte-capped; identifiers are bounded (review P1, security).
24
+ *
25
+ * Auth: the bridge spawns the real `claude`, which authenticates from the host
26
+ * login/keychain session (R17). The bridge binary path is injected by the caller
27
+ * (engine seam, KTD10) — this module never reaches into the ACP plugin.
28
+ *
29
+ * NOT-YET-VERIFIED (kill-switch stays OFF until then): the bridge's tool-execution
30
+ * ordering (`session/request_permission` vs `tool_call` update) and native-tool
31
+ * (Read/Write/Bash) execution-prevention need a live behavioral test against the
32
+ * real bridge before any lane enables this path. `requestPermission` denies by
33
+ * default and we break early, but the TOCTOU window is unproven (review P2).
34
+ */
35
+
36
+ import { spawn, type ChildProcess } from "node:child_process";
37
+ import { Readable, Writable } from "node:stream";
38
+ import { isAbsolute, join } from "node:path";
39
+ import { existsSync, writeFileSync, unlinkSync } from "node:fs";
40
+ import { tmpdir } from "node:os";
41
+ import {
42
+ ClientSideConnection,
43
+ ndJsonStream,
44
+ PROTOCOL_VERSION,
45
+ } from "@agentclientprotocol/sdk";
46
+ import { AssistantMessageEventStream } from "@earendil-works/pi-ai";
47
+ import type { Api, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
48
+ import { buildPrompt, buildResumePrompt, buildSystemPrompt, type PiContext } from "./prompt-builder.js";
49
+ import { createEventBridge } from "./event-bridge.js";
50
+ import { registerProcess, captureStderr } from "./process-manager.js";
51
+ import { isPiKnownClaudeTool } from "./tool-mapping.js";
52
+ import type { ClaudeApiEvent } from "./types.js";
53
+
54
+ /** A stdio MCP server forwarded on `session/new` (schema-only — never executed here). */
55
+ export interface AcpMcpServerSpec {
56
+ name: string;
57
+ command: string;
58
+ args: string[];
59
+ env: { name: string; value: string }[];
60
+ }
61
+
62
+ /** Options for the ACP transport: pi's stream options plus ACP wiring. */
63
+ export type StreamViaAcpOptions = SimpleStreamOptions & {
64
+ cwd?: string;
65
+ /** Absolute path to the `claude-code-cli-acp` bridge binary (injected by the engine seam). */
66
+ bridgePath: string;
67
+ /** MCP servers (tool schemas) forwarded so Claude emits correct tool calls. */
68
+ mcpServers?: AcpMcpServerSpec[];
69
+ /** Env keys to forward to the bridge — filtered to the allow-list below regardless. */
70
+ bridgeEnv?: NodeJS.ProcessEnv;
71
+ };
72
+
73
+ const INITIALIZE_TIMEOUT_MS = 30_000;
74
+ /** Last-resort guard: kill a silent bridge. Mirrors streamViaCli (30 min). */
75
+ const INACTIVITY_TIMEOUT_MS = 30 * 60_000;
76
+ /** Untrusted-output bounds (review P1, security). */
77
+ const MAX_CHUNK_CHARS = 64 * 1024;
78
+ const MAX_TURN_CHARS = 5_000_000;
79
+ const MAX_ID_CHARS = 256;
80
+
81
+ /**
82
+ * FNXC:ClaudeAcp 2026-06-15-11:40:
83
+ * Cross-process signal for the dashboard: when the bridged `claude` can't
84
+ * authenticate (R17 — e.g. a detached daemon with no keychain), the turn comes
85
+ * back as "Not logged in · Please run /login" instead of a real answer. We
86
+ * record that here so `GET /providers/claude-cli/status` can surface it and the
87
+ * UI can prompt the user to fall back to `-p` or fix auth. A real response
88
+ * clears it. Best-effort; the path is recomputed identically dashboard-side.
89
+ */
90
+ export const ACP_BRIDGE_AUTH_SIGNAL_PATH = join(tmpdir(), "fusion-acp-bridge-auth.json");
91
+ const NOT_LOGGED_IN_RE = /not logged in|please run \/login/i;
92
+ let lastAuthFailed: boolean | undefined;
93
+
94
+ function recordBridgeAuthState(failed: boolean, reason?: string): void {
95
+ if (lastAuthFailed === failed) return; // only write on transition
96
+ lastAuthFailed = failed;
97
+ try {
98
+ if (failed) {
99
+ writeFileSync(
100
+ ACP_BRIDGE_AUTH_SIGNAL_PATH,
101
+ JSON.stringify({ authFailed: true, at: new Date().toISOString(), reason: reason ?? "Claude in the ACP bridge is not logged in" }),
102
+ );
103
+ } else {
104
+ unlinkSync(ACP_BRIDGE_AUTH_SIGNAL_PATH);
105
+ }
106
+ } catch {
107
+ /* best-effort signal — never let it affect the turn */
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Bridge subprocess env allow-list. The bridged `claude` needs HOME (for
113
+ * `~/.claude` auth/keychain, R17) and PATH; terminal vars improve rendering.
114
+ * Inherited `process.env` and any secret-bearing keys are NEVER forwarded — the
115
+ * filter is enforced HERE, not trusted from the caller (review P2, security).
116
+ */
117
+ const BRIDGE_ENV_ALLOWLIST = [
118
+ "HOME", "PATH", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE",
119
+ "TERM", "TERMINFO", "TMPDIR", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "COLORTERM",
120
+ ];
121
+
122
+ /**
123
+ * R17 opt-in (detached-daemon auth): a headless daemon can't reach the macOS
124
+ * login Keychain, so `claude` reports "Not logged in". When an operator sets
125
+ * `FUSION_CLAUDE_ACP_FORWARD_AUTH=1` AND provides one of these in the launch
126
+ * environment, we forward it (and ONLY it) so the bridged `claude` can
127
+ * authenticate non-interactively. Default OFF — no secret-bearing var ever
128
+ * reaches the untrusted bridge otherwise. Mirrors the native claude-code
129
+ * adapter's recognized auth vars.
130
+ *
131
+ * Security trade-off (state it where the operator opts in): once forwarded, the
132
+ * token is visible to the bridge subprocess AND everything it spawns (including
133
+ * MCP servers that inherit env). It is NOT in prompt/model context, so the model
134
+ * can't read it, but opting in widens exposure to the bridge process tree.
135
+ * Prefer a scoped/rotatable `CLAUDE_CODE_OAUTH_TOKEN` (`claude setup-token`).
136
+ */
137
+ const BRIDGE_AUTH_ENV_KEYS = ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"];
138
+
139
+ export function buildBridgeEnv(supplied?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
140
+ const source = supplied ?? process.env;
141
+ const env: NodeJS.ProcessEnv = {};
142
+ for (const key of BRIDGE_ENV_ALLOWLIST) {
143
+ const v = source[key];
144
+ if (typeof v === "string") env[key] = v;
145
+ }
146
+ // Opt-in only: forward a single Claude auth token from the operator's launch
147
+ // env (always process.env, never the caller-supplied object).
148
+ if (process.env.FUSION_CLAUDE_ACP_FORWARD_AUTH === "1") {
149
+ for (const key of BRIDGE_AUTH_ENV_KEYS) {
150
+ const v = process.env[key];
151
+ // Treat a whitespace-only value as absent, so a blank higher-preference
152
+ // var doesn't shadow a real lower-preference token (and we never forward
153
+ // a useless blank token).
154
+ if (typeof v === "string" && v.trim().length > 0) {
155
+ env[key] = v;
156
+ break; // forward only the highest-preference token that's present
157
+ }
158
+ }
159
+ }
160
+ return env;
161
+ }
162
+
163
+ /** Strip ANSI escape sequences and C0/C1 control chars (keep \n \r \t), then cap length. */
164
+ function sanitizeText(text: string, cap = MAX_CHUNK_CHARS): string {
165
+ // eslint-disable-next-line no-control-regex
166
+ const stripped = text.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
167
+ return stripped.length > cap ? stripped.slice(0, cap) : stripped;
168
+ }
169
+
170
+ /** Bound an untrusted identifier (tool id / name) before it becomes a content-block key. */
171
+ function boundId(s: string): string {
172
+ // eslint-disable-next-line no-control-regex
173
+ return s.replace(/[\x00-\x1f\x7f/\\]/g, "").slice(0, MAX_ID_CHARS);
174
+ }
175
+
176
+ /**
177
+ * Convert buildPrompt's output into ACP prompt content blocks, PRESERVING image
178
+ * blocks (review P1, correctness — flatten-to-text dropped vision input).
179
+ */
180
+ function toAcpPromptBlocks(
181
+ prompt: string | Array<Record<string, unknown>>,
182
+ ): Array<Record<string, unknown>> {
183
+ if (typeof prompt === "string") return [{ type: "text", text: prompt }];
184
+ const out: Array<Record<string, unknown>> = [];
185
+ for (const b of prompt) {
186
+ if (b.type === "text" && typeof b.text === "string") {
187
+ out.push({ type: "text", text: b.text });
188
+ } else if (b.type === "image" && b.source && typeof b.source === "object") {
189
+ const src = b.source as { media_type?: string; data?: string };
190
+ if (src.data) out.push({ type: "image", mimeType: src.media_type ?? "image/png", data: src.data });
191
+ }
192
+ }
193
+ return out;
194
+ }
195
+
196
+ /**
197
+ * FNXC:ClaudeAcp 2026-06-15-14:10:
198
+ * Connection-reuse cache (item 1 / OQ2). Gated behind `FUSION_CLAUDE_ACP_REUSE`
199
+ * (default OFF). When on, a live bridge connection + ACP session is kept warm
200
+ * across turns of one conversation (keyed by the stable `options.sessionId`), so
201
+ * multi-turn lanes skip the cold bridge+claude spawn, the `session/new`
202
+ * round-trip, AND the full-history resend — sending only `buildResumePrompt`
203
+ * (delta) on reuse. A stable `router` indirection lets the long-lived connection
204
+ * handler serve each turn's fresh per-call state. Default OFF → the cold path
205
+ * below is functionally unchanged.
206
+ */
207
+ const REUSE_IDLE_MS = 5 * 60_000;
208
+ interface AcpRouter {
209
+ onUpdate: ((p: { update?: Record<string, unknown> } & Record<string, unknown>) => Promise<void>) | null;
210
+ onPermission: ((p: Record<string, unknown>) => Promise<{ outcome: { outcome: "cancelled" } }>) | null;
211
+ // Liveness: invoked when the warm child dies so the turn CURRENTLY owning the
212
+ // connection fails fast instead of hanging until the inactivity timeout. The
213
+ // long-lived `child.on("close")` is bound to the cold turn's closure, so
214
+ // without this a reuse turn's death would never reach its own `failWith`.
215
+ // Repointed to each turn's `failWith`; nulled on release (idle → just evict).
216
+ fail: ((msg: string) => void) | null;
217
+ }
218
+ interface CachedAcpConn {
219
+ conn: ClientSideConnection;
220
+ child: ChildProcess;
221
+ acpSessionId: string;
222
+ cwd: string;
223
+ inUse: boolean;
224
+ router: AcpRouter;
225
+ idleTimer?: ReturnType<typeof setTimeout>;
226
+ // Monotonic id of the turn currently owning the connection. A stray
227
+ // session/update from a finished turn is dropped when it no longer matches.
228
+ activeTurn: number;
229
+ }
230
+ const acpSessionCache = new Map<string, CachedAcpConn>();
231
+ let acpTurnCounter = 0;
232
+ function acpReuseEnabled(): boolean {
233
+ return process.env.FUSION_CLAUDE_ACP_REUSE === "1";
234
+ }
235
+ /**
236
+ * Kill a cached connection's child and evict it — but only delete the map key
237
+ * if it STILL points at this exact entry. A concurrent cold turn may have
238
+ * replaced the entry under the same key; a stale close handler / idle timer
239
+ * must not evict (or kill the child of) that newer, live entry. The passed
240
+ * entry's own child is always killed (it is the dead/finished one).
241
+ */
242
+ function evictCachedAcpConn(key: string, entry: CachedAcpConn): void {
243
+ if (acpSessionCache.get(key) === entry) acpSessionCache.delete(key);
244
+ if (entry.idleTimer) { clearTimeout(entry.idleTimer); entry.idleTimer = undefined; }
245
+ entry.router.onUpdate = null;
246
+ entry.router.onPermission = null;
247
+ entry.router.fail = null;
248
+ try { entry.child.kill("SIGKILL"); } catch { /* registry SIGKILL is authoritative */ }
249
+ }
250
+
251
+ /**
252
+ * Stream a Claude response via the ACP bridge as an `AssistantMessageEventStream`.
253
+ * Mirrors `streamViaCli`'s contract (start → deltas → done; break-early on tools).
254
+ */
255
+ export function streamViaAcp(
256
+ model: Model<Api>,
257
+ context: PiContext,
258
+ options: StreamViaAcpOptions,
259
+ ): AssistantMessageEventStream {
260
+ // @ts-expect-error — pi-ai exports AssistantMessageEventStream as a type; the
261
+ // constructor exists at runtime (same workaround as streamViaCli).
262
+ const stream = new AssistantMessageEventStream();
263
+ const bridge = createEventBridge(stream, model);
264
+
265
+ (async () => {
266
+ const cwd = options.cwd ?? process.cwd();
267
+ const reuseKey =
268
+ acpReuseEnabled() && options.sessionId && context.messages.length > 1
269
+ ? options.sessionId
270
+ : undefined;
271
+
272
+ let child: ChildProcess | undefined;
273
+ let getStderr: (() => string) | undefined;
274
+ let ended = false;
275
+ let turnChars = 0;
276
+ let blockIndex = -1;
277
+ let openKind: "text" | "thinking" | null = null;
278
+ let sawToolCall = false;
279
+ let inactivity: ReturnType<typeof setTimeout> | undefined;
280
+ let onAbort: (() => void) | undefined;
281
+ // The cache entry this turn is bound to (set on reuse, or after a cold turn
282
+ // caches its connection). Identity-checked against the map before release.
283
+ let cacheEntry: CachedAcpConn | undefined;
284
+ // This turn's monotonic id, stamped onto the shared cache entry when the
285
+ // turn acquires it. Handlers drop updates once the entry moves to a newer
286
+ // turn (defends the warm connection against cross-turn content bleed).
287
+ let myTurn = 0;
288
+
289
+ // End the turn. `destroy` kills+evicts the connection; otherwise a cached
290
+ // connection is released (kept warm for the next turn) and a one-shot
291
+ // (non-reuse) connection is killed.
292
+ const endTurn = (destroy: boolean) => {
293
+ if (inactivity) { clearTimeout(inactivity); inactivity = undefined; }
294
+ if (onAbort && options.signal) options.signal.removeEventListener("abort", onAbort);
295
+ const entry = cacheEntry;
296
+ // A tool-use turn breaks early while `conn.prompt()` is still pending (we
297
+ // never await it on break). Releasing the connection as warm here would
298
+ // let the next turn launch a SECOND concurrent prompt on the same ACP
299
+ // session — protocol corruption. So a tool-use turn always tears down,
300
+ // exactly like the non-reuse path; only a clean `stop` turn (prompt fully
301
+ // resolved before finish) keeps the connection warm.
302
+ const keepWarm =
303
+ !destroy && !sawToolCall && entry !== undefined && reuseKey !== undefined &&
304
+ acpSessionCache.get(reuseKey) === entry;
305
+ if (keepWarm) {
306
+ // Release the warm connection: drop this turn's handlers (so a late
307
+ // update can't reach a finished turn or the liveness hook fire stale),
308
+ // mark idle, and arm an unref'd reaper bound to THIS entry.
309
+ entry!.router.onUpdate = null;
310
+ entry!.router.onPermission = null;
311
+ entry!.router.fail = null;
312
+ entry!.inUse = false;
313
+ if (entry!.idleTimer) clearTimeout(entry!.idleTimer);
314
+ const idle = setTimeout(() => evictCachedAcpConn(reuseKey!, entry!), REUSE_IDLE_MS);
315
+ idle.unref?.(); // a warm-connection idle timer must not keep the process alive
316
+ entry!.idleTimer = idle;
317
+ return;
318
+ }
319
+ if (entry !== undefined && reuseKey !== undefined) {
320
+ // Kills this turn's child; evicts the map key only if still current.
321
+ evictCachedAcpConn(reuseKey, entry);
322
+ } else {
323
+ try { child?.kill("SIGKILL"); } catch { /* registry SIGKILL is authoritative */ }
324
+ }
325
+ };
326
+ const armInactivity = () => {
327
+ if (inactivity) clearTimeout(inactivity);
328
+ inactivity = setTimeout(() => failWith(`ACP bridge inactivity timeout after ${INACTIVITY_TIMEOUT_MS / 1000}s`), INACTIVITY_TIMEOUT_MS);
329
+ };
330
+
331
+ const finish = (reason: "stop" | "tool_use") => {
332
+ if (ended) return;
333
+ ended = true;
334
+ if (openKind !== null) bridge.handleEvent({ type: "content_block_stop", index: blockIndex } as ClaudeApiEvent);
335
+ // Downgrade a tool_use turn that surfaced zero pi tool calls → stop, so pi
336
+ // doesn't try to dispatch non-existent tools (mirrors provider.ts:366-375).
337
+ const toolCount = (bridge.getOutput().content ?? []).filter((c) => (c as { type?: string }).type === "toolCall").length;
338
+ // R17: a turn that is ONLY "Not logged in" (no tools, no real text) means
339
+ // the bridged `claude` can't authenticate — signal it for the UI. A real
340
+ // response (tools or non-trivial text) clears the signal.
341
+ const trimmedText = (bridge.getOutput().content ?? [])
342
+ .filter((c) => (c as { type?: string }).type === "text")
343
+ .map((c) => (c as { text?: string }).text ?? "")
344
+ .join("")
345
+ .trim();
346
+ // Only treat it as an auth failure when the WHOLE turn is essentially the
347
+ // bridge's short "Not logged in · Please run /login" message — not when a
348
+ // long, legitimate answer merely mentions the phrase (avoids false positives).
349
+ const isAuthFailure =
350
+ toolCount === 0 && trimmedText.length > 0 && trimmedText.length <= 80 && NOT_LOGGED_IN_RE.test(trimmedText);
351
+ if (isAuthFailure) recordBridgeAuthState(true);
352
+ else if (toolCount > 0 || trimmedText.length > 0) recordBridgeAuthState(false);
353
+ const effective: "stop" | "tool_use" = reason === "tool_use" && toolCount > 0 ? "tool_use" : "stop";
354
+ bridge.handleEvent({ type: "message_delta", delta: { stop_reason: effective === "tool_use" ? "tool_use" : "end_turn" } } as ClaudeApiEvent);
355
+ stream.push({ type: "done", reason: effective === "tool_use" ? "toolUse" : "stop", message: bridge.getOutput() });
356
+ stream.end();
357
+ endTurn(false); // clean turn → keep a cached connection warm for next turn
358
+ };
359
+
360
+ const failWith = (msg: string) => {
361
+ if (ended) return;
362
+ ended = true;
363
+ const output = bridge.getOutput();
364
+ stream.push({
365
+ type: "done",
366
+ reason: "stop",
367
+ message: {
368
+ ...output,
369
+ content: output.content?.length ? output.content : [{ type: "text" as const, text: `Error: ${msg}` }],
370
+ stopReason: "stop" as const,
371
+ },
372
+ });
373
+ stream.end();
374
+ endTurn(true); // failed turn → destroy the connection (never reuse a broken one)
375
+ };
376
+
377
+ const openBlock = (kind: "text" | "thinking") => {
378
+ if (openKind === kind) return;
379
+ if (openKind !== null) bridge.handleEvent({ type: "content_block_stop", index: blockIndex } as ClaudeApiEvent);
380
+ blockIndex += 1;
381
+ openKind = kind;
382
+ bridge.handleEvent({ type: "content_block_start", index: blockIndex, content_block: { type: kind } } as ClaudeApiEvent);
383
+ };
384
+
385
+ // Surface a pi-known tool call to pi and break early (pi executes it, not the bridge).
386
+ const surfaceToolAndBreak = (claudeName: string, rawId: string, rawInput: unknown) => {
387
+ const id = boundId(rawId);
388
+ if (openKind !== null) { bridge.handleEvent({ type: "content_block_stop", index: blockIndex } as ClaudeApiEvent); openKind = null; }
389
+ blockIndex += 1;
390
+ bridge.handleEvent({ type: "content_block_start", index: blockIndex, content_block: { type: "tool_use", name: boundId(claudeName), id } } as ClaudeApiEvent);
391
+ bridge.handleEvent({ type: "content_block_delta", index: blockIndex, delta: { type: "input_json_delta", partial_json: sanitizeText(JSON.stringify(rawInput ?? {})) } } as ClaudeApiEvent);
392
+ bridge.handleEvent({ type: "content_block_stop", index: blockIndex } as ClaudeApiEvent);
393
+ sawToolCall = true;
394
+ finish("tool_use");
395
+ };
396
+
397
+ const handleUpdate = async (params: { update?: Record<string, unknown> } & Record<string, unknown>): Promise<void> => {
398
+ if (ended) return;
399
+ // The warm connection is shared across turns; ignore a stray update once
400
+ // the entry has been handed to a newer turn (cross-turn bleed guard).
401
+ if (cacheEntry && cacheEntry.activeTurn !== myTurn) return;
402
+ armInactivity();
403
+ const u = (params.update ?? params) as Record<string, unknown>;
404
+ const kind = u.sessionUpdate as string;
405
+ const content = u.content as { type?: string; text?: string } | undefined;
406
+
407
+ if (kind === "agent_message_chunk" && content?.type === "text" && content.text) {
408
+ if (turnChars >= MAX_TURN_CHARS) return;
409
+ openBlock("text");
410
+ const text = sanitizeText(content.text);
411
+ turnChars += text.length;
412
+ bridge.handleEvent({ type: "content_block_delta", index: blockIndex, delta: { type: "text_delta", text } } as ClaudeApiEvent);
413
+ } else if (kind === "agent_thought_chunk" && content?.text) {
414
+ if (turnChars >= MAX_TURN_CHARS) return;
415
+ openBlock("thinking");
416
+ const text = sanitizeText(content.text);
417
+ turnChars += text.length;
418
+ bridge.handleEvent({ type: "content_block_delta", index: blockIndex, delta: { type: "thinking_delta", thinking: text } } as ClaudeApiEvent);
419
+ } else if (kind === "tool_call") {
420
+ // Break early ONLY on a pi-known tool. Claude's internal tools
421
+ // (ToolSearch/Task/…) are not pi-known — let the bridge run them so
422
+ // Claude can load deferred MCP schemas and emit the real fn_* call.
423
+ const claudeName = ((u._meta as { claudeCode?: { toolName?: string } } | undefined)?.claudeCode?.toolName) ?? (u.title as string) ?? "";
424
+ if (isPiKnownClaudeTool(claudeName)) {
425
+ surfaceToolAndBreak(claudeName, (u.toolCallId as string) ?? `acp_${blockIndex + 1}`, u.rawInput ?? u.input);
426
+ }
427
+ }
428
+ };
429
+
430
+ const handlePermission = async (params: Record<string, unknown>): Promise<{ outcome: { outcome: "cancelled" } }> => {
431
+ // A permission request means the bridge is about to EXECUTE a tool. For a
432
+ // pi-known tool, surface it to pi and break early (pi executes it); deny
433
+ // by default otherwise. We always return cancelled so the bridge never
434
+ // executes Fusion's tools itself.
435
+ if (!ended && !(cacheEntry && cacheEntry.activeTurn !== myTurn)) {
436
+ const tc = (params.toolCall ?? {}) as Record<string, unknown>;
437
+ const claudeName = ((tc._meta as { claudeCode?: { toolName?: string } } | undefined)?.claudeCode?.toolName) ?? (tc.title as string) ?? "";
438
+ if (isPiKnownClaudeTool(claudeName)) {
439
+ surfaceToolAndBreak(claudeName, (tc.toolCallId as string) ?? `acp_${blockIndex + 1}`, tc.rawInput ?? tc.input);
440
+ }
441
+ }
442
+ return { outcome: { outcome: "cancelled" as const } };
443
+ };
444
+
445
+ // Usage emission (OQ3) — shared by the cold + reuse paths. Coerces the
446
+ // untrusted bridge usage payload to finite, non-negative numbers.
447
+ const emitUsage = (res: unknown): void => {
448
+ if (sawToolCall) return;
449
+ const u = (res as { usage?: Record<string, unknown> }).usage;
450
+ if (!u) return;
451
+ const num = (x: unknown): number | undefined =>
452
+ typeof x === "number" && Number.isFinite(x) && x >= 0 ? x : undefined;
453
+ bridge.handleEvent({
454
+ type: "message_delta",
455
+ delta: {},
456
+ usage: {
457
+ input_tokens: num(u.inputTokens),
458
+ output_tokens: num(u.outputTokens),
459
+ cache_read_input_tokens: num(u.cachedReadTokens),
460
+ cache_creation_input_tokens: num(u.cachedWriteTokens),
461
+ },
462
+ } as ClaudeApiEvent);
463
+ };
464
+
465
+ try {
466
+ const withTimeout = <T>(p: Promise<T>, label: string) =>
467
+ Promise.race([p, new Promise<never>((_, rej) => setTimeout(() => rej(new Error(`ACP ${label} timeout`)), INITIALIZE_TIMEOUT_MS))]);
468
+
469
+ // ── Reuse path: a warm connection for this conversation exists ──────────
470
+ // Skip spawn + initialize + session/new, and send ONLY the latest-turn
471
+ // delta (`buildResumePrompt`) because the warm `claude` session already
472
+ // holds the prior turns server-side (sending full history would duplicate
473
+ // it). Gated by `reuseKey`, which is undefined unless reuse is enabled.
474
+ let warm = reuseKey ? acpSessionCache.get(reuseKey) : undefined;
475
+ // Never reuse a busy connection or one bound to a different cwd.
476
+ if (warm && (warm.inUse || warm.cwd !== cwd)) warm = undefined;
477
+ // A reuse turn sends only the delta; if there's nothing new to send, an
478
+ // empty prompt to the warm session could hang. Drop the warm connection
479
+ // and cold-start with full history instead.
480
+ let resumeBlocks: ReturnType<typeof toAcpPromptBlocks> | undefined;
481
+ if (warm && reuseKey) {
482
+ const resume = buildResumePrompt(context);
483
+ const resumeEmpty = typeof resume === "string" ? resume.trim() === "" : resume.length === 0;
484
+ if (resumeEmpty) { evictCachedAcpConn(reuseKey, warm); warm = undefined; }
485
+ else resumeBlocks = toAcpPromptBlocks(resume as string | Array<Record<string, unknown>>);
486
+ }
487
+ if (warm && reuseKey && resumeBlocks) {
488
+ cacheEntry = warm;
489
+ myTurn = ++acpTurnCounter;
490
+ warm.activeTurn = myTurn;
491
+ warm.inUse = true;
492
+ if (warm.idleTimer) { clearTimeout(warm.idleTimer); warm.idleTimer = undefined; }
493
+ warm.router.onUpdate = handleUpdate;
494
+ warm.router.onPermission = handlePermission;
495
+ warm.router.fail = failWith; // a warm-child death now fails THIS turn fast
496
+ child = warm.child;
497
+ onAbort = () => failWith("aborted");
498
+ if (options.signal) options.signal.addEventListener("abort", onAbort, { once: true });
499
+ armInactivity();
500
+
501
+ // ACP ContentBlock[] — text/image shapes match; cast through unknown.
502
+ const res = await warm.conn.prompt({ sessionId: warm.acpSessionId, prompt: resumeBlocks as unknown as Parameters<typeof warm.conn.prompt>[0]["prompt"] });
503
+ if (ended) return;
504
+ emitUsage(res);
505
+ if (!sawToolCall) finish("stop");
506
+ return;
507
+ }
508
+
509
+ // ── Cold path: spawn the bridge and open a fresh ACP session ───────────
510
+ if (!isAbsolute(options.bridgePath) || !existsSync(options.bridgePath)) {
511
+ failWith(`ACP bridge path invalid (must be an absolute, existing binary): ${options.bridgePath}`);
512
+ return;
513
+ }
514
+ child = spawn(options.bridgePath, [], { stdio: ["pipe", "pipe", "pipe"], cwd, env: buildBridgeEnv(options.bridgeEnv) });
515
+ registerProcess(child);
516
+ getStderr = captureStderr(child);
517
+ // Stable router indirection: the long-lived connection + child handlers
518
+ // always dispatch to whichever turn currently owns the connection. On
519
+ // reuse we repoint `router.*` at the new turn; `router.fail` lets a
520
+ // warm-child death fail the CURRENT owner (not the cold turn it spawned).
521
+ const router: AcpRouter = { onUpdate: handleUpdate, onPermission: handlePermission, fail: failWith };
522
+ child.on("error", (e) => router.fail?.(`ACP bridge spawn failed: ${e.message}`));
523
+ child.on("close", (code) => {
524
+ const msg = `ACP bridge exited (code ${code ?? "?"})${getStderr ? `: ${getStderr().slice(-500)}` : ""}`;
525
+ const fail = router.fail; // capture before evict nulls it
526
+ if (reuseKey && cacheEntry) evictCachedAcpConn(reuseKey, cacheEntry); // a dead child can never be reused
527
+ fail?.(msg); // fail the owning turn (no-op if idle / already ended)
528
+ });
529
+ onAbort = () => failWith("aborted");
530
+ if (options.signal) options.signal.addEventListener("abort", onAbort, { once: true });
531
+ armInactivity();
532
+
533
+ const acpStream = ndJsonStream(
534
+ Writable.toWeb(child.stdin!) as unknown as WritableStream<Uint8Array>,
535
+ Readable.toWeb(child.stdout!) as unknown as ReadableStream<Uint8Array>,
536
+ );
537
+ const conn = new ClientSideConnection(
538
+ () => ({
539
+ sessionUpdate: (p) => router.onUpdate?.(p as Parameters<NonNullable<AcpRouter["onUpdate"]>>[0]) ?? Promise.resolve(),
540
+ requestPermission: (p) =>
541
+ router.onPermission?.(p as Parameters<NonNullable<AcpRouter["onPermission"]>>[0]) ??
542
+ Promise.resolve({ outcome: { outcome: "cancelled" as const } }),
543
+ }),
544
+ acpStream,
545
+ );
546
+
547
+ const init = await withTimeout(
548
+ conn.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } } }),
549
+ "initialize",
550
+ );
551
+ if (ended) return;
552
+ if (init.protocolVersion !== PROTOCOL_VERSION) { failWith(`incompatible ACP protocol ${init.protocolVersion}`); return; }
553
+
554
+ const opened = await withTimeout(conn.newSession({ cwd, mcpServers: options.mcpServers ?? [] }), "newSession");
555
+ if (ended) return;
556
+
557
+ // Cache the warm connection so the next turn of this conversation reuses
558
+ // it. Only when reuse is enabled (reuseKey set) and the child is live.
559
+ if (reuseKey) {
560
+ myTurn = ++acpTurnCounter;
561
+ cacheEntry = { conn, child, acpSessionId: opened.sessionId, cwd, inUse: true, router, activeTurn: myTurn };
562
+ acpSessionCache.set(reuseKey, cacheEntry);
563
+ }
564
+
565
+ const systemPrompt = buildSystemPrompt(context, cwd);
566
+ const blocks = [
567
+ ...(systemPrompt ? [{ type: "text" as const, text: `${systemPrompt}\n\n` }] : []),
568
+ ...toAcpPromptBlocks(buildPrompt(context) as string | Array<Record<string, unknown>>),
569
+ ];
570
+
571
+ // ACP ContentBlock[] — text/image shapes match; cast through unknown.
572
+ const res = await conn.prompt({ sessionId: opened.sessionId, prompt: blocks as unknown as Parameters<typeof conn.prompt>[0]["prompt"] });
573
+ if (ended) return;
574
+ // Feed token usage (experimental ACP field) into the bridge BEFORE finish()
575
+ // so it lands in the `done` message. Tool-use turns break early and never
576
+ // resolve here, so they inherently report zero usage. Zero-when-absent safe.
577
+ emitUsage(res);
578
+ if (!sawToolCall) finish("stop");
579
+ } catch (err) {
580
+ failWith(err instanceof Error ? err.message : String(err));
581
+ }
582
+ })();
583
+
584
+ return stream;
585
+ }