openmausbot 0.1.92 → 0.1.93

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 (104) hide show
  1. package/dist/assets/{DesktopViewer-Do6lBa4C.js → DesktopViewer-CKNR3SEW.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-Cd3X7Hxc.js → abnfDiagram-O67JEVCF-Ckrm1I1X.js} +1 -1
  3. package/dist/assets/{arc-DIYc7Ymx.js → arc-BBdBuCgB.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-DBmRGVyP.js → architectureDiagram-NJMV4G6O-CDdA3Hjs.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CPFePSEE.js → blockDiagram-BEXU5L5S-AUrsx1YR.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-DD_DO1qy.js → c4Diagram-YGBWAQC7-BEqQ0Unh.js} +1 -1
  7. package/dist/assets/channel-BU94VpxE.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-BNEeNSjX.js → chunk-2Q5K7J3B-CEEuvd4C.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BbinLaqH.js → chunk-3FUC2YCW-DyANCiHl.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-BBgYrgDR.js → chunk-5VM5RSS4-Dx_Slpw9.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-ezXj-kA-.js → chunk-GTNCS2PH-Dl7h7iiD.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-9FxZ3diV.js → chunk-GWA4HPMP-BLx07SPb.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-CO-40JpI.js → chunk-JWPE2WC7-BMxDj3n7.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-BEhjslYE.js → chunk-NETBCI7D-CN66PT4H.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CNeq4n7_.js → chunk-WEXAMYUT-6HwhsDKw.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-OsCY7KSi.js → chunk-XXDRQBXY-gWnLt7SN.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-CloRUEtk.js → classDiagram-v2-NBCMYWYE-CMS5l2ea.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-D1BGZhp4.js → cose-bilkent-JH36ORCC-CV7sQwIG.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-CUf4T2CN.js → cynefin-OW5HDTMX-CSyl-7vl.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-DPEZrFGA.js → cynefinDiagram-VND7K2PF-BCLKL6az.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-bDe6GIv5.js → dagre-6A5THRUB-DiaZs6S1.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-CdcUQz55.js → diagram-22UHCM2B-BeTtmt3I.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-BZpqGg7c.js → diagram-3UASUU5V-CsdBG-QQ.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-DvGM6ugp.js → diagram-ATOU4E4O-BczvdbR1.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-DSTmMm0s.js → diagram-CDSNMT55-DHTTTMhi.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-15cucnMn.js → diagram-MLGK6HIB-RTjHyd8z.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-tMbuaE6n.js → diagram-MPIPVDR6-BDenHEH_.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-BYxYzjHI.js → ebnfDiagram-ZINNZB2B-BSiRSbtL.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-v6bj852W.js → elk-276RUBZZ-BCAElss-.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-5lBBlnd2.js → erDiagram-OPXOYQCR-C4_1ZPJe.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-CZyl1zIj.js → flowDiagram-KWPJA3E3-Bcb4Z75S.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-BiAI-ltZ.js → ganttDiagram-FUAMR5RP-BiPOzxzb.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-BAZISkj3.js → gitGraphDiagram-X574FWY7-B1wtF3ZS.js} +1 -1
  34. package/dist/assets/index-Biji-bfL.css +1 -0
  35. package/dist/assets/{index-DGBaEXrM.js → index-BkYwilkG.js} +1 -1
  36. package/dist/assets/index-DZlhoES0.js +597 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-B6ooOy-Z.js → infoDiagram-VRGFBTTK-A19No71U.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BZ9ksi-B.js → ishikawaDiagram-OU5B5YK6-BQsqJ_cV.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DWqlzIEe.js → journeyDiagram-ZHPQQLJL-DAO0pVan.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-JX6piDwL.js → kanban-definition-PNTS6WVX-NepmKEe2.js} +1 -1
  41. package/dist/assets/{linear-CmOWzX0P.js → linear-bq1PTDTW.js} +1 -1
  42. package/dist/assets/{mermaid.core-CF1zwoQ-.js → mermaid.core-s4LTbQle.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-CGh2K4fW.js → mindmap-definition-NLK3R4M7-LtdbgHFH.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-CDwYN0s7.js → pegDiagram-GJSIUBJH-tdbaCxiK.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-1rfjJocB.js → pieDiagram-5QR66LMP-DvkbpW8l.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-tKPvPiok.js → quadrantDiagram-O4NWA36T-Do4KaH-E.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-BHLm5vXR.js → railroadDiagram-XR7U4H2S-BQWULOAs.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-CB45STp7.js → requirementDiagram-PLB6GJNP-w9kei1FU.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-55QA_huC.js → sankeyDiagram-IPEJSGJF-D2gRy0e8.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-BckFmQHn.js → sequenceDiagram-PO4LG4MO-a2sO5_Eo.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-BFCOJF11.js → sizeCapture-INFHLROL-CiPJmFEB.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-CZvmiVVN.js → stateDiagram-v2-GCMORJYK-DAkUUlL7.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-BpDUCEKy.js → swimlanes-2SLR337P-DANf2R8Y.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-CdHxG8aM.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BpxHq6IK.js → timeline-definition-EJHVYXUP-BKpQmzrT.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-Cj66vm02.js → usecaseDiagram-POWQR4AR-BtI7qLN-.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-C9nRkWE_.js → vennDiagram-UO4OBE2U-gXJXrAG_.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-D-me_nWj.js → wardleyDiagram-VNRHLVJA-GU8Po5-n.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-Dc_fL4mk.js → xychartDiagram-PMCCYNJV-BF4J30g6.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/companion/src/routes.js +24 -1
  62. package/dist-server/container-mcp.js +6 -1
  63. package/dist-server/drivers/agents-proxy.js +2 -0
  64. package/dist-server/index.js +1722 -906
  65. package/dist-server/local-computer-proxy.js +5 -1
  66. package/dist-server/mcp-gate.js +5 -1
  67. package/dist-server/openmausbot.js +763 -93
  68. package/dist-server/pair-cli.js +763 -93
  69. package/dist-server/server/boat.js +3 -3
  70. package/dist-server/server/cloud-owner.js +1 -1
  71. package/dist-server/server/computer-control.js +6 -0
  72. package/dist-server/server/config.js +14 -1
  73. package/dist-server/server/container-computer.js +43 -32
  74. package/dist-server/server/digest.js +12 -2
  75. package/dist-server/server/drivers/acp/core.js +172 -12
  76. package/dist-server/server/drivers/acp/cursor.js +13 -1
  77. package/dist-server/server/drivers/acp/quiet-status.js +257 -0
  78. package/dist-server/server/drivers/acp/qwen.js +18 -0
  79. package/dist-server/server/drivers/builtIn.js +2 -0
  80. package/dist-server/server/drivers/cerebras.js +108 -0
  81. package/dist-server/server/drivers/chat-mcp-tools.js +20 -3
  82. package/dist-server/server/drivers/claude.js +17 -3
  83. package/dist-server/server/drivers/openai-chat.js +26 -2
  84. package/dist-server/server/drivers/prompt-split.js +2 -0
  85. package/dist-server/server/env-path.js +6 -2
  86. package/dist-server/server/index.js +210 -57
  87. package/dist-server/server/local-vm-stop-reason.js +26 -0
  88. package/dist-server/server/provider-key-check.js +2 -1
  89. package/dist-server/server/routes/desktop-viewer.js +50 -6
  90. package/dist-server/server/routines.js +6 -3
  91. package/dist-server/server/store.js +54 -0
  92. package/dist-server/server/vps-computer.js +1 -1
  93. package/dist-server/server/workspace-backup.js +22 -0
  94. package/dist-server/server/workspace.js +1 -1
  95. package/dist-server/shared/approval-mode.js +1 -1
  96. package/dist-server/shared/redact.js +1 -0
  97. package/dist-server/shared/tool-name.js +22 -0
  98. package/dist-server/vps-container-mcp.js +6 -1
  99. package/dist-server/workspace-backup.worker.js +14 -0
  100. package/package.json +1 -1
  101. package/dist/assets/channel-Dtk5Mtic.js +0 -1
  102. package/dist/assets/index-Cdmjrl28.css +0 -1
  103. package/dist/assets/index-DR7YPMkW.js +0 -597
  104. package/dist/assets/swimlanesDiagram-TC7HE7FX-M0GYW9Y3.js +0 -8
@@ -63,7 +63,7 @@ import { createMemoryUpkeep, upkeepEnabled } from "./memory-upkeep.js";
63
63
  import { appendAboutMe, commitLearned, planLearned } from "./profile-learned.js";
64
64
  import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, livePeerRosterBlock, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
65
65
  import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
66
- import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerExec, containerRuntimeStatus, localVmRecreatableOnDemand, localVmWorkspaceExists, perBotLocalVmTarget, poolLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, VM_WORKSPACE_GUEST, } from "./container-computer.js";
66
+ import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerExec, containerRuntimeStatus, localVmWakeAction, localVmWorkspaceExists, perBotLocalVmTarget, poolLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, VM_WORKSPACE_GUEST, } from "./container-computer.js";
67
67
  import { attachForTurn, saveBotAttachment } from "./bot-attachment.js";
68
68
  import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmIdleTimeoutMinutes, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, computerClaimIdleReleaseEnabled, cloudOverflowAllowlistedThreads, cloudOverflowEnabled, cloudOverflowIdleStopMs, cloudOverflowPerSecondCostUsd, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, stripWorkspaceCredentialEnv, PROVIDER_CREDENTIAL_ENV, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, CLAUDE_API_INSTANCE, } from "./config.js";
69
69
  import { sweepThreadEventLogs } from "./thread-retention.js";
@@ -145,6 +145,7 @@ import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-
145
145
  import { buildSystemPrompt, userProfileSystemPrompt, cloudHomePrompt, computerPrompt, composioSystemPrompt, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, resolveComputerPromptKind, } from "./system-prompt.js";
146
146
  import { readCuaConnection, readCuaUnavailableReason, gatedLocalComputer } from "./local-computer.js";
147
147
  import { discoverExistingPerBotLocalVms, discoverExistingPoolLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
148
+ import { localVmStopReason, recordLocalVmIdleStop } from "./local-vm-stop-reason.js";
148
149
  import { LocalVmIdleTimer } from "./local-vm-idle.js";
149
150
  import { LocalVmLeasePool } from "./local-vm-lease.js";
150
151
  import { LocalVmSeatPool } from "./local-vm-seat-pool.js";
@@ -3466,6 +3467,9 @@ function handleDesktopTrustedApprovalMessage(raw) {
3466
3467
  if (bot.approvalGrant.allThreads && mode === "full") {
3467
3468
  store.setAllThreadApprovalMode(botId, mode);
3468
3469
  }
3470
+ else if (bot.approvalGrant.refreshPermissions && bot.approvalGrant.threadId) {
3471
+ store.refreshTaskPermissions(botId, bot.approvalGrant.threadId);
3472
+ }
3469
3473
  else if (bot.approvalGrant.threadId) {
3470
3474
  store.patchTask(botId, bot.approvalGrant.threadId, { approvalMode: mode, autoApprove: false });
3471
3475
  }
@@ -3671,6 +3675,39 @@ function handleDesktopTrustedApprovalMessage(raw) {
3671
3675
  respond({ ok: false, error: "Invalid thread approval scope" });
3672
3676
  return true;
3673
3677
  }
3678
+ if (message.refreshPermissions !== undefined && message.refreshPermissions !== true) {
3679
+ respond({ ok: false, error: "Invalid permission refresh" });
3680
+ return true;
3681
+ }
3682
+ if (message.refreshPermissions === true) {
3683
+ const target = typeof threadId === "string" ? store.projectBotForTask(botId, threadId) : null;
3684
+ const savedMode = approvalModeFor({ ...existing, approvalGrant: undefined });
3685
+ if (message.threadOnly !== true || !target || message.allThreads === true || message.modelSelection !== undefined || message.updateBotDefault !== undefined || savedMode !== mode) {
3686
+ respond({ ok: false, error: "Choose this bot's approval level in bot settings before refreshing a thread" });
3687
+ return true;
3688
+ }
3689
+ if (existing.approvalGrant || threadBusy(botId, target.threadId)) {
3690
+ respond({ ok: false, error: "Stop this thread and finish its pending approval change first" });
3691
+ return true;
3692
+ }
3693
+ if (!supportsApprovalMode(target.modelSelection, mode)) {
3694
+ respond({ ok: false, error: "This thread's provider does not support that approval level" });
3695
+ return true;
3696
+ }
3697
+ if (mode === "auto" && target.computer === "local" && approvalModeFor(target) !== "auto" && message.acknowledgeLocalAuto !== true) {
3698
+ respond({ ok: false, error: "Auto mode on this computer requires confirming the warning" });
3699
+ return true;
3700
+ }
3701
+ if (mode === "full" || mode === "custom") {
3702
+ store.patchBot(botId, { approvalGrant: { requestId, mode, phase: "prepared", threadId: target.threadId, threadOnly: true, refreshPermissions: true } });
3703
+ }
3704
+ else if (!store.refreshTaskPermissions(botId, target.threadId)) {
3705
+ respond({ ok: false, error: "That thread is no longer available" });
3706
+ return true;
3707
+ }
3708
+ respond({ ok: true, bot: wireTrustedApprovalBot(store.bot(botId)) });
3709
+ return true;
3710
+ }
3674
3711
  if (message.threadOnly === true) {
3675
3712
  const target = typeof threadId === "string" ? store.projectBotForTask(botId, threadId) : null;
3676
3713
  if (!target || message.modelSelection !== undefined || message.updateBotDefault !== undefined) {
@@ -4864,6 +4901,18 @@ const desktopViewer = createDesktopViewer({
4864
4901
  });
4865
4902
  },
4866
4903
  live: auth => auth.kind === "loopback" || sessions.isLive(auth.session.id),
4904
+ // A phone paired with this server directly drives a bot's Local VM through
4905
+ // this proxy under its control lease (see the local-computer join route).
4906
+ // The lease must hold the bot's computer, and that computer must be the
4907
+ // desktop being viewed: a lease on one bot opens no other bot's VM.
4908
+ lease: (id, botId, controlLeaseId, threadId) => {
4909
+ const bot = store.bot(botId);
4910
+ if (!bot || viewerTargetId(localVmTargetForStatus(bot.id, threadId)) !== id)
4911
+ return;
4912
+ const key = botComputerControlKey(bot);
4913
+ const holds = () => computerControl.ownsLease(key, controlLeaseId);
4914
+ return holds() ? holds : undefined;
4915
+ },
4867
4916
  });
4868
4917
  function closeSessionStreams(sessionId) {
4869
4918
  browserLive.closeForOwner(sessionId);
@@ -5012,7 +5061,7 @@ const askMessageByRequest = new Map(); // threadId:requestId -> messageId
5012
5061
  const pendingCommandRules = new Map();
5013
5062
  function commandRememberRefusal(auth, threadId, requestId, behavior) {
5014
5063
  if (!canManageCommandAllowlist(auth))
5015
- return "Only the workspace owner or an admin can save bot-wide command permissions.";
5064
+ return "Only the owner or an admin can save bot-wide command permissions.";
5016
5065
  if (behavior !== "allow" || !pendingCommandRules.has(`${threadId}:${requestId}`)) {
5017
5066
  return "This live request does not contain a command that can be saved. Allow it once instead.";
5018
5067
  }
@@ -5199,10 +5248,9 @@ const watchdog = new TurnWatchdog({
5199
5248
  onStall: (turn) => {
5200
5249
  const stalledResourceOwner = turnResourceOwners.get(turn.threadId);
5201
5250
  const stalledGeneration = directTurnGenerationByThread.get(turn.threadId);
5251
+ const stalledProviderTurn = directRequestOwners.get(turn.threadId);
5202
5252
  cancelDirectTurnDispatch(turn.botId, turn.threadId);
5203
- // Room targets carry an invocation identity; only those claims belong
5204
- // to the room grace cleanup added here.
5205
- const stalledVmTarget = groupSpeakers.has(turn.threadId) ? localVmThreadTargets.get(turn.threadId) : undefined;
5253
+ const stalledVmTarget = localVmThreadTargets.get(turn.threadId);
5206
5254
  revokeInternalCapabilitiesForThread(turn.threadId);
5207
5255
  repeats.settle(turn.threadId);
5208
5256
  const bot = botForThread(turn.botId, turn.threadId);
@@ -5240,6 +5288,9 @@ const watchdog = new TurnWatchdog({
5240
5288
  return;
5241
5289
  if (stalledResourceOwner && turnResourceOwners.get(turn.threadId)?.generation !== stalledResourceOwner.generation)
5242
5290
  return;
5291
+ if (stalledGeneration && stalledProviderTurn?.generation === stalledGeneration && stalledProviderTurn.turnId) {
5292
+ retireProviderTurn(stalledProviderTurn.turnId);
5293
+ }
5243
5294
  // A goal coordinator can stall before sendTurn reveals its provider
5244
5295
  // turn id. Reusing the room during that ambiguous pre-id window would
5245
5296
  // make old and replacement events indistinguishable. Keep ownership
@@ -5254,7 +5305,8 @@ const watchdog = new TurnWatchdog({
5254
5305
  }
5255
5306
  const group = store.groupByThread(turn.threadId);
5256
5307
  const speaker = groupSpeakers.get(turn.threadId);
5257
- if (group && group.busyBotId === turn.botId && speaker?.botId === turn.botId) {
5308
+ const stalledRoomSpeaker = group && group.busyBotId === turn.botId && speaker?.botId === turn.botId;
5309
+ if (stalledRoomSpeaker) {
5258
5310
  groupSpeakers.delete(turn.threadId);
5259
5311
  store.patchGroup(group.id, { busyBotId: null, unread: true });
5260
5312
  }
@@ -5262,7 +5314,7 @@ const watchdog = new TurnWatchdog({
5262
5314
  // computer claim. The generation checks above protect replacements.
5263
5315
  releaseTurnResources(stalledResourceOwner);
5264
5316
  const currentBot = store.bot(turn.botId);
5265
- if (currentBot?.busy) {
5317
+ if (currentBot?.busy && (threadBusy(currentBot.id, turn.threadId) || stalledRoomSpeaker)) {
5266
5318
  stopScreenPoller(currentBot.id, turn.threadId);
5267
5319
  vpsThreadEnded(currentBot.id, turn.threadId);
5268
5320
  if (store.taskByThread(currentBot.id, turn.threadId))
@@ -6378,11 +6430,11 @@ function localVmIdleFor(target) {
6378
6430
  localVmLifecycleBusy.add(target.key);
6379
6431
  try {
6380
6432
  const status = await containerComputerStatus(undefined, undefined, target);
6381
- // The desktop leaves a stale X lock after stop, so idle cleanup
6382
- // removes only the disposable container. Its target-specific durable
6383
- // workspace and the shared prepared image remain.
6384
- if (status.container === "running") {
6385
- await containerComputerAction("remove", undefined, undefined, target);
6433
+ // The pinned VNC startup clears stale X locks. Keep the container's
6434
+ // home and installed software so inactivity is a reversible stop.
6435
+ if (status.container === "running" && status.managed) {
6436
+ const stopped = await containerComputerAction("stop", undefined, undefined, target);
6437
+ recordLocalVmIdleStop(target.key, stopped.stopped_at);
6386
6438
  }
6387
6439
  }
6388
6440
  finally {
@@ -6424,7 +6476,7 @@ function releaseLocalVmThread(threadId) {
6424
6476
  // Browser, This computer, Auto, or Off does not delete its old Local VM.
6425
6477
  void (async () => {
6426
6478
  if (localVmMode(cfg) === "pool") {
6427
- // Same restore rule as per-bot: idle cleanup removes the container, not
6479
+ // Same restore rule as per-bot: idle shutdown preserves the container and
6428
6480
  // its provisioned workspace, so every surviving seat stays Auto-eligible.
6429
6481
  const seats = localVmMaxInstances(cfg);
6430
6482
  for (let seat = 0; seat < seats; seat += 1) {
@@ -8759,7 +8811,7 @@ async function startTurn(botId, text, opts) {
8759
8811
  * desktop, not whatever localVmTargetForBot resolves to by the time
8760
8812
  * the first screen call arrives. */
8761
8813
  const claimAutoLocalVm = async (claimThreadId, pinnedTarget) => {
8762
- const localVmTarget = pinnedTarget ?? localVmTargetForThread(bot.id, claimThreadId);
8814
+ const localVmTarget = pinnedTarget ?? localVmThreadTargets.get(claimThreadId) ?? { ...localVmTargetForThread(bot.id, claimThreadId) };
8763
8815
  await bindTurnComputer(resourceOwner, `computer:vm:${localVmTarget.key}`, true);
8764
8816
  if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) {
8765
8817
  throw new Error("this Local VM is being started, stopped, or replaced — wait for setup to finish");
@@ -8783,6 +8835,8 @@ async function startTurn(botId, text, opts) {
8783
8835
  // without this the exclusive lease would sit held for the rest of a
8784
8836
  // turn that never got the VM — the very serialisation #1361 removes.
8785
8837
  const dropLease = () => {
8838
+ if (localVmThreadTargets.get(claimThreadId) !== localVmTarget)
8839
+ return;
8786
8840
  localVmLeaseFor(localVmTarget).release(claimThreadId);
8787
8841
  if (localVmActiveThreads.get(localVmTarget.key) === claimThreadId)
8788
8842
  localVmActiveThreads.delete(localVmTarget.key);
@@ -8876,7 +8930,7 @@ async function startTurn(botId, text, opts) {
8876
8930
  if (!localVmSeen.has(poolCandidate.key) && !opts?.automationSource)
8877
8931
  return false;
8878
8932
  }
8879
- const localVmTarget = localVmTargetForThread(bot.id, threadId);
8933
+ const localVmTarget = { ...localVmTargetForThread(bot.id, threadId) };
8880
8934
  let lazyReadyVm = null;
8881
8935
  if (!strict) {
8882
8936
  // Nothing this process has ever seen for this target, and nobody is
@@ -8941,7 +8995,8 @@ async function startTurn(botId, text, opts) {
8941
8995
  catch (error) {
8942
8996
  if (strict)
8943
8997
  throw error;
8944
- releaseLocalVmThread(threadId);
8998
+ if (directTurnGenerationByThread.get(threadId) === dispatchClaimId)
8999
+ releaseLocalVmThread(threadId);
8945
9000
  return false;
8946
9001
  }
8947
9002
  };
@@ -10414,7 +10469,7 @@ async function resolveAndSendTeamSetup(res, args, ownerReview) {
10414
10469
  if (!card)
10415
10470
  return false;
10416
10471
  if (args.behavior === "allow" && !ownerReview) {
10417
- json(res, 403, { error: "Approve team setup or deletion from the desktop app or a paired owner device. In a local browser, wait until every bot is idle." });
10472
+ json(res, 403, { error: "Approve team setup or deletion from the desktop app or a paired Full-access device. In a local browser, wait until every bot is idle." });
10418
10473
  return true;
10419
10474
  }
10420
10475
  if (args.behavior === "allow" && !card.answered && !card.dismissed && !card.expired) {
@@ -13183,34 +13238,15 @@ async function localVmPayload(target, auth) {
13183
13238
  const status = await containerComputerStatus(undefined, undefined, target);
13184
13239
  return {
13185
13240
  ...localVmViewerStatus(status, auth),
13241
+ stop_reason: localVmStopReason(target.key, status),
13186
13242
  commands: setupCommands(status.runtime, process.platform, target),
13187
13243
  idle_timeout_ms: localVmIdleMs(),
13188
13244
  mode: localVmMode(cfg),
13189
13245
  max_instances: localVmMaxInstances(cfg),
13190
13246
  };
13191
13247
  }
13192
- /** The Local VM a turn is about to use, recreated if the idle timer took it.
13193
- *
13194
- * `LocalVmIdleTimer` REMOVES an unused Local VM rather than pausing it. The
13195
- * turn then failed with "Create the Local VM (App Settings → Local VM)" —
13196
- * which reads like a fault the person must repair by hand, for a container the
13197
- * app itself deleted eight hours earlier. Someone who steps away overnight
13198
- * comes back to an error on their first message.
13199
- *
13200
- * The cloud branch below already does the opposite: an absent boat is
13201
- * provisioned on first use behind a `provisioning` broadcast. This gives the
13202
- * Local VM the same lifecycle for the same reason.
13203
- *
13204
- * Only `missing` is recovered, and only when a fresh `run` is all it takes.
13205
- * Every other problem still surfaces: no runtime installed, no image pulled,
13206
- * `create_supported` false, or an existing container that is stale, unmanaged
13207
- * or unsafe. Those need a decision — install podman, download 1.4 GB, replace
13208
- * a container someone else made — and a stopped container is deliberately not
13209
- * resumed here, because `localVmProblem` says this desktop image cannot safely
13210
- * resume and asks for a recreate rather than a start. Per-bot mode keeps its
13211
- * instance cap; creating past it would quietly do what the lifecycle route
13212
- * refuses.
13213
- */
13248
+ /** Wake an idle desktop, or recreate a missing one from an already prepared
13249
+ * image. Incompatible or unsafe containers still require an explicit decision. */
13214
13250
  async function readyLocalVmForTurn(botId, target, isCurrent = () => true) {
13215
13251
  localVmLifecycleBusy.add(target.key);
13216
13252
  // Fence this target, and the cross-target capacity decision for creates,
@@ -13230,21 +13266,22 @@ async function readyLocalVmForTurn(botId, target, isCurrent = () => true) {
13230
13266
  noteLocalVmSeen(target, status);
13231
13267
  if (!isCurrent())
13232
13268
  return status;
13233
- if (status.ready || !localVmRecreatableOnDemand(status))
13269
+ const action = localVmWakeAction(status);
13270
+ if (status.ready || !action || !status.runtime)
13234
13271
  return status;
13235
13272
  // Another creation is already mid-flight and its container is not yet
13236
13273
  // visible to a count, so the safe answer is the inspected status —
13237
13274
  // exactly what the over-cap path below returns.
13238
- if (!pooled && !ownsProvision)
13275
+ if (action === "run" && !pooled && !ownsProvision)
13239
13276
  return status;
13240
- if (target.key.startsWith("bot:")) {
13277
+ if (action === "run" && target.key.startsWith("bot:")) {
13241
13278
  const count = await existingPerBotLocalVmCount(status.runtime);
13242
13279
  if (!isCurrent() || count >= localVmMaxInstances(cfg))
13243
13280
  return status;
13244
13281
  }
13245
13282
  broadcast({ kind: "computer", botId, state: "provisioning" });
13246
13283
  try {
13247
- status = await containerComputerAction("run", undefined, undefined, target);
13284
+ status = await containerComputerAction(action, undefined, undefined, target);
13248
13285
  }
13249
13286
  catch {
13250
13287
  // Keep the inspected status: its `problem` names the real obstacle,
@@ -13312,6 +13349,7 @@ function configStatus() {
13312
13349
  return {
13313
13350
  xai: { configured: Boolean(cfg.xai?.key) },
13314
13351
  mistral: { configured: Boolean(cfg.mistral?.key) },
13352
+ cerebras: { configured: Boolean(cfg.cerebras?.key) },
13315
13353
  anthropic: { configured: Boolean(cfg.anthropic?.key), everyClaudeBot: cfg.anthropic?.everyClaudeBot !== false },
13316
13354
  openai: { configured: Boolean(cfg.openai?.key) },
13317
13355
  openrouter: { configured: Boolean(cfg.openrouter?.key) },
@@ -19045,7 +19083,7 @@ const handleRequest = async (req, res) => {
19045
19083
  m = path.match(/^\/api\/bots\/([\w-]+)\/command-allowlist(?:\/([\w-]+))?$/);
19046
19084
  if (m && ["GET", "POST", "DELETE"].includes(method)) {
19047
19085
  if (!canManageCommandAllowlist(auth))
19048
- return json(res, 403, { error: "Only the workspace owner or an admin can manage command permissions." });
19086
+ return json(res, 403, { error: "Only the owner or an admin can manage command permissions." });
19049
19087
  const bot = requestedTaskBot(m[1], url.searchParams.get("threadId") ?? undefined);
19050
19088
  if (method === "GET" && !m[2])
19051
19089
  return json(res, 200, commandAllowlistResponse(bot));
@@ -19529,7 +19567,7 @@ const handleRequest = async (req, res) => {
19529
19567
  // come from local scripts only when all bots are idle; paired owners
19530
19568
  // and the packaged desktop's private capability can grant it mid-turn.
19531
19569
  if (loosened.includes("managedSections") && auth.kind === "loopback" && !DESKTOP_MANAGED && store.bots.some(bot => bot.busy)) {
19532
- return json(res, 409, { error: "Stop running bots before granting access to another team, or use the desktop app or a paired owner session." });
19570
+ return json(res, 409, { error: "Stop running bots before granting access to another team, or use the desktop app or a paired Full-access session." });
19533
19571
  }
19534
19572
  if (body.peers !== undefined && Array.isArray(existingBot?.peers)) {
19535
19573
  const nextPeers = patch.peers;
@@ -20867,7 +20905,7 @@ const handleRequest = async (req, res) => {
20867
20905
  const current = store.projectBotForTask(m[1], m[2]);
20868
20906
  if (!current)
20869
20907
  return json(res, 404, { error: "no such task" });
20870
- const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt", "pinned", "snoozedUntil", "surface"]);
20908
+ const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt", "pinned", "snoozedUntil", "surface", "refreshPermissions"]);
20871
20909
  if (Object.keys(body).some((key) => !allowed.has(key)))
20872
20910
  return json(res, 400, { error: "unsupported thread setting" });
20873
20911
  const notYours = cloudThreadRefusal(auth, m[2]);
@@ -20877,7 +20915,7 @@ const handleRequest = async (req, res) => {
20877
20915
  // asking them, even in a conversation someone else started, and what
20878
20916
  // the bot itself defaults to.
20879
20917
  if (CLOUD_HOME && !cloudOwnerSession(auth) &&
20880
- (body.approvalMode !== undefined || body.autoApprove !== undefined || body.acknowledgeLocalAuto !== undefined || body.updateBotDefault === true)) {
20918
+ (body.approvalMode !== undefined || body.autoApprove !== undefined || body.acknowledgeLocalAuto !== undefined || body.updateBotDefault === true || body.refreshPermissions === true)) {
20881
20919
  return json(res, 403, { error: "On this Cloud only its owner can change how a bot asks for approval, or its default model." });
20882
20920
  }
20883
20921
  for (const key of ["requireAvailableModel", "acknowledgeLocalAuto", "updateBotDefault", "resetApprovalToAsk"]) {
@@ -20892,6 +20930,50 @@ const handleRequest = async (req, res) => {
20892
20930
  (body.approvalMode !== undefined && body.approvalMode !== "ask") || body.autoApprove === true)) {
20893
20931
  return json(res, 400, { error: "resetApprovalToAsk requires a model selection and cannot be combined with another approval mode" });
20894
20932
  }
20933
+ if (body.refreshPermissions !== undefined && body.refreshPermissions !== true) {
20934
+ return json(res, 400, { error: "refreshPermissions must be true" });
20935
+ }
20936
+ if (body.refreshPermissions === true) {
20937
+ const extra = Object.keys(body).filter((key) => key !== "refreshPermissions" && key !== "acknowledgeLocalAuto");
20938
+ if (extra.length)
20939
+ return json(res, 400, { error: "refreshPermissions cannot be combined with other thread settings" });
20940
+ const profile = store.bot(m[1]);
20941
+ if (!profile)
20942
+ return json(res, 404, { error: "no such bot" });
20943
+ if (profile.approvalGrant)
20944
+ return json(res, 409, { error: "the bot's approval mode is still being confirmed" });
20945
+ if (threadBusy(profile.id, m[2]))
20946
+ return json(res, 409, { error: "stop this thread before changing its approval mode" });
20947
+ const nextMode = approvalModeFor({ ...profile, approvalGrant: undefined });
20948
+ // Full and Custom never travel over the bot-reachable HTTP surface.
20949
+ // The desktop's private channel copies those levels, and it is also
20950
+ // the only way to leave Custom.
20951
+ if (nextMode === "full" || nextMode === "custom") {
20952
+ return json(res, 403, { error: "Refresh Full or Custom access from the packaged desktop app" });
20953
+ }
20954
+ if (approvalModeFor(current) === "custom") {
20955
+ return json(res, 403, { error: "Leaving Custom approval requires the packaged desktop app" });
20956
+ }
20957
+ if (!supportsApprovalMode(current.modelSelection, nextMode)) {
20958
+ return json(res, 400, { error: "This provider does not support the selected approval level" });
20959
+ }
20960
+ if (nextMode === "auto" && current.computer === "local" && approvalModeFor(current) !== "auto" && body.acknowledgeLocalAuto !== true) {
20961
+ return json(res, 400, { error: "Auto mode on this computer requires confirming the warning first (acknowledgeLocalAuto)" });
20962
+ }
20963
+ const approvalRank = { ask: 0, edits: 1, auto: 2, full: 3, custom: 4 };
20964
+ const widensMode = approvalRank[nextMode] > approvalRank[approvalModeFor(current)];
20965
+ const nextAllow = profile.alwaysAllow ?? [];
20966
+ const widensAllow = nextAllow.some((key) => !(current.alwaysAllow ?? []).includes(key));
20967
+ if ((widensMode || widensAllow) && auth.kind === "loopback" && !DESKTOP_MANAGED && !req.headers.origin && store.bots.some((bot) => bot.busy)) {
20968
+ return json(res, 409, { error: "Change approval mode from the app or a paired device while bots are working." });
20969
+ }
20970
+ const task = store.refreshTaskPermissions(profile.id, m[2]);
20971
+ if (!task)
20972
+ return json(res, 404, { error: "no such task" });
20973
+ const fresh = botWithThread(store.bot(profile.id));
20974
+ broadcast({ kind: "bot", bot: fresh });
20975
+ return json(res, 200, { task: wireTask(task), bot: fresh });
20976
+ }
20895
20977
  const patch = {};
20896
20978
  if (body.projectId !== undefined) {
20897
20979
  if (body.projectId === null)
@@ -21311,8 +21393,10 @@ const handleRequest = async (req, res) => {
21311
21393
  localVmLifecycleBusy.add(SHARED_LOCAL_VM_TARGET.key);
21312
21394
  try {
21313
21395
  const status = await containerComputerAction(action, undefined, undefined, SHARED_LOCAL_VM_TARGET);
21314
- if (action === "run" || action === "start")
21396
+ if (action === "run" || action === "start") {
21397
+ localVmSeen.add(SHARED_LOCAL_VM_TARGET.key);
21315
21398
  localVmIdleFor(SHARED_LOCAL_VM_TARGET).touch();
21399
+ }
21316
21400
  if (action === "stop" || action === "remove")
21317
21401
  localVmIdleFor(SHARED_LOCAL_VM_TARGET).cancel();
21318
21402
  return json(res, 200, {
@@ -21344,7 +21428,7 @@ const handleRequest = async (req, res) => {
21344
21428
  return json(res, 404, { error: "no such bot" });
21345
21429
  return json(res, 200, await localVmPayload(localVmTargetForStatus(bot.id, bot.threadId), auth));
21346
21430
  }
21347
- m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer\/(run|stop|remove)$/);
21431
+ m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer\/(run|start|stop|remove)$/);
21348
21432
  if (m && method === "POST") {
21349
21433
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
21350
21434
  return json(res, 415, { error: "content-type must be application/json" });
@@ -21355,7 +21439,7 @@ const handleRequest = async (req, res) => {
21355
21439
  if (boatLifecycleBusyBots.has(bot.id)) {
21356
21440
  return json(res, 409, { error: "this bot's computer is being changed or deleted — wait for it to finish" });
21357
21441
  }
21358
- const action = z.enum(["run", "stop", "remove"]).parse(m[2]);
21442
+ const action = z.enum(["run", "start", "stop", "remove"]).parse(m[2]);
21359
21443
  const target = localVmTargetForBot(bot.id);
21360
21444
  if (target.key === SHARED_LOCAL_VM_TARGET.key) {
21361
21445
  return json(res, 409, { error: "Shared mode manages this desktop in App Settings → Computers" });
@@ -21394,8 +21478,10 @@ const handleRequest = async (req, res) => {
21394
21478
  }
21395
21479
  }
21396
21480
  const status = await containerComputerAction(action, undefined, undefined, target);
21397
- if (action === "run")
21481
+ if (action === "run" || action === "start") {
21482
+ localVmSeen.add(target.key);
21398
21483
  localVmIdleFor(target).touch();
21484
+ }
21399
21485
  if (action === "stop" || action === "remove")
21400
21486
  localVmIdleFor(target).cancel();
21401
21487
  return json(res, 200, {
@@ -21427,6 +21513,61 @@ const handleRequest = async (req, res) => {
21427
21513
  image: await containerComputerScreenshot(undefined, undefined, target),
21428
21514
  });
21429
21515
  }
21516
+ // The Local VM's live desktop for a phone, granted only to the control
21517
+ // lease that holds this bot's computer right now. Two callers:
21518
+ //
21519
+ // The companion sidecar (a loopback caller) gets the VM's own noVNC
21520
+ // address, VNC password included, relays it to a paired phone the owner
21521
+ // has allowed computer access, keeps re-checking the lease with
21522
+ // `action: "check"`, and cuts the relay when it no longer holds.
21523
+ //
21524
+ // A phone paired with this server directly (`openmausbot serve`, no
21525
+ // sidecar) never sees that address. It gets this server's own
21526
+ // authenticated desktop proxy, bound to its lease: the proxy re-checks
21527
+ // the lease and the session every few seconds and closes the socket when
21528
+ // either lapses. Reaching here at all took the admin scope, which a
21529
+ // chat-only pairing (`openmausbot pair --client`) does not have, so
21530
+ // computer access is the same explicit choice the owner's browser makes.
21531
+ m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer\/join$/);
21532
+ if (m && method === "POST") {
21533
+ if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
21534
+ return json(res, 415, { error: "content-type must be application/json" });
21535
+ }
21536
+ const bot = computerPreviewBot(m[1], url);
21537
+ if (!bot)
21538
+ return json(res, 404, { error: "no such bot" });
21539
+ const threadId = url.searchParams.has("threadId") ? bot.threadId : undefined;
21540
+ if (threadId && await computerPreviewSurface(bot, threadId) !== "vm") {
21541
+ return json(res, 409, { error: "This conversation is not using the Local VM" });
21542
+ }
21543
+ // A bot's human-control hold does not reserve a shared desktop or pool
21544
+ // seat. Another bot could drive it while the phone is holding this bot.
21545
+ if (localVmMode(cfg) !== "per-bot") {
21546
+ return json(res, 409, { error: "Phone control requires a per-bot Local VM. Select per-bot mode in Settings → Computers." });
21547
+ }
21548
+ const lease = controlLeaseIdSchema.safeParse(url.searchParams.get("controlLeaseId") ?? undefined);
21549
+ if (!lease.success)
21550
+ return json(res, 400, { error: "controlLeaseId is required" });
21551
+ const controlBot = store.bot(bot.id);
21552
+ if (!controlBot || !computerControl.ownsLease(botComputerControlKey(controlBot), lease.data)) {
21553
+ return json(res, 409, { error: "Take control of this computer first" });
21554
+ }
21555
+ const target = localVmTargetForStatus(bot.id, threadId);
21556
+ const status = await containerComputerStatus(undefined, undefined, target);
21557
+ if (!status.ready || !status.managed || status.container !== "running" || status.network !== "loopback"
21558
+ || !status.viewer_url) {
21559
+ return json(res, 409, { error: status.problem ?? "The Local VM is not ready" });
21560
+ }
21561
+ localVmIdleFor(target).touch();
21562
+ res.setHeader("cache-control", "private, no-store");
21563
+ if (auth.kind === "loopback")
21564
+ return json(res, 200, { joinUrl: status.viewer_url });
21565
+ const socket = new URLSearchParams({ botId: bot.id, ...(threadId ? { threadId } : {}), controlLeaseId: lease.data });
21566
+ return json(res, 200, {
21567
+ socketPath: `api/desktop-viewer/${viewerTargetId(target)}/websockify?${socket}`,
21568
+ password: new URLSearchParams(new URL(status.viewer_url).hash.slice(1)).get("password"),
21569
+ });
21570
+ }
21430
21571
  // identity handshake for the packaged app's port fallback: the forked
21431
21572
  // child proves it is OURS by echoing its pid (a stray dev server has
21432
21573
  // the same API shape but a different pid)
@@ -21566,7 +21707,7 @@ const handleRequest = async (req, res) => {
21566
21707
  return json(res, 400, { error: `provider must be one of ${PROVIDER_KEY_KINDS.join(", ")}` });
21567
21708
  }
21568
21709
  const kind = provider;
21569
- const saved = { anthropic: cfg.anthropic, openai: cfg.openai, openrouter: cfg.openrouter, openaiCompat: cfg.openaiCompat, mistral: cfg.mistral, xai: cfg.xai }[kind];
21710
+ const saved = { anthropic: cfg.anthropic, openai: cfg.openai, openrouter: cfg.openrouter, openaiCompat: cfg.openaiCompat, mistral: cfg.mistral, cerebras: cfg.cerebras, xai: cfg.xai }[kind];
21570
21711
  if (body?.key !== undefined && typeof body.key !== "string") {
21571
21712
  return json(res, 400, { error: "key must be a string" });
21572
21713
  }
@@ -21907,8 +22048,8 @@ const handleRequest = async (req, res) => {
21907
22048
  return json(res, 400, { error: "Account settings are currently available for Claude only." });
21908
22049
  }
21909
22050
  if (body.tools !== undefined) {
21910
- if (!["openai-compat", "grok", "minimax", "mistral"].includes(entry.driver)) {
21911
- return json(res, 400, { error: "The tools setting is available for OpenAI-compatible, Grok API, MiniMax API and Mistral API instances only." });
22051
+ if (!["openai-compat", "grok", "minimax", "mistral", "cerebras"].includes(entry.driver)) {
22052
+ return json(res, 400, { error: "The tools setting is available for OpenAI-compatible, Grok API, MiniMax API, Mistral API and Cerebras API instances only." });
21912
22053
  }
21913
22054
  entry.config = { ...entry.config, tools: body.tools };
21914
22055
  }
@@ -22187,7 +22328,7 @@ const handleRequest = async (req, res) => {
22187
22328
  }
22188
22329
  if ((method === "PUT" || method === "PATCH") && path === "/api/config") {
22189
22330
  const body = await readBody(req);
22190
- if (hostedModels && ["instances", "anthropic", "openai", "openrouter", "openaiCompat", "xai", "mistral", "opencodeGo"].some(key => Object.hasOwn(body, key)))
22331
+ if (hostedModels && ["instances", "anthropic", "openai", "openrouter", "openaiCompat", "xai", "mistral", "cerebras", "opencodeGo"].some(key => Object.hasOwn(body, key)))
22191
22332
  return json(res, 403, { error: HOSTED_PROVIDER_SETTINGS_ERROR });
22192
22333
  const patch = parseConfigPatch(body);
22193
22334
  // A Cloud home is personal: nobody is invited to sign in to it.
@@ -23065,13 +23206,19 @@ const handleRequest = async (req, res) => {
23065
23206
  const result = computerControl.releaseLease(controlKey, controlLeaseId);
23066
23207
  return json(res, 200, { ...result.snapshot, released: result.released });
23067
23208
  }
23209
+ // Read-only: does this lease still hold the wheel? The companion
23210
+ // sidecar asks this while it relays a phone's live Local VM desktop,
23211
+ // and cuts the relay the moment the answer is no.
23212
+ if (action === "check" && controlLeaseId) {
23213
+ return json(res, 200, { ...computerControl.snapshot(controlKey), owned: computerControl.ownsLease(controlKey, controlLeaseId) });
23214
+ }
23068
23215
  if (action === "take")
23069
23216
  return json(res, 200, computerControl.take(controlKey));
23070
23217
  if (action === "release")
23071
23218
  return json(res, 200, computerControl.release(controlKey));
23072
23219
  if (action === "dismiss-help")
23073
23220
  return json(res, 200, computerControl.dismissHelp(controlKey));
23074
- return json(res, 400, { error: "action must be take, release, or dismiss-help" });
23221
+ return json(res, 400, { error: "action must be take, release, check, or dismiss-help" });
23075
23222
  }
23076
23223
  return json(res, 405, { error: "method not allowed" });
23077
23224
  }
@@ -23083,7 +23230,13 @@ const handleRequest = async (req, res) => {
23083
23230
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
23084
23231
  return json(res, 415, { error: "content-type must be application/json" });
23085
23232
  }
23086
- return json(res, 200, bot.cloudBackend === "vps" ? vps.closeVpsDesktopTunnel(bot.id) : { closed: false });
23233
+ // A directly paired phone's Local VM desktop runs through this server's
23234
+ // own proxy under its lease on this bot; hand-back closes it here, ahead
23235
+ // of the lease re-check. The session's other viewers are left alone.
23236
+ const closedViewers = auth.kind === "session" ? desktopViewer.closeForOwner(auth.session.id, bot.id) : 0;
23237
+ if (bot.cloudBackend === "vps")
23238
+ return json(res, 200, vps.closeVpsDesktopTunnel(bot.id));
23239
+ return json(res, 200, { closed: closedViewers > 0 });
23087
23240
  }
23088
23241
  m = path.match(/^\/api\/bots\/([\w-]+)\/computer\/(provision|join|sleep|exec|screenshot|remove)$/);
23089
23242
  if (m && method === "POST") {
@@ -0,0 +1,26 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { writeFileAtomic } from "./atomic.js";
5
+ import { DATA_DIR } from "./config.js";
6
+ function recordPath(key, dataDir) {
7
+ return join(dataDir, "local-vm-stops", `${createHash("sha256").update(key).digest("hex")}.json`);
8
+ }
9
+ /** Record the runtime's actual finish timestamp. An external stop later gets
10
+ * a different timestamp and must not be misreported as OMB's idle shutdown. */
11
+ export function recordLocalVmIdleStop(key, stoppedAt, dataDir = DATA_DIR) {
12
+ if (!stoppedAt)
13
+ return;
14
+ mkdirSync(join(dataDir, "local-vm-stops"), { recursive: true, mode: 0o700 });
15
+ writeFileAtomic(recordPath(key, dataDir), JSON.stringify({ stoppedAt }), { mode: 0o600 });
16
+ }
17
+ export function localVmStopReason(key, status, dataDir = DATA_DIR) {
18
+ if (status.container !== "stopped" || !status.stopped_at)
19
+ return null;
20
+ try {
21
+ return JSON.parse(readFileSync(recordPath(key, dataDir), "utf8")).stoppedAt === status.stopped_at ? "idle" : null;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
@@ -1,4 +1,4 @@
1
- export const PROVIDER_KEY_KINDS = ["anthropic", "openai", "openrouter", "openaiCompat", "xai", "mistral"];
1
+ export const PROVIDER_KEY_KINDS = ["anthropic", "openai", "openrouter", "openaiCompat", "xai", "mistral", "cerebras"];
2
2
  const DEFAULT_URLS = {
3
3
  anthropic: "https://api.anthropic.com",
4
4
  openai: "https://api.openai.com/v1",
@@ -6,6 +6,7 @@ const DEFAULT_URLS = {
6
6
  openaiCompat: "https://openrouter.ai/api/v1",
7
7
  xai: "https://api.x.ai/v1",
8
8
  mistral: "https://api.mistral.ai/v1",
9
+ cerebras: "https://api.cerebras.ai/v1",
9
10
  };
10
11
  const MAX_MODELS = 5;
11
12
  function isLoopback(hostname) {