@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
|
@@ -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();
|
package/server/claude-adapter.ts
CHANGED
|
@@ -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
|
-
//
|
|
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.
|
|
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
|
|
969
|
-
*
|
|
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.
|
|
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}:`,
|