@hellcoder/companion 0.99.2-preview.20260610105829.5d5d2f3 → 0.100.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/assets/{AgentsPage-tWiu1_AT.js → AgentsPage-7oHDiJoh.js} +1 -1
- package/dist/assets/{CronManager-BFVFYGxc.js → CronManager-fsEUcByi.js} +1 -1
- package/dist/assets/{IntegrationsPage-DRbbUsum.js → IntegrationsPage-DhiOq9T9.js} +1 -1
- package/dist/assets/{LinearOAuthSettingsPage-DbqiQRYU.js → LinearOAuthSettingsPage-DJ3p4Zyh.js} +1 -1
- package/dist/assets/{LinearSettingsPage-C9QVub6_.js → LinearSettingsPage-EJXrPlao.js} +1 -1
- package/dist/assets/{Playground-BYihPEC9.js → Playground-BJi1T7KP.js} +1 -1
- package/dist/assets/{PromptsPage-Cnhv4Der.js → PromptsPage-DiMm5U1r.js} +1 -1
- package/dist/assets/{RunsPage-NVlYvNqV.js → RunsPage-LPrcOaUc.js} +1 -1
- package/dist/assets/{SandboxManager-BKkKy3p4.js → SandboxManager-9KiHL5rI.js} +1 -1
- package/dist/assets/{SettingsPage-B9jbUi7A.js → SettingsPage-BXy1ZF1F.js} +1 -1
- package/dist/assets/{TailscalePage-Dj5FOGz6.js → TailscalePage-ycECxxya.js} +1 -1
- package/dist/assets/{index-DOmk_hhI.js → index-D-JiBkdW.js} +49 -49
- package/dist/assets/index-DwVmncqT.css +1 -0
- package/dist/assets/{sw-register-exbRbynw.js → sw-register-Duj0Mw6k.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +2 -1
- package/server/claude-adapter.live.ts +315 -0
- package/server/claude-adapter.ts +157 -6
- package/server/cli-launcher.test.ts +80 -45
- package/server/cli-launcher.ts +38 -92
- package/server/event-bus-types.ts +7 -0
- package/server/index.ts +1 -1
- package/server/session-orchestrator.ts +7 -0
- package/server/ws-bridge.ts +22 -13
- package/dist/assets/index-BjomRUsd.css +0 -1
|
@@ -84,12 +84,16 @@ function createMockProc(pid = 12345) {
|
|
|
84
84
|
resolve = r;
|
|
85
85
|
});
|
|
86
86
|
exitResolve = resolve!;
|
|
87
|
+
// The Claude stdio transport (ClaudeAdapter.attachStdio) needs a writable
|
|
88
|
+
// stdin and a readable stdout to bridge NDJSON. Provide inert streams that
|
|
89
|
+
// accept writes and never emit so launcher-lifecycle tests stay deterministic.
|
|
87
90
|
return {
|
|
88
91
|
pid,
|
|
89
92
|
kill: vi.fn(),
|
|
90
93
|
exited: exitedPromise,
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
stdin: new WritableStream<Uint8Array>(),
|
|
95
|
+
stdout: new ReadableStream<Uint8Array>({ start() {} }),
|
|
96
|
+
stderr: new ReadableStream<Uint8Array>({ start() {} }),
|
|
93
97
|
};
|
|
94
98
|
}
|
|
95
99
|
|
|
@@ -156,7 +160,7 @@ beforeEach(() => {
|
|
|
156
160
|
tempDir = mkdtempSync(join(tmpdir(), "launcher-test-"));
|
|
157
161
|
resetSettings(join(tempDir, "settings.json"));
|
|
158
162
|
store = new SessionStore(tempDir);
|
|
159
|
-
launcher = new CliLauncher(
|
|
163
|
+
launcher = new CliLauncher();
|
|
160
164
|
launcher.setStore(store);
|
|
161
165
|
mockSpawn.mockReturnValue(createMockProc());
|
|
162
166
|
mockListen.mockImplementation(() => ({ stop: vi.fn() }));
|
|
@@ -174,16 +178,18 @@ afterEach(() => {
|
|
|
174
178
|
// ─── launch ──────────────────────────────────────────────────────────────────
|
|
175
179
|
|
|
176
180
|
describe("launch", () => {
|
|
177
|
-
it("creates a session with a UUID and
|
|
181
|
+
it("creates a session with a UUID and connects immediately over stdio", () => {
|
|
178
182
|
const info = launcher.launch({ cwd: "/tmp/project" });
|
|
179
183
|
|
|
180
184
|
expect(info.sessionId).toBe("test-session-id");
|
|
181
|
-
|
|
185
|
+
// The stdio transport owns the process and attaches synchronously — there
|
|
186
|
+
// is no WebSocket handshake to wait for, so the session is "connected".
|
|
187
|
+
expect(info.state).toBe("connected");
|
|
182
188
|
expect(info.cwd).toBe("/tmp/project");
|
|
183
189
|
expect(info.createdAt).toBeGreaterThan(0);
|
|
184
190
|
});
|
|
185
191
|
|
|
186
|
-
it("spawns CLI with
|
|
192
|
+
it("spawns CLI with the stdio stream-json transport and flags", () => {
|
|
187
193
|
launcher.launch({ cwd: "/tmp/project" });
|
|
188
194
|
|
|
189
195
|
expect(mockSpawn).toHaveBeenCalledOnce();
|
|
@@ -192,26 +198,36 @@ describe("launch", () => {
|
|
|
192
198
|
// Binary should be resolved via execSync
|
|
193
199
|
expect(cmdAndArgs[0]).toBe("/usr/bin/claude");
|
|
194
200
|
|
|
195
|
-
//
|
|
196
|
-
expect(cmdAndArgs).toContain("--sdk-url");
|
|
197
|
-
expect(cmdAndArgs).toContain("ws://127.0.0.1:3456/ws/cli/test-session-id");
|
|
201
|
+
// Stdio stream-json transport — NOT the legacy --sdk-url WebSocket bridge.
|
|
202
|
+
expect(cmdAndArgs).not.toContain("--sdk-url");
|
|
198
203
|
expect(cmdAndArgs).toContain("--print");
|
|
199
204
|
expect(cmdAndArgs).toContain("--output-format");
|
|
200
205
|
expect(cmdAndArgs).toContain("stream-json");
|
|
201
206
|
expect(cmdAndArgs).toContain("--input-format");
|
|
202
207
|
expect(cmdAndArgs).toContain("--include-partial-messages");
|
|
203
208
|
expect(cmdAndArgs).toContain("--verbose");
|
|
209
|
+
// Routes permission prompts back as can_use_tool over the same pipes.
|
|
210
|
+
const pptIdx = cmdAndArgs.indexOf("--permission-prompt-tool");
|
|
211
|
+
expect(pptIdx).toBeGreaterThan(-1);
|
|
212
|
+
expect(cmdAndArgs[pptIdx + 1]).toBe("stdio");
|
|
204
213
|
|
|
205
|
-
//
|
|
206
|
-
expect(cmdAndArgs).
|
|
207
|
-
expect(cmdAndArgs).toContain("");
|
|
214
|
+
// No trailing `-p ""` — the prompt arrives as a stream-json user message.
|
|
215
|
+
expect(cmdAndArgs[cmdAndArgs.length - 1]).not.toBe("");
|
|
208
216
|
|
|
209
|
-
// Spawn options
|
|
217
|
+
// Spawn options: stdin is piped so the server can write NDJSON to the child.
|
|
210
218
|
expect(options.cwd).toBe("/tmp/project");
|
|
219
|
+
expect(options.stdin).toBe("pipe");
|
|
211
220
|
expect(options.stdout).toBe("pipe");
|
|
212
221
|
expect(options.stderr).toBe("pipe");
|
|
213
222
|
});
|
|
214
223
|
|
|
224
|
+
it("emits backend:claude-adapter-created so the bridge can attach the adapter", () => {
|
|
225
|
+
const seen: string[] = [];
|
|
226
|
+
companionBus.on("backend:claude-adapter-created", ({ sessionId }) => { seen.push(sessionId); });
|
|
227
|
+
const info = launcher.launch({ cwd: "/tmp/project" });
|
|
228
|
+
expect(seen).toEqual([info.sessionId]);
|
|
229
|
+
});
|
|
230
|
+
|
|
215
231
|
it("passes --model when provided", () => {
|
|
216
232
|
launcher.launch({ model: "claude-opus-4-20250514", cwd: "/tmp" });
|
|
217
233
|
|
|
@@ -271,8 +287,7 @@ describe("launch", () => {
|
|
|
271
287
|
}
|
|
272
288
|
});
|
|
273
289
|
|
|
274
|
-
it("
|
|
275
|
-
process.env.COMPANION_CONTAINER_SDK_HOST = "172.17.0.1";
|
|
290
|
+
it("runs containerized Claude over `docker exec -i` with stdio stream-json (no --sdk-url)", () => {
|
|
276
291
|
launcher.launch({
|
|
277
292
|
cwd: "/tmp/project",
|
|
278
293
|
containerId: "abc123def456",
|
|
@@ -280,10 +295,15 @@ describe("launch", () => {
|
|
|
280
295
|
});
|
|
281
296
|
|
|
282
297
|
const [cmdAndArgs] = mockSpawn.mock.calls[0];
|
|
298
|
+
// `docker exec -i` keeps stdin open so the server can drive the CLI.
|
|
299
|
+
expect(cmdAndArgs.slice(0, 3)).toEqual(["docker", "exec", "-i"]);
|
|
300
|
+
expect(cmdAndArgs).toContain("abc123def456");
|
|
283
301
|
// With bash -lc wrapping, CLI args are in the last element as a single string
|
|
284
302
|
const bashCmd = cmdAndArgs[cmdAndArgs.length - 1];
|
|
285
|
-
expect(bashCmd).toContain("--sdk-url");
|
|
286
|
-
expect(bashCmd).toContain("
|
|
303
|
+
expect(bashCmd).not.toContain("--sdk-url");
|
|
304
|
+
expect(bashCmd).toContain("--input-format");
|
|
305
|
+
expect(bashCmd).toContain("stream-json");
|
|
306
|
+
expect(bashCmd).toContain("--permission-prompt-tool");
|
|
287
307
|
});
|
|
288
308
|
|
|
289
309
|
it("passes --allowedTools for each tool", () => {
|
|
@@ -705,8 +725,9 @@ describe("relaunch", () => {
|
|
|
705
725
|
pid: 12345,
|
|
706
726
|
kill: vi.fn(() => { resolveFirst(0); }),
|
|
707
727
|
exited: new Promise<number>((r) => { resolveFirst = r; }),
|
|
708
|
-
|
|
709
|
-
|
|
728
|
+
stdin: new WritableStream<Uint8Array>(),
|
|
729
|
+
stdout: new ReadableStream<Uint8Array>({ start() {} }),
|
|
730
|
+
stderr: new ReadableStream<Uint8Array>({ start() {} }),
|
|
710
731
|
};
|
|
711
732
|
mockSpawn.mockReturnValueOnce(firstProc);
|
|
712
733
|
|
|
@@ -729,11 +750,12 @@ describe("relaunch", () => {
|
|
|
729
750
|
expect(cmdAndArgs).toContain("--resume");
|
|
730
751
|
expect(cmdAndArgs).toContain("cli-resume-id");
|
|
731
752
|
|
|
732
|
-
//
|
|
753
|
+
// After relaunch the stdio transport attaches synchronously, so the session
|
|
754
|
+
// moves straight to "connected" (no WebSocket reconnect to wait for).
|
|
733
755
|
// Allow microtask queue to flush
|
|
734
756
|
await new Promise((r) => setTimeout(r, 10));
|
|
735
757
|
const session = launcher.getSession("test-session-id");
|
|
736
|
-
expect(session?.state).toBe("
|
|
758
|
+
expect(session?.state).toBe("connected");
|
|
737
759
|
});
|
|
738
760
|
|
|
739
761
|
it("reuses launch env variables during relaunch", async () => {
|
|
@@ -742,8 +764,9 @@ describe("relaunch", () => {
|
|
|
742
764
|
pid: 12345,
|
|
743
765
|
kill: vi.fn(() => { resolveFirst(0); }),
|
|
744
766
|
exited: new Promise<number>((r) => { resolveFirst = r; }),
|
|
745
|
-
|
|
746
|
-
|
|
767
|
+
stdin: new WritableStream<Uint8Array>(),
|
|
768
|
+
stdout: new ReadableStream<Uint8Array>({ start() {} }),
|
|
769
|
+
stderr: new ReadableStream<Uint8Array>({ start() {} }),
|
|
747
770
|
};
|
|
748
771
|
mockSpawn.mockReturnValueOnce(firstProc);
|
|
749
772
|
|
|
@@ -803,8 +826,9 @@ describe("relaunch", () => {
|
|
|
803
826
|
pid: 12345,
|
|
804
827
|
kill: vi.fn(() => { resolveFirst(0); }),
|
|
805
828
|
exited: new Promise<number>((r) => { resolveFirst = r; }),
|
|
806
|
-
|
|
807
|
-
|
|
829
|
+
stdin: new WritableStream<Uint8Array>(),
|
|
830
|
+
stdout: new ReadableStream<Uint8Array>({ start() {} }),
|
|
831
|
+
stderr: new ReadableStream<Uint8Array>({ start() {} }),
|
|
808
832
|
};
|
|
809
833
|
mockSpawn.mockReturnValueOnce(firstProc);
|
|
810
834
|
|
|
@@ -872,8 +896,9 @@ describe("relaunch", () => {
|
|
|
872
896
|
pid: 12345,
|
|
873
897
|
kill: vi.fn(() => { resolveFirst(0); }),
|
|
874
898
|
exited: new Promise<number>((r) => { resolveFirst = r; }),
|
|
875
|
-
|
|
876
|
-
|
|
899
|
+
stdin: new WritableStream<Uint8Array>(),
|
|
900
|
+
stdout: new ReadableStream<Uint8Array>({ start() {} }),
|
|
901
|
+
stderr: new ReadableStream<Uint8Array>({ start() {} }),
|
|
877
902
|
};
|
|
878
903
|
mockSpawn.mockReturnValueOnce(firstProc);
|
|
879
904
|
|
|
@@ -999,8 +1024,9 @@ describe("codex websocket launcher", () => {
|
|
|
999
1024
|
pid: 3001,
|
|
1000
1025
|
kill: vi.fn(() => resolveCodex1(0)),
|
|
1001
1026
|
exited: new Promise<number>((r) => { resolveCodex1 = r; }),
|
|
1002
|
-
|
|
1003
|
-
|
|
1027
|
+
stdin: new WritableStream<Uint8Array>(),
|
|
1028
|
+
stdout: new ReadableStream<Uint8Array>({ start() {} }),
|
|
1029
|
+
stderr: new ReadableStream<Uint8Array>({ start() {} }),
|
|
1004
1030
|
};
|
|
1005
1031
|
const proxy1 = createPendingCodexWsProxyProc(3002);
|
|
1006
1032
|
proxy1.proc.kill.mockImplementation(() => proxy1.resolveExit(0));
|
|
@@ -1061,8 +1087,9 @@ describe("codex websocket launcher", () => {
|
|
|
1061
1087
|
pid: 5001,
|
|
1062
1088
|
kill: vi.fn(),
|
|
1063
1089
|
exited: new Promise<number>((r) => { resolveLauncherProc = r; }),
|
|
1064
|
-
|
|
1065
|
-
|
|
1090
|
+
stdin: new WritableStream<Uint8Array>(),
|
|
1091
|
+
stdout: new ReadableStream<Uint8Array>({ start() {} }),
|
|
1092
|
+
stderr: new ReadableStream<Uint8Array>({ start() {} }),
|
|
1066
1093
|
};
|
|
1067
1094
|
const proxy = createPendingCodexWsProxyProc(5002);
|
|
1068
1095
|
|
|
@@ -1132,7 +1159,7 @@ describe("persistence", () => {
|
|
|
1132
1159
|
return origKill.call(process, pid, signal as any);
|
|
1133
1160
|
}) as any);
|
|
1134
1161
|
|
|
1135
|
-
const newLauncher = new CliLauncher(
|
|
1162
|
+
const newLauncher = new CliLauncher();
|
|
1136
1163
|
newLauncher.setStore(store);
|
|
1137
1164
|
const recovered = newLauncher.restoreFromDisk();
|
|
1138
1165
|
|
|
@@ -1168,7 +1195,7 @@ describe("persistence", () => {
|
|
|
1168
1195
|
return true;
|
|
1169
1196
|
}) as any);
|
|
1170
1197
|
|
|
1171
|
-
const newLauncher = new CliLauncher(
|
|
1198
|
+
const newLauncher = new CliLauncher();
|
|
1172
1199
|
newLauncher.setStore(store);
|
|
1173
1200
|
const recovered = newLauncher.restoreFromDisk();
|
|
1174
1201
|
|
|
@@ -1184,13 +1211,13 @@ describe("persistence", () => {
|
|
|
1184
1211
|
});
|
|
1185
1212
|
|
|
1186
1213
|
it("returns 0 when no store is set", () => {
|
|
1187
|
-
const newLauncher = new CliLauncher(
|
|
1214
|
+
const newLauncher = new CliLauncher();
|
|
1188
1215
|
// No setStore call
|
|
1189
1216
|
expect(newLauncher.restoreFromDisk()).toBe(0);
|
|
1190
1217
|
});
|
|
1191
1218
|
|
|
1192
1219
|
it("returns 0 when store has no launcher data", () => {
|
|
1193
|
-
const newLauncher = new CliLauncher(
|
|
1220
|
+
const newLauncher = new CliLauncher();
|
|
1194
1221
|
newLauncher.setStore(store);
|
|
1195
1222
|
// Store is empty, no launcher.json file
|
|
1196
1223
|
expect(newLauncher.restoreFromDisk()).toBe(0);
|
|
@@ -1215,7 +1242,7 @@ describe("persistence", () => {
|
|
|
1215
1242
|
|
|
1216
1243
|
mockIsContainerAlive.mockReturnValueOnce("running");
|
|
1217
1244
|
|
|
1218
|
-
const newLauncher = new CliLauncher(
|
|
1245
|
+
const newLauncher = new CliLauncher();
|
|
1219
1246
|
newLauncher.setStore(store);
|
|
1220
1247
|
const recovered = newLauncher.restoreFromDisk();
|
|
1221
1248
|
|
|
@@ -1243,7 +1270,7 @@ describe("persistence", () => {
|
|
|
1243
1270
|
|
|
1244
1271
|
mockIsContainerAlive.mockReturnValueOnce("stopped");
|
|
1245
1272
|
|
|
1246
|
-
const newLauncher = new CliLauncher(
|
|
1273
|
+
const newLauncher = new CliLauncher();
|
|
1247
1274
|
newLauncher.setStore(store);
|
|
1248
1275
|
const recovered = newLauncher.restoreFromDisk();
|
|
1249
1276
|
|
|
@@ -1269,7 +1296,7 @@ describe("persistence", () => {
|
|
|
1269
1296
|
];
|
|
1270
1297
|
store.saveLauncher(savedSessions);
|
|
1271
1298
|
|
|
1272
|
-
const newLauncher = new CliLauncher(
|
|
1299
|
+
const newLauncher = new CliLauncher();
|
|
1273
1300
|
newLauncher.setStore(store);
|
|
1274
1301
|
const recovered = newLauncher.restoreFromDisk();
|
|
1275
1302
|
|
|
@@ -1285,18 +1312,26 @@ describe("persistence", () => {
|
|
|
1285
1312
|
// ─── getStartingSessions ─────────────────────────────────────────────────────
|
|
1286
1313
|
|
|
1287
1314
|
describe("getStartingSessions", () => {
|
|
1288
|
-
it("returns only sessions in starting state", () => {
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1315
|
+
it("returns only sessions in starting state (restored, awaiting relaunch)", () => {
|
|
1316
|
+
// Fresh stdio launches connect immediately, so "starting" only arises on
|
|
1317
|
+
// restore: restoreFromDisk marks live-PID sessions "starting" until they
|
|
1318
|
+
// are relaunched. Persist such a session and restore it via a new launcher.
|
|
1319
|
+
store.saveLauncher([
|
|
1320
|
+
{ sessionId: "restored-1", state: "connected", cwd: "/tmp", createdAt: Date.now(), pid: process.pid, backendType: "claude" },
|
|
1321
|
+
]);
|
|
1322
|
+
const restored = new CliLauncher();
|
|
1323
|
+
restored.setStore(store);
|
|
1324
|
+
restored.restoreFromDisk();
|
|
1325
|
+
|
|
1326
|
+
const starting = restored.getStartingSessions();
|
|
1292
1327
|
expect(starting).toHaveLength(1);
|
|
1293
1328
|
expect(starting[0].state).toBe("starting");
|
|
1329
|
+
expect(starting[0].sessionId).toBe("restored-1");
|
|
1294
1330
|
});
|
|
1295
1331
|
|
|
1296
|
-
it("excludes sessions
|
|
1332
|
+
it("excludes freshly launched sessions (stdio connects immediately)", () => {
|
|
1297
1333
|
launcher.launch({ cwd: "/tmp" });
|
|
1298
|
-
|
|
1299
|
-
|
|
1334
|
+
// No WebSocket handshake to await — the session is already "connected".
|
|
1300
1335
|
const starting = launcher.getStartingSessions();
|
|
1301
1336
|
expect(starting).toHaveLength(0);
|
|
1302
1337
|
});
|
package/server/cli-launcher.ts
CHANGED
|
@@ -5,21 +5,18 @@ import {
|
|
|
5
5
|
copyFileSync,
|
|
6
6
|
cpSync,
|
|
7
7
|
realpathSync,
|
|
8
|
-
writeFileSync,
|
|
9
|
-
unlinkSync,
|
|
10
8
|
} from "node:fs";
|
|
11
9
|
import { join, resolve } from "node:path";
|
|
12
|
-
import { tmpdir } from "node:os";
|
|
13
10
|
import { fileURLToPath } from "node:url";
|
|
14
11
|
import type { Subprocess } from "bun";
|
|
15
12
|
import type { SessionStore } from "./session-store.js";
|
|
16
13
|
import type { BackendType } from "./session-types.js";
|
|
17
14
|
import type { RecorderManager } from "./recorder.js";
|
|
18
15
|
import { CodexAdapter } from "./codex-adapter.js";
|
|
16
|
+
import { ClaudeAdapter } from "./claude-adapter.js";
|
|
19
17
|
import { resolveBinary, getEnrichedPath } from "./path-resolver.js";
|
|
20
18
|
import { containerManager } from "./container-manager.js";
|
|
21
19
|
import { companionBus } from "./event-bus.js";
|
|
22
|
-
import { getSettings } from "./settings-manager.js";
|
|
23
20
|
import {
|
|
24
21
|
getLegacyCodexHome,
|
|
25
22
|
resolveCompanionCodexSessionHome,
|
|
@@ -192,12 +189,8 @@ export class CliLauncher {
|
|
|
192
189
|
private claimedCodexWsPorts = new Set<number>();
|
|
193
190
|
/** Runtime-only env vars per session (kept out of persisted launcher state). */
|
|
194
191
|
private sessionEnvs = new Map<string, Record<string, string>>();
|
|
195
|
-
private port: number;
|
|
196
192
|
private store: SessionStore | null = null;
|
|
197
193
|
private recorder: RecorderManager | null = null;
|
|
198
|
-
constructor(port: number) {
|
|
199
|
-
this.port = port;
|
|
200
|
-
}
|
|
201
194
|
|
|
202
195
|
/** Attach a persistent store for surviving server restarts. */
|
|
203
196
|
setStore(store: SessionStore): void {
|
|
@@ -490,36 +483,6 @@ export class CliLauncher {
|
|
|
490
483
|
}
|
|
491
484
|
}
|
|
492
485
|
|
|
493
|
-
// Allow overriding the host alias used by containerized Claude sessions.
|
|
494
|
-
// Useful when host.docker.internal is unavailable in a given Docker setup.
|
|
495
|
-
const containerSdkHost = (process.env.COMPANION_CONTAINER_SDK_HOST || "host.docker.internal").trim()
|
|
496
|
-
|| "host.docker.internal";
|
|
497
|
-
|
|
498
|
-
// When running inside a container, the SDK URL targets the host alias so
|
|
499
|
-
// the CLI can connect back to the Hono server running on the host.
|
|
500
|
-
//
|
|
501
|
-
// For host sessions there are two paths depending on settings:
|
|
502
|
-
// * "patched" — the companion has byte-patched the local Claude binary
|
|
503
|
-
// so it accepts [::1] in --sdk-url, and a parallel TLS WS listener is
|
|
504
|
-
// running on wss://[::1]:<ingress-port>. We emit that URL and set
|
|
505
|
-
// NODE_TLS_REJECT_UNAUTHORIZED=0 below so the self-signed cert is
|
|
506
|
-
// accepted. Required on Claude Code >= 2.1.121, where the validator
|
|
507
|
-
// rejects every non-Anthropic host. See claude-versions.ts.
|
|
508
|
-
// * "none" (default) — plain ws://127.0.0.1:<port>/... Works on
|
|
509
|
-
// 2.1.120 and earlier; sessions on a stock 2.1.121+ binary will fail
|
|
510
|
-
// immediately with "host 127.0.0.1 is not an approved Anthropic
|
|
511
|
-
// endpoint" (visible only on direct CLI stderr).
|
|
512
|
-
const settings = getSettings();
|
|
513
|
-
const patchedBridge = !isContainerized
|
|
514
|
-
&& settings.claudeBridgeMode === "patched"
|
|
515
|
-
&& typeof settings.claudeBridgeIngressUrl === "string"
|
|
516
|
-
&& settings.claudeBridgeIngressUrl.length > 0;
|
|
517
|
-
const sdkUrl = isContainerized
|
|
518
|
-
? `ws://${containerSdkHost}:${this.port}/ws/cli/${sessionId}`
|
|
519
|
-
: patchedBridge
|
|
520
|
-
? `${settings.claudeBridgeIngressUrl}/ws/cli/${sessionId}`
|
|
521
|
-
: `ws://127.0.0.1:${this.port}/ws/cli/${sessionId}`;
|
|
522
|
-
|
|
523
486
|
let effectivePermissionMode = options.permissionMode;
|
|
524
487
|
const shouldDowngradeContainerBypass =
|
|
525
488
|
isContainerized
|
|
@@ -535,43 +498,22 @@ export class CliLauncher {
|
|
|
535
498
|
info.permissionMode = "acceptEdits";
|
|
536
499
|
}
|
|
537
500
|
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
let bridgeConfigPath: string | undefined;
|
|
547
|
-
if (useJsonHandoff) {
|
|
548
|
-
bridgeConfigPath = join(tmpdir(), `companion-bridge-${sessionId}.json`);
|
|
549
|
-
const token = randomUUID();
|
|
550
|
-
const descriptor = {
|
|
551
|
-
version: 1,
|
|
552
|
-
transport: "ws",
|
|
553
|
-
url: sdkUrl,
|
|
554
|
-
sessionId,
|
|
555
|
-
token,
|
|
556
|
-
};
|
|
557
|
-
try {
|
|
558
|
-
writeFileSync(bridgeConfigPath, JSON.stringify(descriptor), { mode: 0o600 });
|
|
559
|
-
info.bridgeToken = token;
|
|
560
|
-
info.bridgeConfigPath = bridgeConfigPath;
|
|
561
|
-
} catch (err) {
|
|
562
|
-
console.warn(`[cli-launcher] Failed to write bridge descriptor for ${sessionId}: ${err}. Falling back to --sdk-url.`);
|
|
563
|
-
bridgeConfigPath = undefined;
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
|
|
501
|
+
// Stdio stream-json transport (the supported integration path). The server
|
|
502
|
+
// owns the process and bridges over its stdin/stdout pipes via ClaudeAdapter
|
|
503
|
+
// — no `--sdk-url`, so we're immune to the 2.1.121 host allowlist and the
|
|
504
|
+
// 2.1.170 worker-registration handshake that broke the WebSocket bridge.
|
|
505
|
+
//
|
|
506
|
+
// --permission-prompt-tool stdio routes permission prompts back as
|
|
507
|
+
// `can_use_tool` control_requests over the same pipes (the adapter sends
|
|
508
|
+
// the one-time `initialize` handshake that enables this).
|
|
567
509
|
const args: string[] = [
|
|
568
|
-
...(bridgeConfigPath ? [] : ["--sdk-url", sdkUrl]),
|
|
569
510
|
"--print",
|
|
570
|
-
"--output-format", "stream-json",
|
|
571
511
|
"--input-format", "stream-json",
|
|
572
|
-
|
|
512
|
+
"--output-format", "stream-json",
|
|
513
|
+
// Emit streaming chunk events (partial assistant messages).
|
|
573
514
|
"--include-partial-messages",
|
|
574
515
|
"--verbose",
|
|
516
|
+
"--permission-prompt-tool", "stdio",
|
|
575
517
|
];
|
|
576
518
|
|
|
577
519
|
if (options.model) {
|
|
@@ -591,26 +533,20 @@ export class CliLauncher {
|
|
|
591
533
|
if (options.forkSession) {
|
|
592
534
|
args.push("--fork-session");
|
|
593
535
|
}
|
|
594
|
-
|
|
595
|
-
//
|
|
596
|
-
// to restore the CLI's conversation context.
|
|
536
|
+
// On relaunch, --resume restores the CLI's conversation context. The prompt
|
|
537
|
+
// itself arrives as a stream-json `user` message on stdin (no trailing -p "").
|
|
597
538
|
if (options.resumeSessionId) {
|
|
598
539
|
args.push("--resume", options.resumeSessionId);
|
|
599
540
|
}
|
|
600
541
|
|
|
601
|
-
args.push("-p", "");
|
|
602
|
-
|
|
603
542
|
let spawnCmd: string[];
|
|
604
543
|
let spawnEnv: Record<string, string | undefined>;
|
|
605
544
|
let spawnCwd: string | undefined;
|
|
606
545
|
|
|
607
546
|
if (isContainerized) {
|
|
608
|
-
// Run CLI inside the container via docker exec -i
|
|
609
|
-
//
|
|
610
|
-
// Environment variables are passed via -e flags to docker exec.
|
|
547
|
+
// Run CLI inside the container via `docker exec -i` so the server can
|
|
548
|
+
// drive it over stdin/stdout. Env vars are passed via -e flags.
|
|
611
549
|
const dockerArgs = ["docker", "exec", "-i"];
|
|
612
|
-
|
|
613
|
-
// Pass env vars via -e flags
|
|
614
550
|
if (options.env) {
|
|
615
551
|
for (const [k, v] of Object.entries(options.env)) {
|
|
616
552
|
dockerArgs.push("-e", `${k}=${v}`);
|
|
@@ -629,7 +565,7 @@ export class CliLauncher {
|
|
|
629
565
|
spawnEnv = { ...process.env, PATH: getEnrichedPath() };
|
|
630
566
|
spawnCwd = undefined; // cwd is set inside the container via -w at creation
|
|
631
567
|
} else {
|
|
632
|
-
// Host-based spawn
|
|
568
|
+
// Host-based spawn.
|
|
633
569
|
// On Windows, .cmd/.bat files cannot be spawned directly by Bun.spawn;
|
|
634
570
|
// they must be invoked via cmd.exe /c.
|
|
635
571
|
const isCmdScript = process.platform === "win32" && (binary.endsWith(".cmd") || binary.endsWith(".bat"));
|
|
@@ -639,11 +575,6 @@ export class CliLauncher {
|
|
|
639
575
|
CLAUDECODE: undefined,
|
|
640
576
|
...options.env,
|
|
641
577
|
PATH: getEnrichedPath(),
|
|
642
|
-
...(bridgeConfigPath ? { CLAUDE_BRIDGE_CONFIG: bridgeConfigPath } : {}),
|
|
643
|
-
// Patched-bridge mode terminates --sdk-url at our self-signed wss://[::1]
|
|
644
|
-
// listener. Tell Bun/Node to trust the self-signed cert without involving
|
|
645
|
-
// the user's CA store.
|
|
646
|
-
...(patchedBridge ? { NODE_TLS_REJECT_UNAUTHORIZED: "0" } : {}),
|
|
647
578
|
};
|
|
648
579
|
spawnCwd = info.cwd;
|
|
649
580
|
}
|
|
@@ -656,6 +587,7 @@ export class CliLauncher {
|
|
|
656
587
|
const proc = Bun.spawn(spawnCmd, {
|
|
657
588
|
cwd: spawnCwd,
|
|
658
589
|
env: spawnEnv,
|
|
590
|
+
stdin: "pipe",
|
|
659
591
|
stdout: "pipe",
|
|
660
592
|
stderr: "pipe",
|
|
661
593
|
});
|
|
@@ -663,10 +595,27 @@ export class CliLauncher {
|
|
|
663
595
|
info.pid = proc.pid;
|
|
664
596
|
this.processes.set(sessionId, proc);
|
|
665
597
|
|
|
666
|
-
//
|
|
667
|
-
|
|
598
|
+
// stdout carries the NDJSON protocol stream — it is owned by the adapter
|
|
599
|
+
// (below). Pipe only stderr for diagnostics so we don't steal stdout bytes.
|
|
600
|
+
const stderr = proc.stderr;
|
|
601
|
+
if (stderr && typeof stderr !== "number") {
|
|
602
|
+
this.pipeStream(sessionId, stderr, "stderr");
|
|
603
|
+
}
|
|
668
604
|
|
|
669
|
-
//
|
|
605
|
+
// Create the ClaudeAdapter and bind it to the process's stdio. The adapter
|
|
606
|
+
// owns NDJSON translation; the bridge attaches via backend:claude-adapter-created
|
|
607
|
+
// (mirrors the Codex stdio path). State is "connected" immediately — there is
|
|
608
|
+
// no WebSocket handshake to wait for.
|
|
609
|
+
const adapter = new ClaudeAdapter(sessionId, {
|
|
610
|
+
recorder: this.recorder ?? undefined,
|
|
611
|
+
cwd: info.cwd,
|
|
612
|
+
});
|
|
613
|
+
adapter.attachStdio(proc);
|
|
614
|
+
companionBus.emit("backend:claude-adapter-created", { sessionId, adapter });
|
|
615
|
+
info.state = "connected";
|
|
616
|
+
|
|
617
|
+
// Monitor process exit. Relaunch is driven by the orchestrator's proactive
|
|
618
|
+
// keepalive on session:exited (it relaunches with --resume).
|
|
670
619
|
const spawnedAt = Date.now();
|
|
671
620
|
proc.exited.then((exitCode) => {
|
|
672
621
|
console.log(`[cli-launcher] Session ${sessionId} exited (code=${exitCode})`);
|
|
@@ -684,9 +633,6 @@ export class CliLauncher {
|
|
|
684
633
|
}
|
|
685
634
|
}
|
|
686
635
|
this.processes.delete(sessionId);
|
|
687
|
-
if (bridgeConfigPath) {
|
|
688
|
-
try { unlinkSync(bridgeConfigPath); } catch { /* already gone */ }
|
|
689
|
-
}
|
|
690
636
|
this.persistState();
|
|
691
637
|
companionBus.emit("session:exited", { sessionId, exitCode });
|
|
692
638
|
});
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import type { BrowserIncomingMessage } from "./session-types.js";
|
|
5
5
|
import type { CodexAdapter } from "./codex-adapter.js";
|
|
6
|
+
import type { ClaudeAdapter } from "./claude-adapter.js";
|
|
6
7
|
import type { SessionPhase } from "./session-state-machine.js";
|
|
7
8
|
|
|
8
9
|
export interface CompanionEventMap {
|
|
@@ -45,6 +46,12 @@ export interface CompanionEventMap {
|
|
|
45
46
|
adapter: CodexAdapter;
|
|
46
47
|
};
|
|
47
48
|
|
|
49
|
+
/** Claude adapter (stdio stream-json transport) created and ready to attach. */
|
|
50
|
+
"backend:claude-adapter-created": {
|
|
51
|
+
sessionId: string;
|
|
52
|
+
adapter: ClaudeAdapter;
|
|
53
|
+
};
|
|
54
|
+
|
|
48
55
|
// ── Per-session messages (high volume) ─────────────────────────────
|
|
49
56
|
|
|
50
57
|
/** An assistant message was processed and broadcast to browsers. */
|
package/server/index.ts
CHANGED
|
@@ -60,7 +60,7 @@ const sessionStore = new SessionStore(
|
|
|
60
60
|
process.env.COMPANION_SESSION_DIR || process.env.COMPANION_SESSIONS_DIR,
|
|
61
61
|
);
|
|
62
62
|
const wsBridge = new WsBridge();
|
|
63
|
-
const launcher = new CliLauncher(
|
|
63
|
+
const launcher = new CliLauncher();
|
|
64
64
|
const worktreeTracker = new WorktreeTracker();
|
|
65
65
|
const CONTAINER_STATE_PATH = join(COMPANION_HOME, "containers.json");
|
|
66
66
|
const terminalManager = new TerminalManager();
|
|
@@ -167,6 +167,13 @@ export class SessionOrchestrator {
|
|
|
167
167
|
this.wsBridge.attachBackendAdapter(sessionId, adapter, "codex");
|
|
168
168
|
});
|
|
169
169
|
|
|
170
|
+
// When a Claude adapter (stdio stream-json transport) is created, attach it.
|
|
171
|
+
// Unlike the legacy --sdk-url path (where handleCLIOpen creates+attaches the
|
|
172
|
+
// adapter on WS connect), the launcher now owns the process and emits this.
|
|
173
|
+
companionBus.on("backend:claude-adapter-created", ({ sessionId, adapter }) => {
|
|
174
|
+
this.wsBridge.attachBackendAdapter(sessionId, adapter, "claude");
|
|
175
|
+
});
|
|
176
|
+
|
|
170
177
|
// When a CLI/Codex process exits, notify agent executor and external listeners
|
|
171
178
|
// separately so a throw in one doesn't skip the other (bus isolates each handler).
|
|
172
179
|
companionBus.on("session:exited", ({ sessionId, exitCode }) => {
|
package/server/ws-bridge.ts
CHANGED
|
@@ -392,13 +392,21 @@ export class WsBridge {
|
|
|
392
392
|
const session = this.getOrCreateSession(sessionId, backendType);
|
|
393
393
|
session.backendAdapter = adapter;
|
|
394
394
|
|
|
395
|
+
// The legacy `--sdk-url` Claude transport has the CLI dial back in over a
|
|
396
|
+
// WebSocket, so its lifecycle (state machine advance, disconnect debounce)
|
|
397
|
+
// is driven by handleCLIOpen/handleCLIClose. Every other adapter — Codex,
|
|
398
|
+
// and the stdio stream-json Claude transport — has the server own the
|
|
399
|
+
// process, so attachment IS the transport-open event and disconnect is a
|
|
400
|
+
// process-exit, handled here like Codex.
|
|
401
|
+
const isLegacyWsClaude = adapter instanceof ClaudeAdapter && !adapter.usesProcessTransport();
|
|
402
|
+
|
|
395
403
|
// Advance the state machine so that system_init (starting → ready) is reachable.
|
|
396
|
-
// For Claude, handleCLIOpen does starting → initializing via cli_ws_open.
|
|
397
|
-
// For
|
|
398
|
-
//
|
|
404
|
+
// For legacy WS Claude, handleCLIOpen does starting → initializing via cli_ws_open.
|
|
405
|
+
// For server-owned adapters, the adapter attachment IS the transport open event —
|
|
406
|
+
// no separate WS open fires — so do the equivalent transition here.
|
|
399
407
|
// Also handles relaunched sessions stuck in "terminated": step through
|
|
400
408
|
// terminated → starting → initializing so system_init can land on "ready".
|
|
401
|
-
if (!
|
|
409
|
+
if (!isLegacyWsClaude) {
|
|
402
410
|
// Cancel any pending disconnect debounce — new adapter is reconnecting
|
|
403
411
|
this.cancelDisconnectTimer(sessionId);
|
|
404
412
|
const phase = session.stateMachine.phase;
|
|
@@ -624,14 +632,15 @@ export class WsBridge {
|
|
|
624
632
|
return;
|
|
625
633
|
}
|
|
626
634
|
|
|
627
|
-
// For
|
|
628
|
-
|
|
629
|
-
|
|
635
|
+
// For the legacy WS Claude transport, disconnect is handled by the
|
|
636
|
+
// handleCLIClose debounce logic — do nothing here.
|
|
637
|
+
if (isLegacyWsClaude) {
|
|
630
638
|
return;
|
|
631
639
|
}
|
|
632
640
|
|
|
633
|
-
// For
|
|
634
|
-
// (5s vs 15s for
|
|
641
|
+
// For server-owned adapters (Codex + stdio Claude): transition to
|
|
642
|
+
// "reconnecting" with a short debounce (5s vs 15s for the legacy WS
|
|
643
|
+
// Claude transport, since there's no WebSocket cycling).
|
|
635
644
|
session.backendAdapter = null;
|
|
636
645
|
session.stateMachine.transition("reconnecting", "codex_adapter_disconnected");
|
|
637
646
|
this.persistSession(session);
|
|
@@ -665,10 +674,10 @@ export class WsBridge {
|
|
|
665
674
|
this.broadcastToBrowsers(session, { type: "error", message: error });
|
|
666
675
|
});
|
|
667
676
|
|
|
668
|
-
// Flush pending messages for
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
if (!
|
|
677
|
+
// Flush pending messages for server-owned backends (Codex + stdio Claude),
|
|
678
|
+
// where attachment is the transport-open event. The legacy WS Claude
|
|
679
|
+
// transport flushes in handleCLIOpen after attachWebSocket instead.
|
|
680
|
+
if (!isLegacyWsClaude && session.pendingMessages.length > 0) {
|
|
672
681
|
this.flushQueuedBrowserMessages(session, adapter, "adapter_attach");
|
|
673
682
|
this.persistSession(session);
|
|
674
683
|
}
|