@chenacc/agentbridge 0.0.0-stage → 0.1.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.
@@ -0,0 +1,493 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lines, spawnCommand, probeCommand } from "./process-utils.js";
3
+ import { CcSwitchModelCatalog } from "../cc-switch-models.js";
4
+
5
+ function textFromAssistant(message) {
6
+ const content = message?.message?.content ?? message?.content;
7
+ if (!Array.isArray(content)) return "";
8
+ return content.filter((item) => item?.type === "text").map((item) => item.text).join("");
9
+ }
10
+
11
+ function clipped(value, limit) {
12
+ return typeof value === "string" ? value.slice(0, limit) : "";
13
+ }
14
+
15
+ /**
16
+ * Project Claude Code's `AskUserQuestion` input onto the phone shape.
17
+ *
18
+ * Claude keys its answers by the question text (there is no question id), so
19
+ * the text doubles as the stable id in this batch. `preview` is dropped: the
20
+ * phone renders labels and one-line descriptions, and a preview block would
21
+ * arrive as unusable text.
22
+ */
23
+ export function claudeQuestions(value) {
24
+ const questions = Array.isArray(value) ? value.slice(0, 4) : [];
25
+ return questions.flatMap((question) => {
26
+ if (!question || typeof question !== "object") return [];
27
+ const prompt = clipped(question.question, 4_000);
28
+ if (!prompt) return [];
29
+ const options = Array.isArray(question.options)
30
+ ? question.options.slice(0, 8).flatMap((option) => {
31
+ const label = clipped(option?.label, 200);
32
+ if (!label) return [];
33
+ const description = clipped(option?.description, 500);
34
+ return [{ label, ...(description ? { description } : {}) }];
35
+ })
36
+ : [];
37
+ return [{
38
+ id: prompt,
39
+ question: prompt,
40
+ ...(question.header ? { header: clipped(question.header, 200) } : {}),
41
+ ...(options.length ? { options } : {}),
42
+ ...(question.multiSelect === true ? { multiSelect: true } : {}),
43
+ }];
44
+ });
45
+ }
46
+
47
+ export class ClaudeAdapter {
48
+ id = "claude";
49
+ label = "Claude Code";
50
+ capabilities = {
51
+ create: true,
52
+ attach: true,
53
+ discover: false,
54
+ streaming: true,
55
+ approve: true,
56
+ answer: true,
57
+ resume: true,
58
+ cancel: true,
59
+ selectModel: true,
60
+ realAgent: true,
61
+ };
62
+ #runs = new Map();
63
+ #spawn;
64
+ #model;
65
+ #modelCatalog;
66
+ #firstOutputTimeoutMs;
67
+ #interactionTimeoutMs;
68
+
69
+ constructor({
70
+ spawn = spawnCommand,
71
+ model = process.env.AGENTBRIDGE_CLAUDE_MODEL || null,
72
+ modelCatalog = new CcSwitchModelCatalog(),
73
+ firstOutputTimeoutMs = Number(process.env.AGENTBRIDGE_CLAUDE_FIRST_OUTPUT_TIMEOUT_MS || 30_000),
74
+ interactionTimeoutMs = Number(process.env.AGENTBRIDGE_CLAUDE_INTERACTION_TIMEOUT_MS || 5 * 60_000),
75
+ } = {}) {
76
+ this.#spawn = spawn;
77
+ this.#model = typeof model === "string" && model.trim() ? model.trim() : null;
78
+ this.#modelCatalog = modelCatalog;
79
+ this.#firstOutputTimeoutMs = Number.isFinite(firstOutputTimeoutMs) && firstOutputTimeoutMs > 0
80
+ ? firstOutputTimeoutMs
81
+ : 30_000;
82
+ // A prompt that reaches no phone must not park the turn forever: Claude
83
+ // keeps the tool call open until the host answers, so an unanswered request
84
+ // is denied here and the run continues with that recorded outcome.
85
+ this.#interactionTimeoutMs = Number.isFinite(interactionTimeoutMs) && interactionTimeoutMs > 0
86
+ ? interactionTimeoutMs
87
+ : 5 * 60_000;
88
+ }
89
+
90
+ async detect() {
91
+ return probeCommand("claude");
92
+ }
93
+
94
+ async start(context) {
95
+ return {
96
+ providerSessionId: randomUUID(),
97
+ context,
98
+ started: false,
99
+ modelDirectory: await this.#currentModelDirectory(),
100
+ selectedModel: null,
101
+ };
102
+ }
103
+
104
+ async attach(context, providerSessionId) {
105
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(providerSessionId)) {
106
+ throw Object.assign(new Error("Claude session id must be a UUID"), { statusCode: 400 });
107
+ }
108
+ return {
109
+ providerSessionId,
110
+ context,
111
+ started: true,
112
+ attached: true,
113
+ modelDirectory: await this.#currentModelDirectory(),
114
+ selectedModel: null,
115
+ };
116
+ }
117
+
118
+ modelDirectory(handle) {
119
+ return handle.modelDirectory ?? null;
120
+ }
121
+
122
+ async selectModel(handle, selection) {
123
+ const directory = this.modelDirectory(handle);
124
+ const group = directory?.groups.find((entry) => entry.id === selection.provider);
125
+ const target = group?.models.find((entry) => entry.id === selection.model);
126
+ if (!target) {
127
+ throw Object.assign(new Error("The selected model is not available from the current CC Switch provider"), { statusCode: 403 });
128
+ }
129
+ handle.selectedModel = target.id;
130
+ handle.modelDirectory = { ...directory, current: { provider: group.id, model: target.id } };
131
+ return { selected: handle.modelDirectory.current };
132
+ }
133
+
134
+ async send(handle, { text, attachments = [], emit }) {
135
+ if (this.#runs.has(handle.providerSessionId)) {
136
+ throw Object.assign(new Error("Claude is already running a turn"), { statusCode: 409 });
137
+ }
138
+
139
+ const turnId = randomUUID();
140
+ // Token counts arrive on the result frame, long after the turn starts.
141
+ const runUsage = {};
142
+ const selectedModel = handle.selectedModel ?? this.#model;
143
+ // The prompt travels on stdin as a stream-json user message so the same
144
+ // stdin stays open for the control stream: that is the only channel through
145
+ // which Claude Code asks a human (`can_use_tool`), and `--permission-prompts
146
+ // host` is what makes it consult us instead of deleting the asking tools.
147
+ const args = [
148
+ "--print",
149
+ "--input-format",
150
+ "stream-json",
151
+ "--output-format",
152
+ "stream-json",
153
+ "--include-partial-messages",
154
+ "--verbose",
155
+ "--permission-mode",
156
+ "manual",
157
+ "--permission-prompts",
158
+ "host",
159
+ ];
160
+ if (selectedModel) args.push("--model", selectedModel);
161
+ if (handle.started) args.push("--resume", handle.providerSessionId);
162
+ else args.push("--session-id", handle.providerSessionId);
163
+
164
+ const child = this.#spawn("claude", args, {
165
+ cwd: handle.context.cwd,
166
+ windowsHide: true,
167
+ stdio: ["pipe", "pipe", "pipe"],
168
+ });
169
+ const run = {
170
+ child,
171
+ turnId,
172
+ emit,
173
+ inputs: new Map(),
174
+ approvals: new Map(),
175
+ guardTimer: null,
176
+ closed: false,
177
+ };
178
+ this.#runs.set(handle.providerSessionId, run);
179
+
180
+ let completedText = "";
181
+ let stderr = "";
182
+ let resultError = "";
183
+ let firstOutputTimer;
184
+ const firstOutputTimeout = new Promise((resolve) => {
185
+ firstOutputTimer = setTimeout(() => resolve({ timedOut: true }), this.#firstOutputTimeoutMs);
186
+ });
187
+ const markFirstOutput = () => {
188
+ if (!firstOutputTimer) return;
189
+ clearTimeout(firstOutputTimer);
190
+ firstOutputTimer = null;
191
+ };
192
+ const close = new Promise((resolve) => {
193
+ child.once("error", (error) => resolve({ error }));
194
+ child.once("close", (exitCode, signal) => resolve({ exitCode, signal }));
195
+ });
196
+ lines(child.stdout, (line) => {
197
+ try {
198
+ const message = JSON.parse(line);
199
+ if (message.type === "stream_event") {
200
+ const event = message.event;
201
+ if (event?.type === "content_block_delta" && event.delta?.type === "text_delta") {
202
+ markFirstOutput();
203
+ completedText += event.delta.text;
204
+ void emit("message.delta", { role: "assistant", text: event.delta.text }, { turnId });
205
+ }
206
+ } else if (message.type === "assistant") {
207
+ markFirstOutput();
208
+ if (!completedText) {
209
+ const textValue = textFromAssistant(message);
210
+ if (textValue) completedText = textValue;
211
+ }
212
+ } else if (message.type === "control_request") {
213
+ // A live CLI waiting on a human is not a stalled CLI: the watchdog
214
+ // must not kill the turn while the question is on the phone.
215
+ markFirstOutput();
216
+ this.#onControlRequest(handle, run, message);
217
+ } else if (message.type === "result") {
218
+ // Claude reports token counts on its result frame; carry them through so the
219
+ // phone can show what a conversation actually costs.
220
+ if (message.usage && typeof message.usage === "object") {
221
+ const input = Number(message.usage.input_tokens ?? message.usage.inputTokens ?? 0);
222
+ const output = Number(message.usage.output_tokens ?? message.usage.outputTokens ?? 0);
223
+ if (input > 0) runUsage.inputTokens = (runUsage.inputTokens ?? 0) + input;
224
+ if (output > 0) runUsage.outputTokens = (runUsage.outputTokens ?? 0) + output;
225
+ }
226
+ markFirstOutput();
227
+ if (typeof message.result === "string" && !completedText) completedText = message.result;
228
+ if (message.is_error || String(message.subtype ?? "").startsWith("error")) {
229
+ resultError = Array.isArray(message.errors)
230
+ ? message.errors.join("\n")
231
+ : String(message.result ?? message.subtype ?? "Claude execution failed");
232
+ }
233
+ // With stream-json input the CLI keeps reading until stdin ends, so
234
+ // the finished turn is what closes it.
235
+ try { child.stdin?.end(); } catch { /* already closed */ }
236
+ }
237
+ } catch {
238
+ void emit("adapter.output", { stream: "stdout", text: line }, { turnId });
239
+ }
240
+ });
241
+ child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
242
+ child.on("close", () => { run.closed = true; });
243
+
244
+ try {
245
+ // Claude takes images as content blocks and everything else as a path it can
246
+ // open with its own file tools.
247
+ const blocks = [];
248
+ if (text) blocks.push({ type: "text", text });
249
+ for (const attachment of attachments) {
250
+ if (attachment.isImage && attachment.dataBase64) {
251
+ blocks.push({
252
+ type: "image",
253
+ source: { type: "base64", media_type: attachment.mimeType, data: attachment.dataBase64 },
254
+ });
255
+ if (text === "") blocks.push({ type: "text", text: `(图片:${attachment.name})` });
256
+ } else {
257
+ blocks.push({ type: "text", text: `附件 ${attachment.name} 的路径:${attachment.path}` });
258
+ }
259
+ }
260
+ if (!blocks.length) blocks.push({ type: "text", text: "" });
261
+ this.#write(run, {
262
+ type: "user",
263
+ message: { role: "user", content: blocks },
264
+ });
265
+ } catch (error) {
266
+ run.closed = true;
267
+ this.#runs.delete(handle.providerSessionId);
268
+ await emit("turn.failed", { error: error.message, ...(selectedModel ? { model: selectedModel } : {}) }, { turnId });
269
+ return;
270
+ }
271
+ await emit("turn.started", { adapter: this.id, ...(selectedModel ? { model: selectedModel } : {}) }, { turnId });
272
+
273
+ const outcome = await Promise.race([close, firstOutputTimeout]);
274
+ if (firstOutputTimer) clearTimeout(firstOutputTimer);
275
+ if (run.guardTimer) clearTimeout(run.guardTimer);
276
+ this.#runs.delete(handle.providerSessionId);
277
+ run.closed = true;
278
+
279
+ if (outcome.timedOut) {
280
+ child.kill();
281
+ await emit("turn.failed", {
282
+ error: `Claude did not produce output within ${this.#firstOutputTimeoutMs}ms. Check the configured model and authentication.`,
283
+ ...(selectedModel ? { model: selectedModel } : {}),
284
+ }, { turnId });
285
+ return;
286
+ }
287
+ if (outcome.error) {
288
+ await emit("turn.failed", { error: outcome.error.message, ...(selectedModel ? { model: selectedModel } : {}) }, { turnId });
289
+ return;
290
+ }
291
+ if (outcome.exitCode === 0 && !resultError) {
292
+ handle.started = true;
293
+ await emit("message.completed", { role: "assistant", text: completedText }, { turnId });
294
+ await emit("turn.completed", { status: "completed", ...(Object.keys(runUsage).length ? { usage: runUsage } : {}) }, { turnId });
295
+ return;
296
+ }
297
+ const error = (resultError || stderr.trim() || `Claude exited with code ${outcome.exitCode ?? "unknown"}`).slice(-2000);
298
+ await emit("turn.failed", {
299
+ exitCode: outcome.exitCode,
300
+ signal: outcome.signal,
301
+ error,
302
+ ...(selectedModel ? { model: selectedModel } : {}),
303
+ }, { turnId });
304
+ }
305
+
306
+ async stop(handle) {
307
+ const run = this.#runs.get(handle.providerSessionId);
308
+ if (!run) return false;
309
+ if (run.guardTimer) clearTimeout(run.guardTimer);
310
+ run.closed = true;
311
+ run.child.kill();
312
+ return true;
313
+ }
314
+
315
+ // Claude asks the human through the control stream. `AskUserQuestion` is one
316
+ // tool among many, so it is recognised by name; everything else that needs a
317
+ // human is a permission decision.
318
+ #onControlRequest(handle, run, message) {
319
+ const emit = run.emit;
320
+ const controlId = String(message.request_id ?? message.requestId ?? "");
321
+ const request = message.request && typeof message.request === "object" ? message.request : {};
322
+ if (!controlId || !emit) return;
323
+ if (String(request.subtype ?? "") !== "can_use_tool") {
324
+ // Unknown control requests would leave the CLI waiting for an envelope it
325
+ // will never get, so they are refused explicitly.
326
+ this.#controlResponse(run, controlId, { subtype: "error", error: `AgentBridge does not answer control request "${clipped(request.subtype, 60)}"` });
327
+ return;
328
+ }
329
+ const toolName = clipped(request.tool_name ?? request.toolName, 200) || "tool";
330
+ const input = request.input && typeof request.input === "object" ? request.input : {};
331
+ if (toolName === "AskUserQuestion") {
332
+ const questions = claudeQuestions(input.questions);
333
+ if (!questions.length) {
334
+ this.#controlResponse(run, controlId, { subtype: "success", request_id: controlId, response: { behavior: "deny", message: "the question could not be read" } });
335
+ return;
336
+ }
337
+ const requestId = randomUUID();
338
+ run.inputs.set(requestId, { controlId, input });
339
+ this.#armInteractionGuard(run);
340
+ void emit("input.requested", {
341
+ requestId,
342
+ adapter: this.id,
343
+ questions,
344
+ expiresAt: Date.now() + 30 * 60_000,
345
+ });
346
+ return;
347
+ }
348
+ const approvalId = randomUUID();
349
+ run.approvals.set(approvalId, { controlId });
350
+ this.#armInteractionGuard(run);
351
+ const command = typeof input.command === "string" ? clipped(input.command, 2_000) : null;
352
+ void emit("approval.requested", {
353
+ approvalId,
354
+ adapter: this.id,
355
+ method: toolName,
356
+ request: { toolName, ...(command ? { command } : {}) },
357
+ expiresAt: Date.now() + 30 * 60_000,
358
+ });
359
+ }
360
+
361
+ // Claude parks the tool call until the host answers. With no phone watching,
362
+ // that would look exactly like the bug this seam exists to fix, so an
363
+ // unanswered request is denied after a bounded wait and the turn continues.
364
+ #armInteractionGuard(run) {
365
+ if (run.guardTimer || run.closed) return;
366
+ run.guardTimer = setTimeout(() => {
367
+ run.guardTimer = null;
368
+ this.#expireInteractions(run);
369
+ }, this.#interactionTimeoutMs);
370
+ run.guardTimer.unref?.();
371
+ }
372
+
373
+ #expireInteractions(run) {
374
+ const questions = [...run.inputs.entries()];
375
+ const approvals = [...run.approvals.entries()];
376
+ run.inputs.clear();
377
+ run.approvals.clear();
378
+ if ((!questions.length && !approvals.length) || run.closed) return;
379
+ for (const [, entry] of questions) {
380
+ try {
381
+ this.#denyControl(run, entry.controlId, "no answer arrived from the phone in time");
382
+ } catch {
383
+ return;
384
+ }
385
+ }
386
+ for (const [, entry] of approvals) {
387
+ try {
388
+ this.#denyControl(run, entry.controlId, "no decision arrived from the phone in time");
389
+ } catch {
390
+ return;
391
+ }
392
+ }
393
+ // Retire the phone's cards through the same events a real answer uses, so a
394
+ // timed-out prompt leaves a readable line instead of a dead button.
395
+ for (const [requestId] of questions) void run.emit?.("input.resolved", { requestId, outcome: "expired" });
396
+ for (const [approvalId] of approvals) void run.emit?.("approval.resolved", { approvalId, decision: "deny" });
397
+ }
398
+
399
+ #denyControl(run, controlId, message) {
400
+ this.#controlResponse(run, controlId, {
401
+ subtype: "success",
402
+ request_id: controlId,
403
+ response: { behavior: "deny", message },
404
+ });
405
+ }
406
+
407
+ async resolveInput(handle, requestId, { answers = [], cancel = false } = {}) {
408
+ const run = this.#runs.get(handle.providerSessionId);
409
+ if (!run || run.closed) throw Object.assign(new Error("Claude is not waiting for an answer"), { statusCode: 409 });
410
+ const pending = run.inputs.get(requestId);
411
+ if (!pending) throw Object.assign(new Error("This question is missing or already answered"), { statusCode: 409 });
412
+ run.inputs.delete(requestId);
413
+ if (cancel) {
414
+ this.#releaseGuard(run);
415
+ this.#controlResponse(run, pending.controlId, {
416
+ subtype: "success",
417
+ request_id: pending.controlId,
418
+ response: { behavior: "deny", message: "the user skipped the question" },
419
+ });
420
+ return true;
421
+ }
422
+ // Claude reads answers as question-text → string, comma-joined for
423
+ // multi-select, with a separate slot for free text.
424
+ const byId = new Map(answers.map((answer) => [answer.id, answer]));
425
+ const structured = {};
426
+ const freeform = [];
427
+ for (const question of Array.isArray(pending.input.questions) ? pending.input.questions : []) {
428
+ const text = clipped(question?.question, 4_000);
429
+ if (!text) continue;
430
+ const answer = byId.get(text) ?? { selected: [] };
431
+ const selected = Array.isArray(answer.selected) ? answer.selected : [];
432
+ const custom = String(answer.custom ?? "").trim();
433
+ const value = selected.length ? selected.join(", ") : custom;
434
+ structured[text] = value;
435
+ if (custom && !selected.length) freeform.push(custom);
436
+ }
437
+ this.#controlResponse(run, pending.controlId, {
438
+ subtype: "success",
439
+ request_id: pending.controlId,
440
+ response: {
441
+ behavior: "allow",
442
+ updatedInput: {
443
+ ...pending.input,
444
+ answers: structured,
445
+ ...(freeform.length ? { response: freeform.join("\n") } : {}),
446
+ },
447
+ },
448
+ });
449
+ this.#releaseGuard(run);
450
+ return true;
451
+ }
452
+
453
+ async resolveApproval(handle, approvalId, decision) {
454
+ const run = this.#runs.get(handle.providerSessionId);
455
+ if (!run || run.closed) throw Object.assign(new Error("Claude is not waiting for a decision"), { statusCode: 409 });
456
+ const pending = run.approvals.get(approvalId);
457
+ if (!pending) throw Object.assign(new Error("Approval is missing or already resolved"), { statusCode: 409 });
458
+ run.approvals.delete(approvalId);
459
+ this.#controlResponse(run, pending.controlId, {
460
+ subtype: "success",
461
+ request_id: pending.controlId,
462
+ response: decision === "approve"
463
+ ? { behavior: "allow" }
464
+ : { behavior: "deny", message: "the user denied this call from the phone" },
465
+ });
466
+ this.#releaseGuard(run);
467
+ return true;
468
+ }
469
+
470
+ // The guard only matters while something is still unanswered.
471
+ #releaseGuard(run) {
472
+ if (run.inputs.size || run.approvals.size || !run.guardTimer) return;
473
+ clearTimeout(run.guardTimer);
474
+ run.guardTimer = null;
475
+ }
476
+
477
+ #controlResponse(run, controlId, body) {
478
+ this.#write(run, { type: "control_response", response: { ...body, request_id: controlId } });
479
+ }
480
+
481
+ #write(run, message) {
482
+ if (run.closed || !run.child.stdin?.writable) throw new Error("Claude stdin is closed");
483
+ run.child.stdin.write(`${JSON.stringify(message)}\n`);
484
+ }
485
+
486
+ async #currentModelDirectory() {
487
+ try {
488
+ return await this.#modelCatalog?.current();
489
+ } catch {
490
+ return null;
491
+ }
492
+ }
493
+ }