openmausbot 0.1.96 → 0.1.98

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 (185) hide show
  1. package/dist/assets/ActivityPanel-H6D3SD39.js +1 -0
  2. package/dist/assets/BotSettingsDialog-Dqz_AiBZ.js +7 -0
  3. package/dist/assets/CloudBackendPicker-iezpJFpF.js +1 -0
  4. package/dist/assets/ComputerPanel-DZPj-IWu.js +1 -0
  5. package/dist/assets/DesktopViewer-utONms1p.js +5 -0
  6. package/dist/assets/InspectorPanel-OVaBVrzC.js +5 -0
  7. package/dist/assets/KeyboardShortcutsModal-gk9mvHj_.js +1 -0
  8. package/dist/assets/LocalVmWorkspace-DLIvd2kC.js +1 -0
  9. package/dist/assets/MacLocalControl-bJv8yiPB.js +1 -0
  10. package/dist/assets/NewBotDialog-1qGOAdUM.js +2 -0
  11. package/dist/assets/RemoteAgentSettingsPanel-DqKr0HkO.js +1 -0
  12. package/dist/assets/RoutinesPage-BM2D4qGi.js +1 -0
  13. package/dist/assets/RoutinesSection-Dny9ra-c.js +1 -0
  14. package/dist/assets/SettingsModal-gC6XJJmt.js +11 -0
  15. package/dist/assets/TeamMapPage-B6fuu4r_.js +8 -0
  16. package/dist/assets/TriggersPanel-j0qTP2CO.js +1 -0
  17. package/dist/assets/VisibilitySection-DT0FqPvu.js +12 -0
  18. package/dist/assets/WebhooksPanel--YQyCjKO.js +3 -0
  19. package/dist/assets/{abnfDiagram-O67JEVCF-Dkbd2hcj.js → abnfDiagram-O67JEVCF-BsjcFX0-.js} +1 -1
  20. package/dist/assets/activity-BxOkayvD.js +1 -0
  21. package/dist/assets/{arc-Duj5Coy2.js → arc-CJNA94c6.js} +1 -1
  22. package/dist/assets/architectureDiagram-NJMV4G6O-dz2ud7lP.js +36 -0
  23. package/dist/assets/{blockDiagram-BEXU5L5S-CWDlTpXe.js → blockDiagram-BEXU5L5S-ChBEALoR.js} +1 -1
  24. package/dist/assets/brain-CFZ7PEXc.js +1 -0
  25. package/dist/assets/{c4Diagram-YGBWAQC7-B0t15PDc.js → c4Diagram-YGBWAQC7-KZNRKDQe.js} +1 -1
  26. package/dist/assets/channel-WhFyujhZ.js +1 -0
  27. package/dist/assets/{chunk-2Q5K7J3B-BJSCL-HV.js → chunk-2Q5K7J3B-BLLwmCET.js} +1 -1
  28. package/dist/assets/{chunk-3FUC2YCW-D3_ILccd.js → chunk-3FUC2YCW-CQxyreBZ.js} +2 -2
  29. package/dist/assets/{chunk-5VM5RSS4-D3dmwT_S.js → chunk-5VM5RSS4-D6sFMW0u.js} +1 -1
  30. package/dist/assets/{chunk-GTNCS2PH-Br73HFPD.js → chunk-GTNCS2PH-BaZ1YgLF.js} +1 -1
  31. package/dist/assets/{chunk-GWA4HPMP-uSsjxubC.js → chunk-GWA4HPMP-CcalphmC.js} +1 -1
  32. package/dist/assets/{chunk-JWPE2WC7-C40VWFyr.js → chunk-JWPE2WC7-CSk8R3ve.js} +1 -1
  33. package/dist/assets/{chunk-NETBCI7D-D6WNNYwU.js → chunk-NETBCI7D-CTmLjA57.js} +1 -1
  34. package/dist/assets/{chunk-WEXAMYUT-DOEhq64P.js → chunk-WEXAMYUT-CM9Z77Ne.js} +1 -1
  35. package/dist/assets/{chunk-XXDRQBXY-BqJEi6Pi.js → chunk-XXDRQBXY-B1fxpMS2.js} +1 -1
  36. package/dist/assets/{classDiagram-v2-NBCMYWYE-ByU_GnxT.js → classDiagram-v2-NBCMYWYE-DCCyoLpj.js} +1 -1
  37. package/dist/assets/computer-control-NLov352p.js +1 -0
  38. package/dist/assets/cose-bilkent-JH36ORCC-KyImBKvE.js +1 -0
  39. package/dist/assets/{cynefin-OW5HDTMX-CZWO7aYj.js → cynefin-OW5HDTMX-fsZc6MGa.js} +1 -1
  40. package/dist/assets/{cynefinDiagram-VND7K2PF-C70-w0la.js → cynefinDiagram-VND7K2PF-CGmHsShB.js} +1 -1
  41. package/dist/assets/{dagre-6A5THRUB-CnhjhExB.js → dagre-6A5THRUB-Dah1h-CH.js} +1 -1
  42. package/dist/assets/{diagram-22UHCM2B-bnjWCNb4.js → diagram-22UHCM2B-BIu71OyV.js} +1 -1
  43. package/dist/assets/{diagram-3UASUU5V-A4uTWoFV.js → diagram-3UASUU5V-gxhUB_Mu.js} +1 -1
  44. package/dist/assets/{diagram-ATOU4E4O-Zi6m3NUl.js → diagram-ATOU4E4O-CBhusUBn.js} +1 -1
  45. package/dist/assets/{diagram-CDSNMT55-DtCcmd5k.js → diagram-CDSNMT55-BdPrc86x.js} +1 -1
  46. package/dist/assets/{diagram-MLGK6HIB-DofuETS3.js → diagram-MLGK6HIB-Clp110R_.js} +1 -1
  47. package/dist/assets/{diagram-MPIPVDR6-Dw2T9PuU.js → diagram-MPIPVDR6-DJKfbugw.js} +1 -1
  48. package/dist/assets/{ebnfDiagram-ZINNZB2B-DbrwPiem.js → ebnfDiagram-ZINNZB2B-DnsltoBu.js} +1 -1
  49. package/dist/assets/{elk-276RUBZZ-BvCt1dCb.js → elk-276RUBZZ-jWdq6gqH.js} +1 -1
  50. package/dist/assets/{erDiagram-OPXOYQCR-Y-QKqPnU.js → erDiagram-OPXOYQCR-DArPVf8u.js} +1 -1
  51. package/dist/assets/{flowDiagram-KWPJA3E3-CDkL1B5w.js → flowDiagram-KWPJA3E3-D2HZ-2f7.js} +1 -1
  52. package/dist/assets/ganttDiagram-FUAMR5RP--o0KIxEo.js +292 -0
  53. package/dist/assets/{gitGraphDiagram-X574FWY7-nL9iFTCv.js → gitGraphDiagram-X574FWY7-CsoXr2D7.js} +1 -1
  54. package/dist/assets/image-plus-BbVjGHin.js +1 -0
  55. package/dist/assets/index-BrPW5HDO.css +1 -0
  56. package/dist/assets/index-C3NqfdVU.js +585 -0
  57. package/dist/assets/{index-DreDnjOu.js → index-CJTp9JEp.js} +1 -1
  58. package/dist/assets/{infoDiagram-VRGFBTTK-C7aHjzsf.js → infoDiagram-VRGFBTTK-g-6lNp7r.js} +1 -1
  59. package/dist/assets/{ishikawaDiagram-OU5B5YK6-KUZsNBTh.js → ishikawaDiagram-OU5B5YK6-B1d6qVTV.js} +1 -1
  60. package/dist/assets/{journeyDiagram-ZHPQQLJL-CGUuodFl.js → journeyDiagram-ZHPQQLJL-BTPuiU6T.js} +1 -1
  61. package/dist/assets/{kanban-definition-PNTS6WVX-Cy6r868Z.js → kanban-definition-PNTS6WVX-bDDuRJvP.js} +1 -1
  62. package/dist/assets/{linear-Bl669148.js → linear-BVVKbEMy.js} +1 -1
  63. package/dist/assets/{mermaid.core-86Xqz5Mr.js → mermaid.core-JJdoKyKB.js} +5 -5
  64. package/dist/assets/{mindmap-definition-NLK3R4M7-G4DzHLrd.js → mindmap-definition-NLK3R4M7-B4aE3T0q.js} +1 -1
  65. package/dist/assets/module-DCUsy2BQ.js +3 -0
  66. package/dist/assets/page-visible-BBo_Zm8g.js +1 -0
  67. package/dist/assets/{pegDiagram-GJSIUBJH-K4eruzY0.js → pegDiagram-GJSIUBJH-DFKQWpR7.js} +1 -1
  68. package/dist/assets/{pieDiagram-5QR66LMP-DYdR9PPq.js → pieDiagram-5QR66LMP-Iv7OKVJi.js} +1 -1
  69. package/dist/assets/{quadrantDiagram-O4NWA36T-BCAh0nEy.js → quadrantDiagram-O4NWA36T-U2YnfwBk.js} +1 -1
  70. package/dist/assets/{railroadDiagram-XR7U4H2S-p0dGRc4g.js → railroadDiagram-XR7U4H2S-CuPy6RqM.js} +1 -1
  71. package/dist/assets/remote-desktop-panel-BNEj7tUx.js +1 -0
  72. package/dist/assets/{requirementDiagram-PLB6GJNP-tLeBG0aL.js → requirementDiagram-PLB6GJNP-bMI5AK7T.js} +1 -1
  73. package/dist/assets/{sankeyDiagram-IPEJSGJF-QRuN16rV.js → sankeyDiagram-IPEJSGJF-BKcFE2WQ.js} +1 -1
  74. package/dist/assets/{sequenceDiagram-PO4LG4MO-ueT1nVoa.js → sequenceDiagram-PO4LG4MO-Bn9q88kl.js} +1 -1
  75. package/dist/assets/{sizeCapture-INFHLROL-wRS2zWg2.js → sizeCapture-INFHLROL-Cvmdq9HD.js} +1 -1
  76. package/dist/assets/{stateDiagram-v2-GCMORJYK-CYzA_hE1.js → stateDiagram-v2-GCMORJYK-JJQYw3eQ.js} +1 -1
  77. package/dist/assets/{swimlanes-2SLR337P-kDnv6LMc.js → swimlanes-2SLR337P-BkSCBEwp.js} +1 -1
  78. package/dist/assets/swimlanesDiagram-TC7HE7FX-CmTirbVT.js +8 -0
  79. package/dist/assets/table-file.worker-KkMx-SNx.js +10 -0
  80. package/dist/assets/{timeline-definition-EJHVYXUP-D1y2UbbH.js → timeline-definition-EJHVYXUP-DsaM4GHr.js} +1 -1
  81. package/dist/assets/{usecaseDiagram-POWQR4AR-IqB4v9HX.js → usecaseDiagram-POWQR4AR-3FOwLq7B.js} +1 -1
  82. package/dist/assets/user-DcZBnAuv.js +1 -0
  83. package/dist/assets/{vennDiagram-UO4OBE2U-BEi7KYVF.js → vennDiagram-UO4OBE2U-DyNjJsKt.js} +1 -1
  84. package/dist/assets/{wardleyDiagram-VNRHLVJA-Dkybo-Yf.js → wardleyDiagram-VNRHLVJA-Y4ZMcnBC.js} +1 -1
  85. package/dist/assets/{xychartDiagram-PMCCYNJV-BAPFJLpg.js → xychartDiagram-PMCCYNJV-BXZpg955.js} +1 -1
  86. package/dist/index.html +2 -2
  87. package/dist-server/cloud-home-start.js +20 -9
  88. package/dist-server/container-mcp.js +8 -0
  89. package/dist-server/desktop-entry.mjs +54 -0
  90. package/dist-server/drivers/agents-proxy.js +26 -9
  91. package/dist-server/index.js +3734 -2360
  92. package/dist-server/openmausbot.js +1664 -1553
  93. package/dist-server/pair-cli.js +1664 -1553
  94. package/dist-server/permission-proxy.js +2 -4
  95. package/dist-server/server/atomic.js +70 -2
  96. package/dist-server/server/auto-approve.js +1 -1
  97. package/dist-server/server/auto-vm-claims.js +13 -1
  98. package/dist-server/server/automatic-recovery.js +1 -2
  99. package/dist-server/server/boat.js +68 -84
  100. package/dist-server/server/bot-overview.js +1 -1
  101. package/dist-server/server/browser-engine.js +38 -1
  102. package/dist-server/server/browser-live.js +112 -25
  103. package/dist-server/server/browser-runtime.js +31 -1
  104. package/dist-server/server/checkpoints.js +275 -44
  105. package/dist-server/server/cli-setup.js +3 -3
  106. package/dist-server/server/cloud-computer-tools.js +11 -4
  107. package/dist-server/server/cloud-home-start.js +4 -4
  108. package/dist-server/server/cloud-home.js +3 -9
  109. package/dist-server/server/cloud-owner.js +1 -1
  110. package/dist-server/server/computer-engine-removal.js +70 -0
  111. package/dist-server/server/computer-selection.js +18 -0
  112. package/dist-server/server/config.js +29 -12
  113. package/dist-server/server/connector-card-text.js +15 -0
  114. package/dist-server/server/connector-scope-hint.js +83 -0
  115. package/dist-server/server/default-model-selection.js +23 -14
  116. package/dist-server/server/delegations.js +11 -5
  117. package/dist-server/server/delta-context.js +6 -1
  118. package/dist-server/server/drivers/acp/core.js +11 -1
  119. package/dist-server/server/drivers/acp/grok.js +47 -5
  120. package/dist-server/server/drivers/acp/opencode-go.js +16 -21
  121. package/dist-server/server/drivers/agents-call.js +1 -1
  122. package/dist-server/server/drivers/agents-catalog.js +27 -7
  123. package/dist-server/server/drivers/agents-proxy.js +1 -0
  124. package/dist-server/server/drivers/builtIn.js +0 -2
  125. package/dist-server/server/drivers/chat-mcp-tools.js +5 -3
  126. package/dist-server/server/drivers/claude-login-auth.js +1 -1
  127. package/dist-server/server/drivers/claude.js +84 -41
  128. package/dist-server/server/drivers/codex-device-auth.js +27 -319
  129. package/dist-server/server/drivers/codex.js +2 -2
  130. package/dist-server/server/drivers/device-auth.js +342 -0
  131. package/dist-server/server/drivers/openai-chat.js +110 -10
  132. package/dist-server/server/drivers/pi.js +184 -49
  133. package/dist-server/server/fleet-agent.js +18 -5
  134. package/dist-server/server/harness/http.js +5 -2
  135. package/dist-server/server/harness/registry.js +19 -0
  136. package/dist-server/server/harness-capabilities.js +7 -4
  137. package/dist-server/server/hosted-models.js +3 -1
  138. package/dist-server/server/index.js +823 -333
  139. package/dist-server/server/key-rejections.js +57 -0
  140. package/dist-server/server/live-call.js +11 -6
  141. package/dist-server/server/managed-policy.js +1 -1
  142. package/dist-server/server/message-file.js +7 -4
  143. package/dist-server/server/model-catalog/presets.js +8 -0
  144. package/dist-server/server/peer-roster.js +10 -2
  145. package/dist-server/server/permission-proxy.js +4 -0
  146. package/dist-server/server/provider-key-check.js +5 -1
  147. package/dist-server/server/request-auth.js +1 -1
  148. package/dist-server/server/room-turn-end.js +15 -0
  149. package/dist-server/server/room-turn-timeout.js +28 -0
  150. package/dist-server/server/routes/live.js +3 -3
  151. package/dist-server/server/routes/thread-models.js +36 -0
  152. package/dist-server/server/routine-requests.js +1 -1
  153. package/dist-server/server/routines.js +3 -3
  154. package/dist-server/server/screen-frame-gate.js +1 -3
  155. package/dist-server/server/skills.js +3 -2
  156. package/dist-server/server/store.js +273 -61
  157. package/dist-server/server/surface.js +29 -49
  158. package/dist-server/server/system-prompt.js +10 -10
  159. package/dist-server/server/team-backup.js +19 -4
  160. package/dist-server/server/thread-model.js +25 -0
  161. package/dist-server/server/turn-continuation.js +168 -0
  162. package/dist-server/server/webhook-ingress.js +8 -3
  163. package/dist-server/server/webhooks.js +40 -5
  164. package/dist-server/server/workspace.js +1 -1
  165. package/dist-server/shared/ask-question.js +0 -70
  166. package/dist-server/shared/cloud-computer.js +4 -6
  167. package/dist-server/shared/credential-request.js +1 -1
  168. package/dist-server/shared/device-sign-in.js +57 -0
  169. package/dist-server/shared/failed-turn.js +1 -4
  170. package/dist-server/shared/place-view.js +409 -0
  171. package/dist-server/shared/team-backup.js +2 -0
  172. package/dist-server/shared/thread-model.js +13 -0
  173. package/dist-server/shared/tool-scope-support.js +1 -2
  174. package/dist-server/vps-container-mcp.js +8 -0
  175. package/dist-server/workspace-backup.worker.js +41 -22
  176. package/package.json +1 -1
  177. package/dist/assets/DesktopViewer-DsnA23KM.js +0 -5
  178. package/dist/assets/architectureDiagram-NJMV4G6O-uYUsDawP.js +0 -36
  179. package/dist/assets/channel-BZxwC-Gw.js +0 -1
  180. package/dist/assets/cose-bilkent-JH36ORCC-D-Uglxqq.js +0 -1
  181. package/dist/assets/ganttDiagram-FUAMR5RP-CKV5D2F6.js +0 -292
  182. package/dist/assets/index-Du7o6Dsf.js +0 -610
  183. package/dist/assets/index-KxG4bWSr.css +0 -1
  184. package/dist/assets/swimlanesDiagram-TC7HE7FX-C7voMDle.js +0 -8
  185. package/dist-server/server/drivers/boatagent.js +0 -443
@@ -52,7 +52,9 @@ import { messageFileDisposition, messageFileDownloadName, messageAttachmentName,
52
52
  import { avatarGenerationRequestSchema, avatarGenerationStateMatches, generateAvatarImage, avatarImageStatus, snapshotAvatarGenerationState, } from "./avatar-image.js";
53
53
  import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
54
54
  import { groupTurnCwd } from "./room-cwd.js";
55
- import { RoomTurnDeadline, RoomTurnStallRegistry, roomTurnTimeoutMessage } from "./room-turn-timeout.js";
55
+ import { RoomTurnDeadline, RoomTurnStallRegistry, effectiveRoomTurnTimeoutMinutes, parseConversationTurnTimeout, roomTurnTimeoutMessage } from "./room-turn-timeout.js";
56
+ import { roomTurnEnd } from "./room-turn-end.js";
57
+ import { pickComputer } from "./computer-selection.js";
56
58
  import * as boat from "./boat.js";
57
59
  import { cloudComputerRpc } from "./cloud-computer-tools.js";
58
60
  import { TeamComputers, teamComputerAssignment, teamComputerCreate, teamComputerOwner } from "./team-computers.js";
@@ -65,6 +67,7 @@ import { liveCardKind, liveDecisionRefusal } from "../shared/live-approval.js";
65
67
  import { boatAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourceChangeError, } from "./cloud-backend.js";
66
68
  import * as composio from "./composio.js";
67
69
  import { connectorCallFromFrame, connectorRefusalText, connectorUnrecognizedText, evaluateConnectorTools } from "./connector-verdict.js";
70
+ import { connectorCardText } from "./connector-card-text.js";
68
71
  import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
69
72
  import { buildRecall } from "./recall.js";
70
73
  import { createMemoryUpkeep, upkeepEnabled } from "./memory-upkeep.js";
@@ -72,7 +75,7 @@ import { appendAboutMe, commitLearned, planLearned } from "./profile-learned.js"
72
75
  import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, livePeerRosterBlock, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
73
76
  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";
74
77
  import { attachForTurn, saveBotAttachment } from "./bot-attachment.js";
75
- import { cacheUntilConfigChanges, ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmIdleTimeoutMinutes, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, stripWorkspaceCredentialEnv, PROVIDER_CREDENTIAL_ENV, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, skillsLibraryEnabled, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, CLAUDE_API_INSTANCE, liveSettingsFor, mergeOpenCodeProviderKeys, openCodeProviderKeys, } from "./config.js";
78
+ import { cacheUntilConfigChanges, ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmIdleTimeoutMinutes, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, mcpCallTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, stripWorkspaceCredentialEnv, PROVIDER_CREDENTIAL_ENV, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, skillsLibraryEnabled, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, CLAUDE_API_INSTANCE, liveSettingsFor, mergeOpenCodeProviderKeys, openCodeProviderKeys, } from "./config.js";
76
79
  import { sweepThreadEventLogs } from "./thread-retention.js";
77
80
  import { ComputerControl } from "./computer-control.js";
78
81
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
@@ -81,7 +84,8 @@ import { appendUsage, parseUsageRange, readUsage, summarizeUsage, usageCsv, USAG
81
84
  import { loadPlanUsage, planAccountsFromInstances } from "./plan-usage.js";
82
85
  import { GroupUsageReader } from "./group-thread-usage.js";
83
86
  import { ledgerCost } from "./model-prices.js";
84
- import { checkProviderKey, PROVIDER_KEY_KINDS } from "./provider-key-check.js";
87
+ import { checkProviderKey, PROVIDER_KEY_KINDS, providerBaseUrl } from "./provider-key-check.js";
88
+ import { forgetKey, noteKeyAccepted, noteKeyRejected, onKeyRejectionChange } from "./key-rejections.js";
85
89
  import { assertWithinBudget, noteSpend, spendAlertText, spendState, takeSpendAlert } from "./spend.js";
86
90
  import { fleetAvailable, fleetRequest, fleetSocketPath } from "./fleet-client.js";
87
91
  import { entitled } from "./enterprise.js";
@@ -97,7 +101,7 @@ import { McpOAuthError, McpSignInError, McpOAuthManager, mcpOAuthRedirectUri, wi
97
101
  import { probeMcpServer } from "./mcp-probe.js";
98
102
  import { GROUP_GOAL_MAX_TURNS, groupGoalAssignmentKey, groupGoalCompletionTurnId, groupGoalCoordinatorInstructions, groupGoalWorkerInstructions, parseGroupGoalDecision, resolveGroupGoalMember, selectGroupGoalCoordinator, } from "./group-goal-run.js";
99
103
  import { BUILT_IN_DRIVERS } from "./drivers/builtIn.js";
100
- import { openCodeProviderKeysAllowed, setOpenCodeOwnProviderKeys, setOpenCodeProviderKeyPolicy } from "./drivers/acp/opencode-go.js";
104
+ import { openCodeProviderKeysAllowed, setOpenCodeProviderKeyPolicy } from "./drivers/acp/opencode-go.js";
101
105
  import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from "./comms-visibility.js";
102
106
  import { readMessageText, recallMessages, recentMessages, searchMessagesAsync, closeMessageSearch, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups, threadsReferencing } from "./message-db.js";
103
107
  import { briefCrossingLabel, claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
@@ -118,17 +122,19 @@ import { ProviderRegistry } from "./harness/registry.js";
118
122
  import { ManagedDesktopProviders } from "./managed-desktop.js";
119
123
  import { computerKindForResource, ManagedDesktopPolicy } from "./managed-policy.js";
120
124
  import { hostedModelPolicy, HOSTED_MODEL_POLICY_HEADER, HOSTED_PROVIDER_SETTINGS_ERROR } from "./hosted-models.js";
121
- import { boatNotConfiguredMessage, CLOUD_HOME_SECRET_KEYS, CLOUD_IGNORED_KEYS, CLOUD_PAIRING_PATH, cloudHomeConfiguration, cloudHomeOffersPlace, cloudHomePlaceRefusal, createCloudPairing, firstCloudTurnPatch, readSignedBody, } from "./cloud-home.js";
125
+ import { CLOUD_HOME_SECRET_KEYS, CLOUD_IGNORED_KEYS, CLOUD_PAIRING_PATH, cloudHomeConfiguration, cloudHomeOffersPlace, cloudHomePlaceRefusal, createCloudPairing, firstCloudTurnPatch, readSignedBody, } from "./cloud-home.js";
122
126
  import { CLOUD_PERSONAL_REFUSAL, settleCloudOwnership } from "./cloud-owner.js";
123
127
  import { createCloudMoveRoutes, workspaceShared } from "./cloud-move-http.js";
124
128
  import { RESTART_EXIT_CODE } from "./restart.js";
125
129
  import { holdIncludedServices } from "./included-services.js";
126
130
  import { selectDefaultModelSelection, withNewBotEffort } from "./default-model-selection.js";
131
+ import { threadModelFallback } from "./thread-model.js";
132
+ import { computerEngineMoveText, removedComputerInstanceIds, writeComputerEngineMoveLines } from "./computer-engine-removal.js";
127
133
  import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, peerApprovalFailure, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
128
134
  import { peerDeliveryReceipt } from "./peer-delivery.js";
129
135
  import { peerProvenanceNote, withPeerProvenance } from "./peer-provenance.js";
130
136
  import { decideRoomPost, emptyRoomPostBudget } from "./room-post-budget.js";
131
- import { isProjectEmoji, mentionedBots, parseConnectorTools, roomResponders, sectionKey, Store, titleFromLlm, toWireTask, } from "./store.js";
137
+ import { isProjectEmoji, mentionedBots, parseConnectorTools, roomResponders, sectionKey, Store, threadTitleFrom, titleFromLlm, toWireTask, } from "./store.js";
132
138
  import * as tts from "./tts/index.js";
133
139
  import { createDecider, deciderIncludedHere, deciderReady, deciderSavePatch, describeDecider } from "./decider/index.js";
134
140
  import { decideRoomResponder } from "./decider/room-routing.js";
@@ -136,6 +142,7 @@ import { createLiveSession, liveAttachUrl, LiveSessionError } from "./live-call.
136
142
  import { LiveCallController, LiveCallSignedOutError } from "./live-call-controller.js";
137
143
  import { narrateTool, toUtterances } from "./tts/speech-text.js";
138
144
  import { turnStartLogLine } from "./turn-log.js";
145
+ import { makeCapContinuationSubscriber } from "./turn-continuation.js";
139
146
  import { buildRecoveryText, buildTurnContext, engineIsFresh, NATIVELY_REPLAYING_DRIVER_KINDS, peerMessageText } from "./turn-context.js";
140
147
  import { Handoffs, handedStateUsable, recordHanded, renderUnseen, sessionStart, unseenMessages, withUnseenMessages } from "./delta-context.js";
141
148
  import { extractTurnImages } from "./turn-images.js";
@@ -168,7 +175,7 @@ import { CalendarCallManager } from "./calendar-calls.js";
168
175
  import { BUILT_IN_BROWSER_SYSTEM_PROMPT } from "./browser-engine.js";
169
176
  import { BrowserRuntime, browserRuntimeEnv } from "./browser-runtime.js";
170
177
  import { BrowserLive } from "./browser-live.js";
171
- import { agentBrowserFrame, agentBrowserIntegration, browserEngineEncryptionKey, prepareBrowserSessionState, clearBrowserSessionState, closeBrowserSession, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, browserEngineStatus, browserSessionId, describeBrowserEngine, } from "./browser-engine.js";
178
+ import { agentBrowserFrame, agentBrowserIntegration, browserEngineEncryptionKey, prepareBrowserSessionState, clearBrowserSessionState, closeBrowserSession, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, browserEngineStatus, browserSessionId, describeBrowserEngine, setBrowserViewport, } from "./browser-engine.js";
172
179
  import { createScreenFrameSource } from "./screen-frame-source.js";
173
180
  import { screenFrameHash, screenSurfaceForTool, screenTouchingTool, settledFrameIsNews } from "./screen-frame-gate.js";
174
181
  import { asSchedule, RoutineRequestService } from "./routine-requests.js";
@@ -208,10 +215,11 @@ import { createTeamBackup, importTeamBackup } from "./team-backup.js";
208
215
  import { MAX_TEAM_BACKUP_BYTES } from "../shared/team-backup.js";
209
216
  import { shouldMountLocalComputer } from "./local-routing.js";
210
217
  import { autoLocalVmAttachable } from "./container-computer.js";
211
- import { startAutoVmClaim } from "./auto-vm-claims.js";
218
+ import { lazyClaimWaitMs, startAutoVmClaim } from "./auto-vm-claims.js";
212
219
  import { computerFreeAfterText, computerParkedText, computerStoppedWaitingText, computerWaitingText } from "./computer-wait.js";
213
220
  import { modelContextWindow } from "./model-context-window.js";
214
- import { cloudPlaceDriverError, parseSurface, PlaceUnavailableError, placeUnavailable, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
221
+ import { cloudPlaceRefusal, computerToolsRefusal, parseSurface, PlaceUnavailableError, placeUnavailable, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
222
+ import { cloudRefusal } from "../shared/place-view.js";
215
223
  import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
216
224
  import { createGracefulShutdown } from "./graceful-shutdown.js";
217
225
  import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
@@ -239,11 +247,13 @@ import { createHostedSlackRoutes } from "./routes/hosted-slack.js";
239
247
  import { createBotPresetRoutes } from "./routes/bot-presets.js";
240
248
  import { createBotMemoryRoutes } from "./routes/bot-memory.js";
241
249
  import { createDeciderRoutes } from "./routes/decider.js";
250
+ import { createThreadModelRoutes } from "./routes/thread-models.js";
242
251
  import { createDesktopViewer, desktopViewerUrl } from "./routes/desktop-viewer.js";
243
252
  import { localDesktopTarget, localVmViewerStatus, viewerTargetId } from "./desktop-viewer-targets.js";
244
253
  import { createAntigravityLeftoverRoutes } from "./routes/antigravity-leftovers.js";
245
254
  import { findAntigravityLeftovers, removeAntigravityLeftovers } from "./drivers/antigravity-temp.js";
246
255
  import { createLiveRoutes } from "./routes/live.js";
256
+ import { withScopeHint } from "./connector-scope-hint.js";
247
257
  const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799);
248
258
  const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1);
249
259
  // Behind a proxy or tunnel, the base URL senders should use (docs/self-hosting.md).
@@ -558,7 +568,7 @@ function cloudLendingTurn(capability) {
558
568
  generation: capability.generation,
559
569
  thread: store.messagesFor(capability.threadId),
560
570
  starters: cloudThreadStarters(capability.threadId),
561
- reportsFromOthers: store.messagesFor(capability.threadId).some(cloudOthersRoutineReport),
571
+ reportsFromOthers: store.messagesFor(capability.threadId).some(cloudReportFromOthers),
562
572
  ownerPerson: cloudProvenOwnerPerson,
563
573
  cardAnswerer: cloudCardAnswerer,
564
574
  routineRun: () => {
@@ -601,11 +611,13 @@ function cloudThreadStarters(threadId) {
601
611
  function cloudLendingView(capability) {
602
612
  return CLOUD_HOME !== null && cloudHomeLendingRefusal(cloudLendingTurn(capability)) === null;
603
613
  }
604
- /** On a Cloud home: a routine's report (its lifecycle card, carrying what
605
- * the run said) of a routine the owner did not write, as it stands now. It
606
- * brings someone else's instructions' results into the conversation it
607
- * reports to. */
608
- function cloudOthersRoutineReport(line) {
614
+ /** On a Cloud home: a line that brings someone else's words into the
615
+ * conversation it lands in: a "post" webhook's payload, or a routine's
616
+ * report (its lifecycle card, carrying what the run said) of a routine the
617
+ * owner did not write, as it stands now. */
618
+ function cloudReportFromOthers(line) {
619
+ if (line.webhookPost)
620
+ return true;
609
621
  if (line.kind !== "routine.run" || !line.routineRun)
610
622
  return false;
611
623
  const routine = routines?.listRoutines().find((candidate) => candidate.id === line.routineRun.routineId);
@@ -627,7 +639,7 @@ function cloudOwnerOnlyThread(threadId) {
627
639
  const lastKey = last ? `${last.id}:${last.card?.answeredBy ? JSON.stringify(last.card.answeredBy) : ""}` : undefined;
628
640
  if (cached && cached.length === thread.length && cached.last === lastKey)
629
641
  return cached.owner;
630
- const owner = ownerOnlyConversation(thread, cloudProvenOwnerPerson, cloudCardAnswerer) && !thread.some(cloudOthersRoutineReport);
642
+ const owner = ownerOnlyConversation(thread, cloudProvenOwnerPerson, cloudCardAnswerer) && !thread.some(cloudReportFromOthers);
631
643
  if (ownerOnlyCache.size > 5_000)
632
644
  ownerOnlyCache.clear();
633
645
  ownerOnlyCache.set(threadId, { length: thread.length, last: lastKey, owner });
@@ -776,16 +788,11 @@ function approvalShape(shape) {
776
788
  /** Who opens a routine's results conversation on a Cloud home: the writer
777
789
  * of this request, else the owner for a routine that is theirs (they wrote
778
790
  * it as it stands, or they are its writer: cloud-owner.ts), else its last
779
- * writer, else nobody. A webhook's run is the owner's: only their own
780
- * devices can create, edit or rotate one (admin scope), so it runs at the
781
- * bot's own level, in its folder, as on the desktop. Its payload still never
782
- * reaches the lent Mac: cloud-lending.ts refuses every webhook run. */
791
+ * writer, else nobody. */
783
792
  function routineOpener(routineId) {
784
793
  if (routineWriterInFlight)
785
794
  return routineWriterInFlight;
786
795
  const routine = routines?.listRoutines().find((candidate) => candidate.id === routineId);
787
- if (!routine && CLOUD_OWNER_KEY && webhooks.list().some((hook) => hook.id === routineId))
788
- return CLOUD_OWNER_KEY;
789
796
  if (routine && CLOUD_OWNER_KEY && cloudRoutineAuthors?.authored(routineId, routine))
790
797
  return CLOUD_OWNER_KEY;
791
798
  const writer = cloudRoutineAuthors?.writer(routineId);
@@ -832,12 +839,13 @@ function openerFrom(fromThreadId) {
832
839
  return person;
833
840
  return person && !cloudOwnerPerson(person) ? person : CLOUD_NOBODY_KEY;
834
841
  }
835
- /** The model a thread a bot opens from one of its own threads starts on:
836
- * that thread's, not the bot default the person may have moved away from
837
- * (#1950). Only the bot's own thread counts — a room or a teammate's thread
838
- * is not one of its tasks, and another bot's model belongs to another engine.
839
- * Approval stays the bot's, except that a level the inherited engine would
840
- * have to confirm on a model switch starts the new thread in Ask. */
842
+ /** The model a thread a bot opens from one of its own threads starts on
843
+ * (#1950): the model a person picked in that thread, kept as the new thread's
844
+ * own. A thread that follows the bot opens one that follows it too. Only the
845
+ * bot's own thread counts — a room or a teammate's thread is not one of its
846
+ * tasks, and another bot's model belongs to another engine. Approval stays
847
+ * the bot's, except that a level the inherited engine would have to confirm
848
+ * on a model switch starts the new thread in Ask. */
841
849
  function parentThreadModel(botId, parentThreadId) {
842
850
  // The stored profile, never a per-thread projection: the comparison is
843
851
  // against the defaults the new thread would otherwise get.
@@ -868,7 +876,7 @@ function guestWorkThread(from, target, fromThreadId, brief) {
868
876
  * to have written, or a conversation or room a revoked session opened. What
869
877
  * the owner is told about one, and when its engine cannot be confined. */
870
878
  const ROUTINE_FROM_BEFORE = "This routine was made before this update. Open it and save it once to run it with full access.";
871
- const CONVERSATION_FROM_BEFORE = "This conversation is from before your Cloud was only yours, so this bot works in it without a shell. Start a new conversation to use it fully.";
879
+ const CONVERSATION_FROM_BEFORE = "This conversation is from before My Cloud was only yours, so this bot works in it without a shell. Start a new conversation to use it fully.";
872
880
  function confinedWhy(threadId) {
873
881
  const run = activeRoutineRunForThread(threadId);
874
882
  const writer = run ? cloudRoutineAuthors?.writer(run.routineId) : undefined;
@@ -877,7 +885,7 @@ function confinedWhy(threadId) {
877
885
  function guestEngineRefusal(threadId) {
878
886
  const why = confinedWhy(threadId);
879
887
  return why === ROUTINE_FROM_BEFORE ? `This bot's engine can't run it that way. ${why}`
880
- : "This conversation is from before your Cloud was only yours, and this bot's engine can't work in it. Start a new conversation.";
888
+ : "This conversation is from before My Cloud was only yours, and this bot's engine can't work in it. Start a new conversation.";
881
889
  }
882
890
  /** On a Cloud home: a turn a guest drives, which runs in Ask whatever the
883
891
  * bot's own level (no Full access, no Auto reviewer, no saved commands), with
@@ -1528,8 +1536,6 @@ const openCodeKeysAllowed = () => openCodeProviderKeysAllowed({
1528
1536
  sharedSignIn: sharedSignIn(signInAllowList()),
1529
1537
  });
1530
1538
  setOpenCodeProviderKeyPolicy(openCodeKeysAllowed);
1531
- // Keys the owner saved for OpenCode in Settings reach it on every server.
1532
- setOpenCodeOwnProviderKeys(() => openCodeProviderKeys(cfg));
1533
1539
  let openCodeKeysLastAllowed = openCodeKeysAllowed();
1534
1540
  /** OpenCode lists a provider's models only while it may read that
1535
1541
  * provider's key. When enrollment or the sign-in list changes the answer,
@@ -1667,6 +1673,9 @@ const managedPolicy = new ManagedDesktopPolicy({ onChange: () => {
1667
1673
  broadcast({ kind: "config", ...configStatus() });
1668
1674
  resyncOpenCodeProviderKeys();
1669
1675
  } });
1676
+ // A key the provider refused, or accepted again, changes what Model
1677
+ // providers and the picker show; the config event refreshes /api/instances.
1678
+ onKeyRejectionChange(() => broadcast({ kind: "config", ...configStatus() }));
1670
1679
  /** A computer kind this server will not use, refused before anything is
1671
1680
  * prepared: a Cloud home never offers this computer or a Local VM
1672
1681
  * (cloud-home.ts), and an enrolled organisation may disallow any kind. */
@@ -1677,8 +1686,6 @@ function computerPlaceRefusal(kind) {
1677
1686
  const managed = managedPolicy.computerRefusal(kind);
1678
1687
  return managed ? { message: managed, code: "managed_policy" } : undefined;
1679
1688
  }
1680
- /** Cloud chosen with no Boat account: a Cloud home suggests the browser, not a Local VM. */
1681
- const BOAT_NOT_CONFIGURED = boatNotConfiguredMessage(Boolean(CLOUD_HOME));
1682
1689
  /** Why the organisation refuses this instance for bots, or undefined. */
1683
1690
  function policyModelRefusal(instance) {
1684
1691
  const engine = BUILT_IN_DRIVERS.find(driver => driver.driverKind === instance.driverKind)?.metadata.displayName;
@@ -1794,6 +1801,11 @@ function externalRuntimeMayCall(method, path) {
1794
1801
  return path === "/api/internal/agents" || /^\/api\/internal\/delegations\/[\w-]+$/.test(path);
1795
1802
  return method === "POST" && (path === "/api/internal/ask-bot" || path === "/api/internal/delegate-bot");
1796
1803
  }
1804
+ /** An external runtime polls check/wait_delegation for what it hands off, so
1805
+ * the result must not also wake the bot's own engine on the pinned thread. */
1806
+ function externalCompletion(capability) {
1807
+ return capability.generation === EXTERNAL_RUNTIME_GENERATION ? { completionOwner: "external" } : {};
1808
+ }
1797
1809
  function externalRuntimeCapability(header) {
1798
1810
  const grant = authorizeExternalRuntime(EXTERNAL_RUNTIMES_FILE, header, id => store.bot(id));
1799
1811
  if (!grant)
@@ -2051,6 +2063,9 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation, r
2051
2063
  const speaking = botForThread(botId, threadId) ?? store.bot(botId);
2052
2064
  return tts.voiceReady(cfg, speaking?.voice) && speaking?.voiceNotes !== false ? "1" : "0";
2053
2065
  })(),
2066
+ // And for a role: team setup, bot creation and deletion, rooms and
2067
+ // retries are shown only to a Chief of Staff, whom their routes require.
2068
+ OMB_CHIEF_OF_STAFF: store.bot(botId)?.chiefOfStaff === true ? "1" : "0",
2054
2069
  },
2055
2070
  };
2056
2071
  }
@@ -2087,14 +2102,63 @@ class ComputerWaitParked extends Error {
2087
2102
  this.generation = generation;
2088
2103
  }
2089
2104
  }
2090
- /** A place the turn was told to use failed to attach: its cause plus the one
2091
- * control that changes it (placeUnavailable). A cancelled setup, a parked
2092
- * computer wait and an already-worded place failure pass through as they
2093
- * are. The one wrapper for a bot thread and a room turn. */
2094
- function asPlaceFailure(error, place, source, limit) {
2105
+ /** A failed place never sticks: an Auto-recorded pin to it (a
2106
+ * select_computer switch, or where an earlier Auto turn landed) is the
2107
+ * machine's memory, not a choice, so the next message runs on Auto. A
2108
+ * person's pin stays. */
2109
+ function clearFailedAutoPin(botId, threadId, place) {
2110
+ const pinned = store.taskByThread(botId, threadId);
2111
+ if (pinned?.surface === place && pinned.surfaceSource === "auto") {
2112
+ store.patchTask(botId, threadId, { surface: undefined, surfaceSource: undefined });
2113
+ }
2114
+ }
2115
+ /** A place the turn was told to use failed to attach, read as one state of
2116
+ * shared/place-view.ts: a cloud computer's refusal by its code or words, any
2117
+ * other place by its own words. Its source decides the one way on. A
2118
+ * cancelled setup, a parked computer wait and an already-read place failure
2119
+ * pass through as they are. The one wrapper for a bot thread and a room
2120
+ * turn, at dispatch and at a cloud computer's start on its first call. */
2121
+ async function asPlaceFailure(error, place, source, bot, backend = "box") {
2095
2122
  if (error instanceof DirectTurnSetupCancelled || error instanceof ComputerWaitParked || error instanceof PlaceUnavailableError)
2096
2123
  return error;
2097
- return placeUnavailable(place, source, error instanceof Error ? error.message : String(error), limit);
2124
+ const failure = error instanceof Error ? error : new Error(String(error));
2125
+ const cause = failure.message.trim().replace(/[\s.]+$/, "");
2126
+ if (place !== "cloud" || backend === "vps") {
2127
+ return placeUnavailable(place, {
2128
+ state: "place-failed", params: { bot: bot.name, cause: `${cause.charAt(0).toUpperCase()}${cause.slice(1)}.` }, source,
2129
+ }, Boolean(CLOUD_HOME));
2130
+ }
2131
+ // A Boat refusal keeps the provider's own status and code (boat.ts
2132
+ // boatRefusal); a failed read of the account keeps its status.
2133
+ const { boatCode, boatStatus, status } = failure;
2134
+ const providerStatus = typeof boatStatus === "number" ? boatStatus : typeof status === "number" ? status : undefined;
2135
+ const read = cloudRefusal({
2136
+ message: failure.message,
2137
+ ...(typeof boatCode === "string" ? { code: boatCode } : {}),
2138
+ ...(providerStatus !== undefined ? { status: providerStatus } : {}),
2139
+ }, boat.boatAccount(cfg)?.included === true);
2140
+ const row = { state: read.state, params: { bot: bot.name, ...read.params }, source };
2141
+ if (row.state === "cc-at-once")
2142
+ row.params.holders = await cloudComputerHolders(bot.id);
2143
+ return placeUnavailable(place, row, Boolean(CLOUD_HOME));
2144
+ }
2145
+ /** The bots that hold this account's cloud computers, other than `botId`,
2146
+ * from this installation's own Boat list. Empty when it can't be read. */
2147
+ async function cloudComputerHolders(botId) {
2148
+ try {
2149
+ const listed = await boat.listManagedBoats(cfg, managedBoatOwners(), { adoptLegacy: false });
2150
+ return [...new Set(listed.instances
2151
+ .filter((instance) => instance.ownerBotId && instance.ownerBotId !== botId && instance.ownerName)
2152
+ .map((instance) => instance.ownerName))];
2153
+ }
2154
+ catch {
2155
+ return [];
2156
+ }
2157
+ }
2158
+ /** Cloud chosen with no cloud computers here: on a Cloud home they are the
2159
+ * plan's, so they are only unavailable; anywhere else they need a Boat key. */
2160
+ function boatNotConfigured(bot, source) {
2161
+ return placeUnavailable("cloud", { state: CLOUD_HOME ? "cc-unavailable" : "cc-needs-key", params: { bot: bot.name }, source }, Boolean(CLOUD_HOME));
2098
2162
  }
2099
2163
  const directTurnDispatchClaims = new Map();
2100
2164
  const directTurnGenerationByThread = new Map();
@@ -2520,6 +2584,7 @@ const browserRuntime = new BrowserRuntime({
2520
2584
  browserLive.closeForSession(session);
2521
2585
  return closed;
2522
2586
  },
2587
+ applyViewport: (spec) => setBrowserViewport(spec.command, spec.env),
2523
2588
  });
2524
2589
  const browserLive = new BrowserLive({ runtime: browserRuntime });
2525
2590
  // Temporary profiles last for this server run, but are never saved to disk.
@@ -2704,7 +2769,7 @@ function controlIntegration(botId, threadId, generation, machine = {}) {
2704
2769
  depth: 0,
2705
2770
  kind: "computer",
2706
2771
  ...(localVmTarget ? { localVmTarget } : {}),
2707
- ...(boxId ? { boxId } : {}),
2772
+ ...(boxId !== undefined ? { boxId } : {}),
2708
2773
  ...(teamComputerTurns.get(threadId) ? { teamComputerId: teamComputerTurns.get(threadId).computerId } : {}),
2709
2774
  skillAuthoring: false,
2710
2775
  createdBots: 0,
@@ -2767,17 +2832,89 @@ function askBotAndWait(targetBotId, message, depth, fromBotId, fromThreadId, tar
2767
2832
  }).catch((err) => finish({ status: "error", text: `(couldn't start that bot: ${err instanceof Error ? err.message : String(err)})` }));
2768
2833
  });
2769
2834
  }
2770
- // New bots honor setup's saved choice; unconfigured workspaces prefer Claude.
2771
- // While enrolled, a Company model that can run beats a signed-out personal
2772
- // engine, and the organisation's policy is respected; otherwise inert.
2773
- async function defaultSelection() {
2835
+ // New bots honor setup's saved choice; without one, an engine that can run a
2836
+ // turn now (Claude first). While enrolled, a Company model that can run beats
2837
+ // a signed-out personal engine, and the organisation's policy is respected;
2838
+ // otherwise inert. `null` leaves the saved choice out (moveOffComputerEngine).
2839
+ async function defaultSelection(saved = cfg.defaultModelSelection ?? null) {
2774
2840
  if (hostedModels)
2775
- return hostedModels.select(cfg.defaultModelSelection);
2776
- return selectDefaultModelSelection(await registry.describe(), cfg.defaultModelSelection, {
2841
+ return hostedModels.select(saved ?? undefined);
2842
+ return selectDefaultModelSelection(await registry.describe(), saved ?? undefined, {
2777
2843
  company: (instanceId) => managedDesktop.owns(instanceId),
2778
2844
  refusal: (instance) => policyModelRefusal(instance),
2779
2845
  });
2780
2846
  }
2847
+ /** The Computer engine was removed: it ran a whole turn on Boat's own agent.
2848
+ * Everything still set to it moves, once, to the engine a new bot gets
2849
+ * (defaultSelection, the one picker), or the one a new bot would get without
2850
+ * the saved choice when that cannot run: the saved default and new-bot
2851
+ * defaults, each bot and conversation (Store.retireInstances keeps each one
2852
+ * where it worked), and an automatic-recovery backup, which could never run
2853
+ * and is cleared. Each moved conversation says so in one plain line. When no
2854
+ * engine is available yet it runs again the next time the engines are read
2855
+ * (describeInstances): an install, a sign-in, a key or a Company engine. At
2856
+ * start it sees what a new bot made then would see; a policy that arrives
2857
+ * later refuses an engine for a moved bot the way it does for any bot. A
2858
+ * hosted workspace's reconcile has already moved every bot onto its own
2859
+ * models. */
2860
+ async function moveOffComputerEngine() {
2861
+ if (hostedModels)
2862
+ return;
2863
+ const removed = removedComputerInstanceIds(cfg.instances);
2864
+ const named = (selection) => Boolean(selection && removed.has(selection.instanceId));
2865
+ if (named(cfg.automaticRecovery?.backup)) {
2866
+ saveConfig({ automaticRecovery: { enabled: false } });
2867
+ cfg.automaticRecovery = { enabled: false };
2868
+ }
2869
+ const defaults = named(cfg.defaultModelSelection) || named(cfg.newBotDefaults?.profile.modelSelection);
2870
+ if (!defaults && !store.bots.some(bot => named(bot.modelSelection) || bot.fallback?.some(named) ||
2871
+ store.tasks(bot.id).some(task => named(task.modelSelection))))
2872
+ return;
2873
+ let replacement = named(cfg.defaultModelSelection) ? { instanceId: "", model: "" } : await defaultSelection();
2874
+ if (!replacement.instanceId)
2875
+ replacement = await defaultSelection(null);
2876
+ if (!replacement.instanceId) {
2877
+ console.warn("[engines] the Computer engine was removed and no other engine is available yet; its bots move when one is");
2878
+ return;
2879
+ }
2880
+ if (defaults) {
2881
+ // One write keeps both saved defaults on the replacement (saveConfig
2882
+ // syncs them), and the copies in memory follow at once, so a bot made
2883
+ // before the next start never lands on the removed engine.
2884
+ const newBotDefaults = cfg.newBotDefaults && newBotDefaultsSchema.parse({
2885
+ ...cfg.newBotDefaults, profile: { ...cfg.newBotDefaults.profile, modelSelection: replacement },
2886
+ });
2887
+ saveConfig(newBotDefaults ? { newBotDefaults } : { defaultModelSelection: replacement });
2888
+ cfg.defaultModelSelection = replacement;
2889
+ if (newBotDefaults)
2890
+ cfg.newBotDefaults = newBotDefaults;
2891
+ }
2892
+ const instance = registry.get(replacement.instanceId);
2893
+ const engine = instance?.displayName ?? instance?.driverKind ?? replacement.instanceId;
2894
+ const moves = store.retireInstances(removed, replacement, {
2895
+ driverKind: instance?.driverKind,
2896
+ canWorkOnCloud: canWorkOnCloud(cloudEngine(instance)),
2897
+ keepCloud: (bot) => boat.boatConfigured(cfg) && bot.cloudBackend !== "vps" && !inheritedTeamComputer(bot),
2898
+ });
2899
+ writeComputerEngineMoveLines(moves, (move) => {
2900
+ const bot = store.bot(move.botId);
2901
+ if (bot)
2902
+ store.appendMessage(move.threadId, { role: "bot", kind: "activity", tool: { name: computerEngineMoveText(move, bot.name, engine), ok: true } });
2903
+ });
2904
+ }
2905
+ let computerEngineMoveRunning = null;
2906
+ /** The move, once at a time; it returns at once when nothing names the
2907
+ * removed engine any more. A failure is logged and never rejects, so it never
2908
+ * stops this server from listening. Bots whose save failed are unchanged and
2909
+ * move the next time the engines are read (describeInstances) or a turn
2910
+ * reaches one of them (startTurn), so a server no app reads the engines from
2911
+ * still moves them. */
2912
+ function retryComputerEngineMove() {
2913
+ computerEngineMoveRunning ??= moveOffComputerEngine()
2914
+ .catch((error) => console.warn(`[engines] moving bots off the removed Computer engine failed: ${error instanceof Error ? error.message : String(error)}`))
2915
+ .finally(() => { computerEngineMoveRunning = null; });
2916
+ return computerEngineMoveRunning;
2917
+ }
2781
2918
  function checkedModelSelection(raw, current, requireAvailableModel = false) {
2782
2919
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
2783
2920
  return { ok: false, status: 400, error: "modelSelection must be an object" };
@@ -2879,6 +3016,62 @@ function checkedTaskModelSwitch(current, raw, updateBotDefault, resetApprovalToA
2879
3016
  }
2880
3017
  return checked;
2881
3018
  }
3019
+ /** An engine as a turn's start sees it, without waiting on its CLI: the live
3020
+ * instance and what its last read said (registry.lastSnapshot). Before the
3021
+ * first read it counts as available, so only what is known stops a pick. */
3022
+ function threadEngine(instanceId) {
3023
+ const live = registry.get(instanceId);
3024
+ if (!live)
3025
+ return undefined;
3026
+ return {
3027
+ instanceId, driverKind: live.driverKind, enabled: live.enabled, models: live.models,
3028
+ capabilities: { modelVariants: live.adapter.capabilities.modelVariants === true },
3029
+ snapshot: registry.lastSnapshot(instanceId) ?? { state: "available" },
3030
+ access: providerConfigs()[instanceId]?.access ?? registry.access(instanceId),
3031
+ };
3032
+ }
3033
+ /** This thread's own model, when it gives way to its bot's now
3034
+ * (threadModelFallback); null when the thread follows its bot or its own
3035
+ * model runs as picked. */
3036
+ function threadModelGivingWay(botId, threadId) {
3037
+ const bot = store.bot(botId);
3038
+ const own = store.taskByThread(botId, threadId)?.modelSelection;
3039
+ if (!bot || !own)
3040
+ return null;
3041
+ return threadModelFallback(own, bot.modelSelection, threadEngine, {
3042
+ refusal: policyModelRefusal,
3043
+ ...(hostedModels ? { allows: (selection) => hostedModels.allows(selection) } : {}),
3044
+ }) ? own : null;
3045
+ }
3046
+ /** The model a turn in this thread would run on now (healThreadModel). */
3047
+ function turnSelection(botId, threadId) {
3048
+ return threadModelGivingWay(botId, threadId) ? store.bot(botId)?.modelSelection : store.projectBotForTask(botId, threadId)?.modelSelection;
3049
+ }
3050
+ /** A model as a person reads it: its catalog label, after its engine's name
3051
+ * while that engine is here. */
3052
+ function modelName(selection) {
3053
+ const instance = registry.get(selection.instanceId);
3054
+ const label = instance?.models.options.find((option) => option.id === selection.model)?.label ?? selection.model;
3055
+ return instance ? `${instance.displayName || instance.driverKind} · ${label}` : label;
3056
+ }
3057
+ /** A thread's own model that can't run here gives way to its bot's: the turn
3058
+ * about to start runs on the bot's model, the thread follows the bot from
3059
+ * then on, and one plain line in it says so. Every turn starts through
3060
+ * startTurn (a person's message, a routine, a delegation, a webhook, a
3061
+ * queued follow-up), so this is the one place it happens; a room turn
3062
+ * already runs on each member's own model. */
3063
+ function healThreadModel(botId, threadId) {
3064
+ const bot = store.bot(botId);
3065
+ const own = threadModelGivingWay(botId, threadId);
3066
+ if (!bot || !own || bot.approvalGrant || threadBusy(botId, threadId))
3067
+ return;
3068
+ if (!store.followBotModel(botId, [threadId], (from, to) => hostedModels?.resetTask(from, to) ?? {}).length)
3069
+ return;
3070
+ store.appendMessage(threadId, { role: "bot", kind: "activity", tool: {
3071
+ name: `notice: This thread's model (${modelName(own)}) isn't available, so it now uses ${bot.name}'s model (${modelName(bot.modelSelection)}).`,
3072
+ ok: true,
3073
+ } });
3074
+ }
2882
3075
  function checkedExportSkillNames(value, bots) {
2883
3076
  // Sharing instructions is explicit: ordinary package exports include none.
2884
3077
  if (value === undefined)
@@ -3075,13 +3268,27 @@ function checkedMemberIds(value) {
3075
3268
  return { ok: true, memberIds };
3076
3269
  }
3077
3270
  let bootSelection = { instanceId: "", model: "" };
3271
+ /** The engines read at start, while it runs (below). It is where the server
3272
+ * learns what each engine supports (a Claude CLI's version and flags), so a
3273
+ * turn waits for it, as every turn did when the server listened only after
3274
+ * that read. */
3275
+ let enginesRead = null;
3078
3276
  /** Preset bots for New bot, from shared files and the organization (presets.ts). */
3079
3277
  const presetStore = createPresetStore();
3080
- const store = new Store(() => bootSelection, (selection) => withNewBotEffort(selection, cfg.newBots?.effort, registry.get(selection.instanceId)?.adapter.capabilities.effortLevels));
3278
+ const store = new Store(() => bootSelection, (selection) => withNewBotEffort(selection, cfg.newBots?.effort, registry.get(selection.instanceId)?.adapter.capabilities.effortLevels), (instanceId) => registry.cliTarget(instanceId)?.driverKind);
3081
3279
  const teamComputers = new TeamComputers(join(DATA_DIR, "team-computers.json"), ENVIRONMENT_ID);
3082
3280
  let followupsReady = false;
3083
3281
  const sendSequencer = new SendSequencer();
3084
- bootSelection = await defaultSelection();
3282
+ // Before the new-bot default is read: a saved default may name the engine.
3283
+ await retryComputerEngineMove();
3284
+ // Reading the new-bot default probes every engine CLI (each up to its
3285
+ // timeout), and at start only the first-run seed needs the answer. A later
3286
+ // start listens without waiting: the same read runs behind it, and turns
3287
+ // wait for it (enginesRead).
3288
+ if (store.needsSeed())
3289
+ bootSelection = await defaultSelection();
3290
+ else
3291
+ enginesRead = defaultSelection().then((selection) => { bootSelection = selection; }, (error) => console.warn(`[engines] reading the engines at start failed: ${error instanceof Error ? error.message : String(error)}`)).finally(() => { enginesRead = null; });
3085
3292
  store.seedIfEmpty();
3086
3293
  hostedModels?.reconcile(store);
3087
3294
  // Skills library boot sweep (features.skillsLibrary): migrate per-bot
@@ -3135,11 +3342,13 @@ if (browserCleanupReferencesReconciled)
3135
3342
  * than the desktop window did. Stripped here rather than at each call site
3136
3343
  * so a new broadcast cannot forget. */
3137
3344
  let activeCoordinationForThread = (_threadId) => false;
3138
- const wireTask = (task) => {
3345
+ /** One thread as a client sees it. `botModel` is its bot's model: a thread
3346
+ * without a model of its own runs on it (toWireTask). */
3347
+ const wireTask = (task, botModel) => {
3139
3348
  // Time-based snoozes heal on read against the server clock — no client
3140
3349
  // timer, no device skew. The 0 sentinel ("until new activity") is not a
3141
3350
  // time and survives reads; only a wake event in the store clears it.
3142
- const { snoozedUntil, ...base } = toWireTask(task);
3351
+ const { snoozedUntil, ...base } = toWireTask(task, botModel);
3143
3352
  const coordinated = { ...base, waitingForTeammates: activeCoordinationForThread(task.threadId) && !task.busy };
3144
3353
  const asleep = snoozedUntil === 0 || (snoozedUntil !== undefined && snoozedUntil > Date.now());
3145
3354
  return asleep ? { ...coordinated, snoozedUntil } : coordinated;
@@ -3153,7 +3362,7 @@ const wireBot = (bot) => {
3153
3362
  ? { ...rest, approvalMode: "ask", autoApprove: false }
3154
3363
  : rest;
3155
3364
  return { ...visible, waitingForTeammates: activeCoordinationForThread(bot.threadId) && !threadBusy(bot.id, bot.threadId),
3156
- avatarUrl: visible.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
3365
+ avatarUrl: visible.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map((task) => wireTask(task, bot.modelSelection)) } : {}) };
3157
3366
  };
3158
3367
  function toolScopeForTurn(botId) {
3159
3368
  const bot = store.bot(botId);
@@ -3168,7 +3377,7 @@ function toolScopeForTurn(botId) {
3168
3377
  * can validate it before sending the confirmation that makes it effective. */
3169
3378
  const wireTrustedApprovalBot = (bot) => {
3170
3379
  const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, assignedSkills: _assignedSkills, ...rest } = bot;
3171
- return { ...rest, approvalMode: approvalModeFor(rest), avatarUrl: rest.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
3380
+ return { ...rest, approvalMode: approvalModeFor(rest), avatarUrl: rest.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map((task) => wireTask(task, bot.modelSelection)) } : {}) };
3172
3381
  };
3173
3382
  /** A settings-based preview, not a receipt of a dispatched turn. No
3174
3383
  * provisioning or credentials are needed to inspect it. The selected engine
@@ -3190,7 +3399,6 @@ function previewSystemPrompt(bot) {
3190
3399
  const computerPromptKind = resolveComputerPromptKind({
3191
3400
  kind: previewComputer === "vm" ? "vm" : previewComputer === "cloud"
3192
3401
  ? bot.cloudBackend === "vps" ? "vps" : "box" : previewComputer === "local" ? "local" : null,
3193
- driverKind: instance?.driverKind,
3194
3402
  vmPrivate: localVmMode(cfg) === "per-bot",
3195
3403
  });
3196
3404
  const peers = reachablePeers(store.bots, bot);
@@ -3230,7 +3438,7 @@ function previewSystemPrompt(bot) {
3230
3438
  computer: previewPlan.computer && previewPlan.computer !== "off" && computerPromptKind ? previewPlan.computer : null,
3231
3439
  browser: previewPlan.computer === undefined ? false : previewPlan.browser,
3232
3440
  }, { note: previewPlan.note, cloudHome: Boolean(CLOUD_HOME) }) },
3233
- { id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(agentsMounted && lendingEnabled()) : "" },
3441
+ { id: "cloud-home", label: "My Cloud", text: CLOUD_HOME ? cloudHomePrompt(agentsMounted && lendingEnabled()) : "" },
3234
3442
  { id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? composioSystemPrompt(bot.connectorTools) + describeConnectorScopes(bot.connectorScopes) : "" },
3235
3443
  { id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(engineMcpServers(bot))) : "" },
3236
3444
  { id: "browser", label: "Browser", text: previewPlan.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
@@ -3823,7 +4031,7 @@ const storedAvatarExists = (avatarUrl) => attachmentExists(avatarUrl.slice("/api
3823
4031
  const publicBot = (bot) => ({
3824
4032
  ...wireBot(bot),
3825
4033
  ...messagePage(bot.threadId, DEFAULT_PAGE),
3826
- tasks: store.tasks(bot.id).map(wireTask),
4034
+ tasks: store.tasks(bot.id).map((task) => wireTask(task, bot.modelSelection)),
3827
4035
  });
3828
4036
  const publicBotQueuedMessages = () => queuedSteerSnapshot((botId, threadId) => Boolean(store.taskByThread(botId, threadId)));
3829
4037
  // busyBotId names only the speaker that currently owns the provider process.
@@ -4015,6 +4223,15 @@ function updateChannel(groupId, value) {
4015
4223
  throw Object.assign(new Error("invalid default responder"), { status: 400 });
4016
4224
  patch.defaultResponder = responder;
4017
4225
  }
4226
+ if (Object.prototype.hasOwnProperty.call(body, "turnTimeoutMinutes")) {
4227
+ if (!existing.dm) {
4228
+ throw Object.assign(new Error("set the turn limit on that conversation"), { status: 400 });
4229
+ }
4230
+ const parsed = parseConversationTurnTimeout(body.turnTimeoutMinutes);
4231
+ if (!parsed.ok)
4232
+ throw Object.assign(new Error(parsed.error), { status: 400 });
4233
+ patch.turnTimeoutMinutes = parsed.minutes;
4234
+ }
4018
4235
  if (body.cwd !== undefined) {
4019
4236
  if (existing.dm)
4020
4237
  throw Object.assign(new Error("direct-message channels cannot have a working folder"), { status: 400 });
@@ -4335,7 +4552,14 @@ function publicGroupState(record) {
4335
4552
  usage = groupUsageReader.forThread(group.threadId);
4336
4553
  }
4337
4554
  catch { /* accounting must not block chat */ }
4338
- return { ...group, usage: usage ?? null, working: groupIsWorking(group) || [...roomHandoffs.nodes.values()].some(n => n.groupId === group.id && !["completed", "failed", "cancelled"].includes(n.status)) };
4555
+ return {
4556
+ ...group,
4557
+ // Always present so a client can tell "back to the group default"
4558
+ // apart from a partial patch that did not mention the field.
4559
+ turnTimeoutMinutes: record.turnTimeoutMinutes ?? null,
4560
+ usage: usage ?? null,
4561
+ working: groupIsWorking(group) || [...roomHandoffs.nodes.values()].some(n => n.groupId === group.id && !["completed", "failed", "cancelled"].includes(n.status)),
4562
+ };
4339
4563
  }
4340
4564
  function beginGroupTurnOperation(groupId, threadId, botIds = []) {
4341
4565
  const operation = {
@@ -5449,6 +5673,10 @@ bus.subscribe((event) => {
5449
5673
  else if (event.type !== "session.exited")
5450
5674
  watchdog.touch(event.threadId);
5451
5675
  });
5676
+ // Automatic continuity: a turn that died on its budget or on tool errors gets
5677
+ // a `Continue:` task on the same bot, seeded with the persisted handoff — the
5678
+ // work moves forward without a person noticing and re-dispatching.
5679
+ bus.subscribe(makeCapContinuationSubscriber({ store, startTurn }));
5452
5680
  // Memory journal turn boundary (server/memory-journal.ts). A bot's own
5453
5681
  // file-tool writes to MEMORY.md and memory/ have no hook to tap, so the
5454
5682
  // diff against the baseline taken at dispatch is made when the turn
@@ -5945,7 +6173,7 @@ function inheritedTeamComputer(bot) {
5945
6173
  return teamComputers.forBot(bot);
5946
6174
  }
5947
6175
  function teamComputerPrompt(computer) {
5948
- return computer ? `Your team shares the Boat computer ${JSON.stringify(computer.name)}. Its desktop files and desktop browser logins are shared with other Auto bots in your team; only one turn may drive it at a time. The separate built-in Browser is not this desktop and does not automatically share its logins.` : "";
6176
+ return computer ? `Your team shares the cloud computer ${JSON.stringify(computer.name)}. Its desktop files and desktop browser logins are shared with other Auto bots in your team; only one turn may drive it at a time. The separate built-in Browser is not this desktop and does not automatically share its logins.` : "";
5949
6177
  }
5950
6178
  function botComputerControlKey(bot) {
5951
6179
  const computer = inheritedTeamComputer(bot);
@@ -5956,21 +6184,26 @@ async function computerCallGate(internalCapability) {
5956
6184
  const snapshot = botComputerControlSnapshot(botId, internalCapability.teamComputerId);
5957
6185
  const slot = autoVmClaims.get(internalCapability.threadId);
5958
6186
  const lazyClaim = slot && slot.owner.generation === internalCapability.generation ? slot : undefined;
5959
- if (!snapshot.held && lazyClaim?.lazy && !lazyClaim.begin) {
5960
- // First screen tools/call on a lazily-attached Auto VM (issue
6187
+ const waitMs = snapshot.held ? null : lazyClaimWaitMs(lazyClaim, LAZY_VM_CLAIM_GRACE_MS);
6188
+ if (lazyClaim && waitMs !== null) {
6189
+ // First screen tools/call on a lazily-attached computer (issue
5961
6190
  // #1361): fire the exclusive claim — once — and give it a moment
5962
6191
  // to land. A free, ready VM claims in the time of one container
5963
6192
  // inspect, so this call then proceeds with an honest answer;
5964
6193
  // only a claim still queued behind another holder answers held
5965
- // below, and then the contention text is true. Keyed on the
5966
- // slot, never on the thread's turn-computer entry: a bind this
6194
+ // below, and then the contention text is true. A cloud computer's
6195
+ // claim creates or wakes it, so every call waits on that start
6196
+ // (graceMs) instead of being told someone else holds it. Keyed on
6197
+ // the slot, never on the thread's turn-computer entry: a bind this
5967
6198
  // turn abandoned earlier (a VPS that turned out to be asleep)
5968
6199
  // must not hide the unclaimed VM and let the call through.
5969
6200
  startAutoVmClaim(autoVmClaims, internalCapability.threadId, internalCapability.generation);
6201
+ let timer;
5970
6202
  await Promise.race([
5971
6203
  lazyClaim.begin ?? Promise.resolve(),
5972
- new Promise((resolve) => setTimeout(resolve, LAZY_VM_CLAIM_GRACE_MS)),
6204
+ new Promise((resolve) => { timer = setTimeout(resolve, waitMs); }),
5973
6205
  ]);
6206
+ clearTimeout(timer);
5974
6207
  }
5975
6208
  if (!snapshot.held && lazyClaim?.failed === true) {
5976
6209
  // A rejected lazy claim (gate finding F1, issue #1361): the
@@ -5990,10 +6223,11 @@ async function computerCallGate(internalCapability) {
5990
6223
  }
5991
6224
  if (!snapshot.held && lazyClaim?.lazy && lazyClaim.begin && !lazyClaim.claimed) {
5992
6225
  // The claim fired and is still waiting on the exclusive bind:
5993
- // another turn genuinely holds this desktop right now.
6226
+ // another turn genuinely holds this desktop right now. A slow claim
6227
+ // says what it is still doing instead.
5994
6228
  return {
5995
6229
  held: true, helpOpen: false,
5996
- blockedReason: "Another thread is using this computer. This call was not performed. Pause computer work until that thread finishes, then take a fresh screenshot before acting.",
6230
+ blockedReason: lazyClaim.pendingReason ?? "Another thread is using this computer. This call was not performed. Pause computer work until that thread finishes, then take a fresh screenshot before acting.",
5997
6231
  };
5998
6232
  }
5999
6233
  const computer = turnComputerResources.get(internalCapability.threadId);
@@ -6019,11 +6253,6 @@ function teamComputerInUse(computer) {
6019
6253
  [...teamComputerTurns.values()].some(turn => turn.computerId === computer.id) ||
6020
6254
  store.bots.some(bot => computer.section !== null && sectionKey(bot.section) === computer.section && (botHasActiveTurn(bot.id) || Boolean(routines?.activeRunForBot(bot.id)) || computerControl.snapshot(bot.id).held));
6021
6255
  }
6022
- function assertTeamControlCanBeTaken(computerId) {
6023
- if ([...teamComputerTurns.values()].some(turn => turn.computerId === computerId && turn.remoteAgent)) {
6024
- throw Object.assign(new Error("Stop the Computer engine's active turn before taking control of this shared desktop"), { status: 409 });
6025
- }
6026
- }
6027
6256
  function claimTeamComputerLifecycle(computer) {
6028
6257
  if (computerProviderConfigTransitions.has("box"))
6029
6258
  throw Object.assign(new Error(providerTransitionMessage("box")), { status: 409 });
@@ -6060,9 +6289,9 @@ async function teamComputersPayload() {
6060
6289
  }
6061
6290
  /** The same named Boat identity and whole-turn lease in chats and rooms.
6062
6291
  * Assignment authorizes waking, never replacing a missing paid machine. */
6063
- async function attachTeamBoat(computer, botId, owner, canMount, remoteAgent) {
6292
+ async function attachTeamBoat(computer, botId, owner, canMount) {
6064
6293
  if (!canMount)
6065
- throw new Error("This model cannot use the team's Boat computer; choose a model provider with computer tools or an explicit bot destination");
6294
+ throw new Error("This model cannot use the team's cloud computer; choose a model provider with computer tools or an explicit bot destination");
6066
6295
  if (!boat.boatConfigured(cfg))
6067
6296
  throw new Error("The team's Boat account is not configured; reconnect it in Settings");
6068
6297
  const ownerId = teamComputerOwner(computer.id);
@@ -6071,46 +6300,51 @@ async function attachTeamBoat(computer, botId, owner, canMount, remoteAgent) {
6071
6300
  if (computerControl.snapshot(ownerId).held)
6072
6301
  throw new Error("Release human control of the team computer before starting another turn");
6073
6302
  await bindTurnComputer(owner, `computer:box-bot:${ownerId}`, true);
6074
- teamComputerTurns.set(owner.threadId, { owner, computerId: computer.id, botId, remoteAgent });
6303
+ teamComputerTurns.set(owner.threadId, { owner, computerId: computer.id, botId });
6075
6304
  let machine = await boat.findBoat(cfg, ownerId);
6076
6305
  if (!machine)
6077
- throw new Error("The team's Boat computer is missing; explicitly create or retry it from the Team map");
6306
+ throw new Error("The team's cloud computer is missing; explicitly create or retry it from the Team map");
6078
6307
  await bindTurnComputer(owner, `computer:box:${machine.id}`, true);
6079
- const action = boat.boatTurnLifecycleAction({ explicitCloud: true, state: typeof machine.state === "string" ? machine.state : null });
6308
+ const action = boat.boatTurnLifecycleAction(typeof machine.state === "string" ? machine.state : null);
6080
6309
  if (action === "wake")
6081
6310
  machine = await boat.readyBoat(cfg, ownerId);
6082
- if (!machine || boat.boatTurnLifecycleAction({ explicitCloud: true, state: typeof machine.state === "string" ? machine.state : null }) !== "attach") {
6311
+ if (!machine || boat.boatTurnLifecycleAction(typeof machine.state === "string" ? machine.state : null) !== "attach") {
6083
6312
  throw new Error("The team computer is not ready; check it in the Team map");
6084
6313
  }
6085
6314
  if (turnResourceOwners.get(owner.threadId)?.generation !== owner.generation ||
6086
6315
  !turnResources.owns(`computer:box:${machine.id}`, owner))
6087
6316
  throw new Error("This computer turn ended while its machine was starting");
6088
6317
  return {
6089
- mount: cloudComputerMount(botId, owner, machine.id, remoteAgent),
6318
+ mount: cloudComputerMount(botId, owner, machine.id),
6090
6319
  capture: () => boat.screenshotBoat(cfg, ownerId, machine.id),
6091
6320
  };
6092
6321
  }
6093
- /** The two engine facts the cloud-computer rule reads (shared/cloud-computer.ts). */
6094
- function cloudEngine(instance) {
6095
- return { driverKind: instance?.driverKind, computerMcp: instance?.adapter.capabilities.computerMcp };
6096
- }
6097
- /** How a turn's engine reaches a cloud computer. The Computer engine runs its
6098
- * whole turn on the Boat. Every other engine keeps its own model and sign-in
6099
- * and gets the Boat as one more stdio computer server (harness-mcp-proxy
6100
- * computer), in the same slot a Local VM or VPS uses: one mechanism on a
6101
- * desktop, a headless server and an OMB Cloud. The agent process holds only a
6102
- * turn-scoped capability naming this Boat, never a Boat credential. */
6103
- function cloudComputerMount(botId, owner, boxId, remoteAgent) {
6104
- if (remoteAgent)
6105
- return { computer: { kind: "box", boxId } };
6322
+ /** The engine fact the cloud-computer rule reads (shared/cloud-computer.ts),
6323
+ * and the model's name for a refusal to say. */
6324
+ function cloudEngine(instance, model) {
6325
+ return {
6326
+ computerMcp: instance?.adapter.capabilities.computerMcp,
6327
+ name: model?.trim() || instance?.displayName || instance?.driverKind || "This model",
6328
+ };
6329
+ }
6330
+ /** How a turn's engine reaches a cloud computer. Every engine keeps its own
6331
+ * model and sign-in and gets the Boat as one more stdio computer server
6332
+ * (harness-mcp-proxy computer), in the same slot a Local VM or VPS uses: one
6333
+ * mechanism on a desktop, a headless server and an OMB Cloud. The agent
6334
+ * process holds only a turn-scoped capability naming this Boat, never a Boat
6335
+ * credential. */
6336
+ function cloudComputerMount(botId, owner, boxId) {
6337
+ return { localComputer: cloudComputerTools(botId, owner, boxId) };
6338
+ }
6339
+ /** The cloud computer's stdio tools for this turn. `boxId` null: the Boat is
6340
+ * the one the first computer call's claim creates or wakes (mountBotBoat). */
6341
+ function cloudComputerTools(botId, owner, boxId) {
6106
6342
  const control = controlIntegration(botId, owner.threadId, owner.generation, { boxId });
6107
6343
  return {
6108
- localComputer: {
6109
- command: process.execPath,
6110
- args: [SPAWNED_PROXIES.harnessMcp, "computer"],
6111
- env: { ...AGENTS_NODE_FLAG, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`, OMB_MCP_TOKEN: control.token },
6112
- platform: "linux",
6113
- },
6344
+ command: process.execPath,
6345
+ args: [SPAWNED_PROXIES.harnessMcp, "computer"],
6346
+ env: { ...AGENTS_NODE_FLAG, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`, OMB_MCP_TOKEN: control.token },
6347
+ platform: "linux",
6114
6348
  };
6115
6349
  }
6116
6350
  /** "Works on: This computer", strict. One mount for a bot thread and a group
@@ -6137,8 +6371,12 @@ async function mountHostComputer(owner, botId, providerSupportsLocal) {
6137
6371
  * is claimed on the first computer call, through the computer-control gate
6138
6372
  * (the Local VM's seam, #1361), never at mount: turns that never touch the
6139
6373
  * computer tools run side by side. `start` may provision or start the
6140
- * container; without it the VPS is only inspected. The caller has already
6141
- * recorded the thread with vpsThreadStarted and ends it on a problem. */
6374
+ * container; without it the VPS is only inspected. Unlike the bot's cloud
6375
+ * computer (mountBotBoat), the start stays here, at dispatch: the VPS tools
6376
+ * are a bridge that execs into the running container as soon as the engine
6377
+ * launches it (runMcpBridge), and a self-hosted container uses no plan
6378
+ * hours. The caller has already recorded the thread with vpsThreadStarted
6379
+ * and ends it on a problem. */
6142
6380
  async function mountBotVps(bot, owner, opts) {
6143
6381
  const vpsResource = `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`;
6144
6382
  const remote = opts.start
@@ -6169,54 +6407,113 @@ async function mountBotVps(bot, owner, opts) {
6169
6407
  },
6170
6408
  };
6171
6409
  }
6172
- /** The bot's own Boat. Explicit Cloud is the consent boundary: it may create a
6173
- * missing machine and wake an archived one (~8s, and it un-pauses billing);
6174
- * Auto (only ever the Computer engine, see startTurn) only attaches a machine
6175
- * that is already running. Callers have already checked that the engine can
6176
- * work on it (cloudPlaceDriverError). */
6177
- async function attachBotBoat(bot, owner, opts) {
6178
- // Explicit cloud turns can provision/wake the same bot's Boat. Claim before
6179
- // any network await so setup itself cannot race another turn.
6180
- if (opts.explicitCloud)
6181
- await bindTurnComputer(owner, `computer:box-bot:${bot.id}`, true);
6182
- let b;
6183
- try {
6184
- b = await boat.findBoat(cfg, bot.id);
6185
- }
6186
- catch (error) {
6187
- // Auto may fall through when an optional provider is offline, but a
6188
- // durable deletion fence must never be mistaken for "no computer".
6189
- if (opts.explicitCloud || error?.status === 409)
6190
- throw error;
6191
- b = null;
6192
- }
6193
- const lifecycleOf = (explicitCloud) => boat.boatTurnLifecycleAction({
6194
- explicitCloud,
6195
- state: typeof b?.state === "string" ? b.state : null,
6196
- });
6197
- let lifecycle = lifecycleOf(opts.explicitCloud);
6410
+ /** How long one computer call waits on a cloud computer that is being
6411
+ * created or woken before it is told to look again. Under the 60 seconds an
6412
+ * engine may give one tool call (Codex's default); a call that runs out finds
6413
+ * the start still going and waits again, inside the start's own budget.
6414
+ * OMB_CLOUD_COMPUTER_START_WAIT_MS shortens it for tests. */
6415
+ const CLOUD_COMPUTER_START_WAIT_MS = Math.max(1_000, Number(process.env.OMB_CLOUD_COMPUTER_START_WAIT_MS) || 45_000);
6416
+ /** How long the start itself may take once this turn has the computer to
6417
+ * itself: above a slow first start (about a minute; Boat's own ready wait is
6418
+ * 90 seconds). Past it the start has failed, and the turn says so and ends,
6419
+ * instead of every call answering "still starting" until the watchdog.
6420
+ * OMB_CLOUD_COMPUTER_START_BUDGET_MS shortens it for tests. */
6421
+ const CLOUD_COMPUTER_START_BUDGET_MS = Math.max(1_000, Number(process.env.OMB_CLOUD_COMPUTER_START_BUDGET_MS) || 150_000);
6422
+ const CLOUD_COMPUTER_STARTING = "The cloud computer is still starting. This call was not performed. Take a fresh screenshot in a moment.";
6423
+ const CLOUD_COMPUTER_DID_NOT_START = "the cloud computer didn't start in time";
6424
+ /** The bot's own Boat as a computer its engine uses, mounted like the VPS
6425
+ * (mountBotVps): the tools mount now, and the Boat is created or woken by
6426
+ * the turn's first computer call, through the computer-control gate (the
6427
+ * Local VM's seam, #1361), never at mount. A turn that never touches the
6428
+ * screen ("hi") makes no Boat call at all, so it costs no plan hours. The
6429
+ * claim is attachBotBoat itself, with its explicit-Cloud consent: the person
6430
+ * chose this place, and the bot is now using it. */
6431
+ function mountBotBoat(bot, owner, opts) {
6432
+ const slot = {
6433
+ owner,
6434
+ lazy: true,
6435
+ startsComputer: true,
6436
+ label: "the cloud computer",
6437
+ graceMs: CLOUD_COMPUTER_START_WAIT_MS,
6438
+ // Until this turn has the computer to itself another conversation does,
6439
+ // and a call is told so (the gate's contention text); from then on the
6440
+ // claim is starting it.
6441
+ onRejected: opts.onRejected,
6442
+ claim: async () => {
6443
+ let attached;
6444
+ try {
6445
+ attached = await attachBotBoat(bot, owner, {
6446
+ budgetMs: CLOUD_COMPUTER_START_BUDGET_MS,
6447
+ onSeated: () => { slot.pendingReason = CLOUD_COMPUTER_STARTING; },
6448
+ });
6449
+ if (!attached)
6450
+ throw new Error("the cloud computer could not be created or reached");
6451
+ }
6452
+ catch (error) {
6453
+ throw opts.source ? await asPlaceFailure(error, "cloud", opts.source, bot, "box") : error;
6454
+ }
6455
+ slot.boxId = attached.boxId;
6456
+ opts.onClaimed(attached.capture);
6457
+ },
6458
+ };
6459
+ autoVmClaims.set(owner.threadId, slot);
6460
+ return cloudComputerTools(bot.id, owner, null);
6461
+ }
6462
+ /** The Boat a lazily mounted cloud computer's claim landed on, for this
6463
+ * exact turn; none while the claim is still running or after it failed. */
6464
+ function claimedBoatId(capability) {
6465
+ const slot = autoVmClaims.get(capability.threadId);
6466
+ return slot?.owner.generation === capability.generation && slot.claimed ? slot.boxId : undefined;
6467
+ }
6468
+ /** The bot's own Boat, for a turn whose place is Cloud. That choice is the
6469
+ * consent boundary: it may create a missing machine and wake an archived one
6470
+ * (~8s, and it un-pauses billing). Auto never reaches here. Callers have
6471
+ * already checked that the engine can work on it (cloudPlaceRefusal).
6472
+ * Every engine reaches it through mountBotBoat, on its first computer call:
6473
+ * told once this turn has the Boat to itself, it has `budgetMs` to find,
6474
+ * create or wake it. */
6475
+ async function attachBotBoat(bot, owner, start) {
6476
+ // Claim before any network await so setup itself cannot race another turn.
6477
+ await bindTurnComputer(owner, `computer:box-bot:${bot.id}`, true);
6478
+ start.onSeated();
6479
+ const machine = await withinBudget(startBotBoat(bot), start.budgetMs, CLOUD_COMPUTER_DID_NOT_START);
6480
+ if (!machine)
6481
+ return null;
6482
+ await bindTurnComputer(owner, `computer:box:${machine.id}`);
6483
+ return {
6484
+ boxId: machine.id,
6485
+ capture: () => boat.screenshotBoat(cfg, bot.id, machine.id),
6486
+ };
6487
+ }
6488
+ /** `work`, or a rejection with `message` once `ms` pass first. The work is
6489
+ * not cancelled: it ends on its own requests' deadlines, and the race has
6490
+ * already handled a rejection it brings later. */
6491
+ function withinBudget(work, ms, message) {
6492
+ let timer;
6493
+ const expired = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); });
6494
+ return Promise.race([work, expired]).finally(() => clearTimeout(timer));
6495
+ }
6496
+ /** Find the bot's Boat, creating or waking it: the Boat once it can be
6497
+ * attached, or null. */
6498
+ async function startBotBoat(bot) {
6499
+ let b = await boat.findBoat(cfg, bot.id);
6500
+ const lifecycleOf = () => boat.boatTurnLifecycleAction(typeof b?.state === "string" ? b.state : null);
6501
+ let lifecycle = lifecycleOf();
6198
6502
  if (lifecycle === "provision") {
6199
- broadcast({ kind: "computer", botId: bot.id, state: "provisioning" });
6503
+ broadcast({ kind: "computer", botId: bot.id, state: "provisioning", place: "cloud" });
6200
6504
  await boat.provisionBoat(cfg, bot.id, bot.name);
6201
6505
  b = await boat.findBoat(cfg, bot.id);
6202
- lifecycle = lifecycleOf(true);
6506
+ lifecycle = lifecycleOf();
6203
6507
  }
6204
6508
  // an archived boat answers every action with an error until it resumes —
6205
6509
  // wake it here, once, instead of letting the agent discover it one failed
6206
6510
  // tool call at a time.
6207
6511
  if (lifecycle === "wake") {
6208
- broadcast({ kind: "computer", botId: bot.id, state: "waking" });
6512
+ broadcast({ kind: "computer", botId: bot.id, state: "waking", place: "cloud" });
6209
6513
  b = (await boat.readyBoat(cfg, bot.id)) ?? b;
6210
- lifecycle = lifecycleOf(true);
6514
+ lifecycle = lifecycleOf();
6211
6515
  }
6212
- if (!b || lifecycle !== "attach")
6213
- return null;
6214
- const machine = b;
6215
- await bindTurnComputer(owner, `computer:box:${machine.id}`, opts.remoteAgent);
6216
- return {
6217
- capture: () => boat.screenshotBoat(cfg, bot.id, machine.id),
6218
- mount: cloudComputerMount(bot.id, owner, machine.id, opts.remoteAgent),
6219
- };
6516
+ return b && lifecycle === "attach" ? b : null;
6220
6517
  }
6221
6518
  function managedBoatOwners() {
6222
6519
  return [...store.bots.map((bot) => ({
@@ -6288,8 +6585,6 @@ function turnProvider(bot, runOn, threadId) {
6288
6585
  const wants = turnSurfacePlan(bot, runOn, threadId).computer;
6289
6586
  if (wants !== undefined && wants !== "cloud")
6290
6587
  return null;
6291
- if (registry.get(bot.modelSelection.instanceId)?.adapter.capabilities.remoteAgent === true)
6292
- return "box";
6293
6588
  return bot.cloudBackend === "vps" ? "vps" : wants === "cloud" ? "box" : null;
6294
6589
  }
6295
6590
  /** Preview requests carry the selected conversation, not whichever thread
@@ -6308,8 +6603,6 @@ async function computerPreviewSurface(bot, threadId) {
6308
6603
  if (plan.computer !== undefined)
6309
6604
  return plan.computer === "off" && plan.browser ? "browser" : plan.computer;
6310
6605
  const instance = registry.get(bot.modelSelection.instanceId);
6311
- if (instance?.adapter.capabilities.remoteAgent === true)
6312
- return "cloud";
6313
6606
  if (bot.cloudBackend === "vps") {
6314
6607
  const remote = await vps.vpsComputerStatus(cfg, bot.id);
6315
6608
  if (remote.ready)
@@ -6337,7 +6630,8 @@ async function selectableComputers(bot) {
6337
6630
  const instance = registry.get(bot.modelSelection.instanceId);
6338
6631
  const caps = instance?.adapter.capabilities;
6339
6632
  const off = bot.computer === "off";
6340
- const localEngine = caps?.remoteAgent !== true;
6633
+ // A desktop the bot's tools cannot reach is not offered, and not probed.
6634
+ const toolsLeaveOut = computerToolsRefusal(bot.toolScope, "works-on", bot.name)?.message;
6341
6635
  const surfaces = ["cloud", "vm", "local", "browser"].filter(surface => !CLOUD_HOME || cloudHomeOffersPlace(surface));
6342
6636
  return Promise.all(surfaces.map(async (surface) => {
6343
6637
  let ready = false;
@@ -6351,24 +6645,31 @@ async function selectableComputers(bot) {
6351
6645
  try {
6352
6646
  if (off)
6353
6647
  reason = "Computer access is Off in this bot's settings.";
6648
+ else if (toolsLeaveOut && surface !== "browser")
6649
+ reason = toolsLeaveOut;
6354
6650
  else if (surface === "cloud") {
6355
6651
  if (bot.cloudBackend === "vps") {
6356
- const status = canWorkOnCloud(cloudEngine(instance), "vps") ? await vps.vpsComputerStatus(cfg, bot.id) : null;
6652
+ const status = canWorkOnCloud(cloudEngine(instance)) ? await vps.vpsComputerStatus(cfg, bot.id) : null;
6357
6653
  ready = status?.ready === true;
6358
6654
  canStart = Boolean(status?.daemonUp && status.managed && status.container === "stopped" &&
6359
6655
  status.image && status.imageMatches && status.network === "private" && status.mounts === "none" && status.security === "hardened");
6360
6656
  canCreate = Boolean(status?.configured && status.daemonUp && status.container === "missing");
6361
6657
  reason = status?.problem ?? reason;
6362
6658
  }
6363
- else if (boat.boatConfigured(cfg) && canWorkOnCloud(cloudEngine(instance), "box")) {
6659
+ else if (boat.boatConfigured(cfg) && canWorkOnCloud(cloudEngine(instance))) {
6364
6660
  const status = await boat.boatStatus(cfg, bot.id);
6365
- const lifecycle = boat.boatTurnLifecycleAction({ explicitCloud: true, state: status.box?.state ?? null });
6661
+ const lifecycle = boat.boatTurnLifecycleAction(status.box?.state ?? null);
6366
6662
  ready = lifecycle === "attach";
6367
6663
  canStart = lifecycle === "wake";
6368
6664
  canCreate = lifecycle === "provision";
6369
6665
  }
6666
+ else {
6667
+ // The words the person reads for the same place (shared/place-view.ts).
6668
+ reason = (cloudPlaceRefusal(cloudEngine(instance, bot.modelSelection.model), "works-on", bot.name)
6669
+ ?? boatNotConfigured(bot, "works-on")).message;
6670
+ }
6370
6671
  }
6371
- else if (surface === "vm" && localEngine && caps?.computerMcp) {
6672
+ else if (surface === "vm" && caps?.computerMcp) {
6372
6673
  const target = localVmTargetForStatus(bot.id);
6373
6674
  const status = await containerComputerStatus(undefined, undefined, target);
6374
6675
  ready = status.ready;
@@ -7881,12 +8182,15 @@ function finalizeDelegationWatch(threadId, ok, reply = "", failureName = "Delega
7881
8182
  // with the reply only visible in the thread (the "delegated and went
7882
8183
  // silent" gap). Failures wake it too — the user must hear the task did
7883
8184
  // not finish. Idle-checked and burst-capped so a busy source or a
7884
- // re-delegating loop cannot spin up runs.
7885
- if (ok) {
7886
- wakeDelegationSource(source, watched.sourceThreadId, targetName, undefined, watched.routineRunId);
7887
- }
7888
- else if (!ok) {
7889
- wakeDelegationSource(source, watched.sourceThreadId, targetName, failureName || "the delegated turn did not finish", watched.routineRunId);
8185
+ // re-delegating loop cannot spin up runs. An external runtime polls
8186
+ // for the receipt instead; waking would answer it a second time.
8187
+ if (watched.completionOwner !== "external") {
8188
+ if (ok) {
8189
+ wakeDelegationSource(source, watched.sourceThreadId, targetName, undefined, watched.routineRunId);
8190
+ }
8191
+ else {
8192
+ wakeDelegationSource(source, watched.sourceThreadId, targetName, failureName || "the delegated turn did not finish", watched.routineRunId);
8193
+ }
7890
8194
  }
7891
8195
  }
7892
8196
  }
@@ -7941,7 +8245,7 @@ bus.subscribe((event) => {
7941
8245
  /** How a drained delegation becomes a real turn on the target. Shared by
7942
8246
  * the settle-time drain and the boot-time drain of what a previous process
7943
8247
  * left queued. */
7944
- const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel, taskId, sourceBotId, openedThreadId, oneWay) => {
8248
+ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel, taskId, sourceBotId, openedThreadId, oneWay, completionOwner) => {
7945
8249
  // startTurn REJECTS on an ordinary condition — busy target, deleted bot,
7946
8250
  // unavailable provider. Unhandled, that rejection is fatal to the
7947
8251
  // harness (Node's default), which in the packaged app kills the server
@@ -7975,6 +8279,7 @@ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel,
7975
8279
  routineRunId: activeRoutineRunForThread(sourceThreadId)?.id,
7976
8280
  startedAtMs: Date.now(),
7977
8281
  ...(oneWay ? { oneWay: true } : {}),
8282
+ ...(completionOwner ? { completionOwner } : {}),
7978
8283
  });
7979
8284
  }
7980
8285
  let failureReported = false;
@@ -8469,10 +8774,9 @@ const screenPollers = new Map();
8469
8774
  const SCREEN_POLL_MS = 6000;
8470
8775
  const SCREEN_MIN_GAP_MS = 3000;
8471
8776
  const SCREEN_SETTLE_TIMEOUT_MS = 10_000;
8472
- /** `screenIsTheWork` starts the turn already counting as screen usage: a
8473
- * boxAgent's whole session runs ON the boat, so every tool it calls acts on
8474
- * that screen even though none of them is named like a computer tool. Its
8475
- * shell-only turns are kept honest by the settle-time hash gate instead. */
8777
+ /** `screenIsTheWork` starts the poller already counting as screen usage: a
8778
+ * poller restarted mid-turn (a lazy claim swapping in its capture) carries
8779
+ * over whether the turn had touched its screen. */
8476
8780
  function startScreenPoller(botId, threadId, captures, { screenIsTheWork = false } = {}) {
8477
8781
  if (!captures.computer && !captures.browser)
8478
8782
  return;
@@ -8564,10 +8868,9 @@ function shownScreenHash(threadId) {
8564
8868
  * end state, not the previous action's. A turn that never touched the
8565
8869
  * screen settles nothing — and skips the capture, which is one less
8566
8870
  * command on the boat's single endpoint. A frame the reader can already see
8567
- * settles nothing either: the boxAgent pre-touch counts every turn as
8568
- * screen work, so without this its shell-only replies would all end in the
8569
- * same idle desktop. Either way the poller is torn down here, so no
8570
- * per-turn state survives the turn. */
8871
+ * settles nothing either, so a turn never ends on the same idle desktop it
8872
+ * already showed. Either way the poller is torn down here, so no per-turn
8873
+ * state survives the turn. */
8571
8874
  async function finalScreenFrame(_botId, threadId) {
8572
8875
  const entry = screenPollers.get(threadId);
8573
8876
  const owner = turnResourceOwners.get(threadId);
@@ -8641,6 +8944,8 @@ async function startTurn(botId, text, opts) {
8641
8944
  },
8642
8945
  };
8643
8946
  }
8947
+ // A thread whose own model can't run runs on its bot's from now on.
8948
+ healThreadModel(botId, threadId);
8644
8949
  const bot = store.projectBotForTask(botId, threadId);
8645
8950
  if (!bot)
8646
8951
  throw Object.assign(new Error("no such task"), { status: 404 });
@@ -8695,6 +9000,10 @@ async function startTurn(botId, text, opts) {
8695
9000
  const plan = turnSurfacePlan(bot, opts?.runOn, threadId);
8696
9001
  const instance = registry.get(bot.modelSelection.instanceId);
8697
9002
  if (!instance) {
9003
+ // Still on the removed Computer engine: its move failed or found no
9004
+ // engine. Try it again now, so the next message runs on the new engine.
9005
+ if (removedComputerInstanceIds(cfg.instances).has(bot.modelSelection.instanceId))
9006
+ void retryComputerEngineMove();
8698
9007
  throw Object.assign(new Error(`provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`), { status: 409 });
8699
9008
  }
8700
9009
  const policyRefusal = policyModelRefusal(instance);
@@ -8878,6 +9187,11 @@ async function startTurn(botId, text, opts) {
8878
9187
  turnContext.delete(threadId);
8879
9188
  void (async () => {
8880
9189
  try {
9190
+ if (enginesRead) {
9191
+ await enginesRead;
9192
+ if (!directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId))
9193
+ throw new DirectTurnSetupCancelled("turn stopped while the engines were read");
9194
+ }
8881
9195
  // Readiness can wait on the network. Admit the task first so its busy
8882
9196
  // state, Stop action and watchdog own that wait just like VM setup.
8883
9197
  if (opts?.runOn === "cloud") {
@@ -8988,7 +9302,11 @@ async function startTurn(botId, text, opts) {
8988
9302
  const userTurnText = promptWithReply(skillAuthoring ? expandLearnTurnText(setupText) : setupText, opts?.replyTo, cfg.profile?.name?.trim() || "User");
8989
9303
  // Decided again at dispatch when setup outlasted a soul edit (config).
8990
9304
  const decideContext = (config) => {
8991
- const handedStale = Boolean(handed && (!unseen || (externalUpdate && handed.config !== config)));
9305
+ // A Codex thread keeps the last effort it was sent and no turn can clear
9306
+ // one: back on the default, a thread that holds a level is rebuilt from
9307
+ // the transcript rather than resumed on that level.
9308
+ const effortHeld = !effort && typeof handed?.effort === "string";
9309
+ const handedStale = Boolean(handed && (!unseen || effortHeld || (externalUpdate && handed.config !== config)));
8992
9310
  const { block: unseenBlock, placed } = unseen && !handedStale ? renderUnseen(unseen) : { block: "", placed: [] };
8993
9311
  const { turnText: contextTurnText, resume } = buildTurnContext({
8994
9312
  text: userTurnText,
@@ -9021,6 +9339,7 @@ async function startTurn(botId, text, opts) {
9021
9339
  resumeCursor, sessionReset: !resume, recoveryText, recoveryIsReplay,
9022
9340
  handoff: strictResume ? {
9023
9341
  botId: bot.id, instanceId, config, resumeCursor: typeof resumeCursor === "string" ? resumeCursor : undefined,
9342
+ ...(instance.driverKind === "codex" ? { effort: effort ?? null } : {}),
9024
9343
  started: sessionStart(contextOrder, contextTurnText !== userTurnText ? windowIds : [], carried),
9025
9344
  recovery: sessionStart(contextOrder, recoveryText !== undefined ? windowIds : [], carried),
9026
9345
  resumed: sessionStart(contextOrder, [], [...placed, ...carried]),
@@ -9093,8 +9412,7 @@ async function startTurn(botId, text, opts) {
9093
9412
  // because moving a live session would break resume.
9094
9413
  // A cloud run keeps the bot's own engine on this host, with the cloud
9095
9414
  // computer as one more tool, so it works in the same folder as any
9096
- // other turn (only the Computer engine runs on the Boat, and it has no
9097
- // workspace files).
9415
+ // other turn.
9098
9416
  // On a Cloud home a conversation a guest opened is pinned to its own
9099
9417
  // folder when it first runs, not the bot's project folder the owner's
9100
9418
  // conversations share (what it leaves there reaches none of them). One
@@ -9135,9 +9453,6 @@ async function startTurn(botId, text, opts) {
9135
9453
  // landed. A team computer or a cloud routine is not this conversation's
9136
9454
  // choice, so those ignore the pin.
9137
9455
  const dispatchTask = store.taskByThread(bot.id, threadId);
9138
- if (plan.computer !== undefined && plan.computer !== "cloud" && instance.adapter.capabilities.remoteAgent === true) {
9139
- throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
9140
- }
9141
9456
  const wants = plan.computer;
9142
9457
  // A place the organisation disallows, or a Cloud home never offers, is
9143
9458
  // refused before anything is prepared; Auto below simply skips them.
@@ -9154,12 +9469,14 @@ async function startTurn(botId, text, opts) {
9154
9469
  : opts?.runOn === "cloud" ? "routine"
9155
9470
  : plan.pinned ? (dispatchTask?.surfaceSource === "auto" ? "auto-pin" : "pin")
9156
9471
  : "works-on";
9157
- // An engine that cannot use the cloud computer is refused here, before
9158
- // anything is created or woken — never handed to another engine.
9472
+ // An engine that cannot use the cloud computer, or a Tool selection
9473
+ // that leaves its tools out, is refused here, before anything is
9474
+ // created or woken — never handed to another engine.
9159
9475
  if (wants === "cloud" && placeSource) {
9160
- const unsupported = cloudPlaceDriverError(cloudEngine(instance), cloudBackend, placeSource);
9476
+ const unsupported = cloudPlaceRefusal(cloudEngine(instance, bot.modelSelection.model), placeSource, bot.name)
9477
+ ?? computerToolsRefusal(toolScope, placeSource, bot.name);
9161
9478
  if (unsupported)
9162
- throw new PlaceUnavailableError("cloud", unsupported);
9479
+ throw unsupported;
9163
9480
  }
9164
9481
  let previewCapture = null;
9165
9482
  let browserCapture = null;
@@ -9285,7 +9602,7 @@ async function startTurn(botId, text, opts) {
9285
9602
  // the newer turn. The gate's refusals stay fail-closed for any screen
9286
9603
  // call racing this teardown; the turn-failed buzz and incident follow
9287
9604
  // the dispatch-failure rules (person-started turns only).
9288
- const surfaceLazyClaimRejection = (label) => (failure) => {
9605
+ const surfaceLazyClaimRejection = (label) => (failure, error) => {
9289
9606
  if (activeInternalGenerationByThread.get(threadId) !== resourceOwner.generation || !threadBusy(bot.id, threadId))
9290
9607
  return;
9291
9608
  // The wait ceiling parked this turn (#1651): register the resume and
@@ -9304,11 +9621,16 @@ async function startTurn(botId, text, opts) {
9304
9621
  void interruptDirectThread(bot.id, threadId, { preserveComputerResume: true }).catch(() => { });
9305
9622
  return;
9306
9623
  }
9307
- const message = `computer unavailable — ${label} could not be claimed for this turn (${failure})`;
9624
+ // A place that failed (mountBotBoat) is already read as its one line
9625
+ // and one way on, and never sticks as an Auto-recorded pin.
9626
+ const placeFailure = error instanceof PlaceUnavailableError ? error : undefined;
9627
+ if (placeFailure)
9628
+ clearFailedAutoPin(bot.id, threadId, placeFailure.place);
9629
+ const message = placeFailure?.message ?? `computer unavailable — ${label} could not be claimed for this turn (${failure})`;
9308
9630
  store.appendMessage(threadId, {
9309
9631
  role: "bot",
9310
9632
  kind: "activity",
9311
- tool: failedTurnTool(message),
9633
+ tool: failedTurnTool(message, placeFailure ? { place: placeFailure.row } : {}),
9312
9634
  });
9313
9635
  if (opts?.automationSource === undefined && !opts?.commsDepth && !opts?.cardContinuation) {
9314
9636
  notify(buildNotification("turn-failed", bot, threadId, redactSecretsInText(message), { avatarUrl: bot.avatarUrl }));
@@ -9317,8 +9639,26 @@ async function startTurn(botId, text, opts) {
9317
9639
  // completion fold must not report this failure a second time.
9318
9640
  resourceOwner.lazyClaimFailureReported = true;
9319
9641
  }
9642
+ // A routine, a delegation or a resume reports this exactly as it
9643
+ // reports a place that failed at dispatch: with this cause, before
9644
+ // the interrupt settles the turn as merely "interrupted".
9645
+ opts?.onDispatchError?.(message);
9320
9646
  void interruptDirectThread(bot.id, threadId).catch(() => { });
9321
9647
  };
9648
+ /** A lazily claimed remote desktop (the VPS, the bot's Boat) streams
9649
+ * from the moment its claim lands. The dispatch-site poller start saw
9650
+ * no capture (the claim runs after dispatch), so restart the poller
9651
+ * with this one, keeping any browser capture and whether this turn
9652
+ * already touched its screen. Same still-running guard as dispatch: a
9653
+ * poller started after its own turn.completed would never stop. */
9654
+ const adoptClaimedCapture = (capture) => {
9655
+ previewCapture = capture;
9656
+ if (!threadBusy(bot.id, threadId))
9657
+ return;
9658
+ const touched = screenPollers.get(threadId)?.touched ?? false;
9659
+ stopScreenPoller(bot.id, threadId);
9660
+ startScreenPoller(bot.id, threadId, { computer: capture, ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: touched });
9661
+ };
9322
9662
  // Explicit destinations are strict. In particular, Local VM must never
9323
9663
  // fall through to host CUA and accidentally click on the user's Mac.
9324
9664
  // The Local VM attach, shared by explicit "Local VM" and by Auto. Explicit
@@ -9327,7 +9667,7 @@ async function startTurn(botId, text, opts) {
9327
9667
  // be recreated on demand after idling away — and never creates a first
9328
9668
  // VM on its own; every failure there is a quiet "not this place".
9329
9669
  const attachLocalVm = async (strict) => {
9330
- if (!mountsComputerMcp || instance.adapter.capabilities.remoteAgent === true) {
9670
+ if (!mountsComputerMcp) {
9331
9671
  if (!strict)
9332
9672
  return false;
9333
9673
  throw new Error("this model cannot use the Local VM — choose Claude or an ACP model provider, or select another computer destination");
@@ -9383,7 +9723,7 @@ async function startTurn(botId, text, opts) {
9383
9723
  // guard as dispatch: a poller started after its own
9384
9724
  // turn.completed would never be torn down.
9385
9725
  if (previewCapture && threadBusy(bot.id, threadId)) {
9386
- const touched = screenPollers.get(threadId)?.touched ?? instance.adapter.capabilities.remoteAgent === true;
9726
+ const touched = screenPollers.get(threadId)?.touched ?? false;
9387
9727
  stopScreenPoller(bot.id, threadId);
9388
9728
  startScreenPoller(bot.id, threadId, { computer: previewCapture, ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: touched });
9389
9729
  }
@@ -9430,7 +9770,7 @@ async function startTurn(botId, text, opts) {
9430
9770
  // the person explicitly opted this bot into remote lifecycle actions.
9431
9771
  // On Auto an engine that cannot use a computer simply skips it.
9432
9772
  if ((wants === "cloud" || (wants === undefined && managedPolicy.computerAllowed("vps"))) && cloudBackend === "vps" &&
9433
- canWorkOnCloud(cloudEngine(instance), "vps")) {
9773
+ canWorkOnCloud(cloudEngine(instance))) {
9434
9774
  // The VPS "computer" is the desktop inside this bot's managed
9435
9775
  // container, and only screen work needs that desktop to itself.
9436
9776
  // So the lease is claimed on the first computer call, through the
@@ -9447,12 +9787,7 @@ async function startTurn(botId, text, opts) {
9447
9787
  // Local VM's lazy claim does.
9448
9788
  onClaimed: (vpsCapture) => {
9449
9789
  pinAutoSurface("cloud");
9450
- previewCapture = vpsCapture;
9451
- if (threadBusy(bot.id, threadId)) {
9452
- const touched = screenPollers.get(threadId)?.touched ?? false;
9453
- stopScreenPoller(bot.id, threadId);
9454
- startScreenPoller(bot.id, threadId, { computer: vpsCapture, ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: touched });
9455
- }
9790
+ adoptClaimedCapture(vpsCapture);
9456
9791
  },
9457
9792
  onRejected: surfaceLazyClaimRejection("the VPS computer"),
9458
9793
  });
@@ -9469,30 +9804,31 @@ async function startTurn(botId, text, opts) {
9469
9804
  }
9470
9805
  }
9471
9806
  if (teamComputer) {
9472
- const attached = await attachTeamBoat(teamComputer, bot.id, resourceOwner, canWorkOnCloud(cloudEngine(instance), "box"), instance.adapter.capabilities.remoteAgent === true);
9807
+ const attached = await attachTeamBoat(teamComputer, bot.id, resourceOwner, canWorkOnCloud(cloudEngine(instance)));
9473
9808
  Object.assign(integrations, attached.mount);
9474
9809
  previewCapture = attached.capture;
9475
9810
  computerKind = "box";
9476
9811
  }
9477
- // Cloud is strict when selected. On Auto only the Computer engine,
9478
- // whose whole turn runs on its Boat, attaches one, and only one that
9479
- // is already running. Every other engine reaches the Boat only when
9812
+ // Cloud is strict when selected. A turn reaches the Boat only when
9480
9813
  // this conversation is on it (Works on, a pin, a cloud routine, or
9481
9814
  // select_computer): an Auto turn never reads the Boat account, so it
9482
9815
  // adds no Boat call, cannot be stopped by a Boat being deleted, and
9483
- // never records a place nobody chose.
9484
- const remoteAgent = instance.adapter.capabilities.remoteAgent === true;
9485
- if (!teamComputer && cloudBackend === "box" && boat.boatConfigured(cfg) &&
9486
- (wants === "cloud" || (wants === undefined && remoteAgent && !computerPlaceRefusal("box")))) {
9487
- const attached = await attachBotBoat(bot, resourceOwner, { explicitCloud: wants === "cloud", remoteAgent });
9488
- if (attached) {
9489
- previewCapture = attached.capture;
9490
- Object.assign(integrations, attached.mount);
9491
- computerKind = "box";
9492
- }
9816
+ // never records a place nobody chose. Even then the Boat is created
9817
+ // or woken only by the bot's first computer call (mountBotBoat): a
9818
+ // chat that never touches the screen uses no plan hours.
9819
+ if (!teamComputer && cloudBackend === "box" && boat.boatConfigured(cfg) && wants === "cloud") {
9820
+ integrations.localComputer = mountBotBoat(bot, resourceOwner, {
9821
+ // The row a failed attach always wrote: the place's one line and
9822
+ // its one way on, read where the place came from (an
9823
+ // Auto-recorded pin is cleared, as dispatch clears it).
9824
+ source: placeSource,
9825
+ onClaimed: adoptClaimedCapture,
9826
+ onRejected: surfaceLazyClaimRejection("the cloud computer"),
9827
+ });
9828
+ computerKind = "box";
9493
9829
  }
9494
9830
  if (wants === "cloud" && cloudBackend === "box" && !boat.boatConfigured(cfg)) {
9495
- throw new Error(BOAT_NOT_CONFIGURED);
9831
+ throw boatNotConfigured(bot, placeSource ?? "works-on");
9496
9832
  }
9497
9833
  if (wants === "cloud" && cloudBackend === "box" && computerKind !== "box") {
9498
9834
  throw new Error("the cloud computer could not be created or reached");
@@ -9503,21 +9839,20 @@ async function startTurn(botId, text, opts) {
9503
9839
  // they are.
9504
9840
  if (!namedPlace || !placeSource)
9505
9841
  throw error;
9506
- throw asPlaceFailure(error, namedPlace, placeSource);
9842
+ throw await asPlaceFailure(error, namedPlace, placeSource, bot, cloudBackend);
9507
9843
  }
9508
9844
  // Auto-only host fallback. Electron owns cua-driver/TCC attribution;
9509
9845
  // the harness only reads its already-running connection descriptor.
9510
9846
  // Auto reaches a Local VM this bot already has before it ever touches the
9511
9847
  // host's own desktop: on a headless server that VM is the only desktop
9512
9848
  // there is, and a person who prepared one meant it to be used.
9513
- if (wants === undefined && !integrations.computer && !integrations.localComputer && !computerPlaceRefusal("localVm") && await attachLocalVm(false))
9849
+ if (wants === undefined && !integrations.localComputer && !computerPlaceRefusal("localVm") && await attachLocalVm(false))
9514
9850
  computerKind = "vm";
9515
9851
  // An unattended run on a bot with a VPS configured never lands on the
9516
9852
  // host's own desktop instead: a scheduled job clicking on someone's
9517
9853
  // laptop is worse than a scheduled job that fails and says why.
9518
9854
  const unattendedVps = cloudBackend === "vps" && Boolean(opts?.automationSource);
9519
- if (!integrations.computer &&
9520
- !integrations.localComputer &&
9855
+ if (!integrations.localComputer &&
9521
9856
  wants === undefined &&
9522
9857
  !unattendedVps &&
9523
9858
  !computerPlaceRefusal("thisComputer") &&
@@ -9552,7 +9887,6 @@ async function startTurn(botId, text, opts) {
9552
9887
  }
9553
9888
  if (wants === undefined &&
9554
9889
  cloudBackend === "vps" &&
9555
- !integrations.computer &&
9556
9890
  !integrations.localComputer &&
9557
9891
  autoVpsProblem &&
9558
9892
  !computerSelectionTurns.has(threadId)) {
@@ -9560,7 +9894,7 @@ async function startTurn(botId, text, opts) {
9560
9894
  ? "This scheduled run tried to start the VPS computer and could not reach it. Check the VPS connection in Settings → API keys."
9561
9895
  : bot.autoStartVps
9562
9896
  ? "Check the VPS connection in Settings → API keys."
9563
- : "Open Computer and enable Start VPS automatically, or choose Cloud to start it manually.";
9897
+ : "Open Computer and enable Start VPS automatically, or choose Cloud computer to start it manually.";
9564
9898
  throw new Error(`${autoVpsProblem}. ${hint}`);
9565
9899
  }
9566
9900
  // Agent control tools include peer comms and the secure credential
@@ -9683,9 +10017,8 @@ async function startTurn(botId, text, opts) {
9683
10017
  // Pin on use (issue #1650): every computer that registers a claim
9684
10018
  // slot — a ready Local VM, the VPS, the host fallback, a VM created
9685
10019
  // for this turn — records its pin inside the claim itself, so a turn
9686
- // that only mounted tools records nothing. The Computer engine's Boat
9687
- // (its whole turn runs there) and the built-in browser (no seat to
9688
- // claim) pin here.
10020
+ // that only mounted tools records nothing. The built-in browser (no
10021
+ // seat to claim) pins here.
9689
10022
  if (autoPinAllowed()) {
9690
10023
  const claimSlot = autoVmClaims.get(threadId);
9691
10024
  const pinsAtClaim = claimSlot?.owner.generation === resourceOwner.generation;
@@ -9706,7 +10039,6 @@ async function startTurn(botId, text, opts) {
9706
10039
  }
9707
10040
  const computerPromptKind = resolveComputerPromptKind({
9708
10041
  kind: computerKind,
9709
- driverKind: instance.driverKind,
9710
10042
  vmPrivate: localVmMode(cfg) === "per-bot",
9711
10043
  });
9712
10044
  const coordinationNode = opts?.coordination ? roomHandoffs.nodes.get(opts.coordination.id) : undefined;
@@ -9723,7 +10055,7 @@ async function startTurn(botId, text, opts) {
9723
10055
  { id: "computer", label: "Computer", text: computerPrompt(computerPromptKind) },
9724
10056
  { id: "team-computer", label: "Team computer", text: teamComputerPrompt(teamComputer) },
9725
10057
  { id: "plan", label: "Surface", text: surfacePrompt({ computer: mountedComputer, browser: Boolean(integrations.browser) }, { pinned: plan.pinned, note: plan.note, canSelect: computerSelectionTurns.has(threadId), cloudHome: Boolean(CLOUD_HOME) }) },
9726
- { id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
10058
+ { id: "cloud-home", label: "My Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
9727
10059
  // gated on the integration, not the key: the hint only goes to a
9728
10060
  // bot whose driver actually mounted the tools
9729
10061
  { id: "composio", label: "Connected apps", text: integrations.composio ? composioSystemPrompt(liveBot?.connectorTools ?? bot.connectorTools) + describeConnectorScopes((liveBot ?? bot).connectorScopes) : "" },
@@ -9814,6 +10146,7 @@ async function startTurn(botId, text, opts) {
9814
10146
  mentionTurn: tagged.length > 0,
9815
10147
  integrations,
9816
10148
  mcpFromUserConfig: claudeUserMcpEnabled(cfg) && !managedPolicy.restrictsMcp(),
10149
+ mcpCallTimeoutMs: mcpCallTimeoutMinutes(cfg) * 60_000,
9817
10150
  cwd,
9818
10151
  }), () => !directTurnClaimExists(bot.id, dispatchClaimId, threadId), async () => {
9819
10152
  await instance.adapter.interruptTurn(threadId).catch(() => { });
@@ -9856,7 +10189,7 @@ async function startTurn(botId, text, opts) {
9856
10189
  // keep polling the boat forever, carrying dead per-turn state. busy
9857
10190
  // is flipped false in the fold, so it is the honest "still running".
9858
10191
  if ((previewCapture || browserCapture) && threadBusy(bot.id, threadId)) {
9859
- startScreenPoller(bot.id, threadId, { ...(previewCapture ? { computer: previewCapture } : {}), ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: instance.adapter.capabilities.remoteAgent === true });
10192
+ startScreenPoller(bot.id, threadId, { ...(previewCapture ? { computer: previewCapture } : {}), ...(browserCapture ? { browser: browserCapture } : {}) });
9860
10193
  }
9861
10194
  // An adapter may publish completion synchronously just before its
9862
10195
  // dispatch promise resolves. The event could not use the turn-id map
@@ -9950,16 +10283,10 @@ async function startTurn(botId, text, opts) {
9950
10283
  drainDelegationWakes();
9951
10284
  return;
9952
10285
  }
9953
- // A failed place never sticks. An Auto-recorded pin to it (a
9954
- // select_computer switch, or where an earlier Auto turn landed) is the
9955
- // machine's memory, not a choice: clear it so the next message runs on
9956
- // Auto. A person's pin stays, and the message names the composer.
9957
- if (e instanceof PlaceUnavailableError) {
9958
- const pinned = store.taskByThread(bot.id, threadId);
9959
- if (pinned?.surface === e.place && pinned.surfaceSource === "auto") {
9960
- store.patchTask(bot.id, threadId, { surface: undefined, surfaceSource: undefined });
9961
- }
9962
- }
10286
+ // A failed place never sticks; a person's pin stays, and the message
10287
+ // names the composer.
10288
+ if (e instanceof PlaceUnavailableError)
10289
+ clearFailedAutoPin(bot.id, threadId, e.place);
9963
10290
  let message = e instanceof Error ? e.message : String(e);
9964
10291
  const current = store.projectBotForTask(bot.id, threadId);
9965
10292
  const backups = cfg.automaticRecovery?.enabled
@@ -10014,7 +10341,7 @@ async function startTurn(botId, text, opts) {
10014
10341
  role: "bot",
10015
10342
  kind: "activity",
10016
10343
  turnSucceeded: false,
10017
- tool: failedTurnTool(message),
10344
+ tool: failedTurnTool(message, e instanceof PlaceUnavailableError ? { place: e.row } : {}),
10018
10345
  });
10019
10346
  // Worth a buzz for the same reason a routine failure is, and the rule
10020
10347
  // notify.ts encodes: the bot is not working, and the cause is usually
@@ -10057,6 +10384,9 @@ function routineSourceOwner(run) {
10057
10384
  ? { bot, group: undefined, threadId: task.threadId }
10058
10385
  : null;
10059
10386
  }
10387
+ // Without a results snapshot (a run from before every routine reported to
10388
+ // its bot's main thread, or a bot with no visible thread) the chat that
10389
+ // made the routine is the destination.
10060
10390
  const threadId = run.sourceThreadId?.trim();
10061
10391
  if (!threadId)
10062
10392
  return null;
@@ -10077,6 +10407,21 @@ function routineSourceOwner(run) {
10077
10407
  const group = store.groupByThread(threadId);
10078
10408
  return group?.memberIds.includes(bot.id) ? { bot, group, threadId } : null;
10079
10409
  }
10410
+ /** There is no durable "main thread" (bot.threadId is only the UI
10411
+ * selection), so a routine reports into the bot's oldest open conversation:
10412
+ * the one it was created with until that is deleted or archived. Deleting
10413
+ * the last one already gives the bot a fresh conversation (Store.deleteTask). */
10414
+ function botMainThread(botId) {
10415
+ const visible = store.tasks(botId).filter((task) => !task.routineRunId);
10416
+ const open = visible.filter((task) => task.archivedAt === undefined);
10417
+ return (open.length ? open : visible).reduce((oldest, task) => (!oldest || task.createdAt < oldest.createdAt ? task : oldest), undefined)?.threadId;
10418
+ }
10419
+ /** Routines without a chosen thread used to get an automatic "<name> ·
10420
+ * Results" thread. One nobody renamed is that automatic default, not a
10421
+ * choice: new runs move to the main thread, and the old thread stays. */
10422
+ function legacyResultsThread(routineName, task) {
10423
+ return task.title.endsWith(" · Results") || task.title === threadTitleFrom(`${routineName} · Results`);
10424
+ }
10080
10425
  function routineSourceThread(run) {
10081
10426
  return routineSourceOwner(run)?.threadId ?? null;
10082
10427
  }
@@ -10275,13 +10620,18 @@ routines = new RoutineManager({
10275
10620
  }
10276
10621
  return groupIsWorking(group) || coordinator.busy ? "busy" : "ready";
10277
10622
  },
10278
- createTask: (botId, title, activate = false, routineId) => {
10623
+ createTask: (botId, title, activate = false, run) => {
10279
10624
  const task = store.createTask(botId, title, activate);
10280
10625
  // On a Cloud home a run's conversation is opened, like its results
10281
10626
  // conversation, by whoever wrote the routine: a run of one that is
10282
10627
  // nobody's is confined to a folder of its own, never the bot's project.
10283
- if (task && CLOUD_HOME && routineId)
10284
- threadStarters.set(task.threadId, routineOpener(routineId));
10628
+ // A webhook's run is the owner's: only their own devices can create,
10629
+ // edit or rotate a webhook, so it works at the bot's own level, in its
10630
+ // folder, as on the desktop. Its payload still never reaches the lent
10631
+ // Mac: cloud-lending.ts refuses every webhook run by its trigger.
10632
+ if (task && CLOUD_HOME && run) {
10633
+ threadStarters.set(task.threadId, run.triggerSource === "webhook" ? CLOUD_OWNER_KEY : routineOpener(run.routineId));
10634
+ }
10285
10635
  // The store's frame announces it. A run's task that stays in the
10286
10636
  // background leaves the open thread alone, so that frame carries no
10287
10637
  // transcript: a whole thread on every scheduled run once passed the phone
@@ -10305,21 +10655,32 @@ routines = new RoutineManager({
10305
10655
  return Boolean(bot && !bot.hidden && task && !task.routineRunId);
10306
10656
  },
10307
10657
  resolveResultsThread: (routine, forceNew) => {
10308
- // A trusted chat source is already snapshotted as sourceThreadId on each
10309
- // run. Keep it distinct: it can belong to a teammate or room, whereas an
10310
- // explicit resultsThreadId must be a visible task owned by the running bot.
10311
- if (!forceNew && routineSourceOwner(routine))
10312
- return routine.resultsThreadId;
10658
+ // Every run reports into the routine's bot's main thread, wherever the
10659
+ // routine was made (chat, editor, API, or a teammate's request). A run
10660
+ // still executes in its own hidden task, which Run logs and Open run use.
10661
+ // A thread the person chose in "Post results to" keeps winning.
10662
+ const bot = store.bot(routine.botId);
10663
+ const chosen = !forceNew && routine.resultsThreadId && bot && !bot.hidden
10664
+ ? store.taskByThread(bot.id, routine.resultsThreadId) : undefined;
10665
+ // On a Cloud home a routine reports only where its writer may write: a
10666
+ // guest's (or nobody's) keeps a results conversation opened as theirs.
10667
+ const opener = CLOUD_HOME ? routineOpener(routine.id) : undefined;
10668
+ const main = bot && !bot.hidden ? botMainThread(bot.id) : undefined;
10669
+ const mainAllowed = main && (!CLOUD_HOME || (opener === CLOUD_OWNER_KEY && !cloudGuestOpened(main)));
10670
+ const keepChosen = chosen && !chosen.routineRunId && !(mainAllowed && legacyResultsThread(routine.name, chosen));
10671
+ if (keepChosen)
10672
+ return chosen.threadId;
10673
+ if (mainAllowed || !CLOUD_HOME)
10674
+ return main;
10313
10675
  const threadId = store.createTask(routine.botId, `${routine.name} · Results`, false)?.threadId;
10314
- // On a Cloud home the results conversation is opened by whoever wrote the
10315
- // routine: it carries their name for it (cloudGuestOpened).
10316
- if (threadId && CLOUD_HOME)
10317
- threadStarters.set(threadId, routineOpener(routine.id));
10676
+ if (threadId)
10677
+ threadStarters.set(threadId, opener);
10318
10678
  return threadId;
10319
10679
  },
10320
10680
  discardResultsThread: (botId, threadId) => {
10321
10681
  const task = store.taskByThread(botId, threadId);
10322
- if (task && !task.busy && !task.routineRunId && store.messagesFor(threadId).length === 0) {
10682
+ // The main thread is reused, never allocated, so a failed write keeps it.
10683
+ if (task && threadId !== botMainThread(botId) && !task.busy && !task.routineRunId && store.messagesFor(threadId).length === 0) {
10323
10684
  store.deleteTask(botId, threadId);
10324
10685
  handoffs.forget(threadId);
10325
10686
  }
@@ -10491,31 +10852,35 @@ if (recoveryOwners.length > 0) {
10491
10852
  // after the user confirms a durable card. Keeping this beside the scheduler
10492
10853
  // makes the card resolvable after an app restart without involving the model.
10493
10854
  async function cloudRoutineReadiness(botId, threadId) {
10494
- const bot = threadId ? store.projectBotForTask(botId, threadId) : store.bot(botId);
10855
+ const projected = threadId ? store.projectBotForTask(botId, threadId) : store.bot(botId);
10856
+ // The model its turn will run on: a thread's own model that can't run
10857
+ // gives way to the bot's when the turn starts (healThreadModel).
10858
+ const selection = threadId ? turnSelection(botId, threadId) : projected?.modelSelection;
10859
+ const bot = projected && selection ? { ...projected, modelSelection: selection } : projected;
10495
10860
  if (!bot || bot.hidden)
10496
10861
  return { ready: false, reason: "The routine's target bot no longer exists." };
10497
10862
  if (!boat.boatConfigured(cfg)) {
10498
10863
  return {
10499
10864
  ready: false,
10500
- reason: 'The Boat cloud computer needs a working Boat API key. For the bot’s configured computer, including a self-hosted VPS, set run_on="maus" instead.',
10865
+ reason: `${boatNotConfigured(bot, "routine").message} For the bot's configured computer, including a self-hosted VPS, set run_on="maus" instead.`,
10501
10866
  };
10502
10867
  }
10503
10868
  const instance = registry.get(bot.modelSelection.instanceId);
10504
10869
  if (!instance)
10505
10870
  return { ready: false, reason: "The target bot's model is unavailable. Choose another model in the bot's settings." };
10506
10871
  // The same rule a cloud turn applies, before anything is provisioned.
10507
- const unsupported = cloudPlaceDriverError(cloudEngine(instance), "box", "routine");
10872
+ const unsupported = cloudPlaceRefusal(cloudEngine(instance, bot.modelSelection.model), "routine", bot.name);
10508
10873
  if (unsupported)
10509
- return { ready: false, reason: unsupported };
10874
+ return { ready: false, reason: unsupported.message };
10510
10875
  try {
10511
10876
  if ((await instance.snapshot()).state !== "available") {
10512
- return { ready: false, reason: "The target bot's model is not ready to use the Boat cloud computer." };
10877
+ return { ready: false, reason: "The target bot's model is not ready to use the cloud computer." };
10513
10878
  }
10514
10879
  const teamComputer = inheritedTeamComputer(bot);
10515
10880
  const ownerId = teamComputer ? teamComputerOwner(teamComputer.id) : bot.id;
10516
10881
  const machine = await boat.findBoat(cfg, ownerId);
10517
10882
  if (teamComputer && !machine) {
10518
- return { ready: false, reason: "The team's Boat computer is missing; explicitly create or retry it from the Team map." };
10883
+ return { ready: false, reason: "The team's cloud computer is missing; explicitly create or retry it from the Team map." };
10519
10884
  }
10520
10885
  // Explicit Cloud may provision or wake the bot's own Boat at dispatch.
10521
10886
  // This probe only checks its identity/account, without starting billing.
@@ -11208,12 +11573,11 @@ const webhooks = new WebhookManager({
11208
11573
  post: (botId, threadId, text) => {
11209
11574
  if (!store.bot(botId))
11210
11575
  return;
11211
- store.appendMessage(threadId, { role: "bot", kind: "text", text });
11576
+ store.appendMessage(threadId, { role: "bot", kind: "text", text, webhookPost: true });
11212
11577
  },
11213
- // Mirrors resolveResultsThread's routines wiring a few hundred lines up
11214
- // in this same file: create-on-first-use, never activated (so it never
11215
- // steals the bot's live selection the way POST /api/bots/:id/tasks
11216
- // does), reused forever after via trigger.resultsThreadId.
11578
+ // Create-on-first-use, never activated (so it never steals the bot's
11579
+ // live selection the way POST /api/bots/:id/tasks does), reused forever
11580
+ // after via trigger.resultsThreadId.
11217
11581
  resolvePostThread: (trigger, forceNew) => {
11218
11582
  if (!forceNew && trigger.resultsThreadId)
11219
11583
  return trigger.resultsThreadId;
@@ -11446,6 +11810,10 @@ setupRetry = 0) {
11446
11810
  noteForeignTurn(bot.id, threadId, internalGeneration);
11447
11811
  const resourceOwner = { threadId, generation: internalGeneration };
11448
11812
  turnResourceOwners.set(threadId, resourceOwner);
11813
+ // Set when this member's cloud computer, claimed by its first computer
11814
+ // call, could not start (or parked waiting for its seat): the turn ends on
11815
+ // it, with this cause, instead of as merely interrupted.
11816
+ let roomClaimFailure;
11449
11817
  let roomVmTarget = null;
11450
11818
  let retainRoomVmLease = false;
11451
11819
  let roomSpeaker;
@@ -11692,17 +12060,13 @@ setupRetry = 0) {
11692
12060
  const roomPlaceRefusal = roomKind && computerPlaceRefusal(roomKind);
11693
12061
  if (roomPlaceRefusal)
11694
12062
  throw Object.assign(new Error(roomPlaceRefusal.message), { code: roomPlaceRefusal.code });
11695
- // The Computer engine runs on the Boat; this computer has no tools it can
11696
- // reach, exactly as a bot thread refuses it.
11697
- if (roomPlan.computer === "local" && instance.adapter.capabilities.remoteAgent === true) {
11698
- throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
11699
- }
11700
12063
  // The same one rule as a bot thread, before anything is provisioned: an
11701
12064
  // engine that cannot use the cloud computer is refused, never swapped.
11702
12065
  if (!roomTeamComputer && roomPlan.computer === "cloud") {
11703
- const unsupported = cloudPlaceDriverError(cloudEngine(instance), turnProvider(readyBot) === "vps" ? "vps" : "box");
12066
+ const unsupported = cloudPlaceRefusal(cloudEngine(instance, readyBot.modelSelection.model), "room", readyBot.name)
12067
+ ?? computerToolsRefusal(readyBot.toolScope, "room", readyBot.name);
11704
12068
  if (unsupported)
11705
- throw new PlaceUnavailableError("cloud", unsupported);
12069
+ throw unsupported;
11706
12070
  }
11707
12071
  // One place per room turn as well: a team computer reached on Auto means
11708
12072
  // no separate built-in browser.
@@ -11722,12 +12086,12 @@ setupRetry = 0) {
11722
12086
  return false;
11723
12087
  }
11724
12088
  if (roomTeamComputer) {
11725
- const attached = await attachTeamBoat(roomTeamComputer, readyBot.id, resourceOwner, canWorkOnCloud(cloudEngine(instance), "box"), instance.adapter.capabilities.remoteAgent === true);
12089
+ const attached = await attachTeamBoat(roomTeamComputer, readyBot.id, resourceOwner, canWorkOnCloud(cloudEngine(instance)));
11726
12090
  if (isCancelled?.() || groupSpeakers.get(threadId) !== roomSpeaker ||
11727
12091
  activeInternalGenerationByThread.get(threadId) !== internalGeneration)
11728
12092
  return false;
11729
12093
  Object.assign(integrations, attached.mount);
11730
- startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: instance.adapter.capabilities.remoteAgent === true });
12094
+ startScreenPoller(readyBot.id, threadId, { computer: attached.capture });
11731
12095
  }
11732
12096
  // The speaker's own explicit place, mounted exactly as its bot thread
11733
12097
  // mounts it. A channel has no conversation pin and no Auto fallback here:
@@ -11735,6 +12099,51 @@ setupRetry = 0) {
11735
12099
  const roomSetupIsCurrent = () => !isCancelled?.() &&
11736
12100
  groupSpeakers.get(threadId) === roomSpeaker &&
11737
12101
  activeInternalGenerationByThread.get(threadId) === internalGeneration;
12102
+ // A lazily claimed desktop (the VPS, the member's Boat) shows in the room
12103
+ // from the moment its first computer call claims it.
12104
+ const showClaimedScreen = (capture) => {
12105
+ if (roomSetupIsCurrent() && store.group(readyGroup.id)?.busyBotId === readyBot.id) {
12106
+ roomScreenBotId = readyBot.id;
12107
+ startScreenPoller(readyBot.id, threadId, { computer: capture });
12108
+ }
12109
+ };
12110
+ // The member's cloud computer could not start on its first computer call:
12111
+ // the same row, report and parking a failure at setup gets (the catch at
12112
+ // the end of this function), then the member's turn ends. Fenced to this
12113
+ // member's still-running turn.
12114
+ const failRoomClaim = (failure, error) => {
12115
+ if (activeInternalGenerationByThread.get(threadId) !== internalGeneration || groupSpeakers.get(threadId) !== roomSpeaker)
12116
+ return;
12117
+ if (resourceOwner.computerParkedOn) {
12118
+ registerComputerResume({
12119
+ botId: bot.id,
12120
+ threadId,
12121
+ resource: resourceOwner.computerParkedOn,
12122
+ generation: internalGeneration,
12123
+ afterMessageId: store.activePath(threadId).findLast((entry) => entry.role === "user")?.id,
12124
+ });
12125
+ roomClaimFailure = { parked: true };
12126
+ if (orchestration)
12127
+ orchestration.result.stopReason = `${bot.name} parked waiting for the computer`;
12128
+ }
12129
+ else {
12130
+ // Read as a room member's place (mountBotBoat): its one line, with no
12131
+ // retry of its own.
12132
+ const placeFailure = error instanceof PlaceUnavailableError ? error : undefined;
12133
+ const message = placeFailure?.message ?? failure;
12134
+ roomClaimFailure = { parked: false, message };
12135
+ store.appendMessage(threadId, {
12136
+ role: "bot", kind: "activity",
12137
+ from: { botId: bot.id, name: bot.name, color: bot.color },
12138
+ tool: failedTurnTool(message, placeFailure ? { place: placeFailure.row } : {}),
12139
+ });
12140
+ onDispatchError?.(message);
12141
+ // Claude settles the interrupt as exit_before_result: not a second
12142
+ // incident for the same failure.
12143
+ resourceOwner.lazyClaimFailureReported = true;
12144
+ }
12145
+ void instance.adapter.interruptTurn(threadId).catch(() => { });
12146
+ };
11738
12147
  // An explicit place that cannot be attached fails the member's turn with
11739
12148
  // its cause and the one setting that changes it.
11740
12149
  try {
@@ -11748,15 +12157,7 @@ setupRetry = 0) {
11748
12157
  if (turnProvider(readyBot) === "vps") {
11749
12158
  vpsThreadStarted(readyBot.id, threadId);
11750
12159
  roomVpsBotId = readyBot.id;
11751
- const mounted = await mountBotVps(readyBot, resourceOwner, {
11752
- start: true,
11753
- onClaimed: (capture) => {
11754
- if (roomSetupIsCurrent() && store.group(readyGroup.id)?.busyBotId === readyBot.id) {
11755
- roomScreenBotId = readyBot.id;
11756
- startScreenPoller(readyBot.id, threadId, { computer: capture });
11757
- }
11758
- },
11759
- });
12160
+ const mounted = await mountBotVps(readyBot, resourceOwner, { start: true, onClaimed: showClaimedScreen });
11760
12161
  if (!roomSetupIsCurrent())
11761
12162
  return false;
11762
12163
  if (!("integration" in mounted))
@@ -11766,24 +12167,18 @@ setupRetry = 0) {
11766
12167
  }
11767
12168
  else {
11768
12169
  if (!boat.boatConfigured(cfg))
11769
- throw new Error(BOAT_NOT_CONFIGURED);
11770
- const remoteAgent = instance.adapter.capabilities.remoteAgent === true;
11771
- const attached = await attachBotBoat(readyBot, resourceOwner, { explicitCloud: true, remoteAgent });
11772
- if (!roomSetupIsCurrent())
11773
- return false;
11774
- if (!attached)
11775
- throw new Error("the cloud computer could not be created or reached");
11776
- Object.assign(integrations, attached.mount);
11777
- roomScreenBotId = readyBot.id;
11778
- startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: remoteAgent });
12170
+ throw boatNotConfigured(readyBot, "room");
12171
+ // As in a bot thread: created or woken by the member's first
12172
+ // computer call, never by the room message itself.
12173
+ integrations.localComputer = mountBotBoat(readyBot, resourceOwner, { source: "room", onClaimed: showClaimedScreen, onRejected: failRoomClaim });
11779
12174
  roomComputerKind = "box";
11780
12175
  }
11781
12176
  }
11782
12177
  // Room and Goal turns use the speaker's desktop, never the coordinator's.
11783
12178
  // Claim the same lease as direct turns before asynchronous VM setup.
11784
12179
  if (readyBot.computer === "vm") {
11785
- if (instance.adapter.capabilities.computerMcp !== true || instance.adapter.capabilities.remoteAgent === true) {
11786
- throw new Error("this model cannot use the Local VM");
12180
+ if (instance.adapter.capabilities.computerMcp !== true) {
12181
+ throw placeUnavailable("vm", { state: "vm-cannot", params: { bot: readyBot.name, model: cloudEngine(instance, readyBot.modelSelection.model).name }, source: "room" }, Boolean(CLOUD_HOME));
11787
12182
  }
11788
12183
  // A distinct identity fences cleanup even in shared mode on the same room thread.
11789
12184
  const target = { ...localVmTargetForThread(readyBot.id, threadId) };
@@ -11823,8 +12218,7 @@ setupRetry = 0) {
11823
12218
  const place = roomPlan.computer;
11824
12219
  if (place !== "cloud" && place !== "vm" && place !== "local")
11825
12220
  throw error;
11826
- // A room row is not cut, so the cause keeps all its words.
11827
- throw asPlaceFailure(error, place, "works-on", Infinity);
12221
+ throw await asPlaceFailure(error, place, "room", readyBot, turnProvider(readyBot) === "vps" ? "vps" : "box");
11828
12222
  }
11829
12223
  const roster = readyGroup.memberIds
11830
12224
  .map((id) => store.bot(id))
@@ -11911,7 +12305,6 @@ setupRetry = 0) {
11911
12305
  const recentLines = recentWork(recentWorkSources(bot), bot, { userName, currentThreadId: threadId, ...recentWorkFilter() });
11912
12306
  const roomComputerPromptKind = resolveComputerPromptKind({
11913
12307
  kind: roomTeamComputer || roomComputerKind === "box" ? "box" : roomVmTarget ? "vm" : roomComputerKind,
11914
- driverKind: instance.driverKind,
11915
12308
  vmPrivate: localVmMode(cfg) === "per-bot",
11916
12309
  });
11917
12310
  {
@@ -11932,7 +12325,7 @@ setupRetry = 0) {
11932
12325
  { id: "computer", label: "Computer", text: computerPrompt(roomComputerPromptKind) },
11933
12326
  { id: "team-computer", label: "Team computer", text: teamComputerPrompt(roomTeamComputer) },
11934
12327
  { id: "plan", label: "Surface", text: surfacePrompt({ computer: roomTeamComputer ? "cloud" : roomVmTarget ? "vm" : surfaceOfComputerKind(roomComputerKind), browser: Boolean(integrations.browser) }, { note: roomPlan.note, cloudHome: Boolean(CLOUD_HOME) }) },
11935
- { id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
12328
+ { id: "cloud-home", label: "My Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
11936
12329
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
11937
12330
  { id: "recall", label: "Recall", text: integrations.agents && bot.memoryEnabled !== false ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
11938
12331
  teamAvailabilityPart(outsideRoom),
@@ -11951,6 +12344,10 @@ setupRetry = 0) {
11951
12344
  { id: "skill-instructions", label: "Skill instructions", text: renderSkillInstructions(selectedSkills, { includeRoot: Boolean(workspace) }) },
11952
12345
  { id: "playbooks", label: "Playbooks", text: installedPlaybookInstructions(text, bot.playbooks) },
11953
12346
  ]);
12347
+ // The engines read at start is part of setup (it learns what this engine
12348
+ // supports): wait for it before the claim below.
12349
+ if (enginesRead)
12350
+ await enginesRead;
11954
12351
  // run the turn and wait for it to settle, folding the reply text so a
11955
12352
  // chained @mention can be routed afterwards
11956
12353
  // Claim only after setup succeeded. An unavailable, busy, unsupported, or
@@ -11990,7 +12387,10 @@ setupRetry = 0) {
11990
12387
  else
11991
12388
  markCancelledProviderHandshake(threadId, retirementOwner);
11992
12389
  };
11993
- const timeoutMinutes = roomTurnTimeoutMinutes(cfg);
12390
+ const liveGroup = store.group(groupId) ?? group;
12391
+ const timeoutMinutes = effectiveRoomTurnTimeoutMinutes(roomTurnTimeoutMinutes(cfg), liveGroup.dm
12392
+ ? liveGroup.turnTimeoutMinutes
12393
+ : store.groupTaskByThread(liveGroup.id, threadId)?.turnTimeoutMinutes);
11994
12394
  const outcome = await new Promise((resolve) => {
11995
12395
  let done = false;
11996
12396
  let unsub = () => { };
@@ -12025,13 +12425,10 @@ setupRetry = 0) {
12025
12425
  if (e.type === "item.completed" && e.itemType === "assistant_text")
12026
12426
  replyText += `\n${e.text}`;
12027
12427
  else if (e.type === "turn.completed") {
12028
- if (orchestration && !e.ok) {
12029
- orchestration.result.stopReason = e.stopReason ?? null;
12030
- finish("provider_failed");
12031
- }
12032
- else {
12033
- finish("settled");
12034
- }
12428
+ const end = roomTurnEnd(e, Boolean(orchestration), roomClaimFailure);
12429
+ if (orchestration && end.stopReason !== undefined)
12430
+ orchestration.result.stopReason = end.stopReason;
12431
+ finish(end.outcome);
12035
12432
  }
12036
12433
  // Waiting on a person is not turn work: hold the ceiling while an
12037
12434
  // approval or question card is open, so deciding slowly does not
@@ -12081,6 +12478,7 @@ setupRetry = 0) {
12081
12478
  cwd,
12082
12479
  integrations,
12083
12480
  mcpFromUserConfig: claudeUserMcpEnabled(cfg) && !managedPolicy.restrictsMcp(),
12481
+ mcpCallTimeoutMs: mcpCallTimeoutMinutes(cfg) * 60_000,
12084
12482
  ...(instance.instanceId === readyBot.modelSelection.instanceId
12085
12483
  ? memberTurnSelection(readyBot.modelSelection)
12086
12484
  : { model: instance.models.default }),
@@ -12220,7 +12618,7 @@ setupRetry = 0) {
12220
12618
  drainSecretResumes();
12221
12619
  drainTeamSetupResumes();
12222
12620
  }
12223
- if (outcome === "provider_failed") {
12621
+ if (outcome === "provider_failed" || outcome === "parked") {
12224
12622
  if (skillAuthoring)
12225
12623
  skillAuthoringClaim.claimed = false;
12226
12624
  return false;
@@ -12309,7 +12707,7 @@ setupRetry = 0) {
12309
12707
  store.appendMessage(threadId, {
12310
12708
  role: "bot", kind: "activity",
12311
12709
  from: { botId: bot.id, name: bot.name, color: bot.color },
12312
- tool: failedTurnTool(message),
12710
+ tool: failedTurnTool(message, error instanceof PlaceUnavailableError ? { place: error.row } : {}),
12313
12711
  });
12314
12712
  onDispatchError?.(message);
12315
12713
  if (orchestration) {
@@ -14009,6 +14407,7 @@ function configStatus() {
14009
14407
  // not a secret — the settings picker shows it; "" = follow the system
14010
14408
  language: cfg.language ?? "",
14011
14409
  rooms: { turnTimeoutMinutes: roomTurnTimeoutMinutes(cfg) },
14410
+ mcp: { callTimeoutMinutes: mcpCallTimeoutMinutes(cfg) },
14012
14411
  automaticRecovery: cfg.automaticRecovery ?? { enabled: false },
14013
14412
  // absent effort = no level is sent, so clients can tell it from any level
14014
14413
  newBots: cfg.newBots?.effort ? { effort: cfg.newBots.effort } : {},
@@ -14124,6 +14523,10 @@ function persistMcpServers(next) {
14124
14523
  }
14125
14524
  async function describeInstances() {
14126
14525
  const configs = providerConfigs();
14526
+ // Reading the engines is where this server learns one became available
14527
+ // (an install, a sign-in, a key or a Company engine), so a bot still on the
14528
+ // removed Computer engine moves now rather than at the next start.
14529
+ void retryComputerEngineMove();
14127
14530
  return (await registry.describe()).map((instance) => {
14128
14531
  const entry = configs[instance.instanceId];
14129
14532
  const described = {
@@ -14360,15 +14763,22 @@ function serveStatic(res, path) {
14360
14763
  try {
14361
14764
  const data = readFileSync(file);
14362
14765
  const type = MIME[extname(file)] ?? "application/octet-stream";
14363
- res.writeHead(200, { "content-type": type, ...(type === "text/html" ? PAGE_HEADERS : {}) });
14766
+ // Vite puts a content hash in the name of every file it writes under
14767
+ // /assets/, so a URL there never changes: the browser keeps it instead of
14768
+ // downloading the bundle on every load. That only holds while public/ has
14769
+ // no assets/ dir (index.test.ts checks). Pages are revalidated, so a new
14770
+ // build shows up on the next load.
14771
+ const cache = type === "text/html" ? "no-cache" : safe.startsWith("/assets/") ? "public, max-age=31536000, immutable" : null;
14772
+ res.writeHead(200, { "content-type": type, ...(type === "text/html" ? PAGE_HEADERS : {}), ...(cache ? { "cache-control": cache } : {}) });
14364
14773
  res.end(data);
14365
14774
  return true;
14366
14775
  }
14367
14776
  catch {
14368
- // SPA fallback
14777
+ // SPA fallback. Never immutable: a missing chunk answered with the page
14778
+ // must not stay cached under the chunk's URL.
14369
14779
  try {
14370
14780
  const data = readFileSync(join(STATIC_DIR, "index.html"));
14371
- res.writeHead(200, { "content-type": "text/html", ...PAGE_HEADERS });
14781
+ res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache", ...PAGE_HEADERS });
14372
14782
  res.end(data);
14373
14783
  return true;
14374
14784
  }
@@ -14483,6 +14893,15 @@ ROUTES.push(createBotMemoryRoutes({
14483
14893
  },
14484
14894
  }));
14485
14895
  ROUTES.push(createDeciderRoutes({ decider }));
14896
+ // "Switch them too": a bot's threads on a model of their own follow its model again.
14897
+ ROUTES.push(createThreadModelRoutes({
14898
+ bot: (id) => store.bot(id),
14899
+ busy: (botId, threadId) => threadBusy(botId, threadId),
14900
+ follow: (botId, threadIds) => store.followBotModel(botId, threadIds, (from, to) => hostedModels?.resetTask(from, to) ?? {}),
14901
+ // Like updateBotDefault on a thread: on a Cloud home, the owner's alone.
14902
+ mayChangeModel: (auth) => !CLOUD_HOME || cloudOwnerSession(auth),
14903
+ reply: (botId) => publicBot(store.bot(botId)),
14904
+ }));
14486
14905
  ROUTES.push(createAntigravityLeftoverRoutes({
14487
14906
  hosted: Boolean(hostedModels),
14488
14907
  isAntigravity: (instanceId) => registry.get(instanceId)?.driverKind === "antigravityAgent",
@@ -14552,7 +14971,7 @@ const liveCalls = new LiveCallController({
14552
14971
  },
14553
14972
  broadcast: (frame) => broadcast(frame, { adminOnly: true }),
14554
14973
  settings: () => ({ key: cfg.live?.key ?? "", ...liveSettingsFor(cfg) }),
14555
- createSession: ({ key, sdp, botId, threadId, voice }) => createLiveSession({ key, sdp, voice, bot: liveBotFor(botId), history: liveHistoryFor(threadId) }),
14974
+ createSession: ({ key, sdp, botId, threadId, voice }) => createLiveSession({ key, sdp, voice, bot: liveBotFor(botId), history: liveHistoryFor(threadId), cloudHome: Boolean(CLOUD_HOME) }),
14556
14975
  // Node's WebSocket (undici) accepts headers in its second argument.
14557
14976
  openSocket: (url, key) => new WebSocket(url, { headers: { authorization: `Bearer ${key}` } }),
14558
14977
  attachUrl: (sessionId) => liveAttachUrl(sessionId),
@@ -15047,7 +15466,7 @@ const handleRequest = async (req, res) => {
15047
15466
  // A Cloud home is one person's: only their own devices, which the
15048
15467
  // Admin's pairing signs in with admin scope, may lend to it.
15049
15468
  if (CLOUD_HOME && !auth.scopes.includes("admin"))
15050
- return json(res, 403, { error: "Only your own computers can lend to your Cloud. Connect this computer to your Cloud from its OMB Cloud settings." });
15469
+ return json(res, 403, { error: "Only your own computers can lend to My Cloud. On this computer, open My Cloud from Settings → OpenMausBot Cloud first." });
15051
15470
  sharedComputers.register(registration, { session: auth.session.id, person: CLOUD_HOME ? CLOUD_HOME_LENDER : personKey(auth.session) }, secret);
15052
15471
  return json(res, 200, { ok: true });
15053
15472
  }
@@ -15230,9 +15649,13 @@ const handleRequest = async (req, res) => {
15230
15649
  return json(res, 409, { error: "The user request ended before its computer was selected." });
15231
15650
  if (unoffered)
15232
15651
  return json(res, 409, { error: unoffered, options });
15233
- const option = requested === "auto"
15234
- ? 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)
15235
- : options.find(option => option.surface === requested);
15652
+ // The computer this turn mounted is already selected, even before it
15653
+ // is up when its first call starts it (the bot's cloud computer):
15654
+ // switching to it would only restart the request.
15655
+ const claim = autoVmClaims.get(internalCapability.threadId);
15656
+ const startsOnFirstCall = claim?.owner.generation === internalCapability.generation && claim.startsComputer === true && !claim.failed;
15657
+ const picked = pickComputer(options, requested, current, startsOnFirstCall);
15658
+ const option = picked.option;
15236
15659
  if (!option?.available)
15237
15660
  return json(res, 409, { error: option?.reason ?? "No configured computer or browser is available. Open the Computer panel to set one up.", options });
15238
15661
  if (source.selected) {
@@ -15240,8 +15663,11 @@ const handleRequest = async (req, res) => {
15240
15663
  return json(res, 409, { error: "A computer switch is already pending. End this turn to continue there." });
15241
15664
  }
15242
15665
  else {
15243
- if (option.surface === current && option.ready)
15244
- return json(res, 200, { status: "ready", surface: current, message: "This computer is already selected. Use its mounted tools." });
15666
+ if (picked.current) {
15667
+ return json(res, 200, { status: "ready", surface: current, message: option.ready
15668
+ ? "This computer is already selected. Use its mounted tools."
15669
+ : "This computer is already selected. It starts on its first call; use its mounted tools." });
15670
+ }
15245
15671
  source.selected = option.surface;
15246
15672
  source.previousSurface = store.taskByThread(bot.id, bot.threadId)?.surface;
15247
15673
  }
@@ -15343,17 +15769,19 @@ const handleRequest = async (req, res) => {
15343
15769
  return json(res, 200, { result });
15344
15770
  }
15345
15771
  // The cloud computer's tools (harness-mcp-proxy computer): the turn's
15346
- // capability names its Boat, and the Boat credential stays here.
15772
+ // capability names its Boat, and the Boat credential stays here. A
15773
+ // capability minted before the Boat existed (boxId null) acts on the
15774
+ // one its first call's claim created or woke (mountBotBoat).
15347
15775
  if (method === "POST" && path === "/api/internal/computer/mcp") {
15348
15776
  const body = await readInternalBody();
15349
- const boxId = internalCapability.boxId;
15350
- if (!boxId)
15777
+ if (internalCapability.boxId === undefined)
15351
15778
  return json(res, 403, { error: "this turn has no cloud computer" });
15352
15779
  const abort = new AbortController();
15353
15780
  res.once("close", () => { if (!res.writableEnded)
15354
15781
  abort.abort(); });
15355
15782
  return json(res, 200, { result: await cloudComputerRpc(body, {
15356
- cfg, boxId, signal: abort.signal,
15783
+ cfg, signal: abort.signal,
15784
+ boxId: () => internalCapability.boxId ?? claimedBoatId(internalCapability),
15357
15785
  gate: () => computerCallGate(internalCapability),
15358
15786
  assertActive: requireActiveInternalCapability,
15359
15787
  }) });
@@ -16194,7 +16622,8 @@ const handleRequest = async (req, res) => {
16194
16622
  const guestThread = guestWorkThread(from, target, fromThreadId, message);
16195
16623
  if (guestThread === null)
16196
16624
  return json(res, 500, { error: "couldn't open a conversation for that work" });
16197
- const queued = queueDelegation(commsBus, from, { toBotId, message, reason: "asked while busy", depth, approvalAlreadyGranted, ...(guestThread ? { targetThreadId: guestThread } : {}) }, MAX_COMMS_DEPTH, fromThreadId);
16625
+ const queued = queueDelegation(commsBus, from, { toBotId, message, reason: "asked while busy", depth, approvalAlreadyGranted, ...(guestThread ? { targetThreadId: guestThread } : {}),
16626
+ ...externalCompletion(internalCapability) }, MAX_COMMS_DEPTH, fromThreadId);
16198
16627
  if ((queued.result !== "ok" || !queued.id) && guestThread)
16199
16628
  store.deleteTask(toBotId, guestThread);
16200
16629
  if (queued.result !== "ok" || !queued.id)
@@ -16362,6 +16791,7 @@ const handleRequest = async (req, res) => {
16362
16791
  sourceThreadId: fromThreadId,
16363
16792
  sourceBotId: currentFrom.id,
16364
16793
  routineRunId: activeRoutineRunForThread(fromThreadId)?.id,
16794
+ ...externalCompletion(internalCapability),
16365
16795
  });
16366
16796
  store.appendMessage(fromThreadId, {
16367
16797
  role: "bot",
@@ -16566,7 +16996,7 @@ const handleRequest = async (req, res) => {
16566
16996
  const guestThread = guestWorkThread(from, target, fromThreadId, reason ?? message);
16567
16997
  if (guestThread === null)
16568
16998
  return json(res, 500, { error: "couldn't open a conversation for that work" });
16569
- const queued = queueDelegation(commsBus, from, { toBotId, message, reason, depth, ...(guestThread ? { targetThreadId: guestThread } : {}) }, MAX_COMMS_DEPTH, fromThreadId);
16999
+ const queued = queueDelegation(commsBus, from, { toBotId, message, reason, depth, ...(guestThread ? { targetThreadId: guestThread } : {}), ...externalCompletion(internalCapability) }, MAX_COMMS_DEPTH, fromThreadId);
16570
17000
  if ((queued.result !== "ok" || !queued.id) && guestThread)
16571
17001
  store.deleteTask(toBotId, guestThread);
16572
17002
  if (queued.result !== "ok" || !queued.id) {
@@ -17546,8 +17976,15 @@ const handleRequest = async (req, res) => {
17546
17976
  const policy = currentSender.outbound ?? DEFAULT_OUTBOUND_POLICY;
17547
17977
  const threadId = internalCapability.threadId;
17548
17978
  const { app } = describeTool(outboundTool);
17549
- const summary = outboundCalls.map((call) => {
17979
+ // One entry per call, in the summary's order, so a phone can say
17980
+ // "Send to Linear? Create linear comment ×2" without parsing the
17981
+ // raw arguments out of the subtitle.
17982
+ const calls = outboundCalls.map((call) => {
17550
17983
  const described = describeTool(call.slug);
17984
+ return { app: described.app, label: described.label };
17985
+ });
17986
+ const summary = outboundCalls.map((call, index) => {
17987
+ const described = calls[index];
17551
17988
  const argsText = call.arguments === undefined ? "" : JSON.stringify(call.arguments);
17552
17989
  return `${described.app ? `${described.app} · ` : ""}${described.label}${argsText ? `\n${argsText.slice(0, 400)}${argsText.length > 400 ? "… [arguments truncated]" : ""}` : ""}`;
17553
17990
  }).join("\n\n");
@@ -17577,7 +18014,7 @@ const handleRequest = async (req, res) => {
17577
18014
  tool: outboundTool,
17578
18015
  held: HELD_NOTE["approval.held.outbound"],
17579
18016
  heldCode: "approval.held.outbound",
17580
- outboundRequest: { tool: outboundTool, app },
18017
+ outboundRequest: { tool: outboundTool, app, calls },
17581
18018
  },
17582
18019
  });
17583
18020
  appendDecision(DATA_DIR, {
@@ -17672,7 +18109,8 @@ const handleRequest = async (req, res) => {
17672
18109
  if (upstream.transportSessionId)
17673
18110
  headers["mcp-session-id"] = upstream.transportSessionId;
17674
18111
  res.writeHead(upstream.status, headers);
17675
- return res.end(Buffer.from(upstream.bytes));
18112
+ // A missing OAuth permission gets a note on what fixes it (MOCA-273).
18113
+ return res.end(Buffer.from(withScopeHint(upstream.bytes, upstream.contentType, connectorCalls.map((call) => call.slug))));
17676
18114
  }
17677
18115
  // ── computer control: proxies read the hold, bots plead for help ──
17678
18116
  if (path === "/api/internal/computer-control") {
@@ -17772,6 +18210,9 @@ const handleRequest = async (req, res) => {
17772
18210
  const message = store.appendMessage(threadId, {
17773
18211
  role: "bot",
17774
18212
  kind: "connector",
18213
+ // Read only by clients that predate the card (older phones draw
18214
+ // an unknown kind by its text); see connector-card-text.ts.
18215
+ text: connectorCardText(toolkit.label, item.alias),
17775
18216
  ...(owner.group ? { from: { botId: owner.bot.id, name: owner.bot.name, color: owner.bot.color } } : {}),
17776
18217
  connector: {
17777
18218
  slug: item.slug,
@@ -18184,7 +18625,7 @@ const handleRequest = async (req, res) => {
18184
18625
  return json(res, 200, {
18185
18626
  bots: shownBots.map((bot) => memberBot({
18186
18627
  ...wireBot(bot),
18187
- tasks: store.tasks(bot.id).map(wireTask),
18628
+ tasks: store.tasks(bot.id).map((task) => wireTask(task, bot.modelSelection)),
18188
18629
  ...messagePage(bot.threadId, limit),
18189
18630
  }, visible)),
18190
18631
  botQueuedMessages: visible.everything ? queued : Object.fromEntries(Object.entries(queued).filter(([threadId]) => visible.thread(threadId))),
@@ -19148,7 +19589,7 @@ const handleRequest = async (req, res) => {
19148
19589
  if (!body || typeof body !== "object" || Array.isArray(body)) {
19149
19590
  return json(res, 400, { error: "body must be a JSON object" });
19150
19591
  }
19151
- const allowed = new Set(["title", "pinned"]);
19592
+ const allowed = new Set(["title", "pinned", "turnTimeoutMinutes"]);
19152
19593
  if (Object.keys(body).some((key) => !allowed.has(key)))
19153
19594
  return json(res, 400, { error: "unsupported channel thread setting" });
19154
19595
  const existing = store.groupTaskByThread(group.id, m[2]);
@@ -19156,12 +19597,16 @@ const handleRequest = async (req, res) => {
19156
19597
  return json(res, 404, { error: "no such channel task" });
19157
19598
  const pinning = body.pinned !== undefined;
19158
19599
  const renaming = body.title !== undefined;
19159
- if (!pinning && !renaming)
19600
+ const limiting = Object.prototype.hasOwnProperty.call(body, "turnTimeoutMinutes");
19601
+ if (!pinning && !renaming && !limiting)
19160
19602
  return json(res, 400, { error: "unsupported channel thread setting" });
19161
19603
  if (pinning && typeof body.pinned !== "boolean")
19162
19604
  return json(res, 400, { error: "pinned must be a boolean" });
19163
19605
  if (renaming && typeof body.title !== "string")
19164
19606
  return json(res, 400, { error: "title must be a string" });
19607
+ const parsedLimit = limiting ? parseConversationTurnTimeout(body.turnTimeoutMinutes) : null;
19608
+ if (parsedLimit && !parsedLimit.ok)
19609
+ return json(res, 400, { error: parsedLimit.error });
19165
19610
  // Echoing the current title lets a newer client pin on an older server
19166
19611
  // without the old handler turning a missing title into "Untitled".
19167
19612
  // That echo is not a rename. A real rename stays blocked while working.
@@ -19169,10 +19614,19 @@ const handleRequest = async (req, res) => {
19169
19614
  const notYours = titleChange ? cloudThreadRefusal(auth, m[2]) : null;
19170
19615
  if (notYours)
19171
19616
  return json(res, 403, { error: notYours });
19172
- if (channelTaskBlocked(group) && !(pinning && !titleChange)) {
19617
+ // A longer limit applies to the next turn, so it can be saved while
19618
+ // this one is still running. A rename still waits.
19619
+ const settingsOnly = !titleChange && (pinning || limiting);
19620
+ if (channelTaskBlocked(group) && !settingsOnly) {
19173
19621
  return json(res, 409, { error: "this channel is working or waiting on you — finish that turn first" });
19174
19622
  }
19175
19623
  let task = existing;
19624
+ if (limiting && parsedLimit?.ok) {
19625
+ const limited = store.setGroupTaskTurnTimeout(group.id, m[2], parsedLimit.minutes);
19626
+ if (!limited)
19627
+ return json(res, 404, { error: "no such channel task" });
19628
+ task = limited;
19629
+ }
19176
19630
  if (pinning) {
19177
19631
  const pinned = store.setGroupTaskPinned(group.id, m[2], body.pinned === true);
19178
19632
  if (!pinned)
@@ -20006,10 +20460,12 @@ const handleRequest = async (req, res) => {
20006
20460
  }
20007
20461
  // patchBot persists first and emits the canonical bot change, which the
20008
20462
  // store listener above turns into the slim wire-format SSE broadcast.
20463
+ // Every thread that follows the bot moves with it; the selected thread
20464
+ // follows it from now on too, as if picked there.
20009
20465
  const bot = store.patchBot(existing.id, { modelSelection: checked.selection });
20010
20466
  if (!bot)
20011
20467
  return json(res, 404, { error: "no such bot" });
20012
- store.patchTask(bot.id, selected.threadId, { modelSelection: checked.selection,
20468
+ store.patchTask(bot.id, selected.threadId, { modelSelection: undefined,
20013
20469
  ...hostedModels?.resetTask(selected.modelSelection, checked.selection) });
20014
20470
  return json(res, 200, { bot: wireBot(bot) });
20015
20471
  }
@@ -20695,8 +21151,10 @@ const handleRequest = async (req, res) => {
20695
21151
  // and pins that already match stay; returning to Auto sweeps nothing.
20696
21152
  if (computerSpecified && requestedComputer !== undefined)
20697
21153
  store.clearAutoSurfacePins(m[1], requestedComputer);
21154
+ // A new bot model moves every thread that follows the bot; the selected
21155
+ // thread follows it from now on too.
20698
21156
  if (normalizedSelection && selectedTask)
20699
- store.patchTask(bot.id, selectedTask.threadId, { modelSelection: normalizedSelection,
21157
+ store.patchTask(bot.id, selectedTask.threadId, { modelSelection: undefined,
20700
21158
  ...hostedModels?.resetTask(selectedTask.modelSelection, normalizedSelection) });
20701
21159
  if (existingBot && (bot.browserProfile !== beforeBrowserProfile || bot.browser !== beforeBrowserEnabled)) {
20702
21160
  browserLive.closeForBot(bot.id);
@@ -21493,12 +21951,15 @@ const handleRequest = async (req, res) => {
21493
21951
  if (phoneSecretSubmissions.hasThread(bot.threadId)) {
21494
21952
  return json(res, 409, { error: "this task is securely saving a credential — try again when it finishes" });
21495
21953
  }
21496
- if (!registry.get(bot.modelSelection.instanceId)) {
21954
+ // A thread's own model that can't run gives way to the bot's when the
21955
+ // rerun starts (healThreadModel), so check the model it will run on.
21956
+ const rerunSelection = turnSelection(bot.id, bot.threadId) ?? bot.modelSelection;
21957
+ if (!registry.get(rerunSelection.instanceId)) {
21497
21958
  return json(res, 409, {
21498
- error: `provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`,
21959
+ error: `provider instance "${rerunSelection.instanceId}" is unavailable — pick another model in settings`,
21499
21960
  });
21500
21961
  }
21501
- const rerunInstance = registry.get(bot.modelSelection.instanceId);
21962
+ const rerunInstance = registry.get(rerunSelection.instanceId);
21502
21963
  const rerunRefusal = policyModelRefusal(rerunInstance);
21503
21964
  if (rerunRefusal)
21504
21965
  return json(res, 409, { error: rerunRefusal });
@@ -21929,7 +22390,7 @@ const handleRequest = async (req, res) => {
21929
22390
  // Who opened it decides who may answer its cards on a shared workspace.
21930
22391
  if (auth.kind === "session")
21931
22392
  threadStarters.set(task.threadId, actorKey(auth));
21932
- return json(res, 201, { bot: publicBot(store.bot(bot.id)), task: wireTask(task) });
22393
+ return json(res, 201, { bot: publicBot(store.bot(bot.id)), task: wireTask(task, store.bot(bot.id).modelSelection) });
21933
22394
  }
21934
22395
  m = path.match(/^\/api\/bots\/([\w-]+)\/tasks\/([\w-]+)$/);
21935
22396
  if (m && method === "POST") {
@@ -21947,7 +22408,7 @@ const handleRequest = async (req, res) => {
21947
22408
  const switched = store.switchTask(bot.id, m[2]);
21948
22409
  if (!switched)
21949
22410
  return json(res, 404, { error: "no such task" });
21950
- const switchedSettings = { ...wireBot(switched), tasks: store.tasks(switched.id).map(wireTask) };
22411
+ const switchedSettings = { ...wireBot(switched), tasks: store.tasks(switched.id).map((task) => wireTask(task, switched.modelSelection)) };
21951
22412
  // "0" is settings only, a positive page is a bounded transcript, and no
21952
22413
  // parameter is the whole thread — the one branch that materialises it.
21953
22414
  // Search results land through that branch (src/lib/focus-message.ts):
@@ -22029,7 +22490,7 @@ const handleRequest = async (req, res) => {
22029
22490
  const task = store.refreshTaskPermissions(profile.id, m[2]);
22030
22491
  if (!task)
22031
22492
  return json(res, 404, { error: "no such task" });
22032
- return json(res, 200, { task: wireTask(task), bot: publicBot(store.bot(profile.id)) });
22493
+ return json(res, 200, { task: wireTask(task, store.bot(profile.id).modelSelection), bot: publicBot(store.bot(profile.id)) });
22033
22494
  }
22034
22495
  const patch = {};
22035
22496
  if (body.projectId !== undefined) {
@@ -22135,7 +22596,7 @@ const handleRequest = async (req, res) => {
22135
22596
  const task = patch.modelSelection
22136
22597
  ? store.switchTaskModel(m[1], m[2], patch.modelSelection, body.updateBotDefault === true, body.resetApprovalToAsk === true, { ...patch, ...hostedModels?.resetTask(current.modelSelection, patch.modelSelection) })
22137
22598
  : store.patchTask(m[1], m[2], patch);
22138
- return json(res, 200, { task: wireTask(task), bot: publicBot(store.bot(m[1])) });
22599
+ return json(res, 200, { task: wireTask(task, store.bot(m[1]).modelSelection), bot: publicBot(store.bot(m[1])) });
22139
22600
  }
22140
22601
  if (m && method === "DELETE") {
22141
22602
  const bot = store.bot(m[1]);
@@ -22207,7 +22668,7 @@ const handleRequest = async (req, res) => {
22207
22668
  const task = store.retitleTask(m[1], m[2], startedFrom, title) ?? store.taskByThread(m[1], m[2]);
22208
22669
  if (!task)
22209
22670
  return json(res, 404, { error: "no such task" });
22210
- return json(res, 200, { task: wireTask(task) });
22671
+ return json(res, 200, { task: wireTask(task, store.bot(m[1]).modelSelection) });
22211
22672
  }
22212
22673
  finally {
22213
22674
  titleRegenerations.delete(m[2]);
@@ -22252,8 +22713,6 @@ const handleRequest = async (req, res) => {
22252
22713
  const key = teamComputerOwner(found.id);
22253
22714
  if (parsed.data.action === "take" && boatLifecycleBusyBots.has(key))
22254
22715
  return json(res, 409, { error: "Wait for this computer's action to finish before taking control" });
22255
- if (parsed.data.action === "take")
22256
- assertTeamControlCanBeTaken(found.id);
22257
22716
  if (parsed.data.controlLeaseId) {
22258
22717
  const result = parsed.data.action === "take"
22259
22718
  ? computerControl.acquireLease(key, parsed.data.controlLeaseId)
@@ -22292,7 +22751,7 @@ const handleRequest = async (req, res) => {
22292
22751
  if (method === "POST" && !computerId) {
22293
22752
  const parsed = teamComputerCreate.safeParse(body);
22294
22753
  if (!parsed.success)
22295
- return json(res, 400, { error: "Provide requestId (UUID), a name, and acknowledgeCost: true to create a paid Boat" });
22754
+ return json(res, 400, { error: "Provide requestId (UUID), a name, and acknowledgeCost: true to create a paid cloud computer" });
22296
22755
  if (!boat.boatConfigured(cfg))
22297
22756
  return json(res, 409, { error: "Configure Boat in Settings before creating a cloud computer" });
22298
22757
  const computer = teamComputers.create(parsed.data.name, parsed.data.requestId);
@@ -22312,7 +22771,7 @@ const handleRequest = async (req, res) => {
22312
22771
  }
22313
22772
  if (method === "POST" && found && action && action !== "control") {
22314
22773
  if (action === "provision" && body?.acknowledgeCost !== true)
22315
- return json(res, 400, { error: "Confirm Boat creation or wake costs with acknowledgeCost: true" });
22774
+ return json(res, 400, { error: "Confirm cloud computer creation or wake costs with acknowledgeCost: true" });
22316
22775
  // A ready-only join never wakes, provisions or steals an agent's turn.
22317
22776
  // The caller takes a separate explicit human-control lease first.
22318
22777
  if (action === "join") {
@@ -22634,7 +23093,12 @@ const handleRequest = async (req, res) => {
22634
23093
  // The bots' browser engine: install it on this machine (agent-browser +
22635
23094
  // a Chrome for Testing, a one-time download), or ask how that is going.
22636
23095
  // One install at a time; the config frame's browserEngine tells the rest.
23096
+ // JSON only, so a form posted from another local page cannot start it.
22637
23097
  if (method === "POST" && path === "/api/browser-engine/install") {
23098
+ const mediaType = String(req.headers["content-type"] ?? "").split(";", 1)[0].trim().toLowerCase();
23099
+ if (mediaType !== "application/json") {
23100
+ return json(res, 415, { error: "content-type must be application/json" });
23101
+ }
22638
23102
  if (!browserEngineInstall) {
22639
23103
  const status = browserEngineStatus();
22640
23104
  if (status.kind === "unavailable" && !status.installable)
@@ -22782,7 +23246,17 @@ const handleRequest = async (req, res) => {
22782
23246
  return json(res, 400, { error: "That does not look like an API key." });
22783
23247
  const url = typeof body?.url === "string" && body.url.trim() ? body.url.trim() : (saved && "url" in saved ? saved.url : undefined);
22784
23248
  res.setHeader("cache-control", "no-store");
22785
- return json(res, 200, await checkProviderKey({ provider: kind, key, url }));
23249
+ const verdict = await checkProviderKey({ provider: kind, key, url });
23250
+ // A Test is a real use the person chose: the engines on this key agree
23251
+ // with its verdict. Claude runs its key through its CLI, not here.
23252
+ if (kind !== "anthropic") {
23253
+ const base = providerBaseUrl(kind, url);
23254
+ if (verdict.ok)
23255
+ noteKeyAccepted(base, key);
23256
+ else if (verdict.reason === "rejected")
23257
+ noteKeyRejected(base, key);
23258
+ }
23259
+ return json(res, 200, verdict);
22786
23260
  }
22787
23261
  if (method === "GET" && path === "/api/decisions") {
22788
23262
  const rawLimit = url.searchParams.get("limit");
@@ -23812,6 +24286,12 @@ const handleRequest = async (req, res) => {
23812
24286
  browserCleanup.abort(request);
23813
24287
  throw error;
23814
24288
  }
24289
+ // A saved or cleared key starts over: neither it nor the key it
24290
+ // replaced stays marked as rejected.
24291
+ const resetKeys = PROVIDER_KEY_KINDS.flatMap((kind) => {
24292
+ const next = patch[kind]?.key;
24293
+ return next === undefined ? [] : [cfg[kind]?.key, next];
24294
+ });
23815
24295
  let configWriteCommitted = false;
23816
24296
  const externalSecretStorage = url.searchParams.get("secretStorage") === "external";
23817
24297
  try {
@@ -23877,6 +24357,9 @@ const handleRequest = async (req, res) => {
23877
24357
  }
23878
24358
  throw error;
23879
24359
  }
24360
+ for (const key of resetKeys)
24361
+ if (key)
24362
+ forgetKey(key);
23880
24363
  // Desktops already counting down move to the new window at once, in
23881
24364
  // every isolation mode; busy work still defers its expiry.
23882
24365
  if (patch.localVm?.idleTimeoutMinutes !== undefined) {
@@ -24285,9 +24768,6 @@ const handleRequest = async (req, res) => {
24285
24768
  if (!currentBot)
24286
24769
  return json(res, 404, { error: "no such bot" });
24287
24770
  const controlKey = botComputerControlKey(currentBot);
24288
- const teamComputer = inheritedTeamComputer(currentBot);
24289
- if (action === "take" && teamComputer)
24290
- assertTeamControlCanBeTaken(teamComputer.id);
24291
24771
  const leaseResult = body.controlLeaseId === undefined
24292
24772
  ? null
24293
24773
  : controlLeaseIdSchema.safeParse(body.controlLeaseId);
@@ -24464,12 +24944,12 @@ const handleRequest = async (req, res) => {
24464
24944
  }
24465
24945
  if (bot.computer !== "cloud" && !threadPreview) {
24466
24946
  return json(res, 409, {
24467
- error: "Choose Cloud before changing or opening this Boat. Auto only checks existing computer state.",
24947
+ error: "Choose Cloud computer before changing or opening this cloud computer. Auto only checks existing computer state.",
24468
24948
  });
24469
24949
  }
24470
24950
  if (m[2] === "remove") {
24471
24951
  // Boats sleep and wake; only the VPS backend has a container to remove.
24472
- return json(res, 409, { error: "the cloud Boat backend has no container to remove — use sleep instead" });
24952
+ return json(res, 409, { error: "a cloud computer has no container to remove — put it to sleep instead" });
24473
24953
  }
24474
24954
  const releaseComputerLifecycle = claimBotComputerLifecycle(botId);
24475
24955
  try {
@@ -24496,7 +24976,7 @@ const handleRequest = async (req, res) => {
24496
24976
  catch (e) {
24497
24977
  const status = e?.status ?? 500;
24498
24978
  const candidateCode = e?.code;
24499
- const code = typeof candidateCode === "string" && ["guarded_busy", "guarded_branch", "guarded_permissions", "guarded_request_changed", "guarded_request_untracked"].includes(candidateCode)
24979
+ const code = typeof candidateCode === "string" && ["guarded_busy", "guarded_branch", "guarded_permissions", "guarded_request_changed", "guarded_request_untracked", "outside_workspace"].includes(candidateCode)
24500
24980
  ? candidateCode : undefined;
24501
24981
  return json(res, status, { error: e instanceof Error ? e.message : String(e), ...(code ? { code } : {}) });
24502
24982
  }
@@ -24646,12 +25126,22 @@ restoreChannelMessages();
24646
25126
  if (movedAutoPins)
24647
25127
  console.log(`Works on: moved ${movedAutoPins} auto-pinned thread(s) to their bot's current Works on`);
24648
25128
  }
24649
- server.listen(PORT, "127.0.0.1", () => {
25129
+ server.listen(PORT, "127.0.0.1", async () => {
24650
25130
  companyRuntimeReady();
24651
25131
  console.log(`openmausbot server on http://127.0.0.1:${PORT}`);
25132
+ // Words queued before the restart start first, ahead of anything sent from
25133
+ // now on. Their turns wait for the engines read at start like every turn.
24652
25134
  followupsReady = true;
24653
25135
  drainQueuedSends();
24654
25136
  drainQueuedChannelSends();
25137
+ // The work below wakes delegators, and a woken delegator keeps its session
25138
+ // only on an engine that records what it was handed, which that read finds
25139
+ // out: it starts once the read is done.
25140
+ if (enginesRead) {
25141
+ await enginesRead;
25142
+ if (companyShutdown)
25143
+ return;
25144
+ }
24655
25145
  // Startup work uses the same turn dispatcher and local tool endpoint as
24656
25146
  // ordinary chat. Start only once every registry is initialized and the
24657
25147
  // endpoint is listening; earlier dispatch can hit uninitialized bindings.