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
@@ -3,6 +3,7 @@
3
3
  // folds one SSE event stream; every provider process runs here.
4
4
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
5
5
  import { existsSync, readFileSync, rmSync, unlinkSync } from "node:fs";
6
+ import { rm as removeDirectory } from "node:fs/promises";
6
7
  import { createServer } from "node:http";
7
8
  import { extname, join } from "node:path";
8
9
  import { z } from "zod";
@@ -31,13 +32,14 @@ import { RoomTurnDeadline, RoomTurnStallRegistry, roomTurnTimeoutMessage } from
31
32
  import * as box from "./box.js";
32
33
  import { TeamComputers, teamComputerAssignment, teamComputerCreate, teamComputerOwner } from "./team-computers.js";
33
34
  import { boxCreateRecoverySnapshot, retireDeletedBoxCreate } from "./box-create-idempotency.js";
35
+ import { boxDeletionSnapshot } from "./box-delete-journal.js";
34
36
  import { boxAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourceChangeError, } from "./cloud-backend.js";
35
37
  import * as composio from "./composio.js";
36
38
  import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
37
39
  import { canAccessTeam, canReachPeer, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, roomPeerRosterSystemPrompt, roomRosterLine } from "./peer-roster.js";
38
40
  import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
39
41
  import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerRuntimeStatus, localVmRecreatableOnDemand, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, } from "./container-computer.js";
40
- import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, maxConcurrentBotThreads, saveConfig, showToolCallsEnabled, skillAuthoringEnabled, sharedComputersEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
42
+ import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, maxConcurrentBotThreads, threadEventLogMaxBytes, saveConfig, showToolCallsEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, sharedComputersEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
41
43
  import { ComputerControl } from "./computer-control.js";
42
44
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
43
45
  import { registerEnginesBinDir } from "./engine-install.js";
@@ -49,7 +51,7 @@ import { entitled } from "./enterprise.js";
49
51
  import { HOSTED_CONTRACT_HEADER, HOSTED_CONTRACT_METADATA, HOSTED_CONTRACT_VERSION } from "./hosted-contract.js";
50
52
  import { describeSpawnFailure, execCli } from "./procs.js";
51
53
  import { blockedTarget, buildNotification } from "./notify.js";
52
- import { isEffortLevel, newId, } from "./contracts.js";
54
+ import { isEffortLevel, isModelVariant, newId, } from "./contracts.js";
53
55
  import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.js";
54
56
  import { decodeGeneratedImage } from "./generated-image.js";
55
57
  import { MAX_MCP_SERVERS, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
@@ -57,8 +59,9 @@ import { probeMcpServer } from "./mcp-probe.js";
57
59
  import { GROUP_GOAL_MAX_TURNS, groupGoalAssignmentKey, groupGoalCompletionTurnId, groupGoalCoordinatorInstructions, groupGoalWorkerInstructions, parseGroupGoalDecision, resolveGroupGoalMember, selectGroupGoalCoordinator, } from "./group-goal-run.js";
58
60
  import { BUILT_IN_DRIVERS } from "./drivers/builtIn.js";
59
61
  import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from "./comms-visibility.js";
60
- import { readMessageText, recallMessages, searchMessages, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups } from "./message-db.js";
61
- import { claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
62
+ import { readMessageText, recallMessages, recentMessages, searchMessages, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups } from "./message-db.js";
63
+ import { briefCrossingLabel, claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
64
+ import { parseSince, recentWork, recentWorkPrompt, turnOutcomeLine } from "./recent-work.js";
62
65
  /** A session_read answer competes with the transcript for the context
63
66
  * window; a computer-use turn's output can run to hundreds of KB. */
64
67
  const SESSION_READ_MAX_CHARS = 8_000;
@@ -120,8 +123,9 @@ import { BOT_PACKAGE_MAX_SKILLS, isBotPackage, packageAgentAsMember, parseBotPac
120
123
  import { createTeamManifest, importedMemberProfile, parseTeamManifest } from "./team-manifest.js";
121
124
  import { takeImportName } from "../shared/import-name.js";
122
125
  import { readThreadEvents } from "./thread-events.js";
126
+ import { bindThreadLogCapProvider } from "./thread-log-rotation.js";
123
127
  import { listenWebhookIngress, webhookCredential } from "./webhook-ingress.js";
124
- import { memberTurnSelection } from "./member-turn.js";
128
+ import { assertModelVariantSupported, memberTurnSelection } from "./member-turn.js";
125
129
  import { WebhookManager } from "./webhooks.js";
126
130
  import { SPAWNED_PROXIES } from "./proxy-paths.js";
127
131
  import { loadBundledSkills, loadUserSkills, mergeSkills, renderSkillInstructions, selectBundledSkills } from "./skill-library.js";
@@ -130,8 +134,9 @@ import { createBotPackageExport } from "./package-export.js";
130
134
  import { createTeamBackup, importTeamBackup } from "./team-backup.js";
131
135
  import { MAX_TEAM_BACKUP_BYTES } from "../shared/team-backup.js";
132
136
  import { shouldMountLocalComputer } from "./local-routing.js";
137
+ import { autoLocalVmAttachable } from "./container-computer.js";
133
138
  import { modelContextWindow } from "./model-context-window.js";
134
- import { resolveSurface } from "./surface.js";
139
+ import { parseSurface, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
135
140
  import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
136
141
  import { createGracefulShutdown } from "./graceful-shutdown.js";
137
142
  import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
@@ -222,6 +227,10 @@ let companionMutationToken = DESKTOP_MANAGED ? "" : undefined;
222
227
  // Where remote clients reach this server (a proxy's public address); pairing URLs use it.
223
228
  const FALLBACK_PUBLIC_URL = process.env.OMB_PUBLIC_URL?.trim().replace(/\/+$/, "") || null;
224
229
  const cfg = loadConfig();
230
+ // The per-thread event log cap is checked after every NDJSON append.
231
+ // config.json is read once per process (a change restarts the server, like
232
+ // every other hand-edited knob), so a binding made here never goes stale.
233
+ bindThreadLogCapProvider(() => threadEventLogMaxBytes(cfg));
225
234
  const customDomainVerifier = createCustomDomainVerifier({ environmentId: ENVIRONMENT_ID });
226
235
  // "Sign in with your email" on /pair: the allow-list is read per call so a
227
236
  // Settings change or an env bootstrap applies without a restart.
@@ -388,6 +397,7 @@ const INTERNAL_CAPABILITY_ORPHAN_MS = 30 * 24 * 60 * 60_000;
388
397
  const internalCapabilities = new Map();
389
398
  const activeInternalGenerationByThread = new Map();
390
399
  const internalGenerationByProviderTurn = new ProviderTurnGenerationRegistry();
400
+ const computerSelectionTurns = new Map();
391
401
  function beginInternalCapabilityGeneration(threadId, generation = randomUUID()) {
392
402
  const previous = activeInternalGenerationByThread.get(threadId);
393
403
  if (previous)
@@ -418,6 +428,7 @@ function revokeInternalCapabilityGeneration(threadId, generation) {
418
428
  internalGenerationByProviderTurn.deleteGeneration(threadId, generation);
419
429
  }
420
430
  function revokeInternalCapabilitiesForThread(threadId) {
431
+ computerSelectionTurns.delete(threadId);
421
432
  const generation = activeInternalGenerationByThread.get(threadId);
422
433
  if (generation)
423
434
  revokeInternalCapabilityGeneration(threadId, generation);
@@ -430,6 +441,7 @@ function revokeInternalCapabilitiesForThread(threadId) {
430
441
  }
431
442
  }
432
443
  function revokeAllInternalCapabilities() {
444
+ computerSelectionTurns.clear();
433
445
  internalCapabilities.clear();
434
446
  activeInternalGenerationByThread.clear();
435
447
  internalGenerationByProviderTurn.clear();
@@ -464,6 +476,10 @@ function authorizedInternalCapability(header) {
464
476
  return null;
465
477
  }
466
478
  function internalCapabilityIsActive(capability) {
479
+ const switching = computerSelectionTurns.get(capability.threadId);
480
+ if ((capability.kind === "computer" || capability.kind === "browser") &&
481
+ switching?.generation === capability.generation && switching.selected)
482
+ return false;
467
483
  if (capability.teamComputerId) {
468
484
  const pinned = teamComputerTurns.get(capability.threadId);
469
485
  if (pinned?.computerId !== capability.teamComputerId || pinned.owner.generation !== capability.generation ||
@@ -1019,9 +1035,19 @@ function checkedModelSelection(raw, current, requireAvailableModel = false) {
1019
1035
  }
1020
1036
  selection.effort = value.effort;
1021
1037
  }
1038
+ if (value.variant !== undefined) {
1039
+ if (!isModelVariant(value.variant)) {
1040
+ return { ok: false, status: 400, error: "variant must be a non-empty model variant ID" };
1041
+ }
1042
+ if (value.effort !== undefined) {
1043
+ return { ok: false, status: 400, error: "choose either a model variant or an effort level" };
1044
+ }
1045
+ selection.variant = value.variant;
1046
+ }
1022
1047
  const changed = current && (selection.instanceId !== current.selection.instanceId ||
1023
1048
  selection.model !== current.selection.model ||
1024
- selection.effort !== current.selection.effort);
1049
+ selection.effort !== current.selection.effort ||
1050
+ selection.variant !== current.selection.variant);
1025
1051
  if (current?.busy && changed) {
1026
1052
  return { ok: false, status: 409, error: "the bot is working — stop it before changing models" };
1027
1053
  }
@@ -1051,6 +1077,9 @@ function checkedModelSelection(raw, current, requireAvailableModel = false) {
1051
1077
  if (target && selection.effort !== undefined && !allowed.includes(selection.effort)) {
1052
1078
  return { ok: false, status: 400, error: `effort "${selection.effort}" is not offered by this bot's engine` };
1053
1079
  }
1080
+ if (target && selection.variant !== undefined && !target.adapter.capabilities.modelVariants) {
1081
+ return { ok: false, status: 400, error: "model variants are not offered by this bot's engine" };
1082
+ }
1054
1083
  return { ok: true, selection };
1055
1084
  }
1056
1085
  function checkedTaskModelSwitch(current, raw, updateBotDefault, resetApprovalToAsk, requireAvailableModel = false, trusted = false) {
@@ -1237,7 +1266,7 @@ function previewSystemPrompt(bot) {
1237
1266
  ]
1238
1267
  .filter(Boolean)
1239
1268
  .join(" ");
1240
- const instance = registry.get(bot.modelSelection.instanceId);
1269
+ const instance = turnInstance(bot);
1241
1270
  const caps = instance?.adapter.capabilities;
1242
1271
  const teamComputer = inheritedTeamComputer(bot);
1243
1272
  const previewComputer = teamComputer ? "cloud" : bot.computer;
@@ -1278,7 +1307,12 @@ function previewSystemPrompt(bot) {
1278
1307
  },
1279
1308
  { id: "computer", label: "Computer", text: computerPrompt(computerPromptKind) },
1280
1309
  { id: "team-computer", label: "Team computer", text: teamComputerPrompt(teamComputer) },
1281
- { id: "plan", label: "Surface", text: previewPlan.note },
1310
+ // Auto cannot know its place until dispatch, so the preview stays silent
1311
+ // there and only carries the note; explicit settings preview the paragraph.
1312
+ { id: "plan", label: "Surface", text: previewPlan.computer === undefined ? "" : surfacePrompt({
1313
+ computer: previewPlan.computer && previewPlan.computer !== "off" && computerPromptKind ? previewPlan.computer : null,
1314
+ browser: previewPlan.computer === undefined ? false : previewPlan.browser,
1315
+ }, { note: previewPlan.note }) },
1282
1316
  { id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? COMPOSIO_PROMPT : "" },
1283
1317
  { id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(customMcpServers(cfg, bot.mcpServers))) : "" },
1284
1318
  { id: "browser", label: "Browser", text: previewPlan.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
@@ -2051,7 +2085,16 @@ function outstandingAssignmentsPrompt(threadId) {
2051
2085
  const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
2052
2086
  validate: (node, parent) => roomHandoffProblem(node, parent) ??
2053
2087
  (parent && store.bot(parent.botId)?.approvePeerComms && !fullAccessForSource(parent.botId, parent.threadId) && !node.approvalGranted ? "Sender now requires peer approval; submit a new approved request" : undefined),
2054
- busy: n => Boolean(store.bot(n.botId)?.busy || (n.groupId && store.group(n.groupId) && groupIsWorking(store.group(n.groupId)))),
2088
+ // A direct follow-up is owed to one conversation, so it waits for that
2089
+ // conversation, not for the whole bot. Bot-level busy aggregates every
2090
+ // thread — including cards still waiting on the person — so one busy
2091
+ // sibling thread would otherwise starve the owed resume forever while the
2092
+ // UI keeps showing this thread working. Fresh work still queues behind a
2093
+ // busy teammate's whole bot (#1238); an owed resume only needs its own
2094
+ // thread free and a thread slot to admit it.
2095
+ busy: n => !n.groupId && n.status === "resume"
2096
+ ? threadBusy(n.botId, n.threadId) || botAtThreadCapacity(n.botId)
2097
+ : Boolean(store.bot(n.botId)?.busy || (n.groupId && store.group(n.groupId) && groupIsWorking(store.group(n.groupId)))),
2055
2098
  changed: (groupIds, directThreadIds) => {
2056
2099
  for (const id of groupIds) {
2057
2100
  const group = store.group(id);
@@ -2963,6 +3006,41 @@ bus.subscribe((event) => {
2963
3006
  endMemoryTurn(event.threadId);
2964
3007
  }
2965
3008
  });
3009
+ // One line per finished turn in the bot's daily log (server/recent-work.ts):
3010
+ // what it said last, the tools it used, whether the turn failed. What a bot
3011
+ // did in one conversation was invisible from every other; the log is where
3012
+ // session_search finds it later, and it is never loaded whole into a
3013
+ // prompt. In a room the reply names its speaker; a room turn that never
3014
+ // replied has no one to credit and leaves no line.
3015
+ bus.subscribe((event) => {
3016
+ if (shouldIgnoreProviderEvent(event))
3017
+ return;
3018
+ if (event.type !== "turn.completed" || !event.turnId)
3019
+ return;
3020
+ try {
3021
+ const messages = store.messagesFor(event.threadId);
3022
+ const reply = messages.findLast((message) => message.role === "bot" && message.kind === "text" && message.turnId === event.turnId);
3023
+ const bot = store.botByThread(event.threadId) ?? (reply?.from ? store.bot(reply.from.botId) : undefined);
3024
+ if (!bot)
3025
+ return;
3026
+ const tools = messages
3027
+ .filter((message) => message.kind === "activity" && message.turnId === event.turnId && message.tool?.name)
3028
+ .map((message) => message.tool.name);
3029
+ const line = turnOutcomeLine({ ok: event.ok, reply: reply?.text, stopReason: event.stopReason, tools });
3030
+ if (!line)
3031
+ return;
3032
+ appendMemoryLog(bot.id, line, {
3033
+ source: memorySourceLabel({
3034
+ room: store.groupByThread(event.threadId),
3035
+ task: store.taskByThread(bot.id, event.threadId),
3036
+ threadId: event.threadId,
3037
+ }),
3038
+ });
3039
+ }
3040
+ catch {
3041
+ // a missing note never fails a turn
3042
+ }
3043
+ });
2966
3044
  // Bots currently working with nobody at the keyboard — a webhook turn, or a
2967
3045
  // turn a webhook-driven bot handed to a teammate. Auto mode is a decision
2968
3046
  // someone made for turns they were present for, so these don't inherit it:
@@ -3042,6 +3120,15 @@ const localVmLeases = new LocalVmLeasePool(30 * 60_000);
3042
3120
  const localVmLifecycleBusy = new Set();
3043
3121
  const localVmThreadTargets = new Map();
3044
3122
  const localVmActiveThreads = new Map();
3123
+ /** Local VM targets this process has seen ready or recreatable — at boot, in
3124
+ * the inventory, or in a turn. Auto probes the container runtime for a VM
3125
+ * only when one of these exists, so an ordinary Auto turn on a machine with
3126
+ * no VM never pays for a docker or podman call. */
3127
+ const localVmSeen = new Set();
3128
+ function noteLocalVmSeen(target, status) {
3129
+ if (status && autoLocalVmAttachable(status))
3130
+ localVmSeen.add(target.key);
3131
+ }
3045
3132
  let localVmImageBusy = false;
3046
3133
  let localVmProvisionBusy = false;
3047
3134
  let localVmModeChangeBusy = false;
@@ -3201,7 +3288,8 @@ function providerOperationConflict(provider) {
3201
3288
  if (boxInventoryRequestsBusyIds.size > 0 || orphanBoxLifecycleBusyIds.size > 0) {
3202
3289
  return "wait for cloud computer actions to finish before changing the Box account";
3203
3290
  }
3204
- if (boxCreateRecoverySnapshot().some((entry) => !entry.resolved)) {
3291
+ const deletingBoxIds = new Set(boxDeletionSnapshot().map((entry) => entry.boxId));
3292
+ if (boxCreateRecoverySnapshot().some((entry) => !entry.resolved && (!entry.boxId || !deletingBoxIds.has(entry.boxId)))) {
3205
3293
  return "finish reconciling pending cloud computer creation before changing the Box account";
3206
3294
  }
3207
3295
  }
@@ -3210,34 +3298,168 @@ function providerOperationConflict(provider) {
3210
3298
  }
3211
3299
  return null;
3212
3300
  }
3213
- function turnProvider(bot, runOn) {
3214
- if (runOn === "cloud" || registry.get(bot.modelSelection.instanceId)?.driverKind === "boxAgent")
3215
- return "box";
3216
- if (inheritedTeamComputer(bot))
3301
+ function turnSurfacePlan(bot, runOn, threadId) {
3302
+ const instance = registry.get(bot.modelSelection.instanceId);
3303
+ const forcedBox = runOn === "cloud" || Boolean(inheritedTeamComputer(bot));
3304
+ return resolveSurface({
3305
+ destination: forcedBox ? "cloud" : bot.computer,
3306
+ pinnedSurface: forcedBox || !threadId ? null : store.taskByThread(bot.id, threadId)?.surface,
3307
+ browserOn: builtInBrowserEnabled(cfg) && bot.browser !== false && instance?.adapter.capabilities.browserMcp === true,
3308
+ });
3309
+ }
3310
+ function turnProvider(bot, runOn, threadId) {
3311
+ if (runOn === "cloud" || inheritedTeamComputer(bot))
3217
3312
  return "box";
3218
- if (bot.computer !== undefined && bot.computer !== "cloud")
3313
+ const wants = turnSurfacePlan(bot, runOn, threadId).computer;
3314
+ if (wants !== undefined && wants !== "cloud")
3219
3315
  return null;
3220
- return bot.cloudBackend === "vps" ? "vps" : "box";
3316
+ if (registry.get(bot.modelSelection.instanceId)?.driverKind === "boxAgent")
3317
+ return "box";
3318
+ return bot.cloudBackend === "vps" ? "vps" : wants === "cloud" ? "box" : null;
3221
3319
  }
3222
3320
  /** A turn on the cloud computer runs ON the cloud computer: the Box runs the
3223
3321
  * bot's own harness there with the computer tools built in, so nothing on this
3224
3322
  * machine relays clicks and screenshots. Every start/interrupt of a turn asks
3225
3323
  * here which engine owns it. */
3226
- function turnInstance(bot, runOn) {
3227
- const onBox = runOn === "cloud"
3228
- || turnProvider(bot, runOn) === "box" && (bot.computer === "cloud" || inheritedTeamComputer(bot) !== undefined);
3324
+ function turnInstance(bot, runOn, threadId) {
3325
+ const onBox = turnProvider(bot, runOn, threadId) === "box";
3229
3326
  return onBox
3230
3327
  ? registry.instances().find((candidate) => candidate.driverKind === "boxAgent") ?? null
3231
3328
  : registry.get(bot.modelSelection.instanceId);
3232
3329
  }
3330
+ /** Preview requests carry the selected conversation, not whichever thread
3331
+ * happens to be the bot's default. A query never grants lifecycle authority. */
3332
+ function computerPreviewBot(botId, url) {
3333
+ const threadId = url.searchParams.get("threadId");
3334
+ if (!threadId)
3335
+ return store.bot(botId);
3336
+ const bot = store.projectBotForTask(botId, threadId);
3337
+ if (!bot)
3338
+ throw Object.assign(new Error("no such task"), { status: 404 });
3339
+ return directTurnBots.get(threadId) ?? bot;
3340
+ }
3341
+ async function computerPreviewSurface(bot, threadId) {
3342
+ const plan = turnSurfacePlan(bot, undefined, threadId);
3343
+ if (plan.computer !== undefined)
3344
+ return plan.computer === "off" && plan.browser ? "browser" : plan.computer;
3345
+ const instance = registry.get(bot.modelSelection.instanceId);
3346
+ if (instance?.driverKind === "boxAgent")
3347
+ return "cloud";
3348
+ if (bot.cloudBackend === "vps") {
3349
+ const remote = await vps.vpsComputerStatus(cfg, bot.id);
3350
+ if (remote.ready)
3351
+ return "cloud";
3352
+ }
3353
+ const target = localVmTargetForBot(bot.id);
3354
+ if (instance?.adapter.capabilities.computerMcp && localVmSeen.has(target.key)) {
3355
+ const vm = await containerComputerStatus(undefined, undefined, target).catch(() => null);
3356
+ if (vm && autoLocalVmAttachable(vm))
3357
+ return "vm";
3358
+ }
3359
+ if (shouldMountLocalComputer({ requested: undefined, hostPlatform: process.platform,
3360
+ providerSupportsLocal: instance?.adapter.capabilities.localComputerMcp === true }) && readCuaConnection())
3361
+ return "local";
3362
+ if (bot.cloudBackend === "vps")
3363
+ return "cloud"; // show its unavailable reason
3364
+ return plan.browser ? "browser" : "off";
3365
+ }
3366
+ /** Discovery is read-only. Starting or creating a configured computer is
3367
+ * deferred until a chat tool selects it and the old turn releases its tools. */
3368
+ async function selectableComputers(bot) {
3369
+ const caps = registry.get(bot.modelSelection.instanceId)?.adapter.capabilities;
3370
+ const off = bot.computer === "off";
3371
+ const localEngine = registry.get(bot.modelSelection.instanceId)?.driverKind !== "boxAgent";
3372
+ return Promise.all(["cloud", "vm", "local", "browser"].map(async (surface) => {
3373
+ let ready = false;
3374
+ let canStart = false;
3375
+ let canCreate = false;
3376
+ let reason = "This computer is not configured or running. Open the Computer panel to set it up.";
3377
+ try {
3378
+ if (off)
3379
+ reason = "Computer access is Off in this bot's settings.";
3380
+ else if (surface === "cloud") {
3381
+ if (bot.cloudBackend === "vps") {
3382
+ const status = localEngine && caps?.computerMcp ? await vps.vpsComputerStatus(cfg, bot.id) : null;
3383
+ ready = status?.ready === true;
3384
+ canStart = Boolean(status?.daemonUp && status.managed && status.container === "stopped" &&
3385
+ status.image && status.imageMatches && status.network === "private" && status.mounts === "none" && status.security === "hardened");
3386
+ canCreate = Boolean(status?.configured && status.daemonUp && status.container === "missing");
3387
+ reason = status?.problem ?? reason;
3388
+ }
3389
+ else if (box.boxConfigured(cfg) && registry.instances().some(instance => instance.driverKind === "boxAgent")) {
3390
+ const status = await box.boxStatus(cfg, bot.id);
3391
+ const lifecycle = box.boxTurnLifecycleAction({ explicitCloud: true, canMount: true, state: status.box?.state ?? null });
3392
+ ready = lifecycle === "attach";
3393
+ canStart = lifecycle === "wake";
3394
+ canCreate = lifecycle === "provision";
3395
+ }
3396
+ }
3397
+ else if (surface === "vm" && localEngine && caps?.computerMcp) {
3398
+ const target = localVmTargetForBot(bot.id);
3399
+ const status = await containerComputerStatus(undefined, undefined, target);
3400
+ ready = status.ready;
3401
+ canCreate = !ready && autoLocalVmAttachable(status);
3402
+ if (ready || canCreate)
3403
+ noteLocalVmSeen(target, status);
3404
+ reason = status.problem ?? reason;
3405
+ }
3406
+ else if (surface === "local") {
3407
+ ready = shouldMountLocalComputer({ requested: "local", hostPlatform: process.platform,
3408
+ providerSupportsLocal: caps?.localComputerMcp === true }) && Boolean(readCuaConnection());
3409
+ }
3410
+ else if (surface === "browser") {
3411
+ ready = caps?.browserMcp === true && builtInBrowserEnabled(cfg) && bot.browser !== false && browserEngineStatus().kind === "ready";
3412
+ reason = "The built-in browser is disabled, not installed, or unsupported by this model engine.";
3413
+ }
3414
+ }
3415
+ catch (error) {
3416
+ reason = error instanceof Error ? error.message : String(error);
3417
+ }
3418
+ const available = ready || canStart || canCreate;
3419
+ return { surface, label: surfaceLabel(surface), available, ready, canStart, canCreate, ...(!available ? { reason } : {}) };
3420
+ }));
3421
+ }
3422
+ function continueComputerSelection(threadId, generation, succeeded) {
3423
+ const selection = computerSelectionTurns.get(threadId);
3424
+ if (!selection || selection.generation !== generation)
3425
+ return false;
3426
+ if (!succeeded || !selection.selected) {
3427
+ computerSelectionTurns.delete(threadId);
3428
+ return false;
3429
+ }
3430
+ const surface = selection.selected;
3431
+ setImmediate(() => {
3432
+ // Stop, deletion, a new user send, or any replacement generation wins.
3433
+ if (computerSelectionTurns.get(threadId) !== selection || directTurnGenerationByThread.get(threadId) !== generation)
3434
+ return;
3435
+ computerSelectionTurns.delete(threadId);
3436
+ const bot = store.projectBotForTask(selection.botId, threadId);
3437
+ if (!bot || bot.computer === "off" || threadBusy(bot.id, threadId))
3438
+ return;
3439
+ if (store.taskByThread(bot.id, threadId)?.surface !== selection.previousSurface)
3440
+ return;
3441
+ if (queuedThreadPosition(bot.id, threadId) !== null)
3442
+ return;
3443
+ if (store.activePath(threadId).findLast(message => message.role === "user" && message.kind === "text")?.id !== selection.source.id)
3444
+ return;
3445
+ store.patchTask(bot.id, threadId, { surface });
3446
+ const text = `The computer selection is now ${surfaceLabel(surface)}. Continue the user's original request using the tools mounted for this turn; verify the result before claiming success.\n\n${selection.text}`;
3447
+ void startTurn(bot.id, text, { threadId, userMessage: selection.source, computerSelectionContinuation: true }).catch(error => {
3448
+ if (store.taskByThread(bot.id, threadId))
3449
+ store.appendMessage(threadId, { role: "bot", kind: "activity",
3450
+ tool: { name: `Could not continue on ${surfaceLabel(surface)}: ${error instanceof Error ? error.message : String(error)}`, ok: false } });
3451
+ });
3452
+ });
3453
+ return true;
3454
+ }
3233
3455
  /** The engine that dispatched each live turn. A bot's settings may change
3234
3456
  * mid-turn; an interrupt or steer must reach the engine actually running. */
3235
3457
  const runningTurnEngines = new Map();
3236
3458
  function runningTurnInstance(bot, threadId, runOn) {
3237
- return runningTurnEngines.get(threadId) ?? turnInstance(bot, runOn);
3459
+ return runningTurnEngines.get(threadId) ?? turnInstance(bot, runOn, threadId);
3238
3460
  }
3239
- function providerTransitionForTurn(bot, runOn) {
3240
- const provider = turnProvider(bot, runOn);
3461
+ function providerTransitionForTurn(bot, runOn, threadId) {
3462
+ const provider = turnProvider(bot, runOn, threadId);
3241
3463
  return provider && computerProviderConfigTransitions.has(provider)
3242
3464
  ? providerTransitionMessage(provider)
3243
3465
  : null;
@@ -3338,6 +3560,7 @@ function releaseLocalVmThread(threadId) {
3338
3560
  void (async () => {
3339
3561
  if (localVmMode(cfg) !== "per-bot") {
3340
3562
  const status = await containerComputerStatus(undefined, undefined, SHARED_LOCAL_VM_TARGET).catch(() => null);
3563
+ noteLocalVmSeen(SHARED_LOCAL_VM_TARGET, status);
3341
3564
  if (shouldArmLocalVmIdle(status))
3342
3565
  localVmIdleFor(SHARED_LOCAL_VM_TARGET).touch();
3343
3566
  return;
@@ -3348,6 +3571,7 @@ void (async () => {
3348
3571
  const existing = await discoverExistingPerBotLocalVms(store.bots, runtime.runtime).catch(() => []);
3349
3572
  const statuses = await Promise.all(existing.map(({ target }) => containerComputerStatus(undefined, undefined, target).catch(() => null)));
3350
3573
  existing.forEach(({ target }, index) => {
3574
+ noteLocalVmSeen(target, statuses[index]);
3351
3575
  if (shouldArmLocalVmIdle(statuses[index]))
3352
3576
  localVmIdleFor(target).touch();
3353
3577
  });
@@ -3371,6 +3595,7 @@ async function localVmInventoryPayload() {
3371
3595
  const status = statuses[index];
3372
3596
  if (!status)
3373
3597
  return [];
3598
+ noteLocalVmSeen(target, status);
3374
3599
  const inUse = localVmActiveThreads.has(target.key) ||
3375
3600
  localVmLeaseFor(target).current(localVmOwnerBusy) !== null;
3376
3601
  const entry = localVmInventoryEntry(bot, status, inUse);
@@ -3807,6 +4032,8 @@ bus.subscribe((event) => {
3807
4032
  store.setTaskActivity(bot.id, event.threadId, "idle");
3808
4033
  }
3809
4034
  directTurnBots.delete(event.threadId);
4035
+ if (continueComputerSelection(event.threadId, generation, event.ok))
4036
+ return;
3810
4037
  // Ordinary completion still drains from the existing bus subscribers.
3811
4038
  // An asynchronous screenshot finishes after those subscribers ran.
3812
4039
  if (resumeQueued) {
@@ -3868,7 +4095,7 @@ bus.subscribe((event) => {
3868
4095
  // A failed peer turn stays a chip too. The bot that delegated is woken
3869
4096
  // with the failure and answers the person in its own thread — buzzing
3870
4097
  // here as well would ring twice for one piece of news.
3871
- if ((!routineRun || routineRun.status === "completed") && !internal) {
4098
+ if ((!routineRun || routineRun.status === "completed") && !internal && !computerSelectionTurns.get(event.threadId)?.selected) {
3872
4099
  // the frame carries the bot's avatar so every desktop client can
3873
4100
  // show the notification under that bot's own face
3874
4101
  const completionDetail = routineRun
@@ -4622,7 +4849,7 @@ async function startTurn(botId, text, opts) {
4622
4849
  if (bot.approvalGrant) {
4623
4850
  throw Object.assign(new Error("this bot's approval level is still being confirmed — try again"), { status: 409 });
4624
4851
  }
4625
- const transitionError = providerTransitionForTurn(bot, opts?.runOn);
4852
+ const transitionError = providerTransitionForTurn(bot, opts?.runOn, threadId);
4626
4853
  if (transitionError)
4627
4854
  throw Object.assign(new Error(transitionError), { status: 409 });
4628
4855
  if (providerFleetReloading)
@@ -4666,9 +4893,10 @@ async function startTurn(botId, text, opts) {
4666
4893
  const task = store.taskByThread(bot.id, threadId);
4667
4894
  if (!task)
4668
4895
  throw Object.assign(new Error("no such task"), { status: 404 });
4669
- const instance = turnInstance(bot, opts?.runOn);
4896
+ const plan = turnSurfacePlan(bot, opts?.runOn, threadId);
4897
+ const instance = turnInstance(bot, opts?.runOn, threadId);
4670
4898
  if (!instance) {
4671
- throw Object.assign(new Error(opts?.runOn === "cloud" || bot.computer === "cloud" || inheritedTeamComputer(bot)
4899
+ throw Object.assign(new Error(turnProvider(bot, opts?.runOn, threadId) === "box"
4672
4900
  ? "the Cloud VM runner is unavailable — configure Box in App Settings"
4673
4901
  : `provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`), { status: 409 });
4674
4902
  }
@@ -4696,10 +4924,13 @@ async function startTurn(botId, text, opts) {
4696
4924
  if (providerInstancesChanging.has(instanceId)) {
4697
4925
  throw Object.assign(new Error("this provider account is being updated — try again shortly"), { status: 409 });
4698
4926
  }
4699
- const model = opts?.runOn === "cloud" ? instance.models.default : bot.modelSelection.model;
4927
+ const switchedEngine = instance.instanceId !== bot.modelSelection.instanceId;
4928
+ const model = opts?.runOn === "cloud" || switchedEngine ? instance.models.default : bot.modelSelection.model;
4700
4929
  // a cloud routine borrows the instance default model, so it borrows no
4701
4930
  // per-bot effort either
4702
- const effort = opts?.runOn === "cloud" ? undefined : bot.modelSelection.effort;
4931
+ const effort = opts?.runOn === "cloud" || switchedEngine ? undefined : bot.modelSelection.effort;
4932
+ const variant = opts?.runOn === "cloud" || switchedEngine ? undefined : bot.modelSelection.variant;
4933
+ assertModelVariantSupported({ variant, effort }, instance.adapter.capabilities);
4703
4934
  // A selection can be persisted while its engine is offline. Re-check when
4704
4935
  // the engine returns so an old or unsupported value never reaches a CLI.
4705
4936
  if (effort && !instance.adapter.capabilities.effortLevels?.includes(effort)) {
@@ -4833,6 +5064,12 @@ async function startTurn(botId, text, opts) {
4833
5064
  directTurnDispatchClaims.set(threadId, { id: dispatchClaimId, botId, threadId, phase: "setup" });
4834
5065
  directTurnBots.set(threadId, bot);
4835
5066
  beginInternalCapabilityGeneration(threadId, dispatchClaimId);
5067
+ if (!opts?.computerSelectionContinuation && !opts?.cardContinuation && !opts?.automationSource && !opts?.unattended &&
5068
+ !opts?.commsDepth && !opts?.coordination && !inheritedTeamComputer(bot) && bot.computer !== "off" && agentsMounted) {
5069
+ const source = store.activePath(threadId).findLast(message => message.id === userMessage?.id && message.role === "user" && !message.peerAsk);
5070
+ if (source)
5071
+ computerSelectionTurns.set(threadId, { generation: dispatchClaimId, botId: bot.id, source, text });
5072
+ }
4836
5073
  store.setTaskActivity(bot.id, threadId, "working");
4837
5074
  // A closed thread that gets a new turn is open again: the person (or the
4838
5075
  // opener) picked it back up, so its row returns to the sidebar and
@@ -4922,21 +5159,23 @@ async function startTurn(botId, text, opts) {
4922
5159
  const teamComputer = inheritedTeamComputer(bot);
4923
5160
  const cloudBackend = teamComputer || opts?.runOn === "cloud" || bot.cloudBackend !== "vps" ? "box" : "vps";
4924
5161
  const mountsComputerMcp = instance.adapter.capabilities.computerMcp === true;
4925
- const mountsCloudComputer = mountsComputerMcp || instance.driverKind === "boxAgent";
5162
+ // Box's native runner owns its computer tools. Local drivers mount
5163
+ // Local VM/VPS tools, but have no Box relay to execute this descriptor.
5164
+ const mountsCloudComputer = instance.driverKind === "boxAgent";
4926
5165
  const mountsLocalComputer = instance.adapter.capabilities.localComputerMcp === true;
4927
5166
  // Where this turn's hands may land. The bot's "Works on" choice is
4928
5167
  // strict; a browser-only bot gets no computer at all, and a bot whose
4929
5168
  // browser is withheld (workspace flag, its own switch, or an engine
4930
5169
  // without browser tools) gets told so instead of silently falling back
4931
5170
  // to a desktop it was never meant to touch.
4932
- const browserOn = builtInBrowserEnabled(cfg) &&
4933
- bot.browser !== false &&
4934
- instance.adapter.capabilities.browserMcp === true;
4935
- const plan = resolveSurface({
4936
- destination: teamComputer || opts?.runOn === "cloud" ? "cloud" : bot.computer, // cloud routine overrides the MAUS default
4937
- browserOn,
4938
- });
4939
- if (bot.computer === "browser" && plan.computer === "off" && instance.driverKind === "boxAgent") {
5171
+ // The conversation's own place wins over the bot default: the person
5172
+ // pinned it from the composer, or its first Auto turn recorded where it
5173
+ // landed. A team computer or a cloud routine is not this conversation's
5174
+ // choice, so those ignore the pin.
5175
+ const dispatchTask = store.taskByThread(bot.id, threadId);
5176
+ if (plan.clearPin && dispatchTask)
5177
+ store.patchTask(bot.id, threadId, { surface: undefined });
5178
+ if (plan.computer !== undefined && plan.computer !== "cloud" && instance.driverKind === "boxAgent") {
4940
5179
  throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
4941
5180
  }
4942
5181
  const wants = plan.computer;
@@ -4946,47 +5185,77 @@ async function startTurn(botId, text, opts) {
4946
5185
  let autoVpsProblem = null;
4947
5186
  // Explicit destinations are strict. In particular, Local VM must never
4948
5187
  // fall through to host CUA and accidentally click on the user's Mac.
4949
- if (wants === "vm") {
5188
+ // The Local VM attach, shared by explicit "Local VM" and by Auto. Explicit
5189
+ // is strict and throws with the reason. Auto only reaches a VM this bot
5190
+ // already has — ready now, or one whose desktop image is prepared and can
5191
+ // be recreated on demand after idling away — and never creates a first
5192
+ // VM on its own; every failure there is a quiet "not this place".
5193
+ const attachLocalVm = async (strict) => {
4950
5194
  if (!mountsComputerMcp || instance.driverKind === "boxAgent") {
5195
+ if (!strict)
5196
+ return false;
4951
5197
  throw new Error("this model engine cannot use the Local VM — choose Claude or an ACP engine, or select another computer destination");
4952
5198
  }
4953
5199
  const localVmTarget = localVmTargetForBot(bot.id);
4954
- bindTurnComputer(resourceOwner, `computer:vm:${localVmTarget.key}`, true);
4955
- if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) {
4956
- throw new Error("this Local VM is being started, stopped, or replaced — wait for setup to finish");
4957
- }
4958
- // Claim before the first await. The lifecycle route performs its
4959
- // matching check synchronously, so neither side can enter while the
4960
- // other is between inspection and mutation.
4961
- if (!localVmLeaseFor(localVmTarget).claim(threadId, bot.id, localVmOwnerBusy)) {
4962
- throw new Error("this Local VM is already being used by another turn — wait for that turn to finish");
4963
- }
4964
- localVmThreadTargets.set(threadId, localVmTarget);
4965
- localVmActiveThreads.set(localVmTarget.key, threadId);
4966
- localVmIdleFor(localVmTarget).touch();
4967
- const localVm = await readyLocalVmForTurn(bot.id, localVmTarget);
4968
- if (!localVm.ready || !localVm.runtime) {
4969
- throw new Error(`${localVm.problem ?? "the Local VM is not ready"} (App Settings → Computers)`);
4970
- }
4971
- integrations.localComputer = containerComputerMcp(localVm.runtime, controlIntegration(bot.id, threadId, dispatchClaimId), localVmTarget);
4972
- computerKind = "vm";
4973
- // Same contract as the Box and VPS branches below: without this the
4974
- // poller never starts, so the Local VM publishes no `screen` events
4975
- // and every client that only has the stream (the phone) waits
4976
- // forever. The web panel hid the gap by polling the screenshot
4977
- // route itself.
4978
- previewCapture = () => {
4979
- // The shared desktop outlives the turn. Once another thread owns
4980
- // it, a capture still in flight would picture ITS work under this
4981
- // bot's name — live and in the settled transcript frame, which is
4982
- // taken after the lease is already released. No owner means the
4983
- // desktop is simply idle: that final frame is ours to keep.
4984
- const owner = localVmLeaseFor(localVmTarget).current(localVmOwnerBusy);
4985
- if (owner && owner.threadId !== threadId) {
4986
- throw new Error("the Local VM moved on to another turn");
5200
+ if (!strict) {
5201
+ // Nothing this process has ever seen for this target, and nobody is
5202
+ // relying on an unattended run: do not pay for a runtime probe.
5203
+ if (!localVmSeen.has(localVmTarget.key) && !opts?.automationSource)
5204
+ return false;
5205
+ const seen = await containerComputerStatus(undefined, undefined, localVmTarget).catch(() => null);
5206
+ if (!seen || !autoLocalVmAttachable(seen))
5207
+ return false;
5208
+ if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key))
5209
+ return false;
5210
+ }
5211
+ try {
5212
+ bindTurnComputer(resourceOwner, `computer:vm:${localVmTarget.key}`, true);
5213
+ if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) {
5214
+ throw new Error("this Local VM is being started, stopped, or replaced — wait for setup to finish");
4987
5215
  }
4988
- return containerComputerFrame(undefined, undefined, localVmTarget);
4989
- };
5216
+ // Claim before the first await. The lifecycle route performs its
5217
+ // matching check synchronously, so neither side can enter while the
5218
+ // other is between inspection and mutation.
5219
+ if (!localVmLeaseFor(localVmTarget).claim(threadId, bot.id, localVmOwnerBusy)) {
5220
+ throw new Error("this Local VM is already being used by another turn — wait for that turn to finish");
5221
+ }
5222
+ localVmThreadTargets.set(threadId, localVmTarget);
5223
+ localVmActiveThreads.set(localVmTarget.key, threadId);
5224
+ localVmIdleFor(localVmTarget).touch();
5225
+ const localVm = await readyLocalVmForTurn(bot.id, localVmTarget);
5226
+ if (!localVm.ready || !localVm.runtime) {
5227
+ throw new Error(`${localVm.problem ?? "the Local VM is not ready"} (App Settings → Computers)`);
5228
+ }
5229
+ integrations.localComputer = containerComputerMcp(localVm.runtime, controlIntegration(bot.id, threadId, dispatchClaimId), localVmTarget);
5230
+ // Same contract as the Box and VPS branches below: without this the
5231
+ // poller never starts, so the Local VM publishes no `screen` events
5232
+ // and every client that only has the stream (the phone) waits
5233
+ // forever. The web panel hid the gap by polling the screenshot
5234
+ // route itself.
5235
+ previewCapture = () => {
5236
+ // The shared desktop outlives the turn. Once another thread owns
5237
+ // it, a capture still in flight would picture ITS work under this
5238
+ // bot's name — live and in the settled transcript frame, which is
5239
+ // taken after the lease is already released. No owner means the
5240
+ // desktop is simply idle: that final frame is ours to keep.
5241
+ const owner = localVmLeaseFor(localVmTarget).current(localVmOwnerBusy);
5242
+ if (owner && owner.threadId !== threadId) {
5243
+ throw new Error("the Local VM moved on to another turn");
5244
+ }
5245
+ return containerComputerFrame(undefined, undefined, localVmTarget);
5246
+ };
5247
+ return true;
5248
+ }
5249
+ catch (error) {
5250
+ if (strict)
5251
+ throw error;
5252
+ releaseLocalVmThread(threadId);
5253
+ return false;
5254
+ }
5255
+ };
5256
+ if (wants === "vm") {
5257
+ if (await attachLocalVm(true))
5258
+ computerKind = "vm";
4990
5259
  }
4991
5260
  else if (wants === "local") {
4992
5261
  if (!shouldMountLocalComputer({
@@ -5017,7 +5286,8 @@ async function startTurn(botId, text, opts) {
5017
5286
  // its explicit computer turns serialized; ordinary threads still run.
5018
5287
  bindTurnComputer(resourceOwner, `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`, true);
5019
5288
  activeVpsThreads.set(bot.id, threadId);
5020
- const remote = wants === "cloud" || bot.autoStartVps
5289
+ let remote;
5290
+ remote = vps.vpsStartsForTurn({ wants, autoStartVps: bot.autoStartVps, automationSource: opts?.automationSource })
5021
5291
  ? await vps.vpsComputerAction("provision", cfg, bot.id)
5022
5292
  : await vps.inspectVpsForAuto(cfg, bot.id);
5023
5293
  if (remote?.ready && remote.sshAlias) {
@@ -5040,15 +5310,15 @@ async function startTurn(botId, text, opts) {
5040
5310
  }
5041
5311
  }
5042
5312
  }
5043
- // Cloud is also strict when explicitly selected. Auto (unset) reuses an
5044
- // existing cloud box, then falls back to host CUA without provisioning.
5313
+ // Cloud is strict when selected. Only the native Box engine can reuse
5314
+ // a Box on Auto; local engines have no relay for its desktop tools.
5045
5315
  if (teamComputer) {
5046
5316
  const attached = await attachTeamBox(teamComputer, bot.id, resourceOwner, mountsCloudComputer, instance.driverKind === "boxAgent");
5047
5317
  integrations.computer = attached.integration;
5048
5318
  previewCapture = attached.capture;
5049
5319
  computerKind = "box";
5050
5320
  }
5051
- if (!teamComputer && (wants === "cloud" || wants === undefined) && cloudBackend === "box" && box.boxConfigured(cfg)) {
5321
+ if (!teamComputer && mountsCloudComputer && (wants === "cloud" || wants === undefined) && cloudBackend === "box" && box.boxConfigured(cfg)) {
5052
5322
  // Explicit cloud turns can provision/wake the same bot's Box. Claim
5053
5323
  // before any network await so setup itself cannot race another turn.
5054
5324
  if (wants === "cloud")
@@ -5056,7 +5326,17 @@ async function startTurn(botId, text, opts) {
5056
5326
  if (!mountsCloudComputer && wants === "cloud") {
5057
5327
  throw new Error("this model engine cannot use computer tools — choose Claude, an ACP engine, or the Computer engine");
5058
5328
  }
5059
- let b = await box.findBox(cfg, bot.id).catch(() => null);
5329
+ let b;
5330
+ try {
5331
+ b = await box.findBox(cfg, bot.id);
5332
+ }
5333
+ catch (error) {
5334
+ // Auto may fall through when an optional provider is offline, but a
5335
+ // durable deletion fence must never be mistaken for "no computer".
5336
+ if (wants === "cloud" || error?.status === 409)
5337
+ throw error;
5338
+ b = null;
5339
+ }
5060
5340
  let lifecycle = box.boxTurnLifecycleAction({
5061
5341
  explicitCloud: wants === "cloud",
5062
5342
  canMount: mountsCloudComputer,
@@ -5065,7 +5345,7 @@ async function startTurn(botId, text, opts) {
5065
5345
  if (lifecycle === "provision") {
5066
5346
  broadcast({ kind: "computer", botId: bot.id, state: "provisioning" });
5067
5347
  await box.provisionBox(cfg, bot.id, bot.name);
5068
- b = await box.findBox(cfg, bot.id).catch(() => null);
5348
+ b = await box.findBox(cfg, bot.id);
5069
5349
  lifecycle = box.boxTurnLifecycleAction({
5070
5350
  explicitCloud: true,
5071
5351
  canMount: mountsCloudComputer,
@@ -5078,7 +5358,7 @@ async function startTurn(botId, text, opts) {
5078
5358
  // consent boundary for the resume (~8s, and it un-pauses billing).
5079
5359
  if (lifecycle === "wake") {
5080
5360
  broadcast({ kind: "computer", botId: bot.id, state: "waking" });
5081
- b = (await box.readyBox(cfg, bot.id).catch(() => null)) ?? b;
5361
+ b = (await box.readyBox(cfg, bot.id)) ?? b;
5082
5362
  lifecycle = box.boxTurnLifecycleAction({
5083
5363
  explicitCloud: true,
5084
5364
  canMount: mountsCloudComputer,
@@ -5107,9 +5387,19 @@ async function startTurn(botId, text, opts) {
5107
5387
  }
5108
5388
  // Auto-only host fallback. Electron owns cua-driver/TCC attribution;
5109
5389
  // the harness only reads its already-running connection descriptor.
5390
+ // Auto reaches a Local VM this bot already has before it ever touches the
5391
+ // host's own desktop: on a headless server that VM is the only desktop
5392
+ // there is, and a person who prepared one meant it to be used.
5393
+ if (wants === undefined && !integrations.computer && !integrations.localComputer && await attachLocalVm(false))
5394
+ computerKind = "vm";
5395
+ // An unattended run on a bot with a VPS configured never lands on the
5396
+ // host's own desktop instead: a scheduled job clicking on someone's
5397
+ // laptop is worse than a scheduled job that fails and says why.
5398
+ const unattendedVps = cloudBackend === "vps" && Boolean(opts?.automationSource);
5110
5399
  if (!integrations.computer &&
5111
5400
  !integrations.localComputer &&
5112
5401
  wants === undefined &&
5402
+ !unattendedVps &&
5113
5403
  shouldMountLocalComputer({
5114
5404
  requested: undefined,
5115
5405
  hostPlatform: process.platform,
@@ -5126,10 +5416,13 @@ async function startTurn(botId, text, opts) {
5126
5416
  cloudBackend === "vps" &&
5127
5417
  !integrations.computer &&
5128
5418
  !integrations.localComputer &&
5129
- autoVpsProblem) {
5130
- const hint = bot.autoStartVps
5131
- ? "Check the VPS connection in App Settings → Connections."
5132
- : "Open Computer and enable Start VPS automatically, or choose Cloud to start it manually.";
5419
+ autoVpsProblem &&
5420
+ !computerSelectionTurns.has(threadId)) {
5421
+ const hint = opts?.automationSource
5422
+ ? "This scheduled run tried to start the VPS computer and could not reach it. Check the VPS connection in App Settings → Connections."
5423
+ : bot.autoStartVps
5424
+ ? "Check the VPS connection in App Settings → Connections."
5425
+ : "Open Computer and enable Start VPS automatically, or choose Cloud to start it manually.";
5133
5426
  throw new Error(`${autoVpsProblem}. ${hint}`);
5134
5427
  }
5135
5428
  // Agent control tools include peer comms and the secure credential
@@ -5197,8 +5490,13 @@ async function startTurn(botId, text, opts) {
5197
5490
  description: liveBot?.description ?? bot.description,
5198
5491
  text: providerText,
5199
5492
  });
5493
+ // One place per turn. On Auto the branches above may have reached a
5494
+ // computer; then the built-in browser stays unmounted and web work
5495
+ // happens in that computer's own browser, where the person can see it.
5496
+ const mountedComputer = surfaceOfComputerKind(computerKind);
5200
5497
  if (liveBot &&
5201
5498
  plan.browser &&
5499
+ !(plan.computer === undefined && mountedComputer) &&
5202
5500
  builtInBrowserEnabled(cfg) &&
5203
5501
  liveBot.browser !== false &&
5204
5502
  instance.adapter.capabilities.browserMcp === true) {
@@ -5218,6 +5516,17 @@ async function startTurn(botId, text, opts) {
5218
5516
  browserCapture = () => browserRuntime.withAgentAction(session, () => agentBrowserFrame(frame));
5219
5517
  }
5220
5518
  }
5519
+ // An Auto conversation remembers where its first turn landed, so later
5520
+ // turns stay there and the composer can show it. Explicit settings are
5521
+ // not recorded: changing the bot's Works on should move its threads.
5522
+ if (bot.computer === undefined && !teamComputer && opts?.runOn !== "cloud" && !plan.pinned) {
5523
+ const used = mountedComputer ?? (integrations.browser ? "browser" : null);
5524
+ if (used)
5525
+ store.patchTask(bot.id, threadId, { surface: used });
5526
+ }
5527
+ const computerSelection = computerSelectionTurns.get(threadId);
5528
+ if (computerSelection)
5529
+ computerSelection.mounted = mountedComputer ?? (integrations.browser ? "browser" : undefined);
5221
5530
  // A cancelled adapter can be between accepting sendTurn and revealing
5222
5531
  // its provider turn id. Never overlap a replacement with that ambiguous
5223
5532
  // pre-id window: wait for the old handshake to settle or for its bounded
@@ -5248,7 +5557,7 @@ async function startTurn(botId, text, opts) {
5248
5557
  { id: "files", label: "File locations", text: worksInWorkspace && opts?.runOn !== "cloud" ? workspaceLocationsPrompt(bot.id, cwd, liveBot?.cwd ?? bot.cwd) : "" },
5249
5558
  { id: "computer", label: "Computer", text: computerPrompt(computerPromptKind) },
5250
5559
  { id: "team-computer", label: "Team computer", text: teamComputerPrompt(teamComputer) },
5251
- { id: "plan", label: "Surface", text: plan.note },
5560
+ { id: "plan", label: "Surface", text: surfacePrompt({ computer: mountedComputer, browser: Boolean(integrations.browser) }, { pinned: plan.pinned, note: plan.note, canSelect: computerSelectionTurns.has(threadId) }) },
5252
5561
  // gated on the integration, not the key: the hint only goes to a
5253
5562
  // bot whose driver actually mounted the tools
5254
5563
  { id: "composio", label: "Connected apps", text: integrations.composio ? COMPOSIO_PROMPT : "" },
@@ -5264,6 +5573,9 @@ async function startTurn(botId, text, opts) {
5264
5573
  { id: "profile", label: "Profile changes", text: profilePrompt },
5265
5574
  { id: "learn", label: "Skill authoring", text: learnPrompt },
5266
5575
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
5576
+ // what the bot said lately in its other conversations, so a task
5577
+ // never redoes — or forgets — what another one already did
5578
+ { id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(store, bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId })) },
5267
5579
  { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace }) },
5268
5580
  { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id) : "" },
5269
5581
  { id: "skill-instructions", label: "Skill instructions", text: skillInstructions },
@@ -5276,11 +5588,12 @@ async function startTurn(botId, text, opts) {
5276
5588
  threadId,
5277
5589
  botId: bot.id,
5278
5590
  text: turnText,
5279
- refreshSystemPrompt: Boolean(opts?.coordination),
5591
+ refreshSystemPrompt: true,
5280
5592
  images: turnImages,
5281
5593
  approvalMode: approvalModeForTurn(bot, commsDepth > 0),
5282
5594
  model,
5283
5595
  effort,
5596
+ variant,
5284
5597
  // a rewound thread never resumes the abandoned branch's session
5285
5598
  // the active task's own session — another task's cursor would
5286
5599
  // resume the wrong conversation and defeat the context bubble
@@ -5291,6 +5604,7 @@ async function startTurn(botId, text, opts) {
5291
5604
  systemStable: prompt.stable,
5292
5605
  systemVolatile: prompt.volatile,
5293
5606
  integrations,
5607
+ mcpFromUserConfig: claudeUserMcpEnabled(cfg),
5294
5608
  cwd,
5295
5609
  }), () => !directTurnClaimExists(bot.id, dispatchClaimId, threadId), async () => {
5296
5610
  await instance.adapter.interruptTurn(threadId).catch(() => { });
@@ -5342,6 +5656,8 @@ async function startTurn(botId, text, opts) {
5342
5656
  }
5343
5657
  }
5344
5658
  catch (e) {
5659
+ if (computerSelectionTurns.get(threadId)?.generation === dispatchClaimId)
5660
+ computerSelectionTurns.delete(threadId);
5345
5661
  settleDirectFollowup(dispatchClaimId);
5346
5662
  clearCancelledProviderHandshake(threadId, `direct:${dispatchClaimId}`);
5347
5663
  clearDirectTurnDispatch(threadId, dispatchClaimId);
@@ -5784,6 +6100,9 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
5784
6100
  if (computerProviderConfigTransitions.size > 0) {
5785
6101
  return deletionResponse(409, { error: "computer provider settings are being updated — wait before deleting this bot" });
5786
6102
  }
6103
+ if (localVmModeChangeBusy) {
6104
+ return deletionResponse(409, { error: "Local VM settings are being updated — wait before deleting this bot" });
6105
+ }
5787
6106
  if (boxLifecycleBusyBots.has(bot.id)) {
5788
6107
  return deletionResponse(409, { error: "wait for this bot's cloud computer action to finish before deleting the bot" });
5789
6108
  }
@@ -5806,6 +6125,7 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
5806
6125
  return deletionResponse(409, { error: "stop this bot's work before checking and deleting its cloud computer" });
5807
6126
  }
5808
6127
  const botBoxRecovery = boxCreateRecoverySnapshot().filter((entry) => entry.botId === bot.id);
6128
+ const botBoxDeletions = boxDeletionSnapshot().filter((entry) => entry.ownerBotId === bot.id);
5809
6129
  if (botBoxRecovery.some((entry) => !entry.resolved)) {
5810
6130
  return deletionResponse(409, {
5811
6131
  error: "finish reconciling this bot's pending cloud computer creation before deleting it — check ascii.dev, then retry Box setup",
@@ -5822,84 +6142,94 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
5822
6142
  releaseComputerLifecycle();
5823
6143
  return deletionResponse(409, { error: "this bot or one of its channels is securely saving a credential" });
5824
6144
  }
6145
+ let claimedLocalVmTarget = null;
5825
6146
  try {
6147
+ let localVmCleanup = null;
5826
6148
  if (localVmMode(cfg) === "per-bot") {
5827
6149
  const target = perBotLocalVmTarget(bot.id);
5828
6150
  if (localVmActiveThreads.has(target.key) || localVmLifecycleBusy.has(target.key)) {
5829
6151
  return deletionResponse(409, { error: "stop this bot's Local VM turn or setup action before deleting the bot" });
5830
6152
  }
6153
+ if (localVmLeaseFor(target).current(localVmOwnerBusy)) {
6154
+ return deletionResponse(409, { error: "stop this bot's Local VM turn before deleting the bot" });
6155
+ }
6156
+ // Hold the target from preflight through deletion. A simultaneous
6157
+ // mode change or lifecycle route must not recreate the container
6158
+ // after we checked it and before its bot owner disappears.
6159
+ localVmLifecycleBusy.add(target.key);
6160
+ claimedLocalVmTarget = target;
5831
6161
  const vm = await containerComputerStatus(undefined, undefined, target);
5832
6162
  if (!vm.daemonUp && existsSync(target.workspaceDir)) {
5833
6163
  return deletionResponse(409, {
5834
- error: "start the container runtime and delete this bot's Local VM before deleting the bot",
6164
+ error: "start the container runtime so OpenMausBot can remove this bot's Local VM while deleting it",
5835
6165
  });
5836
6166
  }
5837
- if (vm.container !== "missing") {
5838
- return deletionResponse(409, { error: "delete this bot's Local VM from its Computer panel before deleting the bot" });
6167
+ if (vm.container !== "missing" && !vm.managed) {
6168
+ return deletionResponse(409, {
6169
+ error: `The container named ${vm.container_name} was not created by OpenMausBot. Remove it manually before deleting this bot`,
6170
+ });
5839
6171
  }
6172
+ localVmCleanup = {
6173
+ target,
6174
+ removeContainer: vm.container !== "missing",
6175
+ };
5840
6176
  }
5841
- // VPS containers are also durable and may outlive a destination or
5842
- // backend switch. Keep the bot as the discoverable owner until the
5843
- // person explicitly removes that container from Settings.
6177
+ // Preflight every provider before deleting any resource. Cleanup can
6178
+ // still fail mid-flight across independent providers, but a missing
6179
+ // credential or offline daemon should not cause avoidable partial work.
5844
6180
  const vpsInventory = await vps.listManagedVpsComputers(cfg, managedBoxOwners());
5845
6181
  if (vpsInventory.configured && !vpsInventory.available) {
5846
6182
  return deletionResponse(503, {
5847
- error: `${vpsInventory.problem ?? "VPS computer inventory is unavailable"}. Refresh Settings → Computers before deleting this bot`,
6183
+ error: `${vpsInventory.problem ?? "VPS computer inventory is unavailable"}. The bot was kept so its computer can be retried safely`,
5848
6184
  });
5849
6185
  }
5850
- if (vpsInventory.instances.some((instance) => instance.ownerBotId === bot.id)) {
6186
+ const ownedVpsComputers = vpsInventory.instances.filter((instance) => instance.ownerBotId === bot.id);
6187
+ if ((botBoxRecovery.length > 0 || botBoxDeletions.length > 0) && !box.boxConfigured(cfg)) {
5851
6188
  return deletionResponse(409, {
5852
- error: "remove this bot's VPS computer from Settings → Computers before deleting the bot",
6189
+ error: "Reconnect the Box account that owns this bot's remembered cloud computer, then retry deletion",
5853
6190
  });
5854
6191
  }
5855
- // LIST is eventually consistent, and a remembered Box may also have
5856
- // been renamed outside OpenMausBot. The create journal is stronger
5857
- // ownership evidence: inspect every durable id directly before the bot
5858
- // record that makes it discoverable can be removed. Missing credentials
5859
- // or an unavailable provider must fail closed.
5860
- for (const recovery of botBoxRecovery) {
5861
- if (!recovery.boxId) {
5862
- return deletionResponse(409, {
5863
- error: "finish reconciling this bot's pending cloud computer creation before deleting it",
5864
- });
5865
- }
5866
- const inspected = await box.inspectBoxIdentity(cfg, recovery.boxId);
5867
- if (!inspected.available) {
5868
- return deletionResponse(503, {
5869
- error: `${inspected.problem ?? "a remembered cloud computer could not be verified"}. Restore its Box account before deleting this bot`,
5870
- });
5871
- }
5872
- if (inspected.identity) {
5873
- return deletionResponse(409, {
5874
- error: "delete this bot's remembered cloud computer from Settings → Computers before deleting the bot",
5875
- });
5876
- }
5877
- // A direct 404/410 is authoritative even while account LIST catches
5878
- // up. Retire only this exact provider identity, then continue looking
5879
- // for any older name-based resource the journal never recorded.
5880
- retireDeletedBoxCreate(recovery.boxId);
5881
- }
5882
- // A Box survives destination/backend changes and contains browser
5883
- // sessions and files. Resolve ownership from a fresh provider listing;
5884
- // deleting the bot first would make that durable machine look orphaned.
5885
6192
  const cloudInventory = await box.listManagedBoxes(cfg, managedBoxOwners());
5886
6193
  if (cloudInventory.configured && !cloudInventory.available) {
5887
6194
  return deletionResponse(503, {
5888
- error: `${cloudInventory.problem ?? "cloud computer inventory is unavailable"}. Refresh Settings → Computers before deleting this bot`,
5889
- });
5890
- }
5891
- if (cloudInventory.instances.some((instance) => instance.ownerBotId === bot.id)) {
5892
- return deletionResponse(409, {
5893
- error: "delete this bot's cloud computer from Settings → Computers before deleting the bot",
6195
+ error: `${cloudInventory.problem ?? "cloud computer inventory is unavailable"}. The bot was kept so its computer can be retried safely`,
5894
6196
  });
5895
6197
  }
5896
- // Establish a durable cleanup intent before any teardown. A malformed
5897
- // or unreadable journal therefore rejects the delete with the bot and
5898
- // all of its live work untouched. The intent is aborted if a later
5899
- // pre-delete side effect fails, and committed only after Store deletion.
6198
+ const ownedBoxComputers = cloudInventory.instances.filter((instance) => instance.ownerBotId === bot.id);
6199
+ // Revalidate a reviewed Chief-of-Staff request and establish the
6200
+ // browser cleanup intent before the first irreversible provider
6201
+ // mutation. A stale review or damaged journal therefore leaves every
6202
+ // computer intact. Cross-provider rollback is impossible, so every
6203
+ // subsequent operation is exact and retry-safe.
5900
6204
  revalidate();
5901
6205
  const browserCleanupRequest = browserCleanup.prepare("bot", bot.id);
5902
6206
  try {
6207
+ // Provider-owned computers are durable, billable resources. Remove
6208
+ // each exact, freshly revalidated identity before making its bot
6209
+ // owner disappear. Shared team computers use a different owner id
6210
+ // and are intentionally absent from these lists.
6211
+ for (const instance of ownedBoxComputers) {
6212
+ const removed = await box.deleteManagedBox(cfg, managedBoxOwners(), instance.boxId, instance.name);
6213
+ if (removed.pending) {
6214
+ throw Object.assign(new Error("The cloud computer deletion has started but is still finishing. The bot was kept; retry in a moment"), { status: 409 });
6215
+ }
6216
+ }
6217
+ for (const instance of ownedVpsComputers) {
6218
+ await vps.removeManagedVpsComputer(cfg, managedBoxOwners(), instance.name, instance.name);
6219
+ }
6220
+ if (localVmCleanup) {
6221
+ if (localVmCleanup.removeContainer) {
6222
+ await containerComputerAction("remove", undefined, undefined, localVmCleanup.target);
6223
+ }
6224
+ // Unlike the standalone "Delete VM" action, deleting the bot is
6225
+ // a complete erasure: its now-ownerless desktop files and browser
6226
+ // session must not remain hidden on disk or block the event loop.
6227
+ await removeDirectory(localVmCleanup.target.workspaceDir, { recursive: true, force: true });
6228
+ localVmSeen.delete(localVmCleanup.target.key);
6229
+ localVmIdles.get(localVmCleanup.target.key)?.cancel();
6230
+ localVmIdles.delete(localVmCleanup.target.key);
6231
+ localVmLeases.forget(localVmCleanup.target.key);
6232
+ }
5903
6233
  // a running turn dies with its bot
5904
6234
  // Invalidate every bot-callable bearer before the first asynchronous
5905
6235
  // teardown step. A request that already passed its initial header
@@ -5913,7 +6243,6 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
5913
6243
  revokeInternalCapabilitiesForThread(task.threadId);
5914
6244
  }
5915
6245
  await interruptAllDirectThreads(bot.id);
5916
- revalidate();
5917
6246
  // Deletion removes the thread before a late turn.completed can fold
5918
6247
  // staged provider images into a message, so dispose them here.
5919
6248
  for (const task of store.tasks(bot.id)) {
@@ -5934,6 +6263,12 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
5934
6263
  const target = perBotLocalVmTarget(bot.id);
5935
6264
  localVmIdles.get(target.key)?.cancel();
5936
6265
  localVmIdles.delete(target.key);
6266
+ // Provider and local-computer teardown above can await for an
6267
+ // arbitrary amount of time. A reviewed Chief deletion is bound to
6268
+ // the exact target profile it presented; re-check that receipt at
6269
+ // the final durable mutation boundary so a concurrent profile edit
6270
+ // cannot be erased under a stale approval.
6271
+ revalidate();
5937
6272
  store.deleteBot(bot.id, setupRequest);
5938
6273
  // Removing schedules is not a security revocation. Keep them intact
5939
6274
  // if the bot/receipt write fails, so a failed deletion is retryable.
@@ -5969,6 +6304,8 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
5969
6304
  return deletionResponse(200, { ok: true });
5970
6305
  }
5971
6306
  finally {
6307
+ if (claimedLocalVmTarget)
6308
+ localVmLifecycleBusy.delete(claimedLocalVmTarget.key);
5972
6309
  releaseComputerLifecycle();
5973
6310
  releasePhoneSecretMutation();
5974
6311
  }
@@ -6470,6 +6807,7 @@ setupRetry = 0) {
6470
6807
  // A workspace at its monthly spend limit rechecks the cap at execution time
6471
6808
  // for every room, goal, queued, calendar, and chained-mention turn.
6472
6809
  assertWithinBudget(cfg, DATA_DIR);
6810
+ assertModelVariantSupported(preparedSelection, instance.adapter.capabilities);
6473
6811
  const integrations = {};
6474
6812
  const skillAuthoring = skillAuthoringEnabled(cfg) &&
6475
6813
  hop === 0 &&
@@ -6541,6 +6879,7 @@ setupRetry = 0) {
6541
6879
  readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
6542
6880
  readyBot.modelSelection.model !== preparedSelection.model ||
6543
6881
  readyBot.modelSelection.effort !== preparedSelection.effort ||
6882
+ readyBot.modelSelection.variant !== preparedSelection.variant ||
6544
6883
  readyBot.composio !== preparedComposio;
6545
6884
  if (setupChanged) {
6546
6885
  if (setupRetry === 0) {
@@ -6631,7 +6970,15 @@ setupRetry = 0) {
6631
6970
  readyBot.browser !== false &&
6632
6971
  instance.adapter.capabilities.browserMcp === true,
6633
6972
  });
6634
- if (roomPlan.browser) {
6973
+ // Channels currently mount a team Box or a Local VM. Do not let an
6974
+ // explicitly selected, unsupported destination become a tool-free turn
6975
+ // that can claim to have acted on that screen.
6976
+ if (!roomTeamComputer && (roomPlan.computer === "cloud" || roomPlan.computer === "local")) {
6977
+ throw new Error("This computer destination is not available in channels yet — open a bot thread to work on it, or use a Local VM, team computer, or Browser here");
6978
+ }
6979
+ // One place per room turn as well: a team computer reached on Auto means
6980
+ // no separate built-in browser.
6981
+ if (roomPlan.browser && !(roomPlan.computer === undefined && roomTeamComputer)) {
6635
6982
  const selectedProfile = readyBot.browserProfile;
6636
6983
  const browser = await browserIntegration(readyBot.id, selectedProfile, { threadId, generation: internalGeneration });
6637
6984
  if (browser)
@@ -6647,7 +6994,7 @@ setupRetry = 0) {
6647
6994
  return false;
6648
6995
  }
6649
6996
  if (roomTeamComputer) {
6650
- const attached = await attachTeamBox(roomTeamComputer, readyBot.id, resourceOwner, instance.adapter.capabilities.computerMcp === true || instance.driverKind === "boxAgent", instance.driverKind === "boxAgent");
6997
+ const attached = await attachTeamBox(roomTeamComputer, readyBot.id, resourceOwner, instance.driverKind === "boxAgent", instance.driverKind === "boxAgent");
6651
6998
  if (isCancelled?.() || groupSpeakers.get(threadId) !== roomSpeaker ||
6652
6999
  activeInternalGenerationByThread.get(threadId) !== internalGeneration)
6653
7000
  return false;
@@ -6760,14 +7107,32 @@ setupRetry = 0) {
6760
7107
  store.patchBot(bot.id, { soulDrift: drift.drift });
6761
7108
  }
6762
7109
  const roomMemory = memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace });
7110
+ // The same brief a 1:1 turn gets: what this member said lately in its
7111
+ // other conversations, so a standup is answered from what happened. A
7112
+ // brief can carry a private chat into the room; as with session_search
7113
+ // (#754) the room is told, once per source thread, rather than the
7114
+ // crossing being blocked.
7115
+ const recentLines = recentWork(store, bot, { userName, currentThreadId: threadId });
7116
+ {
7117
+ const crossing = claimRecallCrossings(threadId, recentLines.filter((line) => line.private).map((line) => line.threadId));
7118
+ if (crossing.count) {
7119
+ store.appendMessage(threadId, {
7120
+ role: "bot",
7121
+ kind: "activity",
7122
+ from: { botId: bot.id, name: bot.name, color: bot.color },
7123
+ tool: { name: briefCrossingLabel(bot.name, crossing.count), ok: true },
7124
+ });
7125
+ }
7126
+ }
6763
7127
  const roomSystem = buildSystemPrompt(system, store.bot(bot.id)?.soul ?? bot.soul ?? "", [
6764
7128
  { id: "files", label: "File locations", text: workspace ? workspaceLocationsPrompt(bot.id, cwd, readyBot.cwd) : "" },
6765
7129
  { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
6766
7130
  { id: "computer", label: "Computer", text: computerPrompt(roomTeamComputer ? instance.driverKind === "boxAgent" ? "box-agent" : "box" : roomVmTarget ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared" : null) },
6767
7131
  { id: "team-computer", label: "Team computer", text: teamComputerPrompt(roomTeamComputer) },
6768
- { id: "plan", label: "Surface", text: roomPlan.note },
7132
+ { id: "plan", label: "Surface", text: surfacePrompt({ computer: roomTeamComputer ? "cloud" : roomVmTarget ? "vm" : null, browser: Boolean(integrations.browser) }, { note: roomPlan.note }) },
6769
7133
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
6770
7134
  { id: "recall", label: "Recall", text: integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
7135
+ { id: "recent", label: "Recent work", text: recentWorkPrompt(recentLines) },
6771
7136
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
6772
7137
  // the room path has always put a newline before memory and trimmed
6773
7138
  // the block's leading space; keep that so existing prompts are
@@ -6881,7 +7246,7 @@ setupRetry = 0) {
6881
7246
  threadId,
6882
7247
  botId: readyBot.id,
6883
7248
  text,
6884
- refreshSystemPrompt: Boolean(orchestration?.roomHandoffId),
7249
+ refreshSystemPrompt: true,
6885
7250
  images: turnImages,
6886
7251
  approvalMode: approvalModeForTurn(readyBot, Boolean(orchestration?.roomHandoffId)),
6887
7252
  system: roomSystem.text,
@@ -6889,7 +7254,10 @@ setupRetry = 0) {
6889
7254
  systemVolatile: roomSystem.volatile,
6890
7255
  cwd,
6891
7256
  integrations,
6892
- ...memberTurnSelection(readyBot.modelSelection),
7257
+ mcpFromUserConfig: claudeUserMcpEnabled(cfg),
7258
+ ...(instance.instanceId === readyBot.modelSelection.instanceId
7259
+ ? memberTurnSelection(readyBot.modelSelection)
7260
+ : { model: instance.models.default }),
6893
7261
  }), () => abandoned || Boolean(isCancelled?.()), async () => {
6894
7262
  // Stop may have landed while the adapter was authenticating, before
6895
7263
  // it had an active process for the first interrupt to reach. Now that
@@ -7082,7 +7450,8 @@ setupRetry = 0) {
7082
7450
  if (providerDispatched)
7083
7451
  throw error;
7084
7452
  const isSpendCap = typeof error === "object" && error !== null && error.code === "spend_cap";
7085
- if (!roomSpeaker && !isSpendCap)
7453
+ const isVariantError = typeof error === "object" && error !== null && error.code === "unsupported_model_variant";
7454
+ if (!roomSpeaker && !isSpendCap && !isVariantError)
7086
7455
  throw error;
7087
7456
  const message = error instanceof Error ? error.message : "Local VM setup failed";
7088
7457
  store.appendMessage(threadId, {
@@ -8334,6 +8703,7 @@ function cliProbeEnvironment() {
8334
8703
  "COMPOSIO_API_KEY",
8335
8704
  "OMB_COMPOSIO_BROKER_TOKEN",
8336
8705
  "OMB_TTS_KEY",
8706
+ "OMB_FISH_AUDIO_API_KEY",
8337
8707
  "OMB_OPENAI_IMAGE_KEY",
8338
8708
  "OMB_CUSTOM_IMAGE_KEY",
8339
8709
  "ANTHROPIC_API_KEY",
@@ -8382,6 +8752,7 @@ async function localVmPayload(target) {
8382
8752
  */
8383
8753
  async function readyLocalVmForTurn(botId, target, isCurrent = () => true) {
8384
8754
  let status = await containerComputerStatus(undefined, undefined, target);
8755
+ noteLocalVmSeen(target, status);
8385
8756
  if (!isCurrent())
8386
8757
  return status;
8387
8758
  if (status.ready || !localVmRecreatableOnDemand(status))
@@ -8480,6 +8851,9 @@ function configStatus() {
8480
8851
  // shell and the Settings UI read it so they offer nothing this server
8481
8852
  // would refuse.
8482
8853
  sharedComputers: sharedComputersEnabled(cfg),
8854
+ // Plugins → MCP servers switch: Claude bots also see this machine's
8855
+ // own Claude Code MCP servers
8856
+ claudeUserMcp: claudeUserMcpEnabled(cfg),
8483
8857
  },
8484
8858
  // first-run progress — not a secret; the app decides whether to show
8485
8859
  // the welcome tour from this, never from browser storage
@@ -8517,6 +8891,20 @@ function configForAccess(status, admin) {
8517
8891
  function mcpServerResponse() {
8518
8892
  return { servers: listMcpServers(cfg.mcpServers) };
8519
8893
  }
8894
+ /** The fields a new server may set, in whichever shape the form sent — a
8895
+ * command to run or a URL to reach. Absent keys stay absent, so the strict
8896
+ * schema of one shape never sees the other shape's `undefined`s. */
8897
+ function mcpServerBody(body) {
8898
+ const out = {};
8899
+ if (!body || typeof body !== "object")
8900
+ return out;
8901
+ const record = body;
8902
+ for (const key of ["command", "args", "env", "type", "url", "headers", "enabled"]) {
8903
+ if (record[key] !== undefined)
8904
+ out[key] = record[key];
8905
+ }
8906
+ return out;
8907
+ }
8520
8908
  function persistMcpServers(next) {
8521
8909
  saveConfig({ mcpServers: next });
8522
8910
  // Do not reload the provider fleet: integrations are assembled from cfg at
@@ -8892,7 +9280,7 @@ const handleRequest = async (req, res) => {
8892
9280
  // Hosted workspaces have one sign-in authority. A missing optional layer
8893
9281
  // must not accidentally reactivate legacy email/QR credential minting.
8894
9282
  if (HOSTED_WORKSPACE) {
8895
- if (method === "POST" && ["/api/auth/pair", "/api/auth/pairing", "/api/auth/email/start", "/api/auth/email/verify"].includes(path)) {
9283
+ if (method === "POST" && ["/api/auth/pair", "/api/pair", "/api/auth/pairing", "/api/auth/email/start", "/api/auth/email/verify"].includes(path)) {
8896
9284
  return json(res, 403, { error: "Sign in through the workspace portal." });
8897
9285
  }
8898
9286
  if (workspaceAccess) {
@@ -8983,6 +9371,43 @@ const handleRequest = async (req, res) => {
8983
9371
  }
8984
9372
  return json(res, 200, { token: result.token, session: result.session, environment });
8985
9373
  }
9374
+ // The route the iOS and Android companion apps already POST to. Until now
9375
+ // only the desktop's companion sidecar answered it, so a self-hosted
9376
+ // server had nothing for a native phone to pair against: the app could
9377
+ // reach the server and pass its health probe, then ask for a credential
9378
+ // the server could not issue. Same window, same lockout and the same
9379
+ // single-use exchange as /api/auth/pair above; only the request and
9380
+ // response shapes differ, because the apps were written against the
9381
+ // sidecar. Public and unauthenticated for the same reason
9382
+ // /api/auth/pair is: redeeming a one-time credential IS the sign-in.
9383
+ if (method === "POST" && path === "/api/pair") {
9384
+ if (!/^application\/json\b/i.test(String(req.headers["content-type"] ?? ""))) {
9385
+ return json(res, 415, { error: "send the pairing credential as JSON (content-type: application/json)" });
9386
+ }
9387
+ const body = await readBody(req);
9388
+ const credential = typeof body?.credential === "string" ? body.credential : typeof body?.code === "string" ? body.code : "";
9389
+ const label = typeof body?.deviceName === "string" ? body.deviceName : "";
9390
+ const attemptId = typeof body?.pairRequestId === "string" ? body.pairRequestId : undefined;
9391
+ const paired = sessions.exchange({ code: credential, label, attemptId, source: requestSource(req), fallbackLabel: labelFromUserAgent(req.headers["user-agent"]) });
9392
+ if (!paired.ok) {
9393
+ console.warn(`pairing refused from ${requestSource(req)}: ${paired.error}`);
9394
+ return json(res, paired.status, { error: paired.error });
9395
+ }
9396
+ // The shape the companion apps decode (android/core Models.kt,
9397
+ // PairResponseSerializer): token, device and serverName are required;
9398
+ // hosts and endpoints are advisory and deliberately omitted, because a
9399
+ // harness has no sidecar endpoints to advertise.
9400
+ return json(res, 200, {
9401
+ token: paired.token,
9402
+ device: {
9403
+ id: paired.session.id,
9404
+ name: paired.session.label,
9405
+ createdAt: paired.session.createdAt,
9406
+ lastSeenAt: paired.session.lastSeenAt,
9407
+ },
9408
+ serverName: environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED }).label,
9409
+ });
9410
+ }
8986
9411
  const gate = resolveRequestAuth(req, {
8987
9412
  sessions,
8988
9413
  cookieName: SESSION_COOKIE,
@@ -9078,11 +9503,22 @@ const handleRequest = async (req, res) => {
9078
9503
  const origin = requestOrigin(req);
9079
9504
  const base = publicUrl() ?? (auth.kind === "session" && origin ? origin : null);
9080
9505
  const code = formatPairingCode(opened.code);
9506
+ // Two links for one window. `url` opens the web app and is what a
9507
+ // browser and the iOS app already read. `inviteUrl` is the custom
9508
+ // scheme the native companion scanners accept; it carries the
9509
+ // credential encoding because those scanners cannot take a typed code.
9510
+ const serverName = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED }).label;
9511
+ const invite = base
9512
+ ? `openmausbot://pair?address=${encodeURIComponent(base)}&token=${encodeURIComponent(opened.credential)}&name=${encodeURIComponent(serverName)}`
9513
+ : null;
9081
9514
  return json(res, 200, {
9082
9515
  id: opened.id,
9083
9516
  code,
9517
+ credential: opened.credential,
9084
9518
  expiresAt: opened.expiresAt,
9085
9519
  url: base ? `${base}/pair#code=${code}` : null,
9520
+ inviteUrl: invite,
9521
+ serverName,
9086
9522
  hint: base
9087
9523
  ? null
9088
9524
  : "this server has no public address to put in a link: set OMB_PUBLIC_URL, or open /pair on the address you use and type the code",
@@ -9302,6 +9738,43 @@ const handleRequest = async (req, res) => {
9302
9738
  task: store.taskByThread(internalSender.id, internalCapability.threadId),
9303
9739
  threadId: internalCapability.threadId,
9304
9740
  });
9741
+ if (path === "/api/internal/computer/select" && (method === "GET" || method === "POST")) {
9742
+ const source = computerSelectionTurns.get(internalCapability.threadId);
9743
+ const bot = store.projectBotForTask(internalSender.id, internalCapability.threadId);
9744
+ const canSelect = Boolean(source && source.generation === internalCapability.generation && bot && bot.computer !== "off");
9745
+ if (!bot)
9746
+ return json(res, 403, { error: "Computer selection belongs to a direct bot conversation." });
9747
+ const requested = method === "POST" ? (await readInternalBody()).surface : undefined;
9748
+ if (method === "POST" && !canSelect)
9749
+ return json(res, 403, { error: "Computer selection is only available once per direct user request, with computer access enabled." });
9750
+ if (method === "POST" && requested !== "auto" && !parseSurface(requested)) {
9751
+ return json(res, 400, { error: "surface must be auto, cloud, vm, local, or browser" });
9752
+ }
9753
+ const options = await selectableComputers(bot);
9754
+ const current = source ? source.mounted ?? "off" : await computerPreviewSurface(bot, bot.threadId);
9755
+ requireActiveInternalCapability();
9756
+ if (method === "GET")
9757
+ return json(res, 200, { current, canSelect, options });
9758
+ if (computerSelectionTurns.get(internalCapability.threadId) !== source)
9759
+ return json(res, 409, { error: "The user request ended before its computer was selected." });
9760
+ const option = requested === "auto"
9761
+ ? options.find(option => option.ready && option.surface === current) ?? options.find(option => option.ready && option.surface === "vm") ?? options.find(option => option.ready) ?? options.find(option => option.canStart) ?? options.find(option => option.canCreate && option.surface === "vm") ?? options.find(option => option.canCreate)
9762
+ : options.find(option => option.surface === requested);
9763
+ if (!option?.available)
9764
+ return json(res, 409, { error: option?.reason ?? "No configured computer or browser is available. Open the Computer panel to set one up.", options });
9765
+ if (source.selected) {
9766
+ if (source.selected !== option.surface)
9767
+ return json(res, 409, { error: "A computer switch is already pending. End this turn to continue there." });
9768
+ }
9769
+ else {
9770
+ if (option.surface === current && option.ready)
9771
+ return json(res, 200, { status: "ready", surface: current, message: "This computer is already selected. Use its mounted tools." });
9772
+ source.selected = option.surface;
9773
+ source.previousSurface = store.taskByThread(bot.id, bot.threadId)?.surface;
9774
+ }
9775
+ return json(res, 200, { status: "pending", surface: option.surface,
9776
+ message: `End this turn now without using the previous computer tools. OpenMausBot will continue the original request on ${option.label} with a fresh tool connection.` });
9777
+ }
9305
9778
  if (method === "POST" && path === "/api/internal/memory") {
9306
9779
  const body = await readInternalBody();
9307
9780
  const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText }, { source: memorySource() });
@@ -9694,30 +10167,64 @@ const handleRequest = async (req, res) => {
9694
10167
  return json(res, 403, { error: "source conversation does not belong to sender" });
9695
10168
  }
9696
10169
  const q = String(url.searchParams.get("q") ?? "").trim();
9697
- if (!q)
9698
- return json(res, 400, { error: "q is required" });
10170
+ // A recall by time needs no words: "what happened since yesterday"
10171
+ // is the standup question, and it has no keyword.
10172
+ const now = Date.now();
10173
+ const sinceRaw = url.searchParams.get("since");
10174
+ const untilRaw = url.searchParams.get("until");
10175
+ const since = sinceRaw ? parseSince(sinceRaw, now) : null;
10176
+ const until = untilRaw ? parseSince(untilRaw, now) : null;
10177
+ if (sinceRaw && since === null)
10178
+ return json(res, 400, { error: "since must be a date, or a span like 24h, 3d, today, yesterday" });
10179
+ if (untilRaw && until === null)
10180
+ return json(res, 400, { error: "until must be a date, or a span like 24h, 3d, today, yesterday" });
10181
+ if (!q && since === null)
10182
+ return json(res, 400, { error: "q or since is required" });
10183
+ const range = since !== null || until !== null
10184
+ ? { ...(since !== null ? { since } : {}), ...(until !== null ? { until } : {}) }
10185
+ : undefined;
9699
10186
  const rawLimit = Number(url.searchParams.get("limit"));
9700
10187
  const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.trunc(rawLimit), 25) : 12;
9701
10188
  // Memory files ride along by default: the bot's own notes are as
9702
10189
  // much its notebook as its transcripts, and scoped the same way —
9703
- // the caller's own bot, never another's.
10190
+ // the caller's own bot, never another's. A recall by time alone
10191
+ // has no words to match a file with.
9704
10192
  const scope = url.searchParams.get("scope") ?? "all";
9705
10193
  if (scope !== "all" && scope !== "conversations" && scope !== "memory") {
9706
10194
  return json(res, 400, { error: "scope must be all, conversations, or memory" });
9707
10195
  }
9708
- const memoryHits = scope === "conversations" ? [] : searchMemoryFiles(from.id, q, limit);
10196
+ const memoryHits = scope === "conversations" || !q ? [] : searchMemoryFiles(from.id, q, limit);
9709
10197
  if (scope === "memory")
9710
10198
  return json(res, 200, { hits: [], memoryHits });
9711
- const ownThreads = [...new Set([from.threadId, ...(from.tasks ?? []).map((task) => task.threadId)])];
9712
- // A room is the only place a recall can be a disclosure: in a 1:1 the
9713
- // user already owns every thread the bot can reach.
10199
+ // Own threads: the bot's main chat and tasks, and the rooms it is a
10200
+ // member of with their tasks — conversations it already saw in full.
10201
+ // Still own-bot: another bot's threads never enter this list.
10202
+ const roomByThread = new Map();
10203
+ for (const group of store.groups) {
10204
+ if (!group.memberIds.includes(from.id))
10205
+ continue;
10206
+ roomByThread.set(group.threadId, group);
10207
+ for (const task of group.tasks ?? [])
10208
+ roomByThread.set(task.threadId, group);
10209
+ }
10210
+ const ownThreads = [...new Set([from.threadId, ...(from.tasks ?? []).map((task) => task.threadId), ...roomByThread.keys()])];
10211
+ // A room is the only place a recall can be a disclosure, and only a
10212
+ // private chat is one: in a 1:1 the user already owns every thread
10213
+ // the bot can reach, and a room's lines were said in the open.
9714
10214
  const inRoom = Boolean(store.groupByThread(fromThreadId));
9715
- const hits = recallMessages(q, ownThreads, limit).map((hit) => ({
9716
- ...hit,
9717
- task: store.taskByThread(from.id, hit.threadId)?.title,
9718
- current: hit.threadId === fromThreadId,
9719
- crossed: inRoom && hit.threadId !== fromThreadId,
9720
- }));
10215
+ const found = q ? recallMessages(q, ownThreads, limit, range) : recentMessages(ownThreads, range ?? {}, limit);
10216
+ const hits = found.map((hit) => {
10217
+ const room = roomByThread.get(hit.threadId);
10218
+ return {
10219
+ ...hit,
10220
+ task: room
10221
+ ? (room.tasks ?? []).find((task) => task.threadId === hit.threadId)?.title
10222
+ : store.taskByThread(from.id, hit.threadId)?.title,
10223
+ ...(room ? { room: room.name } : {}),
10224
+ current: hit.threadId === fromThreadId,
10225
+ crossed: inRoom && !room && hit.threadId !== fromThreadId,
10226
+ };
10227
+ });
9721
10228
  if (inRoom) {
9722
10229
  discloseRecall(from, fromThreadId, hits.filter((hit) => hit.crossed).map((hit) => hit.threadId));
9723
10230
  }
@@ -10723,7 +11230,9 @@ const handleRequest = async (req, res) => {
10723
11230
  // where the work is, and say which room it was.
10724
11231
  const roomTurn = activeGroupTurnForBot(bot.id);
10725
11232
  const target = blockedTarget({ ...bot, threadId: internalCapability.threadId }, roomTurn && { ...roomTurn.group, threadId: roomTurn.threadId });
10726
- notify(buildNotification("takeover", bot, target.threadId, snapshot.helpReason ?? "asked you to take over", {
11233
+ const helpPlace = store.taskByThread(bot.id, internalCapability.threadId)?.surface ?? bot.computer;
11234
+ const helpWhere = helpPlace && helpPlace !== "off" ? ` on ${surfaceLabel(helpPlace)}` : "";
11235
+ notify(buildNotification("takeover", bot, target.threadId, `${snapshot.helpReason ?? "asked you to take over"}${helpWhere}`, {
10727
11236
  group: target.group,
10728
11237
  }));
10729
11238
  return json(res, 200, { held: snapshot.held, helpOpen: snapshot.helpReason !== null, requestId });
@@ -10852,6 +11361,11 @@ const handleRequest = async (req, res) => {
10852
11361
  if (path === "/api/routines" && method === "POST") {
10853
11362
  return json(res, 201, { routine: routines.create(await readBody(req)) });
10854
11363
  }
11364
+ // The desktop shell polls this to decide whether to hold the computer
11365
+ // awake: a run in flight, or a routine due within the hour.
11366
+ if (path === "/api/routines/wake" && method === "GET") {
11367
+ return json(res, 200, routines.wakeHold());
11368
+ }
10855
11369
  let routineMatch = path.match(/^\/api\/routines\/([\w-]+)\/run$/);
10856
11370
  if (routineMatch && method === "POST") {
10857
11371
  const run = routines.runNow(routineMatch[1]);
@@ -11084,7 +11598,7 @@ const handleRequest = async (req, res) => {
11084
11598
  if (threadId === undefined &&
11085
11599
  (auth.kind === "session" || req.headers["x-openmausbot-companion"] === "1") &&
11086
11600
  store.tasks(botId).length > 1) {
11087
- throw Object.assign(new Error("This bot has multiple threads. Update this client and choose a thread before sending this action."), { status: 409 });
11601
+ throw Object.assign(new Error("This bot has more than one thread. Update the OpenMausBot app on this device, then choose a thread and try again."), { status: 409 });
11088
11602
  }
11089
11603
  };
11090
11604
  if (method === "GET" && path === "/api/bots") {
@@ -12352,7 +12866,7 @@ const handleRequest = async (req, res) => {
12352
12866
  if (m && method === "PATCH") {
12353
12867
  const body = await readBody(req);
12354
12868
  if (body && typeof body === "object" && !Array.isArray(body)) {
12355
- const unsupported = Object.keys(body).find((key) => key !== "instanceId" && key !== "model" && key !== "effort");
12869
+ const unsupported = Object.keys(body).find((key) => key !== "instanceId" && key !== "model" && key !== "effort" && key !== "variant");
12356
12870
  if (unsupported)
12357
12871
  return json(res, 400, { error: `unsupported model field: ${unsupported}` });
12358
12872
  }
@@ -13497,7 +14011,7 @@ const handleRequest = async (req, res) => {
13497
14011
  // message intact for the next ordinary turn, where central image
13498
14012
  // admission can hand it to the provider natively.
13499
14013
  const carriesImages = extractTurnImages(text).images.length > 0;
13500
- if (!carriesImages && instance?.adapter.capabilities.queueing && instance.adapter.steer) {
14014
+ if (!carriesImages && !computerSelectionTurns.get(threadId)?.selected && instance?.adapter.capabilities.queueing && instance.adapter.steer) {
13501
14015
  steered = await instance.adapter
13502
14016
  .steer(threadId, promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"))
13503
14017
  .catch(() => false);
@@ -13926,7 +14440,7 @@ const handleRequest = async (req, res) => {
13926
14440
  const current = store.projectBotForTask(m[1], m[2]);
13927
14441
  if (!current)
13928
14442
  return json(res, 404, { error: "no such task" });
13929
- const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt"]);
14443
+ const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt", "surface"]);
13930
14444
  if (Object.keys(body).some((key) => !allowed.has(key)))
13931
14445
  return json(res, 400, { error: "unsupported thread setting" });
13932
14446
  for (const key of ["requireAvailableModel", "acknowledgeLocalAuto", "updateBotDefault", "resetApprovalToAsk"]) {
@@ -13963,6 +14477,18 @@ const handleRequest = async (req, res) => {
13963
14477
  else
13964
14478
  return json(res, 400, { error: "archivedAt must be a timestamp, or null to unarchive" });
13965
14479
  }
14480
+ if (body.surface !== undefined) {
14481
+ if (threadBusy(current.id, current.threadId))
14482
+ return json(res, 409, { error: "Stop this thread before changing its computer destination." });
14483
+ // Where this conversation works, chosen from the composer. Null follows
14484
+ // the bot's Works on again. Reachability is the turn's to judge.
14485
+ if (body.surface === null)
14486
+ patch.surface = undefined;
14487
+ else if (parseSurface(body.surface))
14488
+ patch.surface = parseSurface(body.surface);
14489
+ else
14490
+ return json(res, 400, { error: "surface must be cloud, vm, local, browser, or null to follow the bot" });
14491
+ }
13966
14492
  if (body.pinnedMessageId !== undefined) {
13967
14493
  if (body.pinnedMessageId === null || body.pinnedMessageId === "")
13968
14494
  patch.pinnedMessageId = undefined;
@@ -14288,7 +14814,7 @@ const handleRequest = async (req, res) => {
14288
14814
  }
14289
14815
  m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer$/);
14290
14816
  if (m && method === "GET") {
14291
- const bot = store.bot(m[1]);
14817
+ const bot = computerPreviewBot(m[1], url);
14292
14818
  if (!bot)
14293
14819
  return json(res, 404, { error: "no such bot" });
14294
14820
  return json(res, 200, await localVmPayload(localVmTargetForBot(bot.id)));
@@ -14358,9 +14884,12 @@ const handleRequest = async (req, res) => {
14358
14884
  }
14359
14885
  m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer\/screenshot$/);
14360
14886
  if (m && method === "POST") {
14361
- const bot = store.bot(m[1]);
14887
+ const bot = computerPreviewBot(m[1], url);
14362
14888
  if (!bot)
14363
14889
  return json(res, 404, { error: "no such bot" });
14890
+ if (url.searchParams.has("threadId") && await computerPreviewSurface(bot, bot.threadId) !== "vm") {
14891
+ return json(res, 409, { error: "This conversation is not using the Local VM" });
14892
+ }
14364
14893
  const target = localVmTargetForBot(bot.id);
14365
14894
  localVmIdleFor(target).touch();
14366
14895
  return json(res, 200, {
@@ -14784,7 +15313,7 @@ const handleRequest = async (req, res) => {
14784
15313
  providerConfigBusy = false;
14785
15314
  }
14786
15315
  }
14787
- // ── custom MCP servers (stdio, local, secrets write-only) ──
15316
+ // ── custom MCP servers (a local command or a URL; secrets write-only) ──
14788
15317
  if (method === "GET" && path === "/api/mcp/servers") {
14789
15318
  return json(res, 200, mcpServerResponse());
14790
15319
  }
@@ -14830,12 +15359,7 @@ const handleRequest = async (req, res) => {
14830
15359
  if (Object.keys(current).length >= MAX_MCP_SERVERS) {
14831
15360
  return json(res, 400, { error: `You can add at most ${MAX_MCP_SERVERS} MCP servers.` });
14832
15361
  }
14833
- const parsed = parseMcpServerMutation(name, {
14834
- command: body?.command,
14835
- args: body?.args,
14836
- env: body?.env,
14837
- enabled: body?.enabled,
14838
- });
15362
+ const parsed = parseMcpServerMutation(name, mcpServerBody(body));
14839
15363
  if (!parsed.ok)
14840
15364
  return json(res, 400, { error: parsed.error });
14841
15365
  persistMcpServers({ ...current, [name]: parsed.server });
@@ -14931,6 +15455,14 @@ const handleRequest = async (req, res) => {
14931
15455
  const patch = parseConfigPatch(body);
14932
15456
  if (!Object.keys(patch).length)
14933
15457
  return json(res, 400, { error: "nothing to save" });
15458
+ const changingVoiceProvider = patch.tts?.provider !== undefined
15459
+ && patch.tts.provider !== tts.voiceProvider(cfg);
15460
+ if (changingVoiceProvider && patch.tts?.voice === undefined) {
15461
+ // Voice ids are provider-owned opaque values. Never carry a default
15462
+ // from one catalog into another. A caller may explicitly supply a
15463
+ // voice for the newly selected provider in this same atomic patch.
15464
+ patch.tts = { ...patch.tts, voice: "" };
15465
+ }
14934
15466
  if (providerConfigBusy)
14935
15467
  return json(res, 409, { error: "provider settings are already being updated" });
14936
15468
  if (patch.browserProfiles !== undefined && body.expectedBrowserProfiles !== undefined) {
@@ -15014,42 +15546,83 @@ const handleRequest = async (req, res) => {
15014
15546
  if (resourceError)
15015
15547
  return json(res, 409, { error: resourceError });
15016
15548
  }
15017
- const boxRecovery = changingBoxToken ? boxCreateRecoverySnapshot() : [];
15549
+ let boxRecovery = changingBoxToken ? boxCreateRecoverySnapshot() : [];
15550
+ let boxDeletions = changingBoxToken ? boxDeletionSnapshot() : [];
15551
+ if (changingBoxToken && boxDeletions.length > 0 && !nextBoxToken) {
15552
+ return json(res, 409, {
15553
+ error: "finish or retry pending cloud computer deletion before removing the Box account",
15554
+ });
15555
+ }
15556
+ let replacementProvedByDeletion = false;
15557
+ if (changingBoxToken && boxDeletions.length > 0 && nextBoxToken) {
15558
+ try {
15559
+ await box.verifyBoxDeletionCredential({ box: { token: nextBoxToken } });
15560
+ replacementProvedByDeletion = true;
15561
+ // Verification can observe a completed operation and retire both
15562
+ // its deletion fence and matching create receipt. Never continue
15563
+ // with the pre-verification snapshots: they would demand access
15564
+ // to a Box whose exact operation just proved it was deleted.
15565
+ boxRecovery = boxCreateRecoverySnapshot();
15566
+ boxDeletions = boxDeletionSnapshot();
15567
+ }
15568
+ catch (error) {
15569
+ return json(res, error?.status ?? 503, {
15570
+ error: error instanceof Error ? error.message : String(error),
15571
+ });
15572
+ }
15573
+ }
15018
15574
  let currentBoxInventory = null;
15019
15575
  let currentBoxResources = null;
15020
15576
  const journalBoxResources = [];
15577
+ const deletingBoxIds = new Set(boxDeletions.map((entry) => entry.boxId));
15021
15578
  if (changingBoxToken && currentBoxToken) {
15022
15579
  currentBoxInventory = await box.listManagedBoxes({ box: { token: currentBoxToken } }, managedBoxOwners());
15023
15580
  if (!currentBoxInventory.available) {
15024
- return json(res, 503, {
15025
- error: `${currentBoxInventory.problem ?? "cloud computer inventory is unavailable"}. Keep the current Box account and retry`,
15026
- });
15027
- }
15028
- const currentById = new Map(currentBoxInventory.instances.map((instance) => [instance.boxId, { boxId: instance.boxId, name: instance.name }]));
15029
- for (const recovery of boxRecovery) {
15030
- if (!recovery.boxId)
15031
- continue;
15032
- const inspected = await box.inspectBoxIdentity({ box: { token: currentBoxToken } }, recovery.boxId);
15033
- if (!inspected.available) {
15581
+ if (!replacementProvedByDeletion) {
15034
15582
  return json(res, 503, {
15035
- error: `${inspected.problem ?? "a remembered cloud computer could not be verified"}. Keep the current Box account and retry`,
15583
+ error: `${currentBoxInventory.problem ?? "cloud computer inventory is unavailable"}. Keep the current Box account and retry`,
15036
15584
  });
15037
15585
  }
15038
- if (!inspected.identity) {
15039
- // Reconcile exact stale receipts while the current credential is
15040
- // still available. Leaving one behind would make a later token
15041
- // addition demand access to a Box the provider proved is gone.
15042
- retireDeletedBoxCreate(recovery.boxId);
15043
- continue;
15044
- }
15045
- const listed = currentById.get(inspected.identity.boxId);
15046
- if (listed && listed.name !== inspected.identity.name) {
15047
- return json(res, 503, { error: "ascii.dev returned conflicting cloud computer identities; keep the current Box account and retry" });
15586
+ // The old token may be the reason this deletion is stuck. A
15587
+ // target-bound operation/identity proved the replacement belongs
15588
+ // to the same account, so do not deadlock credential recovery on
15589
+ // an inventory request made with the expired token.
15590
+ currentBoxInventory = null;
15591
+ }
15592
+ if (currentBoxInventory) {
15593
+ const currentById = new Map(currentBoxInventory.instances.map((instance) => [instance.boxId, { boxId: instance.boxId, name: instance.name }]));
15594
+ for (const recovery of boxRecovery) {
15595
+ if (!recovery.boxId)
15596
+ continue;
15597
+ // A failed provisioning attempt may never have reached the
15598
+ // deterministic rename. The replacement credential already
15599
+ // proved the exact deletion target, so its in-flight resource
15600
+ // is governed by that stronger target-bound receipt rather
15601
+ // than an OpenMausBot name check.
15602
+ if (replacementProvedByDeletion && deletingBoxIds.has(recovery.boxId))
15603
+ continue;
15604
+ const inspected = await box.inspectBoxIdentity({ box: { token: currentBoxToken } }, recovery.boxId);
15605
+ if (!inspected.available) {
15606
+ return json(res, 503, {
15607
+ error: `${inspected.problem ?? "a remembered cloud computer could not be verified"}. Keep the current Box account and retry`,
15608
+ });
15609
+ }
15610
+ if (!inspected.identity) {
15611
+ // Reconcile exact stale receipts while the current credential is
15612
+ // still available. Leaving one behind would make a later token
15613
+ // addition demand access to a Box the provider proved is gone.
15614
+ retireDeletedBoxCreate(recovery.boxId);
15615
+ continue;
15616
+ }
15617
+ const listed = currentById.get(inspected.identity.boxId);
15618
+ if (listed && listed.name !== inspected.identity.name) {
15619
+ return json(res, 503, { error: "ascii.dev returned conflicting cloud computer identities; keep the current Box account and retry" });
15620
+ }
15621
+ currentById.set(inspected.identity.boxId, inspected.identity);
15622
+ journalBoxResources.push(inspected.identity);
15048
15623
  }
15049
- currentById.set(inspected.identity.boxId, inspected.identity);
15050
- journalBoxResources.push(inspected.identity);
15624
+ currentBoxResources = [...currentById.values()];
15051
15625
  }
15052
- currentBoxResources = [...currentById.values()];
15053
15626
  }
15054
15627
  if (changingLocalVmMode) {
15055
15628
  if (localVmActiveThreads.size > 0 || localVmLifecycleBusy.size > 0 || localVmImageBusy) {
@@ -15096,7 +15669,7 @@ const handleRequest = async (req, res) => {
15096
15669
  if (!check.ok)
15097
15670
  return json(res, 400, { error: check.message });
15098
15671
  }
15099
- if (changingBoxToken && !currentBoxToken && boxRecovery.length > 0) {
15672
+ if (changingBoxToken && (!currentBoxToken || replacementProvedByDeletion) && boxRecovery.length > 0) {
15100
15673
  if (!nextBoxToken) {
15101
15674
  return json(res, 409, { error: "restore the Box account that owns the remembered cloud computers before clearing it" });
15102
15675
  }
@@ -15104,6 +15677,8 @@ const handleRequest = async (req, res) => {
15104
15677
  if (!recovery.boxId) {
15105
15678
  return json(res, 409, { error: "finish reconciling pending cloud computer creation before changing the Box account" });
15106
15679
  }
15680
+ if (replacementProvedByDeletion && deletingBoxIds.has(recovery.boxId))
15681
+ continue;
15107
15682
  const inspected = await box.inspectBoxIdentity({ box: { token: nextBoxToken } }, recovery.boxId);
15108
15683
  if (!inspected.available) {
15109
15684
  return json(res, 503, {
@@ -15147,15 +15722,17 @@ const handleRequest = async (req, res) => {
15147
15722
  if (resourceError)
15148
15723
  return json(res, 409, { error: resourceError });
15149
15724
  }
15150
- // same rule for a voice key — and check it against the provider the
15151
- // patch SELECTS, not the one already saved, or pasting a Cartesia key
15152
- // while switching from ElevenLabs validates against the wrong service
15725
+ // Each cloud voice provider owns its own credential. Validate the field
15726
+ // against that provider rather than whichever provider happens to be
15727
+ // selected, so switching engines never sends one service another's key.
15153
15728
  const newTts = patch.tts;
15154
- // Chatterbox has no key at all — its credential is a local server
15155
- // address, and an ElevenLabs round trip would be the wrong test
15156
- const selectedVoiceProvider = newTts?.provider ?? cfg.tts?.provider ?? "elevenlabs";
15157
- if (newTts?.key?.trim() && selectedVoiceProvider !== "chatterbox") {
15158
- const check = await tts.verifyKey(newTts.key.trim());
15729
+ if (newTts?.key?.trim()) {
15730
+ const check = await tts.verifyKey("elevenlabs", newTts.key.trim());
15731
+ if (!check.ok)
15732
+ return json(res, 400, { error: check.message });
15733
+ }
15734
+ if (newTts?.fishKey?.trim()) {
15735
+ const check = await tts.verifyKey("fish", newTts.fishKey.trim());
15159
15736
  if (!check.ok)
15160
15737
  return json(res, 400, { error: check.message });
15161
15738
  }
@@ -15200,6 +15777,13 @@ const handleRequest = async (req, res) => {
15200
15777
  let configWriteCommitted = false;
15201
15778
  const externalSecretStorage = url.searchParams.get("secretStorage") === "external";
15202
15779
  try {
15780
+ // Provider-owned voice ids must be invalidated before the provider
15781
+ // commit. If bots.json cannot be written, leave the old provider in
15782
+ // place rather than committing a new provider with stale bot voices.
15783
+ // A later config-write failure may leave voices cleared, which is the
15784
+ // safe side of this cross-file mutation: no foreign id can be spoken.
15785
+ if (changingVoiceProvider)
15786
+ store.clearVoiceSelections();
15203
15787
  if (externalSecretStorage) {
15204
15788
  // The packaged Electron caller commits supplied credentials to the
15205
15789
  // OS-encrypted store before entering this route. Persist every
@@ -15217,6 +15801,8 @@ const handleRequest = async (req, res) => {
15217
15801
  persisted.opencodeGo.apiKey = "";
15218
15802
  if (persisted.tts?.key !== undefined)
15219
15803
  persisted.tts.key = "";
15804
+ if (persisted.tts?.fishKey !== undefined)
15805
+ persisted.tts.fishKey = "";
15220
15806
  if (persisted.imageGen?.key !== undefined)
15221
15807
  persisted.imageGen.key = "";
15222
15808
  if (persisted.imageGen?.customApiKey !== undefined)
@@ -15582,15 +16168,18 @@ const handleRequest = async (req, res) => {
15582
16168
  // ── the bot's cloud computer (Box) ──
15583
16169
  m = path.match(/^\/api\/bots\/([\w-]+)\/computer$/);
15584
16170
  if (m && method === "GET") {
15585
- const bot = store.bot(m[1]);
16171
+ const bot = computerPreviewBot(m[1], url);
15586
16172
  if (!bot)
15587
16173
  return json(res, 404, { error: "no such bot" });
16174
+ const surface = url.searchParams.has("threadId") ? await computerPreviewSurface(bot, bot.threadId) : "cloud";
16175
+ if (surface !== "cloud")
16176
+ return json(res, 200, { surface, configured: false, backend: bot.cloudBackend === "vps" ? "vps" : "box" });
15588
16177
  const teamComputer = inheritedTeamComputer(bot);
15589
16178
  if (teamComputer)
15590
- return json(res, 200, { backend: "box", teamComputer: { id: teamComputer.id, name: teamComputer.name }, ...(await box.boxStatus(cfg, teamComputerOwner(teamComputer.id))) });
16179
+ return json(res, 200, { surface, backend: "box", teamComputer: { id: teamComputer.id, name: teamComputer.name }, ...(await box.boxStatus(cfg, teamComputerOwner(teamComputer.id))) });
15591
16180
  return bot.cloudBackend === "vps"
15592
- ? json(res, 200, { backend: "vps", ...(await vps.vpsComputerStatus(cfg, bot.id)) })
15593
- : json(res, 200, { backend: "box", ...(await box.boxStatus(cfg, bot.id)) });
16181
+ ? json(res, 200, { surface, backend: "vps", ...(await vps.vpsComputerStatus(cfg, bot.id)) })
16182
+ : json(res, 200, { surface, backend: "box", ...(await box.boxStatus(cfg, bot.id)) });
15594
16183
  }
15595
16184
  // Who is driving this bot's computer. GET is the panel's initial read;
15596
16185
  // POST take/release/dismiss-help are the person's three moves. The bot
@@ -15651,7 +16240,7 @@ const handleRequest = async (req, res) => {
15651
16240
  }
15652
16241
  m = path.match(/^\/api\/bots\/([\w-]+)\/computer\/viewer-close$/);
15653
16242
  if (m && method === "POST") {
15654
- const bot = store.bot(m[1]);
16243
+ const bot = computerPreviewBot(m[1], url);
15655
16244
  if (!bot)
15656
16245
  return json(res, 404, { error: "no such bot" });
15657
16246
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
@@ -15662,9 +16251,14 @@ const handleRequest = async (req, res) => {
15662
16251
  m = path.match(/^\/api\/bots\/([\w-]+)\/computer\/(provision|join|sleep|exec|screenshot|remove)$/);
15663
16252
  if (m && method === "POST") {
15664
16253
  const botId = m[1];
15665
- const bot = store.bot(botId);
16254
+ const previewOnly = m[2] === "screenshot" || m[2] === "join";
16255
+ const bot = previewOnly ? computerPreviewBot(botId, url) : store.bot(botId);
15666
16256
  if (!bot)
15667
16257
  return json(res, 404, { error: "no such bot" });
16258
+ const threadPreview = previewOnly && url.searchParams.has("threadId");
16259
+ if (threadPreview && await computerPreviewSurface(bot, bot.threadId) !== "cloud") {
16260
+ return json(res, 409, { error: "This conversation is not using the cloud computer" });
16261
+ }
15668
16262
  // Requiring JSON makes every computer mutation a non-simple browser
15669
16263
  // request (same reasoning as the Local VM lifecycle routes above): a
15670
16264
  // hostile page cannot submit it with a form, and its cross-origin JSON
@@ -15756,7 +16350,7 @@ const handleRequest = async (req, res) => {
15756
16350
  });
15757
16351
  }
15758
16352
  }
15759
- if (bot.computer !== "cloud") {
16353
+ if (bot.computer !== "cloud" && !threadPreview) {
15760
16354
  return json(res, 409, {
15761
16355
  error: "Choose Cloud before changing or opening this Box. Auto only checks existing computer state.",
15762
16356
  });
@@ -15771,7 +16365,7 @@ const handleRequest = async (req, res) => {
15771
16365
  case "provision":
15772
16366
  return json(res, 200, await box.provisionBox(cfg, botId, bot.name));
15773
16367
  case "join":
15774
- return json(res, 200, await (activeBoxTurn ? box.joinReadyBox(cfg, botId) : box.joinBox(cfg, botId)));
16368
+ return json(res, 200, await (activeBoxTurn || threadPreview ? box.joinReadyBox(cfg, botId) : box.joinBox(cfg, botId)));
15775
16369
  case "sleep":
15776
16370
  return json(res, 200, await box.sleepBox(cfg, botId));
15777
16371
  case "exec":