@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
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Live integration tests for the Claude stdio stream-json transport.
3
+ *
4
+ * Spawns the REAL `claude` binary on this machine (which has a token) via the
5
+ * production transport (Bun.spawn) and drives a real ClaudeAdapter end-to-end,
6
+ * asserting that every kind of message coming back over stdout — and the
7
+ * control messages written to stdin — is translated into the correct
8
+ * BrowserIncomingMessage. This is coverage the mocked unit tests can't give:
9
+ * real protocol output across assistant text, streaming partials, tool_use +
10
+ * permission round-trips (allow/deny), AskUserQuestion multiple-choice,
11
+ * set_permission_mode, and interrupt.
12
+ *
13
+ * It is a standalone Bun script (NOT a vitest test) because the transport needs
14
+ * a real Bun.spawn + real subprocess, which the node-based vitest workers don't
15
+ * provide. Run it explicitly (real API calls — costs money, ~1–2 min):
16
+ *
17
+ * bun run test:live # from web/ (see package.json)
18
+ * bun server/claude-adapter.live.ts
19
+ *
20
+ * Optional overrides:
21
+ * COMPANION_LIVE_CLAUDE_BIN path to the claude binary (default: resolved)
22
+ * COMPANION_LIVE_CLAUDE_MODEL model id (default: claude-sonnet-4-6)
23
+ */
24
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
25
+ import { tmpdir } from "node:os";
26
+ import { join } from "node:path";
27
+ import { ClaudeAdapter } from "./claude-adapter.js";
28
+ import type {
29
+ BrowserIncomingMessage,
30
+ BrowserOutgoingMessage,
31
+ PermissionRequest,
32
+ } from "./session-types.js";
33
+
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
+ const BunRT = (globalThis as any).Bun as { spawn: (...a: any[]) => any } | undefined;
36
+
37
+ function resolveClaudeBin(): string | null {
38
+ const fromEnv = process.env.COMPANION_LIVE_CLAUDE_BIN;
39
+ if (fromEnv && existsSync(fromEnv)) return fromEnv;
40
+ for (const p of [
41
+ `${process.env.HOME}/.local/bin/claude`,
42
+ "/usr/local/bin/claude",
43
+ "/root/.local/bin/claude",
44
+ ]) {
45
+ if (existsSync(p)) return p;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ const BIN = resolveClaudeBin();
51
+ const MODEL = process.env.COMPANION_LIVE_CLAUDE_MODEL || "claude-sonnet-4-6";
52
+ const TURN_TIMEOUT = 120_000;
53
+
54
+ // ─── Tiny assertion helpers ───────────────────────────────────────────────────
55
+
56
+ function assert(cond: unknown, msg: string): void {
57
+ if (!cond) throw new Error(`Assertion failed: ${msg}`);
58
+ }
59
+ function assertIncludes(haystack: string, needle: string, msg: string): void {
60
+ assert(haystack.includes(needle), `${msg} (expected to contain "${needle}", got "${haystack.slice(0, 200)}")`);
61
+ }
62
+
63
+ // ─── Harness ──────────────────────────────────────────────────────────────────
64
+
65
+ /** A live ClaudeAdapter wired to a real `claude` child over stdio stream-json. */
66
+ class LiveSession {
67
+ readonly messages: BrowserIncomingMessage[] = [];
68
+ readonly permissionRequests: PermissionRequest[] = [];
69
+ readonly cwd: string;
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ private proc: any;
72
+ private adapter: ClaudeAdapter;
73
+
74
+ constructor(opts: { permissionMode?: string } = {}) {
75
+ this.cwd = mkdtempSync(join(tmpdir(), "claude-live-"));
76
+ const args = [
77
+ BIN as string,
78
+ "--print",
79
+ "--input-format", "stream-json",
80
+ "--output-format", "stream-json",
81
+ "--include-partial-messages",
82
+ "--verbose",
83
+ "--permission-prompt-tool", "stdio",
84
+ "--model", MODEL,
85
+ "--permission-mode", opts.permissionMode ?? "default",
86
+ ];
87
+ // The exact transport the launcher uses in production. Bun.spawn's
88
+ // Subprocess shape (FileSink stdin, ReadableStream stdout, `exited` promise)
89
+ // is precisely what ClaudeAdapter.attachStdio expects — no shim.
90
+ this.proc = BunRT!.spawn(args, {
91
+ cwd: this.cwd,
92
+ env: { ...process.env, CLAUDECODE: undefined },
93
+ stdin: "pipe",
94
+ stdout: "pipe",
95
+ stderr: "pipe",
96
+ });
97
+
98
+ this.adapter = new ClaudeAdapter("live-test", { cwd: this.cwd });
99
+ this.adapter.onBrowserMessage((m) => {
100
+ this.messages.push(m);
101
+ if (m.type === "permission_request") this.permissionRequests.push(m.request);
102
+ });
103
+ this.adapter.onSessionMeta(() => {});
104
+ this.adapter.onDisconnect(() => {});
105
+ this.adapter.attachStdio(this.proc);
106
+ }
107
+
108
+ send(msg: BrowserOutgoingMessage): void {
109
+ this.adapter.send(msg);
110
+ }
111
+
112
+ sendUser(content: string): void {
113
+ this.send({ type: "user_message", content });
114
+ }
115
+
116
+ async waitFor(pred: () => boolean, label: string, timeoutMs = TURN_TIMEOUT): Promise<void> {
117
+ const start = Date.now();
118
+ while (!pred()) {
119
+ if (Date.now() - start > timeoutMs) {
120
+ throw new Error(
121
+ `Timed out waiting for ${label}. Last messages: ` +
122
+ JSON.stringify(this.messages.slice(-8).map((m) => m.type)),
123
+ );
124
+ }
125
+ await new Promise((r) => setTimeout(r, 100));
126
+ }
127
+ }
128
+
129
+ async waitForResult(timeoutMs = TURN_TIMEOUT): Promise<Extract<BrowserIncomingMessage, { type: "result" }>> {
130
+ await this.waitFor(() => this.messages.some((m) => m.type === "result"), "result", timeoutMs);
131
+ return this.messages.find((m) => m.type === "result") as Extract<BrowserIncomingMessage, { type: "result" }>;
132
+ }
133
+
134
+ assistantText(): string {
135
+ let out = "";
136
+ for (const m of this.messages) {
137
+ if (m.type !== "assistant") continue;
138
+ for (const block of m.message.content ?? []) {
139
+ if ((block as { type?: string }).type === "text") out += (block as { text: string }).text;
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+
145
+ toolUses(): { name: string; input: unknown }[] {
146
+ const uses: { name: string; input: unknown }[] = [];
147
+ for (const m of this.messages) {
148
+ if (m.type !== "assistant") continue;
149
+ for (const block of m.message.content ?? []) {
150
+ if ((block as { type?: string }).type === "tool_use") {
151
+ uses.push({ name: (block as { name: string }).name, input: (block as { input: unknown }).input });
152
+ }
153
+ }
154
+ }
155
+ return uses;
156
+ }
157
+
158
+ async close(): Promise<void> {
159
+ try { await this.adapter.disconnect(); } catch {}
160
+ try { this.proc?.kill(); } catch {}
161
+ try { rmSync(this.cwd, { recursive: true, force: true }); } catch {}
162
+ }
163
+ }
164
+
165
+ // ─── Cases ────────────────────────────────────────────────────────────────────
166
+
167
+ type Case = { name: string; run: () => Promise<void> };
168
+
169
+ const cases: Case[] = [
170
+ {
171
+ name: "session_init, streaming partials, assistant text, success result",
172
+ run: async () => {
173
+ const s = new LiveSession();
174
+ try {
175
+ s.sendUser("Reply with exactly the single token READY and nothing else.");
176
+ const result = await s.waitForResult();
177
+ assert(s.messages.some((m) => m.type === "session_init"), "session_init emitted");
178
+ assert(s.messages.some((m) => m.type === "stream_event"), "stream_event (partial) emitted");
179
+ assertIncludes(s.assistantText().toUpperCase(), "READY", "assistant text");
180
+ assert(!(result.data as { is_error?: boolean }).is_error, "result is not an error");
181
+ } finally { await s.close(); }
182
+ },
183
+ },
184
+ {
185
+ name: "tool_use + can_use_tool permission → allow runs the tool",
186
+ run: async () => {
187
+ const s = new LiveSession({ permissionMode: "default" });
188
+ try {
189
+ s.sendUser("Use the Write tool to create a file named probe.txt containing exactly HELLO_ALLOW. Do nothing else.");
190
+ await s.waitFor(() => s.permissionRequests.some((p) => p.tool_name === "Write"), "Write permission_request");
191
+ const perm = s.permissionRequests.find((p) => p.tool_name === "Write")!;
192
+ assert(perm.request_id, "permission has request_id");
193
+ assert(s.toolUses().some((t) => t.name === "Write"), "assistant tool_use Write seen");
194
+ s.send({ type: "permission_response", request_id: perm.request_id, behavior: "allow", updated_input: perm.input });
195
+ await s.waitForResult();
196
+ const file = join(s.cwd, "probe.txt");
197
+ assert(existsSync(file), "file created after allow");
198
+ assertIncludes(readFileSync(file, "utf-8"), "HELLO_ALLOW", "file contents");
199
+ } finally { await s.close(); }
200
+ },
201
+ },
202
+ {
203
+ name: "can_use_tool permission → deny blocks the tool",
204
+ run: async () => {
205
+ const s = new LiveSession({ permissionMode: "default" });
206
+ try {
207
+ s.sendUser("Use the Write tool to create a file named denied.txt containing NOPE. Do nothing else.");
208
+ await s.waitFor(() => s.permissionRequests.some((p) => p.tool_name === "Write"), "Write permission_request");
209
+ const perm = s.permissionRequests.find((p) => p.tool_name === "Write")!;
210
+ s.send({ type: "permission_response", request_id: perm.request_id, behavior: "deny", message: "Denied by test" });
211
+ await s.waitForResult();
212
+ assert(!existsSync(join(s.cwd, "denied.txt")), "file NOT created after deny");
213
+ } finally { await s.close(); }
214
+ },
215
+ },
216
+ {
217
+ name: "AskUserQuestion multiple-choice surfaces options",
218
+ run: async () => {
219
+ const s = new LiveSession({ permissionMode: "default" });
220
+ try {
221
+ s.sendUser(
222
+ "Use the AskUserQuestion tool to ask me whether I prefer tabs or spaces. " +
223
+ "Offer exactly two options labelled 'tabs' and 'spaces'. Ask only this one question.",
224
+ );
225
+ await s.waitFor(
226
+ () => s.permissionRequests.some((p) => p.tool_name === "AskUserQuestion"),
227
+ "AskUserQuestion permission_request",
228
+ );
229
+ const perm = s.permissionRequests.find((p) => p.tool_name === "AskUserQuestion")!;
230
+ const input = perm.input as { questions?: Array<{ options?: Array<{ label?: string }> }> };
231
+ assert(Array.isArray(input.questions) && input.questions.length > 0, "questions array present");
232
+ const labels = (input.questions![0].options ?? []).map((o) => (o.label ?? "").toLowerCase()).join(",");
233
+ assert(/tab|space/.test(labels), `options include tabs/spaces (got "${labels}")`);
234
+ s.send({ type: "permission_response", request_id: perm.request_id, behavior: "allow", updated_input: perm.input });
235
+ await s.waitForResult();
236
+ } finally { await s.close(); }
237
+ },
238
+ },
239
+ {
240
+ // Verifies a set_permission_mode control_request is delivered over the
241
+ // stdio transport and the session keeps working through a tool turn.
242
+ // (Runtime-switching to bypassPermissions is a launch-only mode the CLI
243
+ // does not honor mid-session, so we switch to acceptEdits and defensively
244
+ // auto-answer any prompt to avoid coupling the transport test to CLI
245
+ // permission-mode semantics.)
246
+ name: "set_permission_mode is delivered and the session completes a tool turn",
247
+ run: async () => {
248
+ const s = new LiveSession({ permissionMode: "default" });
249
+ const answered = new Set<string>();
250
+ const autoAllow = setInterval(() => {
251
+ for (const p of s.permissionRequests) {
252
+ if (answered.has(p.request_id)) continue;
253
+ answered.add(p.request_id);
254
+ s.send({ type: "permission_response", request_id: p.request_id, behavior: "allow", updated_input: p.input });
255
+ }
256
+ }, 150);
257
+ try {
258
+ s.send({ type: "set_permission_mode", mode: "acceptEdits" });
259
+ s.sendUser("Use the Write tool to create a file named mode.txt containing MODE_OK. Do nothing else.");
260
+ const result = await s.waitForResult();
261
+ assert(!(result.data as { is_error?: boolean }).is_error, "result is not an error after mode switch");
262
+ assert(existsSync(join(s.cwd, "mode.txt")), "Write completed after set_permission_mode");
263
+ } finally {
264
+ clearInterval(autoAllow);
265
+ await s.close();
266
+ }
267
+ },
268
+ },
269
+ {
270
+ name: "interrupt ends an in-flight turn",
271
+ run: async () => {
272
+ const s = new LiveSession({ permissionMode: "bypassPermissions" });
273
+ try {
274
+ s.sendUser("Count from 1 to 60, printing each number on its own line as plain text, slowly. Do not stop early.");
275
+ await s.waitFor(
276
+ () => s.messages.some((m) => m.type === "assistant" || m.type === "stream_event"),
277
+ "streaming to start",
278
+ );
279
+ s.send({ type: "interrupt" });
280
+ await s.waitForResult();
281
+ assert(s.messages.some((m) => m.type === "result"), "turn produced a result after interrupt");
282
+ } finally { await s.close(); }
283
+ },
284
+ },
285
+ ];
286
+
287
+ // ─── Runner ───────────────────────────────────────────────────────────────────
288
+
289
+ async function main(): Promise<void> {
290
+ if (!BunRT?.spawn) {
291
+ console.error("✗ Must run under Bun (need Bun.spawn). Use: bun server/claude-adapter.live.ts");
292
+ process.exit(2);
293
+ }
294
+ if (!BIN) {
295
+ console.error("✗ claude binary not found. Set COMPANION_LIVE_CLAUDE_BIN.");
296
+ process.exit(2);
297
+ }
298
+ console.log(`Running ${cases.length} live cases against ${BIN} (model=${MODEL})\n`);
299
+ let failed = 0;
300
+ for (const c of cases) {
301
+ const start = Date.now();
302
+ try {
303
+ await c.run();
304
+ console.log(` ✓ ${c.name} (${((Date.now() - start) / 1000).toFixed(1)}s)`);
305
+ } catch (err) {
306
+ failed++;
307
+ console.log(` ✗ ${c.name} (${((Date.now() - start) / 1000).toFixed(1)}s)`);
308
+ console.log(` ${err instanceof Error ? err.message : String(err)}`);
309
+ }
310
+ }
311
+ console.log(`\n${cases.length - failed}/${cases.length} passed`);
312
+ process.exit(failed ? 1 : 0);
313
+ }
314
+
315
+ void main();
@@ -13,7 +13,7 @@ import { randomUUID } from "node:crypto";
13
13
  import { mkdirSync, writeFileSync } from "node:fs";
14
14
  import { join, basename } from "node:path";
15
15
  import { log } from "./logger.js";
16
- import type { ServerWebSocket } from "bun";
16
+ import type { ServerWebSocket, Subprocess } from "bun";
17
17
  import type { IBackendAdapter } from "./backend-adapter.js";
18
18
  import type {
19
19
  BrowserIncomingMessage,
@@ -60,9 +60,22 @@ const CLI_DEDUP_WINDOW = 2000;
60
60
  export class ClaudeAdapter implements IBackendAdapter {
61
61
  private sessionId: string;
62
62
 
63
- // WebSocket to the Claude Code CLI process
63
+ // Transport selector. "websocket" is the legacy `--sdk-url` transport where
64
+ // the CLI dials back into the server; "stdio" is the supported stream-json
65
+ // transport where the server owns the child process and bridges over its
66
+ // stdin/stdout pipes. See claude-adapter stdio section below.
67
+ private transportKind: "websocket" | "stdio" = "websocket";
68
+
69
+ // WebSocket to the Claude Code CLI process (transportKind === "websocket")
64
70
  private cliSocket: ServerWebSocket<SocketData> | null = null;
65
71
 
72
+ // Stdio transport state (transportKind === "stdio")
73
+ private stdioWriter: WritableStreamDefaultWriter<Uint8Array> | null = null;
74
+ private stdioConnected = false;
75
+ private stdioBuffer = "";
76
+ /** Whether the one-time `initialize` control_request handshake has been sent. */
77
+ private stdioInitialized = false;
78
+
66
79
  // Callbacks registered by the bridge via on*() methods
67
80
  private browserMessageCb: ((msg: BrowserIncomingMessage) => void) | null = null;
68
81
  private sessionMetaCb: ((meta: { cliSessionId?: string; model?: string; cwd?: string }) => void) | null = null;
@@ -150,6 +163,124 @@ export class ClaudeAdapter implements IBackendAdapter {
150
163
  this.disconnectCb?.();
151
164
  }
152
165
 
166
+ // -- Stdio lifecycle (stream-json over the child process pipes) --------------
167
+
168
+ /**
169
+ * Attach the spawned `claude` process and drive it over stdio stream-json.
170
+ *
171
+ * This is the supported transport (the `--sdk-url` WebSocket is an internal
172
+ * Remote-Control flag Anthropic locks down). The server owns the process:
173
+ * • outbound NDJSON is written to the child's stdin
174
+ * • inbound NDJSON is read from the child's stdout (line-buffered)
175
+ * • process exit means the transport is gone (relaunch is driven by the
176
+ * launcher's `session:exited` → proactive relaunch, mirroring Codex stdio).
177
+ *
178
+ * The child must be spawned with `--input-format stream-json
179
+ * --output-format stream-json --permission-prompt-tool stdio` so the
180
+ * `can_use_tool` permission flow round-trips over the same pipes.
181
+ */
182
+ attachStdio(proc: Subprocess): void {
183
+ this.transportKind = "stdio";
184
+ this.stdioConnected = true;
185
+
186
+ // Wrap Bun's FileSink stdin (which exposes a synchronous `.write()`) in a
187
+ // WritableStream and hold a single writer — matches the proven CodexAdapter
188
+ // idiom and avoids "WritableStream is locked" races under concurrent sends.
189
+ const stdin = proc.stdin as unknown;
190
+ let writable: WritableStream<Uint8Array>;
191
+ if (stdin && typeof (stdin as { write?: unknown }).write === "function") {
192
+ writable = new WritableStream({
193
+ write(chunk) {
194
+ (stdin as { write(data: Uint8Array): number }).write(chunk);
195
+ },
196
+ });
197
+ } else {
198
+ writable = stdin as WritableStream<Uint8Array>;
199
+ }
200
+ this.stdioWriter = writable.getWriter();
201
+
202
+ // Begin consuming stdout NDJSON. The reader is async, so the synchronous
203
+ // attach (and the bridge's callback registration that follows the
204
+ // adapter-created event) completes before any message is dispatched.
205
+ const stdout = proc.stdout as ReadableStream<Uint8Array>;
206
+ void this.readStdioStdout(stdout);
207
+
208
+ // Flush anything queued before the transport attached (applies the lazy
209
+ // `initialize` handshake before the first real message).
210
+ if (this.pendingMessages.length > 0) {
211
+ const queued = this.pendingMessages.splice(0);
212
+ for (const ndjson of queued) {
213
+ this.ensureStdioInitialized(ndjson);
214
+ this.sendRaw(ndjson);
215
+ }
216
+ }
217
+
218
+ // Mark the transport gone on process exit. Relaunch is intentionally NOT
219
+ // driven from here — the launcher emits `session:exited`, and the
220
+ // orchestrator's proactive keepalive relaunches with `--resume`. This
221
+ // mirrors the Codex stdio path and avoids double-relaunch.
222
+ proc.exited.then(() => {
223
+ this.stdioConnected = false;
224
+ this.stdioWriter = null;
225
+ });
226
+ }
227
+
228
+ /** Line-buffered stdout reader: splits NDJSON and routes complete lines. */
229
+ private async readStdioStdout(stdout: ReadableStream<Uint8Array>): Promise<void> {
230
+ const reader = stdout.getReader();
231
+ const decoder = new TextDecoder();
232
+ try {
233
+ while (true) {
234
+ const { done, value } = await reader.read();
235
+ if (done) break;
236
+ this.stdioBuffer += decoder.decode(value, { stream: true });
237
+ const lines = this.stdioBuffer.split("\n");
238
+ // Keep the trailing partial line in the buffer until its newline arrives.
239
+ this.stdioBuffer = lines.pop() || "";
240
+ for (const line of lines) {
241
+ if (line.trim()) this.handleRawMessage(line);
242
+ }
243
+ }
244
+ } catch (err) {
245
+ log.error("claude-adapter", "stdio stdout reader error", {
246
+ sessionId: this.sessionId,
247
+ error: err instanceof Error ? err.message : String(err),
248
+ });
249
+ } finally {
250
+ this.stdioConnected = false;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Send the one-time `initialize` control_request before the first outbound
256
+ * message in stdio mode. This registers the canUseTool capability so the CLI
257
+ * emits `can_use_tool` control_requests (via `--permission-prompt-tool
258
+ * stdio`). If the first outbound message is itself an `initialize` (e.g. from
259
+ * injectSystemPrompt for agent sessions), we skip the duplicate.
260
+ */
261
+ private ensureStdioInitialized(nextNdjson: string): void {
262
+ if (this.stdioInitialized) return;
263
+ this.stdioInitialized = true;
264
+ try {
265
+ const parsed = JSON.parse(nextNdjson) as { type?: string; request?: { subtype?: string } };
266
+ if (parsed?.type === "control_request" && parsed.request?.subtype === "initialize") {
267
+ return; // caller is sending its own initialize — don't double up
268
+ }
269
+ } catch {
270
+ // fall through and send the baseline initialize
271
+ }
272
+ this.sendRaw(JSON.stringify({
273
+ type: "control_request",
274
+ request_id: randomUUID(),
275
+ request: { subtype: "initialize" },
276
+ }));
277
+ }
278
+
279
+ /** True when this adapter owns the child process via stdio (vs legacy WS). */
280
+ usesProcessTransport(): boolean {
281
+ return this.transportKind === "stdio";
282
+ }
283
+
153
284
  // -- IBackendAdapter: Event registration ------------------------------------
154
285
 
155
286
  onBrowserMessage(cb: (msg: BrowserIncomingMessage) => void): void {
@@ -167,6 +298,7 @@ export class ClaudeAdapter implements IBackendAdapter {
167
298
  // -- IBackendAdapter: Transport state ---------------------------------------
168
299
 
169
300
  isConnected(): boolean {
301
+ if (this.transportKind === "stdio") return this.stdioConnected;
170
302
  return this.cliSocket !== null;
171
303
  }
172
304
 
@@ -174,6 +306,13 @@ export class ClaudeAdapter implements IBackendAdapter {
174
306
  // Clear pending control requests to prevent memory leaks from
175
307
  // unresolved promises (CLI won't respond after disconnect)
176
308
  this.pendingControlRequests.clear();
309
+ if (this.transportKind === "stdio") {
310
+ // The launcher owns the process lifecycle (kill). Just drop the writer
311
+ // reference and mark disconnected; closing stdin here could race the kill.
312
+ this.stdioConnected = false;
313
+ this.stdioWriter = null;
314
+ return;
315
+ }
177
316
  if (this.cliSocket) {
178
317
  try {
179
318
  this.cliSocket.close();
@@ -190,6 +329,10 @@ export class ClaudeAdapter implements IBackendAdapter {
190
329
  * allowing the CLI to reconnect.
191
330
  */
192
331
  handleTransportClose(): void {
332
+ if (this.transportKind === "stdio") {
333
+ this.stdioConnected = false;
334
+ return;
335
+ }
193
336
  this.cliSocket = null;
194
337
  }
195
338
 
@@ -954,19 +1097,23 @@ export class ClaudeAdapter implements IBackendAdapter {
954
1097
  * queues the message for later delivery (flushed in attachWebSocket).
955
1098
  */
956
1099
  private sendToBackend(ndjson: string): void {
957
- if (!this.cliSocket) {
1100
+ if (!this.isConnected()) {
958
1101
  console.log(
959
1102
  `[claude-adapter] CLI not yet connected for session ${this.sessionId}, queuing message`,
960
1103
  );
961
1104
  this.pendingMessages.push(ndjson);
962
1105
  return;
963
1106
  }
1107
+ // In stdio mode, send the one-time initialize handshake before the first
1108
+ // real message (enables the can_use_tool permission flow).
1109
+ if (this.transportKind === "stdio") this.ensureStdioInitialized(ndjson);
964
1110
  this.sendRaw(ndjson);
965
1111
  }
966
1112
 
967
1113
  /**
968
- * Low-level send: writes NDJSON to the CLI socket with newline delimiter.
969
- * Records the outgoing message. Assumes cliSocket is non-null.
1114
+ * Low-level send: writes NDJSON to the active transport with a newline
1115
+ * delimiter and records the outgoing message. Assumes the transport is
1116
+ * connected (callers gate on isConnected() / flush after attach).
970
1117
  */
971
1118
  private sendRaw(ndjson: string): void {
972
1119
  // Record raw outgoing CLI message
@@ -975,7 +1122,11 @@ export class ClaudeAdapter implements IBackendAdapter {
975
1122
  );
976
1123
  try {
977
1124
  // NDJSON requires a newline delimiter
978
- this.cliSocket!.send(ndjson + "\n");
1125
+ if (this.transportKind === "stdio") {
1126
+ this.stdioWriter?.write(new TextEncoder().encode(ndjson + "\n"));
1127
+ } else {
1128
+ this.cliSocket!.send(ndjson + "\n");
1129
+ }
979
1130
  } catch (err) {
980
1131
  console.error(
981
1132
  `[claude-adapter] Failed to send to CLI for session ${this.sessionId}:`,