openmausbot 0.1.95 → 0.1.97

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 (169) hide show
  1. package/dist/assets/{DesktopViewer-BwWpc4RK.js → DesktopViewer-CPidMCtt.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-jsQkIqTn.js → abnfDiagram-O67JEVCF-k1UCNXb7.js} +1 -1
  3. package/dist/assets/{arc-7kei4Hsu.js → arc-BUWGa9UU.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CRWDFDMq.js → architectureDiagram-NJMV4G6O-DiPor3UT.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CTKW_IKs.js → blockDiagram-BEXU5L5S-Ag9nLW4H.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-BBTW7-6M.js → c4Diagram-YGBWAQC7-Bh0sjgUa.js} +1 -1
  7. package/dist/assets/channel-DR8eD6QW.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-M_Ox-MkD.js → chunk-2Q5K7J3B-BFBcIIqu.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-D2YiN-Ud.js → chunk-3FUC2YCW-Dvfpex-f.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-BmVYJ0f8.js → chunk-5VM5RSS4-CMjP2YZW.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-BtiLVVnZ.js → chunk-GTNCS2PH-BryoeTc7.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-DFYSb6J6.js → chunk-GWA4HPMP-BhfvcchO.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-mknKBzgV.js → chunk-JWPE2WC7-bcCVp8rp.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-DgSYGiOu.js → chunk-NETBCI7D-yKIjIlkQ.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-C7ZvuguH.js → chunk-WEXAMYUT-D3Zqbmup.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-CPqxINpA.js → chunk-XXDRQBXY-CjVoIMG6.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-Cd6xfJaC.js → classDiagram-v2-NBCMYWYE-mUCWdVyH.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-BSXY5qRh.js → cose-bilkent-JH36ORCC-BBmR1bbX.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-D_lhEMj1.js → cynefin-OW5HDTMX-BpRrxoO4.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-Da9FrFwk.js → cynefinDiagram-VND7K2PF-D3VBWCPK.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-T77jMOXe.js → dagre-6A5THRUB-DxEIACOO.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-Dkj96X5E.js → diagram-22UHCM2B-BANgZEOS.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-CMi9zlD_.js → diagram-3UASUU5V-Cnq7nJir.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-CXK6YUfS.js → diagram-ATOU4E4O-4TBDIWnz.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-BE7B1u9Q.js → diagram-CDSNMT55-CD_B39D4.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-CCMQphKr.js → diagram-MLGK6HIB-CzvnFhEC.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-iHJ_-tGH.js → diagram-MPIPVDR6-C4w2SNml.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-DONsCWQ7.js → ebnfDiagram-ZINNZB2B-gkUEBm2s.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-CU_WOaXk.js → elk-276RUBZZ-B_56X6mw.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-BeYwM04G.js → erDiagram-OPXOYQCR-C_oByoDS.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-Bdw9DqEl.js → flowDiagram-KWPJA3E3-CR9xXJkC.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-C9iwq3qm.js → ganttDiagram-FUAMR5RP-gtYBTu0B.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-Dx-VWzUg.js → gitGraphDiagram-X574FWY7-VJ6-Tp_p.js} +1 -1
  34. package/dist/assets/{index-KygHRE3E.js → index-Cp4S4NOL.js} +1 -1
  35. package/dist/assets/index-CpaIXRLp.js +614 -0
  36. package/dist/assets/index-Z81SMsgR.css +1 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-BG7uaEd4.js → infoDiagram-VRGFBTTK-2Lu9te4D.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-D72WAXnM.js → ishikawaDiagram-OU5B5YK6-DR49XY-e.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DKH1gjYT.js → journeyDiagram-ZHPQQLJL-ZoR0M305.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-3upm9JUT.js → kanban-definition-PNTS6WVX-DLKHxrO-.js} +1 -1
  41. package/dist/assets/{linear-CcR4pc0-.js → linear-BASsfWly.js} +1 -1
  42. package/dist/assets/{mermaid.core-C6yU5FSS.js → mermaid.core-BVidiPkk.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-DOI2xEuv.js → mindmap-definition-NLK3R4M7-g_Qx0U5C.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-BVWJSgLd.js → pegDiagram-GJSIUBJH-DI9Cjs8w.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-jtbYezhq.js → pieDiagram-5QR66LMP-sRI0Uhr5.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-g0u8mtPX.js → quadrantDiagram-O4NWA36T-CamKTby3.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-k8689Wny.js → railroadDiagram-XR7U4H2S-DOLlkbf3.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-DsJ22PDt.js → requirementDiagram-PLB6GJNP-DrRQ5r97.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-CneWBRgG.js → sankeyDiagram-IPEJSGJF-DGS_lXcX.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-DWsFCWap.js → sequenceDiagram-PO4LG4MO-CkjWqwtf.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-Bvf0YE3S.js → sizeCapture-INFHLROL-CepRgful.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-Bxb3Iei8.js → stateDiagram-v2-GCMORJYK-hykr-9RS.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DLkk7W4A.js → swimlanes-2SLR337P-CKOHEZbI.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-D-8vqZ4-.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-KR9bIHYC.js → timeline-definition-EJHVYXUP-CB8pJ6AC.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-DfsOrj8t.js → usecaseDiagram-POWQR4AR-DrclJHdl.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-DRSTzG3h.js → vennDiagram-UO4OBE2U-zS23qjv8.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DtEdhPgT.js → wardleyDiagram-VNRHLVJA-52jgeKxz.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-B-XhTxjG.js → xychartDiagram-PMCCYNJV-DSdl9rQ8.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/cloud-home-start.js +26 -13
  62. package/dist-server/companion/src/listener.js +39 -18
  63. package/dist-server/container-mcp.js +14 -27
  64. package/dist-server/drivers/agents-proxy.js +108 -49
  65. package/dist-server/harness-mcp-proxy.js +1 -2
  66. package/dist-server/index.js +4664 -4644
  67. package/dist-server/openmausbot.js +1425 -1437
  68. package/dist-server/pair-cli.js +1425 -1437
  69. package/dist-server/permission-proxy.js +0 -4
  70. package/dist-server/server/admin-activity.js +1 -4
  71. package/dist-server/server/auto-approve.js +20 -5
  72. package/dist-server/server/auto-vm-claims.js +13 -1
  73. package/dist-server/server/automatic-recovery.js +1 -2
  74. package/dist-server/server/boat.js +71 -87
  75. package/dist-server/server/bot-overview.js +1 -1
  76. package/dist-server/server/browser-engine.js +38 -1
  77. package/dist-server/server/browser-live.js +112 -25
  78. package/dist-server/server/browser-runtime.js +31 -1
  79. package/dist-server/server/checkpoints.js +340 -167
  80. package/dist-server/server/chief-of-staff.js +7 -6
  81. package/dist-server/server/cli-setup.js +3 -3
  82. package/dist-server/server/cli.js +33 -26
  83. package/dist-server/server/cloud-computer-tools.js +11 -4
  84. package/dist-server/server/cloud-home-start.js +13 -16
  85. package/dist-server/server/cloud-home.js +3 -13
  86. package/dist-server/server/cloud-move-http.js +44 -30
  87. package/dist-server/server/cloud-move.js +20 -18
  88. package/dist-server/server/cloud-owner.js +1 -1
  89. package/dist-server/server/composio.js +10 -3
  90. package/dist-server/server/computer-engine-removal.js +70 -0
  91. package/dist-server/server/computer-selection.js +18 -0
  92. package/dist-server/server/config.js +99 -75
  93. package/dist-server/server/connector-scope-hint.js +83 -0
  94. package/dist-server/server/container-computer.js +16 -5
  95. package/dist-server/server/contracts.js +0 -9
  96. package/dist-server/server/decider/jev.js +1 -4
  97. package/dist-server/server/decider/log.js +1 -3
  98. package/dist-server/server/decision-log.js +1 -4
  99. package/dist-server/server/default-model-selection.js +20 -14
  100. package/dist-server/server/delegations.js +14 -7
  101. package/dist-server/server/delta-context.js +6 -1
  102. package/dist-server/server/drivers/acp/core.js +5 -4
  103. package/dist-server/server/drivers/acp/opencode-go.js +19 -23
  104. package/dist-server/server/drivers/agents-call.js +47 -64
  105. package/dist-server/server/drivers/agents-catalog.js +69 -3
  106. package/dist-server/server/drivers/agents-proxy.js +2 -1
  107. package/dist-server/server/drivers/builtIn.js +0 -2
  108. package/dist-server/server/drivers/claude-login-auth.js +2 -11
  109. package/dist-server/server/drivers/claude.js +49 -30
  110. package/dist-server/server/drivers/codex-device-auth.js +3 -1
  111. package/dist-server/server/drivers/codex.js +7 -6
  112. package/dist-server/server/drivers/grok.js +1 -1
  113. package/dist-server/server/drivers/openai-chat-protocol.js +4 -2
  114. package/dist-server/server/drivers/openai-chat.js +113 -11
  115. package/dist-server/server/drivers/pi.js +184 -49
  116. package/dist-server/server/drivers/prompt-split.js +4 -4
  117. package/dist-server/server/harness/bus.js +49 -0
  118. package/dist-server/server/harness-mcp-proxy.js +1 -1
  119. package/dist-server/server/index.js +942 -980
  120. package/dist-server/server/live-call.js +11 -6
  121. package/dist-server/server/managed-policy.js +1 -1
  122. package/dist-server/server/mcp-registry.js +35 -0
  123. package/dist-server/server/message-file.js +4 -4
  124. package/dist-server/server/model-catalog/presets.js +8 -0
  125. package/dist-server/server/peer-delivery.js +4 -19
  126. package/dist-server/server/peer-roster.js +40 -6
  127. package/dist-server/server/profile-versions.js +0 -31
  128. package/dist-server/server/provider-key-check.js +2 -1
  129. package/dist-server/server/restart.js +40 -0
  130. package/dist-server/server/room-handoffs.js +12 -0
  131. package/dist-server/server/room-turn-end.js +15 -0
  132. package/dist-server/server/routes/bot-memory.js +0 -24
  133. package/dist-server/server/routes/live.js +3 -3
  134. package/dist-server/server/routine-requests.js +2 -5
  135. package/dist-server/server/routines.js +6 -5
  136. package/dist-server/server/screen-frame-gate.js +1 -3
  137. package/dist-server/server/server-launcher.js +53 -0
  138. package/dist-server/server/store.js +126 -2
  139. package/dist-server/server/surface.js +40 -64
  140. package/dist-server/server/system-prompt.js +23 -13
  141. package/dist-server/server/team-manifest.js +1 -6
  142. package/dist-server/server/tunnel.js +4 -1
  143. package/dist-server/server/turn-continuation.js +162 -0
  144. package/dist-server/server/turn-images.js +1 -21
  145. package/dist-server/server/turn-resources.js +19 -150
  146. package/dist-server/server/usage-ledger.js +3 -1
  147. package/dist-server/server/workspace.js +3 -4
  148. package/dist-server/server-launcher.js +72 -0
  149. package/dist-server/shared/ask-question.js +1 -71
  150. package/dist-server/shared/cloud-computer.js +10 -0
  151. package/dist-server/shared/credential-request.js +1 -1
  152. package/dist-server/shared/failed-turn.js +22 -0
  153. package/dist-server/shared/live-approval.js +2 -2
  154. package/dist-server/shared/package-format.js +3 -3
  155. package/dist-server/shared/pairing-link.js +144 -0
  156. package/dist-server/shared/place-view.js +409 -0
  157. package/dist-server/shared/tool-scope-support.js +1 -2
  158. package/dist-server/vps-container-mcp.js +14 -27
  159. package/dist-server/workspace-backup.worker.js +14 -27
  160. package/package.json +1 -1
  161. package/dist/assets/channel-DS2NbT5x.js +0 -1
  162. package/dist/assets/index-NF7XCHAy.css +0 -1
  163. package/dist/assets/index-X_5ydXIE.js +0 -610
  164. package/dist/assets/swimlanesDiagram-TC7HE7FX-C6bZtBXk.js +0 -8
  165. package/dist-server/server/claim-idle.js +0 -33
  166. package/dist-server/server/cloud-overflow.js +0 -167
  167. package/dist-server/server/drivers/boatagent.js +0 -443
  168. package/dist-server/server/tightening-requests.js +0 -435
  169. package/dist-server/shared/tightening-request.js +0 -87
@@ -27,6 +27,9 @@ import { assertRequestTarget, guardedRequestPath, requestConflict, requestNeedsI
27
27
  import { botAvatarUrlFromStoredPath } from "../shared/bot-avatar.js";
28
28
  import { BOT_PROFILE_LIMITS } from "../shared/bot-profile.js";
29
29
  import { CLOUD_COMPUTER_BUSY_ERROR } from "../shared/computer-contention.js";
30
+ import { failedTurnTool } from "../shared/failed-turn.js";
31
+ import { phonePairingLink } from "../shared/pairing-link.js";
32
+ import { canWorkOnCloud } from "../shared/cloud-computer.js";
30
33
  import { approvalModeFor, modelSwitchNeedsAsk, isEmergencyApprovalDowngrade, isApprovalMode, } from "../shared/approval-mode.js";
31
34
  import { createApprovalModeSupport } from "./harness-capabilities.js";
32
35
  import { escapeAttribute } from "../src/lib/composer-attachments.js";
@@ -50,6 +53,8 @@ import { avatarGenerationRequestSchema, avatarGenerationStateMatches, generateAv
50
53
  import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
51
54
  import { groupTurnCwd } from "./room-cwd.js";
52
55
  import { RoomTurnDeadline, RoomTurnStallRegistry, roomTurnTimeoutMessage } from "./room-turn-timeout.js";
56
+ import { roomTurnEnd } from "./room-turn-end.js";
57
+ import { pickComputer } from "./computer-selection.js";
53
58
  import * as boat from "./boat.js";
54
59
  import { cloudComputerRpc } from "./cloud-computer-tools.js";
55
60
  import { TeamComputers, teamComputerAssignment, teamComputerCreate, teamComputerOwner } from "./team-computers.js";
@@ -58,7 +63,7 @@ import { isPersistentQuestionCard, QUESTION_DISMISS_MESSAGE, shouldSettleRequest
58
63
  import { parseToolScope, toolScopeWidens } from "../shared/tool-scope.js";
59
64
  import { boatCreateRecoverySnapshot, retireDeletedBoatCreate } from "./boat-create-idempotency.js";
60
65
  import { boatDeletionSnapshot } from "./boat-delete-journal.js";
61
- import { liveDecisionRefusal } from "../shared/live-approval.js";
66
+ import { liveCardKind, liveDecisionRefusal } from "../shared/live-approval.js";
62
67
  import { boatAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourceChangeError, } from "./cloud-backend.js";
63
68
  import * as composio from "./composio.js";
64
69
  import { connectorCallFromFrame, connectorRefusalText, connectorUnrecognizedText, evaluateConnectorTools } from "./connector-verdict.js";
@@ -69,7 +74,7 @@ import { appendAboutMe, commitLearned, planLearned } from "./profile-learned.js"
69
74
  import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, livePeerRosterBlock, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
70
75
  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";
71
76
  import { attachForTurn, saveBotAttachment } from "./bot-attachment.js";
72
- 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, computerClaimIdleReleaseEnabled, cloudOverflowAllowlistedThreads, cloudOverflowEnabled, cloudOverflowIdleStopMs, cloudOverflowPerSecondCostUsd, 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, } from "./config.js";
77
+ 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";
73
78
  import { sweepThreadEventLogs } from "./thread-retention.js";
74
79
  import { ComputerControl } from "./computer-control.js";
75
80
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
@@ -85,11 +90,11 @@ import { entitled } from "./enterprise.js";
85
90
  import { HOSTED_CONTRACT_HEADER, HOSTED_CONTRACT_METADATA, HOSTED_CONTRACT_VERSION } from "./hosted-contract.js";
86
91
  import { describeSpawnFailure, execCli } from "./procs.js";
87
92
  import { blockedTarget, buildNotification, buildSpendNotification } from "./notify.js";
88
- import { isModelVariant, TurnNotStartedError, newId, usesCloudComputer, } from "./contracts.js";
93
+ import { isModelVariant, TurnNotStartedError, newId, } from "./contracts.js";
89
94
  import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.js";
90
95
  import { recoveryCapabilityError } from "./automatic-recovery.js";
91
96
  import { decodeGeneratedImage } from "./generated-image.js";
92
- import { MAX_MCP_SERVERS, isRemoteMcpServer, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
97
+ import { addDisabledMcpServer, MAX_MCP_SERVERS, isRemoteMcpServer, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
93
98
  import { McpOAuthError, McpSignInError, McpOAuthManager, mcpOAuthRedirectUri, withMcpSignIn, withoutPendingSignIn } from "./mcp-oauth.js";
94
99
  import { probeMcpServer } from "./mcp-probe.js";
95
100
  import { GROUP_GOAL_MAX_TURNS, groupGoalAssignmentKey, groupGoalCompletionTurnId, groupGoalCoordinatorInstructions, groupGoalWorkerInstructions, parseGroupGoalDecision, resolveGroupGoalMember, selectGroupGoalCoordinator, } from "./group-goal-run.js";
@@ -115,11 +120,13 @@ import { ProviderRegistry } from "./harness/registry.js";
115
120
  import { ManagedDesktopProviders } from "./managed-desktop.js";
116
121
  import { computerKindForResource, ManagedDesktopPolicy } from "./managed-policy.js";
117
122
  import { hostedModelPolicy, HOSTED_MODEL_POLICY_HEADER, HOSTED_PROVIDER_SETTINGS_ERROR } from "./hosted-models.js";
118
- import { boatNotConfiguredMessage, CLOUD_HOME_RESTART_EXIT_CODE, CLOUD_HOME_SECRET_KEYS, CLOUD_IGNORED_KEYS, CLOUD_PAIRING_PATH, cloudHomeConfiguration, cloudHomeOffersPlace, cloudHomePlaceRefusal, createCloudPairing, firstCloudTurnPatch, readSignedBody, } from "./cloud-home.js";
123
+ import { CLOUD_HOME_SECRET_KEYS, CLOUD_IGNORED_KEYS, CLOUD_PAIRING_PATH, cloudHomeConfiguration, cloudHomeOffersPlace, cloudHomePlaceRefusal, createCloudPairing, firstCloudTurnPatch, readSignedBody, } from "./cloud-home.js";
119
124
  import { CLOUD_PERSONAL_REFUSAL, settleCloudOwnership } from "./cloud-owner.js";
120
- import { createCloudMoveRoutes } from "./cloud-move-http.js";
125
+ import { createCloudMoveRoutes, workspaceShared } from "./cloud-move-http.js";
126
+ import { RESTART_EXIT_CODE } from "./restart.js";
121
127
  import { holdIncludedServices } from "./included-services.js";
122
128
  import { selectDefaultModelSelection, withNewBotEffort } from "./default-model-selection.js";
129
+ import { computerEngineMoveText, removedComputerInstanceIds, writeComputerEngineMoveLines } from "./computer-engine-removal.js";
123
130
  import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, peerApprovalFailure, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
124
131
  import { peerDeliveryReceipt } from "./peer-delivery.js";
125
132
  import { peerProvenanceNote, withPeerProvenance } from "./peer-provenance.js";
@@ -132,13 +139,13 @@ import { createLiveSession, liveAttachUrl, LiveSessionError } from "./live-call.
132
139
  import { LiveCallController, LiveCallSignedOutError } from "./live-call-controller.js";
133
140
  import { narrateTool, toUtterances } from "./tts/speech-text.js";
134
141
  import { turnStartLogLine } from "./turn-log.js";
142
+ import { makeCapContinuationSubscriber } from "./turn-continuation.js";
135
143
  import { buildRecoveryText, buildTurnContext, engineIsFresh, NATIVELY_REPLAYING_DRIVER_KINDS, peerMessageText } from "./turn-context.js";
136
144
  import { Handoffs, handedStateUsable, recordHanded, renderUnseen, sessionStart, unseenMessages, withUnseenMessages } from "./delta-context.js";
137
145
  import { extractTurnImages } from "./turn-images.js";
138
146
  import { threadTitlePrompt, titleConversationExcerpt } from "./thread-title.js";
139
147
  import { TurnWatchdog } from "./turn-watchdog.js";
140
- import { TurnResources, workspaceResource } from "./turn-resources.js";
141
- import { IdleReleasePolicy } from "./claim-idle.js";
148
+ import { TurnResources } from "./turn-resources.js";
142
149
  import { ensureWorkspace, ensureTaskWorkspace, workspaceLocationsPrompt, supportsWorkspaceFiles, updateMemory, appendMemoryLog, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, TASK_WORKSPACES_DIR, readMemoryOnlyFromRegularFiles, workspaceDir, } from "./workspace.js";
143
150
  import { listMemoryTopics, memoryDate, readMemoryFile, readMemoryTopic, writeMemoryFile, writeMemoryTopic } from "./workspace.js";
144
151
  import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, journalMemoryWrite, } from "./memory-journal.js";
@@ -150,7 +157,7 @@ import { botSkillTemplateSchema, newBotDefaultsSchema, resolveBotCreationDefault
150
157
  import { expandLearnTurnText, learnSource } from "./skill-learn.js";
151
158
  import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
152
159
  import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-folder.js";
153
- import { buildSystemPrompt, userProfileSystemPrompt, cloudHomePrompt, computerPrompt, composioSystemPrompt, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, TEAM_MEMORY_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, resolveComputerPromptKind, } from "./system-prompt.js";
160
+ import { buildSystemPrompt, userProfileSystemPrompt, cloudHomePrompt, computerPrompt, composioSystemPrompt, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, TEAM_MEMORY_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, resolveComputerPromptKind, teamAvailabilityPart, } from "./system-prompt.js";
154
161
  import { readCuaConnection, readCuaUnavailableReason, gatedLocalComputer } from "./local-computer.js";
155
162
  import { discoverExistingPerBotLocalVms, discoverExistingPoolLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
156
163
  import { localVmStopReason, recordLocalVmIdleStop } from "./local-vm-stop-reason.js";
@@ -165,7 +172,7 @@ import { CalendarCallManager } from "./calendar-calls.js";
165
172
  import { BUILT_IN_BROWSER_SYSTEM_PROMPT } from "./browser-engine.js";
166
173
  import { BrowserRuntime, browserRuntimeEnv } from "./browser-runtime.js";
167
174
  import { BrowserLive } from "./browser-live.js";
168
- import { agentBrowserFrame, agentBrowserIntegration, browserEngineEncryptionKey, prepareBrowserSessionState, clearBrowserSessionState, closeBrowserSession, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, browserEngineStatus, browserSessionId, describeBrowserEngine, } from "./browser-engine.js";
175
+ import { agentBrowserFrame, agentBrowserIntegration, browserEngineEncryptionKey, prepareBrowserSessionState, clearBrowserSessionState, closeBrowserSession, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, browserEngineStatus, browserSessionId, describeBrowserEngine, setBrowserViewport, } from "./browser-engine.js";
169
176
  import { createScreenFrameSource } from "./screen-frame-source.js";
170
177
  import { screenFrameHash, screenSurfaceForTool, screenTouchingTool, settledFrameIsNews } from "./screen-frame-gate.js";
171
178
  import { asSchedule, RoutineRequestService } from "./routine-requests.js";
@@ -173,7 +180,6 @@ import { createOptionsCard } from "./options-card.js";
173
180
  import { buildBotOverview, connectedAppsFacts } from "./bot-overview.js";
174
181
  import { ProfileRequestService } from "./profile-requests.js";
175
182
  import { ModelRequestService } from "./model-requests.js";
176
- import { TighteningRequestService } from "./tightening-requests.js";
177
183
  import { TeamSetupError, TeamSetupRequestService } from "./team-setup-requests.js";
178
184
  import { profileRevision, profileSnapshot } from "./profile-revision.js";
179
185
  import { flushAllProfileHistory, flushProfileHistory, readHistory, recordProfileChange } from "./profile-versions.js";
@@ -206,11 +212,11 @@ import { createTeamBackup, importTeamBackup } from "./team-backup.js";
206
212
  import { MAX_TEAM_BACKUP_BYTES } from "../shared/team-backup.js";
207
213
  import { shouldMountLocalComputer } from "./local-routing.js";
208
214
  import { autoLocalVmAttachable } from "./container-computer.js";
209
- import { startAutoVmClaim } from "./auto-vm-claims.js";
215
+ import { lazyClaimWaitMs, startAutoVmClaim } from "./auto-vm-claims.js";
210
216
  import { computerFreeAfterText, computerParkedText, computerStoppedWaitingText, computerWaitingText } from "./computer-wait.js";
211
- import { CloudOverflowConsent, CloudSeatLease, cloudOverflowAction, cloudOverflowConsentText, cloudOverflowOfferText, cloudSeatStartedText, cloudSeatStoppedText } from "./cloud-overflow.js";
212
217
  import { modelContextWindow } from "./model-context-window.js";
213
- import { cloudPlaceDriverError, parseSurface, PlaceUnavailableError, placeUnavailable, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
218
+ import { cloudPlaceRefusal, computerToolsRefusal, parseSurface, PlaceUnavailableError, placeUnavailable, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
219
+ import { cloudRefusal } from "../shared/place-view.js";
214
220
  import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
215
221
  import { createGracefulShutdown } from "./graceful-shutdown.js";
216
222
  import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
@@ -243,6 +249,7 @@ import { localDesktopTarget, localVmViewerStatus, viewerTargetId } from "./deskt
243
249
  import { createAntigravityLeftoverRoutes } from "./routes/antigravity-leftovers.js";
244
250
  import { findAntigravityLeftovers, removeAntigravityLeftovers } from "./drivers/antigravity-temp.js";
245
251
  import { createLiveRoutes } from "./routes/live.js";
252
+ import { withScopeHint } from "./connector-scope-hint.js";
246
253
  const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799);
247
254
  const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1);
248
255
  // Behind a proxy or tunnel, the base URL senders should use (docs/self-hosting.md).
@@ -557,7 +564,7 @@ function cloudLendingTurn(capability) {
557
564
  generation: capability.generation,
558
565
  thread: store.messagesFor(capability.threadId),
559
566
  starters: cloudThreadStarters(capability.threadId),
560
- reportsFromOthers: store.messagesFor(capability.threadId).some(cloudOthersRoutineReport),
567
+ reportsFromOthers: store.messagesFor(capability.threadId).some(cloudReportFromOthers),
561
568
  ownerPerson: cloudProvenOwnerPerson,
562
569
  cardAnswerer: cloudCardAnswerer,
563
570
  routineRun: () => {
@@ -600,11 +607,13 @@ function cloudThreadStarters(threadId) {
600
607
  function cloudLendingView(capability) {
601
608
  return CLOUD_HOME !== null && cloudHomeLendingRefusal(cloudLendingTurn(capability)) === null;
602
609
  }
603
- /** On a Cloud home: a routine's report (its lifecycle card, carrying what
604
- * the run said) of a routine the owner did not write, as it stands now. It
605
- * brings someone else's instructions' results into the conversation it
606
- * reports to. */
607
- function cloudOthersRoutineReport(line) {
610
+ /** On a Cloud home: a line that brings someone else's words into the
611
+ * conversation it lands in: a "post" webhook's payload, or a routine's
612
+ * report (its lifecycle card, carrying what the run said) of a routine the
613
+ * owner did not write, as it stands now. */
614
+ function cloudReportFromOthers(line) {
615
+ if (line.webhookPost)
616
+ return true;
608
617
  if (line.kind !== "routine.run" || !line.routineRun)
609
618
  return false;
610
619
  const routine = routines?.listRoutines().find((candidate) => candidate.id === line.routineRun.routineId);
@@ -626,7 +635,7 @@ function cloudOwnerOnlyThread(threadId) {
626
635
  const lastKey = last ? `${last.id}:${last.card?.answeredBy ? JSON.stringify(last.card.answeredBy) : ""}` : undefined;
627
636
  if (cached && cached.length === thread.length && cached.last === lastKey)
628
637
  return cached.owner;
629
- const owner = ownerOnlyConversation(thread, cloudProvenOwnerPerson, cloudCardAnswerer) && !thread.some(cloudOthersRoutineReport);
638
+ const owner = ownerOnlyConversation(thread, cloudProvenOwnerPerson, cloudCardAnswerer) && !thread.some(cloudReportFromOthers);
630
639
  if (ownerOnlyCache.size > 5_000)
631
640
  ownerOnlyCache.clear();
632
641
  ownerOnlyCache.set(threadId, { length: thread.length, last: lastKey, owner });
@@ -862,7 +871,7 @@ function guestWorkThread(from, target, fromThreadId, brief) {
862
871
  * to have written, or a conversation or room a revoked session opened. What
863
872
  * the owner is told about one, and when its engine cannot be confined. */
864
873
  const ROUTINE_FROM_BEFORE = "This routine was made before this update. Open it and save it once to run it with full access.";
865
- 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.";
874
+ 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.";
866
875
  function confinedWhy(threadId) {
867
876
  const run = activeRoutineRunForThread(threadId);
868
877
  const writer = run ? cloudRoutineAuthors?.writer(run.routineId) : undefined;
@@ -871,7 +880,7 @@ function confinedWhy(threadId) {
871
880
  function guestEngineRefusal(threadId) {
872
881
  const why = confinedWhy(threadId);
873
882
  return why === ROUTINE_FROM_BEFORE ? `This bot's engine can't run it that way. ${why}`
874
- : "This conversation is from before your Cloud was only yours, and this bot's engine can't work in it. Start a new conversation.";
883
+ : "This conversation is from before My Cloud was only yours, and this bot's engine can't work in it. Start a new conversation.";
875
884
  }
876
885
  /** On a Cloud home: a turn a guest drives, which runs in Ask whatever the
877
886
  * bot's own level (no Full access, no Auto reviewer, no saved commands), with
@@ -1247,11 +1256,11 @@ function memberFrameContext(visible) {
1247
1256
  webhookBot: (webhookId) => webhooks.list().find((webhook) => webhook.id === webhookId)?.botId,
1248
1257
  freshBot: (botId) => {
1249
1258
  const bot = store.bot(botId);
1250
- return bot ? { ...wireBot(bot), tasks: store.tasks(bot.id).map(wireTask), ...messagePage(bot.threadId, DEFAULT_PAGE) } : undefined;
1259
+ return bot ? publicBot(bot) : undefined;
1251
1260
  },
1252
1261
  freshGroup: (groupId) => {
1253
1262
  const group = store.group(groupId);
1254
- return group ? { ...publicGroupState(group), ...messagePage(group.threadId, DEFAULT_PAGE) } : undefined;
1263
+ return group ? groupWithThread(group) : undefined;
1255
1264
  },
1256
1265
  };
1257
1266
  }
@@ -1669,8 +1678,6 @@ function computerPlaceRefusal(kind) {
1669
1678
  const managed = managedPolicy.computerRefusal(kind);
1670
1679
  return managed ? { message: managed, code: "managed_policy" } : undefined;
1671
1680
  }
1672
- /** Cloud chosen with no Boat account: a Cloud home suggests the browser, not a Local VM. */
1673
- const BOAT_NOT_CONFIGURED = boatNotConfiguredMessage(Boolean(CLOUD_HOME));
1674
1681
  /** Why the organisation refuses this instance for bots, or undefined. */
1675
1682
  function policyModelRefusal(instance) {
1676
1683
  const engine = BUILT_IN_DRIVERS.find(driver => driver.driverKind === instance.driverKind)?.metadata.displayName;
@@ -1754,6 +1761,8 @@ utilityParentPort?.on("message", event => {
1754
1761
  });
1755
1762
  });
1756
1763
  const MAX_ATTACHED_FILES_PER_TURN = 10;
1764
+ const MAX_ROOM_POSTS_PER_TURN = 3;
1765
+ const MAX_CREATED_BOTS_PER_TURN = 4;
1757
1766
  // A capability lives for the exact provider-turn generation, including while
1758
1767
  // that turn is parked on a human approval. The long ceiling is only an orphan
1759
1768
  // backstop for an impossible-to-settle adapter; normal terminal paths revoke
@@ -1784,6 +1793,11 @@ function externalRuntimeMayCall(method, path) {
1784
1793
  return path === "/api/internal/agents" || /^\/api\/internal\/delegations\/[\w-]+$/.test(path);
1785
1794
  return method === "POST" && (path === "/api/internal/ask-bot" || path === "/api/internal/delegate-bot");
1786
1795
  }
1796
+ /** An external runtime polls check/wait_delegation for what it hands off, so
1797
+ * the result must not also wake the bot's own engine on the pinned thread. */
1798
+ function externalCompletion(capability) {
1799
+ return capability.generation === EXTERNAL_RUNTIME_GENERATION ? { completionOwner: "external" } : {};
1800
+ }
1787
1801
  function externalRuntimeCapability(header) {
1788
1802
  const grant = authorizeExternalRuntime(EXTERNAL_RUNTIMES_FILE, header, id => store.bot(id));
1789
1803
  if (!grant)
@@ -1810,15 +1824,52 @@ function beginInternalCapabilityGeneration(threadId, generation = randomUUID())
1810
1824
  activeInternalGenerationByThread.set(threadId, generation);
1811
1825
  return generation;
1812
1826
  }
1813
- function mintInternalCapability(capability) {
1827
+ // The bearer an integration's proxy holds is part of the engine's launch
1828
+ // contract (Claude's process key, ACP's session inputs): a new one every turn
1829
+ // meant a relaunch, a resumed transcript and new MCP servers every turn. So
1830
+ // the bearer stays the same while the same bot works the same thread with the
1831
+ // same grants and approval level, and only the record behind it is per turn:
1832
+ // between turns, and after any revoke, nothing honours it. A changed grant,
1833
+ // Stop, a stall, a deleted bot or thread or a provider reload gives the next
1834
+ // turn a new bearer, so whatever still holds the old one is never honoured
1835
+ // again.
1836
+ /** threadId → "botId kind" → the bearer and the grants it was minted for. */
1837
+ const sessionCredentials = new Map();
1838
+ /** What a bearer stands for: everything its record grants (a turn's counters
1839
+ * all start at zero), plus the thread's approval level; not the turn. */
1840
+ function sessionGrants({ generation: _turn, ...grants }) {
1841
+ const thread = botForThread(grants.botId, grants.threadId);
1842
+ return JSON.stringify({ ...grants, approval: thread ? approvalModeFor(thread) : null });
1843
+ }
1844
+ /** The thread's bearer for this integration, new only when its grants changed. */
1845
+ function sessionBearer(capability) {
1846
+ const credentials = sessionCredentials.get(capability.threadId) ?? new Map();
1847
+ sessionCredentials.set(capability.threadId, credentials);
1848
+ const slot = `${capability.botId} ${capability.kind}`;
1849
+ const grants = sessionGrants(capability);
1850
+ const held = credentials.get(slot);
1851
+ if (held?.grants === grants)
1852
+ return held.token;
1853
+ const token = randomBytes(24).toString("hex");
1854
+ credentials.set(slot, { grants, token });
1855
+ return token;
1856
+ }
1857
+ function mintInternalCapability(capability, { perTurn = false } = {}) {
1814
1858
  if (activeInternalGenerationByThread.get(capability.threadId) !== capability.generation) {
1815
1859
  throw new Error("cannot mint an integration capability for an inactive turn");
1816
1860
  }
1817
- const token = randomBytes(24).toString("hex");
1818
- internalCapabilities.set(token, {
1819
- ...capability,
1820
- orphanExpiresAt: Date.now() + INTERNAL_CAPABILITY_ORPHAN_MS,
1821
- });
1861
+ // perTurn: a bridge into a machine the harness stops and starts between
1862
+ // turns (a Local VM, the VPS, a Boat). A warm engine never restarts a
1863
+ // server whose machine went away, so such a turn gets a new bearer and
1864
+ // starts its engine fresh.
1865
+ const token = perTurn ? randomBytes(24).toString("hex") : sessionBearer(capability);
1866
+ // Minted twice in one turn: keep that turn's record and its counters.
1867
+ if (internalCapabilities.get(token)?.generation !== capability.generation) {
1868
+ internalCapabilities.set(token, {
1869
+ ...capability,
1870
+ orphanExpiresAt: Date.now() + INTERNAL_CAPABILITY_ORPHAN_MS,
1871
+ });
1872
+ }
1822
1873
  return token;
1823
1874
  }
1824
1875
  /** A turn that was not provably the owner's has ended: judge its bot's
@@ -1852,7 +1903,9 @@ function revokeInternalCapabilityGeneration(threadId, generation) {
1852
1903
  }
1853
1904
  internalGenerationByProviderTurn.deleteGeneration(threadId, generation);
1854
1905
  }
1855
- function revokeInternalCapabilitiesForThread(threadId) {
1906
+ /** Before a new turn on this thread mints: nothing an earlier turn here was
1907
+ * given stays honoured. The thread's bearers stay, so its engine stays warm. */
1908
+ function revokeEarlierTurnCapabilities(threadId) {
1856
1909
  computerSelectionTurns.delete(threadId);
1857
1910
  const generation = activeInternalGenerationByThread.get(threadId);
1858
1911
  if (generation)
@@ -1865,9 +1918,16 @@ function revokeInternalCapabilitiesForThread(threadId) {
1865
1918
  internalCapabilities.delete(token);
1866
1919
  }
1867
1920
  }
1921
+ /** Stop, a stall, a deleted bot or thread, a provider change: the thread's
1922
+ * bearers are retired too, so nothing that still holds one is honoured again. */
1923
+ function revokeInternalCapabilitiesForThread(threadId) {
1924
+ revokeEarlierTurnCapabilities(threadId);
1925
+ sessionCredentials.delete(threadId);
1926
+ }
1868
1927
  function revokeAllInternalCapabilities() {
1869
1928
  computerSelectionTurns.clear();
1870
1929
  internalCapabilities.clear();
1930
+ sessionCredentials.clear();
1871
1931
  // foreignTurns stays: a turn that is not provably the owner's can still
1872
1932
  // write while its engine is torn down, and its own end (which revokes its
1873
1933
  // generation by id) is what judges it (server/lending-memory.ts).
@@ -2031,6 +2091,64 @@ class ComputerWaitParked extends Error {
2031
2091
  this.generation = generation;
2032
2092
  }
2033
2093
  }
2094
+ /** A failed place never sticks: an Auto-recorded pin to it (a
2095
+ * select_computer switch, or where an earlier Auto turn landed) is the
2096
+ * machine's memory, not a choice, so the next message runs on Auto. A
2097
+ * person's pin stays. */
2098
+ function clearFailedAutoPin(botId, threadId, place) {
2099
+ const pinned = store.taskByThread(botId, threadId);
2100
+ if (pinned?.surface === place && pinned.surfaceSource === "auto") {
2101
+ store.patchTask(botId, threadId, { surface: undefined, surfaceSource: undefined });
2102
+ }
2103
+ }
2104
+ /** A place the turn was told to use failed to attach, read as one state of
2105
+ * shared/place-view.ts: a cloud computer's refusal by its code or words, any
2106
+ * other place by its own words. Its source decides the one way on. A
2107
+ * cancelled setup, a parked computer wait and an already-read place failure
2108
+ * pass through as they are. The one wrapper for a bot thread and a room
2109
+ * turn, at dispatch and at a cloud computer's start on its first call. */
2110
+ async function asPlaceFailure(error, place, source, bot, backend = "box") {
2111
+ if (error instanceof DirectTurnSetupCancelled || error instanceof ComputerWaitParked || error instanceof PlaceUnavailableError)
2112
+ return error;
2113
+ const failure = error instanceof Error ? error : new Error(String(error));
2114
+ const cause = failure.message.trim().replace(/[\s.]+$/, "");
2115
+ if (place !== "cloud" || backend === "vps") {
2116
+ return placeUnavailable(place, {
2117
+ state: "place-failed", params: { bot: bot.name, cause: `${cause.charAt(0).toUpperCase()}${cause.slice(1)}.` }, source,
2118
+ }, Boolean(CLOUD_HOME));
2119
+ }
2120
+ // A Boat refusal keeps the provider's own status and code (boat.ts
2121
+ // boatRefusal); a failed read of the account keeps its status.
2122
+ const { boatCode, boatStatus, status } = failure;
2123
+ const providerStatus = typeof boatStatus === "number" ? boatStatus : typeof status === "number" ? status : undefined;
2124
+ const read = cloudRefusal({
2125
+ message: failure.message,
2126
+ ...(typeof boatCode === "string" ? { code: boatCode } : {}),
2127
+ ...(providerStatus !== undefined ? { status: providerStatus } : {}),
2128
+ }, boat.boatAccount(cfg)?.included === true);
2129
+ const row = { state: read.state, params: { bot: bot.name, ...read.params }, source };
2130
+ if (row.state === "cc-at-once")
2131
+ row.params.holders = await cloudComputerHolders(bot.id);
2132
+ return placeUnavailable(place, row, Boolean(CLOUD_HOME));
2133
+ }
2134
+ /** The bots that hold this account's cloud computers, other than `botId`,
2135
+ * from this installation's own Boat list. Empty when it can't be read. */
2136
+ async function cloudComputerHolders(botId) {
2137
+ try {
2138
+ const listed = await boat.listManagedBoats(cfg, managedBoatOwners(), { adoptLegacy: false });
2139
+ return [...new Set(listed.instances
2140
+ .filter((instance) => instance.ownerBotId && instance.ownerBotId !== botId && instance.ownerName)
2141
+ .map((instance) => instance.ownerName))];
2142
+ }
2143
+ catch {
2144
+ return [];
2145
+ }
2146
+ }
2147
+ /** Cloud chosen with no cloud computers here: on a Cloud home they are the
2148
+ * plan's, so they are only unavailable; anywhere else they need a Boat key. */
2149
+ function boatNotConfigured(bot, source) {
2150
+ return placeUnavailable("cloud", { state: CLOUD_HOME ? "cc-unavailable" : "cc-needs-key", params: { bot: bot.name }, source }, Boolean(CLOUD_HOME));
2151
+ }
2034
2152
  const directTurnDispatchClaims = new Map();
2035
2153
  const directTurnGenerationByThread = new Map();
2036
2154
  // Only the latest direct request per thread is retained. A fresh user turn
@@ -2073,173 +2191,14 @@ const turnResources = new TurnResources();
2073
2191
  const sharedComputerControl = new SharedComputerControl(turnResources, () => store.bots.some(bot => botComputerControlSnapshot(bot.id).held));
2074
2192
  const turnResourceOwners = new Map();
2075
2193
  const turnComputerResources = new Map();
2076
- /** Screen-touching computer calls that started before their turn's computer
2077
- * claim was bound (#1653 over the lazy attach of #1361): the start event
2078
- * finds no claim to fence, so it waits here and bindTurnComputer carries it
2079
- * into the fresh claim, keeping the quiet window from releasing a seat
2080
- * under a call the desktop is already executing. */
2081
- const pendingComputerCallStarts = new Map();
2082
2194
  const teamComputerTurns = new Map();
2083
2195
  const settlingResourceOwners = new Map();
2084
- /** Idle release for computer claims (#1653), disabled until the feature
2085
- * flag is on: a seat that stays screen-quiet for the policy's window is
2086
- * released to waiting turns while its holder's turn still lives, and the
2087
- * previous holder re-claims directly inside the reclaim window — it
2088
- * yields once another turn holds the seat. The durable VM profile keeps
2089
- * its login state, so that re-claim is cheap. Read per claim: a flag
2090
- * change applies to new claims, never to a seat held under the old
2091
- * setting. computer:phone is excluded: it is the #1663 exclusivity lock,
2092
- * claimed once per turn with no activity signal to renew it. */
2093
- const COMPUTER_CLAIM_IDLE_POLICY = new IdleReleasePolicy();
2094
- function computerClaimIdlePolicy(resource) {
2095
- return resource.startsWith("computer:") && resource !== "computer:phone" && computerClaimIdleReleaseEnabled(cfg) ? COMPUTER_CLAIM_IDLE_POLICY : undefined;
2096
- }
2097
- /** #1655: the consent ledger for local-wait cloud overflow, and the live
2098
- * cloud seats it has started. One seat per bot — it is the bot's own Box,
2099
- * shared by that bot's threads the way every other computer is. */
2100
- const cloudOverflowConsent = new CloudOverflowConsent();
2101
- const cloudSeatLeases = new Map();
2102
- let cloudSeatSweepTimer;
2103
- /** One stop attempt per seat at a time (#1655): the sweep ticks faster
2104
- * than a Box sleeps, and a second stop call would race the first. */
2105
- const cloudSeatStopsInFlight = new Set();
2106
- /** The waits that may overflow: a turn queued for a local desktop, never
2107
- * one already bound for a cloud or VPS seat. */
2108
- function localComputerWait(resource) {
2109
- return resource === "computer:host" || resource.startsWith("computer:vm:");
2110
- }
2111
- function cloudOverflowSituation(owner, resource, started) {
2112
- if (!localComputerWait(resource))
2113
- return null;
2114
- if (!store.botByThread(owner.threadId))
2115
- return null;
2116
- const perSecondCostUsd = cloudOverflowPerSecondCostUsd(cfg);
2117
- return {
2118
- featureEnabled: cloudOverflowEnabled(cfg),
2119
- // An organisation that refuses the Box kind has nothing to offer, and
2120
- // the decision must fail closed before any card is written.
2121
- cloudConfigured: boat.boatConfigured(cfg) && managedPolicy.computerRefusal("box") === undefined,
2122
- perSecondCostUsd,
2123
- // Consent answers the priced card (#1655): a grant counts only at the
2124
- // rate its card showed, so a config change re-offers instead of
2125
- // starting at a price nobody approved.
2126
- consented: cloudOverflowConsent.consented(owner.threadId, cloudOverflowAllowlistedThreads(cfg), perSecondCostUsd ?? undefined),
2127
- offered: cloudOverflowConsent.offered(owner.threadId, perSecondCostUsd ?? undefined),
2128
- started,
2129
- };
2130
- }
2131
2196
  function claimTurnResource(owner, resource) {
2132
- const idle = computerClaimIdlePolicy(resource);
2133
- if (!turnResources.claim(resource, owner, idle ? { idle } : {}))
2197
+ if (!turnResources.claim(resource, owner))
2134
2198
  return false;
2135
2199
  turnResourceOwners.set(owner.threadId, owner);
2136
2200
  return true;
2137
2201
  }
2138
- /** Start the waiting bot's cloud seat (#1655). True once handled — started
2139
- * or terminally failed — so the wait never retries every poll; false when
2140
- * another turn of this bot owns the Box lifecycle and a later poll may
2141
- * still land. */
2142
- async function startCloudSeat(owner) {
2143
- const bot = store.botByThread(owner.threadId);
2144
- if (!bot)
2145
- return true;
2146
- const perSecondCostUsd = cloudOverflowPerSecondCostUsd(cfg);
2147
- if (!perSecondCostUsd)
2148
- return true;
2149
- const boxBotResource = "computer:box-bot:" + bot.id;
2150
- if (!claimTurnResource(owner, boxBotResource))
2151
- return false;
2152
- try {
2153
- broadcast({ kind: "computer", botId: bot.id, state: "waking" });
2154
- const machine = await boat.readyBoat(cfg, bot.id);
2155
- if (!machine)
2156
- throw new Error("the cloud computer did not wake");
2157
- // Consent can be revoked while the Box wakes (#1655): a seat nobody
2158
- // consented to must not bill. It is released and put straight back to
2159
- // sleep — and if even that fails, leased as already idle so the sweep
2160
- // keeps retrying with backoff.
2161
- if (!cloudOverflowConsent.consented(owner.threadId, cloudOverflowAllowlistedThreads(cfg), perSecondCostUsd)) {
2162
- turnResources.releaseOne(boxBotResource, owner);
2163
- const idleStopMs = cloudOverflowIdleStopMs(cfg);
2164
- void boat.sleepBoat(cfg, bot.id).catch(() => {
2165
- cloudSeatLeases.set(bot.id, new CloudSeatLease({ botId: bot.id, threadId: owner.threadId, generation: owner.generation, now: Date.now() - idleStopMs - 1, idleStopMs }));
2166
- ensureCloudSeatSweep();
2167
- });
2168
- store.appendMessage(owner.threadId, {
2169
- role: "bot", kind: "activity",
2170
- tool: { name: "Cloud overflow consent was revoked while the cloud computer woke; it stops unbilled and the local wait continues.", ok: true },
2171
- });
2172
- return true;
2173
- }
2174
- cloudSeatLeases.set(bot.id, new CloudSeatLease({ botId: bot.id, threadId: owner.threadId, generation: owner.generation, idleStopMs: cloudOverflowIdleStopMs(cfg) }));
2175
- ensureCloudSeatSweep();
2176
- store.appendMessage(owner.threadId, {
2177
- role: "bot", kind: "activity",
2178
- tool: { name: cloudSeatStartedText({ perSecondCostUsd, idleStopMs: cloudOverflowIdleStopMs(cfg) }), ok: true },
2179
- });
2180
- return true;
2181
- }
2182
- catch (error) {
2183
- turnResources.releaseOne(boxBotResource, owner);
2184
- store.appendMessage(owner.threadId, {
2185
- role: "bot", kind: "activity",
2186
- tool: { name: "Cloud computer could not start (" + (error instanceof Error ? error.message : String(error)) + "); the local wait continues.", ok: false },
2187
- });
2188
- return true;
2189
- }
2190
- }
2191
- /** A per-second-billed seat must stop on idleness even when nothing else
2192
- * touches the server, so this slice carries the one timer — unref'd, it
2193
- * can never hold the process open, and it dies with the last lease. */
2194
- function ensureCloudSeatSweep() {
2195
- if (cloudSeatSweepTimer)
2196
- return;
2197
- cloudSeatSweepTimer = setInterval(() => {
2198
- sweepIdleCloudSeats();
2199
- if (!cloudSeatLeases.size && cloudSeatSweepTimer) {
2200
- clearInterval(cloudSeatSweepTimer);
2201
- cloudSeatSweepTimer = undefined;
2202
- }
2203
- }, 15_000);
2204
- cloudSeatSweepTimer.unref?.();
2205
- }
2206
- /** Stop every cloud seat idle past its window (#1655): the machine is
2207
- * archived, billing pauses, and the transcript says which wait's seat it
2208
- * was. Only real computer tool completions refresh a lease — the poller's
2209
- * preview frames never reach the touch call. */
2210
- /** Stop one running cloud seat (#1655): the Box sleeps, and only a
2211
- * successful stop clears the lease and its Box claim — a failed stop
2212
- * stays leased and retryable with backoff, because the seat still bills
2213
- * until it sleeps. The claim releases for the lease's own generation,
2214
- * never a newer turn's. */
2215
- function stopCloudSeat(botId, lease, stoppedName, now = Date.now()) {
2216
- if (cloudSeatStopsInFlight.has(botId))
2217
- return;
2218
- if (cloudSeatLeases.get(botId) !== lease)
2219
- return;
2220
- cloudSeatStopsInFlight.add(botId);
2221
- void boat.sleepBoat(cfg, botId)
2222
- .then(() => {
2223
- if (cloudSeatLeases.get(botId) === lease)
2224
- cloudSeatLeases.delete(botId);
2225
- // The stopped seat's Box claim goes with it, so a still-waiting
2226
- // turn may start a fresh seat for the same bot.
2227
- turnResources.releaseOne(`computer:box-bot:${botId}`, { threadId: lease.threadId, generation: lease.generation });
2228
- store.appendMessage(lease.threadId, { role: "bot", kind: "activity", tool: { name: stoppedName, ok: true } });
2229
- })
2230
- .catch(() => {
2231
- lease.deferSleep(now);
2232
- store.appendMessage(lease.threadId, { role: "bot", kind: "activity", tool: { name: "Cloud computer could not stop — the sweep retries with backoff; stop it from Settings if it stays awake. Billing continues until it sleeps.", ok: false } });
2233
- })
2234
- .finally(() => cloudSeatStopsInFlight.delete(botId));
2235
- }
2236
- function sweepIdleCloudSeats(now = Date.now()) {
2237
- for (const [botId, lease] of cloudSeatLeases) {
2238
- if (!lease.idleElapsed(now) || !lease.sleepDue(now))
2239
- continue;
2240
- stopCloudSeat(botId, lease, cloudSeatStoppedText(lease.idleFor(now)), now);
2241
- }
2242
- }
2243
2202
  function releaseTurnResources(owner) {
2244
2203
  if (!owner)
2245
2204
  return;
@@ -2252,8 +2211,6 @@ function releaseTurnResources(owner) {
2252
2211
  turnResourceOwners.delete(owner.threadId);
2253
2212
  if (turnComputerResources.get(owner.threadId)?.owner.generation === owner.generation)
2254
2213
  turnComputerResources.delete(owner.threadId);
2255
- if (pendingComputerCallStarts.get(owner.threadId)?.generation === owner.generation)
2256
- pendingComputerCallStarts.delete(owner.threadId);
2257
2214
  const teamTurn = teamComputerTurns.get(owner.threadId);
2258
2215
  if (teamTurn?.owner.generation === owner.generation) {
2259
2216
  stopScreenPoller(teamTurn.botId, owner.threadId);
@@ -2283,10 +2240,6 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2283
2240
  // turn.wait_* events carry the same facts for the inspector log.
2284
2241
  let waitedSince;
2285
2242
  let waitEnded = false;
2286
- // #1655: whether this wait already started (or terminally failed to
2287
- // start) a cloud seat; the decision is re-evaluated every poll because
2288
- // consent can arrive mid-wait.
2289
- let overflowHandled = false;
2290
2243
  const waitEventBase = () => {
2291
2244
  const selection = store.botByThread(owner.threadId)?.modelSelection;
2292
2245
  const instance = selection ? registry.get(selection.instanceId) : null;
@@ -2360,32 +2313,7 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2360
2313
  position,
2361
2314
  ...(holder ? { holder } : {}),
2362
2315
  });
2363
- // #1655: the wait is where an overflow choice gets made. The card
2364
- // names the per-second cost before anything can start, and only a
2365
- // turn genuinely waiting on a local desktop gets one. Fail closed:
2366
- // feature off, cloud unusable, or no operator-set rate means no
2367
- // card at all — Auto never offers, never starts.
2368
- const overflow = cloudOverflowSituation(owner, resource, overflowHandled);
2369
- const perSecondCostUsd = overflow?.perSecondCostUsd ?? null;
2370
- if (overflow && perSecondCostUsd !== null && cloudOverflowAction(overflow).kind === "offer") {
2371
- store.appendMessage(owner.threadId, {
2372
- role: "bot", kind: "activity",
2373
- tool: { name: cloudOverflowOfferText({ perSecondCostUsd, idleStopMs: cloudOverflowIdleStopMs(cfg) }), ok: true },
2374
- });
2375
- cloudOverflowConsent.markOffered(owner.threadId, perSecondCostUsd);
2376
- }
2377
2316
  }
2378
- // #1655: consent can arrive mid-wait — the card's answer, or a
2379
- // thread the operator allowlisted — and only then may the cloud
2380
- // seat start, its cost chip landing beside the waiting one. The
2381
- // local wait itself is untouched: it still ends by acquisition,
2382
- // stop, or the ceiling (parking, once that stack lands).
2383
- if (!overflowHandled) {
2384
- const situation = cloudOverflowSituation(owner, resource, overflowHandled);
2385
- if (situation && cloudOverflowAction(situation).kind === "start")
2386
- overflowHandled = await startCloudSeat(owner);
2387
- }
2388
- sweepIdleCloudSeats();
2389
2317
  if (Date.now() >= deadline) {
2390
2318
  // ADR-2 (#1651): the ceiling parks the turn — settled, with a
2391
2319
  // registered resume — instead of failing it. The owner mark lets the
@@ -2407,15 +2335,6 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2407
2335
  }
2408
2336
  turnResourceOwners.set(owner.threadId, owner);
2409
2337
  turnComputerResources.set(owner.threadId, { owner, resource });
2410
- // A screen call that started before this bind (#1653) fenced nothing
2411
- // because no claim existed yet: carry its pending start into the fresh
2412
- // claim so the idle window still defers to the running call.
2413
- const pendingStarts = pendingComputerCallStarts.get(owner.threadId);
2414
- if (pendingStarts?.generation === owner.generation && pendingStarts.count > 0) {
2415
- pendingComputerCallStarts.delete(owner.threadId);
2416
- for (let i = 0; i < pendingStarts.count; i += 1)
2417
- turnResources.beginComputerCall(resource, owner);
2418
- }
2419
2338
  }
2420
2339
  function botForThread(botId, threadId) {
2421
2340
  return directTurnBots.get(threadId) ?? store.projectBotForTask(botId, threadId) ?? store.bot(botId);
@@ -2461,15 +2380,9 @@ function canAdmitDirectTurn(botId, threadId) {
2461
2380
  /** Routine and webhook dispatch shares startTurn's admission preconditions
2462
2381
  * instead of waiting for whole-bot idleness: a free thread slot and no
2463
2382
  * active group turn. A group turn blocks scheduled starts the same way it
2464
- * blocks every other turn kind; it does not consume a capacity slot.
2465
- *
2466
- * A fresh dispatch also pins to the bot's own project folder, exactly like
2467
- * startTurn's cwd resolution: a free thread slot alone can miss a second
2468
- * task of this bot landing in that one folder (#F-collide). Predicting that
2469
- * collision here lets it defer through the same busy-target machinery as
2470
- * capacity and group-turn contention, instead of reaching startTurn's own
2471
- * workspace claim — which throws deep inside a detached dispatch that no
2472
- * caller here awaits, so the run would otherwise fail permanently. */
2383
+ * blocks every other turn kind; it does not consume a capacity slot. The
2384
+ * bot's project folder is not a slot: a fresh run works there beside the
2385
+ * bot's other threads (see startTurn's cwd resolution). */
2473
2386
  function unattendedDispatchState(botId) {
2474
2387
  const bot = store.bot(botId);
2475
2388
  const decision = admit("unattended", {}, {
@@ -2479,16 +2392,6 @@ function unattendedDispatchState(botId) {
2479
2392
  });
2480
2393
  if (decision.action === "refuse")
2481
2394
  return decision.code === "missing" ? "missing" : "busy";
2482
- if (bot?.cwd) {
2483
- try {
2484
- if (turnResources.blocker(workspaceResource(bot.cwd), { threadId: "", generation: "" }))
2485
- return "busy";
2486
- }
2487
- catch {
2488
- // An unresolvable folder is startTurn's own admission check to
2489
- // report; this prediction only ever adds a defer, never a refusal.
2490
- }
2491
- }
2492
2395
  return "ready";
2493
2396
  }
2494
2397
  function requestedTaskBot(botId, rawThreadId) {
@@ -2670,6 +2573,7 @@ const browserRuntime = new BrowserRuntime({
2670
2573
  browserLive.closeForSession(session);
2671
2574
  return closed;
2672
2575
  },
2576
+ applyViewport: (spec) => setBrowserViewport(spec.command, spec.env),
2673
2577
  });
2674
2578
  const browserLive = new BrowserLive({ runtime: browserRuntime });
2675
2579
  // Temporary profiles last for this server run, but are never saved to disk.
@@ -2839,8 +2743,12 @@ const routineRequestEnvelopeSchema = z.discriminatedUnion("action", [
2839
2743
  ]);
2840
2744
  /** The loopback endpoint a bot's computer proxy polls before acting. With a
2841
2745
  * Boat id, the same turn-scoped token also serves that Boat's tools at
2842
- * /api/internal/computer/mcp, and is revoked when the turn ends. */
2843
- function controlIntegration(botId, threadId, generation, localVmTarget, boxId) {
2746
+ * /api/internal/computer/mcp, and is revoked when the turn ends. This
2747
+ * computer runs as long as the app does, so its bridge keeps the thread's
2748
+ * bearer like every other integration; any other machine (a Local VM, the
2749
+ * VPS, a Boat) gets a new bearer each turn. */
2750
+ function controlIntegration(botId, threadId, generation, machine = {}) {
2751
+ const { localVmTarget, boxId } = machine === "this computer" ? {} : machine;
2844
2752
  return {
2845
2753
  url: `http://127.0.0.1:${PORT}/api/internal/computer-control?botId=${encodeURIComponent(botId)}`,
2846
2754
  token: mintInternalCapability({
@@ -2850,12 +2758,12 @@ function controlIntegration(botId, threadId, generation, localVmTarget, boxId) {
2850
2758
  depth: 0,
2851
2759
  kind: "computer",
2852
2760
  ...(localVmTarget ? { localVmTarget } : {}),
2853
- ...(boxId ? { boxId } : {}),
2761
+ ...(boxId !== undefined ? { boxId } : {}),
2854
2762
  ...(teamComputerTurns.get(threadId) ? { teamComputerId: teamComputerTurns.get(threadId).computerId } : {}),
2855
2763
  skillAuthoring: false,
2856
2764
  createdBots: 0,
2857
2765
  openedThreads: 0,
2858
- }),
2766
+ }, { perTurn: machine !== "this computer" }),
2859
2767
  };
2860
2768
  }
2861
2769
  function askBotAndWait(targetBotId, message, depth, fromBotId, fromThreadId, targetThreadId) {
@@ -2913,17 +2821,89 @@ function askBotAndWait(targetBotId, message, depth, fromBotId, fromThreadId, tar
2913
2821
  }).catch((err) => finish({ status: "error", text: `(couldn't start that bot: ${err instanceof Error ? err.message : String(err)})` }));
2914
2822
  });
2915
2823
  }
2916
- // New bots honor setup's saved choice; unconfigured workspaces prefer Claude.
2917
- // While enrolled, a Company model that can run beats a signed-out personal
2918
- // engine, and the organisation's policy is respected; otherwise inert.
2919
- async function defaultSelection() {
2824
+ // New bots honor setup's saved choice; without one, an engine that can run a
2825
+ // turn now (Claude first). While enrolled, a Company model that can run beats
2826
+ // a signed-out personal engine, and the organisation's policy is respected;
2827
+ // otherwise inert. `null` leaves the saved choice out (moveOffComputerEngine).
2828
+ async function defaultSelection(saved = cfg.defaultModelSelection ?? null) {
2920
2829
  if (hostedModels)
2921
- return hostedModels.select(cfg.defaultModelSelection);
2922
- return selectDefaultModelSelection(await registry.describe(), cfg.defaultModelSelection, {
2830
+ return hostedModels.select(saved ?? undefined);
2831
+ return selectDefaultModelSelection(await registry.describe(), saved ?? undefined, {
2923
2832
  company: (instanceId) => managedDesktop.owns(instanceId),
2924
2833
  refusal: (instance) => policyModelRefusal(instance),
2925
2834
  });
2926
2835
  }
2836
+ /** The Computer engine was removed: it ran a whole turn on Boat's own agent.
2837
+ * Everything still set to it moves, once, to the engine a new bot gets
2838
+ * (defaultSelection, the one picker), or the one a new bot would get without
2839
+ * the saved choice when that cannot run: the saved default and new-bot
2840
+ * defaults, each bot and conversation (Store.retireInstances keeps each one
2841
+ * where it worked), and an automatic-recovery backup, which could never run
2842
+ * and is cleared. Each moved conversation says so in one plain line. When no
2843
+ * engine is available yet it runs again the next time the engines are read
2844
+ * (describeInstances): an install, a sign-in, a key or a Company engine. At
2845
+ * start it sees what a new bot made then would see; a policy that arrives
2846
+ * later refuses an engine for a moved bot the way it does for any bot. A
2847
+ * hosted workspace's reconcile has already moved every bot onto its own
2848
+ * models. */
2849
+ async function moveOffComputerEngine() {
2850
+ if (hostedModels)
2851
+ return;
2852
+ const removed = removedComputerInstanceIds(cfg.instances);
2853
+ const named = (selection) => Boolean(selection && removed.has(selection.instanceId));
2854
+ if (named(cfg.automaticRecovery?.backup)) {
2855
+ saveConfig({ automaticRecovery: { enabled: false } });
2856
+ cfg.automaticRecovery = { enabled: false };
2857
+ }
2858
+ const defaults = named(cfg.defaultModelSelection) || named(cfg.newBotDefaults?.profile.modelSelection);
2859
+ if (!defaults && !store.bots.some(bot => named(bot.modelSelection) || bot.fallback?.some(named) ||
2860
+ store.tasks(bot.id).some(task => named(task.modelSelection))))
2861
+ return;
2862
+ let replacement = named(cfg.defaultModelSelection) ? { instanceId: "", model: "" } : await defaultSelection();
2863
+ if (!replacement.instanceId)
2864
+ replacement = await defaultSelection(null);
2865
+ if (!replacement.instanceId) {
2866
+ console.warn("[engines] the Computer engine was removed and no other engine is available yet; its bots move when one is");
2867
+ return;
2868
+ }
2869
+ if (defaults) {
2870
+ // One write keeps both saved defaults on the replacement (saveConfig
2871
+ // syncs them), and the copies in memory follow at once, so a bot made
2872
+ // before the next start never lands on the removed engine.
2873
+ const newBotDefaults = cfg.newBotDefaults && newBotDefaultsSchema.parse({
2874
+ ...cfg.newBotDefaults, profile: { ...cfg.newBotDefaults.profile, modelSelection: replacement },
2875
+ });
2876
+ saveConfig(newBotDefaults ? { newBotDefaults } : { defaultModelSelection: replacement });
2877
+ cfg.defaultModelSelection = replacement;
2878
+ if (newBotDefaults)
2879
+ cfg.newBotDefaults = newBotDefaults;
2880
+ }
2881
+ const instance = registry.get(replacement.instanceId);
2882
+ const engine = instance?.displayName ?? instance?.driverKind ?? replacement.instanceId;
2883
+ const moves = store.retireInstances(removed, replacement, {
2884
+ driverKind: instance?.driverKind,
2885
+ canWorkOnCloud: canWorkOnCloud(cloudEngine(instance)),
2886
+ keepCloud: (bot) => boat.boatConfigured(cfg) && bot.cloudBackend !== "vps" && !inheritedTeamComputer(bot),
2887
+ });
2888
+ writeComputerEngineMoveLines(moves, (move) => {
2889
+ const bot = store.bot(move.botId);
2890
+ if (bot)
2891
+ store.appendMessage(move.threadId, { role: "bot", kind: "activity", tool: { name: computerEngineMoveText(move, bot.name, engine), ok: true } });
2892
+ });
2893
+ }
2894
+ let computerEngineMoveRunning = null;
2895
+ /** The move, once at a time; it returns at once when nothing names the
2896
+ * removed engine any more. A failure is logged and never rejects, so it never
2897
+ * stops this server from listening. Bots whose save failed are unchanged and
2898
+ * move the next time the engines are read (describeInstances) or a turn
2899
+ * reaches one of them (startTurn), so a server no app reads the engines from
2900
+ * still moves them. */
2901
+ function retryComputerEngineMove() {
2902
+ computerEngineMoveRunning ??= moveOffComputerEngine()
2903
+ .catch((error) => console.warn(`[engines] moving bots off the removed Computer engine failed: ${error instanceof Error ? error.message : String(error)}`))
2904
+ .finally(() => { computerEngineMoveRunning = null; });
2905
+ return computerEngineMoveRunning;
2906
+ }
2927
2907
  function checkedModelSelection(raw, current, requireAvailableModel = false) {
2928
2908
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
2929
2909
  return { ok: false, status: 400, error: "modelSelection must be an object" };
@@ -3153,9 +3133,6 @@ function packageImportDeps(selection) {
3153
3133
  return avatarUrl;
3154
3134
  },
3155
3135
  },
3156
- broadcast: (event) => event.kind === "bot"
3157
- ? broadcast({ kind: "bot", bot: publicBot(event.bot) })
3158
- : broadcast({ kind: "group", group: publicGroupState(event.group) }),
3159
3136
  defaultSelection: () => selection,
3160
3137
  presets: presetStore,
3161
3138
  };
@@ -3230,6 +3207,8 @@ const store = new Store(() => bootSelection, (selection) => withNewBotEffort(sel
3230
3207
  const teamComputers = new TeamComputers(join(DATA_DIR, "team-computers.json"), ENVIRONMENT_ID);
3231
3208
  let followupsReady = false;
3232
3209
  const sendSequencer = new SendSequencer();
3210
+ // Before the new-bot default is read: a saved default may name the engine.
3211
+ await retryComputerEngineMove();
3233
3212
  bootSelection = await defaultSelection();
3234
3213
  store.seedIfEmpty();
3235
3214
  hostedModels?.reconcile(store);
@@ -3294,7 +3273,7 @@ const wireTask = (task) => {
3294
3273
  return asleep ? { ...coordinated, snoozedUntil } : coordinated;
3295
3274
  };
3296
3275
  const wireBot = (bot) => {
3297
- const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, assignedSkills: _assignedSkills, ...rest } = bot;
3276
+ const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, assignedSkills: _assignedSkills, ...rest } = bot;
3298
3277
  // An elevated selection is inert until the desktop confirms its exact
3299
3278
  // private reply. Every ordinary client sees the effective Ask state during
3300
3279
  // that two-phase window, never a grant that may still roll back.
@@ -3316,7 +3295,7 @@ function toolScopeForTurn(botId) {
3316
3295
  /** The correlated private response carries the requested value so Electron
3317
3296
  * can validate it before sending the confirmation that makes it effective. */
3318
3297
  const wireTrustedApprovalBot = (bot) => {
3319
- const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, assignedSkills: _assignedSkills, ...rest } = bot;
3298
+ const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, assignedSkills: _assignedSkills, ...rest } = bot;
3320
3299
  return { ...rest, approvalMode: approvalModeFor(rest), avatarUrl: rest.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
3321
3300
  };
3322
3301
  /** A settings-based preview, not a receipt of a dispatched turn. No
@@ -3339,7 +3318,6 @@ function previewSystemPrompt(bot) {
3339
3318
  const computerPromptKind = resolveComputerPromptKind({
3340
3319
  kind: previewComputer === "vm" ? "vm" : previewComputer === "cloud"
3341
3320
  ? bot.cloudBackend === "vps" ? "vps" : "box" : previewComputer === "local" ? "local" : null,
3342
- driverKind: instance?.driverKind,
3343
3321
  vmPrivate: localVmMode(cfg) === "per-bot",
3344
3322
  });
3345
3323
  const peers = reachablePeers(store.bots, bot);
@@ -3379,7 +3357,7 @@ function previewSystemPrompt(bot) {
3379
3357
  computer: previewPlan.computer && previewPlan.computer !== "off" && computerPromptKind ? previewPlan.computer : null,
3380
3358
  browser: previewPlan.computer === undefined ? false : previewPlan.browser,
3381
3359
  }, { note: previewPlan.note, cloudHome: Boolean(CLOUD_HOME) }) },
3382
- { id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(agentsMounted && lendingEnabled()) : "" },
3360
+ { id: "cloud-home", label: "My Cloud", text: CLOUD_HOME ? cloudHomePrompt(agentsMounted && lendingEnabled()) : "" },
3383
3361
  { id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? composioSystemPrompt(bot.connectorTools) + describeConnectorScopes(bot.connectorScopes) : "" },
3384
3362
  { id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(engineMcpServers(bot))) : "" },
3385
3363
  { id: "browser", label: "Browser", text: previewPlan.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
@@ -3389,6 +3367,7 @@ function previewSystemPrompt(bot) {
3389
3367
  { id: "profile", label: "Profile changes", text: agentsMounted ? PROFILE_PROMPT : "" },
3390
3368
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
3391
3369
  { id: "team-memory", label: "Team memory", text: teamMemory.systemPrompt(bot.section) + (agentsMounted ? TEAM_MEMORY_PROMPT : "") },
3370
+ teamAvailabilityPart(agentsMounted && coordination ? peers : []),
3392
3371
  { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: agentsMounted, fileTools: Boolean(privateWorkspace), enabled: bot.memoryEnabled !== false }) },
3393
3372
  { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id, skillsLibraryEnabled(cfg) ? bot.assignedSkills : undefined) : "" },
3394
3373
  ]);
@@ -3966,10 +3945,11 @@ function handleDesktopTrustedApprovalMessage(raw) {
3966
3945
  * fetched immediately and a deleted/guessed attachment id cannot become a
3967
3946
  * dangling profile reference. */
3968
3947
  const storedAvatarExists = (avatarUrl) => attachmentExists(avatarUrl.slice("/api/attachments/".length));
3948
+ /** A bot with its open thread's newest page, for HTTP replies and a
3949
+ * member stream's first frame of it. */
3969
3950
  const publicBot = (bot) => ({
3970
3951
  ...wireBot(bot),
3971
- messages: store.messagesFor(bot.threadId),
3972
- activeLeafId: store.activeLeaf(bot.threadId),
3952
+ ...messagePage(bot.threadId, DEFAULT_PAGE),
3973
3953
  tasks: store.tasks(bot.id).map(wireTask),
3974
3954
  });
3975
3955
  const publicBotQueuedMessages = () => queuedSteerSnapshot((botId, threadId) => Boolean(store.taskByThread(botId, threadId)));
@@ -4291,7 +4271,9 @@ function outstandingAssignmentsPrompt(threadId) {
4291
4271
  requestId: node.id,
4292
4272
  bot: store.bot(node.botId)?.name ?? "Teammate",
4293
4273
  assignment: node.text.slice(0, 1_000),
4294
- status: node.status === "queued" ? "waiting for that teammate to be free" : "working on it now",
4274
+ status: node.status === "queued" ? "waiting for that teammate to be free"
4275
+ : openPersonCard(node.threadId) ? "stopped on a card waiting for the person's approval or answer in that teammate's thread; tell the person to open it"
4276
+ : "working on it now",
4295
4277
  }));
4296
4278
  return ` Assignments you already sent are still outstanding, and nothing in this conversation cancelled them: ${JSON.stringify(listed)}. Do not resend them, do not poll or wait for them, and do not tell the user they were lost. Each result returns to this conversation on its own and resumes you then. To change or add to one, send the change to the same teammate with coordinate_bots; it runs after the current work. Answer the message above with that work still in flight.`;
4297
4279
  }
@@ -4804,18 +4786,33 @@ function activeGroupTurnForBot(botId) {
4804
4786
  }
4805
4787
  return null;
4806
4788
  }
4789
+ /** A room with its open thread's newest page, for HTTP replies and a
4790
+ * member stream's first frame of it. */
4807
4791
  const groupWithThread = (group) => ({
4808
4792
  ...publicGroupState(group),
4809
- messages: store.messagesFor(group.threadId),
4810
- activeLeafId: store.activeLeaf(group.threadId),
4793
+ ...messagePage(group.threadId, DEFAULT_PAGE),
4811
4794
  ...(group.dm ? {} : { tasks: store.groupTasks(group.id) }),
4812
4795
  });
4813
4796
  // The store tells us what it wrote; this is the ONE place that turns those
4814
4797
  // into SSE frames. No mutation path can persist without emitting — the
4815
4798
  // property holds by construction, not by every call site remembering to
4816
- // broadcast. Bot frames are the slim wire shape (no transcript); the few
4817
- // endpoints whose callers need the transcript (task create/switch, imports)
4818
- // still send their richer payload on top.
4799
+ // broadcast.
4800
+ //
4801
+ // A bot or room frame carries its open thread's newest page only when that
4802
+ // thread is not the one it last went out with: a switch, a new open thread,
4803
+ // deleting the open one, a new bot or room. Other windows swap transcripts
4804
+ // on exactly those frames (one whose open thread was deleted keeps its
4805
+ // composer locked until a page arrives). Every other frame stays slim, and
4806
+ // clients keep the transcript they hold when a frame carries none.
4807
+ const sentThreads = new Map();
4808
+ for (const record of [...store.bots, ...store.groups])
4809
+ sentThreads.set(record.id, record.threadId);
4810
+ function pageIfThreadChanged(id, threadId) {
4811
+ if (sentThreads.get(id) === threadId)
4812
+ return {};
4813
+ sentThreads.set(id, threadId);
4814
+ return messagePage(threadId, DEFAULT_PAGE);
4815
+ }
4819
4816
  store.onChange((change) => {
4820
4817
  // Who owns which thread, and what each member may see, follow the fleet.
4821
4818
  if (change.type !== "message" && change.type !== "message.patch" && change.type !== "thread" && change.type !== "sections")
@@ -4824,11 +4821,12 @@ store.onChange((change) => {
4824
4821
  case "sections":
4825
4822
  broadcast({ kind: "sections", sections: store.sections });
4826
4823
  break;
4824
+ // Live frames never carry screenshot pixels; clients load them by URL.
4827
4825
  case "message":
4828
- broadcast({ kind: "message", threadId: change.threadId, message: change.message });
4826
+ broadcast({ kind: "message", threadId: change.threadId, message: slimMessage(change.message) });
4829
4827
  break;
4830
4828
  case "message.patch":
4831
- broadcast({ kind: "message.patch", threadId: change.threadId, message: change.message });
4829
+ broadcast({ kind: "message.patch", threadId: change.threadId, message: slimMessage(change.message) });
4832
4830
  break;
4833
4831
  case "thread":
4834
4832
  broadcast({ kind: "thread", threadId: change.threadId, activeLeafId: change.activeLeafId });
@@ -4848,7 +4846,7 @@ store.onChange((change) => {
4848
4846
  case "bot": {
4849
4847
  const bot = store.bot(change.botId);
4850
4848
  if (bot)
4851
- broadcast({ kind: "bot", bot: wireBot(bot) });
4849
+ broadcast({ kind: "bot", bot: { ...wireBot(bot), ...pageIfThreadChanged(bot.id, bot.threadId) } });
4852
4850
  // A new audience narrows the rooms this bot is in, for good.
4853
4851
  if (bot)
4854
4852
  for (const group of store.groups.filter((candidate) => candidate.memberIds.includes(bot.id)))
@@ -4856,6 +4854,7 @@ store.onChange((change) => {
4856
4854
  break;
4857
4855
  }
4858
4856
  case "bot.deleted":
4857
+ sentThreads.delete(change.botId);
4859
4858
  try {
4860
4859
  commandAllowlist.clear(change.botId);
4861
4860
  }
@@ -4867,12 +4866,13 @@ store.onChange((change) => {
4867
4866
  case "group": {
4868
4867
  const group = store.group(change.groupId);
4869
4868
  if (group)
4870
- broadcast({ kind: "group", group: publicGroupState(group) });
4869
+ broadcast({ kind: "group", group: { ...publicGroupState(group), ...pageIfThreadChanged(group.id, group.threadId) } });
4871
4870
  if (group)
4872
4871
  settleRoomFloor(group);
4873
4872
  break;
4874
4873
  }
4875
4874
  case "group.deleted":
4875
+ sentThreads.delete(change.groupId);
4876
4876
  broadcast({ kind: "group.deleted", groupId: change.groupId });
4877
4877
  break;
4878
4878
  }
@@ -4897,12 +4897,13 @@ function pageSize(raw) {
4897
4897
  return null;
4898
4898
  return Math.min(size, MESSAGE_PAGE_MAX);
4899
4899
  }
4900
- /** A screen message without its pixels. The client fetches those from
4901
- * `/api/threads/:threadId/messages/:id/image` when it actually shows one. */
4900
+ /** A screen message without its pixels, for live frames and pages alike.
4901
+ * The client fetches those from `/api/threads/:threadId/messages/:id/image`
4902
+ * when it actually shows one; `mime` stays so it can name a download. */
4902
4903
  function slimMessage(message) {
4903
4904
  if (message.kind !== "screen" || !message.png)
4904
4905
  return message;
4905
- const { png: _png, mime: _mime, ...rest } = message;
4906
+ const { png: _png, ...rest } = message;
4906
4907
  return { ...rest, hasImage: true };
4907
4908
  }
4908
4909
  /** `limit === undefined` is the original, unpaginated shape. A bounded,
@@ -5309,7 +5310,7 @@ always, rememberCommand) {
5309
5310
  }
5310
5311
  /** A decision or answer on a provider/peer card, made on behalf of `auth`
5311
5312
  * (a Live call answers as the person who started the call). Harness-native
5312
- * proposals (skill, routine, profile, default model, tightening, team setup)
5313
+ * proposals (skill, routine, profile, default model, team setup)
5313
5314
  * are not handled here; they are reviewed on screen (liveDecisionRefusal).
5314
5315
  * Mirrors POST /api/threads/:id/respond. */
5315
5316
  async function respondToCard(input) {
@@ -5468,10 +5469,7 @@ const watchdog = new TurnWatchdog({
5468
5469
  repeats.settle(turn.threadId);
5469
5470
  const bot = botForThread(turn.botId, turn.threadId);
5470
5471
  const routineRun = activeRoutineRunForThread(turn.threadId);
5471
- const instance = bot
5472
- ? runningTurnInstance(bot, turn.threadId)
5473
- : runningTurnEngines.get(turn.threadId) ?? null;
5474
- void instance?.adapter.interruptTurn(turn.threadId).catch(() => { });
5472
+ void runningTurnInstance(bot, turn.threadId)?.adapter.interruptTurn(turn.threadId).catch(() => { });
5475
5473
  const minutes = Math.round(TURN_STALL_MS / 60_000);
5476
5474
  if (routineRun?.target === "bot") {
5477
5475
  routines?.failThread(turn.threadId, `No activity for ${minutes} minutes — the routine was stopped`);
@@ -5481,7 +5479,7 @@ const watchdog = new TurnWatchdog({
5481
5479
  store.appendMessage(turn.threadId, {
5482
5480
  role: "bot",
5483
5481
  kind: "activity",
5484
- tool: { name: `error: no activity for ${minutes} minutes — the turn was stopped`, ok: false },
5482
+ tool: failedTurnTool(`no activity for ${minutes} minutes — the turn was stopped`),
5485
5483
  });
5486
5484
  // a routine's stall reports through its own failure path
5487
5485
  if (bot && routineRun?.target !== "bot") {
@@ -5578,6 +5576,10 @@ bus.subscribe((event) => {
5578
5576
  else if (event.type !== "session.exited")
5579
5577
  watchdog.touch(event.threadId);
5580
5578
  });
5579
+ // Automatic continuity: a turn that died on its budget or on tool errors gets
5580
+ // a `Continue:` task on the same bot, seeded with the persisted handoff — the
5581
+ // work moves forward without a person noticing and re-dispatching.
5582
+ bus.subscribe(makeCapContinuationSubscriber({ store, startTurn }));
5581
5583
  // Memory journal turn boundary (server/memory-journal.ts). A bot's own
5582
5584
  // file-tool writes to MEMORY.md and memory/ have no hook to tap, so the
5583
5585
  // diff against the baseline taken at dispatch is made when the turn
@@ -5761,6 +5763,8 @@ bus.subscribe((event) => {
5761
5763
  });
5762
5764
  // Per-thread state the digest needs from before the turn: when it started,
5763
5765
  // and the checkpoint taken at dispatch (hash + folder) so settle can diff.
5766
+ // `pin` is the dispatch that pinned that commit in the shadow repo, so a
5767
+ // sibling thread's snapshot in the same folder cannot reclaim it mid-turn.
5764
5768
  const turnStartedAt = new Map();
5765
5769
  const turnCheckpoints = new Map();
5766
5770
  // The turn a thread is in, from the last event that named one. Not every
@@ -5768,9 +5772,18 @@ const turnCheckpoints = new Map();
5768
5772
  // a digest can only count activity it can attribute — so an unstamped tool
5769
5773
  // row is attributed to the thread's live turn instead of to nothing.
5770
5774
  const liveTurnByThread = new Map();
5775
+ /** Forget a thread's pre-turn checkpoint and drop its pin, so the shadow
5776
+ * repo's next cleanup may reclaim that commit. */
5777
+ function forgetTurnCheckpoint(threadId) {
5778
+ const checkpoint = turnCheckpoints.get(threadId);
5779
+ if (!checkpoint)
5780
+ return;
5781
+ turnCheckpoints.delete(threadId);
5782
+ void checkpoints.release(checkpoint.botId, checkpoint.cwd, checkpoint.pin);
5783
+ }
5771
5784
  function clearTurnDigestState(threadId) {
5772
5785
  turnStartedAt.delete(threadId);
5773
- turnCheckpoints.delete(threadId);
5786
+ forgetTurnCheckpoint(threadId);
5774
5787
  liveTurnByThread.delete(threadId);
5775
5788
  memoryRowsByThread.delete(threadId);
5776
5789
  }
@@ -5839,9 +5852,8 @@ function ingestEngineHook(capability, body) {
5839
5852
  }
5840
5853
  return { ok: true };
5841
5854
  }
5842
- /** Persist observed tools and memory immediately. Keep a direct turn's
5843
- * workspace claimed while its bounded file snapshot settles, then enrich
5844
- * that same row only if its task and active branch still exist. */
5855
+ /** Persist observed tools and memory immediately, then enrich that same
5856
+ * row only if its task and active branch still exist. */
5845
5857
  async function scheduleTurnDigest(input) {
5846
5858
  const at = Date.now();
5847
5859
  const startedAt = turnStartedAt.get(input.threadId);
@@ -5886,6 +5898,8 @@ async function scheduleTurnDigest(input) {
5886
5898
  }
5887
5899
  if (!checkpoint)
5888
5900
  return;
5901
+ // A folder-level diff: when a sibling thread of this bot works in the
5902
+ // same folder, its edits since this turn's snapshot are listed here too.
5889
5903
  const controller = new AbortController();
5890
5904
  let timer;
5891
5905
  const capture = async () => {
@@ -5909,6 +5923,12 @@ async function scheduleTurnDigest(input) {
5909
5923
  catch (error) {
5910
5924
  console.error(`digest: could not record turn ${input.turnId} on ${input.threadId}:`, error instanceof Error ? error.message : error);
5911
5925
  }
5926
+ finally {
5927
+ // The pre-turn commit has had its last reader (the diff above is queued
5928
+ // ahead of this in the shadow repo); the next snapshot may reclaim it.
5929
+ if (checkpoint)
5930
+ void checkpoints.release(checkpoint.botId, checkpoint.cwd, checkpoint.pin);
5931
+ }
5912
5932
  }
5913
5933
  // Bots currently working with nobody at the keyboard — a webhook turn, or a
5914
5934
  // turn a webhook-driven bot handed to a teammate. Auto mode is a decision
@@ -6044,9 +6064,6 @@ const vpsJoinRequests = new Map();
6044
6064
  const orphanBoatLifecycleBusyIds = new Set();
6045
6065
  const boatInventoryRequestsBusyIds = new Set();
6046
6066
  const computerProviderConfigTransitions = new Set();
6047
- // A restore mutates and cleans a project work tree. Claim the bot across the
6048
- // entire async Git operation so a turn cannot start in that folder midway.
6049
- const checkpointRestoreLeases = new Set();
6050
6067
  /** The effective idle window, read from config so a Settings change applies
6051
6068
  * to both new and already-armed timers (see the config route). */
6052
6069
  const localVmIdleMs = () => localVmIdleTimeoutMinutes(cfg) * 60_000;
@@ -6059,7 +6076,7 @@ function inheritedTeamComputer(bot) {
6059
6076
  return teamComputers.forBot(bot);
6060
6077
  }
6061
6078
  function teamComputerPrompt(computer) {
6062
- 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.` : "";
6079
+ 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.` : "";
6063
6080
  }
6064
6081
  function botComputerControlKey(bot) {
6065
6082
  const computer = inheritedTeamComputer(bot);
@@ -6070,21 +6087,26 @@ async function computerCallGate(internalCapability) {
6070
6087
  const snapshot = botComputerControlSnapshot(botId, internalCapability.teamComputerId);
6071
6088
  const slot = autoVmClaims.get(internalCapability.threadId);
6072
6089
  const lazyClaim = slot && slot.owner.generation === internalCapability.generation ? slot : undefined;
6073
- if (!snapshot.held && lazyClaim?.lazy && !lazyClaim.begin) {
6074
- // First screen tools/call on a lazily-attached Auto VM (issue
6090
+ const waitMs = snapshot.held ? null : lazyClaimWaitMs(lazyClaim, LAZY_VM_CLAIM_GRACE_MS);
6091
+ if (lazyClaim && waitMs !== null) {
6092
+ // First screen tools/call on a lazily-attached computer (issue
6075
6093
  // #1361): fire the exclusive claim — once — and give it a moment
6076
6094
  // to land. A free, ready VM claims in the time of one container
6077
6095
  // inspect, so this call then proceeds with an honest answer;
6078
6096
  // only a claim still queued behind another holder answers held
6079
- // below, and then the contention text is true. Keyed on the
6080
- // slot, never on the thread's turn-computer entry: a bind this
6097
+ // below, and then the contention text is true. A cloud computer's
6098
+ // claim creates or wakes it, so every call waits on that start
6099
+ // (graceMs) instead of being told someone else holds it. Keyed on
6100
+ // the slot, never on the thread's turn-computer entry: a bind this
6081
6101
  // turn abandoned earlier (a VPS that turned out to be asleep)
6082
6102
  // must not hide the unclaimed VM and let the call through.
6083
6103
  startAutoVmClaim(autoVmClaims, internalCapability.threadId, internalCapability.generation);
6104
+ let timer;
6084
6105
  await Promise.race([
6085
6106
  lazyClaim.begin ?? Promise.resolve(),
6086
- new Promise((resolve) => setTimeout(resolve, LAZY_VM_CLAIM_GRACE_MS)),
6107
+ new Promise((resolve) => { timer = setTimeout(resolve, waitMs); }),
6087
6108
  ]);
6109
+ clearTimeout(timer);
6088
6110
  }
6089
6111
  if (!snapshot.held && lazyClaim?.failed === true) {
6090
6112
  // A rejected lazy claim (gate finding F1, issue #1361): the
@@ -6104,17 +6126,14 @@ async function computerCallGate(internalCapability) {
6104
6126
  }
6105
6127
  if (!snapshot.held && lazyClaim?.lazy && lazyClaim.begin && !lazyClaim.claimed) {
6106
6128
  // The claim fired and is still waiting on the exclusive bind:
6107
- // another turn genuinely holds this desktop right now.
6129
+ // another turn genuinely holds this desktop right now. A slow claim
6130
+ // says what it is still doing instead.
6108
6131
  return {
6109
6132
  held: true, helpOpen: false,
6110
- 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.",
6133
+ 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.",
6111
6134
  };
6112
6135
  }
6113
6136
  const computer = turnComputerResources.get(internalCapability.threadId);
6114
- // The screen call arriving here re-claims an idle-released seat directly
6115
- // (#1653): inside the reclaim window the previous holder picks its seat
6116
- // back up without re-entering the wait, and the successful claim
6117
- // restarts the quiet window. A seat another turn holds still refuses.
6118
6137
  if (!snapshot.held && computer && computer.owner.generation === internalCapability.generation &&
6119
6138
  !claimTurnResource(computer.owner, computer.resource)) {
6120
6139
  return {
@@ -6137,11 +6156,6 @@ function teamComputerInUse(computer) {
6137
6156
  [...teamComputerTurns.values()].some(turn => turn.computerId === computer.id) ||
6138
6157
  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));
6139
6158
  }
6140
- function assertTeamControlCanBeTaken(computerId) {
6141
- if ([...teamComputerTurns.values()].some(turn => turn.computerId === computerId && turn.remoteAgent)) {
6142
- throw Object.assign(new Error("Stop the Computer engine's active turn before taking control of this shared desktop"), { status: 409 });
6143
- }
6144
- }
6145
6159
  function claimTeamComputerLifecycle(computer) {
6146
6160
  if (computerProviderConfigTransitions.has("box"))
6147
6161
  throw Object.assign(new Error(providerTransitionMessage("box")), { status: 409 });
@@ -6178,9 +6192,9 @@ async function teamComputersPayload() {
6178
6192
  }
6179
6193
  /** The same named Boat identity and whole-turn lease in chats and rooms.
6180
6194
  * Assignment authorizes waking, never replacing a missing paid machine. */
6181
- async function attachTeamBoat(computer, botId, owner, canMount, remoteAgent) {
6195
+ async function attachTeamBoat(computer, botId, owner, canMount) {
6182
6196
  if (!canMount)
6183
- throw new Error("This model cannot use the team's Boat computer; choose a model provider with computer tools or an explicit bot destination");
6197
+ throw new Error("This model cannot use the team's cloud computer; choose a model provider with computer tools or an explicit bot destination");
6184
6198
  if (!boat.boatConfigured(cfg))
6185
6199
  throw new Error("The team's Boat account is not configured; reconnect it in Settings");
6186
6200
  const ownerId = teamComputerOwner(computer.id);
@@ -6189,42 +6203,51 @@ async function attachTeamBoat(computer, botId, owner, canMount, remoteAgent) {
6189
6203
  if (computerControl.snapshot(ownerId).held)
6190
6204
  throw new Error("Release human control of the team computer before starting another turn");
6191
6205
  await bindTurnComputer(owner, `computer:box-bot:${ownerId}`, true);
6192
- teamComputerTurns.set(owner.threadId, { owner, computerId: computer.id, botId, remoteAgent });
6206
+ teamComputerTurns.set(owner.threadId, { owner, computerId: computer.id, botId });
6193
6207
  let machine = await boat.findBoat(cfg, ownerId);
6194
6208
  if (!machine)
6195
- throw new Error("The team's Boat computer is missing; explicitly create or retry it from the Team map");
6209
+ throw new Error("The team's cloud computer is missing; explicitly create or retry it from the Team map");
6196
6210
  await bindTurnComputer(owner, `computer:box:${machine.id}`, true);
6197
- const action = boat.boatTurnLifecycleAction({ explicitCloud: true, canMount: true, state: typeof machine.state === "string" ? machine.state : null });
6211
+ const action = boat.boatTurnLifecycleAction(typeof machine.state === "string" ? machine.state : null);
6198
6212
  if (action === "wake")
6199
6213
  machine = await boat.readyBoat(cfg, ownerId);
6200
- if (!machine || boat.boatTurnLifecycleAction({ explicitCloud: true, canMount: true, state: typeof machine.state === "string" ? machine.state : null }) !== "attach") {
6214
+ if (!machine || boat.boatTurnLifecycleAction(typeof machine.state === "string" ? machine.state : null) !== "attach") {
6201
6215
  throw new Error("The team computer is not ready; check it in the Team map");
6202
6216
  }
6203
6217
  if (turnResourceOwners.get(owner.threadId)?.generation !== owner.generation ||
6204
6218
  !turnResources.owns(`computer:box:${machine.id}`, owner))
6205
6219
  throw new Error("This computer turn ended while its machine was starting");
6206
6220
  return {
6207
- mount: cloudComputerMount(botId, owner, machine.id, remoteAgent),
6221
+ mount: cloudComputerMount(botId, owner, machine.id),
6208
6222
  capture: () => boat.screenshotBoat(cfg, ownerId, machine.id),
6209
6223
  };
6210
6224
  }
6211
- /** How a turn's engine reaches a cloud computer. The Computer engine runs its
6212
- * whole turn on the Boat. Every other engine keeps its own model and sign-in
6213
- * and gets the Boat as one more stdio computer server (harness-mcp-proxy
6214
- * computer), in the same slot a Local VM or VPS uses: one mechanism on a
6215
- * desktop, a headless server and an OMB Cloud. The agent process holds only a
6216
- * turn-scoped capability naming this Boat, never a Boat credential. */
6217
- function cloudComputerMount(botId, owner, boxId, remoteAgent) {
6218
- if (remoteAgent)
6219
- return { computer: { kind: "box", boxId } };
6220
- const control = controlIntegration(botId, owner.threadId, owner.generation, undefined, boxId);
6225
+ /** The engine fact the cloud-computer rule reads (shared/cloud-computer.ts),
6226
+ * and the model's name for a refusal to say. */
6227
+ function cloudEngine(instance, model) {
6221
6228
  return {
6222
- localComputer: {
6223
- command: process.execPath,
6224
- args: [SPAWNED_PROXIES.harnessMcp, "computer"],
6225
- env: { ...AGENTS_NODE_FLAG, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`, OMB_MCP_TOKEN: control.token },
6226
- platform: "linux",
6227
- },
6229
+ computerMcp: instance?.adapter.capabilities.computerMcp,
6230
+ name: model?.trim() || instance?.displayName || instance?.driverKind || "This model",
6231
+ };
6232
+ }
6233
+ /** How a turn's engine reaches a cloud computer. Every engine keeps its own
6234
+ * model and sign-in and gets the Boat as one more stdio computer server
6235
+ * (harness-mcp-proxy computer), in the same slot a Local VM or VPS uses: one
6236
+ * mechanism on a desktop, a headless server and an OMB Cloud. The agent
6237
+ * process holds only a turn-scoped capability naming this Boat, never a Boat
6238
+ * credential. */
6239
+ function cloudComputerMount(botId, owner, boxId) {
6240
+ return { localComputer: cloudComputerTools(botId, owner, boxId) };
6241
+ }
6242
+ /** The cloud computer's stdio tools for this turn. `boxId` null: the Boat is
6243
+ * the one the first computer call's claim creates or wakes (mountBotBoat). */
6244
+ function cloudComputerTools(botId, owner, boxId) {
6245
+ const control = controlIntegration(botId, owner.threadId, owner.generation, { boxId });
6246
+ return {
6247
+ command: process.execPath,
6248
+ args: [SPAWNED_PROXIES.harnessMcp, "computer"],
6249
+ env: { ...AGENTS_NODE_FLAG, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`, OMB_MCP_TOKEN: control.token },
6250
+ platform: "linux",
6228
6251
  };
6229
6252
  }
6230
6253
  /** "Works on: This computer", strict. One mount for a bot thread and a group
@@ -6245,14 +6268,18 @@ async function mountHostComputer(owner, botId, providerSupportsLocal) {
6245
6268
  : "CUA Driver is not ready for this computer — check permissions and restart OpenMausBot");
6246
6269
  }
6247
6270
  await bindTurnComputer(owner, "computer:host");
6248
- return gatedLocalComputer(cua, controlIntegration(botId, owner.threadId, owner.generation));
6271
+ return gatedLocalComputer(cua, controlIntegration(botId, owner.threadId, owner.generation, "this computer"));
6249
6272
  }
6250
6273
  /** The bot's own VPS desktop, mounted into the local agent. The desktop lease
6251
6274
  * is claimed on the first computer call, through the computer-control gate
6252
6275
  * (the Local VM's seam, #1361), never at mount: turns that never touch the
6253
6276
  * computer tools run side by side. `start` may provision or start the
6254
- * container; without it the VPS is only inspected. The caller has already
6255
- * recorded the thread with vpsThreadStarted and ends it on a problem. */
6277
+ * container; without it the VPS is only inspected. Unlike the bot's cloud
6278
+ * computer (mountBotBoat), the start stays here, at dispatch: the VPS tools
6279
+ * are a bridge that execs into the running container as soon as the engine
6280
+ * launches it (runMcpBridge), and a self-hosted container uses no plan
6281
+ * hours. The caller has already recorded the thread with vpsThreadStarted
6282
+ * and ends it on a problem. */
6256
6283
  async function mountBotVps(bot, owner, opts) {
6257
6284
  const vpsResource = `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`;
6258
6285
  const remote = opts.start
@@ -6283,53 +6310,113 @@ async function mountBotVps(bot, owner, opts) {
6283
6310
  },
6284
6311
  };
6285
6312
  }
6286
- /** The bot's own Boat. Explicit Cloud is the consent boundary: it may create a
6287
- * missing machine and wake an archived one (~8s, and it un-pauses billing);
6288
- * Auto only attaches a machine that is already running. */
6289
- async function attachBotBoat(bot, owner, opts) {
6290
- // Explicit cloud turns can provision/wake the same bot's Boat. Claim before
6291
- // any network await so setup itself cannot race another turn.
6292
- if (opts.explicitCloud)
6293
- await bindTurnComputer(owner, `computer:box-bot:${bot.id}`, true);
6294
- let b;
6295
- try {
6296
- b = await boat.findBoat(cfg, bot.id);
6297
- }
6298
- catch (error) {
6299
- // Auto may fall through when an optional provider is offline, but a
6300
- // durable deletion fence must never be mistaken for "no computer".
6301
- if (opts.explicitCloud || error?.status === 409)
6302
- throw error;
6303
- b = null;
6304
- }
6305
- const lifecycleOf = (explicitCloud) => boat.boatTurnLifecycleAction({
6306
- explicitCloud,
6307
- canMount: opts.canMount,
6308
- state: typeof b?.state === "string" ? b.state : null,
6309
- });
6310
- let lifecycle = lifecycleOf(opts.explicitCloud);
6313
+ /** How long one computer call waits on a cloud computer that is being
6314
+ * created or woken before it is told to look again. Under the 60 seconds an
6315
+ * engine may give one tool call (Codex's default); a call that runs out finds
6316
+ * the start still going and waits again, inside the start's own budget.
6317
+ * OMB_CLOUD_COMPUTER_START_WAIT_MS shortens it for tests. */
6318
+ const CLOUD_COMPUTER_START_WAIT_MS = Math.max(1_000, Number(process.env.OMB_CLOUD_COMPUTER_START_WAIT_MS) || 45_000);
6319
+ /** How long the start itself may take once this turn has the computer to
6320
+ * itself: above a slow first start (about a minute; Boat's own ready wait is
6321
+ * 90 seconds). Past it the start has failed, and the turn says so and ends,
6322
+ * instead of every call answering "still starting" until the watchdog.
6323
+ * OMB_CLOUD_COMPUTER_START_BUDGET_MS shortens it for tests. */
6324
+ const CLOUD_COMPUTER_START_BUDGET_MS = Math.max(1_000, Number(process.env.OMB_CLOUD_COMPUTER_START_BUDGET_MS) || 150_000);
6325
+ const CLOUD_COMPUTER_STARTING = "The cloud computer is still starting. This call was not performed. Take a fresh screenshot in a moment.";
6326
+ const CLOUD_COMPUTER_DID_NOT_START = "the cloud computer didn't start in time";
6327
+ /** The bot's own Boat as a computer its engine uses, mounted like the VPS
6328
+ * (mountBotVps): the tools mount now, and the Boat is created or woken by
6329
+ * the turn's first computer call, through the computer-control gate (the
6330
+ * Local VM's seam, #1361), never at mount. A turn that never touches the
6331
+ * screen ("hi") makes no Boat call at all, so it costs no plan hours. The
6332
+ * claim is attachBotBoat itself, with its explicit-Cloud consent: the person
6333
+ * chose this place, and the bot is now using it. */
6334
+ function mountBotBoat(bot, owner, opts) {
6335
+ const slot = {
6336
+ owner,
6337
+ lazy: true,
6338
+ startsComputer: true,
6339
+ label: "the cloud computer",
6340
+ graceMs: CLOUD_COMPUTER_START_WAIT_MS,
6341
+ // Until this turn has the computer to itself another conversation does,
6342
+ // and a call is told so (the gate's contention text); from then on the
6343
+ // claim is starting it.
6344
+ onRejected: opts.onRejected,
6345
+ claim: async () => {
6346
+ let attached;
6347
+ try {
6348
+ attached = await attachBotBoat(bot, owner, {
6349
+ budgetMs: CLOUD_COMPUTER_START_BUDGET_MS,
6350
+ onSeated: () => { slot.pendingReason = CLOUD_COMPUTER_STARTING; },
6351
+ });
6352
+ if (!attached)
6353
+ throw new Error("the cloud computer could not be created or reached");
6354
+ }
6355
+ catch (error) {
6356
+ throw opts.source ? await asPlaceFailure(error, "cloud", opts.source, bot, "box") : error;
6357
+ }
6358
+ slot.boxId = attached.boxId;
6359
+ opts.onClaimed(attached.capture);
6360
+ },
6361
+ };
6362
+ autoVmClaims.set(owner.threadId, slot);
6363
+ return cloudComputerTools(bot.id, owner, null);
6364
+ }
6365
+ /** The Boat a lazily mounted cloud computer's claim landed on, for this
6366
+ * exact turn; none while the claim is still running or after it failed. */
6367
+ function claimedBoatId(capability) {
6368
+ const slot = autoVmClaims.get(capability.threadId);
6369
+ return slot?.owner.generation === capability.generation && slot.claimed ? slot.boxId : undefined;
6370
+ }
6371
+ /** The bot's own Boat, for a turn whose place is Cloud. That choice is the
6372
+ * consent boundary: it may create a missing machine and wake an archived one
6373
+ * (~8s, and it un-pauses billing). Auto never reaches here. Callers have
6374
+ * already checked that the engine can work on it (cloudPlaceRefusal).
6375
+ * Every engine reaches it through mountBotBoat, on its first computer call:
6376
+ * told once this turn has the Boat to itself, it has `budgetMs` to find,
6377
+ * create or wake it. */
6378
+ async function attachBotBoat(bot, owner, start) {
6379
+ // Claim before any network await so setup itself cannot race another turn.
6380
+ await bindTurnComputer(owner, `computer:box-bot:${bot.id}`, true);
6381
+ start.onSeated();
6382
+ const machine = await withinBudget(startBotBoat(bot), start.budgetMs, CLOUD_COMPUTER_DID_NOT_START);
6383
+ if (!machine)
6384
+ return null;
6385
+ await bindTurnComputer(owner, `computer:box:${machine.id}`);
6386
+ return {
6387
+ boxId: machine.id,
6388
+ capture: () => boat.screenshotBoat(cfg, bot.id, machine.id),
6389
+ };
6390
+ }
6391
+ /** `work`, or a rejection with `message` once `ms` pass first. The work is
6392
+ * not cancelled: it ends on its own requests' deadlines, and the race has
6393
+ * already handled a rejection it brings later. */
6394
+ function withinBudget(work, ms, message) {
6395
+ let timer;
6396
+ const expired = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); });
6397
+ return Promise.race([work, expired]).finally(() => clearTimeout(timer));
6398
+ }
6399
+ /** Find the bot's Boat, creating or waking it: the Boat once it can be
6400
+ * attached, or null. */
6401
+ async function startBotBoat(bot) {
6402
+ let b = await boat.findBoat(cfg, bot.id);
6403
+ const lifecycleOf = () => boat.boatTurnLifecycleAction(typeof b?.state === "string" ? b.state : null);
6404
+ let lifecycle = lifecycleOf();
6311
6405
  if (lifecycle === "provision") {
6312
- broadcast({ kind: "computer", botId: bot.id, state: "provisioning" });
6406
+ broadcast({ kind: "computer", botId: bot.id, state: "provisioning", place: "cloud" });
6313
6407
  await boat.provisionBoat(cfg, bot.id, bot.name);
6314
6408
  b = await boat.findBoat(cfg, bot.id);
6315
- lifecycle = lifecycleOf(true);
6409
+ lifecycle = lifecycleOf();
6316
6410
  }
6317
6411
  // an archived boat answers every action with an error until it resumes —
6318
6412
  // wake it here, once, instead of letting the agent discover it one failed
6319
6413
  // tool call at a time.
6320
6414
  if (lifecycle === "wake") {
6321
- broadcast({ kind: "computer", botId: bot.id, state: "waking" });
6415
+ broadcast({ kind: "computer", botId: bot.id, state: "waking", place: "cloud" });
6322
6416
  b = (await boat.readyBoat(cfg, bot.id)) ?? b;
6323
- lifecycle = lifecycleOf(true);
6417
+ lifecycle = lifecycleOf();
6324
6418
  }
6325
- if (!b || lifecycle !== "attach")
6326
- return null;
6327
- const machine = b;
6328
- await bindTurnComputer(owner, `computer:box:${machine.id}`, opts.remoteAgent);
6329
- return {
6330
- capture: () => boat.screenshotBoat(cfg, bot.id, machine.id),
6331
- mount: opts.canMount ? cloudComputerMount(bot.id, owner, machine.id, opts.remoteAgent) : null,
6332
- };
6419
+ return b && lifecycle === "attach" ? b : null;
6333
6420
  }
6334
6421
  function managedBoatOwners() {
6335
6422
  return [...store.bots.map((bot) => ({
@@ -6401,8 +6488,6 @@ function turnProvider(bot, runOn, threadId) {
6401
6488
  const wants = turnSurfacePlan(bot, runOn, threadId).computer;
6402
6489
  if (wants !== undefined && wants !== "cloud")
6403
6490
  return null;
6404
- if (registry.get(bot.modelSelection.instanceId)?.adapter.capabilities.remoteAgent === true)
6405
- return "box";
6406
6491
  return bot.cloudBackend === "vps" ? "vps" : wants === "cloud" ? "box" : null;
6407
6492
  }
6408
6493
  /** Preview requests carry the selected conversation, not whichever thread
@@ -6421,8 +6506,6 @@ async function computerPreviewSurface(bot, threadId) {
6421
6506
  if (plan.computer !== undefined)
6422
6507
  return plan.computer === "off" && plan.browser ? "browser" : plan.computer;
6423
6508
  const instance = registry.get(bot.modelSelection.instanceId);
6424
- if (instance?.adapter.capabilities.remoteAgent === true)
6425
- return "cloud";
6426
6509
  if (bot.cloudBackend === "vps") {
6427
6510
  const remote = await vps.vpsComputerStatus(cfg, bot.id);
6428
6511
  if (remote.ready)
@@ -6447,9 +6530,11 @@ async function computerPreviewSurface(bot, threadId) {
6447
6530
  * A Cloud home lists only the places it has (cloud-home.ts), so a bot never
6448
6531
  * tells the person to set up this computer or a Local VM there. */
6449
6532
  async function selectableComputers(bot) {
6450
- const caps = registry.get(bot.modelSelection.instanceId)?.adapter.capabilities;
6533
+ const instance = registry.get(bot.modelSelection.instanceId);
6534
+ const caps = instance?.adapter.capabilities;
6451
6535
  const off = bot.computer === "off";
6452
- const localEngine = caps?.remoteAgent !== true;
6536
+ // A desktop the bot's tools cannot reach is not offered, and not probed.
6537
+ const toolsLeaveOut = computerToolsRefusal(bot.toolScope, "works-on", bot.name)?.message;
6453
6538
  const surfaces = ["cloud", "vm", "local", "browser"].filter(surface => !CLOUD_HOME || cloudHomeOffersPlace(surface));
6454
6539
  return Promise.all(surfaces.map(async (surface) => {
6455
6540
  let ready = false;
@@ -6463,24 +6548,31 @@ async function selectableComputers(bot) {
6463
6548
  try {
6464
6549
  if (off)
6465
6550
  reason = "Computer access is Off in this bot's settings.";
6551
+ else if (toolsLeaveOut && surface !== "browser")
6552
+ reason = toolsLeaveOut;
6466
6553
  else if (surface === "cloud") {
6467
6554
  if (bot.cloudBackend === "vps") {
6468
- const status = localEngine && caps?.computerMcp ? await vps.vpsComputerStatus(cfg, bot.id) : null;
6555
+ const status = canWorkOnCloud(cloudEngine(instance)) ? await vps.vpsComputerStatus(cfg, bot.id) : null;
6469
6556
  ready = status?.ready === true;
6470
6557
  canStart = Boolean(status?.daemonUp && status.managed && status.container === "stopped" &&
6471
6558
  status.image && status.imageMatches && status.network === "private" && status.mounts === "none" && status.security === "hardened");
6472
6559
  canCreate = Boolean(status?.configured && status.daemonUp && status.container === "missing");
6473
6560
  reason = status?.problem ?? reason;
6474
6561
  }
6475
- else if (boat.boatConfigured(cfg) && caps !== undefined && usesCloudComputer(caps)) {
6562
+ else if (boat.boatConfigured(cfg) && canWorkOnCloud(cloudEngine(instance))) {
6476
6563
  const status = await boat.boatStatus(cfg, bot.id);
6477
- const lifecycle = boat.boatTurnLifecycleAction({ explicitCloud: true, canMount: true, state: status.box?.state ?? null });
6564
+ const lifecycle = boat.boatTurnLifecycleAction(status.box?.state ?? null);
6478
6565
  ready = lifecycle === "attach";
6479
6566
  canStart = lifecycle === "wake";
6480
6567
  canCreate = lifecycle === "provision";
6481
6568
  }
6569
+ else {
6570
+ // The words the person reads for the same place (shared/place-view.ts).
6571
+ reason = (cloudPlaceRefusal(cloudEngine(instance, bot.modelSelection.model), "works-on", bot.name)
6572
+ ?? boatNotConfigured(bot, "works-on")).message;
6573
+ }
6482
6574
  }
6483
- else if (surface === "vm" && localEngine && caps?.computerMcp) {
6575
+ else if (surface === "vm" && caps?.computerMcp) {
6484
6576
  const target = localVmTargetForStatus(bot.id);
6485
6577
  const status = await containerComputerStatus(undefined, undefined, target);
6486
6578
  ready = status.ready;
@@ -6553,7 +6645,7 @@ function continueComputerSelection(threadId, generation, succeeded) {
6553
6645
  const runningTurnEngines = new Map();
6554
6646
  function runningTurnInstance(bot, threadId) {
6555
6647
  // A turn always runs on the bot's own engine, wherever it works.
6556
- return runningTurnEngines.get(threadId) ?? registry.get(bot.modelSelection.instanceId);
6648
+ return runningTurnEngines.get(threadId) ?? (bot ? registry.get(bot.modelSelection.instanceId) : null);
6557
6649
  }
6558
6650
  function providerTransitionForTurn(bot, runOn, threadId) {
6559
6651
  const provider = turnProvider(bot, runOn, threadId);
@@ -6934,38 +7026,6 @@ bus.subscribe((event) => {
6934
7026
  if (touches || /computer|screenshot|click|type_text|press_key|scroll|open_url|wait_for|browser_/i.test(toolName)) {
6935
7027
  pokeScreenPoller(event.threadId, touches, surface);
6936
7028
  }
6937
- // A completed screen-touching computer tool is real screen
6938
- // activity (#1653): it restarts the idle clock on that turn's
6939
- // computer claim. The poller's own frames never arrive here, so
6940
- // they cannot keep a quiet seat held.
6941
- if (touches && surface === "computer") {
6942
- const computer = turnComputerResources.get(event.threadId);
6943
- if (computer) {
6944
- // A quiet window cannot expire while this call executes.
6945
- turnResources.endComputerCall(computer.resource, computer.owner);
6946
- turnResources.activity(computer.resource, computer.owner);
6947
- }
6948
- else {
6949
- // The call never forwarded onto a bound desktop, or the lazy
6950
- // bind never landed: drop its pending start so it cannot
6951
- // fence a claim taken later (#1653).
6952
- const pending = pendingComputerCallStarts.get(event.threadId);
6953
- if (pending) {
6954
- if (pending.count > 1)
6955
- pendingComputerCallStarts.set(event.threadId, { ...pending, count: pending.count - 1 });
6956
- else
6957
- pendingComputerCallStarts.delete(event.threadId);
6958
- }
6959
- }
6960
- // Real cloud-screen work refreshes the #1655 idle stop for
6961
- // that seat; the poller's own frames never arrive here, so
6962
- // preview traffic cannot keep a paid machine awake.
6963
- if (computer?.resource.startsWith("computer:box:")) {
6964
- const seatBot = store.botByThread(event.threadId);
6965
- if (seatBot)
6966
- cloudSeatLeases.get(seatBot.id)?.touch();
6967
- }
6968
- }
6969
7029
  }
6970
7030
  }
6971
7031
  break;
@@ -6988,26 +7048,6 @@ bus.subscribe((event) => {
6988
7048
  });
6989
7049
  if (event.itemId)
6990
7050
  toolMessageByItem.set(`${event.threadId}:${event.itemId}`, message.id);
6991
- // A screen-touching computer tool opening is an in-flight call on
6992
- // that turn's claim (#1653): the seat cannot be idle-released out
6993
- // from under I/O the desktop is still executing. Its completion —
6994
- // or the turn settling — drops the fence.
6995
- if (screenTouchingTool(name) && screenSurfaceForTool(name) === "computer") {
6996
- const computer = turnComputerResources.get(event.threadId);
6997
- if (computer) {
6998
- turnResources.beginComputerCall(computer.resource, computer.owner);
6999
- }
7000
- else {
7001
- // A lazy attach (#1361) may not have seated the claim yet: the
7002
- // gate finishes the bind before the call forwards, so hold the
7003
- // start here and let bindTurnComputer fence the fresh claim.
7004
- const generation = activeInternalGenerationByThread.get(event.threadId);
7005
- if (generation) {
7006
- const pending = pendingComputerCallStarts.get(event.threadId);
7007
- pendingComputerCallStarts.set(event.threadId, pending?.generation === generation ? { generation, count: pending.count + 1 } : { generation, count: 1 });
7008
- }
7009
- }
7010
- }
7011
7051
  }
7012
7052
  break;
7013
7053
  case "request.opened": {
@@ -7019,8 +7059,9 @@ bus.subscribe((event) => {
7019
7059
  // A permission request here is one the provider left for a person: its
7020
7060
  // own mode already ran (Ask, Edits, Auto's reviewer, Custom's config).
7021
7061
  // Only the person's explicit Full access or exact saved command answers.
7022
- // The app does not guess whether the action is safe. A QUESTION
7023
- // always reaches the human — even Full access never invents an answer.
7062
+ // Approve for me allows a web search. The app does not guess whether
7063
+ // the action is safe. A QUESTION always reaches the human — even Full
7064
+ // access never invents an answer.
7024
7065
  const asker = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
7025
7066
  const unattended = permission && asker && event.requestId ? isUnattended(asker.id, event.threadId) : false;
7026
7067
  // A turn a guest drives on a Cloud home is Ask, room turns included,
@@ -7082,9 +7123,9 @@ bus.subscribe((event) => {
7082
7123
  if (outcome !== "unavailable")
7083
7124
  pushMessage({
7084
7125
  role: "bot", kind: "activity",
7085
- tool: { name: outcome === "rejected"
7086
- ? `The provider rejected this action despite ${verdict.source === "command-allowlist" ? "the saved command permission" : "Full access"}.`
7087
- : "error: could not deliver approval to the provider; retry the task after reconnecting.", ok: false },
7126
+ tool: outcome === "rejected"
7127
+ ? { name: `The provider rejected this action despite ${verdict.source === "command-allowlist" ? "the saved command permission" : "Full access"}.`, ok: false }
7128
+ : failedTurnTool("could not deliver approval to the provider; retry the task after reconnecting."),
7088
7129
  });
7089
7130
  return;
7090
7131
  }
@@ -7176,6 +7217,8 @@ bus.subscribe((event) => {
7176
7217
  store.setTaskActivity(bot.id, event.threadId, "waiting-on-you");
7177
7218
  else if (asker.busy)
7178
7219
  store.setActivity(asker.id, "waiting-on-you");
7220
+ if (event.requestId)
7221
+ showWaitingOnPersonChip(event.threadId, event.requestId, asker, permission ? "approval" : "answer");
7179
7222
  const notificationBot = (routineRun && routineSourceOwner(routineRun)?.bot) || asker;
7180
7223
  notify(buildNotification(permission ? "approval" : "question", notificationBot, (routineRun && routineSourceThread(routineRun)) || event.threadId, event.summary));
7181
7224
  }
@@ -7185,6 +7228,8 @@ bus.subscribe((event) => {
7185
7228
  case "request.resolved": {
7186
7229
  if (event.requestId)
7187
7230
  pendingCommandRules.delete(`${event.threadId}:${event.requestId}`);
7231
+ if (event.requestId)
7232
+ settleWaitingOnPersonChips(event.threadId, event.requestId);
7188
7233
  // answered (by whoever): the turn is working again, unless it settled
7189
7234
  const waiting = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
7190
7235
  if (bot && store.taskByThread(bot.id, event.threadId)?.activity === "waiting-on-you") {
@@ -7230,14 +7275,7 @@ bus.subscribe((event) => {
7230
7275
  pushMessage({
7231
7276
  role: "bot",
7232
7277
  kind: "activity",
7233
- tool: {
7234
- // a setup error carries its fix, which must not be cut mid-command
7235
- name: `error: ${event.message.slice(0, event.setup ? 320 : 160)}`,
7236
- ok: false,
7237
- setup: event.setup,
7238
- ...(event.terminal ? { terminal: true } : {}),
7239
- ...(event.claudeUpdate ? { claudeUpdate: true } : {}),
7240
- },
7278
+ tool: failedTurnTool(event.message, event),
7241
7279
  });
7242
7280
  // a setup error means the engine could not even start: the bot is
7243
7281
  // dead until something changes, not merely idle. The next successful
@@ -7255,6 +7293,7 @@ bus.subscribe((event) => {
7255
7293
  }
7256
7294
  break;
7257
7295
  case "turn.completed": {
7296
+ settleWaitingOnPersonChips(event.threadId);
7258
7297
  // A peer-started turn settles as coordination, not as news. What keeps
7259
7298
  // that classification from outliving its turn is the rewrite at
7260
7299
  // dispatch, not this line — releasing it here too is hygiene, so a
@@ -7472,8 +7511,8 @@ bus.subscribe((event) => {
7472
7511
  // still be in flight).
7473
7512
  //
7474
7513
  // Keep the thread busy until its bounded final capture releases
7475
- // the screen AND workspace. Otherwise an accepted follow-up races
7476
- // these claims and fails as though another thread owned its folder.
7514
+ // the screen: an accepted follow-up must not take the desktop
7515
+ // before the END-state frame is captured.
7477
7516
  const settleLeafId = store.activePath(event.threadId).at(-1)?.id;
7478
7517
  let timeout;
7479
7518
  const screenSettled = Promise.race([
@@ -7565,6 +7604,57 @@ bus.subscribe((event) => {
7565
7604
  * channel that only ever shows requests is half a record. Mirror the
7566
7605
  * reply on success; mirror a failed/stopped terminal chip otherwise. */
7567
7606
  const delegationWatch = new Map();
7607
+ /** The card in this thread that is waiting on the person: an approval, a
7608
+ * question or a proposal to review, newest first. */
7609
+ function openPersonCard(threadId) {
7610
+ const messages = store.messagesFor(threadId);
7611
+ for (let i = messages.length - 1; i >= 0; i--) {
7612
+ const card = messages[i].kind === "options" ? messages[i].card : undefined;
7613
+ const kind = liveCardKind(card);
7614
+ if (kind)
7615
+ return { kind, tool: card?.tool };
7616
+ }
7617
+ return undefined;
7618
+ }
7619
+ /** The conversation waiting on work that runs in this thread: a Chief's
7620
+ * delegate_bot task or coordinate_bots assignment. */
7621
+ function delegatorOf(threadId) {
7622
+ const watch = delegationWatch.get(threadId);
7623
+ if (watch?.sourceThreadId)
7624
+ return { threadId: watch.sourceThreadId, botId: watch.sourceBotId };
7625
+ const parent = roomHandoffs.assignerOf(threadId);
7626
+ return parent ? { threadId: parent.threadId, botId: parent.botId } : undefined;
7627
+ }
7628
+ // MOCA-274: a delegated teammate's card used to live only in its own thread,
7629
+ // so the person talking to the Chief never saw that work was waiting on them.
7630
+ // A chip in the delegating conversation points at the card until it settles.
7631
+ const waitingOnPersonChips = new Map();
7632
+ function showWaitingOnPersonChip(threadId, requestId, asker, kind) {
7633
+ const delegator = delegatorOf(threadId);
7634
+ if (!delegator || delegator.threadId === threadId)
7635
+ return;
7636
+ const chip = store.appendMessage(delegator.threadId, { role: "bot", kind: "activity",
7637
+ from: { botId: asker.id, name: asker.name, color: asker.color },
7638
+ tool: { name: `Waiting on your ${kind} in @${asker.name}` },
7639
+ threadRef: { botId: asker.id, threadId, title: store.taskByThread(asker.id, threadId)?.title ?? asker.name },
7640
+ });
7641
+ waitingOnPersonChips.set(`${threadId}:${requestId}`, { threadId: delegator.threadId, messageId: chip.id, name: asker.name, kind });
7642
+ }
7643
+ /** Settle the chip for one answered card, or every chip for this thread once
7644
+ * its turn ends (a card nobody answered can no longer be answered). */
7645
+ function settleWaitingOnPersonChips(threadId, requestId) {
7646
+ for (const [key, chip] of waitingOnPersonChips) {
7647
+ if (requestId ? key !== `${threadId}:${requestId}` : !key.startsWith(`${threadId}:`))
7648
+ continue;
7649
+ waitingOnPersonChips.delete(key);
7650
+ const existing = store.messagesFor(chip.threadId).find((message) => message.id === chip.messageId);
7651
+ if (!existing?.tool)
7652
+ continue;
7653
+ store.patchMessage(chip.threadId, chip.messageId, {
7654
+ tool: { ...existing.tool, name: requestId ? `@${chip.name} got your ${chip.kind}` : `@${chip.name} is no longer waiting on your ${chip.kind}`, ok: true },
7655
+ });
7656
+ }
7657
+ }
7568
7658
  // Peer wake: when a delegated reply lands, resume the source bot so it can
7569
7659
  // fold the result in and answer the user instead of sitting idle. Mirrors
7570
7660
  // the cardContinuation resume pattern used for connector/credential cards.
@@ -7608,10 +7698,7 @@ function dispatchDelegationWake(botId, threadId, targetName, failureReason, rout
7608
7698
  store.appendMessage(threadId, {
7609
7699
  role: "bot",
7610
7700
  kind: "activity",
7611
- tool: {
7612
- name: `error: could not resume after delegation — ${message.slice(0, 120)}`,
7613
- ok: false,
7614
- },
7701
+ tool: failedTurnTool(`could not resume after delegation — ${message}`),
7615
7702
  });
7616
7703
  routines?.failThread(threadId, `Could not resume after delegation: ${message}`);
7617
7704
  });
@@ -7713,7 +7800,7 @@ function reportIncident(input) {
7713
7800
  store.appendMessage(incidents.threadId, {
7714
7801
  role: "bot",
7715
7802
  kind: "activity",
7716
- tool: { name: `error: the incident could not reach ${chief.name} — ${why.slice(0, 120)}`, ok: false },
7803
+ tool: failedTurnTool(`the incident could not reach ${chief.name} — ${why}`),
7717
7804
  });
7718
7805
  tellThePerson();
7719
7806
  });
@@ -7998,12 +8085,15 @@ function finalizeDelegationWatch(threadId, ok, reply = "", failureName = "Delega
7998
8085
  // with the reply only visible in the thread (the "delegated and went
7999
8086
  // silent" gap). Failures wake it too — the user must hear the task did
8000
8087
  // not finish. Idle-checked and burst-capped so a busy source or a
8001
- // re-delegating loop cannot spin up runs.
8002
- if (ok) {
8003
- wakeDelegationSource(source, watched.sourceThreadId, targetName, undefined, watched.routineRunId);
8004
- }
8005
- else if (!ok) {
8006
- wakeDelegationSource(source, watched.sourceThreadId, targetName, failureName || "the delegated turn did not finish", watched.routineRunId);
8088
+ // re-delegating loop cannot spin up runs. An external runtime polls
8089
+ // for the receipt instead; waking would answer it a second time.
8090
+ if (watched.completionOwner !== "external") {
8091
+ if (ok) {
8092
+ wakeDelegationSource(source, watched.sourceThreadId, targetName, undefined, watched.routineRunId);
8093
+ }
8094
+ else {
8095
+ wakeDelegationSource(source, watched.sourceThreadId, targetName, failureName || "the delegated turn did not finish", watched.routineRunId);
8096
+ }
8007
8097
  }
8008
8098
  }
8009
8099
  }
@@ -8058,7 +8148,7 @@ bus.subscribe((event) => {
8058
8148
  /** How a drained delegation becomes a real turn on the target. Shared by
8059
8149
  * the settle-time drain and the boot-time drain of what a previous process
8060
8150
  * left queued. */
8061
- const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel, taskId, sourceBotId, openedThreadId, oneWay) => {
8151
+ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel, taskId, sourceBotId, openedThreadId, oneWay, completionOwner) => {
8062
8152
  // startTurn REJECTS on an ordinary condition — busy target, deleted bot,
8063
8153
  // unavailable provider. Unhandled, that rejection is fatal to the
8064
8154
  // harness (Node's default), which in the packaged app kills the server
@@ -8092,6 +8182,7 @@ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel,
8092
8182
  routineRunId: activeRoutineRunForThread(sourceThreadId)?.id,
8093
8183
  startedAtMs: Date.now(),
8094
8184
  ...(oneWay ? { oneWay: true } : {}),
8185
+ ...(completionOwner ? { completionOwner } : {}),
8095
8186
  });
8096
8187
  }
8097
8188
  let failureReported = false;
@@ -8112,7 +8203,7 @@ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel,
8112
8203
  store.appendMessage(sourceThreadId, {
8113
8204
  role: "bot",
8114
8205
  kind: "activity",
8115
- tool: { name: `error: delegation to @${bot?.name ?? toBotId} could not start — ${why.slice(0, 120)}`, ok: false },
8206
+ tool: failedTurnTool(`delegation to @${bot?.name ?? toBotId} could not start — ${why}`),
8116
8207
  });
8117
8208
  };
8118
8209
  return startTurn(toBotId, text, {
@@ -8224,10 +8315,7 @@ function drainQueuedSends() {
8224
8315
  }).catch((err) => {
8225
8316
  store.appendMessage(threadId, {
8226
8317
  role: "bot", kind: "activity",
8227
- tool: {
8228
- name: `error: queued message could not start — ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`,
8229
- ok: false,
8230
- },
8318
+ tool: failedTurnTool(`queued message could not start — ${err instanceof Error ? err.message : String(err)}`),
8231
8319
  });
8232
8320
  resolve();
8233
8321
  // M2: only this group left the queue, and a turn that never
@@ -8284,10 +8372,7 @@ function drainAsideLane() {
8284
8372
  }).catch((err) => {
8285
8373
  store.appendMessage(threadId, {
8286
8374
  role: "bot", kind: "activity",
8287
- tool: {
8288
- name: `error: queued aside could not start — ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`,
8289
- ok: false,
8290
- },
8375
+ tool: failedTurnTool(`queued aside could not start — ${err instanceof Error ? err.message : String(err)}`),
8291
8376
  });
8292
8377
  resolve();
8293
8378
  }).catch(reject);
@@ -8579,7 +8664,7 @@ async function startOrQueueOpenedThread(botId, threadId, text, unattended) {
8579
8664
  store.appendMessage(threadId, {
8580
8665
  role: "bot",
8581
8666
  kind: "activity",
8582
- tool: { name: `error: this thread could not start — ${why.slice(0, 120)}`, ok: false },
8667
+ tool: failedTurnTool(`this thread could not start — ${why}`),
8583
8668
  });
8584
8669
  return { state: "failed", error: why };
8585
8670
  }
@@ -8592,10 +8677,9 @@ const screenPollers = new Map();
8592
8677
  const SCREEN_POLL_MS = 6000;
8593
8678
  const SCREEN_MIN_GAP_MS = 3000;
8594
8679
  const SCREEN_SETTLE_TIMEOUT_MS = 10_000;
8595
- /** `screenIsTheWork` starts the turn already counting as screen usage: a
8596
- * boxAgent's whole session runs ON the boat, so every tool it calls acts on
8597
- * that screen even though none of them is named like a computer tool. Its
8598
- * shell-only turns are kept honest by the settle-time hash gate instead. */
8680
+ /** `screenIsTheWork` starts the poller already counting as screen usage: a
8681
+ * poller restarted mid-turn (a lazy claim swapping in its capture) carries
8682
+ * over whether the turn had touched its screen. */
8599
8683
  function startScreenPoller(botId, threadId, captures, { screenIsTheWork = false } = {}) {
8600
8684
  if (!captures.computer && !captures.browser)
8601
8685
  return;
@@ -8687,10 +8771,9 @@ function shownScreenHash(threadId) {
8687
8771
  * end state, not the previous action's. A turn that never touched the
8688
8772
  * screen settles nothing — and skips the capture, which is one less
8689
8773
  * command on the boat's single endpoint. A frame the reader can already see
8690
- * settles nothing either: the boxAgent pre-touch counts every turn as
8691
- * screen work, so without this its shell-only replies would all end in the
8692
- * same idle desktop. Either way the poller is torn down here, so no
8693
- * per-turn state survives the turn. */
8774
+ * settles nothing either, so a turn never ends on the same idle desktop it
8775
+ * already showed. Either way the poller is torn down here, so no per-turn
8776
+ * state survives the turn. */
8694
8777
  async function finalScreenFrame(_botId, threadId) {
8695
8778
  const entry = screenPollers.get(threadId);
8696
8779
  const owner = turnResourceOwners.get(threadId);
@@ -8782,11 +8865,6 @@ async function startTurn(botId, text, opts) {
8782
8865
  // A workspace at its monthly spend limit starts no turn of any kind: a
8783
8866
  // person's message, a routine, a peer hop or a webhook all stop here.
8784
8867
  assertWithinBudget(cfg, DATA_DIR);
8785
- if (checkpointRestoreLeases.has(botId)) {
8786
- throw Object.assign(new Error("this bot's project files are being restored — wait for the restore to finish"), {
8787
- status: 409,
8788
- });
8789
- }
8790
8868
  if (boatLifecycleBusyBots.has(botId)) {
8791
8869
  throw Object.assign(new Error("this bot's cloud computer is being changed — wait for it to finish"), { status: 409 });
8792
8870
  }
@@ -8803,10 +8881,10 @@ async function startTurn(botId, text, opts) {
8803
8881
  // their results still return here (outstandingAssignmentsPrompt tells this
8804
8882
  // turn which are still out). Stop, in this conversation, is the gesture
8805
8883
  // that ends coordination — see interruptDirectThread.
8806
- // Retire anything a previous turn left behind before minting this turn's
8807
- // integrations. Completion and interrupt paths do the same; this is the
8808
- // final backstop against a retained proxy process.
8809
- revokeInternalCapabilitiesForThread(threadId);
8884
+ // Revoke anything a previous turn left behind before minting this turn's
8885
+ // integrations. Completion and interrupt paths do the same; a proxy the
8886
+ // warm engine kept is honoured again only through this turn's records.
8887
+ revokeEarlierTurnCapabilities(threadId);
8810
8888
  // a webhook turn, or one inherited from a bot already running unattended
8811
8889
  if (opts?.automationSource === "webhook" || opts?.unattended)
8812
8890
  markUnattended(bot.id, threadId);
@@ -8823,6 +8901,10 @@ async function startTurn(botId, text, opts) {
8823
8901
  const plan = turnSurfacePlan(bot, opts?.runOn, threadId);
8824
8902
  const instance = registry.get(bot.modelSelection.instanceId);
8825
8903
  if (!instance) {
8904
+ // Still on the removed Computer engine: its move failed or found no
8905
+ // engine. Try it again now, so the next message runs on the new engine.
8906
+ if (removedComputerInstanceIds(cfg.instances).has(bot.modelSelection.instanceId))
8907
+ void retryComputerEngineMove();
8826
8908
  throw Object.assign(new Error(`provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`), { status: 409 });
8827
8909
  }
8828
8910
  const policyRefusal = policyModelRefusal(instance);
@@ -9116,7 +9198,11 @@ async function startTurn(botId, text, opts) {
9116
9198
  const userTurnText = promptWithReply(skillAuthoring ? expandLearnTurnText(setupText) : setupText, opts?.replyTo, cfg.profile?.name?.trim() || "User");
9117
9199
  // Decided again at dispatch when setup outlasted a soul edit (config).
9118
9200
  const decideContext = (config) => {
9119
- const handedStale = Boolean(handed && (!unseen || (externalUpdate && handed.config !== config)));
9201
+ // A Codex thread keeps the last effort it was sent and no turn can clear
9202
+ // one: back on the default, a thread that holds a level is rebuilt from
9203
+ // the transcript rather than resumed on that level.
9204
+ const effortHeld = !effort && typeof handed?.effort === "string";
9205
+ const handedStale = Boolean(handed && (!unseen || effortHeld || (externalUpdate && handed.config !== config)));
9120
9206
  const { block: unseenBlock, placed } = unseen && !handedStale ? renderUnseen(unseen) : { block: "", placed: [] };
9121
9207
  const { turnText: contextTurnText, resume } = buildTurnContext({
9122
9208
  text: userTurnText,
@@ -9149,6 +9235,7 @@ async function startTurn(botId, text, opts) {
9149
9235
  resumeCursor, sessionReset: !resume, recoveryText, recoveryIsReplay,
9150
9236
  handoff: strictResume ? {
9151
9237
  botId: bot.id, instanceId, config, resumeCursor: typeof resumeCursor === "string" ? resumeCursor : undefined,
9238
+ ...(instance.driverKind === "codex" ? { effort: effort ?? null } : {}),
9152
9239
  started: sessionStart(contextOrder, contextTurnText !== userTurnText ? windowIds : [], carried),
9153
9240
  recovery: sessionStart(contextOrder, recoveryText !== undefined ? windowIds : [], carried),
9154
9241
  resumed: sessionStart(contextOrder, [], [...placed, ...carried]),
@@ -9221,8 +9308,7 @@ async function startTurn(botId, text, opts) {
9221
9308
  // because moving a live session would break resume.
9222
9309
  // A cloud run keeps the bot's own engine on this host, with the cloud
9223
9310
  // computer as one more tool, so it works in the same folder as any
9224
- // other turn (only the Computer engine runs on the Boat, and it has no
9225
- // workspace files).
9311
+ // other turn.
9226
9312
  // On a Cloud home a conversation a guest opened is pinned to its own
9227
9313
  // folder when it first runs, not the bot's project folder the owner's
9228
9314
  // conversations share (what it leaves there reaches none of them). One
@@ -9231,14 +9317,16 @@ async function startTurn(botId, text, opts) {
9231
9317
  const pinnedCwd = privateWorkspace
9232
9318
  ? store.pinTaskCwd(bot.id, threadId, privateWorkspace, { privateOnly: cloudGuestOpened(threadId) })
9233
9319
  : null;
9320
+ // A bot's threads run side by side in one project folder, like several
9321
+ // Claude Code or Codex sessions open in one repo: the folder is never a
9322
+ // turn-long claim. What truly needs one writer queues on its own — the
9323
+ // per-turn snapshot per folder (server/checkpoints.ts), memory through
9324
+ // memory_update — and the engines key their sessions by id, not folder.
9234
9325
  const cwd = pinnedCwd ?? undefined;
9235
- if (cwd && !claimTurnResource(resourceOwner, workspaceResource(cwd))) {
9236
- throw Object.assign(new Error("another thread is working in this project folder — wait for it to finish or choose a separate folder"), { status: 409, code: "workspace_busy" });
9237
- }
9238
9326
  // Checkpoint explicit project folders, where a bot can overwrite the
9239
9327
  // user's work. Its private OpenMaus workspace is app-owned and changes
9240
9328
  // on nearly every ordinary chat; snapshotting it would add hidden disk
9241
- // and process overhead without a user project to restore.
9329
+ // and process overhead without a user project to list changes for.
9242
9330
  const checkpointCwd = cwd && cwd !== privateWorkspace ? cwd : undefined;
9243
9331
  // dweb is opt-in: without an explicit daemon URL, do not advertise
9244
9332
  // tools that would fail on every call or spawn an unnecessary proxy.
@@ -9261,11 +9349,6 @@ async function startTurn(botId, text, opts) {
9261
9349
  // landed. A team computer or a cloud routine is not this conversation's
9262
9350
  // choice, so those ignore the pin.
9263
9351
  const dispatchTask = store.taskByThread(bot.id, threadId);
9264
- if (plan.clearPin && dispatchTask)
9265
- store.patchTask(bot.id, threadId, { surface: undefined });
9266
- if (plan.computer !== undefined && plan.computer !== "cloud" && instance.adapter.capabilities.remoteAgent === true) {
9267
- throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
9268
- }
9269
9352
  const wants = plan.computer;
9270
9353
  // A place the organisation disallows, or a Cloud home never offers, is
9271
9354
  // refused before anything is prepared; Auto below simply skips them.
@@ -9277,16 +9360,19 @@ async function startTurn(botId, text, opts) {
9277
9360
  // Where an explicit place came from, so its failure names the one
9278
9361
  // control that changes it (and a failed Auto-recorded pin is cleared).
9279
9362
  // Auto and a team's shared computer are nobody's per-turn choice.
9280
- const placeSource = teamComputer || wants === undefined || wants === "off" ? undefined
9363
+ const namedPlace = teamComputer || wants === undefined || wants === "off" ? undefined : wants;
9364
+ const placeSource = !namedPlace ? undefined
9281
9365
  : opts?.runOn === "cloud" ? "routine"
9282
9366
  : plan.pinned ? (dispatchTask?.surfaceSource === "auto" ? "auto-pin" : "pin")
9283
9367
  : "works-on";
9284
- // An engine that cannot use the cloud computer is refused here, before
9285
- // anything is created or woken — never handed to another engine.
9368
+ // An engine that cannot use the cloud computer, or a Tool selection
9369
+ // that leaves its tools out, is refused here, before anything is
9370
+ // created or woken — never handed to another engine.
9286
9371
  if (wants === "cloud" && placeSource) {
9287
- const unsupported = cloudPlaceDriverError(instance.adapter.capabilities, cloudBackend, placeSource);
9372
+ const unsupported = cloudPlaceRefusal(cloudEngine(instance, bot.modelSelection.model), placeSource, bot.name)
9373
+ ?? computerToolsRefusal(toolScope, placeSource, bot.name);
9288
9374
  if (unsupported)
9289
- throw new PlaceUnavailableError("cloud", placeSource, unsupported);
9375
+ throw unsupported;
9290
9376
  }
9291
9377
  let previewCapture = null;
9292
9378
  let browserCapture = null;
@@ -9412,7 +9498,7 @@ async function startTurn(botId, text, opts) {
9412
9498
  // the newer turn. The gate's refusals stay fail-closed for any screen
9413
9499
  // call racing this teardown; the turn-failed buzz and incident follow
9414
9500
  // the dispatch-failure rules (person-started turns only).
9415
- const surfaceLazyClaimRejection = (label) => (failure) => {
9501
+ const surfaceLazyClaimRejection = (label) => (failure, error) => {
9416
9502
  if (activeInternalGenerationByThread.get(threadId) !== resourceOwner.generation || !threadBusy(bot.id, threadId))
9417
9503
  return;
9418
9504
  // The wait ceiling parked this turn (#1651): register the resume and
@@ -9431,11 +9517,16 @@ async function startTurn(botId, text, opts) {
9431
9517
  void interruptDirectThread(bot.id, threadId, { preserveComputerResume: true }).catch(() => { });
9432
9518
  return;
9433
9519
  }
9434
- const message = `computer unavailable — ${label} could not be claimed for this turn (${failure})`;
9520
+ // A place that failed (mountBotBoat) is already read as its one line
9521
+ // and one way on, and never sticks as an Auto-recorded pin.
9522
+ const placeFailure = error instanceof PlaceUnavailableError ? error : undefined;
9523
+ if (placeFailure)
9524
+ clearFailedAutoPin(bot.id, threadId, placeFailure.place);
9525
+ const message = placeFailure?.message ?? `computer unavailable — ${label} could not be claimed for this turn (${failure})`;
9435
9526
  store.appendMessage(threadId, {
9436
9527
  role: "bot",
9437
9528
  kind: "activity",
9438
- tool: { name: `error: ${message.slice(0, 160)}`, ok: false },
9529
+ tool: failedTurnTool(message, placeFailure ? { place: placeFailure.row } : {}),
9439
9530
  });
9440
9531
  if (opts?.automationSource === undefined && !opts?.commsDepth && !opts?.cardContinuation) {
9441
9532
  notify(buildNotification("turn-failed", bot, threadId, redactSecretsInText(message), { avatarUrl: bot.avatarUrl }));
@@ -9444,8 +9535,26 @@ async function startTurn(botId, text, opts) {
9444
9535
  // completion fold must not report this failure a second time.
9445
9536
  resourceOwner.lazyClaimFailureReported = true;
9446
9537
  }
9538
+ // A routine, a delegation or a resume reports this exactly as it
9539
+ // reports a place that failed at dispatch: with this cause, before
9540
+ // the interrupt settles the turn as merely "interrupted".
9541
+ opts?.onDispatchError?.(message);
9447
9542
  void interruptDirectThread(bot.id, threadId).catch(() => { });
9448
9543
  };
9544
+ /** A lazily claimed remote desktop (the VPS, the bot's Boat) streams
9545
+ * from the moment its claim lands. The dispatch-site poller start saw
9546
+ * no capture (the claim runs after dispatch), so restart the poller
9547
+ * with this one, keeping any browser capture and whether this turn
9548
+ * already touched its screen. Same still-running guard as dispatch: a
9549
+ * poller started after its own turn.completed would never stop. */
9550
+ const adoptClaimedCapture = (capture) => {
9551
+ previewCapture = capture;
9552
+ if (!threadBusy(bot.id, threadId))
9553
+ return;
9554
+ const touched = screenPollers.get(threadId)?.touched ?? false;
9555
+ stopScreenPoller(bot.id, threadId);
9556
+ startScreenPoller(bot.id, threadId, { computer: capture, ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: touched });
9557
+ };
9449
9558
  // Explicit destinations are strict. In particular, Local VM must never
9450
9559
  // fall through to host CUA and accidentally click on the user's Mac.
9451
9560
  // The Local VM attach, shared by explicit "Local VM" and by Auto. Explicit
@@ -9454,7 +9563,7 @@ async function startTurn(botId, text, opts) {
9454
9563
  // be recreated on demand after idling away — and never creates a first
9455
9564
  // VM on its own; every failure there is a quiet "not this place".
9456
9565
  const attachLocalVm = async (strict) => {
9457
- if (!mountsComputerMcp || instance.adapter.capabilities.remoteAgent === true) {
9566
+ if (!mountsComputerMcp) {
9458
9567
  if (!strict)
9459
9568
  return false;
9460
9569
  throw new Error("this model cannot use the Local VM — choose Claude or an ACP model provider, or select another computer destination");
@@ -9510,7 +9619,7 @@ async function startTurn(botId, text, opts) {
9510
9619
  // guard as dispatch: a poller started after its own
9511
9620
  // turn.completed would never be torn down.
9512
9621
  if (previewCapture && threadBusy(bot.id, threadId)) {
9513
- const touched = screenPollers.get(threadId)?.touched ?? instance.adapter.capabilities.remoteAgent === true;
9622
+ const touched = screenPollers.get(threadId)?.touched ?? false;
9514
9623
  stopScreenPoller(bot.id, threadId);
9515
9624
  startScreenPoller(bot.id, threadId, { computer: previewCapture, ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: touched });
9516
9625
  }
@@ -9557,7 +9666,7 @@ async function startTurn(botId, text, opts) {
9557
9666
  // the person explicitly opted this bot into remote lifecycle actions.
9558
9667
  // On Auto an engine that cannot use a computer simply skips it.
9559
9668
  if ((wants === "cloud" || (wants === undefined && managedPolicy.computerAllowed("vps"))) && cloudBackend === "vps" &&
9560
- !cloudPlaceDriverError(instance.adapter.capabilities, "vps")) {
9669
+ canWorkOnCloud(cloudEngine(instance))) {
9561
9670
  // The VPS "computer" is the desktop inside this bot's managed
9562
9671
  // container, and only screen work needs that desktop to itself.
9563
9672
  // So the lease is claimed on the first computer call, through the
@@ -9574,12 +9683,7 @@ async function startTurn(botId, text, opts) {
9574
9683
  // Local VM's lazy claim does.
9575
9684
  onClaimed: (vpsCapture) => {
9576
9685
  pinAutoSurface("cloud");
9577
- previewCapture = vpsCapture;
9578
- if (threadBusy(bot.id, threadId)) {
9579
- const touched = screenPollers.get(threadId)?.touched ?? false;
9580
- stopScreenPoller(bot.id, threadId);
9581
- startScreenPoller(bot.id, threadId, { computer: vpsCapture, ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: touched });
9582
- }
9686
+ adoptClaimedCapture(vpsCapture);
9583
9687
  },
9584
9688
  onRejected: surfaceLazyClaimRejection("the VPS computer"),
9585
9689
  });
@@ -9595,54 +9699,56 @@ async function startTurn(botId, text, opts) {
9595
9699
  autoVpsProblem = mounted.problem ?? "the VPS computer could not be reached";
9596
9700
  }
9597
9701
  }
9598
- // Cloud is strict when selected. Every engine that can use it may also
9599
- // reuse an already-ready Boat on Auto.
9600
9702
  if (teamComputer) {
9601
- const attached = await attachTeamBoat(teamComputer, bot.id, resourceOwner, usesCloudComputer(instance.adapter.capabilities), instance.adapter.capabilities.remoteAgent === true);
9703
+ const attached = await attachTeamBoat(teamComputer, bot.id, resourceOwner, canWorkOnCloud(cloudEngine(instance)));
9602
9704
  Object.assign(integrations, attached.mount);
9603
9705
  previewCapture = attached.capture;
9604
9706
  computerKind = "box";
9605
9707
  }
9606
- if (!teamComputer && usesCloudComputer(instance.adapter.capabilities) && (wants === "cloud" || (wants === undefined && !computerPlaceRefusal("box"))) && cloudBackend === "box" && boat.boatConfigured(cfg)) {
9607
- const attached = await attachBotBoat(bot, resourceOwner, {
9608
- explicitCloud: wants === "cloud",
9609
- canMount: usesCloudComputer(instance.adapter.capabilities),
9610
- remoteAgent: instance.adapter.capabilities.remoteAgent === true,
9708
+ // Cloud is strict when selected. A turn reaches the Boat only when
9709
+ // this conversation is on it (Works on, a pin, a cloud routine, or
9710
+ // select_computer): an Auto turn never reads the Boat account, so it
9711
+ // adds no Boat call, cannot be stopped by a Boat being deleted, and
9712
+ // never records a place nobody chose. Even then the Boat is created
9713
+ // or woken only by the bot's first computer call (mountBotBoat): a
9714
+ // chat that never touches the screen uses no plan hours.
9715
+ if (!teamComputer && cloudBackend === "box" && boat.boatConfigured(cfg) && wants === "cloud") {
9716
+ integrations.localComputer = mountBotBoat(bot, resourceOwner, {
9717
+ // The row a failed attach always wrote: the place's one line and
9718
+ // its one way on, read where the place came from (an
9719
+ // Auto-recorded pin is cleared, as dispatch clears it).
9720
+ source: placeSource,
9721
+ onClaimed: adoptClaimedCapture,
9722
+ onRejected: surfaceLazyClaimRejection("the cloud computer"),
9611
9723
  });
9612
- if (attached) {
9613
- previewCapture = attached.capture;
9614
- if (attached.mount) {
9615
- Object.assign(integrations, attached.mount);
9616
- computerKind = "box";
9617
- }
9618
- }
9724
+ computerKind = "box";
9619
9725
  }
9620
9726
  if (wants === "cloud" && cloudBackend === "box" && !boat.boatConfigured(cfg)) {
9621
- throw new Error(BOAT_NOT_CONFIGURED);
9727
+ throw boatNotConfigured(bot, placeSource ?? "works-on");
9622
9728
  }
9623
9729
  if (wants === "cloud" && cloudBackend === "box" && computerKind !== "box") {
9624
9730
  throw new Error("the cloud computer could not be created or reached");
9625
9731
  }
9626
9732
  }
9627
9733
  catch (error) {
9628
- if (!placeSource || wants === undefined || wants === "off" || error instanceof DirectTurnSetupCancelled ||
9629
- error instanceof ComputerWaitParked || error instanceof PlaceUnavailableError)
9734
+ // Auto, Off and a team's shared computer name no place; they fail as
9735
+ // they are.
9736
+ if (!namedPlace || !placeSource)
9630
9737
  throw error;
9631
- throw placeUnavailable(wants, placeSource, error instanceof Error ? error.message : String(error));
9738
+ throw await asPlaceFailure(error, namedPlace, placeSource, bot, cloudBackend);
9632
9739
  }
9633
9740
  // Auto-only host fallback. Electron owns cua-driver/TCC attribution;
9634
9741
  // the harness only reads its already-running connection descriptor.
9635
9742
  // Auto reaches a Local VM this bot already has before it ever touches the
9636
9743
  // host's own desktop: on a headless server that VM is the only desktop
9637
9744
  // there is, and a person who prepared one meant it to be used.
9638
- if (wants === undefined && !integrations.computer && !integrations.localComputer && !computerPlaceRefusal("localVm") && await attachLocalVm(false))
9745
+ if (wants === undefined && !integrations.localComputer && !computerPlaceRefusal("localVm") && await attachLocalVm(false))
9639
9746
  computerKind = "vm";
9640
9747
  // An unattended run on a bot with a VPS configured never lands on the
9641
9748
  // host's own desktop instead: a scheduled job clicking on someone's
9642
9749
  // laptop is worse than a scheduled job that fails and says why.
9643
9750
  const unattendedVps = cloudBackend === "vps" && Boolean(opts?.automationSource);
9644
- if (!integrations.computer &&
9645
- !integrations.localComputer &&
9751
+ if (!integrations.localComputer &&
9646
9752
  wants === undefined &&
9647
9753
  !unattendedVps &&
9648
9754
  !computerPlaceRefusal("thisComputer") &&
@@ -9671,13 +9777,12 @@ async function startTurn(botId, text, opts) {
9671
9777
  pinAutoSurface("local");
9672
9778
  },
9673
9779
  });
9674
- integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
9780
+ integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId, "this computer"));
9675
9781
  computerKind = "local";
9676
9782
  }
9677
9783
  }
9678
9784
  if (wants === undefined &&
9679
9785
  cloudBackend === "vps" &&
9680
- !integrations.computer &&
9681
9786
  !integrations.localComputer &&
9682
9787
  autoVpsProblem &&
9683
9788
  !computerSelectionTurns.has(threadId)) {
@@ -9685,7 +9790,7 @@ async function startTurn(botId, text, opts) {
9685
9790
  ? "This scheduled run tried to start the VPS computer and could not reach it. Check the VPS connection in Settings → API keys."
9686
9791
  : bot.autoStartVps
9687
9792
  ? "Check the VPS connection in Settings → API keys."
9688
- : "Open Computer and enable Start VPS automatically, or choose Cloud to start it manually.";
9793
+ : "Open Computer and enable Start VPS automatically, or choose Cloud computer to start it manually.";
9689
9794
  throw new Error(`${autoVpsProblem}. ${hint}`);
9690
9795
  }
9691
9796
  // Agent control tools include peer comms and the secure credential
@@ -9739,14 +9844,17 @@ async function startTurn(botId, text, opts) {
9739
9844
  // snapshot() absorbs failures, so checkpointing may delay but never fail
9740
9845
  // a turn.
9741
9846
  if (checkpointCwd) {
9742
- const before = await checkpoints.snapshot(bot.id, checkpointCwd, `turn ${threadId.slice(0, 8)}`);
9847
+ // Pinned by this dispatch: a sibling thread's snapshot in the same
9848
+ // folder must not reclaim this turn's "before" state while it runs.
9849
+ const before = await checkpoints.snapshot(bot.id, checkpointCwd, `turn ${threadId.slice(0, 8)}`, undefined, { pin: dispatchClaimId });
9743
9850
  if (!directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId)) {
9851
+ if (before)
9852
+ void checkpoints.release(bot.id, checkpointCwd, dispatchClaimId);
9744
9853
  throw new DirectTurnSetupCancelled("turn stopped during checkpoint");
9745
9854
  }
9855
+ forgetTurnCheckpoint(threadId);
9746
9856
  if (before)
9747
- turnCheckpoints.set(threadId, { cwd: checkpointCwd, hash: before });
9748
- else
9749
- turnCheckpoints.delete(threadId);
9857
+ turnCheckpoints.set(threadId, { botId: bot.id, cwd: checkpointCwd, hash: before, pin: dispatchClaimId });
9750
9858
  }
9751
9859
  if (!directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId)) {
9752
9860
  throw new DirectTurnSetupCancelled("turn stopped before dispatch");
@@ -9805,8 +9913,8 @@ async function startTurn(botId, text, opts) {
9805
9913
  // Pin on use (issue #1650): every computer that registers a claim
9806
9914
  // slot — a ready Local VM, the VPS, the host fallback, a VM created
9807
9915
  // for this turn — records its pin inside the claim itself, so a turn
9808
- // that only mounted tools records nothing. A Box attached during
9809
- // dispatch and the built-in browser (no seat to claim) pin here.
9916
+ // that only mounted tools records nothing. The built-in browser (no
9917
+ // seat to claim) pins here.
9810
9918
  if (autoPinAllowed()) {
9811
9919
  const claimSlot = autoVmClaims.get(threadId);
9812
9920
  const pinsAtClaim = claimSlot?.owner.generation === resourceOwner.generation;
@@ -9827,7 +9935,6 @@ async function startTurn(botId, text, opts) {
9827
9935
  }
9828
9936
  const computerPromptKind = resolveComputerPromptKind({
9829
9937
  kind: computerKind,
9830
- driverKind: instance.driverKind,
9831
9938
  vmPrivate: localVmMode(cfg) === "per-bot",
9832
9939
  });
9833
9940
  const coordinationNode = opts?.coordination ? roomHandoffs.nodes.get(opts.coordination.id) : undefined;
@@ -9844,7 +9951,7 @@ async function startTurn(botId, text, opts) {
9844
9951
  { id: "computer", label: "Computer", text: computerPrompt(computerPromptKind) },
9845
9952
  { id: "team-computer", label: "Team computer", text: teamComputerPrompt(teamComputer) },
9846
9953
  { 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) }) },
9847
- { id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
9954
+ { id: "cloud-home", label: "My Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
9848
9955
  // gated on the integration, not the key: the hint only goes to a
9849
9956
  // bot whose driver actually mounted the tools
9850
9957
  { id: "composio", label: "Connected apps", text: integrations.composio ? composioSystemPrompt(liveBot?.connectorTools ?? bot.connectorTools) + describeConnectorScopes((liveBot ?? bot).connectorScopes) : "" },
@@ -9861,6 +9968,9 @@ async function startTurn(botId, text, opts) {
9861
9968
  { id: "learn", label: "Skill authoring", text: learnPrompt },
9862
9969
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
9863
9970
  { id: "team-memory", label: "Team memory", text: teamMemory.systemPrompt(bot.section) + (integrations.agents ? TEAM_MEMORY_PROMPT : "") },
9971
+ // who on the team roster is busy right now; volatile, so a teammate
9972
+ // starting or finishing work never changes the stable half
9973
+ teamAvailabilityPart(coordinationPrompt ? sectionPeers : []),
9864
9974
  // what the bot said lately in its other conversations, so a task
9865
9975
  // never redoes — or forgets — what another one already did
9866
9976
  { id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(recentWorkSources(bot), bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId, ...recentWorkFilter() })) },
@@ -9974,7 +10084,7 @@ async function startTurn(botId, text, opts) {
9974
10084
  // keep polling the boat forever, carrying dead per-turn state. busy
9975
10085
  // is flipped false in the fold, so it is the honest "still running".
9976
10086
  if ((previewCapture || browserCapture) && threadBusy(bot.id, threadId)) {
9977
- startScreenPoller(bot.id, threadId, { ...(previewCapture ? { computer: previewCapture } : {}), ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: instance.adapter.capabilities.remoteAgent === true });
10087
+ startScreenPoller(bot.id, threadId, { ...(previewCapture ? { computer: previewCapture } : {}), ...(browserCapture ? { browser: browserCapture } : {}) });
9978
10088
  }
9979
10089
  // An adapter may publish completion synchronously just before its
9980
10090
  // dispatch promise resolves. The event could not use the turn-id map
@@ -10068,16 +10178,10 @@ async function startTurn(botId, text, opts) {
10068
10178
  drainDelegationWakes();
10069
10179
  return;
10070
10180
  }
10071
- // A failed place never sticks. An Auto-recorded pin to it (a
10072
- // select_computer switch, or where an earlier Auto turn landed) is the
10073
- // machine's memory, not a choice: clear it so the next message runs on
10074
- // Auto. A person's pin stays, and the message names the composer.
10075
- if (e instanceof PlaceUnavailableError) {
10076
- const pinned = store.taskByThread(bot.id, threadId);
10077
- if (pinned?.surface === e.place && pinned.surfaceSource === "auto") {
10078
- store.patchTask(bot.id, threadId, { surface: undefined, surfaceSource: undefined });
10079
- }
10080
- }
10181
+ // A failed place never sticks; a person's pin stays, and the message
10182
+ // names the composer.
10183
+ if (e instanceof PlaceUnavailableError)
10184
+ clearFailedAutoPin(bot.id, threadId, e.place);
10081
10185
  let message = e instanceof Error ? e.message : String(e);
10082
10186
  const current = store.projectBotForTask(bot.id, threadId);
10083
10187
  const backups = cfg.automaticRecovery?.enabled
@@ -10132,7 +10236,7 @@ async function startTurn(botId, text, opts) {
10132
10236
  role: "bot",
10133
10237
  kind: "activity",
10134
10238
  turnSucceeded: false,
10135
- tool: { name: `error: ${message.slice(0, 160)}`, ok: false },
10239
+ tool: failedTurnTool(message, e instanceof PlaceUnavailableError ? { place: e.row } : {}),
10136
10240
  });
10137
10241
  // Worth a buzz for the same reason a routine failure is, and the rule
10138
10242
  // notify.ts encodes: the bot is not working, and the cause is usually
@@ -10393,16 +10497,22 @@ routines = new RoutineManager({
10393
10497
  }
10394
10498
  return groupIsWorking(group) || coordinator.busy ? "busy" : "ready";
10395
10499
  },
10396
- createTask: (botId, title, activate = false, routineId) => {
10500
+ createTask: (botId, title, activate = false, run) => {
10397
10501
  const task = store.createTask(botId, title, activate);
10398
10502
  // On a Cloud home a run's conversation is opened, like its results
10399
10503
  // conversation, by whoever wrote the routine: a run of one that is
10400
10504
  // nobody's is confined to a folder of its own, never the bot's project.
10401
- if (task && CLOUD_HOME && routineId)
10402
- threadStarters.set(task.threadId, routineOpener(routineId));
10403
- const bot = store.bot(botId);
10404
- if (task && bot)
10405
- broadcast({ kind: "bot", bot: publicBot(bot) });
10505
+ // A webhook's run is the owner's: only their own devices can create,
10506
+ // edit or rotate a webhook, so it works at the bot's own level, in its
10507
+ // folder, as on the desktop. Its payload still never reaches the lent
10508
+ // Mac: cloud-lending.ts refuses every webhook run by its trigger.
10509
+ if (task && CLOUD_HOME && run) {
10510
+ threadStarters.set(task.threadId, run.triggerSource === "webhook" ? CLOUD_OWNER_KEY : routineOpener(run.routineId));
10511
+ }
10512
+ // The store's frame announces it. A run's task that stays in the
10513
+ // background leaves the open thread alone, so that frame carries no
10514
+ // transcript: a whole thread on every scheduled run once passed the phone
10515
+ // sidecar's 4 MiB event ceiling and ended the stream (MOCA-179).
10406
10516
  return task;
10407
10517
  },
10408
10518
  joinConversation: (run) => {
@@ -10457,11 +10567,8 @@ routines = new RoutineManager({
10457
10567
  discardDelegations(commsBus, threadId);
10458
10568
  cancelDirectTurnDispatch(botId, threadId);
10459
10569
  revokeInternalCapabilitiesForThread(threadId);
10460
- const instance = bot
10461
- ? runningTurnInstance(bot, threadId)
10462
- : runningTurnEngines.get(threadId) ?? null;
10463
10570
  try {
10464
- await instance?.adapter.interruptTurn(threadId);
10571
+ await runningTurnInstance(bot, threadId)?.adapter.interruptTurn(threadId);
10465
10572
  }
10466
10573
  finally {
10467
10574
  closeOpenApprovals(threadId);
@@ -10617,25 +10724,25 @@ async function cloudRoutineReadiness(botId, threadId) {
10617
10724
  if (!boat.boatConfigured(cfg)) {
10618
10725
  return {
10619
10726
  ready: false,
10620
- 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.',
10727
+ reason: `${boatNotConfigured(bot, "routine").message} For the bot's configured computer, including a self-hosted VPS, set run_on="maus" instead.`,
10621
10728
  };
10622
10729
  }
10623
10730
  const instance = registry.get(bot.modelSelection.instanceId);
10624
10731
  if (!instance)
10625
10732
  return { ready: false, reason: "The target bot's model is unavailable. Choose another model in the bot's settings." };
10626
10733
  // The same rule a cloud turn applies, before anything is provisioned.
10627
- const unsupported = cloudPlaceDriverError(instance.adapter.capabilities, "box");
10734
+ const unsupported = cloudPlaceRefusal(cloudEngine(instance, bot.modelSelection.model), "routine", bot.name);
10628
10735
  if (unsupported)
10629
- return { ready: false, reason: "This model can't use a computer. Choose another model, or change where this routine runs." };
10736
+ return { ready: false, reason: unsupported.message };
10630
10737
  try {
10631
10738
  if ((await instance.snapshot()).state !== "available") {
10632
- return { ready: false, reason: "The target bot's model is not ready to use the Boat cloud computer." };
10739
+ return { ready: false, reason: "The target bot's model is not ready to use the cloud computer." };
10633
10740
  }
10634
10741
  const teamComputer = inheritedTeamComputer(bot);
10635
10742
  const ownerId = teamComputer ? teamComputerOwner(teamComputer.id) : bot.id;
10636
10743
  const machine = await boat.findBoat(cfg, ownerId);
10637
10744
  if (teamComputer && !machine) {
10638
- return { ready: false, reason: "The team's Boat computer is missing; explicitly create or retry it from the Team map." };
10745
+ return { ready: false, reason: "The team's cloud computer is missing; explicitly create or retry it from the Team map." };
10639
10746
  }
10640
10747
  // Explicit Cloud may provision or wake the bot's own Boat at dispatch.
10641
10748
  // This probe only checks its identity/account, without starting billing.
@@ -10998,52 +11105,6 @@ const modelRequests = new ModelRequestService({
10998
11105
  validateModel: validateModelProposal,
10999
11106
  driverCapabilities: driverCapabilitiesFor,
11000
11107
  });
11001
- const tighteningRequests = new TighteningRequestService({
11002
- store,
11003
- autoApply: fullAccessForSource,
11004
- canPersist: proposalPersistence,
11005
- // Same reach as a profile change: a Chief may tighten a section peer;
11006
- // anyone else only itself. Re-checked at confirm.
11007
- validateTarget: (proposerBotId, targetBotId) => {
11008
- const proposer = store.bot(proposerBotId);
11009
- const target = store.bot(targetBotId);
11010
- if (!target)
11011
- return "that bot no longer exists";
11012
- if (!proposer?.chiefOfStaff)
11013
- return "only a section's Chief of Staff can change another bot's permissions";
11014
- if (!canReachPeer(proposer, target))
11015
- return "that bot is not in a team this Chief is allowed to manage";
11016
- return null;
11017
- },
11018
- // The card judges the effective mounts, so the snapshot resolves the same
11019
- // way the engine does: through the config-aware custom-server filter.
11020
- mountedMcpServers: (bot) => Object.keys(engineMcpServers(bot)),
11021
- enabledSkills: (botId) => {
11022
- const record = store.bot(botId);
11023
- return (record ? listBotSkills(record) : listSkills(botId)).filter((skill) => skill.enabled).map((skill) => skill.name);
11024
- },
11025
- disableSkill: (botId, name) => {
11026
- const record = store.bot(botId);
11027
- // Private skills win collisions, including disabled/private copies.
11028
- // Disabling one must not reveal a same-named shared skill instead.
11029
- if (skillsLibraryEnabled(cfg) && !listSkills(botId).some((skill) => skill.name === name)
11030
- && record?.assignedSkills?.includes(name)) {
11031
- try {
11032
- const saved = store.patchBot(botId, { assignedSkills: record.assignedSkills.filter((assigned) => assigned !== name) });
11033
- return saved ? { ok: true } : { ok: false, error: "that bot no longer exists" };
11034
- }
11035
- catch (error) {
11036
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
11037
- }
11038
- }
11039
- const disabled = setSkillEnabled(botId, name, false);
11040
- return typeof disabled === "object" && "error" in disabled ? { ok: false, error: disabled.error } : { ok: true };
11041
- },
11042
- // The owner's own routes stop a busy bot before touching its approval
11043
- // level or MCP mounts; the card asks for the same, except an emergency
11044
- // full/custom → ask downgrade, which applies first and stops after.
11045
- targetBusy: (botId) => Boolean(store.bot(botId)?.busy || activeGroupTurnForBot(botId)),
11046
- });
11047
11108
  const teamSetupTeams = () => [...new Set(["", ...readSections(), ...store.bots.map((bot) => sectionKey(bot.section)), ...store.groups.map((group) => sectionKey(group.section))])];
11048
11109
  const teamSetupRequests = new TeamSetupRequestService({
11049
11110
  store, teams: teamSetupTeams, canAccessTeam, canPersist: proposalPersistence, maxBots: MAX_WORKSPACE_BOTS,
@@ -11355,46 +11416,6 @@ function resolveAndSendModel(res, args) {
11355
11416
  json(res, 200, { ok: true, outcome: "rejected" });
11356
11417
  return true;
11357
11418
  }
11358
- function resolveAndSendTightening(res, args) {
11359
- const card = store.messagesFor(args.threadId).find((message) => message.card?.requestId === args.requestId && message.card.tighteningRequest)?.card;
11360
- if (!card)
11361
- return false;
11362
- const result = tighteningRequests.resolve(args);
11363
- if (!result.claimed)
11364
- return false;
11365
- if (result.state === "applied" || result.state === "denied") {
11366
- appendDecision(DATA_DIR, {
11367
- threadId: args.threadId, requestId: args.requestId, botId: args.botId, botName: args.botName,
11368
- tool: "tighten_permissions", summary: card.subtitle,
11369
- decision: result.state === "applied" ? "user-approved" : "user-denied", source: "user",
11370
- });
11371
- }
11372
- if (result.state === "applied") {
11373
- const target = store.bot(result.targetBotId);
11374
- if (target)
11375
- broadcast({ kind: "bot", bot: wireBot(target) });
11376
- if (result.emergencyStop) {
11377
- // Mirror the owner's own route: the fail-closed Ask is already
11378
- // persisted, then the exact turn still holding the elevated
11379
- // per-turn snapshot is stopped. The response never waits on it.
11380
- void stopBotForEmergencyApprovalDowngrade(result.targetBotId).catch((error) => {
11381
- console.warn(`[tightening] emergency stop failed for bot ${result.targetBotId}: ${error instanceof Error ? error.message : String(error)}. The fail-closed Ask is persisted, but the in-flight turn keeps its elevated access until it ends.`);
11382
- });
11383
- }
11384
- json(res, 200, { ok: true, outcome: "allowed-once", tighteningFields: result.fields });
11385
- return true;
11386
- }
11387
- if (result.state === "invalid") {
11388
- json(res, result.status, { error: result.error });
11389
- return true;
11390
- }
11391
- if (result.state === "already_settled") {
11392
- json(res, 200, { ok: true, outcome: result.behavior === "allow" ? "allowed-once" : "rejected", alreadySettled: true });
11393
- return true;
11394
- }
11395
- json(res, 200, { ok: true, outcome: "rejected" });
11396
- return true;
11397
- }
11398
11419
  // Webhook definitions are independent from calendar schedules, but every
11399
11420
  // delivery joins the same RoutineManager queue. That keeps unattended work
11400
11421
  // ordered behind a busy MAUS and gives webhook runs the same durable receipts.
@@ -11414,7 +11435,7 @@ const webhooks = new WebhookManager({
11414
11435
  post: (botId, threadId, text) => {
11415
11436
  if (!store.bot(botId))
11416
11437
  return;
11417
- store.appendMessage(threadId, { role: "bot", kind: "text", text });
11438
+ store.appendMessage(threadId, { role: "bot", kind: "text", text, webhookPost: true });
11418
11439
  },
11419
11440
  // Mirrors resolveResultsThread's routines wiring a few hundred lines up
11420
11441
  // in this same file: create-on-first-use, never activated (so it never
@@ -11589,7 +11610,7 @@ setupRetry = 0) {
11589
11610
  onDispatchError?.(`${bot.name}'s approval level is still being confirmed — skipped this round`);
11590
11611
  return true;
11591
11612
  }
11592
- revokeInternalCapabilitiesForThread(threadId);
11613
+ revokeEarlierTurnCapabilities(threadId);
11593
11614
  spoken.add(botId);
11594
11615
  // Must be the SAME resolver the readiness re-check uses below, or a Chief's
11595
11616
  // delegated Full elevation makes the two disagree by construction: every
@@ -11617,7 +11638,7 @@ setupRetry = 0) {
11617
11638
  role: "bot",
11618
11639
  kind: "activity",
11619
11640
  from: { botId: bot.id, name: bot.name, color: bot.color },
11620
- tool: { name: `error: ${message}`, ok: false },
11641
+ tool: failedTurnTool(message),
11621
11642
  });
11622
11643
  onDispatchError?.(message);
11623
11644
  return true;
@@ -11652,6 +11673,10 @@ setupRetry = 0) {
11652
11673
  noteForeignTurn(bot.id, threadId, internalGeneration);
11653
11674
  const resourceOwner = { threadId, generation: internalGeneration };
11654
11675
  turnResourceOwners.set(threadId, resourceOwner);
11676
+ // Set when this member's cloud computer, claimed by its first computer
11677
+ // call, could not start (or parked waiting for its seat): the turn ends on
11678
+ // it, with this cause, instead of as merely interrupted.
11679
+ let roomClaimFailure;
11655
11680
  let roomVmTarget = null;
11656
11681
  let retainRoomVmLease = false;
11657
11682
  let roomSpeaker;
@@ -11749,7 +11774,7 @@ setupRetry = 0) {
11749
11774
  role: "bot",
11750
11775
  kind: "activity",
11751
11776
  from: { botId: bot.id, name: bot.name, color: bot.color },
11752
- tool: { name: `error: ${message}`, ok: false },
11777
+ tool: failedTurnTool(message),
11753
11778
  });
11754
11779
  onDispatchError?.(message);
11755
11780
  return true;
@@ -11898,17 +11923,13 @@ setupRetry = 0) {
11898
11923
  const roomPlaceRefusal = roomKind && computerPlaceRefusal(roomKind);
11899
11924
  if (roomPlaceRefusal)
11900
11925
  throw Object.assign(new Error(roomPlaceRefusal.message), { code: roomPlaceRefusal.code });
11901
- // The Computer engine runs on the Boat; this computer has no tools it can
11902
- // reach, exactly as a bot thread refuses it.
11903
- if (roomPlan.computer === "local" && instance.adapter.capabilities.remoteAgent === true) {
11904
- throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
11905
- }
11906
11926
  // The same one rule as a bot thread, before anything is provisioned: an
11907
11927
  // engine that cannot use the cloud computer is refused, never swapped.
11908
11928
  if (!roomTeamComputer && roomPlan.computer === "cloud") {
11909
- const unsupported = cloudPlaceDriverError(instance.adapter.capabilities, turnProvider(readyBot) === "vps" ? "vps" : "box");
11929
+ const unsupported = cloudPlaceRefusal(cloudEngine(instance, readyBot.modelSelection.model), "room", readyBot.name)
11930
+ ?? computerToolsRefusal(readyBot.toolScope, "room", readyBot.name);
11910
11931
  if (unsupported)
11911
- throw new PlaceUnavailableError("cloud", "works-on", unsupported);
11932
+ throw unsupported;
11912
11933
  }
11913
11934
  // One place per room turn as well: a team computer reached on Auto means
11914
11935
  // no separate built-in browser.
@@ -11928,12 +11949,12 @@ setupRetry = 0) {
11928
11949
  return false;
11929
11950
  }
11930
11951
  if (roomTeamComputer) {
11931
- const attached = await attachTeamBoat(roomTeamComputer, readyBot.id, resourceOwner, usesCloudComputer(instance.adapter.capabilities), instance.adapter.capabilities.remoteAgent === true);
11952
+ const attached = await attachTeamBoat(roomTeamComputer, readyBot.id, resourceOwner, canWorkOnCloud(cloudEngine(instance)));
11932
11953
  if (isCancelled?.() || groupSpeakers.get(threadId) !== roomSpeaker ||
11933
11954
  activeInternalGenerationByThread.get(threadId) !== internalGeneration)
11934
11955
  return false;
11935
11956
  Object.assign(integrations, attached.mount);
11936
- startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: instance.adapter.capabilities.remoteAgent === true });
11957
+ startScreenPoller(readyBot.id, threadId, { computer: attached.capture });
11937
11958
  }
11938
11959
  // The speaker's own explicit place, mounted exactly as its bot thread
11939
11960
  // mounts it. A channel has no conversation pin and no Auto fallback here:
@@ -11941,6 +11962,51 @@ setupRetry = 0) {
11941
11962
  const roomSetupIsCurrent = () => !isCancelled?.() &&
11942
11963
  groupSpeakers.get(threadId) === roomSpeaker &&
11943
11964
  activeInternalGenerationByThread.get(threadId) === internalGeneration;
11965
+ // A lazily claimed desktop (the VPS, the member's Boat) shows in the room
11966
+ // from the moment its first computer call claims it.
11967
+ const showClaimedScreen = (capture) => {
11968
+ if (roomSetupIsCurrent() && store.group(readyGroup.id)?.busyBotId === readyBot.id) {
11969
+ roomScreenBotId = readyBot.id;
11970
+ startScreenPoller(readyBot.id, threadId, { computer: capture });
11971
+ }
11972
+ };
11973
+ // The member's cloud computer could not start on its first computer call:
11974
+ // the same row, report and parking a failure at setup gets (the catch at
11975
+ // the end of this function), then the member's turn ends. Fenced to this
11976
+ // member's still-running turn.
11977
+ const failRoomClaim = (failure, error) => {
11978
+ if (activeInternalGenerationByThread.get(threadId) !== internalGeneration || groupSpeakers.get(threadId) !== roomSpeaker)
11979
+ return;
11980
+ if (resourceOwner.computerParkedOn) {
11981
+ registerComputerResume({
11982
+ botId: bot.id,
11983
+ threadId,
11984
+ resource: resourceOwner.computerParkedOn,
11985
+ generation: internalGeneration,
11986
+ afterMessageId: store.activePath(threadId).findLast((entry) => entry.role === "user")?.id,
11987
+ });
11988
+ roomClaimFailure = { parked: true };
11989
+ if (orchestration)
11990
+ orchestration.result.stopReason = `${bot.name} parked waiting for the computer`;
11991
+ }
11992
+ else {
11993
+ // Read as a room member's place (mountBotBoat): its one line, with no
11994
+ // retry of its own.
11995
+ const placeFailure = error instanceof PlaceUnavailableError ? error : undefined;
11996
+ const message = placeFailure?.message ?? failure;
11997
+ roomClaimFailure = { parked: false, message };
11998
+ store.appendMessage(threadId, {
11999
+ role: "bot", kind: "activity",
12000
+ from: { botId: bot.id, name: bot.name, color: bot.color },
12001
+ tool: failedTurnTool(message, placeFailure ? { place: placeFailure.row } : {}),
12002
+ });
12003
+ onDispatchError?.(message);
12004
+ // Claude settles the interrupt as exit_before_result: not a second
12005
+ // incident for the same failure.
12006
+ resourceOwner.lazyClaimFailureReported = true;
12007
+ }
12008
+ void instance.adapter.interruptTurn(threadId).catch(() => { });
12009
+ };
11944
12010
  // An explicit place that cannot be attached fails the member's turn with
11945
12011
  // its cause and the one setting that changes it.
11946
12012
  try {
@@ -11954,15 +12020,7 @@ setupRetry = 0) {
11954
12020
  if (turnProvider(readyBot) === "vps") {
11955
12021
  vpsThreadStarted(readyBot.id, threadId);
11956
12022
  roomVpsBotId = readyBot.id;
11957
- const mounted = await mountBotVps(readyBot, resourceOwner, {
11958
- start: true,
11959
- onClaimed: (capture) => {
11960
- if (roomSetupIsCurrent() && store.group(readyGroup.id)?.busyBotId === readyBot.id) {
11961
- roomScreenBotId = readyBot.id;
11962
- startScreenPoller(readyBot.id, threadId, { computer: capture });
11963
- }
11964
- },
11965
- });
12023
+ const mounted = await mountBotVps(readyBot, resourceOwner, { start: true, onClaimed: showClaimedScreen });
11966
12024
  if (!roomSetupIsCurrent())
11967
12025
  return false;
11968
12026
  if (!("integration" in mounted))
@@ -11972,25 +12030,18 @@ setupRetry = 0) {
11972
12030
  }
11973
12031
  else {
11974
12032
  if (!boat.boatConfigured(cfg))
11975
- throw new Error(BOAT_NOT_CONFIGURED);
11976
- const remoteAgent = instance.adapter.capabilities.remoteAgent === true;
11977
- const attached = await attachBotBoat(readyBot, resourceOwner, { explicitCloud: true,
11978
- canMount: usesCloudComputer(instance.adapter.capabilities), remoteAgent });
11979
- if (!roomSetupIsCurrent())
11980
- return false;
11981
- if (!attached?.mount)
11982
- throw new Error("the cloud computer could not be created or reached");
11983
- Object.assign(integrations, attached.mount);
11984
- roomScreenBotId = readyBot.id;
11985
- startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: remoteAgent });
12033
+ throw boatNotConfigured(readyBot, "room");
12034
+ // As in a bot thread: created or woken by the member's first
12035
+ // computer call, never by the room message itself.
12036
+ integrations.localComputer = mountBotBoat(readyBot, resourceOwner, { source: "room", onClaimed: showClaimedScreen, onRejected: failRoomClaim });
11986
12037
  roomComputerKind = "box";
11987
12038
  }
11988
12039
  }
11989
12040
  // Room and Goal turns use the speaker's desktop, never the coordinator's.
11990
12041
  // Claim the same lease as direct turns before asynchronous VM setup.
11991
12042
  if (readyBot.computer === "vm") {
11992
- if (instance.adapter.capabilities.computerMcp !== true || instance.adapter.capabilities.remoteAgent === true) {
11993
- throw new Error("this model cannot use the Local VM");
12043
+ if (instance.adapter.capabilities.computerMcp !== true) {
12044
+ throw placeUnavailable("vm", { state: "vm-cannot", params: { bot: readyBot.name, model: cloudEngine(instance, readyBot.modelSelection.model).name }, source: "room" }, Boolean(CLOUD_HOME));
11994
12045
  }
11995
12046
  // A distinct identity fences cleanup even in shared mode on the same room thread.
11996
12047
  const target = { ...localVmTargetForThread(readyBot.id, threadId) };
@@ -12023,17 +12074,14 @@ setupRetry = 0) {
12023
12074
  if (owner?.threadId !== threadId || owner.botId !== readyBot.id) {
12024
12075
  throw new Error("the Local VM lease expired while preparing the turn");
12025
12076
  }
12026
- integrations.localComputer = containerComputerMcp(vm.runtime, controlIntegration(readyBot.id, threadId, internalGeneration, target), target);
12077
+ integrations.localComputer = containerComputerMcp(vm.runtime, controlIntegration(readyBot.id, threadId, internalGeneration, { localVmTarget: target }), target);
12027
12078
  }
12028
12079
  }
12029
12080
  catch (error) {
12030
12081
  const place = roomPlan.computer;
12031
12082
  if (place !== "cloud" && place !== "vm" && place !== "local")
12032
12083
  throw error;
12033
- if (error instanceof DirectTurnSetupCancelled || error instanceof ComputerWaitParked || error instanceof PlaceUnavailableError)
12034
- throw error;
12035
- // A room row is not cut, so the cause keeps all its words.
12036
- throw placeUnavailable(place, "works-on", error instanceof Error ? error.message : String(error), Infinity);
12084
+ throw await asPlaceFailure(error, place, "room", readyBot, turnProvider(readyBot) === "vps" ? "vps" : "box");
12037
12085
  }
12038
12086
  const roster = readyGroup.memberIds
12039
12087
  .map((id) => store.bot(id))
@@ -12044,8 +12092,9 @@ setupRetry = 0) {
12044
12092
  // are who it cannot. The 1:1 prompt has carried a peer roster since #774,
12045
12093
  // and a room turn had nothing — the only advice it gave ("mention them
12046
12094
  // like @Name") sends the model after a teammate who will never see it.
12047
- // Same reachability rule as list_bots, minus the room's own members.
12048
- const outsideRoom = integrations.agents
12095
+ // Same reachability rule as list_bots, minus the room's own members, and
12096
+ // only on a turn that is not answering a room request.
12097
+ const outsideRoom = integrations.agents && orchestration && !orchestration.roomHandoffId
12049
12098
  ? reachablePeers(store.bots, bot).filter((peer) => !readyGroup.memberIds.includes(peer.id))
12050
12099
  : [];
12051
12100
  const system = [
@@ -12055,7 +12104,7 @@ setupRetry = 0) {
12055
12104
  `Room members: ${roster}, and ${userName} (the human).`,
12056
12105
  readyGroup.bulletin.trim() && `Room bulletin (shared instructions for everyone):\n${readyGroup.bulletin.trim()}`,
12057
12106
  `Reply as yourself, briefly and conversationally. To bring a teammate in, mention them like @Name — they'll see the conversation and respond.`,
12058
- outsideRoom.length > 0 && orchestration && !orchestration.roomHandoffId && roomPeerRosterSystemPrompt(outsideRoom),
12107
+ outsideRoom.length > 0 && roomPeerRosterSystemPrompt(outsideRoom),
12059
12108
  integrations.agents && (CREDENTIAL_PROMPT + (orchestration && !orchestration.roomHandoffId ? THREADS_PROMPT : "")).trim(),
12060
12109
  integrations.agents && (!orchestration || orchestration.roomHandoffId) && "For actual OpenMausBot teamwork, discover IDs with list_room_targets and use coordinate_bots for advice or work in this or another room. Do not substitute native coding helpers for these named bots. Consult only when needed to make a decision; no discussion step is mandatory. Give concrete responsibilities, exact accessible paths and acceptance checks. End your turn after assigning; busy teammates queue and results automatically resume you. When they return, finish the requested verification and give the user one final answer. Native helper names are not evidence that an OpenMausBot teammate participated. Plain @mentions are only for conversational replies in this room.",
12061
12110
  integrations.agents && ROUTINE_PROMPT.trim(),
@@ -12119,7 +12168,6 @@ setupRetry = 0) {
12119
12168
  const recentLines = recentWork(recentWorkSources(bot), bot, { userName, currentThreadId: threadId, ...recentWorkFilter() });
12120
12169
  const roomComputerPromptKind = resolveComputerPromptKind({
12121
12170
  kind: roomTeamComputer || roomComputerKind === "box" ? "box" : roomVmTarget ? "vm" : roomComputerKind,
12122
- driverKind: instance.driverKind,
12123
12171
  vmPrivate: localVmMode(cfg) === "per-bot",
12124
12172
  });
12125
12173
  {
@@ -12140,9 +12188,10 @@ setupRetry = 0) {
12140
12188
  { id: "computer", label: "Computer", text: computerPrompt(roomComputerPromptKind) },
12141
12189
  { id: "team-computer", label: "Team computer", text: teamComputerPrompt(roomTeamComputer) },
12142
12190
  { id: "plan", label: "Surface", text: surfacePrompt({ computer: roomTeamComputer ? "cloud" : roomVmTarget ? "vm" : surfaceOfComputerKind(roomComputerKind), browser: Boolean(integrations.browser) }, { note: roomPlan.note, cloudHome: Boolean(CLOUD_HOME) }) },
12143
- { id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
12191
+ { id: "cloud-home", label: "My Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
12144
12192
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
12145
12193
  { id: "recall", label: "Recall", text: integrations.agents && bot.memoryEnabled !== false ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
12194
+ teamAvailabilityPart(outsideRoom),
12146
12195
  { id: "recent", label: "Recent work", text: recentWorkPrompt(recentLines) },
12147
12196
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
12148
12197
  { id: "team-memory", label: "Team memory", text: teamMemory.systemPrompt(bot.section) + (integrations.agents ? TEAM_MEMORY_PROMPT : "") },
@@ -12232,13 +12281,10 @@ setupRetry = 0) {
12232
12281
  if (e.type === "item.completed" && e.itemType === "assistant_text")
12233
12282
  replyText += `\n${e.text}`;
12234
12283
  else if (e.type === "turn.completed") {
12235
- if (orchestration && !e.ok) {
12236
- orchestration.result.stopReason = e.stopReason ?? null;
12237
- finish("provider_failed");
12238
- }
12239
- else {
12240
- finish("settled");
12241
- }
12284
+ const end = roomTurnEnd(e, Boolean(orchestration), roomClaimFailure);
12285
+ if (orchestration && end.stopReason !== undefined)
12286
+ orchestration.result.stopReason = end.stopReason;
12287
+ finish(end.outcome);
12242
12288
  }
12243
12289
  // Waiting on a person is not turn work: hold the ceiling while an
12244
12290
  // approval or question card is open, so deciding slowly does not
@@ -12323,7 +12369,7 @@ setupRetry = 0) {
12323
12369
  role: "bot",
12324
12370
  kind: "activity",
12325
12371
  from: { botId: bot.id, name: bot.name, color: bot.color },
12326
- tool: { name: `error: ${message.slice(0, 140)}`, ok: false },
12372
+ tool: failedTurnTool(message),
12327
12373
  });
12328
12374
  onDispatchError?.(message);
12329
12375
  watchdog.settle(threadId);
@@ -12427,7 +12473,7 @@ setupRetry = 0) {
12427
12473
  drainSecretResumes();
12428
12474
  drainTeamSetupResumes();
12429
12475
  }
12430
- if (outcome === "provider_failed") {
12476
+ if (outcome === "provider_failed" || outcome === "parked") {
12431
12477
  if (skillAuthoring)
12432
12478
  skillAuthoringClaim.claimed = false;
12433
12479
  return false;
@@ -12516,7 +12562,7 @@ setupRetry = 0) {
12516
12562
  store.appendMessage(threadId, {
12517
12563
  role: "bot", kind: "activity",
12518
12564
  from: { botId: bot.id, name: bot.name, color: bot.color },
12519
- tool: { name: `error: ${message}`, ok: false },
12565
+ tool: failedTurnTool(message, error instanceof PlaceUnavailableError ? { place: error.row } : {}),
12520
12566
  });
12521
12567
  onDispatchError?.(message);
12522
12568
  if (orchestration) {
@@ -13125,10 +13171,7 @@ function drainQueuedChannelSends() {
13125
13171
  store.appendMessage(threadId, {
13126
13172
  role: "bot",
13127
13173
  kind: "activity",
13128
- tool: {
13129
- name: `error: queued channel message could not start — ${(error instanceof Error ? error.message : String(error)).slice(0, 120)}`,
13130
- ok: false,
13131
- },
13174
+ tool: failedTurnTool(`queued channel message could not start — ${error instanceof Error ? error.message : String(error)}`),
13132
13175
  });
13133
13176
  }
13134
13177
  // A message with no eligible responder creates no operation. Continue
@@ -13335,9 +13378,9 @@ function proposalPersistence(botId, threadId) {
13335
13378
  return { ok: true };
13336
13379
  // Only cards on the visible branch can be acted on from the composer.
13337
13380
  // Abandoned branches must not permanently consume the proposal quota.
13338
- // Routine, profile, default-model, tightening and team-setup proposals
13381
+ // Routine, profile, default-model and team-setup proposals
13339
13382
  // share one budget per bot per thread, so one thread cannot pile up 8 of each.
13340
- const openRequests = store.activePath(threadId).filter((message) => (message.card?.routineRequest?.botId === botId || message.card?.profileRequest?.botId === botId || message.card?.modelRequest?.botId === botId || message.card?.tighteningRequest?.botId === botId || message.card?.teamSetupRequest?.botId === botId) &&
13383
+ const openRequests = store.activePath(threadId).filter((message) => (message.card?.routineRequest?.botId === botId || message.card?.profileRequest?.botId === botId || message.card?.modelRequest?.botId === botId || message.card?.teamSetupRequest?.botId === botId) &&
13341
13384
  !message.card.answered &&
13342
13385
  !message.card.dismissed && !message.card.expired).length;
13343
13386
  return openRequests >= 8
@@ -14199,7 +14242,8 @@ function configStatus() {
14199
14242
  // not a saved key
14200
14243
  box: boat.describeBoatAccount(cfg),
14201
14244
  vps: { configured: Boolean(vpsSshAlias(cfg)), sshAlias: vpsSshAlias(cfg) ?? "" },
14202
- opencodeGo: { configured: Boolean(cfg.opencodeGo?.apiKey) },
14245
+ // names only: every provider key stays write-only
14246
+ opencodeGo: { configured: Boolean(cfg.opencodeGo?.apiKey), providerKeys: Object.keys(openCodeProviderKeys(cfg)) },
14203
14247
  // the chosen voice is a setting, not a secret; the key is reported the
14204
14248
  // same configured-or-not way as every other credential
14205
14249
  tts: tts.describeVoice(cfg),
@@ -14281,6 +14325,7 @@ function configForAccess(status, admin) {
14281
14325
  edition: { edition: status.edition.edition, features: status.edition.features },
14282
14326
  signIn: { admins: [], members: [] },
14283
14327
  vps: { configured: status.vps.configured, sshAlias: "" },
14328
+ opencodeGo: { configured: status.opencodeGo.configured, providerKeys: [] },
14284
14329
  profile: { name: status.profile.name, email: "" },
14285
14330
  browserProfiles: status.browserProfiles.map((profile) => Object.fromEntries(Object.entries(profile).filter(([key]) => key !== "partitionId"))),
14286
14331
  };
@@ -14332,6 +14377,10 @@ function persistMcpServers(next) {
14332
14377
  }
14333
14378
  async function describeInstances() {
14334
14379
  const configs = providerConfigs();
14380
+ // Reading the engines is where this server learns one became available
14381
+ // (an install, a sign-in, a key or a Company engine), so a bot still on the
14382
+ // removed Computer engine moves now rather than at the next start.
14383
+ void retryComputerEngineMove();
14335
14384
  return (await registry.describe()).map((instance) => {
14336
14385
  const entry = configs[instance.instanceId];
14337
14386
  const described = {
@@ -14508,7 +14557,7 @@ async function reloadProviders() {
14508
14557
  if (store.taskByThread(botId, threadId)) {
14509
14558
  store.appendMessage(threadId, {
14510
14559
  role: "bot", kind: "activity",
14511
- tool: { name: "error: turn interrupted — provider settings changed", ok: false },
14560
+ tool: failedTurnTool("turn interrupted — provider settings changed"),
14512
14561
  });
14513
14562
  store.setTaskActivity(botId, threadId, "idle");
14514
14563
  }
@@ -14611,7 +14660,7 @@ const workspaceBackupAccess = {
14611
14660
  !routines?.isTicking && !calendarCalls?.isTicking &&
14612
14661
  !localVmImageBusy && !localVmProvisionBusy && !localVmModeChangeBusy &&
14613
14662
  !localVmLifecycleBusy.size && !boatLifecycleBusyBots.size && !vpsPreviewRequests.size && !orphanBoatLifecycleBusyIds.size &&
14614
- !computerProviderConfigTransitions.size && !checkpointRestoreLeases.size &&
14663
+ !computerProviderConfigTransitions.size &&
14615
14664
  teamComputers.list().every(computer => !teamComputerInUse(computer)) &&
14616
14665
  store.bots.every((bot) => !botHasActiveTurn(bot.id) && !routines?.activeRunForBot(bot.id) && !botComputerControlSnapshot(bot.id).held) &&
14617
14666
  store.groups.every((group) => !groupIsWorking(group)),
@@ -14634,18 +14683,24 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
14634
14683
  restored: workspaceRestore,
14635
14684
  ...workspaceBackupAccess,
14636
14685
  });
14637
- // Move to Cloud (server/cloud-move-http.ts): every server sizes its own
14638
- // workspace for the desktop; only a Cloud home receives one. After its
14639
- // restore commits, the Cloud home restarts so startup installs it.
14640
- let cloudHomeRestartRequested = false;
14686
+ // Copy this computer here (server/cloud-move-http.ts, docs/copy-workspace.md):
14687
+ // every server sizes its own workspace for its desktop, and every server the
14688
+ // person owns receives one, a Cloud home included. A workspace shared with
14689
+ // other people never does (workspaceShared): a hosted organisation
14690
+ // workspace, or a server whose email sign-in lets someone besides its owner
14691
+ // in. After the restore commits, the server
14692
+ // exits with RESTART_EXIT_CODE and its launcher starts it again, so startup
14693
+ // installs it (server/restart.ts).
14694
+ let restartRequested = false;
14641
14695
  const cloudMoveRoutes = createCloudMoveRoutes({
14642
14696
  dataDir: DATA_DIR,
14643
14697
  appVersion: serverVersion(),
14644
- cloudHome: Boolean(CLOUD_HOME),
14698
+ environmentId: ENVIRONMENT_ID,
14699
+ sharedWorkspace: () => workspaceShared({ hosted: HOSTED_WORKSPACE, cloudHome: Boolean(CLOUD_HOME), signIn: signInAllowList() }),
14645
14700
  readBody,
14646
14701
  restored: workspaceRestore,
14647
14702
  ...workspaceBackupAccess,
14648
- restart: () => { cloudHomeRestartRequested = true; gracefulShutdown(); },
14703
+ restart: () => { restartRequested = true; gracefulShutdown(); },
14649
14704
  });
14650
14705
  // Route modules (server/routes/README.md). `workspaceAccess` is assigned at
14651
14706
  // boot, after this line, so the dependency reads it per request.
@@ -14754,7 +14809,7 @@ const liveCalls = new LiveCallController({
14754
14809
  },
14755
14810
  broadcast: (frame) => broadcast(frame, { adminOnly: true }),
14756
14811
  settings: () => ({ key: cfg.live?.key ?? "", ...liveSettingsFor(cfg) }),
14757
- createSession: ({ key, sdp, botId, threadId, voice }) => createLiveSession({ key, sdp, voice, bot: liveBotFor(botId), history: liveHistoryFor(threadId) }),
14812
+ createSession: ({ key, sdp, botId, threadId, voice }) => createLiveSession({ key, sdp, voice, bot: liveBotFor(botId), history: liveHistoryFor(threadId), cloudHome: Boolean(CLOUD_HOME) }),
14758
14813
  // Node's WebSocket (undici) accepts headers in its second argument.
14759
14814
  openSocket: (url, key) => new WebSocket(url, { headers: { authorization: `Bearer ${key}` } }),
14760
14815
  attachUrl: (sessionId) => liveAttachUrl(sessionId),
@@ -15135,9 +15190,7 @@ const handleRequest = async (req, res) => {
15135
15190
  // scheme the native companion scanners accept; it carries the
15136
15191
  // credential encoding because those scanners cannot take a typed code.
15137
15192
  const serverName = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED }).label;
15138
- const invite = base
15139
- ? `openmausbot://pair?address=${encodeURIComponent(base)}&token=${encodeURIComponent(opened.credential)}&name=${encodeURIComponent(serverName)}`
15140
- : null;
15193
+ const invite = base ? phonePairingLink({ address: base, token: opened.credential, name: serverName }) : null;
15141
15194
  return json(res, 200, {
15142
15195
  id: opened.id,
15143
15196
  code,
@@ -15251,7 +15304,7 @@ const handleRequest = async (req, res) => {
15251
15304
  // A Cloud home is one person's: only their own devices, which the
15252
15305
  // Admin's pairing signs in with admin scope, may lend to it.
15253
15306
  if (CLOUD_HOME && !auth.scopes.includes("admin"))
15254
- 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." });
15307
+ 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." });
15255
15308
  sharedComputers.register(registration, { session: auth.session.id, person: CLOUD_HOME ? CLOUD_HOME_LENDER : personKey(auth.session) }, secret);
15256
15309
  return json(res, 200, { ok: true });
15257
15310
  }
@@ -15401,6 +15454,9 @@ const handleRequest = async (req, res) => {
15401
15454
  throw Object.assign(new Error("the internal turn capability has expired"), { status: 401 });
15402
15455
  }
15403
15456
  };
15457
+ const delegatedThisTurn = (taskId) => {
15458
+ (internalCapability.delegatedThisTurn ??= new Set()).add(taskId);
15459
+ };
15404
15460
  // Where an entry came from, as the person will read it in MEMORY.md:
15405
15461
  // the room or the thread title, never a bare id unless nothing else
15406
15462
  // names the conversation.
@@ -15431,9 +15487,13 @@ const handleRequest = async (req, res) => {
15431
15487
  return json(res, 409, { error: "The user request ended before its computer was selected." });
15432
15488
  if (unoffered)
15433
15489
  return json(res, 409, { error: unoffered, options });
15434
- const option = requested === "auto"
15435
- ? 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)
15436
- : options.find(option => option.surface === requested);
15490
+ // The computer this turn mounted is already selected, even before it
15491
+ // is up when its first call starts it (the bot's cloud computer):
15492
+ // switching to it would only restart the request.
15493
+ const claim = autoVmClaims.get(internalCapability.threadId);
15494
+ const startsOnFirstCall = claim?.owner.generation === internalCapability.generation && claim.startsComputer === true && !claim.failed;
15495
+ const picked = pickComputer(options, requested, current, startsOnFirstCall);
15496
+ const option = picked.option;
15437
15497
  if (!option?.available)
15438
15498
  return json(res, 409, { error: option?.reason ?? "No configured computer or browser is available. Open the Computer panel to set one up.", options });
15439
15499
  if (source.selected) {
@@ -15441,8 +15501,11 @@ const handleRequest = async (req, res) => {
15441
15501
  return json(res, 409, { error: "A computer switch is already pending. End this turn to continue there." });
15442
15502
  }
15443
15503
  else {
15444
- if (option.surface === current && option.ready)
15445
- return json(res, 200, { status: "ready", surface: current, message: "This computer is already selected. Use its mounted tools." });
15504
+ if (picked.current) {
15505
+ return json(res, 200, { status: "ready", surface: current, message: option.ready
15506
+ ? "This computer is already selected. Use its mounted tools."
15507
+ : "This computer is already selected. It starts on its first call; use its mounted tools." });
15508
+ }
15446
15509
  source.selected = option.surface;
15447
15510
  source.previousSurface = store.taskByThread(bot.id, bot.threadId)?.surface;
15448
15511
  }
@@ -15544,17 +15607,19 @@ const handleRequest = async (req, res) => {
15544
15607
  return json(res, 200, { result });
15545
15608
  }
15546
15609
  // The cloud computer's tools (harness-mcp-proxy computer): the turn's
15547
- // capability names its Boat, and the Boat credential stays here.
15610
+ // capability names its Boat, and the Boat credential stays here. A
15611
+ // capability minted before the Boat existed (boxId null) acts on the
15612
+ // one its first call's claim created or woke (mountBotBoat).
15548
15613
  if (method === "POST" && path === "/api/internal/computer/mcp") {
15549
15614
  const body = await readInternalBody();
15550
- const boxId = internalCapability.boxId;
15551
- if (!boxId)
15615
+ if (internalCapability.boxId === undefined)
15552
15616
  return json(res, 403, { error: "this turn has no cloud computer" });
15553
15617
  const abort = new AbortController();
15554
15618
  res.once("close", () => { if (!res.writableEnded)
15555
15619
  abort.abort(); });
15556
15620
  return json(res, 200, { result: await cloudComputerRpc(body, {
15557
- cfg, boxId, signal: abort.signal,
15621
+ cfg, signal: abort.signal,
15622
+ boxId: () => internalCapability.boxId ?? claimedBoatId(internalCapability),
15558
15623
  gate: () => computerCallGate(internalCapability),
15559
15624
  assertActive: requireActiveInternalCapability,
15560
15625
  }) });
@@ -16057,39 +16122,6 @@ const handleRequest = async (req, res) => {
16057
16122
  });
16058
16123
  return json(res, 201, proposed);
16059
16124
  }
16060
- if (method === "POST" && path === "/api/internal/tightening-requests") {
16061
- const parsed = z.object({
16062
- fromBotId: z.string().min(1).max(128),
16063
- fromThreadId: z.string().min(1).max(128),
16064
- forBotId: z.string().max(128).optional(),
16065
- intents: z.unknown(),
16066
- reason: z.unknown(),
16067
- }).strict().safeParse(await readInternalBody());
16068
- if (!parsed.success)
16069
- return json(res, 400, { error: "invalid tightening proposal" });
16070
- const body = parsed.data;
16071
- const from = store.bot(body.fromBotId);
16072
- if (!from)
16073
- return json(res, 403, { error: "unknown sender" });
16074
- const owner = connectorThread(from.id, body.fromThreadId);
16075
- if (!owner)
16076
- return json(res, 403, { error: "source conversation does not belong to sender" });
16077
- const targetBotId = body.forBotId?.trim() || from.id;
16078
- const proposed = tighteningRequests.submit({
16079
- botId: from.id,
16080
- threadId: body.fromThreadId,
16081
- targetBotId,
16082
- intents: body.intents,
16083
- reason: body.reason,
16084
- from: owner.group ? { botId: from.id, name: from.name, color: from.color } : undefined,
16085
- });
16086
- appendDecision(DATA_DIR, {
16087
- threadId: body.fromThreadId, requestId: proposed.requestId, botId: from.id, botName: from.name,
16088
- tool: "tighten_permissions", summary: proposed.detail, decision: proposed.state === "applied" ? "auto-approved" : "card-shown",
16089
- source: proposed.state === "applied" ? "full-access" : "tightening",
16090
- });
16091
- return json(res, 201, proposed);
16092
- }
16093
16125
  if (method === "GET" && path === "/api/internal/team-setup-catalog") {
16094
16126
  let chief = store.bot(internalCapability.botId);
16095
16127
  if (!chief.chiefOfStaff || chief.hidden)
@@ -16428,7 +16460,8 @@ const handleRequest = async (req, res) => {
16428
16460
  const guestThread = guestWorkThread(from, target, fromThreadId, message);
16429
16461
  if (guestThread === null)
16430
16462
  return json(res, 500, { error: "couldn't open a conversation for that work" });
16431
- const queued = queueDelegation(commsBus, from, { toBotId, message, reason: "asked while busy", depth, approvalAlreadyGranted, ...(guestThread ? { targetThreadId: guestThread } : {}) }, MAX_COMMS_DEPTH, fromThreadId);
16463
+ const queued = queueDelegation(commsBus, from, { toBotId, message, reason: "asked while busy", depth, approvalAlreadyGranted, ...(guestThread ? { targetThreadId: guestThread } : {}),
16464
+ ...externalCompletion(internalCapability) }, MAX_COMMS_DEPTH, fromThreadId);
16432
16465
  if ((queued.result !== "ok" || !queued.id) && guestThread)
16433
16466
  store.deleteTask(toBotId, guestThread);
16434
16467
  if (queued.result !== "ok" || !queued.id)
@@ -16442,6 +16475,7 @@ const handleRequest = async (req, res) => {
16442
16475
  if (internalCapability.generation === EXTERNAL_RUNTIME_GENERATION && !threadBusy(from.id, fromThreadId)) {
16443
16476
  drainThreadDelegations(fromThreadId);
16444
16477
  }
16478
+ delegatedThisTurn(queued.id);
16445
16479
  return json(res, 200, { busy: true, taskId: queued.id, toBotName: target.name, receipt: peerDeliveryReceipt({
16446
16480
  botId: toBotId, botName: target.name, outcome: "queued", taskId: queued.id,
16447
16481
  detail: "the teammate was busy; the ask was queued as a delegation instead",
@@ -16595,12 +16629,14 @@ const handleRequest = async (req, res) => {
16595
16629
  sourceThreadId: fromThreadId,
16596
16630
  sourceBotId: currentFrom.id,
16597
16631
  routineRunId: activeRoutineRunForThread(fromThreadId)?.id,
16632
+ ...externalCompletion(internalCapability),
16598
16633
  });
16599
16634
  store.appendMessage(fromThreadId, {
16600
16635
  role: "bot",
16601
16636
  kind: "activity",
16602
16637
  tool: { name: `@${currentTarget.name} is still working — ask converted to a delegation` },
16603
16638
  });
16639
+ delegatedThisTurn(taskId);
16604
16640
  return json(res, 200, { timeout: true, taskId, toBotName: currentTarget.name, waitedMs: ASK_BOT_TIMEOUT_MS,
16605
16641
  receipt: peerDeliveryReceipt({ botId: toBotId, botName: currentTarget.name, outcome: "injected", taskId,
16606
16642
  detail: "the teammate's turn started and is still running; only the synchronous wait ended" }) });
@@ -16639,6 +16675,9 @@ const handleRequest = async (req, res) => {
16639
16675
  const delegationMatch = method === "GET" ? path.match(/^\/api\/internal\/delegations\/([\w-]{4,64})$/) : null;
16640
16676
  if (delegationMatch) {
16641
16677
  const taskId = delegationMatch[1];
16678
+ if (internalCapability.delegatedThisTurn?.has(taskId)) {
16679
+ return json(res, 409, { error: `Task ${taskId} was delegated during this turn. Finish your response now so the other bot can work; its result will be delivered to this conversation automatically. Do not check or wait for a newly delegated task until a later turn.` });
16680
+ }
16642
16681
  const fromThreadId = internalCapability.threadId;
16643
16682
  const from = internalSender;
16644
16683
  if (!connectorThread(from.id, fromThreadId))
@@ -16680,7 +16719,10 @@ const handleRequest = async (req, res) => {
16680
16719
  if (!sender || (target && !canReachPeer(sender, target))) {
16681
16720
  return json(res, 403, { error: "Result withheld: team access changed while the teammate was working" });
16682
16721
  }
16683
- if (Date.now() >= deadline) {
16722
+ // Nothing moves while the teammate's card waits on the person, so
16723
+ // say so now rather than holding the long-poll to its deadline.
16724
+ const awaitingPerson = running && runningEntry ? openPersonCard(runningEntry[0]) : undefined;
16725
+ if (Date.now() >= deadline || awaitingPerson) {
16684
16726
  if (running && runningEntry) {
16685
16727
  const recent = summarizeDelegatedActivity(store.messagesFor(runningEntry[0]), running.startedAtMs ?? Date.now());
16686
16728
  return json(res, 200, {
@@ -16688,6 +16730,12 @@ const handleRequest = async (req, res) => {
16688
16730
  toBotName: store.bot(toBotId)?.name ?? toBotId,
16689
16731
  elapsedMs: Math.max(0, Date.now() - (running.startedAtMs ?? Date.now())),
16690
16732
  recentActivity: recent,
16733
+ ...(awaitingPerson ? {
16734
+ awaitingPerson: {
16735
+ ...awaitingPerson,
16736
+ threadTitle: store.taskByThread(toBotId, runningEntry[0])?.title,
16737
+ },
16738
+ } : {}),
16691
16739
  });
16692
16740
  }
16693
16741
  const queuedTarget = store.bot(toBotId);
@@ -16786,7 +16834,7 @@ const handleRequest = async (req, res) => {
16786
16834
  const guestThread = guestWorkThread(from, target, fromThreadId, reason ?? message);
16787
16835
  if (guestThread === null)
16788
16836
  return json(res, 500, { error: "couldn't open a conversation for that work" });
16789
- const queued = queueDelegation(commsBus, from, { toBotId, message, reason, depth, ...(guestThread ? { targetThreadId: guestThread } : {}) }, MAX_COMMS_DEPTH, fromThreadId);
16837
+ const queued = queueDelegation(commsBus, from, { toBotId, message, reason, depth, ...(guestThread ? { targetThreadId: guestThread } : {}), ...externalCompletion(internalCapability) }, MAX_COMMS_DEPTH, fromThreadId);
16790
16838
  if ((queued.result !== "ok" || !queued.id) && guestThread)
16791
16839
  store.deleteTask(toBotId, guestThread);
16792
16840
  if (queued.result !== "ok" || !queued.id) {
@@ -16804,6 +16852,7 @@ const handleRequest = async (req, res) => {
16804
16852
  detail: `${refusal} — nothing was queued`,
16805
16853
  }) });
16806
16854
  }
16855
+ delegatedThisTurn(queued.id);
16807
16856
  const targetName = store.bot(toBotId)?.name ?? toBotId;
16808
16857
  // The queue normally drains when the source thread's turn completes. A
16809
16858
  // caller this server did not spawn (an external runtime) has no live
@@ -17032,6 +17081,12 @@ const handleRequest = async (req, res) => {
17032
17081
  if (!groupId || !message) {
17033
17082
  return json(res, 400, { error: "post_to_room needs group_id (from list_rooms) and message" });
17034
17083
  }
17084
+ // One turn's worth of posts is a handful; past it the turn has
17085
+ // stopped reporting and started broadcasting. Counted per turn, on
17086
+ // top of the room's own budget below.
17087
+ if ((internalCapability.roomPosts ?? 0) >= MAX_ROOM_POSTS_PER_TURN) {
17088
+ return json(res, 429, { error: `You have already posted ${MAX_ROOM_POSTS_PER_TURN} times this turn, which is the limit. Do not retry — finish your turn and say anything further to the user directly.` });
17089
+ }
17035
17090
  if (message.length > ROOM_POST_MAX_CHARS) {
17036
17091
  return json(res, 400, {
17037
17092
  error: `a room post is at most ${ROOM_POST_MAX_CHARS} characters — post the short version and keep the detail in your own reply`,
@@ -17160,6 +17215,7 @@ const handleRequest = async (req, res) => {
17160
17215
  peerPost: unattended ? { unattended: true } : {},
17161
17216
  ...(attachedVoiceNote ? { attachments: stagedAttachments } : {}),
17162
17217
  });
17218
+ internalCapability.roomPosts = (internalCapability.roomPosts ?? 0) + 1;
17163
17219
  detachParkedAudio?.();
17164
17220
  store.patchGroup(room.id, { unread: true });
17165
17221
  // The same visibility contract the peer tools keep: whatever a bot
@@ -17203,7 +17259,7 @@ const handleRequest = async (req, res) => {
17203
17259
  if (!fitsOnOneLine(title))
17204
17260
  return json(res, 400, { error: "title must fit on one line" });
17205
17261
  if (internalCapability.openedThreads >= MAX_THREADS_OPENED_PER_TURN) {
17206
- return json(res, 429, { error: `you can open at most ${MAX_THREADS_OPENED_PER_TURN} threads in one turn` });
17262
+ return json(res, 429, { error: `You have already opened ${MAX_THREADS_OPENED_PER_TURN} threads this turn, which is the limit. Do not retry — finish your turn and tell the person which threads you still wanted to open, so they can open them or ask you again.` });
17207
17263
  }
17208
17264
  const toBotId = typeof body.toBotId === "string" && body.toBotId.trim() ? body.toBotId.trim() : from.id;
17209
17265
  const target = store.bot(toBotId);
@@ -17302,6 +17358,7 @@ const handleRequest = async (req, res) => {
17302
17358
  }
17303
17359
  store.setTaskOpenedBy(target.id, task.threadId, { botId: from.id, name: from.name, delegationId: queued.id, at: task.openedBy?.at ?? Date.now() });
17304
17360
  internalCapability.openedThreads += 1;
17361
+ delegatedThisTurn(queued.id);
17305
17362
  // An honest forecast, not a promise: the handoff starts when this
17306
17363
  // turn ends, and by then the target's slots are taken by whatever is
17307
17364
  // running there plus the threads this turn already opened ahead.
@@ -17331,8 +17388,8 @@ const handleRequest = async (req, res) => {
17331
17388
  if (!chief.chiefOfStaff) {
17332
17389
  return json(res, 403, { error: "only a section's Chief of Staff can create operator bots" });
17333
17390
  }
17334
- if (internalCapability.createdBots >= 4) {
17335
- return json(res, 429, { error: "you can create at most 4 bots in one turn" });
17391
+ if (internalCapability.createdBots >= MAX_CREATED_BOTS_PER_TURN) {
17392
+ return json(res, 429, { error: `You can create at most ${MAX_CREATED_BOTS_PER_TURN} bots in one turn. Use the team you have before adding more.` });
17336
17393
  }
17337
17394
  if (store.bots.length >= MAX_WORKSPACE_BOTS) {
17338
17395
  return json(res, 409, { error: `this workspace is limited to ${MAX_WORKSPACE_BOTS} bots` });
@@ -17381,8 +17438,9 @@ const handleRequest = async (req, res) => {
17381
17438
  if (store.bot(chief.id) !== chief || chief.hidden || !chief.chiefOfStaff || !connectorThread(chief.id, fromThreadId)) {
17382
17439
  return json(res, 403, { error: "only an active Chief of Staff can create operator bots" });
17383
17440
  }
17384
- if (internalCapability.createdBots >= 4)
17385
- return json(res, 429, { error: "you can create at most 4 bots in one turn" });
17441
+ if (internalCapability.createdBots >= MAX_CREATED_BOTS_PER_TURN) {
17442
+ return json(res, 429, { error: `You can create at most ${MAX_CREATED_BOTS_PER_TURN} bots in one turn. Use the team you have before adding more.` });
17443
+ }
17386
17444
  if (store.bots.length >= MAX_WORKSPACE_BOTS)
17387
17445
  return json(res, 409, { error: `this workspace is limited to ${MAX_WORKSPACE_BOTS} bots` });
17388
17446
  const duplicate = store.bots.find((candidate) => !candidate.hidden &&
@@ -17501,6 +17559,36 @@ const handleRequest = async (req, res) => {
17501
17559
  const updated = updateChannel(room.id, patch);
17502
17560
  return json(res, 200, { ok: true, memberIds: updated.memberIds, memberCount: updated.memberIds.length, message: `Updated room “${updated.name}”.` });
17503
17561
  }
17562
+ if (method === "POST" && path === "/api/internal/mcp-servers") {
17563
+ // Same single-flight lock as POST /api/mcp/servers: two writers must
17564
+ // not interleave a read of cfg.mcpServers with a save.
17565
+ if (mcpConfigBusy)
17566
+ return json(res, 409, { error: "MCP servers are already being updated." });
17567
+ mcpConfigBusy = true;
17568
+ try {
17569
+ const body = await readInternalBody();
17570
+ const added = addDisabledMcpServer(cfg.mcpServers ?? {}, body);
17571
+ if (!added.ok)
17572
+ return json(res, added.status, { error: added.error });
17573
+ const refusal = mcpPolicyRefusal(added.name, added.server);
17574
+ if (refusal)
17575
+ return json(res, 403, { error: refusal, code: "managed_policy" });
17576
+ persistMcpServers(added.next);
17577
+ const server = added.server;
17578
+ const remote = isRemoteMcpServer(server);
17579
+ return json(res, 201, {
17580
+ name: added.name,
17581
+ enabled: false,
17582
+ transport: remote ? server.type : "command",
17583
+ target: remote ? server.url : server.command,
17584
+ envKeys: remote ? [] : Object.keys(server.env).sort(),
17585
+ headerKeys: remote ? Object.keys(server.headers).sort() : [],
17586
+ });
17587
+ }
17588
+ finally {
17589
+ mcpConfigBusy = false;
17590
+ }
17591
+ }
17504
17592
  if (method === "POST" && path === "/api/internal/request-credential") {
17505
17593
  const body = await readInternalBody();
17506
17594
  const from = internalSender;
@@ -17726,8 +17814,15 @@ const handleRequest = async (req, res) => {
17726
17814
  const policy = currentSender.outbound ?? DEFAULT_OUTBOUND_POLICY;
17727
17815
  const threadId = internalCapability.threadId;
17728
17816
  const { app } = describeTool(outboundTool);
17729
- const summary = outboundCalls.map((call) => {
17817
+ // One entry per call, in the summary's order, so a phone can say
17818
+ // "Send to Linear? Create linear comment ×2" without parsing the
17819
+ // raw arguments out of the subtitle.
17820
+ const calls = outboundCalls.map((call) => {
17730
17821
  const described = describeTool(call.slug);
17822
+ return { app: described.app, label: described.label };
17823
+ });
17824
+ const summary = outboundCalls.map((call, index) => {
17825
+ const described = calls[index];
17731
17826
  const argsText = call.arguments === undefined ? "" : JSON.stringify(call.arguments);
17732
17827
  return `${described.app ? `${described.app} · ` : ""}${described.label}${argsText ? `\n${argsText.slice(0, 400)}${argsText.length > 400 ? "… [arguments truncated]" : ""}` : ""}`;
17733
17828
  }).join("\n\n");
@@ -17757,7 +17852,7 @@ const handleRequest = async (req, res) => {
17757
17852
  tool: outboundTool,
17758
17853
  held: HELD_NOTE["approval.held.outbound"],
17759
17854
  heldCode: "approval.held.outbound",
17760
- outboundRequest: { tool: outboundTool, app },
17855
+ outboundRequest: { tool: outboundTool, app, calls },
17761
17856
  },
17762
17857
  });
17763
17858
  appendDecision(DATA_DIR, {
@@ -17852,7 +17947,8 @@ const handleRequest = async (req, res) => {
17852
17947
  if (upstream.transportSessionId)
17853
17948
  headers["mcp-session-id"] = upstream.transportSessionId;
17854
17949
  res.writeHead(upstream.status, headers);
17855
- return res.end(Buffer.from(upstream.bytes));
17950
+ // A missing OAuth permission gets a note on what fixes it (MOCA-273).
17951
+ return res.end(Buffer.from(withScopeHint(upstream.bytes, upstream.contentType, connectorCalls.map((call) => call.slug))));
17856
17952
  }
17857
17953
  // ── computer control: proxies read the hold, bots plead for help ──
17858
17954
  if (path === "/api/internal/computer-control") {
@@ -18353,12 +18449,10 @@ const handleRequest = async (req, res) => {
18353
18449
  const limit = pageSize(url.searchParams.get("messages"));
18354
18450
  if (limit === null)
18355
18451
  return json(res, 400, { error: "messages must be a non-negative whole number" });
18356
- // wireBot(), not publicBot(): publicBot() pulls the whole transcript via
18357
- // messagesFor() just to have it overwritten below by messagePage(),
18358
- // which — for a bounded request — never needs the full transcript.
18359
- // tasks stays explicit, because that is the one field publicBot() adds
18360
- // that messagePage() does not: wireBot() omits the key entirely for a
18361
- // bot record carrying no tasks, where publicBot() always sent [].
18452
+ // wireBot(), not publicBot(): this snapshot answers with the page it
18453
+ // was asked for, not the default one. tasks stays explicit, as in
18454
+ // publicBot(): wireBot() omits the key entirely for a bot record
18455
+ // carrying no tasks, where clients expect [].
18362
18456
  // A member on a workspace with a restricted bot gets only what they may
18363
18457
  // see (bot-visibility.ts); for everyone else these filters keep all.
18364
18458
  const shownBots = store.bots.filter((bot) => visible.bot(bot.id));
@@ -18382,47 +18476,6 @@ const handleRequest = async (req, res) => {
18382
18476
  });
18383
18477
  }
18384
18478
  // scrollback: the page before a message the client already holds
18385
- // #1655: the consent card's answer, per conversation. Explicit and
18386
- // revocable; a thread allowlisted in config carries the same standing
18387
- // consent without ever seeing a card.
18388
- m = path.match(/^\/api\/threads\/([\w-]+)\/cloud-overflow$/);
18389
- if (m && method === "POST") {
18390
- const threadId = m[1];
18391
- if (!store.botByThread(threadId))
18392
- return json(res, 404, { error: "no such conversation" });
18393
- const body = await readBody(req);
18394
- if (!body || typeof body !== "object" || Array.isArray(body))
18395
- return json(res, 400, { error: "body must be a JSON object" });
18396
- if (typeof body.consent !== "boolean")
18397
- return json(res, 400, { error: "consent must be a boolean" });
18398
- if (body.consent) {
18399
- // Consent answers the priced card, never a guess: without a prior
18400
- // offer the rate was never shown, so a seat must not start yet.
18401
- // The grant binds to the rate the card showed (#1655): a config
18402
- // change re-offers instead of starting at a new price silently.
18403
- const offeredRate = cloudOverflowConsent.offeredRate(threadId);
18404
- if (offeredRate === null)
18405
- return json(res, 409, { error: "no cloud overflow offer" });
18406
- cloudOverflowConsent.grant(threadId, offeredRate);
18407
- }
18408
- else {
18409
- cloudOverflowConsent.revoke(threadId);
18410
- // Revocation must reach the machine (#1655): the ledger alone
18411
- // would leave this conversation's seat billing to its idle
18412
- // timeout. A start still waking is caught by its own revoke
18413
- // check once readyBox resolves.
18414
- const seatBot = store.botByThread(threadId);
18415
- const lease = seatBot ? cloudSeatLeases.get(seatBot.id) : undefined;
18416
- if (seatBot && lease && lease.threadId === threadId) {
18417
- stopCloudSeat(seatBot.id, lease, "Cloud computer stopped because overflow consent was revoked — billing pauses while it sleeps.");
18418
- }
18419
- }
18420
- store.appendMessage(threadId, {
18421
- role: "bot", kind: "activity",
18422
- tool: { name: cloudOverflowConsentText(body.consent, cloudOverflowIdleStopMs(cfg)), ok: true },
18423
- });
18424
- return json(res, 200, { consented: body.consent });
18425
- }
18426
18479
  m = path.match(/^\/api\/threads\/([\w-]+)\/messages$/);
18427
18480
  if (m && method === "GET") {
18428
18481
  const threadId = m[1];
@@ -19172,8 +19225,11 @@ const handleRequest = async (req, res) => {
19172
19225
  return json(res, 400, { error: "Import backups alongside your existing bots; project mode is only for templates" });
19173
19226
  try {
19174
19227
  const imported = importTeamBackup(store, routines, body, await defaultSelection(), { visibility: importVisibility });
19228
+ // A restore writes its transcripts without store events, after the
19229
+ // store's own frames announced each bot and room, so these frames
19230
+ // are what carries the restored pages to other clients.
19175
19231
  const bots = imported.bots.map((bot) => publicBot(bot));
19176
- const groups = imported.groups.map((group) => ({ ...publicGroupState(group), ...messagePage(group.threadId, undefined) }));
19232
+ const groups = imported.groups.map((group) => groupWithThread(group));
19177
19233
  for (const bot of bots)
19178
19234
  broadcast({ kind: "bot", bot });
19179
19235
  for (const group of groups)
@@ -19323,9 +19379,7 @@ const handleRequest = async (req, res) => {
19323
19379
  // Who opened it decides who may answer its cards on a shared workspace.
19324
19380
  if (auth.kind === "session")
19325
19381
  threadStarters.set(task.threadId, actorKey(auth));
19326
- const fresh = groupWithThread(store.group(group.id));
19327
- broadcast({ kind: "group", group: fresh });
19328
- return json(res, 201, { group: fresh, task });
19382
+ return json(res, 201, { group: groupWithThread(store.group(group.id)), task });
19329
19383
  }
19330
19384
  m = path.match(/^\/api\/groups\/([\w-]+)\/tasks\/([\w-]+)$/);
19331
19385
  if (m && method === "POST") {
@@ -19350,15 +19404,6 @@ const handleRequest = async (req, res) => {
19350
19404
  if (!switched)
19351
19405
  return json(res, 404, { error: "no such channel task" });
19352
19406
  const switchedSettings = { ...publicGroupState(switched), tasks: store.groupTasks(switched.id) };
19353
- // The frame says the channel moved; it is not a transcript delivery.
19354
- // A long room's whole history over SSE is the payload `?messages=`
19355
- // exists to avoid, and it would also overwrite a bounded snapshot on
19356
- // every other client. One default page is enough to render the switch;
19357
- // anything earlier pages back through /api/threads/:id/messages.
19358
- broadcast({
19359
- kind: "group",
19360
- group: { ...switchedSettings, ...messagePage(switched.threadId, DEFAULT_PAGE) },
19361
- });
19362
19407
  // "0" predates paging and means settings only — no `messages` key at
19363
19408
  // all, which clients tell apart from an empty page. A positive page is
19364
19409
  // the transcript a client can actually hold; omitting the parameter
@@ -19366,9 +19411,7 @@ const handleRequest = async (req, res) => {
19366
19411
  // materialises it.
19367
19412
  const responseGroup = requestedMessages === "0"
19368
19413
  ? switchedSettings
19369
- : switchLimit === undefined
19370
- ? groupWithThread(switched)
19371
- : { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
19414
+ : { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
19372
19415
  return json(res, 200, { group: responseGroup });
19373
19416
  }
19374
19417
  if (m && method === "PATCH") {
@@ -19441,14 +19484,12 @@ const handleRequest = async (req, res) => {
19441
19484
  lastReply.delete(m[2]);
19442
19485
  cancelTeamSetupResumesForThread(m[2]);
19443
19486
  const updated = store.deleteGroupTask(group.id, m[2]);
19444
- if (updated)
19445
- clearTurnDigestState(m[2]);
19446
19487
  if (!updated)
19447
19488
  return json(res, 400, { error: "a channel keeps at least one task" });
19489
+ clearTurnDigestState(m[2]);
19490
+ revokeInternalCapabilitiesForThread(m[2]);
19448
19491
  rejectDeletedThreadSkillStages(stagedSkillCleanups);
19449
- const fresh = groupWithThread(updated);
19450
- broadcast({ kind: "group", group: fresh });
19451
- return json(res, 200, { group: fresh });
19492
+ return json(res, 200, { group: groupWithThread(updated) });
19452
19493
  }
19453
19494
  m = path.match(/^\/api\/groups\/([\w-]+)$/);
19454
19495
  if (m && method === "PATCH") {
@@ -21412,54 +21453,6 @@ const handleRequest = async (req, res) => {
21412
21453
  broadcast({ kind: "bot", bot: visible });
21413
21454
  return json(res, 200, { bot: visible });
21414
21455
  }
21415
- // ── workspace checkpoints: per-turn shadow-git snapshots ────────────
21416
- // The list endpoint is the source of truth (turns store nothing), and
21417
- // `enabled` tells the UI whether snapshots can happen here at all —
21418
- // false for refused folders (home, Desktop…), a missing git, or a bot
21419
- // whose checkpoints failed earlier this session.
21420
- m = path.match(/^\/api\/bots\/([\w-]+)\/checkpoints$/);
21421
- if (m && method === "GET") {
21422
- if (!store.bot(m[1]))
21423
- return json(res, 404, { error: "no such bot" });
21424
- const cwd = url.searchParams.get("cwd") ?? "";
21425
- if (!cwd.trim())
21426
- return json(res, 400, { error: "cwd query parameter required" });
21427
- return json(res, 200, {
21428
- checkpoints: await checkpoints.listCheckpoints(m[1], cwd),
21429
- enabled: await checkpoints.checkpointsEnabled(m[1], cwd),
21430
- });
21431
- }
21432
- m = path.match(/^\/api\/bots\/([\w-]+)\/checkpoints\/restore$/);
21433
- if (m && method === "POST") {
21434
- const bot = store.bot(m[1]);
21435
- if (!bot)
21436
- return json(res, 404, { error: "no such bot" });
21437
- const parsed = z
21438
- .object({ cwd: z.string().min(1), hash: z.string().regex(/^[0-9a-f]{40}$/) })
21439
- .safeParse(await readBody(req));
21440
- if (!parsed.success) {
21441
- return json(res, 400, { error: "cwd (absolute path) and hash (full 40-character checkpoint hash) required" });
21442
- }
21443
- // Claim synchronously with the busy check. startTurn checks the same
21444
- // lease before reserving the bot, so no turn can enter during the
21445
- // awaited Git operation.
21446
- if (bot.busy)
21447
- return json(res, 409, { error: "the bot is working — stop the turn before restoring files" });
21448
- if (checkpointRestoreLeases.has(bot.id)) {
21449
- return json(res, 409, { error: "this bot's project files are already being restored" });
21450
- }
21451
- checkpointRestoreLeases.add(bot.id);
21452
- let result;
21453
- try {
21454
- result = await checkpoints.restore(bot.id, parsed.data.cwd, parsed.data.hash);
21455
- }
21456
- finally {
21457
- checkpointRestoreLeases.delete(bot.id);
21458
- }
21459
- if (!result.ok)
21460
- return json(res, 400, { error: result.error });
21461
- return json(res, 200, { ok: true });
21462
- }
21463
21456
  // onboarding/ask cards persist their answered/dismissed state
21464
21457
  m = path.match(/^\/api\/bots\/([\w-]+)\/cards\/([\w-]+)$/);
21465
21458
  if (m && method === "PATCH") {
@@ -21863,14 +21856,6 @@ const handleRequest = async (req, res) => {
21863
21856
  behavior,
21864
21857
  }))
21865
21858
  return;
21866
- if (resolveAndSendTightening(res, {
21867
- botId: bot.id,
21868
- botName: bot.name,
21869
- threadId: bot.threadId,
21870
- requestId: String(body.requestId),
21871
- behavior,
21872
- }))
21873
- return;
21874
21859
  if (sendSkillResolution(res, resolveSkillRequest({
21875
21860
  botId: bot.id,
21876
21861
  botName: bot.name,
@@ -22011,21 +21996,6 @@ const handleRequest = async (req, res) => {
22011
21996
  }))
22012
21997
  return;
22013
21998
  }
22014
- const tighteningCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.tighteningRequest);
22015
- if (tighteningCard?.card?.tighteningRequest) {
22016
- const tighteningBotId = tighteningCard.from?.botId ?? store.botByThread(threadId)?.id;
22017
- if (!tighteningBotId)
22018
- return json(res, 400, { error: "this tightening request has no valid owner" });
22019
- const tighteningOwner = store.bot(tighteningBotId);
22020
- if (resolveAndSendTightening(res, {
22021
- botId: tighteningBotId,
22022
- botName: tighteningOwner?.name,
22023
- threadId,
22024
- requestId,
22025
- behavior,
22026
- }))
22027
- return;
22028
- }
22029
21999
  // peer-approval intercept (see /api/bots/:id/respond above). A peer card
22030
22000
  // belongs to the bus rather than to a speaker, so resolve it before we go
22031
22001
  // looking for one — a room between turns has no speaker to find.
@@ -22148,15 +22118,8 @@ const handleRequest = async (req, res) => {
22148
22118
  return json(res, 200, { ok: true });
22149
22119
  }
22150
22120
  // ── tasks: a bot's separate contexts ────────────────────────────────
22151
- // The bot record answers with its messages because switching tasks
22152
- // changes which transcript is live, and a partial patch would leave
22153
- // the client showing the previous task's conversation.
22154
- const botWithThread = (bot) => ({
22155
- ...wireBot(bot),
22156
- messages: store.messagesFor(bot.threadId),
22157
- activeLeafId: store.activeLeaf(bot.threadId),
22158
- tasks: store.tasks(bot.id).map(wireTask),
22159
- });
22121
+ // Replies carry the open thread's newest page: a create or delete can
22122
+ // change which transcript is live. The frames come from store.onChange.
22160
22123
  // Folders organize one bot's threads; they never own settings,
22161
22124
  // transcripts or working directories. The project wire names stay stable.
22162
22125
  m = path.match(/^\/api\/bots\/([\w-]+)\/projects\/order$/);
@@ -22173,7 +22136,7 @@ const handleRequest = async (req, res) => {
22173
22136
  const projects = store.reorderProjects(bot.id, body.projectIds);
22174
22137
  if (!projects)
22175
22138
  return json(res, 400, { error: "projectIds must include each of this bot's folders exactly once" });
22176
- return json(res, 200, { projects, bot: botWithThread(bot) });
22139
+ return json(res, 200, { projects, bot: publicBot(bot) });
22177
22140
  }
22178
22141
  m = path.match(/^\/api\/bots\/([\w-]+)\/projects(?:\/([\w-]+))?$/);
22179
22142
  if (m && ((method === "POST" && !m[2]) || (method === "PATCH" && m[2]) || (method === "DELETE" && m[2]))) {
@@ -22184,7 +22147,7 @@ const handleRequest = async (req, res) => {
22184
22147
  return json(res, 404, { error: "no such folder" });
22185
22148
  if (method === "DELETE") {
22186
22149
  const updated = store.deleteProject(bot.id, m[2]);
22187
- return json(res, 200, { bot: botWithThread(updated) });
22150
+ return json(res, 200, { bot: publicBot(updated) });
22188
22151
  }
22189
22152
  const body = await readBody(req);
22190
22153
  if (!body || typeof body !== "object" || Array.isArray(body))
@@ -22207,7 +22170,7 @@ const handleRequest = async (req, res) => {
22207
22170
  const project = method === "POST"
22208
22171
  ? store.createProject(bot.id, patch.name, patch.emoji)
22209
22172
  : store.patchProject(bot.id, m[2], patch);
22210
- return json(res, method === "POST" ? 201 : 200, { project, bot: botWithThread(bot) });
22173
+ return json(res, method === "POST" ? 201 : 200, { project, bot: publicBot(bot) });
22211
22174
  }
22212
22175
  m = path.match(/^\/api\/bots\/([\w-]+)\/tasks$/);
22213
22176
  if (m && method === "POST") {
@@ -22242,9 +22205,7 @@ const handleRequest = async (req, res) => {
22242
22205
  // Who opened it decides who may answer its cards on a shared workspace.
22243
22206
  if (auth.kind === "session")
22244
22207
  threadStarters.set(task.threadId, actorKey(auth));
22245
- const fresh = botWithThread(store.bot(bot.id));
22246
- broadcast({ kind: "bot", bot: fresh });
22247
- return json(res, 201, { bot: fresh, task: wireTask(task) });
22208
+ return json(res, 201, { bot: publicBot(store.bot(bot.id)), task: wireTask(task) });
22248
22209
  }
22249
22210
  m = path.match(/^\/api\/bots\/([\w-]+)\/tasks\/([\w-]+)$/);
22250
22211
  if (m && method === "POST") {
@@ -22263,18 +22224,13 @@ const handleRequest = async (req, res) => {
22263
22224
  if (!switched)
22264
22225
  return json(res, 404, { error: "no such task" });
22265
22226
  const switchedSettings = { ...wireBot(switched), tasks: store.tasks(switched.id).map(wireTask) };
22266
- // Bounded for the same reason as the channel switch above.
22267
- broadcast({
22268
- kind: "bot",
22269
- bot: { ...switchedSettings, ...messagePage(switched.threadId, DEFAULT_PAGE) },
22270
- });
22271
22227
  // "0" is settings only, a positive page is a bounded transcript, and no
22272
22228
  // parameter is the whole thread — the one branch that materialises it.
22229
+ // Search results land through that branch (src/lib/focus-message.ts):
22230
+ // the hit can be older than one page.
22273
22231
  const responseBot = requestedMessages === "0"
22274
22232
  ? switchedSettings
22275
- : switchLimit === undefined
22276
- ? botWithThread(switched)
22277
- : { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
22233
+ : { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
22278
22234
  return json(res, 200, { bot: responseBot });
22279
22235
  }
22280
22236
  if (m && method === "PATCH") {
@@ -22349,9 +22305,7 @@ const handleRequest = async (req, res) => {
22349
22305
  const task = store.refreshTaskPermissions(profile.id, m[2]);
22350
22306
  if (!task)
22351
22307
  return json(res, 404, { error: "no such task" });
22352
- const fresh = botWithThread(store.bot(profile.id));
22353
- broadcast({ kind: "bot", bot: fresh });
22354
- return json(res, 200, { task: wireTask(task), bot: fresh });
22308
+ return json(res, 200, { task: wireTask(task), bot: publicBot(store.bot(profile.id)) });
22355
22309
  }
22356
22310
  const patch = {};
22357
22311
  if (body.projectId !== undefined) {
@@ -22457,9 +22411,7 @@ const handleRequest = async (req, res) => {
22457
22411
  const task = patch.modelSelection
22458
22412
  ? store.switchTaskModel(m[1], m[2], patch.modelSelection, body.updateBotDefault === true, body.resetApprovalToAsk === true, { ...patch, ...hostedModels?.resetTask(current.modelSelection, patch.modelSelection) })
22459
22413
  : store.patchTask(m[1], m[2], patch);
22460
- const fresh = botWithThread(store.bot(m[1]));
22461
- broadcast({ kind: "bot", bot: fresh });
22462
- return json(res, 200, { task: wireTask(task), bot: fresh });
22414
+ return json(res, 200, { task: wireTask(task), bot: publicBot(store.bot(m[1])) });
22463
22415
  }
22464
22416
  if (m && method === "DELETE") {
22465
22417
  const bot = store.bot(m[1]);
@@ -22482,12 +22434,11 @@ const handleRequest = async (req, res) => {
22482
22434
  if (!updated)
22483
22435
  return json(res, 404, { error: "no such task" });
22484
22436
  clearTurnDigestState(m[2]);
22437
+ revokeInternalCapabilitiesForThread(m[2]);
22485
22438
  handoffs.forget(m[2]);
22486
22439
  settleDirectFollowup(directTurnGenerationByThread.get(m[2]));
22487
22440
  rejectDeletedThreadSkillStages(stagedSkillCleanups);
22488
- const fresh = botWithThread(updated);
22489
- broadcast({ kind: "bot", bot: fresh });
22490
- return json(res, 200, { bot: fresh });
22441
+ return json(res, 200, { bot: publicBot(updated) });
22491
22442
  }
22492
22443
  // Regenerate title (#1858): name the thread again from its conversation
22493
22444
  // as it is now. The person asked, so the result replaces any title, but
@@ -22577,8 +22528,6 @@ const handleRequest = async (req, res) => {
22577
22528
  const key = teamComputerOwner(found.id);
22578
22529
  if (parsed.data.action === "take" && boatLifecycleBusyBots.has(key))
22579
22530
  return json(res, 409, { error: "Wait for this computer's action to finish before taking control" });
22580
- if (parsed.data.action === "take")
22581
- assertTeamControlCanBeTaken(found.id);
22582
22531
  if (parsed.data.controlLeaseId) {
22583
22532
  const result = parsed.data.action === "take"
22584
22533
  ? computerControl.acquireLease(key, parsed.data.controlLeaseId)
@@ -22617,7 +22566,7 @@ const handleRequest = async (req, res) => {
22617
22566
  if (method === "POST" && !computerId) {
22618
22567
  const parsed = teamComputerCreate.safeParse(body);
22619
22568
  if (!parsed.success)
22620
- return json(res, 400, { error: "Provide requestId (UUID), a name, and acknowledgeCost: true to create a paid Boat" });
22569
+ return json(res, 400, { error: "Provide requestId (UUID), a name, and acknowledgeCost: true to create a paid cloud computer" });
22621
22570
  if (!boat.boatConfigured(cfg))
22622
22571
  return json(res, 409, { error: "Configure Boat in Settings before creating a cloud computer" });
22623
22572
  const computer = teamComputers.create(parsed.data.name, parsed.data.requestId);
@@ -22637,7 +22586,7 @@ const handleRequest = async (req, res) => {
22637
22586
  }
22638
22587
  if (method === "POST" && found && action && action !== "control") {
22639
22588
  if (action === "provision" && body?.acknowledgeCost !== true)
22640
- return json(res, 400, { error: "Confirm Boat creation or wake costs with acknowledgeCost: true" });
22589
+ return json(res, 400, { error: "Confirm cloud computer creation or wake costs with acknowledgeCost: true" });
22641
22590
  // A ready-only join never wakes, provisions or steals an agent's turn.
22642
22591
  // The caller takes a separate explicit human-control lease first.
22643
22592
  if (action === "join") {
@@ -22959,7 +22908,12 @@ const handleRequest = async (req, res) => {
22959
22908
  // The bots' browser engine: install it on this machine (agent-browser +
22960
22909
  // a Chrome for Testing, a one-time download), or ask how that is going.
22961
22910
  // One install at a time; the config frame's browserEngine tells the rest.
22911
+ // JSON only, so a form posted from another local page cannot start it.
22962
22912
  if (method === "POST" && path === "/api/browser-engine/install") {
22913
+ const mediaType = String(req.headers["content-type"] ?? "").split(";", 1)[0].trim().toLowerCase();
22914
+ if (mediaType !== "application/json") {
22915
+ return json(res, 415, { error: "content-type must be application/json" });
22916
+ }
22963
22917
  if (!browserEngineInstall) {
22964
22918
  const status = browserEngineStatus();
22965
22919
  if (status.kind === "unavailable" && !status.installable)
@@ -24112,6 +24066,15 @@ const handleRequest = async (req, res) => {
24112
24066
  if (conflict)
24113
24067
  return json(res, 409, { error: conflict });
24114
24068
  }
24069
+ // A change names only the OpenCode provider keys it saves or removes;
24070
+ // the rest stay. Merged with no await before the save below, so it
24071
+ // starts from what is saved at that moment.
24072
+ if (patch.opencodeGo?.providerKeys) {
24073
+ const merged = mergeOpenCodeProviderKeys(cfg, patch.opencodeGo.providerKeys);
24074
+ if (!merged.ok)
24075
+ return json(res, 400, { error: merged.error });
24076
+ patch.opencodeGo = { ...patch.opencodeGo, providerKeys: merged.keys };
24077
+ }
24115
24078
  const browserCleanupRequests = [];
24116
24079
  if (profileControlConflict())
24117
24080
  return json(res, 409, { error: "Release browser control before deleting its profile." });
@@ -24601,9 +24564,6 @@ const handleRequest = async (req, res) => {
24601
24564
  if (!currentBot)
24602
24565
  return json(res, 404, { error: "no such bot" });
24603
24566
  const controlKey = botComputerControlKey(currentBot);
24604
- const teamComputer = inheritedTeamComputer(currentBot);
24605
- if (action === "take" && teamComputer)
24606
- assertTeamControlCanBeTaken(teamComputer.id);
24607
24567
  const leaseResult = body.controlLeaseId === undefined
24608
24568
  ? null
24609
24569
  : controlLeaseIdSchema.safeParse(body.controlLeaseId);
@@ -24780,12 +24740,12 @@ const handleRequest = async (req, res) => {
24780
24740
  }
24781
24741
  if (bot.computer !== "cloud" && !threadPreview) {
24782
24742
  return json(res, 409, {
24783
- error: "Choose Cloud before changing or opening this Boat. Auto only checks existing computer state.",
24743
+ error: "Choose Cloud computer before changing or opening this cloud computer. Auto only checks existing computer state.",
24784
24744
  });
24785
24745
  }
24786
24746
  if (m[2] === "remove") {
24787
24747
  // Boats sleep and wake; only the VPS backend has a container to remove.
24788
- return json(res, 409, { error: "the cloud Boat backend has no container to remove — use sleep instead" });
24748
+ return json(res, 409, { error: "a cloud computer has no container to remove — put it to sleep instead" });
24789
24749
  }
24790
24750
  const releaseComputerLifecycle = claimBotComputerLifecycle(botId);
24791
24751
  try {
@@ -25050,6 +25010,8 @@ const gracefulShutdown = createGracefulShutdown({
25050
25010
  // the shutdown deadline), immediately before the process exits, so no new
25051
25011
  // server can overlap with a still-mutating old one.
25052
25012
  exit: (code) => {
25013
+ // Streamed text the bus is still merging reaches the log and clients.
25014
+ bus.flush();
25053
25015
  try {
25054
25016
  sessions.close();
25055
25017
  }
@@ -25062,8 +25024,8 @@ const gracefulShutdown = createGracefulShutdown({
25062
25024
  closeMessageDb();
25063
25025
  mcpOAuth.dispose();
25064
25026
  releaseDataDirLeaseAtExit();
25065
- // A Cloud home's launcher starts the server again on this code only.
25066
- process.exit(cloudHomeRestartRequested && code === 0 ? CLOUD_HOME_RESTART_EXIT_CODE : code);
25027
+ // Every launcher in this repo starts the server again on this code (server/restart.ts).
25028
+ process.exit(restartRequested && code === 0 ? RESTART_EXIT_CODE : code);
25067
25029
  },
25068
25030
  });
25069
25031
  for (const signal of ["SIGINT", "SIGTERM"]) {