@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.
- package/dist/bin.js +8264 -1823
- package/dist/client/.vite/manifest.json +277 -266
- package/dist/client/assets/AgentDetailView-CanIeYHg.css +1 -0
- package/dist/client/assets/{AgentDetailView-FUKwQxBP.js → AgentDetailView-DxV8pFHX.js} +3 -3
- package/dist/client/assets/{AgentPermissionPolicyEditor-DMDUhF1n.js → AgentPermissionPolicyEditor--YS4iMUY.js} +2 -2
- package/dist/client/assets/AgentsView-BVILQjdS.css +1 -0
- package/dist/client/assets/{AgentsView-BrQLd-yI.js → AgentsView-CTzbcvhK.js} +3 -3
- package/dist/client/assets/ChatView-COygcnyk.js +6 -0
- package/dist/client/assets/CommandCenter-CTaopaC0.css +1 -0
- package/dist/client/assets/CommandCenter-DoKX2HWm.js +56 -0
- package/dist/client/assets/DevServerView-Cyevyvqx.js +1 -0
- package/dist/client/assets/{DirectoryPicker-DTKtd8L2.css → DirectoryPicker-0rspvcMz.css} +1 -1
- package/dist/client/assets/{DirectoryPicker-BFot4CVM.js → DirectoryPicker-DBdyXEhR.js} +1 -1
- package/dist/client/assets/DocumentsView-BzSMyrns.js +1 -0
- package/dist/client/assets/{EvalsView-DAXqDqn2.js → EvalsView-ikz00o9g.js} +1 -1
- package/dist/client/assets/ExperimentalAgentOnboardingModal-CZ_jbgep.css +1 -0
- package/dist/client/assets/{ExperimentalAgentOnboardingModal-B4aZ1y0n.js → ExperimentalAgentOnboardingModal-D-rzCCET.js} +10 -10
- package/dist/client/assets/GoalsView-Bfu1Oy2-.js +2 -0
- package/dist/client/assets/GoalsView-rg9VSIza.css +1 -0
- package/dist/client/assets/{InsightsView-D5fzFy-F.js → InsightsView-Cgmf5nES.js} +1 -1
- package/dist/client/assets/InsightsView-LMv1M1dU.css +1 -0
- package/dist/client/assets/MemoryView-DkEuDKQs.js +2 -0
- package/dist/client/assets/{NodesView-BA3pKMZu.js → NodesView-B_KYfS4o.js} +3 -3
- package/dist/client/assets/{PiExtensionsManager-C4I11iKm.js → PiExtensionsManager-Dz69kkKs.js} +2 -2
- package/dist/client/assets/PluginManager-DSiHizRD.css +1 -0
- package/dist/client/assets/{PluginManager-BzTzTMXZ.js → PluginManager-LHCVcduP.js} +1 -1
- package/dist/client/assets/PullRequestView-3AdbS2a1.css +1 -0
- package/dist/client/assets/{PullRequestView-11xJ565q.js → PullRequestView-DBJutLLl.js} +2 -2
- package/dist/client/assets/{ResearchView-BHiXfjzE.js → ResearchView-gpn1XcVy.js} +1 -1
- package/dist/client/assets/{SecretsView-CC5OYoCn.js → SecretsView-B7YqIowt.js} +1 -1
- package/dist/client/assets/SecretsView-Djo1rahi.css +1 -0
- package/dist/client/assets/SessionTerminal-9l4bdKcn.js +2 -0
- package/dist/client/assets/SessionTerminal-BNgtmXb3.css +1 -0
- package/dist/client/assets/SettingsModal-BGEK9_9A.js +31 -0
- package/dist/client/assets/{SettingsModal-DpAxDq6M.css → SettingsModal-CKv0TJfM.css} +1 -1
- package/dist/client/assets/SettingsModal-CXogw-Er.css +1 -0
- package/dist/client/assets/{SettingsModal-BKEbtdqR.js → SettingsModal-DIIoHGUH.js} +1 -1
- package/dist/client/assets/{SetupWizardModal-CBfItlP6.js → SetupWizardModal-BcVeMxeX.js} +1 -1
- package/dist/client/assets/{SkillsView-DbcW87-E.js → SkillsView-BxSJ4PHL.js} +1 -1
- package/dist/client/assets/{StashRecoveryView-CH9RPtzX.js → StashRecoveryView-BhFQ2FsP.js} +1 -1
- package/dist/client/assets/{TodoView-ToviJ0SV.js → TodoView-BZpewjy0.js} +2 -2
- package/dist/client/assets/WorkflowNodeEditor-Yn1FRF2v.css +1 -0
- package/dist/client/assets/WorkflowNodeEditor-tWsgCdVt.js +8 -0
- package/dist/client/assets/{addon-fit-B4jkcKk4.js → addon-fit-SnYDEvGm.js} +1 -1
- package/dist/client/assets/{addon-unicode11-Cdne2Sj4.js → addon-unicode11-s5GoZCpi.js} +1 -1
- package/dist/client/assets/{addon-web-links-CRNLzj7m.js → addon-web-links-jjt2f_oV.js} +1 -1
- package/dist/client/assets/{addon-webgl-CeCSULF1.js → addon-webgl-Icn-RnEx.js} +1 -1
- package/dist/client/assets/{app-sEkTyT-w.js → app-BmGA0Hiz.js} +3 -3
- package/dist/client/assets/{app-BffGU_HQ.js → app-CEVYlVuJ.js} +3 -3
- package/dist/client/assets/{app-C8AHhs5N.js → app-CbBFax2C.js} +3 -3
- package/dist/client/assets/{app-B2ZxMfJT.js → app-DORKrGAz.js} +3 -3
- package/dist/client/assets/{app-Ri9Te2Dv.js → app-DQibuQ9z.js} +3 -3
- package/dist/client/assets/app-k76GrQP0.js +13 -0
- package/dist/client/assets/{createLucideIcon-BYvOGRiO.js → createLucideIcon-DmVITl5d.js} +1 -1
- package/dist/client/assets/{dashboard-view-C3SsjXo1.js → dashboard-view-B4LXCv5H.js} +4 -4
- package/dist/client/assets/{dashboard-view-DlKsVySD.js → dashboard-view-BFoyhTYF.js} +1 -1
- package/dist/client/assets/{dashboard-view-DtgLZ_LM.js → dashboard-view-Cxzm_eqc.js} +1 -1
- package/dist/client/assets/dashboard-view-_8yfNwju.css +1 -0
- package/dist/client/assets/{folder-open-BlPyIWyf.js → folder-open-33WGVnbp.js} +1 -1
- package/dist/client/assets/index-Bglw05rM.js +791 -0
- package/dist/client/assets/index-FYgnflWD.css +1 -0
- package/dist/client/assets/shield-alert-CqeGWBUK.js +6 -0
- package/dist/client/assets/{upload-B-qmm1NH.js → upload-C11J2diI.js} +1 -1
- package/dist/client/assets/{users-D6C5TXf2.js → users-a-ELbyyZ.js} +1 -1
- package/dist/client/assets/{vendor-i18n-CV0mwX_-.js → vendor-i18n-DYlSUfot.js} +1 -1
- package/dist/client/assets/vendor-react-BTgM5bbZ.js +49 -0
- package/dist/client/assets/{vendor-reactflow-CRZcPCXF.js → vendor-reactflow-ZasuEfF9.js} +3 -3
- package/dist/client/assets/{vendor-xterm-tcedd2Jt.js → vendor-xterm-ea7-K05z.js} +1 -1
- package/dist/client/assets/{x-CqNX_9E2.js → x-PItWc-wQ.js} +1 -1
- package/dist/client/assets/{zoom-out-CSTHbJgD.js → zoom-out-CzlHv0Kj.js} +1 -1
- package/dist/client/icons/icon-192.png +0 -0
- package/dist/client/icons/icon-512.png +0 -0
- package/dist/client/index.html +6 -6
- package/dist/client/sw.js +1 -1
- package/dist/client/theme-data.css +288 -285
- package/dist/client/version.json +1 -1
- package/dist/droid-cli/package.json +1 -1
- package/dist/droid-cli/src/__tests__/execsync-guard.test.ts +17 -0
- package/dist/droid-cli/src/__tests__/process-manager.test.ts +1 -45
- package/dist/extension.js +4946 -820
- package/dist/pi-claude-cli/index.ts +47 -8
- package/dist/pi-claude-cli/package.json +4 -1
- package/dist/pi-claude-cli/src/__tests__/acp-dispatch.test.ts +80 -0
- package/dist/pi-claude-cli/src/__tests__/acp-driver.test.ts +423 -0
- package/dist/pi-claude-cli/src/__tests__/execsync-guard.test.ts +14 -0
- package/dist/pi-claude-cli/src/__tests__/process-manager.test.ts +1 -45
- package/dist/pi-claude-cli/src/__tests__/provider.test.ts +0 -1
- package/dist/pi-claude-cli/src/acp-driver.ts +585 -0
- package/dist/pi-claude-cli/src/event-bridge.ts +7 -0
- package/dist/pi-claude-cli/src/mcp-config.ts +29 -0
- package/dist/pi-claude-cli/src/process-manager.ts +6 -38
- package/dist/plugin-sdk/index.d.ts +5181 -5144
- package/dist/plugins/fusion-plugin-acp-runtime/bundled.js +263 -19
- package/dist/plugins/fusion-plugin-acp-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-cli-printing-press/package.json +1 -1
- package/dist/plugins/fusion-plugin-compound-engineering/bundled.js +37 -7
- package/dist/plugins/fusion-plugin-compound-engineering/manifest.json +4 -4
- package/dist/plugins/fusion-plugin-compound-engineering/package.json +1 -1
- package/dist/plugins/fusion-plugin-cursor-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-dependency-graph/package.json +1 -1
- package/dist/plugins/fusion-plugin-droid-runtime/bundled.js +1 -1
- package/dist/plugins/fusion-plugin-droid-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-hermes-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-openclaw-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-paperclip-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-reports/package.json +1 -1
- package/dist/plugins/fusion-plugin-roadmap/package.json +1 -1
- package/dist/plugins/fusion-plugin-whatsapp-chat/package.json +1 -1
- package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts +5 -1
- package/package.json +1 -1
- package/skill/fusion/references/engine-tools.md +6 -4
- package/dist/client/assets/AgentDetailView-DiWELtb1.css +0 -1
- package/dist/client/assets/AgentsView-DfznJDek.css +0 -1
- package/dist/client/assets/ChatView-Wz_0goif.js +0 -6
- package/dist/client/assets/DevServerView-DRqQpHsc.js +0 -1
- package/dist/client/assets/DocumentsView-hamhUcvL.js +0 -1
- package/dist/client/assets/ExperimentalAgentOnboardingModal-BNpveCEk.css +0 -1
- package/dist/client/assets/GoalsView-2QOp9DA0.js +0 -2
- package/dist/client/assets/GoalsView-DI66UsjT.css +0 -1
- package/dist/client/assets/InsightsView-C9-mqVvf.css +0 -1
- package/dist/client/assets/MemoryView-B082KoR-.js +0 -2
- package/dist/client/assets/PluginManager-UHVLvWWl.css +0 -1
- package/dist/client/assets/PullRequestView-WQIS0zUC.css +0 -1
- package/dist/client/assets/ReliabilityView-CpELpGqw.js +0 -1
- package/dist/client/assets/ReliabilityView-eoKBhBc4.css +0 -1
- package/dist/client/assets/SecretsView-D5bHZ-wM.css +0 -1
- package/dist/client/assets/SessionTerminal-BiigPP2l.css +0 -1
- package/dist/client/assets/SessionTerminal-G70Jwkzs.js +0 -2
- package/dist/client/assets/SettingsModal-C0M1CqOu.js +0 -31
- package/dist/client/assets/SettingsModal-nF_GaeeK.css +0 -1
- package/dist/client/assets/WorkflowNodeEditor-BTu0D9c5.js +0 -8
- package/dist/client/assets/WorkflowNodeEditor-BokhW3de.css +0 -1
- package/dist/client/assets/app-CUu1sm3x.js +0 -13
- package/dist/client/assets/dashboard-view-Du53JbmQ.css +0 -1
- package/dist/client/assets/index-DqhCiU35.css +0 -1
- package/dist/client/assets/index-bZCpHtGq.js +0 -793
- 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
|
|
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();
|
|
@@ -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
|
+
}
|