@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.
Files changed (26) hide show
  1. package/dist/assets/{AgentsPage-tWiu1_AT.js → AgentsPage-7oHDiJoh.js} +1 -1
  2. package/dist/assets/{CronManager-BFVFYGxc.js → CronManager-fsEUcByi.js} +1 -1
  3. package/dist/assets/{IntegrationsPage-DRbbUsum.js → IntegrationsPage-DhiOq9T9.js} +1 -1
  4. package/dist/assets/{LinearOAuthSettingsPage-DbqiQRYU.js → LinearOAuthSettingsPage-DJ3p4Zyh.js} +1 -1
  5. package/dist/assets/{LinearSettingsPage-C9QVub6_.js → LinearSettingsPage-EJXrPlao.js} +1 -1
  6. package/dist/assets/{Playground-BYihPEC9.js → Playground-BJi1T7KP.js} +1 -1
  7. package/dist/assets/{PromptsPage-Cnhv4Der.js → PromptsPage-DiMm5U1r.js} +1 -1
  8. package/dist/assets/{RunsPage-NVlYvNqV.js → RunsPage-LPrcOaUc.js} +1 -1
  9. package/dist/assets/{SandboxManager-BKkKy3p4.js → SandboxManager-9KiHL5rI.js} +1 -1
  10. package/dist/assets/{SettingsPage-B9jbUi7A.js → SettingsPage-BXy1ZF1F.js} +1 -1
  11. package/dist/assets/{TailscalePage-Dj5FOGz6.js → TailscalePage-ycECxxya.js} +1 -1
  12. package/dist/assets/{index-DOmk_hhI.js → index-D-JiBkdW.js} +49 -49
  13. package/dist/assets/index-DwVmncqT.css +1 -0
  14. package/dist/assets/{sw-register-exbRbynw.js → sw-register-Duj0Mw6k.js} +1 -1
  15. package/dist/index.html +2 -2
  16. package/dist/sw.js +1 -1
  17. package/package.json +2 -1
  18. package/server/claude-adapter.live.ts +315 -0
  19. package/server/claude-adapter.ts +157 -6
  20. package/server/cli-launcher.test.ts +80 -45
  21. package/server/cli-launcher.ts +38 -92
  22. package/server/event-bus-types.ts +7 -0
  23. package/server/index.ts +1 -1
  24. package/server/session-orchestrator.ts +7 -0
  25. package/server/ws-bridge.ts +22 -13
  26. 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
- stdout: null,
92
- stderr: null,
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(3456);
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 starting state", () => {
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
- expect(info.state).toBe("starting");
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 correct --sdk-url and flags", () => {
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
- // Core required flags
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
- // Headless prompt
206
- expect(cmdAndArgs).toContain("-p");
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("uses COMPANION_CONTAINER_SDK_HOST for containerized sdk-url when set", () => {
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("ws://172.17.0.1:3456/ws/cli/test-session-id");
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
- stdout: null,
709
- stderr: null,
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
- // Session state should be reset to starting (set by relaunch before spawnCLI)
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("starting");
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
- stdout: null,
746
- stderr: null,
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
- stdout: null,
807
- stderr: null,
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
- stdout: null,
876
- stderr: null,
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
- stdout: null,
1003
- stderr: null,
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
- stdout: null,
1065
- stderr: null,
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(3456);
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(3456);
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(3456);
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(3456);
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(3456);
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(3456);
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(3456);
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
- launcher.launch({ cwd: "/tmp" });
1290
-
1291
- const starting = launcher.getStartingSessions();
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 that have been connected", () => {
1332
+ it("excludes freshly launched sessions (stdio connects immediately)", () => {
1297
1333
  launcher.launch({ cwd: "/tmp" });
1298
- launcher.markConnected("test-session-id");
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
  });
@@ -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
- // Optional: just-every/code-style JSON handoff. When enabled (and not
539
- // containerized the temp file path wouldn't be visible inside the
540
- // container), write a temp descriptor with a one-shot token and pass its
541
- // path via CLAUDE_BRIDGE_CONFIG env var instead of --sdk-url on argv.
542
- // This is forward-compatible if Anthropic further restricts --sdk-url
543
- // (e.g. drops it entirely or adds origin/handshake checks).
544
- const bridgeMode = settings.cliBridgeMode ?? "loopback";
545
- const useJsonHandoff = bridgeMode === "jsonHandoff" && !isContainerized;
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
- // Required on newer Claude Code versions to emit streaming chunk events.
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
- // Always pass -p "" for headless mode. When relaunching, also pass --resume
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
- // Keeping stdin open avoids premature EOF-driven exits in SDK mode.
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 (original behavior)
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
- // Stream stdout/stderr for debugging
667
- this.pipeOutput(sessionId, proc);
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
- // Monitor process exit
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(port);
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 }) => {
@@ -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 Codex (and any non-Claude adapter), the adapter attachment IS the transport
398
- // open event — no separate WS open fires — so do the equivalent transition here.
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 (!(adapter instanceof ClaudeAdapter)) {
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 ClaudeAdapter, disconnect is handled by handleCLIClose debounce logic
628
- if (adapter instanceof ClaudeAdapter) {
629
- // Do nothing here — handleCLIClose manages the debounce timer
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 Codex adapters: transition to "reconnecting" with a short debounce
634
- // (5s vs 15s for Claude Code, since Codex doesn't cycle its WebSocket).
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 non-Claude backends (Codex uses stdio, not
669
- // a CLI WebSocket, so handleCLIOpen never runs to flush the queue).
670
- // For Claude backends, handleCLIOpen handles this after attachWebSocket.
671
- if (!(adapter instanceof ClaudeAdapter) && session.pendingMessages.length > 0) {
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
  }