openmausbot 0.1.80 → 0.1.81

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 (56) hide show
  1. package/README.md +6 -5
  2. package/dist/assets/index-CKysBq-V.css +1 -0
  3. package/dist/assets/{index-D6RZKWks.js → index-oLMljotU.js} +1 -1
  4. package/dist/assets/index-peLFiSH8.js +307 -0
  5. package/dist/index.html +2 -2
  6. package/dist-server/container-mcp.js +41 -10
  7. package/dist-server/drivers/agents-proxy.js +50 -11
  8. package/dist-server/index.js +3433 -1278
  9. package/dist-server/openmausbot.js +389 -83
  10. package/dist-server/pair-cli.js +389 -83
  11. package/dist-server/server/box-delete-journal.js +471 -0
  12. package/dist-server/server/box.js +345 -75
  13. package/dist-server/server/cli-phone-setup.js +2 -2
  14. package/dist-server/server/cli.js +84 -12
  15. package/dist-server/server/config.js +48 -18
  16. package/dist-server/server/container-computer.js +7 -0
  17. package/dist-server/server/contracts.js +5 -0
  18. package/dist-server/server/default-model-selection.js +1 -0
  19. package/dist-server/server/drivers/acp/core.js +112 -18
  20. package/dist-server/server/drivers/acp/opencode-go.js +6 -0
  21. package/dist-server/server/drivers/agents-proxy.js +49 -11
  22. package/dist-server/server/drivers/chat-mcp-tools.js +6 -1
  23. package/dist-server/server/drivers/claude.js +33 -2
  24. package/dist-server/server/drivers/codex.js +93 -8
  25. package/dist-server/server/drivers/native.js +6 -1
  26. package/dist-server/server/harness/bus.js +4 -0
  27. package/dist-server/server/harness/registry.js +1 -0
  28. package/dist-server/server/index.js +807 -213
  29. package/dist-server/server/local-vm-lease.js +4 -0
  30. package/dist-server/server/mcp-http.js +337 -0
  31. package/dist-server/server/mcp-probe.js +67 -18
  32. package/dist-server/server/mcp-registry.js +153 -58
  33. package/dist-server/server/member-turn.js +13 -0
  34. package/dist-server/server/message-db.js +74 -8
  35. package/dist-server/server/recall-disclosure.js +4 -0
  36. package/dist-server/server/recent-work.js +142 -0
  37. package/dist-server/server/request-auth.js +3 -1
  38. package/dist-server/server/routines.js +17 -0
  39. package/dist-server/server/screen-frame-gate.js +4 -94
  40. package/dist-server/server/sessions.js +22 -3
  41. package/dist-server/server/store.js +16 -1
  42. package/dist-server/server/surface.js +41 -19
  43. package/dist-server/server/team-setup-requests.js +1 -1
  44. package/dist-server/server/thread-log-rotation.js +104 -0
  45. package/dist-server/server/tts/fish.js +155 -0
  46. package/dist-server/server/tts/index.js +25 -3
  47. package/dist-server/server/vps-computer.js +26 -4
  48. package/dist-server/server/vps-ssh.js +88 -0
  49. package/dist-server/server/workspace-backup.js +11 -0
  50. package/dist-server/server/workspace.js +2 -1
  51. package/dist-server/shared/credential-request.js +10 -0
  52. package/dist-server/shared/tool-surface.js +62 -0
  53. package/dist-server/vps-container-mcp.js +41 -10
  54. package/package.json +1 -1
  55. package/dist/assets/index-Cu8BkIfo.js +0 -305
  56. package/dist/assets/index-tfpeJAIG.css +0 -1
@@ -90,6 +90,15 @@ export function parseArgs(argv, env = process.env) {
90
90
  options.tunnel = true;
91
91
  else if (arg === "--client")
92
92
  options.client = true;
93
+ // Which phone is about to scan, for a run with nobody at the keyboard.
94
+ // `docker compose exec … pair` and any scripted pairing never reach the
95
+ // interactive chooser, and only an Android phone needs a different QR.
96
+ else if (arg === "--phone") {
97
+ const kind = value().toLowerCase();
98
+ if (kind !== "ios" && kind !== "android")
99
+ return { error: "--phone takes ios or android" };
100
+ options.phone = kind;
101
+ }
93
102
  else if (arg === "--no-pair")
94
103
  options.pair = false;
95
104
  else if (arg === "--no-open")
@@ -194,7 +203,8 @@ export const USAGE = `openmausbot — your team of AI bots, ready in a few steps
194
203
  openmausbot start [the same options as serve]
195
204
  openmausbot serve [--port 8799] [--data-dir DIR] [--label NAME]
196
205
  [--public-url https://host] [--tailscale | --tunnel | --domain HOST] [--no-pair]
197
- openmausbot pair [--label NAME] [--client] [--public-url https://host]
206
+ openmausbot pair [--label NAME] [--client] [--phone ios|android]
207
+ [--public-url https://host]
198
208
  openmausbot sessions [revoke ID]
199
209
  openmausbot status
200
210
  openmausbot login [--email you@example.com]
@@ -381,25 +391,73 @@ async function showPhonePairing(options, origin, log) {
381
391
  }
382
392
  for (const line of phonePairingInstructions(options.phone ?? "ios", { origin: origin, ready }))
383
393
  log(line);
384
- log(await mintPairing(options.port, { client: true, label: options.label ?? (options.phone === "android" ? "Android" : "iPhone / iPad"), publicUrl: origin }));
394
+ log(await mintPairing(options.port, { client: true, label: options.label ?? (options.phone === "android" ? "Android" : "iPhone / iPad"), publicUrl: origin, phone: options.phone }));
385
395
  log("Waiting for you to connect on the phone. Keep this terminal and the code private.");
386
396
  return true;
387
397
  }
388
- /** The pairing link a device opens, rendered as text and a QR code. */
398
+ /** The pairing link a device opens, rendered as text and a QR code.
399
+ *
400
+ * One window has two links. `url` opens the web app and is what a browser and
401
+ * the iOS app read. `inviteUrl` is the openmausbot:// scheme the native
402
+ * companion scanners accept, and it is the ONLY thing an Android app can
403
+ * scan — its parser rejects any https QR outright. Which one becomes the QR
404
+ * therefore depends on which app is about to scan it; the other is still
405
+ * printed as text so neither route is hidden. */
389
406
  export function pairingBlock(input) {
390
407
  const lines = [`pairing code: ${input.code}`, `expires: ${new Date(input.expiresAt).toLocaleTimeString()} (single use)`];
391
- if (input.url) {
392
- lines.push(`open or scan: ${input.url}`);
393
- lines.push("");
394
- lines.push(qrToString(input.url));
395
- }
396
- else {
408
+ if (!input.url && !input.inviteUrl) {
397
409
  lines.push(`open: /pair on the address you use for this server, and type the code`);
398
410
  if (input.hint)
399
411
  lines.push(` (${input.hint})`);
412
+ return lines.join("\n");
413
+ }
414
+ // One QR, and it belongs to whichever app is about to scan it. Android's
415
+ // scanner rejects an https payload outright, so an Android phone gets the
416
+ // app-scheme invite; everyone else gets the web link, which Camera opens
417
+ // and which the iOS app also accepts.
418
+ const scanInvite = input.phone === "android" && !!input.inviteUrl;
419
+ // Print every link this window has, and label them by what the QR below
420
+ // actually encodes: "scan" belongs only to the link it is a picture of. A
421
+ // link that is named but never shown is worse than one that is absent —
422
+ // the iOS app takes a pasted invite, so the text form is the fallback when
423
+ // a QR cannot be scanned off a terminal.
424
+ if (input.url)
425
+ lines.push(scanInvite ? `web browser: ${input.url}` : `open or scan: ${input.url}`);
426
+ if (input.inviteUrl)
427
+ lines.push(`phone app: ${input.inviteUrl}`);
428
+ const target = scanInvite ? input.inviteUrl : input.url;
429
+ if (target) {
430
+ lines.push("");
431
+ lines.push(qrToString(target));
432
+ lines.push("");
433
+ if (scanInvite) {
434
+ lines.push(`Scan that in the OpenMausBot app. For a browser instead, open the web`);
435
+ lines.push(`address above and type the code.`);
436
+ }
437
+ else if (input.phone === "android") {
438
+ // Android asked for an app invite this server cannot build. Say so,
439
+ // rather than leave a QR its scanner will reject under instructions
440
+ // telling someone to scan it.
441
+ lines.push(`That QR opens the web app. The Android app needs the phone-app link,`);
442
+ lines.push(`which this server cannot build without a public address: set`);
443
+ lines.push(`OMB_PUBLIC_URL, or open the web address above and type the code.`);
444
+ }
445
+ else if (input.inviteUrl) {
446
+ lines.push(`Scan that with Camera for the browser, or paste the phone-app link`);
447
+ lines.push(`above into the OpenMausBot app.`);
448
+ }
400
449
  }
401
450
  return lines.join("\n");
402
451
  }
452
+ /** The scheme and host of a link, or null if it is not one we can dial. */
453
+ function originOf(link) {
454
+ try {
455
+ return new URL(link).origin;
456
+ }
457
+ catch {
458
+ return null;
459
+ }
460
+ }
403
461
  export function qrToString(text) {
404
462
  let out = "";
405
463
  qrcode.generate(text, { small: true }, (rendered) => {
@@ -417,7 +475,21 @@ async function mintPairing(port, options) {
417
475
  if (status !== 200)
418
476
  throw new Error(`server refused to mint a pairing code: ${typeof body?.error === "string" ? body.error : status}`);
419
477
  const url = options.publicUrl ? `${options.publicUrl}/pair#code=${body.code}` : typeof body.url === "string" ? body.url : null;
420
- return pairingBlock({ code: body.code, url, expiresAt: body.expiresAt, hint: typeof body.hint === "string" ? body.hint : null });
478
+ // A server too old to mint a credential simply has no invite: the web link
479
+ // still works, so an upgrade is never required to pair a browser.
480
+ // The address the phone will dial. `--public-url` wins, exactly as it does
481
+ // for the web link above: a server behind someone else's proxy often does
482
+ // not know its own public name, which is what that flag is for. Gate on the
483
+ // credential, never on the server's own invite — a server started without
484
+ // OMB_PUBLIC_URL returns a credential and no invite, and gating on the
485
+ // invite would throw away a secret the CLI has every part it needs to use.
486
+ const address = options.publicUrl ?? (typeof body.url === "string" ? originOf(body.url) : null);
487
+ // A server too old to mint a credential simply has no invite: the web link
488
+ // still works, so an upgrade is never required to pair a browser.
489
+ const invite = typeof body.credential === "string" && address
490
+ ? `openmausbot://pair?address=${encodeURIComponent(address)}&token=${encodeURIComponent(body.credential)}${typeof body.serverName === "string" ? `&name=${encodeURIComponent(body.serverName)}` : ""}`
491
+ : typeof body.inviteUrl === "string" ? body.inviteUrl : null;
492
+ return pairingBlock({ code: body.code, url, inviteUrl: invite, expiresAt: body.expiresAt, hint: typeof body.hint === "string" ? body.hint : null, phone: options.phone });
421
493
  }
422
494
  // ── commands ───────────────────────────────────────────────────────────
423
495
  export async function runPair(options) {
@@ -450,7 +522,7 @@ export async function runPair(options) {
450
522
  }
451
523
  const ui = defaultSetupIo();
452
524
  try {
453
- const selected = await ui.choose("Which phone are you connecting?", ["iPhone / iPad — app or Safari", "Android — web browser", "Cancel"], 0);
525
+ const selected = await ui.choose("Which phone are you connecting?", ["iPhone / iPad — app or Safari", "Android — app or browser", "Cancel"], 0);
454
526
  if (selected === 2)
455
527
  return 0;
456
528
  launch = { ...launch, phone: selected === 0 ? "ios" : "android" };
@@ -463,7 +535,7 @@ export async function runPair(options) {
463
535
  return 130;
464
536
  }
465
537
  }
466
- console.log(await mintPairing(options.port, { label: options.label, client: options.client, publicUrl: options.publicUrl }));
538
+ console.log(await mintPairing(options.port, { label: options.label, client: options.client, publicUrl: options.publicUrl, phone: options.phone }));
467
539
  if (options.client)
468
540
  console.log("(client scope: chat and approvals only; cannot change settings or pair others)");
469
541
  return 0;
@@ -7,8 +7,8 @@ import { join } from "node:path";
7
7
  import { z } from "zod";
8
8
  import { normalizeImageGenerationUrl } from "../shared/image-generation.js";
9
9
  import { writeFileAtomic } from "./atomic.js";
10
- import { EFFORT_LEVELS } from "./contracts.js";
11
- import { parseStoredMcpServer } from "./mcp-registry.js";
10
+ import { EFFORT_LEVELS, isModelVariant } from "./contracts.js";
11
+ import { isRemoteMcpServer, parseStoredMcpServer } from "./mcp-registry.js";
12
12
  import { parseJson, schemaIssue } from "./schema.js";
13
13
  const optionalText = z.string().optional();
14
14
  const SSH_ALIAS = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
@@ -19,6 +19,11 @@ export const MIN_ROOM_TURN_TIMEOUT_MINUTES = 1;
19
19
  export const MAX_ROOM_TURN_TIMEOUT_MINUTES = 1_440;
20
20
  export const DEFAULT_MAX_CONCURRENT_BOT_THREADS = 3;
21
21
  export const MAX_CONCURRENT_BOT_THREADS = 10;
22
+ /** Bounds for threads.eventLogMaxBytes: the floor keeps the kept tail large
23
+ * enough to still serve the event inspector's recent-line window; the
24
+ * ceiling just rejects absurd hand edits. */
25
+ export const MIN_THREAD_EVENT_LOG_BYTES = 256 * 1024;
26
+ export const MAX_THREAD_EVENT_LOG_BYTES = 4 * 1024 * 1024 * 1024;
22
27
  export const DEFAULT_LOCAL_VM_MODE = "shared";
23
28
  export const DEFAULT_LOCAL_VM_MAX_INSTANCES = 2;
24
29
  export const MIN_LOCAL_VM_MAX_INSTANCES = 1;
@@ -212,6 +217,10 @@ const featureConfigSchema = z.object({
212
217
  * computer control to a workspace). Off until explicitly enabled; there is
213
218
  * no Settings toggle — see sharedComputersEnabled. */
214
219
  sharedComputers: z.boolean().optional(),
220
+ /** Claude bots also load the MCP servers and connectors from this
221
+ * machine's own Claude Code setup (Plugins → MCP servers switch). Off by
222
+ * default: each extra tool costs tokens on every model call. */
223
+ claudeUserMcp: z.boolean().optional(),
215
224
  });
216
225
  /** First-run progress. Kept in the workspace config rather than a browser so
217
226
  * it survives cleared site data and is shared by every paired client. Hint
@@ -238,7 +247,8 @@ const defaultModelSelectionSchema = z.object({
238
247
  instanceId: z.string().trim().min(1),
239
248
  model: z.string().trim().min(1),
240
249
  effort: z.enum(EFFORT_LEVELS).optional(),
241
- });
250
+ variant: z.string().refine(isModelVariant, "invalid model variant").optional(),
251
+ }).refine((selection) => selection.variant === undefined || selection.effort === undefined, "choose either a model variant or an effort level");
242
252
  const appConfigSchema = z.object({
243
253
  /** Verified by the dedicated domain endpoint, never a generic config patch. */
244
254
  customDomain: z.string().optional(),
@@ -292,13 +302,16 @@ const appConfigSchema = z.object({
292
302
  /** Optional OpenCode key; persisted write-only and passed only to its child. */
293
303
  opencodeGo: z.object({ apiKey: optionalText }).optional(),
294
304
  /** Voice settings and the selected voice id. `provider` picks the
295
- * engine: "elevenlabs" (default; needs a key), "system" (the Mac's
296
- * built-in voices, no key), or "chatterbox" (a local OpenAI-compatible
297
- * Chatterbox server; `baseUrl` and `model` are settings, not secrets). */
305
+ * engine: "elevenlabs" (default; needs `key`), "fish" (needs its own
306
+ * `fishKey`), "system" (the Mac's built-in voices, no key), or
307
+ * "chatterbox" (a local OpenAI-compatible Chatterbox server; `baseUrl`
308
+ * and `model` are settings, not secrets). Cloud keys stay separate so
309
+ * switching providers never overwrites or misuses the other key. */
298
310
  tts: z.object({
299
311
  key: optionalText,
312
+ fishKey: optionalText,
300
313
  voice: optionalText,
301
- provider: z.enum(["elevenlabs", "system", "chatterbox"]).optional(),
314
+ provider: z.enum(["elevenlabs", "fish", "system", "chatterbox"]).optional(),
302
315
  baseUrl: z
303
316
  .string()
304
317
  .trim()
@@ -331,7 +344,12 @@ const appConfigSchema = z.object({
331
344
  * system language. Unknown tags degrade to English in the renderer. */
332
345
  language: optionalText,
333
346
  rooms: roomConfigSchema.optional(),
334
- threads: z.object({ maxConcurrentPerBot: z.number().int().min(1).max(MAX_CONCURRENT_BOT_THREADS) }).strict().optional(),
347
+ threads: z.object({
348
+ maxConcurrentPerBot: z.number().int().min(1).max(MAX_CONCURRENT_BOT_THREADS),
349
+ /** Cap each per-thread events/ and native/ NDJSON log at this many
350
+ * bytes; absent (the default) keeps today's unbounded growth (#1280). */
351
+ eventLogMaxBytes: z.number().int().min(MIN_THREAD_EVENT_LOG_BYTES).max(MAX_THREAD_EVENT_LOG_BYTES).optional(),
352
+ }).strict().optional(),
335
353
  localVm: localVmConfigSchema.optional(),
336
354
  features: featureConfigSchema.optional(),
337
355
  onboarding: onboardingConfigSchema.optional(),
@@ -432,6 +450,12 @@ export function roomTurnTimeoutMinutes(cfg) {
432
450
  export function maxConcurrentBotThreads(cfg) {
433
451
  return cfg.threads?.maxConcurrentPerBot ?? DEFAULT_MAX_CONCURRENT_BOT_THREADS;
434
452
  }
453
+ /** Size cap for each per-thread events/ and native/ NDJSON log. Null (the
454
+ * default) means unbounded growth — rotation is strictly opt-in (#1280). */
455
+ export function threadEventLogMaxBytes(cfg) {
456
+ const cap = cfg.threads?.eventLogMaxBytes;
457
+ return typeof cap === "number" && Number.isFinite(cap) && cap > 0 ? cap : null;
458
+ }
435
459
  export function localVmMode(cfg) {
436
460
  return cfg.localVm?.mode ?? DEFAULT_LOCAL_VM_MODE;
437
461
  }
@@ -463,6 +487,14 @@ export function builtInBrowserEnabled(cfg) {
463
487
  export function sharedComputersEnabled(cfg) {
464
488
  return cfg.features?.sharedComputers === true;
465
489
  }
490
+ /** Claude bots also see the MCP servers of this machine's own Claude Code
491
+ * setup — the way Codex bots already read ~/.codex/config.toml. Off unless
492
+ * the person switched it on under Plugins → MCP servers; the Claude driver
493
+ * then omits --strict-mcp-config while keeping skills, hooks and the
494
+ * personal CLAUDE.md out. */
495
+ export function claudeUserMcpEnabled(cfg) {
496
+ return cfg.features?.claudeUserMcp === true;
497
+ }
466
498
  /** Config sections no provider driver reads. A write that touches only
467
499
  * these must not rebuild the fleet: rebuilding disposes every engine child
468
500
  * and reloads it, seconds of work that would also interrupt in-flight
@@ -583,6 +615,8 @@ export function loadConfig() {
583
615
  cfg.tts = { ...cfg.tts };
584
616
  if (process.env.OMB_TTS_KEY !== undefined)
585
617
  cfg.tts.key = process.env.OMB_TTS_KEY;
618
+ if (process.env.OMB_FISH_AUDIO_API_KEY !== undefined)
619
+ cfg.tts.fishKey = process.env.OMB_FISH_AUDIO_API_KEY;
586
620
  cfg.imageGen = { ...cfg.imageGen };
587
621
  if (process.env.OMB_OPENAI_IMAGE_KEY !== undefined)
588
622
  cfg.imageGen.key = process.env.OMB_OPENAI_IMAGE_KEY;
@@ -616,6 +650,7 @@ export function syncCredentialEnv(patch) {
616
650
  [patch.box?.token, "BOX_TOKEN"],
617
651
  [patch.opencodeGo?.apiKey, "OPENCODE_API_KEY"],
618
652
  [patch.tts?.key, "OMB_TTS_KEY"],
653
+ [patch.tts?.fishKey, "OMB_FISH_AUDIO_API_KEY"],
619
654
  [patch.imageGen?.key, "OMB_OPENAI_IMAGE_KEY"],
620
655
  [patch.imageGen?.customApiKey, "OMB_CUSTOM_IMAGE_KEY"],
621
656
  ];
@@ -658,6 +693,7 @@ export const WORKSPACE_CREDENTIAL_ENV = [
658
693
  "BOX_TOKEN",
659
694
  "OPENCODE_API_KEY",
660
695
  "OMB_TTS_KEY",
696
+ "OMB_FISH_AUDIO_API_KEY",
661
697
  "OMB_OPENAI_IMAGE_KEY",
662
698
  "OMB_CUSTOM_IMAGE_KEY",
663
699
  "COMPOSIO_API_KEY",
@@ -967,22 +1003,16 @@ export function customMcpServers(cfg, only) {
967
1003
  // keeps getting every enabled server, as before this field existed
968
1004
  if (only && !only.includes(name))
969
1005
  continue;
970
- if (raw && typeof raw === "object" && "url" in raw) {
971
- skipMcpEntry(name, 'only stdio servers ("command") are supported so far — HTTP transports are a planned follow-up');
972
- continue;
973
- }
974
1006
  const parsed = parseStoredMcpServer(name, raw);
975
1007
  if (!parsed.ok) {
976
- skipMcpEntry(name, `${parsed.error} Expected { "command": "npx", "args": [...], "env": { ... } }`);
1008
+ skipMcpEntry(name, `${parsed.error} Expected { "command": "npx", "args": [...], "env": { ... } } or { "type": "http", "url": "https://…", "headers": { ... } }`);
977
1009
  continue;
978
1010
  }
979
1011
  if (!parsed.server.enabled)
980
1012
  continue;
981
- out[name] = {
982
- command: parsed.server.command,
983
- args: parsed.server.args,
984
- env: parsed.server.env,
985
- };
1013
+ out[name] = isRemoteMcpServer(parsed.server)
1014
+ ? { type: parsed.server.type, url: parsed.server.url, headers: parsed.server.headers }
1015
+ : { command: parsed.server.command, args: parsed.server.args, env: parsed.server.env };
986
1016
  }
987
1017
  return out;
988
1018
  }
@@ -282,6 +282,13 @@ export function localVmRecreatableOnDemand(status) {
282
282
  && status.container === "missing"
283
283
  && status.create_supported;
284
284
  }
285
+ /** Whether Auto may attach this Local VM without a person choosing it: the
286
+ * desktop is ready, or its image is prepared and the container can simply be
287
+ * recreated after idling away. Anything else — no runtime, daemon down, image
288
+ * never prepared, an unmanaged or unsafe container — stays the person's call. */
289
+ export function autoLocalVmAttachable(status) {
290
+ return status.ready === true || localVmRecreatableOnDemand(status);
291
+ }
285
292
  function statusProblem(status) {
286
293
  if (!status.runtime)
287
294
  return "Install a supported container runtime first";
@@ -19,6 +19,11 @@ export const EFFORT_LEVELS = ["none", "low", "medium", "high", "xhigh", "max"];
19
19
  export function isEffortLevel(value) {
20
20
  return typeof value === "string" && EFFORT_LEVELS.includes(value);
21
21
  }
22
+ /** Variants are opaque provider IDs, not the cross-engine effort enum. */
23
+ export function isModelVariant(value) {
24
+ return typeof value === "string" && value.length > 0 && value.length <= 256 &&
25
+ value.trim() === value && !/\p{Cc}/u.test(value);
26
+ }
22
27
  let eventCounter = 0;
23
28
  export const newEventId = () => `ev-${Date.now().toString(36)}-${(eventCounter++).toString(36)}`;
24
29
  export const newId = () => crypto.randomUUID();
@@ -5,6 +5,7 @@ export function selectDefaultModelSelection(instances, preferred) {
5
5
  const instance = instances.find((candidate) => candidate.instanceId === preferred.instanceId);
6
6
  if (instance?.snapshot.state !== "available" ||
7
7
  instance.snapshot.authenticated === false ||
8
+ (preferred.variant !== undefined && !instance.capabilities?.modelVariants) ||
8
9
  !(instance.models.default === preferred.model || instance.models.options.some((model) => model.id === preferred.model))) {
9
10
  return { instanceId: "", model: "" };
10
11
  }
@@ -12,7 +12,8 @@
12
12
  // unless the agent explicitly offered an `allow`-kind option — option ORDER
13
13
  // is never a security contract). session/load REPLAYS history as ordinary
14
14
  // session/update notifications, so updates are double-gated: nothing emits
15
- // before the prompt is sent, and `_meta.isReplay` updates are dropped.
15
+ // before the prompt is sent, and `_meta.isReplay` updates are dropped. Session
16
+ // configuration is the exception: its live updates apply before prompting too.
16
17
  import { homedir } from "node:os";
17
18
  import { lstat, mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
18
19
  import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
@@ -37,6 +38,32 @@ const SESSION_CONFIG_TIMEOUT = envOr("OPENMAUS_ACP_SESSION_CONFIG_TIMEOUT_MS", 3
37
38
  const NEW_SESSION_TIMEOUT = envOr("OPENMAUS_ACP_NEW_SESSION_TIMEOUT_MS", 300_000);
38
39
  const LOAD_SESSION_TIMEOUT = envOr("OPENMAUS_ACP_LOAD_SESSION_TIMEOUT_MS", 120_000); // history replay on a long thread is slow
39
40
  const CLIENT_FILE_MAX_BYTES = 8 * 1024 * 1024;
41
+ function acpVariantOption(result) {
42
+ const option = (Array.isArray(result?.configOptions) ? result.configOptions : []).find((entry) => entry?.type === "select" && typeof entry.id === "string"
43
+ && (entry.id === "effort" || entry.category === "thought_level"));
44
+ if (!option)
45
+ return;
46
+ const options = [];
47
+ const seen = new Set();
48
+ const collect = (entries) => {
49
+ if (!Array.isArray(entries))
50
+ return;
51
+ for (const entry of entries) {
52
+ if (typeof entry?.value === "string" && !seen.has(entry.value)) {
53
+ seen.add(entry.value);
54
+ options.push({ id: entry.value, label: typeof entry.name === "string" ? entry.name : entry.value });
55
+ }
56
+ else if (Array.isArray(entry?.options))
57
+ collect(entry.options);
58
+ }
59
+ };
60
+ collect(option.options);
61
+ return {
62
+ configId: option.id,
63
+ options,
64
+ ...(typeof option.currentValue === "string" ? { currentValue: option.currentValue } : {}),
65
+ };
66
+ }
40
67
  const TOOL_LOG_TEXT_LIMIT = 64_000;
41
68
  async function readAcpImageBlocks(images) {
42
69
  return Promise.all(images.map(async (image) => ({
@@ -224,10 +251,6 @@ export function createAcpDriver(support) {
224
251
  turnId,
225
252
  createdAt: new Date().toISOString(),
226
253
  });
227
- // ACP session mcpServers: stdio is the baseline every ACP agent
228
- // supports (mcpCapabilities.http/.sse only add EXTRA transports), so
229
- // an injected stdio proxy — e.g. the peer-agent comms tool — attaches
230
- // fine here. env is the ACP {name,value}[] shape.
231
254
  const acpMcpServers = (turn) => {
232
255
  const servers = [];
233
256
  const acpEnv = (env) => Object.entries(env).map(([name, value]) => ({ name, value: String(value) }));
@@ -266,6 +289,10 @@ export function createAcpDriver(support) {
266
289
  for (const [name, server] of Object.entries(turn.integrations?.custom ?? {})) {
267
290
  if (servers.some((existing) => existing.name === name))
268
291
  continue;
292
+ if ("url" in server) {
293
+ servers.push({ type: server.type, name, url: server.url, headers: acpEnv(server.headers) });
294
+ continue;
295
+ }
269
296
  servers.push({ name, command: server.command, args: server.args, env: acpEnv(server.env) });
270
297
  }
271
298
  return servers;
@@ -331,6 +358,38 @@ export function createAcpDriver(support) {
331
358
  const asks = new Map();
332
359
  let nextId = 1;
333
360
  let sessionId = null;
361
+ let sessionConfigResult = null;
362
+ const modelOf = (result) => {
363
+ const option = (Array.isArray(result?.configOptions) ? result.configOptions : []).find((entry) => entry?.id === (support.selectModel?.configId ?? "model"));
364
+ return typeof option?.currentValue === "string" ? option.currentValue : null;
365
+ };
366
+ const receiveModelVariants = (result) => {
367
+ sessionConfigResult = result;
368
+ if (!support.modelVariants)
369
+ return;
370
+ const nativeModel = modelOf(result) ?? cliTurn.model;
371
+ if (!nativeModel)
372
+ return;
373
+ const option = acpVariantOption(result);
374
+ emit({
375
+ ...base(threadId, turnId),
376
+ type: "session.model-variants",
377
+ model: nativeModel === cliTurn.model ? (turn.model ?? nativeModel) : nativeModel,
378
+ variants: {
379
+ options: option?.options ?? [],
380
+ ...(option?.currentValue !== undefined ? { currentValue: option.currentValue } : {}),
381
+ },
382
+ });
383
+ };
384
+ const requestedVariantOption = () => {
385
+ if (!support.modelVariants)
386
+ throw new Error(`${support.displayName} does not support model variants`);
387
+ const option = acpVariantOption(sessionConfigResult);
388
+ if (!option || !option.options.some((entry) => entry.id === turn.variant)) {
389
+ throw new Error(`${support.displayName} does not advertise variant ${turn.variant} for this model`);
390
+ }
391
+ return option;
392
+ };
334
393
  let interruptTimer = null;
335
394
  const rpcPending = new Map();
336
395
  const send = (obj) => {
@@ -340,7 +399,7 @@ export function createAcpDriver(support) {
340
399
  catch { }
341
400
  appendNative(threadId, { dir: "out", source: SOURCE, msg: nativeLogMessage(obj) });
342
401
  };
343
- const request = (method, params, timeoutMs) => new Promise((resolve, reject) => {
402
+ const request = (method, params, timeoutMs, receive) => new Promise((resolve, reject) => {
344
403
  const id = nextId++;
345
404
  let timer = null;
346
405
  if (timeoutMs) {
@@ -350,7 +409,13 @@ export function createAcpDriver(support) {
350
409
  }, timeoutMs);
351
410
  timer.unref?.();
352
411
  }
353
- rpcPending.set(id, { resolve, reject, timer });
412
+ rpcPending.set(id, {
413
+ // Consume configuration in wire order: an update following this
414
+ // response may arrive before the awaiting continuation resumes.
415
+ resolve: (result) => { receive?.(result); resolve(result); },
416
+ reject,
417
+ timer,
418
+ });
354
419
  send({ jsonrpc: "2.0", id, method, params });
355
420
  });
356
421
  const stop = () => killCliTree(child);
@@ -566,7 +631,14 @@ export function createAcpDriver(support) {
566
631
  if (msg.method !== "session/update")
567
632
  return;
568
633
  const p = msg.params ?? {};
569
- if (!state.promptSent || p._meta?.isReplay === true)
634
+ if (p._meta?.isReplay === true)
635
+ return;
636
+ if (support.modelVariants && p.update?.sessionUpdate === "config_option_update") {
637
+ if (!state.settled && sessionId && p.sessionId === sessionId)
638
+ receiveModelVariants(p.update);
639
+ return;
640
+ }
641
+ if (!state.promptSent)
570
642
  return;
571
643
  const u = p.update ?? {};
572
644
  switch (u.sessionUpdate) {
@@ -737,6 +809,10 @@ export function createAcpDriver(support) {
737
809
  if (images.length && support.images === true && !runtimeAcceptsImages) {
738
810
  throw new Error(`${support.displayName} is configured for image attachments, but this installed runtime does not advertise ACP image input. Update the ${support.displayName} CLI or send the message without an image.`);
739
811
  }
812
+ // stdio is every agent's baseline; a url server rides only with
813
+ // an agent that advertised its transport, so an agent without
814
+ // http/sse never sees an entry it would refuse the session over
815
+ const sessionServers = mcpServers.filter((server) => !("type" in server) || init?.agentCapabilities?.mcpCapabilities?.[server.type] === true);
740
816
  const cursor = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
741
817
  // a fresh native session forgets what the previous one allowed
742
818
  if (!cursor)
@@ -744,17 +820,22 @@ export function createAcpDriver(support) {
744
820
  let sessionResult = null;
745
821
  if (cursor) {
746
822
  try {
747
- sessionResult = await request(support.resumeMethod === "resume" ? "session/resume" : "session/load", { sessionId: cursor, cwd, mcpServers }, LOAD_SESSION_TIMEOUT);
748
- if (sessionResult)
749
- sessionId = cursor;
823
+ sessionResult = await request(support.resumeMethod === "resume" ? "session/resume" : "session/load", { sessionId: cursor, cwd, mcpServers: sessionServers }, LOAD_SESSION_TIMEOUT, (result) => {
824
+ if (result) {
825
+ sessionId = cursor;
826
+ receiveModelVariants(result);
827
+ }
828
+ });
750
829
  }
751
830
  catch {
752
831
  /* session gone, load unsupported, or too slow — start fresh */
753
832
  }
754
833
  }
755
834
  if (!sessionId) {
756
- sessionResult = await request("session/new", { cwd, mcpServers }, NEW_SESSION_TIMEOUT);
757
- sessionId = typeof sessionResult?.sessionId === "string" ? sessionResult.sessionId : null;
835
+ sessionResult = await request("session/new", { cwd, mcpServers: sessionServers }, NEW_SESSION_TIMEOUT, (result) => {
836
+ sessionId = typeof result?.sessionId === "string" ? result.sessionId : null;
837
+ receiveModelVariants(result);
838
+ });
758
839
  if (!sessionId)
759
840
  throw new Error("session/new returned no sessionId");
760
841
  }
@@ -774,11 +855,10 @@ export function createAcpDriver(support) {
774
855
  try {
775
856
  if (support.selectModel) {
776
857
  const { configId } = support.selectModel;
777
- const currentOf = (r) => (Array.isArray(r?.configOptions) ? r.configOptions : []).find((o) => o?.id === configId)
778
- ?.currentValue ?? null;
779
- selectedModel = currentOf(sessionResult);
858
+ selectedModel = modelOf(sessionConfigResult);
780
859
  if (cliTurn.model && cliTurn.model !== selectedModel) {
781
- selectedModel = currentOf(await request("session/set_config_option", { sessionId, configId, value: cliTurn.model }, INIT_TIMEOUT));
860
+ sessionResult = await request("session/set_config_option", { sessionId, configId, value: cliTurn.model }, INIT_TIMEOUT, receiveModelVariants);
861
+ selectedModel = modelOf(sessionConfigResult);
782
862
  // an agent that answers OK but keeps its old model is worse than
783
863
  // one that errors: it burns a paid turn on the wrong thing
784
864
  if (selectedModel !== cliTurn.model) {
@@ -802,6 +882,13 @@ export function createAcpDriver(support) {
802
882
  if (!selectedModel && cliTurn.model)
803
883
  selectedModel = cliTurn.model;
804
884
  }
885
+ if (turn.variant !== undefined) {
886
+ const option = requestedVariantOption();
887
+ await request("session/set_config_option", { sessionId, configId: option.configId, value: turn.variant }, SESSION_CONFIG_TIMEOUT, receiveModelVariants);
888
+ if (requestedVariantOption().currentValue !== turn.variant) {
889
+ throw new Error(`${support.displayName} did not apply variant ${turn.variant}`);
890
+ }
891
+ }
805
892
  }
806
893
  catch (error) {
807
894
  // session.started is the only place the resume cursor is recorded,
@@ -810,7 +897,6 @@ export function createAcpDriver(support) {
810
897
  throw error;
811
898
  }
812
899
  emitSessionStarted();
813
- state.promptSent = true;
814
900
  const text = support.buildPromptText
815
901
  ? support.buildPromptText(turn)
816
902
  : turn.system
@@ -819,6 +905,13 @@ export function createAcpDriver(support) {
819
905
  const imageBlocks = support.images === true && runtimeAcceptsImages
820
906
  ? await readAcpImageBlocks(images)
821
907
  : [];
908
+ if (support.modelVariants && cliTurn.model && modelOf(sessionConfigResult) !== cliTurn.model) {
909
+ throw new Error(`${support.displayName} changed model before the prompt`);
910
+ }
911
+ if (turn.variant !== undefined && requestedVariantOption().currentValue !== turn.variant) {
912
+ throw new Error(`${support.displayName} changed variant before the prompt`);
913
+ }
914
+ state.promptSent = true;
822
915
  const result = await request("session/prompt", {
823
916
  sessionId,
824
917
  prompt: [{ type: "text", text }, ...imageBlocks],
@@ -907,6 +1000,7 @@ export function createAcpDriver(support) {
907
1000
  images: support.images !== false,
908
1001
  nativeImageInput: support.images === true,
909
1002
  effortLevels: support.effortLevels,
1003
+ modelVariants: support.modelVariants === true,
910
1004
  // OpenMausBot supplies a per-bot approvalMode on every harness
911
1005
  // turn, which safely overrides a legacy instance fullAuto value.
912
1006
  // Direct adapter calls that omit it still fail closed in sendTurn.
@@ -97,12 +97,17 @@ export function parseOpenCodeModelsOutput(stdout) {
97
97
  const contextWindow = typeof limit.context === "number" && Number.isFinite(limit.context) && limit.context > 0
98
98
  ? Math.floor(limit.context)
99
99
  : undefined;
100
+ const variants = record.variants && typeof record.variants === "object" && !Array.isArray(record.variants)
101
+ ? Object.entries(record.variants).filter(([, settings]) => (settings && typeof settings === "object" && !Array.isArray(settings)
102
+ && settings.disabled !== true)).map(([id]) => ({ id, label: labelForModel(id) }))
103
+ : undefined;
100
104
  seen.add(slug);
101
105
  options.push({
102
106
  id: slug,
103
107
  label: `${providerLabel(provider)} · ${name}`,
104
108
  ...(localModelRecord(record) ? { custom: true, loaded: true } : {}),
105
109
  ...(contextWindow ? { contextWindow } : {}),
110
+ ...(variants ? { variants } : {}),
106
111
  });
107
112
  };
108
113
  for (const line of stdout.split(/\r?\n/u)) {
@@ -330,6 +335,7 @@ const support = (loadCatalog) => ({
330
335
  spawnArgs: () => ["acp"],
331
336
  credentialEnv: ["OPENCODE_API_KEY"],
332
337
  selectModel: { configId: "model" },
338
+ modelVariants: true,
333
339
  resolveTurnModel: (model, env) => model
334
340
  ? ensureOpenCodeInjectModel(normalizeLegacyOpenCodeModel(model, env), env)
335
341
  : model,