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.
- package/README.md +6 -5
- package/dist/assets/index-CKysBq-V.css +1 -0
- package/dist/assets/{index-D6RZKWks.js → index-oLMljotU.js} +1 -1
- package/dist/assets/index-peLFiSH8.js +307 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +41 -10
- package/dist-server/drivers/agents-proxy.js +50 -11
- package/dist-server/index.js +3433 -1278
- package/dist-server/openmausbot.js +389 -83
- package/dist-server/pair-cli.js +389 -83
- package/dist-server/server/box-delete-journal.js +471 -0
- package/dist-server/server/box.js +345 -75
- package/dist-server/server/cli-phone-setup.js +2 -2
- package/dist-server/server/cli.js +84 -12
- package/dist-server/server/config.js +48 -18
- package/dist-server/server/container-computer.js +7 -0
- package/dist-server/server/contracts.js +5 -0
- package/dist-server/server/default-model-selection.js +1 -0
- package/dist-server/server/drivers/acp/core.js +112 -18
- package/dist-server/server/drivers/acp/opencode-go.js +6 -0
- package/dist-server/server/drivers/agents-proxy.js +49 -11
- package/dist-server/server/drivers/chat-mcp-tools.js +6 -1
- package/dist-server/server/drivers/claude.js +33 -2
- package/dist-server/server/drivers/codex.js +93 -8
- package/dist-server/server/drivers/native.js +6 -1
- package/dist-server/server/harness/bus.js +4 -0
- package/dist-server/server/harness/registry.js +1 -0
- package/dist-server/server/index.js +807 -213
- package/dist-server/server/local-vm-lease.js +4 -0
- package/dist-server/server/mcp-http.js +337 -0
- package/dist-server/server/mcp-probe.js +67 -18
- package/dist-server/server/mcp-registry.js +153 -58
- package/dist-server/server/member-turn.js +13 -0
- package/dist-server/server/message-db.js +74 -8
- package/dist-server/server/recall-disclosure.js +4 -0
- package/dist-server/server/recent-work.js +142 -0
- package/dist-server/server/request-auth.js +3 -1
- package/dist-server/server/routines.js +17 -0
- package/dist-server/server/screen-frame-gate.js +4 -94
- package/dist-server/server/sessions.js +22 -3
- package/dist-server/server/store.js +16 -1
- package/dist-server/server/surface.js +41 -19
- package/dist-server/server/team-setup-requests.js +1 -1
- package/dist-server/server/thread-log-rotation.js +104 -0
- package/dist-server/server/tts/fish.js +155 -0
- package/dist-server/server/tts/index.js +25 -3
- package/dist-server/server/vps-computer.js +26 -4
- package/dist-server/server/vps-ssh.js +88 -0
- package/dist-server/server/workspace-backup.js +11 -0
- package/dist-server/server/workspace.js +2 -1
- package/dist-server/shared/credential-request.js +10 -0
- package/dist-server/shared/tool-surface.js +62 -0
- package/dist-server/vps-container-mcp.js +41 -10
- package/package.json +1 -1
- package/dist/assets/index-Cu8BkIfo.js +0 -305
- 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] [--
|
|
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
|
-
|
|
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 —
|
|
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
|
|
296
|
-
* built-in voices, no key), or
|
|
297
|
-
* Chatterbox server; `baseUrl`
|
|
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({
|
|
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
|
-
|
|
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, {
|
|
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 (
|
|
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
|
-
|
|
749
|
-
|
|
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
|
-
|
|
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
|
-
|
|
778
|
-
?.currentValue ?? null;
|
|
779
|
-
selectedModel = currentOf(sessionResult);
|
|
858
|
+
selectedModel = modelOf(sessionConfigResult);
|
|
780
859
|
if (cliTurn.model && cliTurn.model !== selectedModel) {
|
|
781
|
-
|
|
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,
|