openmausbot 0.1.77 → 0.1.79

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 (70) hide show
  1. package/dist/assets/{index-DeK-z9ld.js → index-C2Je5jdF.js} +1 -1
  2. package/dist/assets/index-D_TFZLlo.js +305 -0
  3. package/dist/assets/index-Pbb6Ao0s.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +11 -5
  6. package/dist-server/drivers/agents-proxy.js +786 -29
  7. package/dist-server/enterprise/server/index.js +0 -1
  8. package/dist-server/index.js +17598 -7843
  9. package/dist-server/local-computer.js +0 -1
  10. package/dist-server/mcp-server.js +8 -1
  11. package/dist-server/openmausbot.js +9195 -759
  12. package/dist-server/pair-cli.js +9195 -759
  13. package/dist-server/proxy-paths.js +0 -1
  14. package/dist-server/server/auto-approve.js +16 -0
  15. package/dist-server/server/bot-overview.js +6 -2
  16. package/dist-server/server/bot-package.js +17 -3
  17. package/dist-server/server/box.js +98 -41
  18. package/dist-server/server/browser-engine.js +1 -1
  19. package/dist-server/server/browser-runtime.js +11 -3
  20. package/dist-server/server/browser-tool-shape.js +72 -0
  21. package/dist-server/server/chief-of-staff.js +2 -2
  22. package/dist-server/server/claude-accounts.js +1 -0
  23. package/dist-server/server/cli-setup.js +1 -1
  24. package/dist-server/server/composio.js +18 -0
  25. package/dist-server/server/config.js +16 -4
  26. package/dist-server/server/container-computer.js +0 -12
  27. package/dist-server/server/drivers/acp/core.js +4 -19
  28. package/dist-server/server/drivers/agents-proxy.js +74 -25
  29. package/dist-server/server/drivers/boxagent.js +48 -4
  30. package/dist-server/server/drivers/chat-mcp-tools.js +368 -0
  31. package/dist-server/server/drivers/chat-tool-approval.js +42 -0
  32. package/dist-server/server/drivers/claude.js +87 -32
  33. package/dist-server/server/drivers/codex.js +69 -31
  34. package/dist-server/server/drivers/grok.js +4 -0
  35. package/dist-server/server/drivers/minimax.js +11 -3
  36. package/dist-server/server/drivers/openai-chat-protocol.js +92 -0
  37. package/dist-server/server/drivers/openai-chat.js +332 -88
  38. package/dist-server/server/drivers/openai-compat.js +4 -0
  39. package/dist-server/server/drivers/pi.js +1 -9
  40. package/dist-server/server/index.js +271 -136
  41. package/dist-server/server/package-export.js +16 -14
  42. package/dist-server/server/peer-approval.js +1 -1
  43. package/dist-server/server/profile-requests.js +26 -4
  44. package/dist-server/server/proxy-paths.js +0 -1
  45. package/dist-server/server/room-handoffs.js +85 -10
  46. package/dist-server/server/routine-requests.js +86 -3
  47. package/dist-server/server/routines.js +31 -13
  48. package/dist-server/server/setup-mode.js +11 -13
  49. package/dist-server/server/skill-learn.js +4 -4
  50. package/dist-server/server/skills.js +21 -0
  51. package/dist-server/server/store.js +90 -1
  52. package/dist-server/server/system-prompt.js +7 -5
  53. package/dist-server/server/team-backup.js +6 -2
  54. package/dist-server/server/team-setup-requests.js +83 -31
  55. package/dist-server/server/tts/chatterbox.js +83 -0
  56. package/dist-server/server/tts/index.js +45 -12
  57. package/dist-server/server/workspace.js +12 -2
  58. package/dist-server/shared/ask-question.js +28 -0
  59. package/dist-server/shared/computer-contention.js +17 -0
  60. package/dist-server/shared/cron-label.js +39 -0
  61. package/dist-server/shared/routine-schedule.js +89 -0
  62. package/dist-server/shared/team-backup.js +1 -1
  63. package/dist-server/vps-container-mcp.js +11 -5
  64. package/enterprise/server/index.js +0 -1
  65. package/package.json +1 -1
  66. package/dist/assets/index-XdHJ-9CF.css +0 -1
  67. package/dist/assets/index-lhqE1umn.js +0 -305
  68. package/dist-server/computer-proxy.js +0 -1196
  69. package/dist-server/server/computer-proxy.js +0 -1070
  70. package/dist-server/server/remote-computer.js +0 -170
@@ -0,0 +1,368 @@
1
+ // Per-turn MCP transport for the shared Chat Completions runtime. Approval is
2
+ // owned by the caller; only registered, schema-validated calls reach this file.
3
+ import { Ajv } from "ajv";
4
+ import { Ajv2020 } from "ajv/dist/2020.js";
5
+ import formats from "ajv-formats";
6
+ import { killCliTree, spawnCli } from "../procs.js";
7
+ /** The transport cannot safely continue this turn. A dispatched operation may
8
+ * already have taken effect, so callers must not retry it through a new round. */
9
+ export class ChatToolSessionError extends Error {
10
+ }
11
+ const STARTUP_MS = 8_000;
12
+ const CALL_MS = 10 * 60_000;
13
+ const FRAME_BYTES = 2 * 1024 * 1024;
14
+ const OUTPUT_BYTES = 50 * 1024;
15
+ const TOOL_COUNT = 128;
16
+ const MAX_PAGES = 100;
17
+ const SCHEMA_BYTES = 64 * 1024;
18
+ const CATALOG_BYTES = 1024 * 1024;
19
+ const MAX_FRAMES = 10_000;
20
+ function object(value) {
21
+ return value !== null && typeof value === "object" && !Array.isArray(value);
22
+ }
23
+ function aborted() { return new Error("MCP operation cancelled"); }
24
+ class ChatMcpClient {
25
+ child;
26
+ buffer = "";
27
+ nextId = 1;
28
+ frames = 0;
29
+ closed = false;
30
+ closing;
31
+ pending = new Map();
32
+ constructor(server) {
33
+ try {
34
+ this.child = spawnCli(server.command, server.args, {
35
+ stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, ...server.env },
36
+ });
37
+ }
38
+ catch {
39
+ throw new Error("MCP server could not start; check its command and installation");
40
+ }
41
+ this.child.stdout.setEncoding("utf8");
42
+ this.child.stdout.on("data", (chunk) => this.onData(chunk));
43
+ // Stderr may contain credentials; drain without recording or returning it.
44
+ this.child.stderr.on("data", () => { });
45
+ this.child.on("error", () => this.fail(new Error("MCP server could not start; check its command and installation")));
46
+ this.child.on("exit", () => this.fail(new Error("MCP server exited before the session closed")));
47
+ this.child.stdin.on("error", () => this.fail(new Error("MCP server input closed")));
48
+ }
49
+ fail(error) {
50
+ if (this.closed)
51
+ return;
52
+ this.closed = true;
53
+ for (const entry of this.pending.values())
54
+ entry.reject(error);
55
+ this.pending.clear();
56
+ // Capture rejection here; mount/execute finally await close and surface it.
57
+ void this.close().catch(() => { });
58
+ }
59
+ close() {
60
+ if (this.closing)
61
+ return this.closing;
62
+ this.closed = true;
63
+ for (const entry of this.pending.values())
64
+ entry.reject(new Error("MCP session closed"));
65
+ this.pending.clear();
66
+ this.buffer = "";
67
+ this.closing = killCliTree(this.child, 500).then((stopped) => {
68
+ if (!stopped)
69
+ throw new Error("MCP server shutdown could not be confirmed; execution outcome may be uncertain");
70
+ });
71
+ return this.closing;
72
+ }
73
+ write(frame) {
74
+ if (this.closed || !this.child.stdin.writable || this.child.stdin.destroyed)
75
+ throw new Error("MCP session closed");
76
+ const encoded = JSON.stringify(frame) + "\n";
77
+ if (Buffer.byteLength(encoded) > FRAME_BYTES)
78
+ throw new Error("MCP request exceeds the frame limit");
79
+ this.child.stdin.write(encoded);
80
+ }
81
+ onData(chunk) {
82
+ if (this.closed)
83
+ return;
84
+ this.buffer += chunk;
85
+ let newline;
86
+ while ((newline = this.buffer.indexOf("\n")) !== -1) {
87
+ const line = this.buffer.slice(0, newline);
88
+ this.buffer = this.buffer.slice(newline + 1);
89
+ if (++this.frames > MAX_FRAMES)
90
+ return this.fail(new Error("MCP session exceeded the response frame count limit"));
91
+ if (Buffer.byteLength(line) > FRAME_BYTES)
92
+ return this.fail(new Error("MCP response exceeds the frame limit"));
93
+ if (!line.trim())
94
+ continue;
95
+ let message;
96
+ try {
97
+ message = JSON.parse(line);
98
+ }
99
+ catch {
100
+ return this.fail(new Error("MCP server returned invalid JSON"));
101
+ }
102
+ if (!object(message) || message.jsonrpc !== "2.0")
103
+ return this.fail(new Error("MCP server returned an invalid RPC envelope"));
104
+ if (typeof message.method === "string") {
105
+ if (typeof message.id === "number" || typeof message.id === "string") {
106
+ try {
107
+ this.write({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Client method not supported" } });
108
+ }
109
+ catch {
110
+ return this.fail(new Error("MCP server input closed"));
111
+ }
112
+ }
113
+ continue;
114
+ }
115
+ if (typeof message.id !== "number")
116
+ return this.fail(new Error("MCP server returned an invalid response ID"));
117
+ const entry = this.pending.get(message.id);
118
+ if (!entry)
119
+ continue;
120
+ this.pending.delete(message.id);
121
+ if ("error" in message)
122
+ entry.reject(new Error("MCP request failed; inspect the configured integration"));
123
+ else if (!("result" in message))
124
+ entry.reject(new Error("MCP response has no result"));
125
+ else
126
+ entry.resolve(message.result);
127
+ }
128
+ if (Buffer.byteLength(this.buffer) > FRAME_BYTES)
129
+ this.fail(new Error("MCP response exceeds the frame limit"));
130
+ }
131
+ async call(method, params, signal, timeout) {
132
+ if (signal.aborted) {
133
+ await this.close();
134
+ throw aborted();
135
+ }
136
+ if (this.closed)
137
+ throw new Error("MCP session closed");
138
+ const id = this.nextId++;
139
+ try {
140
+ return await new Promise((resolve, reject) => {
141
+ const finish = (error, value) => {
142
+ clearTimeout(timer);
143
+ signal.removeEventListener("abort", cancel);
144
+ this.pending.delete(id);
145
+ if (error)
146
+ reject(error);
147
+ else
148
+ resolve(value);
149
+ };
150
+ const cancel = () => {
151
+ try {
152
+ this.write({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: id, reason: "Cancelled" } });
153
+ }
154
+ catch { /* cleanup owns transport failure */ }
155
+ finish(aborted());
156
+ };
157
+ const timer = setTimeout(() => finish(new Error("MCP request timed out; execution outcome may be uncertain")), timeout);
158
+ timer.unref();
159
+ signal.addEventListener("abort", cancel, { once: true });
160
+ this.pending.set(id, { resolve: (value) => finish(undefined, value), reject: (error) => finish(error) });
161
+ try {
162
+ this.write({ jsonrpc: "2.0", id, method, params });
163
+ }
164
+ catch {
165
+ finish(new Error("MCP request could not be sent"));
166
+ }
167
+ });
168
+ }
169
+ catch (error) {
170
+ await this.close();
171
+ throw error;
172
+ }
173
+ }
174
+ async tools(signal) {
175
+ const deadline = Date.now() + STARTUP_MS;
176
+ const remaining = () => {
177
+ if (Date.now() >= deadline)
178
+ throw new Error("MCP startup timed out");
179
+ return deadline - Date.now();
180
+ };
181
+ const initialized = await this.call("initialize", {
182
+ protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "openmausbot-chat", version: "1" },
183
+ }, signal, remaining());
184
+ if (!object(initialized))
185
+ throw new Error("MCP initialization returned an invalid result");
186
+ if (signal.aborted)
187
+ throw aborted();
188
+ this.write({ jsonrpc: "2.0", method: "notifications/initialized" });
189
+ const tools = [];
190
+ const cursors = new Set();
191
+ let cursor;
192
+ for (let page = 0; page < MAX_PAGES; page += 1) {
193
+ const result = await this.call("tools/list", cursor ? { cursor } : {}, signal, remaining());
194
+ if (!object(result) || !Array.isArray(result.tools))
195
+ throw new Error("MCP tools/list returned an invalid result");
196
+ tools.push(...result.tools);
197
+ if (tools.length > TOOL_COUNT)
198
+ throw new Error("MCP tool count exceeds the 128-tool limit");
199
+ if (result.nextCursor === undefined)
200
+ return tools;
201
+ if (typeof result.nextCursor !== "string" || !result.nextCursor || cursors.has(result.nextCursor))
202
+ throw new Error("MCP tools/list returned an invalid pagination cursor");
203
+ cursors.add(result.nextCursor);
204
+ cursor = result.nextCursor;
205
+ }
206
+ throw new Error("MCP tools/list exceeded the pagination limit");
207
+ }
208
+ }
209
+ // A schema is a contract, so conversion that drops constraints is not safe.
210
+ // Ajv validates the same schema sent to the provider without coercing values,
211
+ // applying defaults, removing fields, or fetching external references.
212
+ const validatorOptions = {
213
+ strict: true, allErrors: false, coerceTypes: false, useDefaults: false,
214
+ removeAdditional: false, validateFormats: true, ownProperties: true, logger: false,
215
+ // These are style diagnostics, not unsupported validation keywords. Valid
216
+ // schemas may require undeclared names, use untyped composition branches,
217
+ // or describe an open tuple. Ajv still enforces every constraint.
218
+ strictRequired: false, strictTypes: false, strictTuples: false,
219
+ };
220
+ function compileSchema(schema) {
221
+ const dialect = schema.$schema;
222
+ if (dialect !== undefined && dialect !== "http://json-schema.org/draft-07/schema#" && dialect !== "https://json-schema.org/draft/2020-12/schema") {
223
+ throw new Error("MCP tool schema uses an unsupported dialect; use JSON Schema draft-07 or 2020-12");
224
+ }
225
+ // One compiler per schema also prevents external IDs from resolving against
226
+ // unrelated tools or retaining schemas after the turn has closed.
227
+ const compiler = dialect === "https://json-schema.org/draft/2020-12/schema"
228
+ ? new Ajv2020(validatorOptions) : new Ajv(validatorOptions);
229
+ // ajv-formats is CommonJS and exports the plugin as both module.exports
230
+ // and .default; the latter also matches its NodeNext declaration.
231
+ formats.default(compiler);
232
+ try {
233
+ return compiler.compile(schema);
234
+ }
235
+ catch {
236
+ throw new Error("MCP tool schema could not be validated; check its constraints, formats, and references");
237
+ }
238
+ }
239
+ function boundedText(value) {
240
+ const bytes = Buffer.from(value);
241
+ if (bytes.length <= OUTPUT_BYTES)
242
+ return value;
243
+ let end = OUTPUT_BYTES;
244
+ while ((bytes[end] & 0xc0) === 0x80)
245
+ end -= 1;
246
+ return `${bytes.subarray(0, end).toString()}\n[MCP result truncated at 50KB; request less output.]`;
247
+ }
248
+ export async function mountChatTools(integrations, signal) {
249
+ const servers = [];
250
+ if (integrations?.agents)
251
+ servers.push(["agents", integrations.agents]);
252
+ if (integrations?.composio)
253
+ servers.push(["composio", integrations.composio]);
254
+ servers.push(...Object.entries(integrations?.custom ?? {}));
255
+ if (servers.length > 32)
256
+ throw new Error("MCP server count exceeds the 32-server limit");
257
+ const clients = [];
258
+ let closed = false;
259
+ let closing;
260
+ const close = () => {
261
+ if (closing)
262
+ return closing;
263
+ closed = true;
264
+ signal.removeEventListener("abort", cancel);
265
+ closing = Promise.allSettled(clients.map((client) => client.close())).then((results) => {
266
+ if (results.some((result) => result.status === "rejected"))
267
+ throw new ChatToolSessionError("MCP server shutdown could not be confirmed; execution outcome may be uncertain");
268
+ });
269
+ return closing;
270
+ };
271
+ const cancel = () => { void close().catch(() => { }); };
272
+ signal.addEventListener("abort", cancel, { once: true });
273
+ const definitions = [];
274
+ const registered = new Map();
275
+ try {
276
+ if (signal.aborted)
277
+ throw aborted();
278
+ // Start independent servers concurrently; consume results in config order
279
+ // so names and collision suffixes remain stable across startup timings.
280
+ const mounts = await Promise.allSettled(servers.map(async ([name, descriptor]) => {
281
+ if (signal.aborted || closed)
282
+ throw aborted();
283
+ const client = new ChatMcpClient(descriptor);
284
+ clients.push(client);
285
+ return { name, client, tools: await client.tools(signal) };
286
+ }));
287
+ for (const mount of mounts) {
288
+ if (mount.status === "rejected")
289
+ throw mount.reason;
290
+ const { name: server, client, tools } = mount.value;
291
+ const originalNames = new Set();
292
+ for (const tool of tools) {
293
+ if (!object(tool) || typeof tool.name !== "string" || !tool.name.trim() || originalNames.has(tool.name))
294
+ throw new Error("MCP server advertised an invalid or duplicate tool name");
295
+ originalNames.add(tool.name);
296
+ if (definitions.length >= TOOL_COUNT)
297
+ throw new Error("MCP tool count exceeds the 128-tool limit");
298
+ if (!object(tool.inputSchema) || tool.inputSchema.type !== "object")
299
+ throw new Error("MCP tools require an object input schema");
300
+ if (Buffer.byteLength(JSON.stringify(tool.inputSchema)) > SCHEMA_BYTES)
301
+ throw new Error("MCP tool schema exceeds the 64KB limit");
302
+ const schema = compileSchema(tool.inputSchema);
303
+ const base = `${server}_${tool.name}`.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64) || "mcp_tool";
304
+ let name = base;
305
+ for (let index = 2; registered.has(name); index += 1) {
306
+ const suffix = `_${index}`;
307
+ name = base.slice(0, 64 - suffix.length) + suffix;
308
+ }
309
+ registered.set(name, { client, name: tool.name, schema });
310
+ definitions.push({ type: "function", function: { name, description: typeof tool.description === "string" ? tool.description : "Configured MCP tool", parameters: tool.inputSchema } });
311
+ if (Buffer.byteLength(JSON.stringify(definitions)) > CATALOG_BYTES)
312
+ throw new Error("MCP tool catalog exceeds the 1MB limit");
313
+ }
314
+ }
315
+ if (signal.aborted || closed)
316
+ throw aborted();
317
+ }
318
+ catch (error) {
319
+ await close();
320
+ throw error;
321
+ }
322
+ const validate = (name, args) => {
323
+ if (closed || signal.aborted)
324
+ throw new ChatToolSessionError("MCP session closed");
325
+ const tool = registered.get(name);
326
+ if (!tool)
327
+ throw new Error("The requested tool was not advertised for this turn");
328
+ if (!object(args) || !tool.schema(args))
329
+ throw new Error("Tool arguments do not match the advertised input schema; use its required fields and types");
330
+ };
331
+ return {
332
+ definitions, validate, close,
333
+ async execute(name, args, callSignal) {
334
+ validate(name, args);
335
+ if (callSignal.aborted) {
336
+ await close();
337
+ throw aborted();
338
+ }
339
+ const tool = registered.get(name);
340
+ try {
341
+ const result = await tool.client.call("tools/call", { name: tool.name, arguments: args }, AbortSignal.any([signal, callSignal]), CALL_MS);
342
+ if (signal.aborted || callSignal.aborted)
343
+ throw aborted();
344
+ if (!object(result) || !Array.isArray(result.content) || (result.isError !== undefined && typeof result.isError !== "boolean"))
345
+ throw new Error("MCP tool returned an invalid result; execution outcome may be uncertain");
346
+ const parts = [];
347
+ let unsupported = 0;
348
+ for (const item of result.content) {
349
+ if (!object(item) || typeof item.type !== "string" || (item.type === "text" && typeof item.text !== "string"))
350
+ throw new Error("MCP tool returned invalid content; execution outcome may be uncertain");
351
+ if (item.type === "text")
352
+ parts.push(item.text);
353
+ else
354
+ unsupported += 1;
355
+ }
356
+ if (result.structuredContent !== undefined)
357
+ parts.push(JSON.stringify(result.structuredContent));
358
+ if (unsupported)
359
+ parts.unshift(`[${unsupported} unsupported MCP content item(s) omitted. The operation may have taken effect, but its full result cannot be represented; inspect its state before retrying.]`);
360
+ return { text: boundedText(parts.join("\n") || "(empty result)"), ok: result.isError !== true && unsupported === 0 };
361
+ }
362
+ catch (error) {
363
+ await close();
364
+ throw new ChatToolSessionError(error instanceof Error ? error.message : "MCP transport failed; execution outcome may be uncertain");
365
+ }
366
+ },
367
+ };
368
+ }
@@ -0,0 +1,42 @@
1
+ // Per-turn permission gate, adapted from the approval lifecycle in #780.
2
+ // Register before publishing: a harness listener may answer synchronously.
3
+ import { newId } from "../contracts.js";
4
+ export function createChatToolApproval(options) {
5
+ const pending = new Map();
6
+ let closed = false;
7
+ return {
8
+ ask(tool, summary) {
9
+ if (closed || options.signal.aborted)
10
+ return Promise.resolve(false);
11
+ const ask = { id: newId(), tool, summary };
12
+ return new Promise((resolve) => {
13
+ const finish = (allowed, source) => {
14
+ if (!pending.delete(ask.id))
15
+ return;
16
+ clearTimeout(timer);
17
+ options.signal.removeEventListener("abort", abort);
18
+ options.resolved(ask, allowed, source);
19
+ resolve(allowed);
20
+ };
21
+ const abort = () => finish(false, "system");
22
+ const timer = setTimeout(() => finish(false, "timeout"), options.timeoutMs ?? 15 * 60_000);
23
+ timer.unref?.();
24
+ pending.set(ask.id, finish);
25
+ options.signal.addEventListener("abort", abort, { once: true });
26
+ options.open(ask);
27
+ });
28
+ },
29
+ answer(id, behavior) {
30
+ const finish = pending.get(id);
31
+ if (!finish || options.signal.aborted || behavior === "answer")
32
+ return "unavailable";
33
+ finish(behavior === "allow", "user");
34
+ return behavior === "allow" ? "allowed-once" : "rejected";
35
+ },
36
+ close() {
37
+ closed = true;
38
+ for (const finish of pending.values())
39
+ finish(false, "system");
40
+ },
41
+ };
42
+ }
@@ -18,7 +18,6 @@ import { augmentedPath } from "../env-path.js";
18
18
  import { brokerSocketPath, describeSpawnFailure, execCli, killCliTree, spawnCli } from "../procs.js";
19
19
  import { classifyResumeFailure, mayReplay, recoveryPromptFor } from "../resume-recovery.js";
20
20
  import { ClaudeLoginController } from "./claude-login-auth.js";
21
- import { computerProxyEnv } from "../container-computer.js";
22
21
  import { gateServer, resultBudget } from "../mcp-gate-config.js";
23
22
  import { newEventId, newId } from "../contracts.js";
24
23
  import { askInputSummary, commandSummary, toolDetailPreview } from "../tool-summary.js";
@@ -26,7 +25,7 @@ import { classifyError, computeBackoff, interruptibleDelay, RETRY_MAX_ATTEMPTS }
26
25
  import { applyClaudeInject, decodeInjectId, mergeLocalInject, probeLocalInjects, resolveInjectId, } from "./local-inject.js";
27
26
  import { appendNative } from "./native.js";
28
27
  import { SPAWNED_PROXIES } from "../proxy-paths.js";
29
- import { ASK_USER_QUESTION_TOOL, askQuestionSummary, parseAskQuestions, questionChoices, } from "../../shared/ask-question.js";
28
+ import { ASK_USER_QUESTION_TOOL, askQuestionSummary, parseAskQuestions, parseChoices, questionChoices, } from "../../shared/ask-question.js";
30
29
  /** Whether `claude` has been signed in.
31
30
  *
32
31
  * Credential storage is deliberately not inspected here. Claude Code uses the
@@ -293,6 +292,30 @@ export function claudeCliUpdate(version, cli) {
293
292
  command: cli === "claude" ? "claude update" : `${cli} update`,
294
293
  };
295
294
  }
295
+ /** Whether `claude --help` output lists `flag` as a supported option.
296
+ *
297
+ * A wrapper may print its own banner before the real options list, and the
298
+ * flag may appear only in an example or description rather than as an
299
+ * option. We scan for a line that begins with the flag (after optional
300
+ * leading whitespace), which is how the real CLI formats its `--help`.
301
+ */
302
+ export function claudeCliHelpSupportsFlag(help, flag) {
303
+ if (!help)
304
+ return false;
305
+ const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
306
+ return new RegExp(`^\\s*${escaped}\\b`, "m").test(help);
307
+ }
308
+ /** Whether the installed CLI supports `--autocompact`.
309
+ *
310
+ * When `snapshot()` has already probed `claude --help`, use that. If the
311
+ * probe has not run or `--help` failed, fall back to the version floor so
312
+ * the pre-snapshot behavior is preserved.
313
+ */
314
+ export function claudeAutoCompactSupported(version, help) {
315
+ if (help !== null)
316
+ return claudeCliHelpSupportsFlag(help, "--autocompact");
317
+ return claudeCliSupports(version, "--autocompact");
318
+ }
296
319
  const DRIVER_KIND = "claudeAgent";
297
320
  // model catalog ported from upstream packages/contracts/src/model.ts
298
321
  export const STATIC_CLAUDE_MODELS = {
@@ -338,12 +361,15 @@ function extrasFromUnknown(value) {
338
361
  * ANTHROPIC_API_KEY ("Not logged in · Please run /login"). Live injects
339
362
  * come from mergeLocalInject. */
340
363
  export function readClaudeModelCatalog(env = process.env) {
364
+ // A missing or unreadable settings.json is not fatal: an instance whose
365
+ // environment sets ANTHROPIC_MODEL (a Claude Code install pointed at an
366
+ // Anthropic-compatible host) still lists that model as Custom.
341
367
  let settings = {};
342
368
  try {
343
369
  settings = JSON.parse(readFileSync(join(resolveClaudeConfigDir(undefined, env), "settings.json"), "utf8"));
344
370
  }
345
371
  catch {
346
- return STATIC_CLAUDE_MODELS;
372
+ settings = {};
347
373
  }
348
374
  const extras = [
349
375
  ...extrasFromUnknown(settings.availableModels),
@@ -367,7 +393,6 @@ export function readClaudeModelCatalog(env = process.env) {
367
393
  // Resolved from the server root, never relative to this file: bundling inlines
368
394
  // this module into an entry one directory up, so a `".."` here would climb too
369
395
  // far. See server/proxy-paths.ts.
370
- const PROXY_PATH = SPAWNED_PROXIES.computer;
371
396
  const PERM_PROXY_PATH = SPAWNED_PROXIES.permission;
372
397
  const DWEB_PROXY_PATH = SPAWNED_PROXIES.dweb;
373
398
  // in the packaged app process.execPath is the Electron binary — this env
@@ -782,15 +807,16 @@ export const ClaudeDriver = {
782
807
  }
783
808
  };
784
809
  await refreshModels();
785
- // The installed CLI's version as snapshot() last read it, so a flag the
786
- // CLI does not know is never passed to it (CLAUDE_FLAG_FLOORS). The
787
- // harness snapshots every instance whenever it describes them — app
788
- // load, the Engines page, and right after `claude update`, which is
789
- // exactly when the answer changes — so a turn normally finds it filled.
790
- // A turn before any snapshot assumes a current CLI rather than paying a
791
- // CLI start-up of its own: the flags are the default, the exception is
792
- // the older install, and the next snapshot corrects it.
810
+ // The installed CLI's version and `--help` output as snapshot() last read
811
+ // them, so a flag the CLI does not know is never passed to it. The harness
812
+ // snapshots every instance whenever it describes them — app load, the
813
+ // Engines page, and right after `claude update` — which is exactly when
814
+ // the answer changes, so a turn normally finds it filled. A turn before
815
+ // any snapshot falls back to the version floor for the flags that are not
816
+ // known yet.
793
817
  let cliVersion = null;
818
+ let cliHelp = null;
819
+ let cliHelpVersion = null;
794
820
  const listeners = new Set();
795
821
  // one active turn per thread; a second send while busy is a caller bug
796
822
  const active = new Map();
@@ -876,7 +902,11 @@ export const ClaudeDriver = {
876
902
  const retryState = new Map();
877
903
  const sendTurn = async (turn, logicalTurnId) => {
878
904
  const { threadId, botId } = turn;
879
- if (active.has(threadId))
905
+ // An internal relaunch (transient failure, rejected resume) keeps the
906
+ // logical turn's stop handle in `active` while it sets up, so Stop is
907
+ // never a silent no-op between two CLI processes of the same turn.
908
+ const relaunch = logicalTurnId !== undefined;
909
+ if (active.has(threadId) && !relaunch)
880
910
  throw new Error("a turn is already running on this thread");
881
911
  // A bot-level mode is authoritative for this turn. In particular, an
882
912
  // old provider instance may still be configured with
@@ -901,7 +931,10 @@ export const ClaudeDriver = {
901
931
  const turnId = logicalTurnId ?? newId();
902
932
  const retryAbort = new AbortController();
903
933
  const retry = retryState.get(threadId) ?? { attempt: 0, cancelled: false };
904
- retry.cancelled = false;
934
+ // A fresh user turn starts un-cancelled. A relaunch must keep a Stop
935
+ // that landed while it was being scheduled.
936
+ if (!relaunch)
937
+ retry.cancelled = false;
905
938
  retryState.set(threadId, retry);
906
939
  // a retry relaunches the whole CLI; the backoff is scaled down in tests
907
940
  // so a fake's transient failures don't stall real seconds
@@ -941,7 +974,7 @@ export const ClaudeDriver = {
941
974
  args.push("--setting-sources", "project");
942
975
  }
943
976
  const compactWindow = autoCompactWindow(turnEnvironment);
944
- if (compactWindow && claudeCliSupports(cliVersion, "--autocompact")) {
977
+ if (compactWindow && claudeAutoCompactSupported(cliVersion, cliHelp)) {
945
978
  args.push("--autocompact", compactWindow);
946
979
  }
947
980
  const turnModel = await resolveClaudeTurnModel(turn.model, turnEnvironment);
@@ -964,15 +997,7 @@ export const ClaudeDriver = {
964
997
  mcpServers.composio = { ...turn.integrations.composio };
965
998
  allowed.push("mcp__composio");
966
999
  }
967
- if (turn.integrations?.computer) {
968
- mcpServers.computer = {
969
- command: process.execPath,
970
- args: [PROXY_PATH],
971
- env: { ...NODE_ENV_FLAG, ...computerProxyEnv(turn.integrations.computer) },
972
- };
973
- allowed.push("mcp__computer");
974
- }
975
- else if (turn.integrations?.localComputer) {
1000
+ if (turn.integrations?.localComputer) {
976
1001
  const local = turn.integrations.localComputer;
977
1002
  mcpServers.computer = {
978
1003
  command: local.command,
@@ -1216,9 +1241,7 @@ export const ClaudeDriver = {
1216
1241
  questions: questions ?? undefined,
1217
1242
  // A structured ask still offers flat labels, for the phone
1218
1243
  // companions and any client that predates the question card.
1219
- choices: questions
1220
- ? questionChoices(questions)
1221
- : Array.isArray(ask.input?.choices) ? ask.input.choices.slice(0, 5) : undefined,
1244
+ choices: questions ? questionChoices(questions) : parseChoices(ask.input?.choices),
1222
1245
  });
1223
1246
  },
1224
1247
  onResolve: (resolved) => {
@@ -1259,6 +1282,16 @@ export const ClaudeDriver = {
1259
1282
  cleanupUnownedLaunch();
1260
1283
  throw error;
1261
1284
  }
1285
+ // Stop reached the relaunch handle while this attempt was still setting
1286
+ // up (model probe, broker). Settle the logical turn as interrupted
1287
+ // instead of spawning a process nobody wants.
1288
+ if (relaunch && retry.cancelled) {
1289
+ cleanupUnownedLaunch();
1290
+ if (active.get(threadId)?.turnId === turnId)
1291
+ active.delete(threadId);
1292
+ emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: "interrupted", cost: null });
1293
+ return { turnId };
1294
+ }
1262
1295
  let child;
1263
1296
  try {
1264
1297
  child = spawnCli(config.cli, args, {
@@ -1544,14 +1577,18 @@ export const ClaudeDriver = {
1544
1577
  });
1545
1578
  return;
1546
1579
  }
1547
- // hand the thread back before recursing — the relaunch's own
1548
- // guard would otherwise reject it as "already running"
1549
- active.delete(threadId);
1580
+ // Keep Stop reachable while the relaunch sets up: there is no
1581
+ // process yet, so this handle only records the cancellation and
1582
+ // the relaunched sendTurn honors it before spawning.
1583
+ retryState.set(threadId, retry);
1584
+ active.set(threadId, { stop: () => { retry.cancelled = true; retryAbort.abort(); }, turnId });
1550
1585
  try {
1551
1586
  const cursor = session.sessionId ?? sessionId ?? undefined;
1552
1587
  await sendTurn({ ...turn, resumeCursor: cursor }, turnId);
1553
1588
  }
1554
1589
  catch (e) {
1590
+ if (active.get(threadId)?.turnId === turnId)
1591
+ active.delete(threadId);
1555
1592
  retryState.delete(threadId);
1556
1593
  emit({
1557
1594
  ...base(threadId, turnId),
@@ -1605,7 +1642,9 @@ export const ClaudeDriver = {
1605
1642
  }
1606
1643
  sessions.delete(threadId);
1607
1644
  session.turn = null;
1608
- active.delete(threadId);
1645
+ // Same relaunch handle as the transient-retry path above.
1646
+ retryState.set(threadId, retry);
1647
+ active.set(threadId, { stop: () => { retry.cancelled = true; retryAbort.abort(); }, turnId });
1609
1648
  emit({
1610
1649
  ...base(threadId, turnId),
1611
1650
  type: "turn.retrying",
@@ -1619,6 +1658,8 @@ export const ClaudeDriver = {
1619
1658
  await sendTurn({ ...turn, resumeCursor: undefined, recoveryText: undefined, text: recovery.text }, turnId);
1620
1659
  }
1621
1660
  catch (e) {
1661
+ if (active.get(threadId)?.turnId === turnId)
1662
+ active.delete(threadId);
1622
1663
  retryState.delete(threadId);
1623
1664
  emit({
1624
1665
  ...base(threadId, turnId),
@@ -1693,12 +1734,26 @@ export const ClaudeDriver = {
1693
1734
  if (!version)
1694
1735
  return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` };
1695
1736
  cliVersion = parseClaudeCliVersion(version);
1737
+ if (version !== cliHelpVersion) {
1738
+ cliHelp = await new Promise((resolve) => {
1739
+ execCli(config.cli, ["--help"], { timeout: 8000, env }, (err, stdout) => resolve(err ? null : stdout));
1740
+ });
1741
+ cliHelpVersion = version;
1742
+ }
1743
+ const features = cliHelp !== null ? { autocompact: claudeCliHelpSupportsFlag(cliHelp, "--autocompact") } : undefined;
1696
1744
  const auth = await claudeAuthStatus(config.cli, env);
1697
1745
  // claudeEnvironment strips ANTHROPIC_API_KEY, so turns run on the
1698
1746
  // CLI's own login (Pro/Max): the cost it reports is what the call
1699
1747
  // WOULD bill, not a charge
1700
1748
  const update = claudeCliUpdate(version, config.cli);
1701
- return { state: "available", version, ...auth, ...(update ? { update } : {}), billing: "subscription" };
1749
+ return {
1750
+ state: "available",
1751
+ version,
1752
+ ...auth,
1753
+ ...(update ? { update } : {}),
1754
+ ...(features ? { features } : {}),
1755
+ billing: "subscription",
1756
+ };
1702
1757
  };
1703
1758
  /** One-shot Claude call with the prompt on stdin, never argv. Approval
1704
1759
  * summaries can contain paths, commands, or secrets, so the generic