openmausbot 0.1.94 → 0.1.96

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 (188) hide show
  1. package/dist/assets/{DesktopViewer-bxM7NV3N.js → DesktopViewer-DsnA23KM.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-BhMdJhRJ.js → abnfDiagram-O67JEVCF-Dkbd2hcj.js} +1 -1
  3. package/dist/assets/{arc-B5itKDcx.js → arc-Duj5Coy2.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-C5d7UY6U.js → architectureDiagram-NJMV4G6O-uYUsDawP.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-BrZIhMmu.js → blockDiagram-BEXU5L5S-CWDlTpXe.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-7I0ArfbD.js → c4Diagram-YGBWAQC7-B0t15PDc.js} +1 -1
  7. package/dist/assets/channel-BZxwC-Gw.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-BnEsyN36.js → chunk-2Q5K7J3B-BJSCL-HV.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BXATQ52s.js → chunk-3FUC2YCW-D3_ILccd.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-B0BpTkEx.js → chunk-5VM5RSS4-D3dmwT_S.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-BtLTz6FR.js → chunk-GTNCS2PH-Br73HFPD.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-D6gcZOYt.js → chunk-GWA4HPMP-uSsjxubC.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-DCLrosvf.js → chunk-JWPE2WC7-C40VWFyr.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-B9JsknV9.js → chunk-NETBCI7D-D6WNNYwU.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CsoTxw7z.js → chunk-WEXAMYUT-DOEhq64P.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-0F_frB0c.js → chunk-XXDRQBXY-BqJEi6Pi.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-DkrpnflX.js → classDiagram-v2-NBCMYWYE-ByU_GnxT.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-BNDwYBt9.js → cose-bilkent-JH36ORCC-D-Uglxqq.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-JUkNr1Mk.js → cynefin-OW5HDTMX-CZWO7aYj.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-DKeh9N4M.js → cynefinDiagram-VND7K2PF-C70-w0la.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-ve-6T_wX.js → dagre-6A5THRUB-CnhjhExB.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-BuJ2d8rT.js → diagram-22UHCM2B-bnjWCNb4.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-BgOs_Ni-.js → diagram-3UASUU5V-A4uTWoFV.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-DMK6VmcP.js → diagram-ATOU4E4O-Zi6m3NUl.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-DksASlcY.js → diagram-CDSNMT55-DtCcmd5k.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-Bgn8I5k6.js → diagram-MLGK6HIB-DofuETS3.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-zK0jqO8N.js → diagram-MPIPVDR6-Dw2T9PuU.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-vKrbzTwr.js → ebnfDiagram-ZINNZB2B-DbrwPiem.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-Bzv2mPNf.js → elk-276RUBZZ-BvCt1dCb.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-CvOyYuxu.js → erDiagram-OPXOYQCR-Y-QKqPnU.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-CtbnDbc0.js → flowDiagram-KWPJA3E3-CDkL1B5w.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-c4dMRYyG.js → ganttDiagram-FUAMR5RP-CKV5D2F6.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-B34uaQho.js → gitGraphDiagram-X574FWY7-nL9iFTCv.js} +1 -1
  34. package/dist/assets/{index-CPiqc_DN.js → index-DreDnjOu.js} +1 -1
  35. package/dist/assets/index-Du7o6Dsf.js +610 -0
  36. package/dist/assets/index-KxG4bWSr.css +1 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-o64ma4CI.js → infoDiagram-VRGFBTTK-C7aHjzsf.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-DV4C9BAm.js → ishikawaDiagram-OU5B5YK6-KUZsNBTh.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-Bf0AHk1H.js → journeyDiagram-ZHPQQLJL-CGUuodFl.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-B1UMSKmE.js → kanban-definition-PNTS6WVX-Cy6r868Z.js} +1 -1
  41. package/dist/assets/{linear-BpKhuyLm.js → linear-Bl669148.js} +1 -1
  42. package/dist/assets/{mermaid.core-B_BKNCw5.js → mermaid.core-86Xqz5Mr.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-DeARruT1.js → mindmap-definition-NLK3R4M7-G4DzHLrd.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-BOQEk_1D.js → pegDiagram-GJSIUBJH-K4eruzY0.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-EIfoiTZo.js → pieDiagram-5QR66LMP-DYdR9PPq.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-GgZwT-fi.js → quadrantDiagram-O4NWA36T-BCAh0nEy.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-D9rDQomj.js → railroadDiagram-XR7U4H2S-p0dGRc4g.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-DAh1MIeo.js → requirementDiagram-PLB6GJNP-tLeBG0aL.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-CPr6Uz64.js → sankeyDiagram-IPEJSGJF-QRuN16rV.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-BU6VIdcm.js → sequenceDiagram-PO4LG4MO-ueT1nVoa.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-qF2HO41r.js → sizeCapture-INFHLROL-wRS2zWg2.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-DlJ8G4MD.js → stateDiagram-v2-GCMORJYK-CYzA_hE1.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DzYE8I54.js → swimlanes-2SLR337P-kDnv6LMc.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-C7voMDle.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-B7wzEQ0A.js → timeline-definition-EJHVYXUP-D1y2UbbH.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-BvGgrxIP.js → usecaseDiagram-POWQR4AR-IqB4v9HX.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-CEECdgQg.js → vennDiagram-UO4OBE2U-BEi7KYVF.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-XQHA0P_P.js → wardleyDiagram-VNRHLVJA-Dkybo-Yf.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-B5mLcZI2.js → xychartDiagram-PMCCYNJV-BAPFJLpg.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/cloud-home-start.js +27 -13
  62. package/dist-server/companion/src/listener.js +39 -18
  63. package/dist-server/companion/src/routes.js +39 -0
  64. package/dist-server/container-mcp.js +16 -29
  65. package/dist-server/drivers/agents-proxy.js +161 -82
  66. package/dist-server/enterprise/server/index.js +2 -1
  67. package/dist-server/{browser-proxy.js → harness-mcp-proxy.js} +34 -19
  68. package/dist-server/index.js +4991 -4908
  69. package/dist-server/local-computer.js +2 -1
  70. package/dist-server/openmausbot.js +906 -1785
  71. package/dist-server/pair-cli.js +906 -1785
  72. package/dist-server/proxy-paths.js +2 -1
  73. package/dist-server/server/account-signin.js +0 -2
  74. package/dist-server/server/activity.js +201 -0
  75. package/dist-server/server/admin-activity.js +1 -4
  76. package/dist-server/server/auto-approve.js +25 -4
  77. package/dist-server/server/automatic-recovery.js +1 -1
  78. package/dist-server/server/boat-create-idempotency.js +0 -7
  79. package/dist-server/server/boat-delete-journal.js +0 -4
  80. package/dist-server/server/boat.js +4 -4
  81. package/dist-server/server/bot-package.js +1 -19
  82. package/dist-server/server/browser-runtime.js +1 -2
  83. package/dist-server/server/checkpoints.js +90 -148
  84. package/dist-server/server/chief-of-staff.js +9 -9
  85. package/dist-server/server/cli.js +33 -26
  86. package/dist-server/server/cloud-backend.js +0 -4
  87. package/dist-server/server/cloud-computer-tools.js +110 -0
  88. package/dist-server/server/cloud-home-start.js +12 -15
  89. package/dist-server/server/cloud-home.js +0 -5
  90. package/dist-server/server/cloud-lending.js +0 -3
  91. package/dist-server/server/cloud-move-http.js +44 -30
  92. package/dist-server/server/cloud-move.js +20 -18
  93. package/dist-server/server/composio.js +41 -5
  94. package/dist-server/server/computer-control.js +0 -15
  95. package/dist-server/server/computer-observation.js +0 -74
  96. package/dist-server/server/config.js +121 -65
  97. package/dist-server/server/container-computer.js +23 -24
  98. package/dist-server/server/control-client.js +4 -10
  99. package/dist-server/server/decider/jev.js +1 -4
  100. package/dist-server/server/decider/log.js +1 -3
  101. package/dist-server/server/decision-log.js +1 -4
  102. package/dist-server/server/delegations.js +3 -2
  103. package/dist-server/server/drivers/acp/core.js +11 -8
  104. package/dist-server/server/drivers/acp/opencode-go.js +17 -16
  105. package/dist-server/server/drivers/agents-call.js +88 -92
  106. package/dist-server/server/drivers/agents-catalog.js +90 -11
  107. package/dist-server/server/drivers/agents-proxy.js +10 -31
  108. package/dist-server/server/drivers/antigravity.js +1 -4
  109. package/dist-server/server/drivers/boatagent.js +23 -4
  110. package/dist-server/server/drivers/cerebras.js +3 -3
  111. package/dist-server/server/drivers/chat-mcp-tools.js +4 -7
  112. package/dist-server/server/drivers/claude-login-auth.js +2 -11
  113. package/dist-server/server/drivers/claude.js +16 -14
  114. package/dist-server/server/drivers/codex-device-auth.js +3 -1
  115. package/dist-server/server/drivers/codex.js +5 -4
  116. package/dist-server/server/drivers/grok.js +1 -1
  117. package/dist-server/server/drivers/openai-chat-protocol.js +4 -2
  118. package/dist-server/server/drivers/openai-chat.js +18 -11
  119. package/dist-server/server/drivers/prompt-split.js +4 -4
  120. package/dist-server/server/drivers/retry.js +8 -4
  121. package/dist-server/server/engine-install.js +1 -5
  122. package/dist-server/server/harness/bus.js +49 -0
  123. package/dist-server/server/harness/registry.js +0 -1
  124. package/dist-server/server/{browser-proxy.js → harness-mcp-proxy.js} +40 -20
  125. package/dist-server/server/index.js +1190 -971
  126. package/dist-server/server/lending-memory.js +12 -8
  127. package/dist-server/server/mcp-registry.js +35 -0
  128. package/dist-server/server/memory-entries.js +38 -8
  129. package/dist-server/server/memory-organize.js +1 -8
  130. package/dist-server/server/memory-topics.js +4 -4
  131. package/dist-server/server/memory-upkeep.js +13 -15
  132. package/dist-server/server/message-file.js +9 -13
  133. package/dist-server/server/outbound-counts.js +79 -0
  134. package/dist-server/server/outbound-requests.js +60 -0
  135. package/dist-server/server/package-import.js +1 -1
  136. package/dist-server/server/peer-delivery.js +5 -19
  137. package/dist-server/server/peer-roster.js +30 -4
  138. package/dist-server/server/profile-versions.js +0 -31
  139. package/dist-server/server/provider-key-check.js +2 -1
  140. package/dist-server/server/proxy-paths.js +2 -1
  141. package/dist-server/server/restart.js +40 -0
  142. package/dist-server/server/room-handoffs.js +17 -13
  143. package/dist-server/server/routes/bot-memory.js +0 -24
  144. package/dist-server/server/routine-requests.js +2 -5
  145. package/dist-server/server/routines.js +10 -5
  146. package/dist-server/server/server-launcher.js +53 -0
  147. package/dist-server/server/setup-mode.js +0 -2
  148. package/dist-server/server/skill-learn.js +0 -1
  149. package/dist-server/server/skill-library.js +0 -15
  150. package/dist-server/server/store.js +10 -2
  151. package/dist-server/server/surface.js +69 -28
  152. package/dist-server/server/system-prompt.js +17 -7
  153. package/dist-server/server/team-manifest.js +1 -6
  154. package/dist-server/server/team-memory.js +209 -0
  155. package/dist-server/server/thread-events.js +2 -2
  156. package/dist-server/server/tunnel.js +4 -1
  157. package/dist-server/server/turn-images.js +1 -21
  158. package/dist-server/server/turn-resources.js +19 -150
  159. package/dist-server/server/usage-ledger.js +3 -1
  160. package/dist-server/server/vps-computer.js +4 -14
  161. package/dist-server/server/workspace.js +87 -50
  162. package/dist-server/server-launcher.js +72 -0
  163. package/dist-server/shared/activity.js +3 -0
  164. package/dist-server/shared/ask-question.js +1 -1
  165. package/dist-server/shared/bot-avatar.js +8 -0
  166. package/dist-server/shared/cloud-computer.js +12 -0
  167. package/dist-server/shared/connector-scopes.js +78 -0
  168. package/dist-server/shared/failed-turn.js +25 -0
  169. package/dist-server/shared/live-approval.js +2 -2
  170. package/dist-server/shared/outbound.js +128 -0
  171. package/dist-server/shared/package-format.js +3 -3
  172. package/dist-server/shared/pairing-link.js +144 -0
  173. package/dist-server/shared/tool-surface.js +3 -2
  174. package/dist-server/vps-container-mcp.js +16 -29
  175. package/dist-server/workspace-backup.worker.js +14 -27
  176. package/enterprise/server/index.js +2 -1
  177. package/package.json +1 -1
  178. package/dist/assets/channel-CdJX6i8k.js +0 -1
  179. package/dist/assets/index-CPYrGsv1.js +0 -610
  180. package/dist/assets/index-CzT42GR8.css +0 -1
  181. package/dist/assets/swimlanesDiagram-TC7HE7FX-D-U4GtLQ.js +0 -8
  182. package/dist-server/server/claim-idle.js +0 -33
  183. package/dist-server/server/cloud-overflow.js +0 -167
  184. package/dist-server/server/drivers/chat-boat-tools.js +0 -90
  185. package/dist-server/server/drivers/grokagent.js +0 -4
  186. package/dist-server/server/openmaus-status-capsule.js +0 -329
  187. package/dist-server/server/tightening-requests.js +0 -435
  188. package/dist-server/shared/tightening-request.js +0 -87
@@ -27,12 +27,15 @@ 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";
33
36
  import { threadRefUrl } from "../src/lib/thread-refs.js";
34
37
  import { CREDENTIAL_TARGETS, credentialResumeOutcome, credentialIsConfigured, isPendingCredentialRequest, isCredentialTargetId, } from "../shared/credential-request.js";
35
- import { approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval, delegatedApprovalMode } from "./auto-approve.js";
38
+ import { HELD_NOTE, approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval, delegatedApprovalMode } from "./auto-approve.js";
36
39
  import { CommandAllowlistStore, commandAllowlistCandidate } from "./command-allowlist.js";
37
40
  import { updateClaudeCli } from "./claude-update.js";
38
41
  import { configuredAccountDirectory, assertSeparateClaudeAccount, claudeAccountInfo, createClaudeAccountSchema, instanceSettingsSchema, newClaudeAccount } from "./claude-accounts.js";
@@ -51,13 +54,14 @@ 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";
53
56
  import * as boat from "./boat.js";
57
+ import { cloudComputerRpc } from "./cloud-computer-tools.js";
54
58
  import { TeamComputers, teamComputerAssignment, teamComputerCreate, teamComputerOwner } from "./team-computers.js";
55
59
  import { isEffortLevel } from "../shared/wire.js";
56
60
  import { isPersistentQuestionCard, QUESTION_DISMISS_MESSAGE, shouldSettleRequestCard } from "../shared/ask-question.js";
57
61
  import { parseToolScope, toolScopeWidens } from "../shared/tool-scope.js";
58
62
  import { boatCreateRecoverySnapshot, retireDeletedBoatCreate } from "./boat-create-idempotency.js";
59
63
  import { boatDeletionSnapshot } from "./boat-delete-journal.js";
60
- import { liveDecisionRefusal } from "../shared/live-approval.js";
64
+ import { liveCardKind, liveDecisionRefusal } from "../shared/live-approval.js";
61
65
  import { boatAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourceChangeError, } from "./cloud-backend.js";
62
66
  import * as composio from "./composio.js";
63
67
  import { connectorCallFromFrame, connectorRefusalText, connectorUnrecognizedText, evaluateConnectorTools } from "./connector-verdict.js";
@@ -66,10 +70,9 @@ import { buildRecall } from "./recall.js";
66
70
  import { createMemoryUpkeep, upkeepEnabled } from "./memory-upkeep.js";
67
71
  import { appendAboutMe, commitLearned, planLearned } from "./profile-learned.js";
68
72
  import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, livePeerRosterBlock, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
69
- import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
70
73
  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
74
  import { attachForTurn, saveBotAttachment } from "./bot-attachment.js";
72
- import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmIdleTimeoutMinutes, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, computerClaimIdleReleaseEnabled, cloudOverflowAllowlistedThreads, cloudOverflowEnabled, cloudOverflowIdleStopMs, cloudOverflowPerSecondCostUsd, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, stripWorkspaceCredentialEnv, PROVIDER_CREDENTIAL_ENV, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, skillsLibraryEnabled, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, CLAUDE_API_INSTANCE, liveSettingsFor, } from "./config.js";
75
+ import { cacheUntilConfigChanges, ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmIdleTimeoutMinutes, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, stripWorkspaceCredentialEnv, PROVIDER_CREDENTIAL_ENV, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, skillsLibraryEnabled, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, CLAUDE_API_INSTANCE, liveSettingsFor, mergeOpenCodeProviderKeys, openCodeProviderKeys, } from "./config.js";
73
76
  import { sweepThreadEventLogs } from "./thread-retention.js";
74
77
  import { ComputerControl } from "./computer-control.js";
75
78
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
@@ -89,12 +92,12 @@ import { isModelVariant, TurnNotStartedError, newId, } from "./contracts.js";
89
92
  import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.js";
90
93
  import { recoveryCapabilityError } from "./automatic-recovery.js";
91
94
  import { decodeGeneratedImage } from "./generated-image.js";
92
- import { MAX_MCP_SERVERS, isRemoteMcpServer, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
95
+ import { addDisabledMcpServer, MAX_MCP_SERVERS, isRemoteMcpServer, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
93
96
  import { McpOAuthError, McpSignInError, McpOAuthManager, mcpOAuthRedirectUri, withMcpSignIn, withoutPendingSignIn } from "./mcp-oauth.js";
94
97
  import { probeMcpServer } from "./mcp-probe.js";
95
98
  import { GROUP_GOAL_MAX_TURNS, groupGoalAssignmentKey, groupGoalCompletionTurnId, groupGoalCoordinatorInstructions, groupGoalWorkerInstructions, parseGroupGoalDecision, resolveGroupGoalMember, selectGroupGoalCoordinator, } from "./group-goal-run.js";
96
99
  import { BUILT_IN_DRIVERS } from "./drivers/builtIn.js";
97
- import { openCodeProviderKeysAllowed, setOpenCodeProviderKeyPolicy } from "./drivers/acp/opencode-go.js";
100
+ import { openCodeProviderKeysAllowed, setOpenCodeOwnProviderKeys, setOpenCodeProviderKeyPolicy } from "./drivers/acp/opencode-go.js";
98
101
  import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from "./comms-visibility.js";
99
102
  import { readMessageText, recallMessages, recentMessages, searchMessagesAsync, closeMessageSearch, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups, threadsReferencing } from "./message-db.js";
100
103
  import { briefCrossingLabel, claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
@@ -115,9 +118,10 @@ import { ProviderRegistry } from "./harness/registry.js";
115
118
  import { ManagedDesktopProviders } from "./managed-desktop.js";
116
119
  import { computerKindForResource, ManagedDesktopPolicy } from "./managed-policy.js";
117
120
  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";
121
+ import { boatNotConfiguredMessage, CLOUD_HOME_SECRET_KEYS, CLOUD_IGNORED_KEYS, CLOUD_PAIRING_PATH, cloudHomeConfiguration, cloudHomeOffersPlace, cloudHomePlaceRefusal, createCloudPairing, firstCloudTurnPatch, readSignedBody, } from "./cloud-home.js";
119
122
  import { CLOUD_PERSONAL_REFUSAL, settleCloudOwnership } from "./cloud-owner.js";
120
- import { createCloudMoveRoutes } from "./cloud-move-http.js";
123
+ import { createCloudMoveRoutes, workspaceShared } from "./cloud-move-http.js";
124
+ import { RESTART_EXIT_CODE } from "./restart.js";
121
125
  import { holdIncludedServices } from "./included-services.js";
122
126
  import { selectDefaultModelSelection, withNewBotEffort } from "./default-model-selection.js";
123
127
  import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, peerApprovalFailure, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
@@ -137,8 +141,7 @@ import { Handoffs, handedStateUsable, recordHanded, renderUnseen, sessionStart,
137
141
  import { extractTurnImages } from "./turn-images.js";
138
142
  import { threadTitlePrompt, titleConversationExcerpt } from "./thread-title.js";
139
143
  import { TurnWatchdog } from "./turn-watchdog.js";
140
- import { TurnResources, workspaceResource } from "./turn-resources.js";
141
- import { IdleReleasePolicy } from "./claim-idle.js";
144
+ import { TurnResources } from "./turn-resources.js";
142
145
  import { ensureWorkspace, ensureTaskWorkspace, workspaceLocationsPrompt, supportsWorkspaceFiles, updateMemory, appendMemoryLog, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, TASK_WORKSPACES_DIR, readMemoryOnlyFromRegularFiles, workspaceDir, } from "./workspace.js";
143
146
  import { listMemoryTopics, memoryDate, readMemoryFile, readMemoryTopic, writeMemoryFile, writeMemoryTopic } from "./workspace.js";
144
147
  import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, journalMemoryWrite, } from "./memory-journal.js";
@@ -150,7 +153,7 @@ import { botSkillTemplateSchema, newBotDefaultsSchema, resolveBotCreationDefault
150
153
  import { expandLearnTurnText, learnSource } from "./skill-learn.js";
151
154
  import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
152
155
  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, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, resolveComputerPromptKind, } from "./system-prompt.js";
156
+ 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
157
  import { readCuaConnection, readCuaUnavailableReason, gatedLocalComputer } from "./local-computer.js";
155
158
  import { discoverExistingPerBotLocalVms, discoverExistingPoolLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
156
159
  import { localVmStopReason, recordLocalVmIdleStop } from "./local-vm-stop-reason.js";
@@ -173,7 +176,6 @@ import { createOptionsCard } from "./options-card.js";
173
176
  import { buildBotOverview, connectedAppsFacts } from "./bot-overview.js";
174
177
  import { ProfileRequestService } from "./profile-requests.js";
175
178
  import { ModelRequestService } from "./model-requests.js";
176
- import { TighteningRequestService } from "./tightening-requests.js";
177
179
  import { TeamSetupError, TeamSetupRequestService } from "./team-setup-requests.js";
178
180
  import { profileRevision, profileSnapshot } from "./profile-revision.js";
179
181
  import { flushAllProfileHistory, flushProfileHistory, readHistory, recordProfileChange } from "./profile-versions.js";
@@ -183,6 +185,12 @@ import { fetchGithubTeam, fetchLibraryTeam, fetchTeamCatalog } from "./team-libr
183
185
  import { BOT_PACKAGE_MAX_SKILLS, isBotPackage, packageSummary, parsePackageDocument, renderBotPackageMarkdown } from "./bot-package.js";
184
186
  import { createTeamManifest, parseTeamManifest } from "./team-manifest.js";
185
187
  import { readThreadEvents } from "./thread-events.js";
188
+ import { TeamMemory, TEAM_MEMORY_KINDS } from "./team-memory.js";
189
+ import { describeTool, readBotActivity } from "./activity.js";
190
+ import { OutboundCounts } from "./outbound-counts.js";
191
+ import { OutboundRequestService } from "./outbound-requests.js";
192
+ import { DEFAULT_OUTBOUND_POLICY, connectorCallsIn, normalizeOutboundPolicy, outboundCallsIn } from "../shared/outbound.js";
193
+ import { connectorAccessDecision, describeConnectorScopes, normalizeConnectorScopes } from "../shared/connector-scopes.js";
186
194
  import { bindThreadLogCapProvider } from "./thread-log-rotation.js";
187
195
  import { listenWebhookIngress, webhookCredential } from "./webhook-ingress.js";
188
196
  import { assertModelVariantSupported, memberTurnSelection } from "./member-turn.js";
@@ -202,9 +210,8 @@ import { shouldMountLocalComputer } from "./local-routing.js";
202
210
  import { autoLocalVmAttachable } from "./container-computer.js";
203
211
  import { startAutoVmClaim } from "./auto-vm-claims.js";
204
212
  import { computerFreeAfterText, computerParkedText, computerStoppedWaitingText, computerWaitingText } from "./computer-wait.js";
205
- import { CloudOverflowConsent, CloudSeatLease, cloudOverflowAction, cloudOverflowConsentText, cloudOverflowOfferText, cloudSeatStartedText, cloudSeatStoppedText } from "./cloud-overflow.js";
206
213
  import { modelContextWindow } from "./model-context-window.js";
207
- import { parseSurface, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
214
+ import { cloudPlaceDriverError, parseSurface, PlaceUnavailableError, placeUnavailable, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
208
215
  import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
209
216
  import { createGracefulShutdown } from "./graceful-shutdown.js";
210
217
  import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
@@ -292,10 +299,12 @@ const workspaceMaintenance = new WorkspaceBackupMaintenance();
292
299
  // Remote clients (server/request-auth.ts, server/sessions.ts): a stable identity
293
300
  // for this server, the paired sessions, and the cookie the served UI uses.
294
301
  const ENVIRONMENT_ID = loadEnvironmentId(DATA_DIR);
295
- function signInAllowList() {
296
- const current = loadConfig().signIn;
297
- return { admins: parseAllowList(current?.admins?.join(",")), members: parseAllowList(current?.members?.join(",")) };
298
- }
302
+ // Every stream frame and request rechecks email sessions against this list,
303
+ // so config.json is parsed again only when the file changes.
304
+ const signInAllowList = cacheUntilConfigChanges((config) => ({
305
+ admins: parseAllowList(config.signIn?.admins?.join(",")),
306
+ members: parseAllowList(config.signIn?.members?.join(",")),
307
+ }));
299
308
  const sessions = new SessionRegistry({
300
309
  file: join(DATA_DIR, "sessions.json"),
301
310
  emailScopesSnapshot: () => {
@@ -767,11 +776,16 @@ function approvalShape(shape) {
767
776
  /** Who opens a routine's results conversation on a Cloud home: the writer
768
777
  * of this request, else the owner for a routine that is theirs (they wrote
769
778
  * it as it stands, or they are its writer: cloud-owner.ts), else its last
770
- * writer, else nobody. */
779
+ * writer, else nobody. A webhook's run is the owner's: only their own
780
+ * devices can create, edit or rotate one (admin scope), so it runs at the
781
+ * bot's own level, in its folder, as on the desktop. Its payload still never
782
+ * reaches the lent Mac: cloud-lending.ts refuses every webhook run. */
771
783
  function routineOpener(routineId) {
772
784
  if (routineWriterInFlight)
773
785
  return routineWriterInFlight;
774
786
  const routine = routines?.listRoutines().find((candidate) => candidate.id === routineId);
787
+ if (!routine && CLOUD_OWNER_KEY && webhooks.list().some((hook) => hook.id === routineId))
788
+ return CLOUD_OWNER_KEY;
775
789
  if (routine && CLOUD_OWNER_KEY && cloudRoutineAuthors?.authored(routineId, routine))
776
790
  return CLOUD_OWNER_KEY;
777
791
  const writer = cloudRoutineAuthors?.writer(routineId);
@@ -940,6 +954,9 @@ function cardAnswerRefusal(auth, threadId, requestId, behavior) {
940
954
  ? "A local service can only decline this request. Approve or answer it in OpenMausBot while signed in."
941
955
  : null;
942
956
  }
957
+ if (!auth.scopes.includes("admin") && store.messagesFor(threadId).some(message => message.card?.requestId === requestId && message.card.teamMemoryRequest)) {
958
+ return "Only a workspace admin can review shared team memory.";
959
+ }
943
960
  // A Cloud home is one person's: a device paired with chat-only access (a
944
961
  // guest) may read along but never answer, or its words would reach the
945
962
  // owner's turn, and through it a lent Mac (docs/cloud-pro.md).
@@ -1236,11 +1253,11 @@ function memberFrameContext(visible) {
1236
1253
  webhookBot: (webhookId) => webhooks.list().find((webhook) => webhook.id === webhookId)?.botId,
1237
1254
  freshBot: (botId) => {
1238
1255
  const bot = store.bot(botId);
1239
- return bot ? { ...wireBot(bot), tasks: store.tasks(bot.id).map(wireTask), ...messagePage(bot.threadId, DEFAULT_PAGE) } : undefined;
1256
+ return bot ? publicBot(bot) : undefined;
1240
1257
  },
1241
1258
  freshGroup: (groupId) => {
1242
1259
  const group = store.group(groupId);
1243
- return group ? { ...publicGroupState(group), ...messagePage(group.threadId, DEFAULT_PAGE) } : undefined;
1260
+ return group ? groupWithThread(group) : undefined;
1244
1261
  },
1245
1262
  };
1246
1263
  }
@@ -1511,6 +1528,8 @@ const openCodeKeysAllowed = () => openCodeProviderKeysAllowed({
1511
1528
  sharedSignIn: sharedSignIn(signInAllowList()),
1512
1529
  });
1513
1530
  setOpenCodeProviderKeyPolicy(openCodeKeysAllowed);
1531
+ // Keys the owner saved for OpenCode in Settings reach it on every server.
1532
+ setOpenCodeOwnProviderKeys(() => openCodeProviderKeys(cfg));
1514
1533
  let openCodeKeysLastAllowed = openCodeKeysAllowed();
1515
1534
  /** OpenCode lists a provider's models only while it may read that
1516
1535
  * provider's key. When enrollment or the sign-in list changes the answer,
@@ -1743,6 +1762,8 @@ utilityParentPort?.on("message", event => {
1743
1762
  });
1744
1763
  });
1745
1764
  const MAX_ATTACHED_FILES_PER_TURN = 10;
1765
+ const MAX_ROOM_POSTS_PER_TURN = 3;
1766
+ const MAX_CREATED_BOTS_PER_TURN = 4;
1746
1767
  // A capability lives for the exact provider-turn generation, including while
1747
1768
  // that turn is parked on a human approval. The long ceiling is only an orphan
1748
1769
  // backstop for an impossible-to-settle adapter; normal terminal paths revoke
@@ -1799,15 +1820,52 @@ function beginInternalCapabilityGeneration(threadId, generation = randomUUID())
1799
1820
  activeInternalGenerationByThread.set(threadId, generation);
1800
1821
  return generation;
1801
1822
  }
1802
- function mintInternalCapability(capability) {
1823
+ // The bearer an integration's proxy holds is part of the engine's launch
1824
+ // contract (Claude's process key, ACP's session inputs): a new one every turn
1825
+ // meant a relaunch, a resumed transcript and new MCP servers every turn. So
1826
+ // the bearer stays the same while the same bot works the same thread with the
1827
+ // same grants and approval level, and only the record behind it is per turn:
1828
+ // between turns, and after any revoke, nothing honours it. A changed grant,
1829
+ // Stop, a stall, a deleted bot or thread or a provider reload gives the next
1830
+ // turn a new bearer, so whatever still holds the old one is never honoured
1831
+ // again.
1832
+ /** threadId → "botId kind" → the bearer and the grants it was minted for. */
1833
+ const sessionCredentials = new Map();
1834
+ /** What a bearer stands for: everything its record grants (a turn's counters
1835
+ * all start at zero), plus the thread's approval level; not the turn. */
1836
+ function sessionGrants({ generation: _turn, ...grants }) {
1837
+ const thread = botForThread(grants.botId, grants.threadId);
1838
+ return JSON.stringify({ ...grants, approval: thread ? approvalModeFor(thread) : null });
1839
+ }
1840
+ /** The thread's bearer for this integration, new only when its grants changed. */
1841
+ function sessionBearer(capability) {
1842
+ const credentials = sessionCredentials.get(capability.threadId) ?? new Map();
1843
+ sessionCredentials.set(capability.threadId, credentials);
1844
+ const slot = `${capability.botId} ${capability.kind}`;
1845
+ const grants = sessionGrants(capability);
1846
+ const held = credentials.get(slot);
1847
+ if (held?.grants === grants)
1848
+ return held.token;
1849
+ const token = randomBytes(24).toString("hex");
1850
+ credentials.set(slot, { grants, token });
1851
+ return token;
1852
+ }
1853
+ function mintInternalCapability(capability, { perTurn = false } = {}) {
1803
1854
  if (activeInternalGenerationByThread.get(capability.threadId) !== capability.generation) {
1804
1855
  throw new Error("cannot mint an integration capability for an inactive turn");
1805
1856
  }
1806
- const token = randomBytes(24).toString("hex");
1807
- internalCapabilities.set(token, {
1808
- ...capability,
1809
- orphanExpiresAt: Date.now() + INTERNAL_CAPABILITY_ORPHAN_MS,
1810
- });
1857
+ // perTurn: a bridge into a machine the harness stops and starts between
1858
+ // turns (a Local VM, the VPS, a Boat). A warm engine never restarts a
1859
+ // server whose machine went away, so such a turn gets a new bearer and
1860
+ // starts its engine fresh.
1861
+ const token = perTurn ? randomBytes(24).toString("hex") : sessionBearer(capability);
1862
+ // Minted twice in one turn: keep that turn's record and its counters.
1863
+ if (internalCapabilities.get(token)?.generation !== capability.generation) {
1864
+ internalCapabilities.set(token, {
1865
+ ...capability,
1866
+ orphanExpiresAt: Date.now() + INTERNAL_CAPABILITY_ORPHAN_MS,
1867
+ });
1868
+ }
1811
1869
  return token;
1812
1870
  }
1813
1871
  /** A turn that was not provably the owner's has ended: judge its bot's
@@ -1841,7 +1899,9 @@ function revokeInternalCapabilityGeneration(threadId, generation) {
1841
1899
  }
1842
1900
  internalGenerationByProviderTurn.deleteGeneration(threadId, generation);
1843
1901
  }
1844
- function revokeInternalCapabilitiesForThread(threadId) {
1902
+ /** Before a new turn on this thread mints: nothing an earlier turn here was
1903
+ * given stays honoured. The thread's bearers stay, so its engine stays warm. */
1904
+ function revokeEarlierTurnCapabilities(threadId) {
1845
1905
  computerSelectionTurns.delete(threadId);
1846
1906
  const generation = activeInternalGenerationByThread.get(threadId);
1847
1907
  if (generation)
@@ -1854,9 +1914,16 @@ function revokeInternalCapabilitiesForThread(threadId) {
1854
1914
  internalCapabilities.delete(token);
1855
1915
  }
1856
1916
  }
1917
+ /** Stop, a stall, a deleted bot or thread, a provider change: the thread's
1918
+ * bearers are retired too, so nothing that still holds one is honoured again. */
1919
+ function revokeInternalCapabilitiesForThread(threadId) {
1920
+ revokeEarlierTurnCapabilities(threadId);
1921
+ sessionCredentials.delete(threadId);
1922
+ }
1857
1923
  function revokeAllInternalCapabilities() {
1858
1924
  computerSelectionTurns.clear();
1859
1925
  internalCapabilities.clear();
1926
+ sessionCredentials.clear();
1860
1927
  // foreignTurns stays: a turn that is not provably the owner's can still
1861
1928
  // write while its engine is torn down, and its own end (which revokes its
1862
1929
  // generation by id) is what judges it (server/lending-memory.ts).
@@ -2020,6 +2087,15 @@ class ComputerWaitParked extends Error {
2020
2087
  this.generation = generation;
2021
2088
  }
2022
2089
  }
2090
+ /** A place the turn was told to use failed to attach: its cause plus the one
2091
+ * control that changes it (placeUnavailable). A cancelled setup, a parked
2092
+ * computer wait and an already-worded place failure pass through as they
2093
+ * are. The one wrapper for a bot thread and a room turn. */
2094
+ function asPlaceFailure(error, place, source, limit) {
2095
+ if (error instanceof DirectTurnSetupCancelled || error instanceof ComputerWaitParked || error instanceof PlaceUnavailableError)
2096
+ return error;
2097
+ return placeUnavailable(place, source, error instanceof Error ? error.message : String(error), limit);
2098
+ }
2023
2099
  const directTurnDispatchClaims = new Map();
2024
2100
  const directTurnGenerationByThread = new Map();
2025
2101
  // Only the latest direct request per thread is retained. A fresh user turn
@@ -2062,173 +2138,14 @@ const turnResources = new TurnResources();
2062
2138
  const sharedComputerControl = new SharedComputerControl(turnResources, () => store.bots.some(bot => botComputerControlSnapshot(bot.id).held));
2063
2139
  const turnResourceOwners = new Map();
2064
2140
  const turnComputerResources = new Map();
2065
- /** Screen-touching computer calls that started before their turn's computer
2066
- * claim was bound (#1653 over the lazy attach of #1361): the start event
2067
- * finds no claim to fence, so it waits here and bindTurnComputer carries it
2068
- * into the fresh claim, keeping the quiet window from releasing a seat
2069
- * under a call the desktop is already executing. */
2070
- const pendingComputerCallStarts = new Map();
2071
2141
  const teamComputerTurns = new Map();
2072
2142
  const settlingResourceOwners = new Map();
2073
- /** Idle release for computer claims (#1653), disabled until the feature
2074
- * flag is on: a seat that stays screen-quiet for the policy's window is
2075
- * released to waiting turns while its holder's turn still lives, and the
2076
- * previous holder re-claims directly inside the reclaim window — it
2077
- * yields once another turn holds the seat. The durable VM profile keeps
2078
- * its login state, so that re-claim is cheap. Read per claim: a flag
2079
- * change applies to new claims, never to a seat held under the old
2080
- * setting. computer:phone is excluded: it is the #1663 exclusivity lock,
2081
- * claimed once per turn with no activity signal to renew it. */
2082
- const COMPUTER_CLAIM_IDLE_POLICY = new IdleReleasePolicy();
2083
- function computerClaimIdlePolicy(resource) {
2084
- return resource.startsWith("computer:") && resource !== "computer:phone" && computerClaimIdleReleaseEnabled(cfg) ? COMPUTER_CLAIM_IDLE_POLICY : undefined;
2085
- }
2086
- /** #1655: the consent ledger for local-wait cloud overflow, and the live
2087
- * cloud seats it has started. One seat per bot — it is the bot's own Box,
2088
- * shared by that bot's threads the way every other computer is. */
2089
- const cloudOverflowConsent = new CloudOverflowConsent();
2090
- const cloudSeatLeases = new Map();
2091
- let cloudSeatSweepTimer;
2092
- /** One stop attempt per seat at a time (#1655): the sweep ticks faster
2093
- * than a Box sleeps, and a second stop call would race the first. */
2094
- const cloudSeatStopsInFlight = new Set();
2095
- /** The waits that may overflow: a turn queued for a local desktop, never
2096
- * one already bound for a cloud or VPS seat. */
2097
- function localComputerWait(resource) {
2098
- return resource === "computer:host" || resource.startsWith("computer:vm:");
2099
- }
2100
- function cloudOverflowSituation(owner, resource, started) {
2101
- if (!localComputerWait(resource))
2102
- return null;
2103
- if (!store.botByThread(owner.threadId))
2104
- return null;
2105
- const perSecondCostUsd = cloudOverflowPerSecondCostUsd(cfg);
2106
- return {
2107
- featureEnabled: cloudOverflowEnabled(cfg),
2108
- // An organisation that refuses the Box kind has nothing to offer, and
2109
- // the decision must fail closed before any card is written.
2110
- cloudConfigured: boat.boatConfigured(cfg) && managedPolicy.computerRefusal("box") === undefined,
2111
- perSecondCostUsd,
2112
- // Consent answers the priced card (#1655): a grant counts only at the
2113
- // rate its card showed, so a config change re-offers instead of
2114
- // starting at a price nobody approved.
2115
- consented: cloudOverflowConsent.consented(owner.threadId, cloudOverflowAllowlistedThreads(cfg), perSecondCostUsd ?? undefined),
2116
- offered: cloudOverflowConsent.offered(owner.threadId, perSecondCostUsd ?? undefined),
2117
- started,
2118
- };
2119
- }
2120
2143
  function claimTurnResource(owner, resource) {
2121
- const idle = computerClaimIdlePolicy(resource);
2122
- if (!turnResources.claim(resource, owner, idle ? { idle } : {}))
2144
+ if (!turnResources.claim(resource, owner))
2123
2145
  return false;
2124
2146
  turnResourceOwners.set(owner.threadId, owner);
2125
2147
  return true;
2126
2148
  }
2127
- /** Start the waiting bot's cloud seat (#1655). True once handled — started
2128
- * or terminally failed — so the wait never retries every poll; false when
2129
- * another turn of this bot owns the Box lifecycle and a later poll may
2130
- * still land. */
2131
- async function startCloudSeat(owner) {
2132
- const bot = store.botByThread(owner.threadId);
2133
- if (!bot)
2134
- return true;
2135
- const perSecondCostUsd = cloudOverflowPerSecondCostUsd(cfg);
2136
- if (!perSecondCostUsd)
2137
- return true;
2138
- const boxBotResource = "computer:box-bot:" + bot.id;
2139
- if (!claimTurnResource(owner, boxBotResource))
2140
- return false;
2141
- try {
2142
- broadcast({ kind: "computer", botId: bot.id, state: "waking" });
2143
- const machine = await boat.readyBoat(cfg, bot.id);
2144
- if (!machine)
2145
- throw new Error("the cloud computer did not wake");
2146
- // Consent can be revoked while the Box wakes (#1655): a seat nobody
2147
- // consented to must not bill. It is released and put straight back to
2148
- // sleep — and if even that fails, leased as already idle so the sweep
2149
- // keeps retrying with backoff.
2150
- if (!cloudOverflowConsent.consented(owner.threadId, cloudOverflowAllowlistedThreads(cfg), perSecondCostUsd)) {
2151
- turnResources.releaseOne(boxBotResource, owner);
2152
- const idleStopMs = cloudOverflowIdleStopMs(cfg);
2153
- void boat.sleepBoat(cfg, bot.id).catch(() => {
2154
- cloudSeatLeases.set(bot.id, new CloudSeatLease({ botId: bot.id, threadId: owner.threadId, generation: owner.generation, now: Date.now() - idleStopMs - 1, idleStopMs }));
2155
- ensureCloudSeatSweep();
2156
- });
2157
- store.appendMessage(owner.threadId, {
2158
- role: "bot", kind: "activity",
2159
- tool: { name: "Cloud overflow consent was revoked while the cloud computer woke; it stops unbilled and the local wait continues.", ok: true },
2160
- });
2161
- return true;
2162
- }
2163
- cloudSeatLeases.set(bot.id, new CloudSeatLease({ botId: bot.id, threadId: owner.threadId, generation: owner.generation, idleStopMs: cloudOverflowIdleStopMs(cfg) }));
2164
- ensureCloudSeatSweep();
2165
- store.appendMessage(owner.threadId, {
2166
- role: "bot", kind: "activity",
2167
- tool: { name: cloudSeatStartedText({ perSecondCostUsd, idleStopMs: cloudOverflowIdleStopMs(cfg) }), ok: true },
2168
- });
2169
- return true;
2170
- }
2171
- catch (error) {
2172
- turnResources.releaseOne(boxBotResource, owner);
2173
- store.appendMessage(owner.threadId, {
2174
- role: "bot", kind: "activity",
2175
- tool: { name: "Cloud computer could not start (" + (error instanceof Error ? error.message : String(error)) + "); the local wait continues.", ok: false },
2176
- });
2177
- return true;
2178
- }
2179
- }
2180
- /** A per-second-billed seat must stop on idleness even when nothing else
2181
- * touches the server, so this slice carries the one timer — unref'd, it
2182
- * can never hold the process open, and it dies with the last lease. */
2183
- function ensureCloudSeatSweep() {
2184
- if (cloudSeatSweepTimer)
2185
- return;
2186
- cloudSeatSweepTimer = setInterval(() => {
2187
- sweepIdleCloudSeats();
2188
- if (!cloudSeatLeases.size && cloudSeatSweepTimer) {
2189
- clearInterval(cloudSeatSweepTimer);
2190
- cloudSeatSweepTimer = undefined;
2191
- }
2192
- }, 15_000);
2193
- cloudSeatSweepTimer.unref?.();
2194
- }
2195
- /** Stop every cloud seat idle past its window (#1655): the machine is
2196
- * archived, billing pauses, and the transcript says which wait's seat it
2197
- * was. Only real computer tool completions refresh a lease — the poller's
2198
- * preview frames never reach the touch call. */
2199
- /** Stop one running cloud seat (#1655): the Box sleeps, and only a
2200
- * successful stop clears the lease and its Box claim — a failed stop
2201
- * stays leased and retryable with backoff, because the seat still bills
2202
- * until it sleeps. The claim releases for the lease's own generation,
2203
- * never a newer turn's. */
2204
- function stopCloudSeat(botId, lease, stoppedName, now = Date.now()) {
2205
- if (cloudSeatStopsInFlight.has(botId))
2206
- return;
2207
- if (cloudSeatLeases.get(botId) !== lease)
2208
- return;
2209
- cloudSeatStopsInFlight.add(botId);
2210
- void boat.sleepBoat(cfg, botId)
2211
- .then(() => {
2212
- if (cloudSeatLeases.get(botId) === lease)
2213
- cloudSeatLeases.delete(botId);
2214
- // The stopped seat's Box claim goes with it, so a still-waiting
2215
- // turn may start a fresh seat for the same bot.
2216
- turnResources.releaseOne(`computer:box-bot:${botId}`, { threadId: lease.threadId, generation: lease.generation });
2217
- store.appendMessage(lease.threadId, { role: "bot", kind: "activity", tool: { name: stoppedName, ok: true } });
2218
- })
2219
- .catch(() => {
2220
- lease.deferSleep(now);
2221
- 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 } });
2222
- })
2223
- .finally(() => cloudSeatStopsInFlight.delete(botId));
2224
- }
2225
- function sweepIdleCloudSeats(now = Date.now()) {
2226
- for (const [botId, lease] of cloudSeatLeases) {
2227
- if (!lease.idleElapsed(now) || !lease.sleepDue(now))
2228
- continue;
2229
- stopCloudSeat(botId, lease, cloudSeatStoppedText(lease.idleFor(now)), now);
2230
- }
2231
- }
2232
2149
  function releaseTurnResources(owner) {
2233
2150
  if (!owner)
2234
2151
  return;
@@ -2241,8 +2158,6 @@ function releaseTurnResources(owner) {
2241
2158
  turnResourceOwners.delete(owner.threadId);
2242
2159
  if (turnComputerResources.get(owner.threadId)?.owner.generation === owner.generation)
2243
2160
  turnComputerResources.delete(owner.threadId);
2244
- if (pendingComputerCallStarts.get(owner.threadId)?.generation === owner.generation)
2245
- pendingComputerCallStarts.delete(owner.threadId);
2246
2161
  const teamTurn = teamComputerTurns.get(owner.threadId);
2247
2162
  if (teamTurn?.owner.generation === owner.generation) {
2248
2163
  stopScreenPoller(teamTurn.botId, owner.threadId);
@@ -2272,10 +2187,6 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2272
2187
  // turn.wait_* events carry the same facts for the inspector log.
2273
2188
  let waitedSince;
2274
2189
  let waitEnded = false;
2275
- // #1655: whether this wait already started (or terminally failed to
2276
- // start) a cloud seat; the decision is re-evaluated every poll because
2277
- // consent can arrive mid-wait.
2278
- let overflowHandled = false;
2279
2190
  const waitEventBase = () => {
2280
2191
  const selection = store.botByThread(owner.threadId)?.modelSelection;
2281
2192
  const instance = selection ? registry.get(selection.instanceId) : null;
@@ -2349,32 +2260,7 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2349
2260
  position,
2350
2261
  ...(holder ? { holder } : {}),
2351
2262
  });
2352
- // #1655: the wait is where an overflow choice gets made. The card
2353
- // names the per-second cost before anything can start, and only a
2354
- // turn genuinely waiting on a local desktop gets one. Fail closed:
2355
- // feature off, cloud unusable, or no operator-set rate means no
2356
- // card at all — Auto never offers, never starts.
2357
- const overflow = cloudOverflowSituation(owner, resource, overflowHandled);
2358
- const perSecondCostUsd = overflow?.perSecondCostUsd ?? null;
2359
- if (overflow && perSecondCostUsd !== null && cloudOverflowAction(overflow).kind === "offer") {
2360
- store.appendMessage(owner.threadId, {
2361
- role: "bot", kind: "activity",
2362
- tool: { name: cloudOverflowOfferText({ perSecondCostUsd, idleStopMs: cloudOverflowIdleStopMs(cfg) }), ok: true },
2363
- });
2364
- cloudOverflowConsent.markOffered(owner.threadId, perSecondCostUsd);
2365
- }
2366
2263
  }
2367
- // #1655: consent can arrive mid-wait — the card's answer, or a
2368
- // thread the operator allowlisted — and only then may the cloud
2369
- // seat start, its cost chip landing beside the waiting one. The
2370
- // local wait itself is untouched: it still ends by acquisition,
2371
- // stop, or the ceiling (parking, once that stack lands).
2372
- if (!overflowHandled) {
2373
- const situation = cloudOverflowSituation(owner, resource, overflowHandled);
2374
- if (situation && cloudOverflowAction(situation).kind === "start")
2375
- overflowHandled = await startCloudSeat(owner);
2376
- }
2377
- sweepIdleCloudSeats();
2378
2264
  if (Date.now() >= deadline) {
2379
2265
  // ADR-2 (#1651): the ceiling parks the turn — settled, with a
2380
2266
  // registered resume — instead of failing it. The owner mark lets the
@@ -2396,15 +2282,6 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2396
2282
  }
2397
2283
  turnResourceOwners.set(owner.threadId, owner);
2398
2284
  turnComputerResources.set(owner.threadId, { owner, resource });
2399
- // A screen call that started before this bind (#1653) fenced nothing
2400
- // because no claim existed yet: carry its pending start into the fresh
2401
- // claim so the idle window still defers to the running call.
2402
- const pendingStarts = pendingComputerCallStarts.get(owner.threadId);
2403
- if (pendingStarts?.generation === owner.generation && pendingStarts.count > 0) {
2404
- pendingComputerCallStarts.delete(owner.threadId);
2405
- for (let i = 0; i < pendingStarts.count; i += 1)
2406
- turnResources.beginComputerCall(resource, owner);
2407
- }
2408
2285
  }
2409
2286
  function botForThread(botId, threadId) {
2410
2287
  return directTurnBots.get(threadId) ?? store.projectBotForTask(botId, threadId) ?? store.bot(botId);
@@ -2450,15 +2327,9 @@ function canAdmitDirectTurn(botId, threadId) {
2450
2327
  /** Routine and webhook dispatch shares startTurn's admission preconditions
2451
2328
  * instead of waiting for whole-bot idleness: a free thread slot and no
2452
2329
  * active group turn. A group turn blocks scheduled starts the same way it
2453
- * blocks every other turn kind; it does not consume a capacity slot.
2454
- *
2455
- * A fresh dispatch also pins to the bot's own project folder, exactly like
2456
- * startTurn's cwd resolution: a free thread slot alone can miss a second
2457
- * task of this bot landing in that one folder (#F-collide). Predicting that
2458
- * collision here lets it defer through the same busy-target machinery as
2459
- * capacity and group-turn contention, instead of reaching startTurn's own
2460
- * workspace claim — which throws deep inside a detached dispatch that no
2461
- * caller here awaits, so the run would otherwise fail permanently. */
2330
+ * blocks every other turn kind; it does not consume a capacity slot. The
2331
+ * bot's project folder is not a slot: a fresh run works there beside the
2332
+ * bot's other threads (see startTurn's cwd resolution). */
2462
2333
  function unattendedDispatchState(botId) {
2463
2334
  const bot = store.bot(botId);
2464
2335
  const decision = admit("unattended", {}, {
@@ -2468,16 +2339,6 @@ function unattendedDispatchState(botId) {
2468
2339
  });
2469
2340
  if (decision.action === "refuse")
2470
2341
  return decision.code === "missing" ? "missing" : "busy";
2471
- if (bot?.cwd) {
2472
- try {
2473
- if (turnResources.blocker(workspaceResource(bot.cwd), { threadId: "", generation: "" }))
2474
- return "busy";
2475
- }
2476
- catch {
2477
- // An unresolvable folder is startTurn's own admission check to
2478
- // report; this prediction only ever adds a defer, never a refusal.
2479
- }
2480
- }
2481
2342
  return "ready";
2482
2343
  }
2483
2344
  function requestedTaskBot(botId, rawThreadId) {
@@ -2724,8 +2585,8 @@ async function browserIntegration(botId, profile, turn) {
2724
2585
  const token = mintInternalCapability({ botId, ...turn, browserSession: session,
2725
2586
  kind: "browser", depth: 0, skillAuthoring: false, createdBots: 0, openedThreads: 0 });
2726
2587
  return { profile: partitionId, session, spec, integration: {
2727
- command: process.execPath, args: [SPAWNED_PROXIES.browser], env: {
2728
- ...AGENTS_NODE_FLAG, OMB_BROWSER_TOKEN: token, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`,
2588
+ command: process.execPath, args: [SPAWNED_PROXIES.harnessMcp, "browser"], env: {
2589
+ ...AGENTS_NODE_FLAG, OMB_MCP_TOKEN: token, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`,
2729
2590
  },
2730
2591
  } };
2731
2592
  }
@@ -2826,8 +2687,14 @@ const routineRequestEnvelopeSchema = z.discriminatedUnion("action", [
2826
2687
  }).strict(),
2827
2688
  ...["pause", "resume", "run_now", "delete"].map((action) => z.object({ ...routineRequestSourceSchema, action: z.literal(action), routineId: z.unknown(), forBotId: z.unknown().optional() }).strict()),
2828
2689
  ]);
2829
- /** The loopback endpoint a bot's computer proxy polls before acting. */
2830
- function controlIntegration(botId, threadId, generation, localVmTarget) {
2690
+ /** The loopback endpoint a bot's computer proxy polls before acting. With a
2691
+ * Boat id, the same turn-scoped token also serves that Boat's tools at
2692
+ * /api/internal/computer/mcp, and is revoked when the turn ends. This
2693
+ * computer runs as long as the app does, so its bridge keeps the thread's
2694
+ * bearer like every other integration; any other machine (a Local VM, the
2695
+ * VPS, a Boat) gets a new bearer each turn. */
2696
+ function controlIntegration(botId, threadId, generation, machine = {}) {
2697
+ const { localVmTarget, boxId } = machine === "this computer" ? {} : machine;
2831
2698
  return {
2832
2699
  url: `http://127.0.0.1:${PORT}/api/internal/computer-control?botId=${encodeURIComponent(botId)}`,
2833
2700
  token: mintInternalCapability({
@@ -2837,11 +2704,12 @@ function controlIntegration(botId, threadId, generation, localVmTarget) {
2837
2704
  depth: 0,
2838
2705
  kind: "computer",
2839
2706
  ...(localVmTarget ? { localVmTarget } : {}),
2707
+ ...(boxId ? { boxId } : {}),
2840
2708
  ...(teamComputerTurns.get(threadId) ? { teamComputerId: teamComputerTurns.get(threadId).computerId } : {}),
2841
2709
  skillAuthoring: false,
2842
2710
  createdBots: 0,
2843
2711
  openedThreads: 0,
2844
- }),
2712
+ }, { perTurn: machine !== "this computer" }),
2845
2713
  };
2846
2714
  }
2847
2715
  function askBotAndWait(targetBotId, message, depth, fromBotId, fromThreadId, targetThreadId) {
@@ -3139,9 +3007,6 @@ function packageImportDeps(selection) {
3139
3007
  return avatarUrl;
3140
3008
  },
3141
3009
  },
3142
- broadcast: (event) => event.kind === "bot"
3143
- ? broadcast({ kind: "bot", bot: publicBot(event.bot) })
3144
- : broadcast({ kind: "group", group: publicGroupState(event.group) }),
3145
3010
  defaultSelection: () => selection,
3146
3011
  presets: presetStore,
3147
3012
  };
@@ -3280,7 +3145,7 @@ const wireTask = (task) => {
3280
3145
  return asleep ? { ...coordinated, snoozedUntil } : coordinated;
3281
3146
  };
3282
3147
  const wireBot = (bot) => {
3283
- const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, assignedSkills: _assignedSkills, ...rest } = bot;
3148
+ const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, assignedSkills: _assignedSkills, ...rest } = bot;
3284
3149
  // An elevated selection is inert until the desktop confirms its exact
3285
3150
  // private reply. Every ordinary client sees the effective Ask state during
3286
3151
  // that two-phase window, never a grant that may still roll back.
@@ -3302,7 +3167,7 @@ function toolScopeForTurn(botId) {
3302
3167
  /** The correlated private response carries the requested value so Electron
3303
3168
  * can validate it before sending the confirmation that makes it effective. */
3304
3169
  const wireTrustedApprovalBot = (bot) => {
3305
- const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, assignedSkills: _assignedSkills, ...rest } = bot;
3170
+ const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, assignedSkills: _assignedSkills, ...rest } = bot;
3306
3171
  return { ...rest, approvalMode: approvalModeFor(rest), avatarUrl: rest.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
3307
3172
  };
3308
3173
  /** A settings-based preview, not a receipt of a dispatched turn. No
@@ -3318,7 +3183,7 @@ function previewSystemPrompt(bot) {
3318
3183
  ]
3319
3184
  .filter(Boolean)
3320
3185
  .join(" ");
3321
- const instance = turnInstance(bot);
3186
+ const instance = registry.get(bot.modelSelection.instanceId);
3322
3187
  const caps = instance?.adapter.capabilities;
3323
3188
  const teamComputer = inheritedTeamComputer(bot);
3324
3189
  const previewComputer = teamComputer ? "cloud" : bot.computer;
@@ -3326,12 +3191,11 @@ function previewSystemPrompt(bot) {
3326
3191
  kind: previewComputer === "vm" ? "vm" : previewComputer === "cloud"
3327
3192
  ? bot.cloudBackend === "vps" ? "vps" : "box" : previewComputer === "local" ? "local" : null,
3328
3193
  driverKind: instance?.driverKind,
3329
- cloudComputerMcp: caps?.cloudComputerMcp,
3330
3194
  vmPrivate: localVmMode(cfg) === "per-bot",
3331
3195
  });
3332
3196
  const peers = reachablePeers(store.bots, bot);
3333
3197
  const coordination = bot.chiefOfStaff
3334
- ? chiefOfStaffSystemPrompt(bot.id, store.bots, true, openMausStatusSystemPrompt())
3198
+ ? chiefOfStaffSystemPrompt(bot.id, store.bots, true)
3335
3199
  : peers.length > 0
3336
3200
  ? peerRosterSystemPrompt(peers)
3337
3201
  : "";
@@ -3367,7 +3231,7 @@ function previewSystemPrompt(bot) {
3367
3231
  browser: previewPlan.computer === undefined ? false : previewPlan.browser,
3368
3232
  }, { note: previewPlan.note, cloudHome: Boolean(CLOUD_HOME) }) },
3369
3233
  { id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(agentsMounted && lendingEnabled()) : "" },
3370
- { id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? composioSystemPrompt(bot.connectorTools) : "" },
3234
+ { id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? composioSystemPrompt(bot.connectorTools) + describeConnectorScopes(bot.connectorScopes) : "" },
3371
3235
  { id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(engineMcpServers(bot))) : "" },
3372
3236
  { id: "browser", label: "Browser", text: previewPlan.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
3373
3237
  { id: "coordination", label: "Team", text: agentsMounted && coordination ? ` ${coordination}` : "" },
@@ -3375,6 +3239,8 @@ function previewSystemPrompt(bot) {
3375
3239
  { id: "routine", label: "Routines", text: agentsMounted ? ROUTINE_PROMPT : "" },
3376
3240
  { id: "profile", label: "Profile changes", text: agentsMounted ? PROFILE_PROMPT : "" },
3377
3241
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
3242
+ { id: "team-memory", label: "Team memory", text: teamMemory.systemPrompt(bot.section) + (agentsMounted ? TEAM_MEMORY_PROMPT : "") },
3243
+ teamAvailabilityPart(agentsMounted && coordination ? peers : []),
3378
3244
  { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: agentsMounted, fileTools: Boolean(privateWorkspace), enabled: bot.memoryEnabled !== false }) },
3379
3245
  { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id, skillsLibraryEnabled(cfg) ? bot.assignedSkills : undefined) : "" },
3380
3246
  ]);
@@ -3952,10 +3818,11 @@ function handleDesktopTrustedApprovalMessage(raw) {
3952
3818
  * fetched immediately and a deleted/guessed attachment id cannot become a
3953
3819
  * dangling profile reference. */
3954
3820
  const storedAvatarExists = (avatarUrl) => attachmentExists(avatarUrl.slice("/api/attachments/".length));
3821
+ /** A bot with its open thread's newest page, for HTTP replies and a
3822
+ * member stream's first frame of it. */
3955
3823
  const publicBot = (bot) => ({
3956
3824
  ...wireBot(bot),
3957
- messages: store.messagesFor(bot.threadId),
3958
- activeLeafId: store.activeLeaf(bot.threadId),
3825
+ ...messagePage(bot.threadId, DEFAULT_PAGE),
3959
3826
  tasks: store.tasks(bot.id).map(wireTask),
3960
3827
  });
3961
3828
  const publicBotQueuedMessages = () => queuedSteerSnapshot((botId, threadId) => Boolean(store.taskByThread(botId, threadId)));
@@ -4277,9 +4144,11 @@ function outstandingAssignmentsPrompt(threadId) {
4277
4144
  requestId: node.id,
4278
4145
  bot: store.bot(node.botId)?.name ?? "Teammate",
4279
4146
  assignment: node.text.slice(0, 1_000),
4280
- status: node.status === "queued" ? "waiting for that teammate to be free" : "working on it now",
4147
+ status: node.status === "queued" ? "waiting for that teammate to be free"
4148
+ : 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"
4149
+ : "working on it now",
4281
4150
  }));
4282
- return ` Assignments you already sent are still outstanding, and nothing in this conversation cancelled them: ${JSON.stringify(listed)}. Do not send them again, 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. Answer the message above with that work still in flight.`;
4151
+ 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.`;
4283
4152
  }
4284
4153
  const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
4285
4154
  validate: (node, parent) => roomHandoffProblem(node, parent) ??
@@ -4353,9 +4222,10 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
4353
4222
  }
4354
4223
  // A successful direct work thread closes after its result is reported:
4355
4224
  // folded out of the sidebar, never deleted, and reopened if addressed.
4356
- // Failed or withheld work stays visible.
4225
+ // Failed or withheld work stays visible, and so does a thread whose
4226
+ // next request is already waiting there.
4357
4227
  const childTask = store.taskByThread(child.botId, child.threadId);
4358
- if (!child.groupId && child.status === "completed" && !problem && !childTask?.closedBy
4228
+ if (!child.groupId && child.status === "completed" && !problem && !childTask?.closedBy && !roomHandoffs.activeDirect(child.threadId)
4359
4229
  && childTask?.openedBy?.kind === "work" && childTask.openedBy.botId === parent.botId) {
4360
4230
  store.setTaskClosedBy(child.botId, child.threadId, { botId: parent.botId, name: store.bot(parent.botId)?.name ?? childTask.openedBy.name, at: Date.now() });
4361
4231
  }
@@ -4789,18 +4659,33 @@ function activeGroupTurnForBot(botId) {
4789
4659
  }
4790
4660
  return null;
4791
4661
  }
4662
+ /** A room with its open thread's newest page, for HTTP replies and a
4663
+ * member stream's first frame of it. */
4792
4664
  const groupWithThread = (group) => ({
4793
4665
  ...publicGroupState(group),
4794
- messages: store.messagesFor(group.threadId),
4795
- activeLeafId: store.activeLeaf(group.threadId),
4666
+ ...messagePage(group.threadId, DEFAULT_PAGE),
4796
4667
  ...(group.dm ? {} : { tasks: store.groupTasks(group.id) }),
4797
4668
  });
4798
4669
  // The store tells us what it wrote; this is the ONE place that turns those
4799
4670
  // into SSE frames. No mutation path can persist without emitting — the
4800
4671
  // property holds by construction, not by every call site remembering to
4801
- // broadcast. Bot frames are the slim wire shape (no transcript); the few
4802
- // endpoints whose callers need the transcript (task create/switch, imports)
4803
- // still send their richer payload on top.
4672
+ // broadcast.
4673
+ //
4674
+ // A bot or room frame carries its open thread's newest page only when that
4675
+ // thread is not the one it last went out with: a switch, a new open thread,
4676
+ // deleting the open one, a new bot or room. Other windows swap transcripts
4677
+ // on exactly those frames (one whose open thread was deleted keeps its
4678
+ // composer locked until a page arrives). Every other frame stays slim, and
4679
+ // clients keep the transcript they hold when a frame carries none.
4680
+ const sentThreads = new Map();
4681
+ for (const record of [...store.bots, ...store.groups])
4682
+ sentThreads.set(record.id, record.threadId);
4683
+ function pageIfThreadChanged(id, threadId) {
4684
+ if (sentThreads.get(id) === threadId)
4685
+ return {};
4686
+ sentThreads.set(id, threadId);
4687
+ return messagePage(threadId, DEFAULT_PAGE);
4688
+ }
4804
4689
  store.onChange((change) => {
4805
4690
  // Who owns which thread, and what each member may see, follow the fleet.
4806
4691
  if (change.type !== "message" && change.type !== "message.patch" && change.type !== "thread" && change.type !== "sections")
@@ -4809,11 +4694,12 @@ store.onChange((change) => {
4809
4694
  case "sections":
4810
4695
  broadcast({ kind: "sections", sections: store.sections });
4811
4696
  break;
4697
+ // Live frames never carry screenshot pixels; clients load them by URL.
4812
4698
  case "message":
4813
- broadcast({ kind: "message", threadId: change.threadId, message: change.message });
4699
+ broadcast({ kind: "message", threadId: change.threadId, message: slimMessage(change.message) });
4814
4700
  break;
4815
4701
  case "message.patch":
4816
- broadcast({ kind: "message.patch", threadId: change.threadId, message: change.message });
4702
+ broadcast({ kind: "message.patch", threadId: change.threadId, message: slimMessage(change.message) });
4817
4703
  break;
4818
4704
  case "thread":
4819
4705
  broadcast({ kind: "thread", threadId: change.threadId, activeLeafId: change.activeLeafId });
@@ -4833,7 +4719,7 @@ store.onChange((change) => {
4833
4719
  case "bot": {
4834
4720
  const bot = store.bot(change.botId);
4835
4721
  if (bot)
4836
- broadcast({ kind: "bot", bot: wireBot(bot) });
4722
+ broadcast({ kind: "bot", bot: { ...wireBot(bot), ...pageIfThreadChanged(bot.id, bot.threadId) } });
4837
4723
  // A new audience narrows the rooms this bot is in, for good.
4838
4724
  if (bot)
4839
4725
  for (const group of store.groups.filter((candidate) => candidate.memberIds.includes(bot.id)))
@@ -4841,6 +4727,7 @@ store.onChange((change) => {
4841
4727
  break;
4842
4728
  }
4843
4729
  case "bot.deleted":
4730
+ sentThreads.delete(change.botId);
4844
4731
  try {
4845
4732
  commandAllowlist.clear(change.botId);
4846
4733
  }
@@ -4852,12 +4739,13 @@ store.onChange((change) => {
4852
4739
  case "group": {
4853
4740
  const group = store.group(change.groupId);
4854
4741
  if (group)
4855
- broadcast({ kind: "group", group: publicGroupState(group) });
4742
+ broadcast({ kind: "group", group: { ...publicGroupState(group), ...pageIfThreadChanged(group.id, group.threadId) } });
4856
4743
  if (group)
4857
4744
  settleRoomFloor(group);
4858
4745
  break;
4859
4746
  }
4860
4747
  case "group.deleted":
4748
+ sentThreads.delete(change.groupId);
4861
4749
  broadcast({ kind: "group.deleted", groupId: change.groupId });
4862
4750
  break;
4863
4751
  }
@@ -4882,12 +4770,13 @@ function pageSize(raw) {
4882
4770
  return null;
4883
4771
  return Math.min(size, MESSAGE_PAGE_MAX);
4884
4772
  }
4885
- /** A screen message without its pixels. The client fetches those from
4886
- * `/api/threads/:threadId/messages/:id/image` when it actually shows one. */
4773
+ /** A screen message without its pixels, for live frames and pages alike.
4774
+ * The client fetches those from `/api/threads/:threadId/messages/:id/image`
4775
+ * when it actually shows one; `mime` stays so it can name a download. */
4887
4776
  function slimMessage(message) {
4888
4777
  if (message.kind !== "screen" || !message.png)
4889
4778
  return message;
4890
- const { png: _png, mime: _mime, ...rest } = message;
4779
+ const { png: _png, ...rest } = message;
4891
4780
  return { ...rest, hasImage: true };
4892
4781
  }
4893
4782
  /** `limit === undefined` is the original, unpaginated shape. A bounded,
@@ -5280,7 +5169,10 @@ always, rememberCommand) {
5280
5169
  }
5281
5170
  if (messageId)
5282
5171
  askMessageByRequest.delete(`${threadId}:${requestId}`);
5283
- if (!persistentQuestion)
5172
+ // The card's own answered state gates the message too: a replayed respond
5173
+ // for a card that already settled must not read as a failed delivery —
5174
+ // nothing was lost, the earlier answer already ran.
5175
+ if (!persistentQuestion && !existing?.card?.answered)
5284
5176
  store.appendMessage(threadId, {
5285
5177
  role: "bot",
5286
5178
  kind: "activity",
@@ -5291,7 +5183,7 @@ always, rememberCommand) {
5291
5183
  }
5292
5184
  /** A decision or answer on a provider/peer card, made on behalf of `auth`
5293
5185
  * (a Live call answers as the person who started the call). Harness-native
5294
- * proposals (skill, routine, profile, default model, tightening, team setup)
5186
+ * proposals (skill, routine, profile, default model, team setup)
5295
5187
  * are not handled here; they are reviewed on screen (liveDecisionRefusal).
5296
5188
  * Mirrors POST /api/threads/:id/respond. */
5297
5189
  async function respondToCard(input) {
@@ -5450,10 +5342,7 @@ const watchdog = new TurnWatchdog({
5450
5342
  repeats.settle(turn.threadId);
5451
5343
  const bot = botForThread(turn.botId, turn.threadId);
5452
5344
  const routineRun = activeRoutineRunForThread(turn.threadId);
5453
- const instance = bot
5454
- ? runningTurnInstance(bot, turn.threadId, routineRun?.runOn)
5455
- : routineRun?.runOn === "cloud" ? registry.instances().find((candidate) => candidate.driverKind === "boxAgent") : null;
5456
- void instance?.adapter.interruptTurn(turn.threadId).catch(() => { });
5345
+ void runningTurnInstance(bot, turn.threadId)?.adapter.interruptTurn(turn.threadId).catch(() => { });
5457
5346
  const minutes = Math.round(TURN_STALL_MS / 60_000);
5458
5347
  if (routineRun?.target === "bot") {
5459
5348
  routines?.failThread(turn.threadId, `No activity for ${minutes} minutes — the routine was stopped`);
@@ -5463,7 +5352,7 @@ const watchdog = new TurnWatchdog({
5463
5352
  store.appendMessage(turn.threadId, {
5464
5353
  role: "bot",
5465
5354
  kind: "activity",
5466
- tool: { name: `error: no activity for ${minutes} minutes — the turn was stopped`, ok: false },
5355
+ tool: failedTurnTool(`no activity for ${minutes} minutes — the turn was stopped`),
5467
5356
  });
5468
5357
  // a routine's stall reports through its own failure path
5469
5358
  if (bot && routineRun?.target !== "bot") {
@@ -5743,6 +5632,8 @@ bus.subscribe((event) => {
5743
5632
  });
5744
5633
  // Per-thread state the digest needs from before the turn: when it started,
5745
5634
  // and the checkpoint taken at dispatch (hash + folder) so settle can diff.
5635
+ // `pin` is the dispatch that pinned that commit in the shadow repo, so a
5636
+ // sibling thread's snapshot in the same folder cannot reclaim it mid-turn.
5746
5637
  const turnStartedAt = new Map();
5747
5638
  const turnCheckpoints = new Map();
5748
5639
  // The turn a thread is in, from the last event that named one. Not every
@@ -5750,9 +5641,18 @@ const turnCheckpoints = new Map();
5750
5641
  // a digest can only count activity it can attribute — so an unstamped tool
5751
5642
  // row is attributed to the thread's live turn instead of to nothing.
5752
5643
  const liveTurnByThread = new Map();
5644
+ /** Forget a thread's pre-turn checkpoint and drop its pin, so the shadow
5645
+ * repo's next cleanup may reclaim that commit. */
5646
+ function forgetTurnCheckpoint(threadId) {
5647
+ const checkpoint = turnCheckpoints.get(threadId);
5648
+ if (!checkpoint)
5649
+ return;
5650
+ turnCheckpoints.delete(threadId);
5651
+ void checkpoints.release(checkpoint.botId, checkpoint.cwd, checkpoint.pin);
5652
+ }
5753
5653
  function clearTurnDigestState(threadId) {
5754
5654
  turnStartedAt.delete(threadId);
5755
- turnCheckpoints.delete(threadId);
5655
+ forgetTurnCheckpoint(threadId);
5756
5656
  liveTurnByThread.delete(threadId);
5757
5657
  memoryRowsByThread.delete(threadId);
5758
5658
  }
@@ -5821,9 +5721,8 @@ function ingestEngineHook(capability, body) {
5821
5721
  }
5822
5722
  return { ok: true };
5823
5723
  }
5824
- /** Persist observed tools and memory immediately. Keep a direct turn's
5825
- * workspace claimed while its bounded file snapshot settles, then enrich
5826
- * that same row only if its task and active branch still exist. */
5724
+ /** Persist observed tools and memory immediately, then enrich that same
5725
+ * row only if its task and active branch still exist. */
5827
5726
  async function scheduleTurnDigest(input) {
5828
5727
  const at = Date.now();
5829
5728
  const startedAt = turnStartedAt.get(input.threadId);
@@ -5868,6 +5767,8 @@ async function scheduleTurnDigest(input) {
5868
5767
  }
5869
5768
  if (!checkpoint)
5870
5769
  return;
5770
+ // A folder-level diff: when a sibling thread of this bot works in the
5771
+ // same folder, its edits since this turn's snapshot are listed here too.
5871
5772
  const controller = new AbortController();
5872
5773
  let timer;
5873
5774
  const capture = async () => {
@@ -5891,6 +5792,12 @@ async function scheduleTurnDigest(input) {
5891
5792
  catch (error) {
5892
5793
  console.error(`digest: could not record turn ${input.turnId} on ${input.threadId}:`, error instanceof Error ? error.message : error);
5893
5794
  }
5795
+ finally {
5796
+ // The pre-turn commit has had its last reader (the diff above is queued
5797
+ // ahead of this in the shadow repo); the next snapshot may reclaim it.
5798
+ if (checkpoint)
5799
+ void checkpoints.release(checkpoint.botId, checkpoint.cwd, checkpoint.pin);
5800
+ }
5894
5801
  }
5895
5802
  // Bots currently working with nobody at the keyboard — a webhook turn, or a
5896
5803
  // turn a webhook-driven bot handed to a teammate. Auto mode is a decision
@@ -6026,9 +5933,6 @@ const vpsJoinRequests = new Map();
6026
5933
  const orphanBoatLifecycleBusyIds = new Set();
6027
5934
  const boatInventoryRequestsBusyIds = new Set();
6028
5935
  const computerProviderConfigTransitions = new Set();
6029
- // A restore mutates and cleans a project work tree. Claim the bot across the
6030
- // entire async Git operation so a turn cannot start in that folder midway.
6031
- const checkpointRestoreLeases = new Set();
6032
5936
  /** The effective idle window, read from config so a Settings change applies
6033
5937
  * to both new and already-armed timers (see the config route). */
6034
5938
  const localVmIdleMs = () => localVmIdleTimeoutMinutes(cfg) * 60_000;
@@ -6093,10 +5997,6 @@ async function computerCallGate(internalCapability) {
6093
5997
  };
6094
5998
  }
6095
5999
  const computer = turnComputerResources.get(internalCapability.threadId);
6096
- // The screen call arriving here re-claims an idle-released seat directly
6097
- // (#1653): inside the reclaim window the previous holder picks its seat
6098
- // back up without re-entering the wait, and the successful claim
6099
- // restarts the quiet window. A seat another turn holds still refuses.
6100
6000
  if (!snapshot.held && computer && computer.owner.generation === internalCapability.generation &&
6101
6001
  !claimTurnResource(computer.owner, computer.resource)) {
6102
6002
  return {
@@ -6176,20 +6076,43 @@ async function attachTeamBoat(computer, botId, owner, canMount, remoteAgent) {
6176
6076
  if (!machine)
6177
6077
  throw new Error("The team's Boat computer is missing; explicitly create or retry it from the Team map");
6178
6078
  await bindTurnComputer(owner, `computer:box:${machine.id}`, true);
6179
- const action = boat.boatTurnLifecycleAction({ explicitCloud: true, canMount: true, state: typeof machine.state === "string" ? machine.state : null });
6079
+ const action = boat.boatTurnLifecycleAction({ explicitCloud: true, state: typeof machine.state === "string" ? machine.state : null });
6180
6080
  if (action === "wake")
6181
6081
  machine = await boat.readyBoat(cfg, ownerId);
6182
- if (!machine || boat.boatTurnLifecycleAction({ explicitCloud: true, canMount: true, state: typeof machine.state === "string" ? machine.state : null }) !== "attach") {
6082
+ if (!machine || boat.boatTurnLifecycleAction({ explicitCloud: true, state: typeof machine.state === "string" ? machine.state : null }) !== "attach") {
6183
6083
  throw new Error("The team computer is not ready; check it in the Team map");
6184
6084
  }
6185
6085
  if (turnResourceOwners.get(owner.threadId)?.generation !== owner.generation ||
6186
6086
  !turnResources.owns(`computer:box:${machine.id}`, owner))
6187
6087
  throw new Error("This computer turn ended while its machine was starting");
6188
6088
  return {
6189
- integration: { kind: "box", boxId: machine.id, token: boat.boatAccount(cfg)?.token ?? "", control: controlIntegration(botId, owner.threadId, owner.generation) },
6089
+ mount: cloudComputerMount(botId, owner, machine.id, remoteAgent),
6190
6090
  capture: () => boat.screenshotBoat(cfg, ownerId, machine.id),
6191
6091
  };
6192
6092
  }
6093
+ /** The two engine facts the cloud-computer rule reads (shared/cloud-computer.ts). */
6094
+ function cloudEngine(instance) {
6095
+ return { driverKind: instance?.driverKind, computerMcp: instance?.adapter.capabilities.computerMcp };
6096
+ }
6097
+ /** How a turn's engine reaches a cloud computer. The Computer engine runs its
6098
+ * whole turn on the Boat. Every other engine keeps its own model and sign-in
6099
+ * and gets the Boat as one more stdio computer server (harness-mcp-proxy
6100
+ * computer), in the same slot a Local VM or VPS uses: one mechanism on a
6101
+ * desktop, a headless server and an OMB Cloud. The agent process holds only a
6102
+ * turn-scoped capability naming this Boat, never a Boat credential. */
6103
+ function cloudComputerMount(botId, owner, boxId, remoteAgent) {
6104
+ if (remoteAgent)
6105
+ return { computer: { kind: "box", boxId } };
6106
+ const control = controlIntegration(botId, owner.threadId, owner.generation, { boxId });
6107
+ return {
6108
+ localComputer: {
6109
+ command: process.execPath,
6110
+ args: [SPAWNED_PROXIES.harnessMcp, "computer"],
6111
+ env: { ...AGENTS_NODE_FLAG, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`, OMB_MCP_TOKEN: control.token },
6112
+ platform: "linux",
6113
+ },
6114
+ };
6115
+ }
6193
6116
  /** "Works on: This computer", strict. One mount for a bot thread and a group
6194
6117
  * chat, so the two cannot drift on what they check or how they fail. */
6195
6118
  async function mountHostComputer(owner, botId, providerSupportsLocal) {
@@ -6208,7 +6131,7 @@ async function mountHostComputer(owner, botId, providerSupportsLocal) {
6208
6131
  : "CUA Driver is not ready for this computer — check permissions and restart OpenMausBot");
6209
6132
  }
6210
6133
  await bindTurnComputer(owner, "computer:host");
6211
- return gatedLocalComputer(cua, controlIntegration(botId, owner.threadId, owner.generation));
6134
+ return gatedLocalComputer(cua, controlIntegration(botId, owner.threadId, owner.generation, "this computer"));
6212
6135
  }
6213
6136
  /** The bot's own VPS desktop, mounted into the local agent. The desktop lease
6214
6137
  * is claimed on the first computer call, through the computer-control gate
@@ -6248,7 +6171,9 @@ async function mountBotVps(bot, owner, opts) {
6248
6171
  }
6249
6172
  /** The bot's own Boat. Explicit Cloud is the consent boundary: it may create a
6250
6173
  * missing machine and wake an archived one (~8s, and it un-pauses billing);
6251
- * Auto only attaches a machine that is already running. */
6174
+ * Auto (only ever the Computer engine, see startTurn) only attaches a machine
6175
+ * that is already running. Callers have already checked that the engine can
6176
+ * work on it (cloudPlaceDriverError). */
6252
6177
  async function attachBotBoat(bot, owner, opts) {
6253
6178
  // Explicit cloud turns can provision/wake the same bot's Boat. Claim before
6254
6179
  // any network await so setup itself cannot race another turn.
@@ -6267,7 +6192,6 @@ async function attachBotBoat(bot, owner, opts) {
6267
6192
  }
6268
6193
  const lifecycleOf = (explicitCloud) => boat.boatTurnLifecycleAction({
6269
6194
  explicitCloud,
6270
- canMount: opts.canMount,
6271
6195
  state: typeof b?.state === "string" ? b.state : null,
6272
6196
  });
6273
6197
  let lifecycle = lifecycleOf(opts.explicitCloud);
@@ -6291,9 +6215,7 @@ async function attachBotBoat(bot, owner, opts) {
6291
6215
  await bindTurnComputer(owner, `computer:box:${machine.id}`, opts.remoteAgent);
6292
6216
  return {
6293
6217
  capture: () => boat.screenshotBoat(cfg, bot.id, machine.id),
6294
- integration: opts.canMount
6295
- ? { kind: "box", boxId: machine.id, token: boat.boatAccount(cfg)?.token ?? "", control: controlIntegration(bot.id, owner.threadId, owner.generation) }
6296
- : null,
6218
+ mount: cloudComputerMount(bot.id, owner, machine.id, opts.remoteAgent),
6297
6219
  };
6298
6220
  }
6299
6221
  function managedBoatOwners() {
@@ -6370,17 +6292,6 @@ function turnProvider(bot, runOn, threadId) {
6370
6292
  return "box";
6371
6293
  return bot.cloudBackend === "vps" ? "vps" : wants === "cloud" ? "box" : null;
6372
6294
  }
6373
- /** A driver with a Boat bridge keeps the selected model. Other engines use
6374
- * Boat's native runner. Start and interrupt must resolve the same owner. */
6375
- function turnInstance(bot, runOn, threadId) {
6376
- const selected = registry.get(bot.modelSelection.instanceId);
6377
- if (selected?.adapter.capabilities.cloudComputerMcp)
6378
- return selected;
6379
- const onBoat = turnProvider(bot, runOn, threadId) === "box";
6380
- return onBoat
6381
- ? registry.instances().find((candidate) => candidate.driverKind === "boxAgent") ?? null
6382
- : registry.get(bot.modelSelection.instanceId);
6383
- }
6384
6295
  /** Preview requests carry the selected conversation, not whichever thread
6385
6296
  * happens to be the bot's default. A query never grants lifecycle authority. */
6386
6297
  function computerPreviewBot(botId, url) {
@@ -6423,7 +6334,8 @@ async function computerPreviewSurface(bot, threadId) {
6423
6334
  * A Cloud home lists only the places it has (cloud-home.ts), so a bot never
6424
6335
  * tells the person to set up this computer or a Local VM there. */
6425
6336
  async function selectableComputers(bot) {
6426
- const caps = registry.get(bot.modelSelection.instanceId)?.adapter.capabilities;
6337
+ const instance = registry.get(bot.modelSelection.instanceId);
6338
+ const caps = instance?.adapter.capabilities;
6427
6339
  const off = bot.computer === "off";
6428
6340
  const localEngine = caps?.remoteAgent !== true;
6429
6341
  const surfaces = ["cloud", "vm", "local", "browser"].filter(surface => !CLOUD_HOME || cloudHomeOffersPlace(surface));
@@ -6441,16 +6353,16 @@ async function selectableComputers(bot) {
6441
6353
  reason = "Computer access is Off in this bot's settings.";
6442
6354
  else if (surface === "cloud") {
6443
6355
  if (bot.cloudBackend === "vps") {
6444
- const status = localEngine && caps?.computerMcp ? await vps.vpsComputerStatus(cfg, bot.id) : null;
6356
+ const status = canWorkOnCloud(cloudEngine(instance), "vps") ? await vps.vpsComputerStatus(cfg, bot.id) : null;
6445
6357
  ready = status?.ready === true;
6446
6358
  canStart = Boolean(status?.daemonUp && status.managed && status.container === "stopped" &&
6447
6359
  status.image && status.imageMatches && status.network === "private" && status.mounts === "none" && status.security === "hardened");
6448
6360
  canCreate = Boolean(status?.configured && status.daemonUp && status.container === "missing");
6449
6361
  reason = status?.problem ?? reason;
6450
6362
  }
6451
- else if (boat.boatConfigured(cfg) && (caps?.usesCloudComputer === true || registry.instances().some(instance => instance.driverKind === "boxAgent"))) {
6363
+ else if (boat.boatConfigured(cfg) && canWorkOnCloud(cloudEngine(instance), "box")) {
6452
6364
  const status = await boat.boatStatus(cfg, bot.id);
6453
- const lifecycle = boat.boatTurnLifecycleAction({ explicitCloud: true, canMount: true, state: status.box?.state ?? null });
6365
+ const lifecycle = boat.boatTurnLifecycleAction({ explicitCloud: true, state: status.box?.state ?? null });
6454
6366
  ready = lifecycle === "attach";
6455
6367
  canStart = lifecycle === "wake";
6456
6368
  canCreate = lifecycle === "provision";
@@ -6527,8 +6439,9 @@ function continueComputerSelection(threadId, generation, succeeded) {
6527
6439
  /** The engine that dispatched each live turn. A bot's settings may change
6528
6440
  * mid-turn; an interrupt or steer must reach the engine actually running. */
6529
6441
  const runningTurnEngines = new Map();
6530
- function runningTurnInstance(bot, threadId, runOn) {
6531
- return runningTurnEngines.get(threadId) ?? turnInstance(bot, runOn, threadId);
6442
+ function runningTurnInstance(bot, threadId) {
6443
+ // A turn always runs on the bot's own engine, wherever it works.
6444
+ return runningTurnEngines.get(threadId) ?? (bot ? registry.get(bot.modelSelection.instanceId) : null);
6532
6445
  }
6533
6446
  function providerTransitionForTurn(bot, runOn, threadId) {
6534
6447
  const provider = turnProvider(bot, runOn, threadId);
@@ -6909,38 +6822,6 @@ bus.subscribe((event) => {
6909
6822
  if (touches || /computer|screenshot|click|type_text|press_key|scroll|open_url|wait_for|browser_/i.test(toolName)) {
6910
6823
  pokeScreenPoller(event.threadId, touches, surface);
6911
6824
  }
6912
- // A completed screen-touching computer tool is real screen
6913
- // activity (#1653): it restarts the idle clock on that turn's
6914
- // computer claim. The poller's own frames never arrive here, so
6915
- // they cannot keep a quiet seat held.
6916
- if (touches && surface === "computer") {
6917
- const computer = turnComputerResources.get(event.threadId);
6918
- if (computer) {
6919
- // A quiet window cannot expire while this call executes.
6920
- turnResources.endComputerCall(computer.resource, computer.owner);
6921
- turnResources.activity(computer.resource, computer.owner);
6922
- }
6923
- else {
6924
- // The call never forwarded onto a bound desktop, or the lazy
6925
- // bind never landed: drop its pending start so it cannot
6926
- // fence a claim taken later (#1653).
6927
- const pending = pendingComputerCallStarts.get(event.threadId);
6928
- if (pending) {
6929
- if (pending.count > 1)
6930
- pendingComputerCallStarts.set(event.threadId, { ...pending, count: pending.count - 1 });
6931
- else
6932
- pendingComputerCallStarts.delete(event.threadId);
6933
- }
6934
- }
6935
- // Real cloud-screen work refreshes the #1655 idle stop for
6936
- // that seat; the poller's own frames never arrive here, so
6937
- // preview traffic cannot keep a paid machine awake.
6938
- if (computer?.resource.startsWith("computer:box:")) {
6939
- const seatBot = store.botByThread(event.threadId);
6940
- if (seatBot)
6941
- cloudSeatLeases.get(seatBot.id)?.touch();
6942
- }
6943
- }
6944
6825
  }
6945
6826
  }
6946
6827
  break;
@@ -6963,26 +6844,6 @@ bus.subscribe((event) => {
6963
6844
  });
6964
6845
  if (event.itemId)
6965
6846
  toolMessageByItem.set(`${event.threadId}:${event.itemId}`, message.id);
6966
- // A screen-touching computer tool opening is an in-flight call on
6967
- // that turn's claim (#1653): the seat cannot be idle-released out
6968
- // from under I/O the desktop is still executing. Its completion —
6969
- // or the turn settling — drops the fence.
6970
- if (screenTouchingTool(name) && screenSurfaceForTool(name) === "computer") {
6971
- const computer = turnComputerResources.get(event.threadId);
6972
- if (computer) {
6973
- turnResources.beginComputerCall(computer.resource, computer.owner);
6974
- }
6975
- else {
6976
- // A lazy attach (#1361) may not have seated the claim yet: the
6977
- // gate finishes the bind before the call forwards, so hold the
6978
- // start here and let bindTurnComputer fence the fresh claim.
6979
- const generation = activeInternalGenerationByThread.get(event.threadId);
6980
- if (generation) {
6981
- const pending = pendingComputerCallStarts.get(event.threadId);
6982
- pendingComputerCallStarts.set(event.threadId, pending?.generation === generation ? { generation, count: pending.count + 1 } : { generation, count: 1 });
6983
- }
6984
- }
6985
- }
6986
6847
  }
6987
6848
  break;
6988
6849
  case "request.opened": {
@@ -6994,8 +6855,9 @@ bus.subscribe((event) => {
6994
6855
  // A permission request here is one the provider left for a person: its
6995
6856
  // own mode already ran (Ask, Edits, Auto's reviewer, Custom's config).
6996
6857
  // Only the person's explicit Full access or exact saved command answers.
6997
- // The app does not guess whether the action is safe. A QUESTION
6998
- // always reaches the human — even Full access never invents an answer.
6858
+ // Approve for me allows a web search. The app does not guess whether
6859
+ // the action is safe. A QUESTION always reaches the human — even Full
6860
+ // access never invents an answer.
6999
6861
  const asker = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
7000
6862
  const unattended = permission && asker && event.requestId ? isUnattended(asker.id, event.threadId) : false;
7001
6863
  // A turn a guest drives on a Cloud home is Ask, room turns included,
@@ -7057,9 +6919,9 @@ bus.subscribe((event) => {
7057
6919
  if (outcome !== "unavailable")
7058
6920
  pushMessage({
7059
6921
  role: "bot", kind: "activity",
7060
- tool: { name: outcome === "rejected"
7061
- ? `The provider rejected this action despite ${verdict.source === "command-allowlist" ? "the saved command permission" : "Full access"}.`
7062
- : "error: could not deliver approval to the provider; retry the task after reconnecting.", ok: false },
6922
+ tool: outcome === "rejected"
6923
+ ? { name: `The provider rejected this action despite ${verdict.source === "command-allowlist" ? "the saved command permission" : "Full access"}.`, ok: false }
6924
+ : failedTurnTool("could not deliver approval to the provider; retry the task after reconnecting."),
7063
6925
  });
7064
6926
  return;
7065
6927
  }
@@ -7151,6 +7013,8 @@ bus.subscribe((event) => {
7151
7013
  store.setTaskActivity(bot.id, event.threadId, "waiting-on-you");
7152
7014
  else if (asker.busy)
7153
7015
  store.setActivity(asker.id, "waiting-on-you");
7016
+ if (event.requestId)
7017
+ showWaitingOnPersonChip(event.threadId, event.requestId, asker, permission ? "approval" : "answer");
7154
7018
  const notificationBot = (routineRun && routineSourceOwner(routineRun)?.bot) || asker;
7155
7019
  notify(buildNotification(permission ? "approval" : "question", notificationBot, (routineRun && routineSourceThread(routineRun)) || event.threadId, event.summary));
7156
7020
  }
@@ -7160,6 +7024,8 @@ bus.subscribe((event) => {
7160
7024
  case "request.resolved": {
7161
7025
  if (event.requestId)
7162
7026
  pendingCommandRules.delete(`${event.threadId}:${event.requestId}`);
7027
+ if (event.requestId)
7028
+ settleWaitingOnPersonChips(event.threadId, event.requestId);
7163
7029
  // answered (by whoever): the turn is working again, unless it settled
7164
7030
  const waiting = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
7165
7031
  if (bot && store.taskByThread(bot.id, event.threadId)?.activity === "waiting-on-you") {
@@ -7205,14 +7071,7 @@ bus.subscribe((event) => {
7205
7071
  pushMessage({
7206
7072
  role: "bot",
7207
7073
  kind: "activity",
7208
- tool: {
7209
- // a setup error carries its fix, which must not be cut mid-command
7210
- name: `error: ${event.message.slice(0, event.setup ? 320 : 160)}`,
7211
- ok: false,
7212
- setup: event.setup,
7213
- ...(event.terminal ? { terminal: true } : {}),
7214
- ...(event.claudeUpdate ? { claudeUpdate: true } : {}),
7215
- },
7074
+ tool: failedTurnTool(event.message, event),
7216
7075
  });
7217
7076
  // a setup error means the engine could not even start: the bot is
7218
7077
  // dead until something changes, not merely idle. The next successful
@@ -7230,6 +7089,7 @@ bus.subscribe((event) => {
7230
7089
  }
7231
7090
  break;
7232
7091
  case "turn.completed": {
7092
+ settleWaitingOnPersonChips(event.threadId);
7233
7093
  // A peer-started turn settles as coordination, not as news. What keeps
7234
7094
  // that classification from outliving its turn is the rewrite at
7235
7095
  // dispatch, not this line — releasing it here too is hygiene, so a
@@ -7447,8 +7307,8 @@ bus.subscribe((event) => {
7447
7307
  // still be in flight).
7448
7308
  //
7449
7309
  // Keep the thread busy until its bounded final capture releases
7450
- // the screen AND workspace. Otherwise an accepted follow-up races
7451
- // these claims and fails as though another thread owned its folder.
7310
+ // the screen: an accepted follow-up must not take the desktop
7311
+ // before the END-state frame is captured.
7452
7312
  const settleLeafId = store.activePath(event.threadId).at(-1)?.id;
7453
7313
  let timeout;
7454
7314
  const screenSettled = Promise.race([
@@ -7540,6 +7400,57 @@ bus.subscribe((event) => {
7540
7400
  * channel that only ever shows requests is half a record. Mirror the
7541
7401
  * reply on success; mirror a failed/stopped terminal chip otherwise. */
7542
7402
  const delegationWatch = new Map();
7403
+ /** The card in this thread that is waiting on the person: an approval, a
7404
+ * question or a proposal to review, newest first. */
7405
+ function openPersonCard(threadId) {
7406
+ const messages = store.messagesFor(threadId);
7407
+ for (let i = messages.length - 1; i >= 0; i--) {
7408
+ const card = messages[i].kind === "options" ? messages[i].card : undefined;
7409
+ const kind = liveCardKind(card);
7410
+ if (kind)
7411
+ return { kind, tool: card?.tool };
7412
+ }
7413
+ return undefined;
7414
+ }
7415
+ /** The conversation waiting on work that runs in this thread: a Chief's
7416
+ * delegate_bot task or coordinate_bots assignment. */
7417
+ function delegatorOf(threadId) {
7418
+ const watch = delegationWatch.get(threadId);
7419
+ if (watch?.sourceThreadId)
7420
+ return { threadId: watch.sourceThreadId, botId: watch.sourceBotId };
7421
+ const parent = roomHandoffs.assignerOf(threadId);
7422
+ return parent ? { threadId: parent.threadId, botId: parent.botId } : undefined;
7423
+ }
7424
+ // MOCA-274: a delegated teammate's card used to live only in its own thread,
7425
+ // so the person talking to the Chief never saw that work was waiting on them.
7426
+ // A chip in the delegating conversation points at the card until it settles.
7427
+ const waitingOnPersonChips = new Map();
7428
+ function showWaitingOnPersonChip(threadId, requestId, asker, kind) {
7429
+ const delegator = delegatorOf(threadId);
7430
+ if (!delegator || delegator.threadId === threadId)
7431
+ return;
7432
+ const chip = store.appendMessage(delegator.threadId, { role: "bot", kind: "activity",
7433
+ from: { botId: asker.id, name: asker.name, color: asker.color },
7434
+ tool: { name: `Waiting on your ${kind} in @${asker.name}` },
7435
+ threadRef: { botId: asker.id, threadId, title: store.taskByThread(asker.id, threadId)?.title ?? asker.name },
7436
+ });
7437
+ waitingOnPersonChips.set(`${threadId}:${requestId}`, { threadId: delegator.threadId, messageId: chip.id, name: asker.name, kind });
7438
+ }
7439
+ /** Settle the chip for one answered card, or every chip for this thread once
7440
+ * its turn ends (a card nobody answered can no longer be answered). */
7441
+ function settleWaitingOnPersonChips(threadId, requestId) {
7442
+ for (const [key, chip] of waitingOnPersonChips) {
7443
+ if (requestId ? key !== `${threadId}:${requestId}` : !key.startsWith(`${threadId}:`))
7444
+ continue;
7445
+ waitingOnPersonChips.delete(key);
7446
+ const existing = store.messagesFor(chip.threadId).find((message) => message.id === chip.messageId);
7447
+ if (!existing?.tool)
7448
+ continue;
7449
+ store.patchMessage(chip.threadId, chip.messageId, {
7450
+ tool: { ...existing.tool, name: requestId ? `@${chip.name} got your ${chip.kind}` : `@${chip.name} is no longer waiting on your ${chip.kind}`, ok: true },
7451
+ });
7452
+ }
7453
+ }
7543
7454
  // Peer wake: when a delegated reply lands, resume the source bot so it can
7544
7455
  // fold the result in and answer the user instead of sitting idle. Mirrors
7545
7456
  // the cardContinuation resume pattern used for connector/credential cards.
@@ -7583,10 +7494,7 @@ function dispatchDelegationWake(botId, threadId, targetName, failureReason, rout
7583
7494
  store.appendMessage(threadId, {
7584
7495
  role: "bot",
7585
7496
  kind: "activity",
7586
- tool: {
7587
- name: `error: could not resume after delegation — ${message.slice(0, 120)}`,
7588
- ok: false,
7589
- },
7497
+ tool: failedTurnTool(`could not resume after delegation — ${message}`),
7590
7498
  });
7591
7499
  routines?.failThread(threadId, `Could not resume after delegation: ${message}`);
7592
7500
  });
@@ -7688,7 +7596,7 @@ function reportIncident(input) {
7688
7596
  store.appendMessage(incidents.threadId, {
7689
7597
  role: "bot",
7690
7598
  kind: "activity",
7691
- tool: { name: `error: the incident could not reach ${chief.name} — ${why.slice(0, 120)}`, ok: false },
7599
+ tool: failedTurnTool(`the incident could not reach ${chief.name} — ${why}`),
7692
7600
  });
7693
7601
  tellThePerson();
7694
7602
  });
@@ -7738,7 +7646,7 @@ function markTaskContextExternallyUpdated(bot, threadId) {
7738
7646
  const owner = task.lastInstanceId;
7739
7647
  const record = owner ? task.handedMessages?.[owner] : undefined;
7740
7648
  if (owner && record?.session !== undefined && record.session === task.resumeCursors[owner] &&
7741
- turnInstance(botForThread(bot.id, threadId) ?? bot, undefined, threadId)?.instanceId === owner &&
7649
+ (botForThread(bot.id, threadId) ?? bot).modelSelection.instanceId === owner &&
7742
7650
  registry.get(owner)?.adapter.capabilities.strictResume) {
7743
7651
  store.patchTask(bot.id, threadId, { unread: true });
7744
7652
  return;
@@ -8087,7 +7995,7 @@ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel,
8087
7995
  store.appendMessage(sourceThreadId, {
8088
7996
  role: "bot",
8089
7997
  kind: "activity",
8090
- tool: { name: `error: delegation to @${bot?.name ?? toBotId} could not start — ${why.slice(0, 120)}`, ok: false },
7998
+ tool: failedTurnTool(`delegation to @${bot?.name ?? toBotId} could not start — ${why}`),
8091
7999
  });
8092
8000
  };
8093
8001
  return startTurn(toBotId, text, {
@@ -8199,10 +8107,7 @@ function drainQueuedSends() {
8199
8107
  }).catch((err) => {
8200
8108
  store.appendMessage(threadId, {
8201
8109
  role: "bot", kind: "activity",
8202
- tool: {
8203
- name: `error: queued message could not start — ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`,
8204
- ok: false,
8205
- },
8110
+ tool: failedTurnTool(`queued message could not start — ${err instanceof Error ? err.message : String(err)}`),
8206
8111
  });
8207
8112
  resolve();
8208
8113
  // M2: only this group left the queue, and a turn that never
@@ -8259,10 +8164,7 @@ function drainAsideLane() {
8259
8164
  }).catch((err) => {
8260
8165
  store.appendMessage(threadId, {
8261
8166
  role: "bot", kind: "activity",
8262
- tool: {
8263
- name: `error: queued aside could not start — ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`,
8264
- ok: false,
8265
- },
8167
+ tool: failedTurnTool(`queued aside could not start — ${err instanceof Error ? err.message : String(err)}`),
8266
8168
  });
8267
8169
  resolve();
8268
8170
  }).catch(reject);
@@ -8554,7 +8456,7 @@ async function startOrQueueOpenedThread(botId, threadId, text, unattended) {
8554
8456
  store.appendMessage(threadId, {
8555
8457
  role: "bot",
8556
8458
  kind: "activity",
8557
- tool: { name: `error: this thread could not start — ${why.slice(0, 120)}`, ok: false },
8459
+ tool: failedTurnTool(`this thread could not start — ${why}`),
8558
8460
  });
8559
8461
  return { state: "failed", error: why };
8560
8462
  }
@@ -8757,11 +8659,6 @@ async function startTurn(botId, text, opts) {
8757
8659
  // A workspace at its monthly spend limit starts no turn of any kind: a
8758
8660
  // person's message, a routine, a peer hop or a webhook all stop here.
8759
8661
  assertWithinBudget(cfg, DATA_DIR);
8760
- if (checkpointRestoreLeases.has(botId)) {
8761
- throw Object.assign(new Error("this bot's project files are being restored — wait for the restore to finish"), {
8762
- status: 409,
8763
- });
8764
- }
8765
8662
  if (boatLifecycleBusyBots.has(botId)) {
8766
8663
  throw Object.assign(new Error("this bot's cloud computer is being changed — wait for it to finish"), { status: 409 });
8767
8664
  }
@@ -8778,10 +8675,10 @@ async function startTurn(botId, text, opts) {
8778
8675
  // their results still return here (outstandingAssignmentsPrompt tells this
8779
8676
  // turn which are still out). Stop, in this conversation, is the gesture
8780
8677
  // that ends coordination — see interruptDirectThread.
8781
- // Retire anything a previous turn left behind before minting this turn's
8782
- // integrations. Completion and interrupt paths do the same; this is the
8783
- // final backstop against a retained proxy process.
8784
- revokeInternalCapabilitiesForThread(threadId);
8678
+ // Revoke anything a previous turn left behind before minting this turn's
8679
+ // integrations. Completion and interrupt paths do the same; a proxy the
8680
+ // warm engine kept is honoured again only through this turn's records.
8681
+ revokeEarlierTurnCapabilities(threadId);
8785
8682
  // a webhook turn, or one inherited from a bot already running unattended
8786
8683
  if (opts?.automationSource === "webhook" || opts?.unattended)
8787
8684
  markUnattended(bot.id, threadId);
@@ -8796,11 +8693,9 @@ async function startTurn(botId, text, opts) {
8796
8693
  if (hostedModels && !hostedModels.allows(bot.modelSelection))
8797
8694
  throw Object.assign(new Error(hostedModels.error()), { status: 409 });
8798
8695
  const plan = turnSurfacePlan(bot, opts?.runOn, threadId);
8799
- const instance = turnInstance(bot, opts?.runOn, threadId);
8696
+ const instance = registry.get(bot.modelSelection.instanceId);
8800
8697
  if (!instance) {
8801
- throw Object.assign(new Error(turnProvider(bot, opts?.runOn, threadId) === "box"
8802
- ? "the Cloud VM runner is unavailable — configure Boat in App Settings"
8803
- : `provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`), { status: 409 });
8698
+ throw Object.assign(new Error(`provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`), { status: 409 });
8804
8699
  }
8805
8700
  const policyRefusal = policyModelRefusal(instance);
8806
8701
  if (policyRefusal)
@@ -8860,13 +8755,12 @@ async function startTurn(botId, text, opts) {
8860
8755
  if (providerInstancesChanging.has(instanceId)) {
8861
8756
  throw Object.assign(new Error("this provider account is being updated — try again shortly"), { status: 409 });
8862
8757
  }
8863
- const switchedEngine = instance.instanceId !== bot.modelSelection.instanceId;
8864
- const useInstanceDefaults = switchedEngine || (opts?.runOn === "cloud" && !instance.adapter.capabilities.cloudComputerMcp);
8865
- const model = useInstanceDefaults ? instance.models.default : bot.modelSelection.model;
8866
- // A native cloud runner borrows its instance defaults; a Boat bridge keeps
8867
- // the bot's selected model, effort and variant.
8868
- const effort = useInstanceDefaults ? undefined : bot.modelSelection.effort;
8869
- const variant = useInstanceDefaults ? undefined : bot.modelSelection.variant;
8758
+ // A turn always runs on the bot's own engine, model, effort and variant,
8759
+ // wherever it works: a cloud computer is a tool it mounts, never a reason
8760
+ // to swap the engine (the provider_not_configured incident).
8761
+ const model = bot.modelSelection.model;
8762
+ const effort = bot.modelSelection.effort;
8763
+ const variant = bot.modelSelection.variant;
8870
8764
  assertModelVariantSupported({ variant, effort }, instance.adapter.capabilities);
8871
8765
  // A selection can be persisted while its engine is offline. Re-check when
8872
8766
  // the engine returns so an old or unsupported value never reaches a CLI.
@@ -9190,42 +9084,43 @@ async function startTurn(botId, text, opts) {
9190
9084
  }
9191
9085
  const privateWorkspace = worksInWorkspace ? ensureTaskWorkspace(bot.id, threadId) : undefined;
9192
9086
  const skillInstructions = renderSkillInstructions(selectedSkills, {
9193
- includeRoot: worksInWorkspace && opts?.runOn !== "cloud",
9087
+ includeRoot: worksInWorkspace,
9194
9088
  });
9195
9089
  const packagePlaybooks = installedPlaybookInstructions(providerText, bot.playbooks);
9196
9090
  // An explicit working folder wins for new tasks; otherwise they use
9197
9091
  // the private bot workspace. A legacy task with an existing provider
9198
9092
  // session deliberately pins to null (the old home-folder behavior),
9199
9093
  // because moving a live session would break resume.
9200
- // A cloud run happens on the boat, where a host folder means nothing:
9201
- // pin the task to the default so the header chip never shows the
9202
- // bot's folder for a task that runs elsewhere.
9203
- if (opts?.runOn === "cloud")
9204
- store.pinTaskCwd(bot.id, threadId, undefined, { none: true });
9094
+ // A cloud run keeps the bot's own engine on this host, with the cloud
9095
+ // computer as one more tool, so it works in the same folder as any
9096
+ // other turn (only the Computer engine runs on the Boat, and it has no
9097
+ // workspace files).
9205
9098
  // On a Cloud home a conversation a guest opened is pinned to its own
9206
9099
  // folder when it first runs, not the bot's project folder the owner's
9207
9100
  // conversations share (what it leaves there reaches none of them). One
9208
9101
  // that ran before keeps its pin, unless the session that opened it was
9209
9102
  // revoked: then it was unpinned at boot (server/cloud-owner.ts).
9210
- const pinnedCwd = privateWorkspace && opts?.runOn !== "cloud"
9103
+ const pinnedCwd = privateWorkspace
9211
9104
  ? store.pinTaskCwd(bot.id, threadId, privateWorkspace, { privateOnly: cloudGuestOpened(threadId) })
9212
9105
  : null;
9106
+ // A bot's threads run side by side in one project folder, like several
9107
+ // Claude Code or Codex sessions open in one repo: the folder is never a
9108
+ // turn-long claim. What truly needs one writer queues on its own — the
9109
+ // per-turn snapshot per folder (server/checkpoints.ts), memory through
9110
+ // memory_update — and the engines key their sessions by id, not folder.
9213
9111
  const cwd = pinnedCwd ?? undefined;
9214
- if (cwd && !claimTurnResource(resourceOwner, workspaceResource(cwd))) {
9215
- 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" });
9216
- }
9217
9112
  // Checkpoint explicit project folders, where a bot can overwrite the
9218
9113
  // user's work. Its private OpenMaus workspace is app-owned and changes
9219
9114
  // on nearly every ordinary chat; snapshotting it would add hidden disk
9220
- // and process overhead without a user project to restore.
9115
+ // and process overhead without a user project to list changes for.
9221
9116
  const checkpointCwd = cwd && cwd !== privateWorkspace ? cwd : undefined;
9222
9117
  // dweb is opt-in: without an explicit daemon URL, do not advertise
9223
9118
  // tools that would fail on every call or spawn an unnecessary proxy.
9224
9119
  const dwebUrl = process.env.DWEB_URL?.trim();
9225
9120
  if (dwebUrl)
9226
9121
  integrations.dweb = { url: dwebUrl };
9227
- // Cloud routines always use Boat/BoatAgent. The per-bot backend applies
9228
- // only to ordinary turns that mount a computer into the local agent.
9122
+ // Cloud routines always use the bot's Boat. The per-bot backend applies
9123
+ // only to ordinary turns.
9229
9124
  const teamComputer = inheritedTeamComputer(bot);
9230
9125
  const cloudBackend = teamComputer || opts?.runOn === "cloud" || bot.cloudBackend !== "vps" ? "box" : "vps";
9231
9126
  const mountsComputerMcp = instance.adapter.capabilities.computerMcp === true;
@@ -9240,8 +9135,6 @@ async function startTurn(botId, text, opts) {
9240
9135
  // landed. A team computer or a cloud routine is not this conversation's
9241
9136
  // choice, so those ignore the pin.
9242
9137
  const dispatchTask = store.taskByThread(bot.id, threadId);
9243
- if (plan.clearPin && dispatchTask)
9244
- store.patchTask(bot.id, threadId, { surface: undefined });
9245
9138
  if (plan.computer !== undefined && plan.computer !== "cloud" && instance.adapter.capabilities.remoteAgent === true) {
9246
9139
  throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
9247
9140
  }
@@ -9253,6 +9146,21 @@ async function startTurn(botId, text, opts) {
9253
9146
  const placeRefusal = wantedKind && computerPlaceRefusal(wantedKind);
9254
9147
  if (placeRefusal)
9255
9148
  throw Object.assign(new Error(placeRefusal.message), { status: 409, code: placeRefusal.code });
9149
+ // Where an explicit place came from, so its failure names the one
9150
+ // control that changes it (and a failed Auto-recorded pin is cleared).
9151
+ // Auto and a team's shared computer are nobody's per-turn choice.
9152
+ const namedPlace = teamComputer || wants === undefined || wants === "off" ? undefined : wants;
9153
+ const placeSource = !namedPlace ? undefined
9154
+ : opts?.runOn === "cloud" ? "routine"
9155
+ : plan.pinned ? (dispatchTask?.surfaceSource === "auto" ? "auto-pin" : "pin")
9156
+ : "works-on";
9157
+ // An engine that cannot use the cloud computer is refused here, before
9158
+ // anything is created or woken — never handed to another engine.
9159
+ if (wants === "cloud" && placeSource) {
9160
+ const unsupported = cloudPlaceDriverError(cloudEngine(instance), cloudBackend, placeSource);
9161
+ if (unsupported)
9162
+ throw new PlaceUnavailableError("cloud", unsupported);
9163
+ }
9256
9164
  let previewCapture = null;
9257
9165
  let browserCapture = null;
9258
9166
  let computerKind = null;
@@ -9400,7 +9308,7 @@ async function startTurn(botId, text, opts) {
9400
9308
  store.appendMessage(threadId, {
9401
9309
  role: "bot",
9402
9310
  kind: "activity",
9403
- tool: { name: `error: ${message.slice(0, 160)}`, ok: false },
9311
+ tool: failedTurnTool(message),
9404
9312
  });
9405
9313
  if (opts?.automationSource === undefined && !opts?.commsDepth && !opts?.cardContinuation) {
9406
9314
  notify(buildNotification("turn-failed", bot, threadId, redactSecretsInText(message), { avatarUrl: bot.avatarUrl }));
@@ -9506,24 +9414,23 @@ async function startTurn(botId, text, opts) {
9506
9414
  return false;
9507
9415
  }
9508
9416
  };
9509
- if (wants === "vm") {
9510
- if (await attachLocalVm(true))
9511
- computerKind = "vm";
9512
- }
9513
- else if (wants === "local") {
9514
- integrations.localComputer = await mountHostComputer(resourceOwner, bot.id, mountsLocalComputer);
9515
- computerKind = "local";
9516
- }
9517
- // A VPS is a local-agent computer mount, never a remote agent runner.
9518
- // Explicit Cloud may prepare/start it. Auto remains read-only unless
9519
- // the person explicitly opted this bot into remote lifecycle actions.
9520
- if ((wants === "cloud" || (wants === undefined && managedPolicy.computerAllowed("vps"))) && cloudBackend === "vps") {
9521
- const unsupported = vps.vpsDriverError(instance.driverKind, mountsComputerMcp);
9522
- if (unsupported && wants === "cloud")
9523
- throw new Error(unsupported);
9524
- if (unsupported && wants === undefined)
9525
- autoVpsProblem = unsupported;
9526
- if (!unsupported) {
9417
+ // An explicit place that cannot be attached fails the turn with its
9418
+ // cause and the next action; it never quietly moves the work elsewhere.
9419
+ try {
9420
+ if (wants === "vm") {
9421
+ if (await attachLocalVm(true))
9422
+ computerKind = "vm";
9423
+ }
9424
+ else if (wants === "local") {
9425
+ integrations.localComputer = await mountHostComputer(resourceOwner, bot.id, mountsLocalComputer);
9426
+ computerKind = "local";
9427
+ }
9428
+ // A VPS is a local-agent computer mount, never a remote agent runner.
9429
+ // Explicit Cloud may prepare/start it. Auto remains read-only unless
9430
+ // the person explicitly opted this bot into remote lifecycle actions.
9431
+ // On Auto an engine that cannot use a computer simply skips it.
9432
+ if ((wants === "cloud" || (wants === undefined && managedPolicy.computerAllowed("vps"))) && cloudBackend === "vps" &&
9433
+ canWorkOnCloud(cloudEngine(instance), "vps")) {
9527
9434
  // The VPS "computer" is the desktop inside this bot's managed
9528
9435
  // container, and only screen work needs that desktop to itself.
9529
9436
  // So the lease is claimed on the first computer call, through the
@@ -9561,34 +9468,42 @@ async function startTurn(botId, text, opts) {
9561
9468
  autoVpsProblem = mounted.problem ?? "the VPS computer could not be reached";
9562
9469
  }
9563
9470
  }
9564
- }
9565
- // Cloud is strict when selected. Native Boat and drivers with a Boat
9566
- // bridge may also reuse an already-ready Boat on Auto.
9567
- if (teamComputer) {
9568
- const attached = await attachTeamBoat(teamComputer, bot.id, resourceOwner, instance.adapter.capabilities.usesCloudComputer === true, instance.adapter.capabilities.remoteAgent === true);
9569
- integrations.computer = attached.integration;
9570
- previewCapture = attached.capture;
9571
- computerKind = "box";
9572
- }
9573
- if (!teamComputer && instance.adapter.capabilities.usesCloudComputer === true && (wants === "cloud" || wants === undefined) && cloudBackend === "box" && boat.boatConfigured(cfg)) {
9574
- const attached = await attachBotBoat(bot, resourceOwner, {
9575
- explicitCloud: wants === "cloud",
9576
- canMount: instance.adapter.capabilities.usesCloudComputer === true,
9577
- remoteAgent: instance.adapter.capabilities.remoteAgent === true,
9578
- });
9579
- if (attached) {
9471
+ if (teamComputer) {
9472
+ const attached = await attachTeamBoat(teamComputer, bot.id, resourceOwner, canWorkOnCloud(cloudEngine(instance), "box"), instance.adapter.capabilities.remoteAgent === true);
9473
+ Object.assign(integrations, attached.mount);
9580
9474
  previewCapture = attached.capture;
9581
- if (attached.integration) {
9582
- integrations.computer = attached.integration;
9475
+ computerKind = "box";
9476
+ }
9477
+ // Cloud is strict when selected. On Auto only the Computer engine,
9478
+ // whose whole turn runs on its Boat, attaches one, and only one that
9479
+ // is already running. Every other engine reaches the Boat only when
9480
+ // this conversation is on it (Works on, a pin, a cloud routine, or
9481
+ // select_computer): an Auto turn never reads the Boat account, so it
9482
+ // adds no Boat call, cannot be stopped by a Boat being deleted, and
9483
+ // never records a place nobody chose.
9484
+ const remoteAgent = instance.adapter.capabilities.remoteAgent === true;
9485
+ if (!teamComputer && cloudBackend === "box" && boat.boatConfigured(cfg) &&
9486
+ (wants === "cloud" || (wants === undefined && remoteAgent && !computerPlaceRefusal("box")))) {
9487
+ const attached = await attachBotBoat(bot, resourceOwner, { explicitCloud: wants === "cloud", remoteAgent });
9488
+ if (attached) {
9489
+ previewCapture = attached.capture;
9490
+ Object.assign(integrations, attached.mount);
9583
9491
  computerKind = "box";
9584
9492
  }
9585
9493
  }
9494
+ if (wants === "cloud" && cloudBackend === "box" && !boat.boatConfigured(cfg)) {
9495
+ throw new Error(BOAT_NOT_CONFIGURED);
9496
+ }
9497
+ if (wants === "cloud" && cloudBackend === "box" && computerKind !== "box") {
9498
+ throw new Error("the cloud computer could not be created or reached");
9499
+ }
9586
9500
  }
9587
- if (wants === "cloud" && cloudBackend === "box" && !boat.boatConfigured(cfg)) {
9588
- throw new Error(BOAT_NOT_CONFIGURED);
9589
- }
9590
- if (wants === "cloud" && cloudBackend === "box" && !integrations.computer) {
9591
- throw new Error("the cloud computer could not be created or reached");
9501
+ catch (error) {
9502
+ // Auto, Off and a team's shared computer name no place; they fail as
9503
+ // they are.
9504
+ if (!namedPlace || !placeSource)
9505
+ throw error;
9506
+ throw asPlaceFailure(error, namedPlace, placeSource);
9592
9507
  }
9593
9508
  // Auto-only host fallback. Electron owns cua-driver/TCC attribution;
9594
9509
  // the harness only reads its already-running connection descriptor.
@@ -9631,7 +9546,7 @@ async function startTurn(botId, text, opts) {
9631
9546
  pinAutoSurface("local");
9632
9547
  },
9633
9548
  });
9634
- integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
9549
+ integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId, "this computer"));
9635
9550
  computerKind = "local";
9636
9551
  }
9637
9552
  }
@@ -9679,7 +9594,7 @@ async function startTurn(botId, text, opts) {
9679
9594
  ? mentionedBots(providerText, sectionPeers)
9680
9595
  : [];
9681
9596
  const coordinationPrompt = bot.chiefOfStaff
9682
- ? chiefOfStaffSystemPrompt(bot.id, store.bots, Boolean(integrations.agents), openMausStatusSystemPrompt(), boundedCoordination)
9597
+ ? chiefOfStaffSystemPrompt(bot.id, store.bots, Boolean(integrations.agents), boundedCoordination)
9683
9598
  : integrations.agents && sectionPeers.length > 0
9684
9599
  // Ordinary bots could always CALL the peer tools; until now the
9685
9600
  // one generic sentence they got never named a teammate, so the
@@ -9699,14 +9614,17 @@ async function startTurn(botId, text, opts) {
9699
9614
  // snapshot() absorbs failures, so checkpointing may delay but never fail
9700
9615
  // a turn.
9701
9616
  if (checkpointCwd) {
9702
- const before = await checkpoints.snapshot(bot.id, checkpointCwd, `turn ${threadId.slice(0, 8)}`);
9617
+ // Pinned by this dispatch: a sibling thread's snapshot in the same
9618
+ // folder must not reclaim this turn's "before" state while it runs.
9619
+ const before = await checkpoints.snapshot(bot.id, checkpointCwd, `turn ${threadId.slice(0, 8)}`, undefined, { pin: dispatchClaimId });
9703
9620
  if (!directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId)) {
9621
+ if (before)
9622
+ void checkpoints.release(bot.id, checkpointCwd, dispatchClaimId);
9704
9623
  throw new DirectTurnSetupCancelled("turn stopped during checkpoint");
9705
9624
  }
9625
+ forgetTurnCheckpoint(threadId);
9706
9626
  if (before)
9707
- turnCheckpoints.set(threadId, { cwd: checkpointCwd, hash: before });
9708
- else
9709
- turnCheckpoints.delete(threadId);
9627
+ turnCheckpoints.set(threadId, { botId: bot.id, cwd: checkpointCwd, hash: before, pin: dispatchClaimId });
9710
9628
  }
9711
9629
  if (!directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId)) {
9712
9630
  throw new DirectTurnSetupCancelled("turn stopped before dispatch");
@@ -9765,8 +9683,9 @@ async function startTurn(botId, text, opts) {
9765
9683
  // Pin on use (issue #1650): every computer that registers a claim
9766
9684
  // slot — a ready Local VM, the VPS, the host fallback, a VM created
9767
9685
  // for this turn — records its pin inside the claim itself, so a turn
9768
- // that only mounted tools records nothing. A Box attached during
9769
- // dispatch and the built-in browser (no seat to claim) pin here.
9686
+ // that only mounted tools records nothing. The Computer engine's Boat
9687
+ // (its whole turn runs there) and the built-in browser (no seat to
9688
+ // claim) pin here.
9770
9689
  if (autoPinAllowed()) {
9771
9690
  const claimSlot = autoVmClaims.get(threadId);
9772
9691
  const pinsAtClaim = claimSlot?.owner.generation === resourceOwner.generation;
@@ -9788,7 +9707,6 @@ async function startTurn(botId, text, opts) {
9788
9707
  const computerPromptKind = resolveComputerPromptKind({
9789
9708
  kind: computerKind,
9790
9709
  driverKind: instance.driverKind,
9791
- cloudComputerMcp: instance.adapter.capabilities.cloudComputerMcp,
9792
9710
  vmPrivate: localVmMode(cfg) === "per-bot",
9793
9711
  });
9794
9712
  const coordinationNode = opts?.coordination ? roomHandoffs.nodes.get(opts.coordination.id) : undefined;
@@ -9801,14 +9719,14 @@ async function startTurn(botId, text, opts) {
9801
9719
  // to a turn whose engine actually mounted them (setupMode is already
9802
9720
  // false when they are not — see agentsMounted above)
9803
9721
  { id: "setup", label: "Setup", text: setupSystemPrompt(setupMode, { skills: skillAuthoring, cwd: liveBot?.cwd ?? bot.cwd }) },
9804
- { id: "files", label: "File locations", text: worksInWorkspace && opts?.runOn !== "cloud" ? workspaceLocationsPrompt(bot.id, cwd, liveBot?.cwd ?? bot.cwd) : "" },
9722
+ { id: "files", label: "File locations", text: worksInWorkspace ? workspaceLocationsPrompt(bot.id, cwd, liveBot?.cwd ?? bot.cwd) : "" },
9805
9723
  { id: "computer", label: "Computer", text: computerPrompt(computerPromptKind) },
9806
9724
  { id: "team-computer", label: "Team computer", text: teamComputerPrompt(teamComputer) },
9807
9725
  { 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) }) },
9808
9726
  { id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
9809
9727
  // gated on the integration, not the key: the hint only goes to a
9810
9728
  // bot whose driver actually mounted the tools
9811
- { id: "composio", label: "Connected apps", text: integrations.composio ? composioSystemPrompt(liveBot?.connectorTools ?? bot.connectorTools) : "" },
9729
+ { id: "composio", label: "Connected apps", text: integrations.composio ? composioSystemPrompt(liveBot?.connectorTools ?? bot.connectorTools) + describeConnectorScopes((liveBot ?? bot).connectorScopes) : "" },
9812
9730
  { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
9813
9731
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
9814
9732
  { id: "coordination", label: "Team", text: coordinationPrompt ? ` ${coordinationPrompt}` : "" },
@@ -9821,6 +9739,10 @@ async function startTurn(botId, text, opts) {
9821
9739
  { id: "profile", label: "Profile changes", text: profilePrompt },
9822
9740
  { id: "learn", label: "Skill authoring", text: learnPrompt },
9823
9741
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
9742
+ { id: "team-memory", label: "Team memory", text: teamMemory.systemPrompt(bot.section) + (integrations.agents ? TEAM_MEMORY_PROMPT : "") },
9743
+ // who on the team roster is busy right now; volatile, so a teammate
9744
+ // starting or finishing work never changes the stable half
9745
+ teamAvailabilityPart(coordinationPrompt ? sectionPeers : []),
9824
9746
  // what the bot said lately in its other conversations, so a task
9825
9747
  // never redoes — or forgets — what another one already did
9826
9748
  { id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(recentWorkSources(bot), bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId, ...recentWorkFilter() })) },
@@ -9866,9 +9788,9 @@ async function startTurn(botId, text, opts) {
9866
9788
  const dispatch = await guardTurnDispatch(instance.adapter.sendTurn({
9867
9789
  threadId,
9868
9790
  botId: bot.id,
9869
- startupRecovery: cfg.automaticRecovery?.enabled === true && !opts?.automaticRecoveryAttempted,
9791
+ startupRecovery: cfg.automaticRecovery?.enabled === true &&
9792
+ (opts?.automaticRecoveryIndex ?? 0) < ((liveBot ?? bot).fallback?.length || (cfg.automaticRecovery.backup ? 1 : 0)),
9870
9793
  text: withRecalled(recalled, dispatchContext.turnText),
9871
- refreshSystemPrompt: true,
9872
9794
  images: turnImages,
9873
9795
  approvalMode: approvalModeForTurn(bot, commsDepth > 0, threadId),
9874
9796
  toolScope,
@@ -10028,10 +9950,26 @@ async function startTurn(botId, text, opts) {
10028
9950
  drainDelegationWakes();
10029
9951
  return;
10030
9952
  }
9953
+ // A failed place never sticks. An Auto-recorded pin to it (a
9954
+ // select_computer switch, or where an earlier Auto turn landed) is the
9955
+ // machine's memory, not a choice: clear it so the next message runs on
9956
+ // Auto. A person's pin stays, and the message names the composer.
9957
+ if (e instanceof PlaceUnavailableError) {
9958
+ const pinned = store.taskByThread(bot.id, threadId);
9959
+ if (pinned?.surface === e.place && pinned.surfaceSource === "auto") {
9960
+ store.patchTask(bot.id, threadId, { surface: undefined, surfaceSource: undefined });
9961
+ }
9962
+ }
10031
9963
  let message = e instanceof Error ? e.message : String(e);
10032
- const backup = cfg.automaticRecovery?.enabled ? cfg.automaticRecovery.backup : undefined;
10033
9964
  const current = store.projectBotForTask(bot.id, threadId);
10034
- if (mayRecover && backup && current && !opts?.automaticRecoveryAttempted && (!opts?.cardContinuation || opts.coordination) &&
9965
+ const backups = cfg.automaticRecovery?.enabled
9966
+ ? current?.fallback?.length ? current.fallback : cfg.automaticRecovery.backup ? [cfg.automaticRecovery.backup] : []
9967
+ : [];
9968
+ const nextIndex = backups.findIndex((candidate, index) => index >= (opts?.automaticRecoveryIndex ?? 0) &&
9969
+ registry.get(candidate.instanceId)?.enabled &&
9970
+ (candidate.instanceId !== instanceId || candidate.model !== model));
9971
+ const backup = backups[nextIndex];
9972
+ if (mayRecover && backup && current && (!opts?.cardContinuation || opts.coordination) &&
10035
9973
  !directRequestOwners.get(threadId)?.stopped && !hasQueuedSteeredMessages(bot.id, threadId) &&
10036
9974
  JSON.stringify(current.modelSelection) === JSON.stringify(bot.modelSelection) &&
10037
9975
  (backup.instanceId !== instanceId || backup.model !== model) &&
@@ -10055,7 +9993,7 @@ async function startTurn(botId, text, opts) {
10055
9993
  name: `recovery: Automatic recovery: ${instance.displayName || instance.driverKind} could not start. Trying ${target.displayName || target.driverKind} · ${checked.selection.model} once in this thread.`, ok: true,
10056
9994
  } });
10057
9995
  await startTurn(bot.id, text, { ...opts, threadId, userMessage, editedMessageId: undefined,
10058
- automaticRecoveryAttempted: true });
9996
+ automaticRecoveryAttempted: true, automaticRecoveryIndex: nextIndex + 1 });
10059
9997
  // The backup now owns these callbacks; don't publish a failed
10060
9998
  // routine/peer result or release a queued receipt in between.
10061
9999
  directFollowupSettlers.delete(dispatchClaimId);
@@ -10076,7 +10014,7 @@ async function startTurn(botId, text, opts) {
10076
10014
  role: "bot",
10077
10015
  kind: "activity",
10078
10016
  turnSucceeded: false,
10079
- tool: { name: `error: ${message.slice(0, 160)}`, ok: false },
10017
+ tool: failedTurnTool(message),
10080
10018
  });
10081
10019
  // Worth a buzz for the same reason a routine failure is, and the rule
10082
10020
  // notify.ts encodes: the bot is not working, and the cause is usually
@@ -10344,9 +10282,10 @@ routines = new RoutineManager({
10344
10282
  // nobody's is confined to a folder of its own, never the bot's project.
10345
10283
  if (task && CLOUD_HOME && routineId)
10346
10284
  threadStarters.set(task.threadId, routineOpener(routineId));
10347
- const bot = store.bot(botId);
10348
- if (task && bot)
10349
- broadcast({ kind: "bot", bot: publicBot(bot) });
10285
+ // The store's frame announces it. A run's task that stays in the
10286
+ // background leaves the open thread alone, so that frame carries no
10287
+ // transcript: a whole thread on every scheduled run once passed the phone
10288
+ // sidecar's 4 MiB event ceiling and ended the stream (MOCA-179).
10350
10289
  return task;
10351
10290
  },
10352
10291
  joinConversation: (run) => {
@@ -10395,17 +10334,14 @@ routines = new RoutineManager({
10395
10334
  goalRunId: runId,
10396
10335
  });
10397
10336
  },
10398
- interruptTurn: async (botId, threadId, runOn) => {
10337
+ interruptTurn: async (botId, threadId) => {
10399
10338
  const bot = botForThread(botId, threadId);
10400
10339
  pendingDelegationWakes.delete(threadId);
10401
10340
  discardDelegations(commsBus, threadId);
10402
10341
  cancelDirectTurnDispatch(botId, threadId);
10403
10342
  revokeInternalCapabilitiesForThread(threadId);
10404
- const instance = bot
10405
- ? runningTurnInstance(bot, threadId, runOn)
10406
- : runOn === "cloud" ? registry.instances().find((candidate) => candidate.driverKind === "boxAgent") ?? null : null;
10407
10343
  try {
10408
- await instance?.adapter.interruptTurn(threadId);
10344
+ await runningTurnInstance(bot, threadId)?.adapter.interruptTurn(threadId);
10409
10345
  }
10410
10346
  finally {
10411
10347
  closeOpenApprovals(threadId);
@@ -10564,9 +10500,13 @@ async function cloudRoutineReadiness(botId, threadId) {
10564
10500
  reason: 'The Boat cloud computer needs a working Boat API key. For the bot’s configured computer, including a self-hosted VPS, set run_on="maus" instead.',
10565
10501
  };
10566
10502
  }
10567
- const instance = turnInstance(bot, "cloud", threadId);
10503
+ const instance = registry.get(bot.modelSelection.instanceId);
10568
10504
  if (!instance)
10569
- return { ready: false, reason: "The Cloud VM runner is unavailable. Restart OpenMausBot and try again." };
10505
+ return { ready: false, reason: "The target bot's model is unavailable. Choose another model in the bot's settings." };
10506
+ // The same rule a cloud turn applies, before anything is provisioned.
10507
+ const unsupported = cloudPlaceDriverError(cloudEngine(instance), "box", "routine");
10508
+ if (unsupported)
10509
+ return { ready: false, reason: unsupported };
10570
10510
  try {
10571
10511
  if ((await instance.snapshot()).state !== "available") {
10572
10512
  return { ready: false, reason: "The target bot's model is not ready to use the Boat cloud computer." };
@@ -10859,6 +10799,69 @@ const validateModelProposal = (selection, current) => {
10859
10799
  }
10860
10800
  return null;
10861
10801
  };
10802
+ const teamMemory = new TeamMemory(join(DATA_DIR, "team-memory.json"));
10803
+ const outboundRequests = new OutboundRequestService();
10804
+ const outboundCounts = new OutboundCounts(join(DATA_DIR, "outbound-counts.json"));
10805
+ const OUTBOUND_HOLD_MS = 9 * 60_000;
10806
+ function resolveAndSendTrust(res, threadId, requestId, behavior) {
10807
+ const teamMemoryCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.teamMemoryRequest);
10808
+ if (teamMemoryCard?.card?.teamMemoryRequest) {
10809
+ const request = teamMemoryCard.card.teamMemoryRequest;
10810
+ const proposer = teamMemoryCard.from?.botId ? store.bot(teamMemoryCard.from.botId) : store.botByThread(threadId);
10811
+ const result = teamMemory.resolve(request.section, request.entryId, behavior === "allow" ? "accept" : "reject");
10812
+ if (!result.claimed) {
10813
+ if (!teamMemoryCard.card.answered) {
10814
+ store.patchMessage(threadId, teamMemoryCard.id, { card: { ...teamMemoryCard.card, answered: "unavailable", dismissed: true } });
10815
+ }
10816
+ json(res, 200, { ok: true, outcome: "unavailable" });
10817
+ return true;
10818
+ }
10819
+ if (result.state === "already_settled") {
10820
+ json(res, 200, { ok: true, outcome: "allowed-once", alreadySettled: true });
10821
+ return true;
10822
+ }
10823
+ store.patchMessage(threadId, teamMemoryCard.id, {
10824
+ card: { ...teamMemoryCard.card, answered: result.state === "accepted" ? "allow" : "deny" },
10825
+ });
10826
+ appendDecision(DATA_DIR, {
10827
+ threadId, requestId, botId: proposer?.id, botName: proposer?.name,
10828
+ tool: "propose_team_memory", summary: teamMemoryCard.card.subtitle,
10829
+ decision: result.state === "accepted" ? "user-approved" : "user-denied", source: "user",
10830
+ });
10831
+ json(res, 200, { ok: true, outcome: result.state === "accepted" ? "allowed-once" : "rejected" });
10832
+ return true;
10833
+ }
10834
+ const outboundCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.outboundRequest);
10835
+ if (outboundCard?.card?.outboundRequest) {
10836
+ const outboundBot = outboundCard.from?.botId ? store.bot(outboundCard.from.botId) : store.botByThread(threadId);
10837
+ const result = outboundRequests.resolve({ threadId, requestId, behavior });
10838
+ if (!result.claimed) {
10839
+ // The hold is memory-only: after a restart the card outlives the
10840
+ // relay that was waiting on it, so close it rather than leave an
10841
+ // approval nobody can deliver owning the composer.
10842
+ if (!outboundCard.card.answered) {
10843
+ store.patchMessage(threadId, outboundCard.id, { card: { ...outboundCard.card, answered: "unavailable", dismissed: true } });
10844
+ }
10845
+ json(res, 200, { ok: true, outcome: "unavailable" });
10846
+ return true;
10847
+ }
10848
+ if (result.state === "already_settled") {
10849
+ json(res, 200, { ok: true, outcome: result.behavior === "allow" ? "allowed-once" : "rejected", alreadySettled: true });
10850
+ return true;
10851
+ }
10852
+ store.patchMessage(threadId, outboundCard.id, {
10853
+ card: { ...outboundCard.card, answered: result.state === "allowed" ? "allow" : "deny" },
10854
+ });
10855
+ appendDecision(DATA_DIR, {
10856
+ threadId, requestId, botId: outboundBot?.id, botName: outboundBot?.name,
10857
+ tool: outboundCard.card.tool, summary: outboundCard.card.subtitle,
10858
+ decision: result.state === "allowed" ? "user-approved" : "user-denied", source: "user",
10859
+ });
10860
+ json(res, 200, { ok: true, outcome: result.state === "allowed" ? "allowed-once" : "rejected" });
10861
+ return true;
10862
+ }
10863
+ return false;
10864
+ }
10862
10865
  const profileRequests = new ProfileRequestService({
10863
10866
  store,
10864
10867
  autoApply: fullAccessForSource,
@@ -10875,52 +10878,6 @@ const modelRequests = new ModelRequestService({
10875
10878
  validateModel: validateModelProposal,
10876
10879
  driverCapabilities: driverCapabilitiesFor,
10877
10880
  });
10878
- const tighteningRequests = new TighteningRequestService({
10879
- store,
10880
- autoApply: fullAccessForSource,
10881
- canPersist: proposalPersistence,
10882
- // Same reach as a profile change: a Chief may tighten a section peer;
10883
- // anyone else only itself. Re-checked at confirm.
10884
- validateTarget: (proposerBotId, targetBotId) => {
10885
- const proposer = store.bot(proposerBotId);
10886
- const target = store.bot(targetBotId);
10887
- if (!target)
10888
- return "that bot no longer exists";
10889
- if (!proposer?.chiefOfStaff)
10890
- return "only a section's Chief of Staff can change another bot's permissions";
10891
- if (!canReachPeer(proposer, target))
10892
- return "that bot is not in a team this Chief is allowed to manage";
10893
- return null;
10894
- },
10895
- // The card judges the effective mounts, so the snapshot resolves the same
10896
- // way the engine does: through the config-aware custom-server filter.
10897
- mountedMcpServers: (bot) => Object.keys(engineMcpServers(bot)),
10898
- enabledSkills: (botId) => {
10899
- const record = store.bot(botId);
10900
- return (record ? listBotSkills(record) : listSkills(botId)).filter((skill) => skill.enabled).map((skill) => skill.name);
10901
- },
10902
- disableSkill: (botId, name) => {
10903
- const record = store.bot(botId);
10904
- // Private skills win collisions, including disabled/private copies.
10905
- // Disabling one must not reveal a same-named shared skill instead.
10906
- if (skillsLibraryEnabled(cfg) && !listSkills(botId).some((skill) => skill.name === name)
10907
- && record?.assignedSkills?.includes(name)) {
10908
- try {
10909
- const saved = store.patchBot(botId, { assignedSkills: record.assignedSkills.filter((assigned) => assigned !== name) });
10910
- return saved ? { ok: true } : { ok: false, error: "that bot no longer exists" };
10911
- }
10912
- catch (error) {
10913
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
10914
- }
10915
- }
10916
- const disabled = setSkillEnabled(botId, name, false);
10917
- return typeof disabled === "object" && "error" in disabled ? { ok: false, error: disabled.error } : { ok: true };
10918
- },
10919
- // The owner's own routes stop a busy bot before touching its approval
10920
- // level or MCP mounts; the card asks for the same, except an emergency
10921
- // full/custom → ask downgrade, which applies first and stops after.
10922
- targetBusy: (botId) => Boolean(store.bot(botId)?.busy || activeGroupTurnForBot(botId)),
10923
- });
10924
10881
  const teamSetupTeams = () => [...new Set(["", ...readSections(), ...store.bots.map((bot) => sectionKey(bot.section)), ...store.groups.map((group) => sectionKey(group.section))])];
10925
10882
  const teamSetupRequests = new TeamSetupRequestService({
10926
10883
  store, teams: teamSetupTeams, canAccessTeam, canPersist: proposalPersistence, maxBots: MAX_WORKSPACE_BOTS,
@@ -11232,46 +11189,6 @@ function resolveAndSendModel(res, args) {
11232
11189
  json(res, 200, { ok: true, outcome: "rejected" });
11233
11190
  return true;
11234
11191
  }
11235
- function resolveAndSendTightening(res, args) {
11236
- const card = store.messagesFor(args.threadId).find((message) => message.card?.requestId === args.requestId && message.card.tighteningRequest)?.card;
11237
- if (!card)
11238
- return false;
11239
- const result = tighteningRequests.resolve(args);
11240
- if (!result.claimed)
11241
- return false;
11242
- if (result.state === "applied" || result.state === "denied") {
11243
- appendDecision(DATA_DIR, {
11244
- threadId: args.threadId, requestId: args.requestId, botId: args.botId, botName: args.botName,
11245
- tool: "tighten_permissions", summary: card.subtitle,
11246
- decision: result.state === "applied" ? "user-approved" : "user-denied", source: "user",
11247
- });
11248
- }
11249
- if (result.state === "applied") {
11250
- const target = store.bot(result.targetBotId);
11251
- if (target)
11252
- broadcast({ kind: "bot", bot: wireBot(target) });
11253
- if (result.emergencyStop) {
11254
- // Mirror the owner's own route: the fail-closed Ask is already
11255
- // persisted, then the exact turn still holding the elevated
11256
- // per-turn snapshot is stopped. The response never waits on it.
11257
- void stopBotForEmergencyApprovalDowngrade(result.targetBotId).catch((error) => {
11258
- 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.`);
11259
- });
11260
- }
11261
- json(res, 200, { ok: true, outcome: "allowed-once", tighteningFields: result.fields });
11262
- return true;
11263
- }
11264
- if (result.state === "invalid") {
11265
- json(res, result.status, { error: result.error });
11266
- return true;
11267
- }
11268
- if (result.state === "already_settled") {
11269
- json(res, 200, { ok: true, outcome: result.behavior === "allow" ? "allowed-once" : "rejected", alreadySettled: true });
11270
- return true;
11271
- }
11272
- json(res, 200, { ok: true, outcome: "rejected" });
11273
- return true;
11274
- }
11275
11192
  // Webhook definitions are independent from calendar schedules, but every
11276
11193
  // delivery joins the same RoutineManager queue. That keeps unattended work
11277
11194
  // ordered behind a busy MAUS and gives webhook runs the same durable receipts.
@@ -11466,7 +11383,7 @@ setupRetry = 0) {
11466
11383
  onDispatchError?.(`${bot.name}'s approval level is still being confirmed — skipped this round`);
11467
11384
  return true;
11468
11385
  }
11469
- revokeInternalCapabilitiesForThread(threadId);
11386
+ revokeEarlierTurnCapabilities(threadId);
11470
11387
  spoken.add(botId);
11471
11388
  // Must be the SAME resolver the readiness re-check uses below, or a Chief's
11472
11389
  // delegated Full elevation makes the two disagree by construction: every
@@ -11475,7 +11392,7 @@ setupRetry = 0) {
11475
11392
  const preparedApprovalMode = roomTurnApprovalMode(bot, threadId, orchestration);
11476
11393
  const preparedSelection = { ...bot.modelSelection };
11477
11394
  const preparedComposio = bot.composio;
11478
- const instance = turnInstance(bot);
11395
+ const instance = registry.get(bot.modelSelection.instanceId);
11479
11396
  const userName = cfg.profile?.name?.trim() || "User";
11480
11397
  if (providerInstancesChanging.has(bot.modelSelection.instanceId)) {
11481
11398
  onDispatchError?.(`${bot.name}'s provider account is being updated — try again shortly`);
@@ -11494,7 +11411,7 @@ setupRetry = 0) {
11494
11411
  role: "bot",
11495
11412
  kind: "activity",
11496
11413
  from: { botId: bot.id, name: bot.name, color: bot.color },
11497
- tool: { name: `error: ${message}`, ok: false },
11414
+ tool: failedTurnTool(message),
11498
11415
  });
11499
11416
  onDispatchError?.(message);
11500
11417
  return true;
@@ -11626,7 +11543,7 @@ setupRetry = 0) {
11626
11543
  role: "bot",
11627
11544
  kind: "activity",
11628
11545
  from: { botId: bot.id, name: bot.name, color: bot.color },
11629
- tool: { name: `error: ${message}`, ok: false },
11546
+ tool: failedTurnTool(message),
11630
11547
  });
11631
11548
  onDispatchError?.(message);
11632
11549
  return true;
@@ -11655,7 +11572,7 @@ setupRetry = 0) {
11655
11572
  : Boolean(readyGroup && store.groupTaskByThread(readyGroup.id, threadId));
11656
11573
  if (!readyGroup || !stillOwnsThread || !readyGroup.memberIds.includes(readyBot.id))
11657
11574
  return false;
11658
- const setupChanged = turnInstance(readyBot) !== instance ||
11575
+ const setupChanged = registry.get(readyBot.modelSelection.instanceId) !== instance ||
11659
11576
  roomTurnApprovalMode(readyBot, threadId, orchestration) !== preparedApprovalMode ||
11660
11577
  readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
11661
11578
  readyBot.modelSelection.model !== preparedSelection.model ||
@@ -11780,6 +11697,13 @@ setupRetry = 0) {
11780
11697
  if (roomPlan.computer === "local" && instance.adapter.capabilities.remoteAgent === true) {
11781
11698
  throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
11782
11699
  }
11700
+ // The same one rule as a bot thread, before anything is provisioned: an
11701
+ // engine that cannot use the cloud computer is refused, never swapped.
11702
+ if (!roomTeamComputer && roomPlan.computer === "cloud") {
11703
+ const unsupported = cloudPlaceDriverError(cloudEngine(instance), turnProvider(readyBot) === "vps" ? "vps" : "box");
11704
+ if (unsupported)
11705
+ throw new PlaceUnavailableError("cloud", unsupported);
11706
+ }
11783
11707
  // One place per room turn as well: a team computer reached on Auto means
11784
11708
  // no separate built-in browser.
11785
11709
  if (roomPlan.browser && !(roomPlan.computer === undefined && roomTeamComputer)) {
@@ -11798,11 +11722,11 @@ setupRetry = 0) {
11798
11722
  return false;
11799
11723
  }
11800
11724
  if (roomTeamComputer) {
11801
- const attached = await attachTeamBoat(roomTeamComputer, readyBot.id, resourceOwner, instance.adapter.capabilities.usesCloudComputer === true, instance.adapter.capabilities.remoteAgent === true);
11725
+ const attached = await attachTeamBoat(roomTeamComputer, readyBot.id, resourceOwner, canWorkOnCloud(cloudEngine(instance), "box"), instance.adapter.capabilities.remoteAgent === true);
11802
11726
  if (isCancelled?.() || groupSpeakers.get(threadId) !== roomSpeaker ||
11803
11727
  activeInternalGenerationByThread.get(threadId) !== internalGeneration)
11804
11728
  return false;
11805
- integrations.computer = attached.integration;
11729
+ Object.assign(integrations, attached.mount);
11806
11730
  startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: instance.adapter.capabilities.remoteAgent === true });
11807
11731
  }
11808
11732
  // The speaker's own explicit place, mounted exactly as its bot thread
@@ -11811,89 +11735,96 @@ setupRetry = 0) {
11811
11735
  const roomSetupIsCurrent = () => !isCancelled?.() &&
11812
11736
  groupSpeakers.get(threadId) === roomSpeaker &&
11813
11737
  activeInternalGenerationByThread.get(threadId) === internalGeneration;
11814
- if (!roomTeamComputer && roomPlan.computer === "local") {
11815
- integrations.localComputer = await mountHostComputer(resourceOwner, readyBot.id, instance.adapter.capabilities.localComputerMcp === true);
11816
- if (!roomSetupIsCurrent())
11817
- return false;
11818
- roomComputerKind = "local";
11819
- }
11820
- if (!roomTeamComputer && roomPlan.computer === "cloud") {
11821
- if (turnProvider(readyBot) === "vps") {
11822
- const unsupported = vps.vpsDriverError(instance.driverKind, instance.adapter.capabilities.computerMcp === true);
11823
- if (unsupported)
11824
- throw new Error(unsupported);
11825
- vpsThreadStarted(readyBot.id, threadId);
11826
- roomVpsBotId = readyBot.id;
11827
- const mounted = await mountBotVps(readyBot, resourceOwner, {
11828
- start: true,
11829
- onClaimed: (capture) => {
11830
- if (roomSetupIsCurrent() && store.group(readyGroup.id)?.busyBotId === readyBot.id) {
11831
- roomScreenBotId = readyBot.id;
11832
- startScreenPoller(readyBot.id, threadId, { computer: capture });
11833
- }
11834
- },
11835
- });
11738
+ // An explicit place that cannot be attached fails the member's turn with
11739
+ // its cause and the one setting that changes it.
11740
+ try {
11741
+ if (!roomTeamComputer && roomPlan.computer === "local") {
11742
+ integrations.localComputer = await mountHostComputer(resourceOwner, readyBot.id, instance.adapter.capabilities.localComputerMcp === true);
11836
11743
  if (!roomSetupIsCurrent())
11837
11744
  return false;
11838
- if (!("integration" in mounted))
11839
- throw new Error(mounted.problem ?? "the VPS computer could not be created or reached");
11840
- integrations.localComputer = mounted.integration;
11841
- roomComputerKind = "vps";
11745
+ roomComputerKind = "local";
11746
+ }
11747
+ if (!roomTeamComputer && roomPlan.computer === "cloud") {
11748
+ if (turnProvider(readyBot) === "vps") {
11749
+ vpsThreadStarted(readyBot.id, threadId);
11750
+ roomVpsBotId = readyBot.id;
11751
+ const mounted = await mountBotVps(readyBot, resourceOwner, {
11752
+ start: true,
11753
+ onClaimed: (capture) => {
11754
+ if (roomSetupIsCurrent() && store.group(readyGroup.id)?.busyBotId === readyBot.id) {
11755
+ roomScreenBotId = readyBot.id;
11756
+ startScreenPoller(readyBot.id, threadId, { computer: capture });
11757
+ }
11758
+ },
11759
+ });
11760
+ if (!roomSetupIsCurrent())
11761
+ return false;
11762
+ if (!("integration" in mounted))
11763
+ throw new Error(mounted.problem ?? "the VPS computer could not be created or reached");
11764
+ integrations.localComputer = mounted.integration;
11765
+ roomComputerKind = "vps";
11766
+ }
11767
+ else {
11768
+ if (!boat.boatConfigured(cfg))
11769
+ throw new Error(BOAT_NOT_CONFIGURED);
11770
+ const remoteAgent = instance.adapter.capabilities.remoteAgent === true;
11771
+ const attached = await attachBotBoat(readyBot, resourceOwner, { explicitCloud: true, remoteAgent });
11772
+ if (!roomSetupIsCurrent())
11773
+ return false;
11774
+ if (!attached)
11775
+ throw new Error("the cloud computer could not be created or reached");
11776
+ Object.assign(integrations, attached.mount);
11777
+ roomScreenBotId = readyBot.id;
11778
+ startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: remoteAgent });
11779
+ roomComputerKind = "box";
11780
+ }
11842
11781
  }
11843
- else {
11844
- if (!boat.boatConfigured(cfg))
11845
- throw new Error(BOAT_NOT_CONFIGURED);
11846
- const remoteAgent = instance.adapter.capabilities.remoteAgent === true;
11847
- const attached = await attachBotBoat(readyBot, resourceOwner, { explicitCloud: true,
11848
- canMount: instance.adapter.capabilities.usesCloudComputer === true, remoteAgent });
11849
- if (!roomSetupIsCurrent())
11782
+ // Room and Goal turns use the speaker's desktop, never the coordinator's.
11783
+ // Claim the same lease as direct turns before asynchronous VM setup.
11784
+ if (readyBot.computer === "vm") {
11785
+ if (instance.adapter.capabilities.computerMcp !== true || instance.adapter.capabilities.remoteAgent === true) {
11786
+ throw new Error("this model cannot use the Local VM");
11787
+ }
11788
+ // A distinct identity fences cleanup even in shared mode on the same room thread.
11789
+ const target = { ...localVmTargetForThread(readyBot.id, threadId) };
11790
+ await bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
11791
+ if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(target.key)) {
11792
+ throw new Error("this Local VM is being started, stopped, or replaced");
11793
+ }
11794
+ if (!localVmLeaseFor(target).claim(threadId, readyBot.id, localVmOwnerBusy)) {
11795
+ throw new Error("this Local VM is already being used by another turn");
11796
+ }
11797
+ if (localVmMode(cfg) === "pool")
11798
+ localVmSeatPool.touch(threadId);
11799
+ roomVmTarget = target;
11800
+ localVmThreadTargets.set(threadId, target);
11801
+ localVmActiveThreads.set(target.key, threadId);
11802
+ localVmIdleFor(target).touch();
11803
+ const setupIsCurrent = () => !isCancelled?.() &&
11804
+ groupSpeakers.get(threadId) === roomSpeaker &&
11805
+ activeInternalGenerationByThread.get(threadId) === internalGeneration &&
11806
+ store.group(readyGroup.id)?.memberIds.includes(readyBot.id) === true &&
11807
+ store.bot(readyBot.id)?.busy === true &&
11808
+ store.group(readyGroup.id)?.busyBotId === readyBot.id;
11809
+ const vm = await readyLocalVmForTurn(readyBot.id, target, setupIsCurrent);
11810
+ if (!setupIsCurrent()) {
11850
11811
  return false;
11851
- if (!attached?.integration)
11852
- throw new Error("the cloud computer could not be created or reached");
11853
- integrations.computer = attached.integration;
11854
- roomScreenBotId = readyBot.id;
11855
- startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: remoteAgent });
11856
- roomComputerKind = "box";
11812
+ }
11813
+ if (!vm.ready || !vm.runtime)
11814
+ throw new Error(vm.problem ?? "the Local VM is not ready");
11815
+ const owner = localVmLeaseFor(target).current(localVmOwnerBusy);
11816
+ if (owner?.threadId !== threadId || owner.botId !== readyBot.id) {
11817
+ throw new Error("the Local VM lease expired while preparing the turn");
11818
+ }
11819
+ integrations.localComputer = containerComputerMcp(vm.runtime, controlIntegration(readyBot.id, threadId, internalGeneration, { localVmTarget: target }), target);
11857
11820
  }
11858
11821
  }
11859
- // Room and Goal turns use the speaker's desktop, never the coordinator's.
11860
- // Claim the same lease as direct turns before asynchronous VM setup.
11861
- if (readyBot.computer === "vm") {
11862
- if (instance.adapter.capabilities.computerMcp !== true || instance.adapter.capabilities.remoteAgent === true) {
11863
- throw new Error("this model cannot use the Local VM");
11864
- }
11865
- // A distinct identity fences cleanup even in shared mode on the same room thread.
11866
- const target = { ...localVmTargetForThread(readyBot.id, threadId) };
11867
- await bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
11868
- if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(target.key)) {
11869
- throw new Error("this Local VM is being started, stopped, or replaced");
11870
- }
11871
- if (!localVmLeaseFor(target).claim(threadId, readyBot.id, localVmOwnerBusy)) {
11872
- throw new Error("this Local VM is already being used by another turn");
11873
- }
11874
- if (localVmMode(cfg) === "pool")
11875
- localVmSeatPool.touch(threadId);
11876
- roomVmTarget = target;
11877
- localVmThreadTargets.set(threadId, target);
11878
- localVmActiveThreads.set(target.key, threadId);
11879
- localVmIdleFor(target).touch();
11880
- const setupIsCurrent = () => !isCancelled?.() &&
11881
- groupSpeakers.get(threadId) === roomSpeaker &&
11882
- activeInternalGenerationByThread.get(threadId) === internalGeneration &&
11883
- store.group(readyGroup.id)?.memberIds.includes(readyBot.id) === true &&
11884
- store.bot(readyBot.id)?.busy === true &&
11885
- store.group(readyGroup.id)?.busyBotId === readyBot.id;
11886
- const vm = await readyLocalVmForTurn(readyBot.id, target, setupIsCurrent);
11887
- if (!setupIsCurrent()) {
11888
- return false;
11889
- }
11890
- if (!vm.ready || !vm.runtime)
11891
- throw new Error(vm.problem ?? "the Local VM is not ready");
11892
- const owner = localVmLeaseFor(target).current(localVmOwnerBusy);
11893
- if (owner?.threadId !== threadId || owner.botId !== readyBot.id) {
11894
- throw new Error("the Local VM lease expired while preparing the turn");
11895
- }
11896
- integrations.localComputer = containerComputerMcp(vm.runtime, controlIntegration(readyBot.id, threadId, internalGeneration, target), target);
11822
+ catch (error) {
11823
+ const place = roomPlan.computer;
11824
+ if (place !== "cloud" && place !== "vm" && place !== "local")
11825
+ throw error;
11826
+ // A room row is not cut, so the cause keeps all its words.
11827
+ throw asPlaceFailure(error, place, "works-on", Infinity);
11897
11828
  }
11898
11829
  const roster = readyGroup.memberIds
11899
11830
  .map((id) => store.bot(id))
@@ -11904,8 +11835,9 @@ setupRetry = 0) {
11904
11835
  // are who it cannot. The 1:1 prompt has carried a peer roster since #774,
11905
11836
  // and a room turn had nothing — the only advice it gave ("mention them
11906
11837
  // like @Name") sends the model after a teammate who will never see it.
11907
- // Same reachability rule as list_bots, minus the room's own members.
11908
- const outsideRoom = integrations.agents
11838
+ // Same reachability rule as list_bots, minus the room's own members, and
11839
+ // only on a turn that is not answering a room request.
11840
+ const outsideRoom = integrations.agents && orchestration && !orchestration.roomHandoffId
11909
11841
  ? reachablePeers(store.bots, bot).filter((peer) => !readyGroup.memberIds.includes(peer.id))
11910
11842
  : [];
11911
11843
  const system = [
@@ -11915,7 +11847,7 @@ setupRetry = 0) {
11915
11847
  `Room members: ${roster}, and ${userName} (the human).`,
11916
11848
  readyGroup.bulletin.trim() && `Room bulletin (shared instructions for everyone):\n${readyGroup.bulletin.trim()}`,
11917
11849
  `Reply as yourself, briefly and conversationally. To bring a teammate in, mention them like @Name — they'll see the conversation and respond.`,
11918
- outsideRoom.length > 0 && orchestration && !orchestration.roomHandoffId && roomPeerRosterSystemPrompt(outsideRoom),
11850
+ outsideRoom.length > 0 && roomPeerRosterSystemPrompt(outsideRoom),
11919
11851
  integrations.agents && (CREDENTIAL_PROMPT + (orchestration && !orchestration.roomHandoffId ? THREADS_PROMPT : "")).trim(),
11920
11852
  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.",
11921
11853
  integrations.agents && ROUTINE_PROMPT.trim(),
@@ -11979,7 +11911,7 @@ setupRetry = 0) {
11979
11911
  const recentLines = recentWork(recentWorkSources(bot), bot, { userName, currentThreadId: threadId, ...recentWorkFilter() });
11980
11912
  const roomComputerPromptKind = resolveComputerPromptKind({
11981
11913
  kind: roomTeamComputer || roomComputerKind === "box" ? "box" : roomVmTarget ? "vm" : roomComputerKind,
11982
- driverKind: instance.driverKind, cloudComputerMcp: instance.adapter.capabilities.cloudComputerMcp,
11914
+ driverKind: instance.driverKind,
11983
11915
  vmPrivate: localVmMode(cfg) === "per-bot",
11984
11916
  });
11985
11917
  {
@@ -12003,8 +11935,10 @@ setupRetry = 0) {
12003
11935
  { id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
12004
11936
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
12005
11937
  { id: "recall", label: "Recall", text: integrations.agents && bot.memoryEnabled !== false ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
11938
+ teamAvailabilityPart(outsideRoom),
12006
11939
  { id: "recent", label: "Recent work", text: recentWorkPrompt(recentLines) },
12007
11940
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
11941
+ { id: "team-memory", label: "Team memory", text: teamMemory.systemPrompt(bot.section) + (integrations.agents ? TEAM_MEMORY_PROMPT : "") },
12008
11942
  // the room path has always put a newline before memory and trimmed
12009
11943
  // the block's leading space; keep that so existing prompts are
12010
11944
  // byte-identical. The write guidance follows the tools actually
@@ -12137,7 +12071,6 @@ setupRetry = 0) {
12137
12071
  threadId,
12138
12072
  botId: readyBot.id,
12139
12073
  text: withRecalled(roomRecalled, text),
12140
- refreshSystemPrompt: true,
12141
12074
  images: turnImages,
12142
12075
  approvalMode: roomTurnApprovalMode(readyBot, threadId, orchestration),
12143
12076
  toolScope: toolScopeForTurn(readyBot.id),
@@ -12183,7 +12116,7 @@ setupRetry = 0) {
12183
12116
  role: "bot",
12184
12117
  kind: "activity",
12185
12118
  from: { botId: bot.id, name: bot.name, color: bot.color },
12186
- tool: { name: `error: ${message.slice(0, 140)}`, ok: false },
12119
+ tool: failedTurnTool(message),
12187
12120
  });
12188
12121
  onDispatchError?.(message);
12189
12122
  watchdog.settle(threadId);
@@ -12376,7 +12309,7 @@ setupRetry = 0) {
12376
12309
  store.appendMessage(threadId, {
12377
12310
  role: "bot", kind: "activity",
12378
12311
  from: { botId: bot.id, name: bot.name, color: bot.color },
12379
- tool: { name: `error: ${message}`, ok: false },
12312
+ tool: failedTurnTool(message),
12380
12313
  });
12381
12314
  onDispatchError?.(message);
12382
12315
  if (orchestration) {
@@ -12985,10 +12918,7 @@ function drainQueuedChannelSends() {
12985
12918
  store.appendMessage(threadId, {
12986
12919
  role: "bot",
12987
12920
  kind: "activity",
12988
- tool: {
12989
- name: `error: queued channel message could not start — ${(error instanceof Error ? error.message : String(error)).slice(0, 120)}`,
12990
- ok: false,
12991
- },
12921
+ tool: failedTurnTool(`queued channel message could not start — ${error instanceof Error ? error.message : String(error)}`),
12992
12922
  });
12993
12923
  }
12994
12924
  // A message with no eligible responder creates no operation. Continue
@@ -13195,9 +13125,9 @@ function proposalPersistence(botId, threadId) {
13195
13125
  return { ok: true };
13196
13126
  // Only cards on the visible branch can be acted on from the composer.
13197
13127
  // Abandoned branches must not permanently consume the proposal quota.
13198
- // Routine, profile, default-model, tightening and team-setup proposals
13128
+ // Routine, profile, default-model and team-setup proposals
13199
13129
  // share one budget per bot per thread, so one thread cannot pile up 8 of each.
13200
- 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) &&
13130
+ 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) &&
13201
13131
  !message.card.answered &&
13202
13132
  !message.card.dismissed && !message.card.expired).length;
13203
13133
  return openRequests >= 8
@@ -14059,7 +13989,8 @@ function configStatus() {
14059
13989
  // not a saved key
14060
13990
  box: boat.describeBoatAccount(cfg),
14061
13991
  vps: { configured: Boolean(vpsSshAlias(cfg)), sshAlias: vpsSshAlias(cfg) ?? "" },
14062
- opencodeGo: { configured: Boolean(cfg.opencodeGo?.apiKey) },
13992
+ // names only: every provider key stays write-only
13993
+ opencodeGo: { configured: Boolean(cfg.opencodeGo?.apiKey), providerKeys: Object.keys(openCodeProviderKeys(cfg)) },
14063
13994
  // the chosen voice is a setting, not a secret; the key is reported the
14064
13995
  // same configured-or-not way as every other credential
14065
13996
  tts: tts.describeVoice(cfg),
@@ -14141,6 +14072,7 @@ function configForAccess(status, admin) {
14141
14072
  edition: { edition: status.edition.edition, features: status.edition.features },
14142
14073
  signIn: { admins: [], members: [] },
14143
14074
  vps: { configured: status.vps.configured, sshAlias: "" },
14075
+ opencodeGo: { configured: status.opencodeGo.configured, providerKeys: [] },
14144
14076
  profile: { name: status.profile.name, email: "" },
14145
14077
  browserProfiles: status.browserProfiles.map((profile) => Object.fromEntries(Object.entries(profile).filter(([key]) => key !== "partitionId"))),
14146
14078
  };
@@ -14368,7 +14300,7 @@ async function reloadProviders() {
14368
14300
  if (store.taskByThread(botId, threadId)) {
14369
14301
  store.appendMessage(threadId, {
14370
14302
  role: "bot", kind: "activity",
14371
- tool: { name: "error: turn interrupted — provider settings changed", ok: false },
14303
+ tool: failedTurnTool("turn interrupted — provider settings changed"),
14372
14304
  });
14373
14305
  store.setTaskActivity(botId, threadId, "idle");
14374
14306
  }
@@ -14471,7 +14403,7 @@ const workspaceBackupAccess = {
14471
14403
  !routines?.isTicking && !calendarCalls?.isTicking &&
14472
14404
  !localVmImageBusy && !localVmProvisionBusy && !localVmModeChangeBusy &&
14473
14405
  !localVmLifecycleBusy.size && !boatLifecycleBusyBots.size && !vpsPreviewRequests.size && !orphanBoatLifecycleBusyIds.size &&
14474
- !computerProviderConfigTransitions.size && !checkpointRestoreLeases.size &&
14406
+ !computerProviderConfigTransitions.size &&
14475
14407
  teamComputers.list().every(computer => !teamComputerInUse(computer)) &&
14476
14408
  store.bots.every((bot) => !botHasActiveTurn(bot.id) && !routines?.activeRunForBot(bot.id) && !botComputerControlSnapshot(bot.id).held) &&
14477
14409
  store.groups.every((group) => !groupIsWorking(group)),
@@ -14494,18 +14426,24 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
14494
14426
  restored: workspaceRestore,
14495
14427
  ...workspaceBackupAccess,
14496
14428
  });
14497
- // Move to Cloud (server/cloud-move-http.ts): every server sizes its own
14498
- // workspace for the desktop; only a Cloud home receives one. After its
14499
- // restore commits, the Cloud home restarts so startup installs it.
14500
- let cloudHomeRestartRequested = false;
14429
+ // Copy this computer here (server/cloud-move-http.ts, docs/copy-workspace.md):
14430
+ // every server sizes its own workspace for its desktop, and every server the
14431
+ // person owns receives one, a Cloud home included. A workspace shared with
14432
+ // other people never does (workspaceShared): a hosted organisation
14433
+ // workspace, or a server whose email sign-in lets someone besides its owner
14434
+ // in. After the restore commits, the server
14435
+ // exits with RESTART_EXIT_CODE and its launcher starts it again, so startup
14436
+ // installs it (server/restart.ts).
14437
+ let restartRequested = false;
14501
14438
  const cloudMoveRoutes = createCloudMoveRoutes({
14502
14439
  dataDir: DATA_DIR,
14503
14440
  appVersion: serverVersion(),
14504
- cloudHome: Boolean(CLOUD_HOME),
14441
+ environmentId: ENVIRONMENT_ID,
14442
+ sharedWorkspace: () => workspaceShared({ hosted: HOSTED_WORKSPACE, cloudHome: Boolean(CLOUD_HOME), signIn: signInAllowList() }),
14505
14443
  readBody,
14506
14444
  restored: workspaceRestore,
14507
14445
  ...workspaceBackupAccess,
14508
- restart: () => { cloudHomeRestartRequested = true; gracefulShutdown(); },
14446
+ restart: () => { restartRequested = true; gracefulShutdown(); },
14509
14447
  });
14510
14448
  // Route modules (server/routes/README.md). `workspaceAccess` is assigned at
14511
14449
  // boot, after this line, so the dependency reads it per request.
@@ -14995,9 +14933,7 @@ const handleRequest = async (req, res) => {
14995
14933
  // scheme the native companion scanners accept; it carries the
14996
14934
  // credential encoding because those scanners cannot take a typed code.
14997
14935
  const serverName = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED }).label;
14998
- const invite = base
14999
- ? `openmausbot://pair?address=${encodeURIComponent(base)}&token=${encodeURIComponent(opened.credential)}&name=${encodeURIComponent(serverName)}`
15000
- : null;
14936
+ const invite = base ? phonePairingLink({ address: base, token: opened.credential, name: serverName }) : null;
15001
14937
  return json(res, 200, {
15002
14938
  id: opened.id,
15003
14939
  code,
@@ -15212,7 +15148,7 @@ const handleRequest = async (req, res) => {
15212
15148
  ? "phone"
15213
15149
  : path.startsWith("/api/internal/connectors/")
15214
15150
  ? "connectors"
15215
- : path === "/api/internal/computer-control"
15151
+ : path === "/api/internal/computer-control" || path === "/api/internal/computer/mcp"
15216
15152
  ? "computer"
15217
15153
  : "agents";
15218
15154
  if (internalCapability.kind !== requiredCapabilityKind) {
@@ -15261,6 +15197,9 @@ const handleRequest = async (req, res) => {
15261
15197
  throw Object.assign(new Error("the internal turn capability has expired"), { status: 401 });
15262
15198
  }
15263
15199
  };
15200
+ const delegatedThisTurn = (taskId) => {
15201
+ (internalCapability.delegatedThisTurn ??= new Set()).add(taskId);
15202
+ };
15264
15203
  // Where an entry came from, as the person will read it in MEMORY.md:
15265
15204
  // the room or the thread title, never a bare id unless nothing else
15266
15205
  // names the conversation.
@@ -15369,7 +15308,7 @@ const handleRequest = async (req, res) => {
15369
15308
  if (method === "POST" && path === "/api/internal/memory") {
15370
15309
  const body = await readInternalBody();
15371
15310
  const result = ownersWrite(() => updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText, ...(body.until !== undefined ? { until: body.until } : {}) }, { source: memorySource() }));
15372
- return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : result.code === "over-budget" ? 413 : 400, result);
15311
+ return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : 400, result);
15373
15312
  }
15374
15313
  if (method === "POST" && path === "/api/internal/memory/log") {
15375
15314
  const body = await readInternalBody();
@@ -15403,6 +15342,22 @@ const handleRequest = async (req, res) => {
15403
15342
  requireActiveInternalCapability();
15404
15343
  return json(res, 200, { result });
15405
15344
  }
15345
+ // The cloud computer's tools (harness-mcp-proxy computer): the turn's
15346
+ // capability names its Boat, and the Boat credential stays here.
15347
+ if (method === "POST" && path === "/api/internal/computer/mcp") {
15348
+ const body = await readInternalBody();
15349
+ const boxId = internalCapability.boxId;
15350
+ if (!boxId)
15351
+ return json(res, 403, { error: "this turn has no cloud computer" });
15352
+ const abort = new AbortController();
15353
+ res.once("close", () => { if (!res.writableEnded)
15354
+ abort.abort(); });
15355
+ return json(res, 200, { result: await cloudComputerRpc(body, {
15356
+ cfg, boxId, signal: abort.signal,
15357
+ gate: () => computerCallGate(internalCapability),
15358
+ assertActive: requireActiveInternalCapability,
15359
+ }) });
15360
+ }
15406
15361
  if (method === "POST" && path === "/api/internal/phone/claim") {
15407
15362
  // Lazy phone exclusivity (issue #1663): the turn holds computer:phone
15408
15363
  // from its first real tool call until it settles. The proxy answers a
@@ -15784,6 +15739,58 @@ const handleRequest = async (req, res) => {
15784
15739
  });
15785
15740
  return json(res, 201, proposed);
15786
15741
  }
15742
+ if (method === "POST" && path === "/api/internal/team-memory") {
15743
+ const parsed = z.object({
15744
+ fromBotId: z.string().min(1).max(128),
15745
+ fromThreadId: z.string().min(1).max(128),
15746
+ kind: z.string(),
15747
+ name: z.string(),
15748
+ detail: z.string(),
15749
+ aliases: z.array(z.string()).optional(),
15750
+ }).strict().safeParse(await readInternalBody());
15751
+ if (!parsed.success)
15752
+ return json(res, 400, { error: "invalid team memory proposal" });
15753
+ const body = parsed.data;
15754
+ const from = store.bot(body.fromBotId);
15755
+ if (!from)
15756
+ return json(res, 403, { error: "unknown sender" });
15757
+ const owner = connectorThread(from.id, body.fromThreadId);
15758
+ if (!owner)
15759
+ return json(res, 403, { error: "source conversation does not belong to sender" });
15760
+ if (!sameAudience(from.visibility, undefined) || (owner.group && !roomFeedsBot(owner.group, from))) {
15761
+ return json(res, 403, { error: "restricted conversations cannot publish section-wide memory; ask an admin to add it" });
15762
+ }
15763
+ if (!TEAM_MEMORY_KINDS.includes(body.kind)) {
15764
+ return json(res, 400, { error: `kind must be one of ${TEAM_MEMORY_KINDS.join(", ")}` });
15765
+ }
15766
+ let proposed;
15767
+ try {
15768
+ proposed = teamMemory.propose(from.section, { kind: body.kind, name: body.name, detail: body.detail, aliases: body.aliases }, { botId: from.id, botName: from.name, threadId: body.fromThreadId, at: Date.now() });
15769
+ }
15770
+ catch (error) {
15771
+ return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
15772
+ }
15773
+ const summary = `${proposed.entry.kind[0].toUpperCase()}${proposed.entry.kind.slice(1)}: ${proposed.entry.name}${proposed.entry.aliases.length ? ` (also ${proposed.entry.aliases.join(", ")})` : ""} — ${proposed.entry.detail}`;
15774
+ const section = sectionContextKey(from.section);
15775
+ const card = store.appendMessage(body.fromThreadId, {
15776
+ role: "bot",
15777
+ kind: "options",
15778
+ ...(owner.group ? { from: { botId: from.id, name: from.name, color: from.color } } : {}),
15779
+ card: {
15780
+ title: "Remember this for the team?",
15781
+ subtitle: summary,
15782
+ options: ["Remember", "Skip"],
15783
+ requestId: proposed.entry.id,
15784
+ tool: "propose_team_memory",
15785
+ teamMemoryRequest: { section, entryId: proposed.entry.id, kind: proposed.entry.kind },
15786
+ },
15787
+ });
15788
+ appendDecision(DATA_DIR, {
15789
+ threadId: body.fromThreadId, requestId: proposed.entry.id, botId: from.id, botName: from.name,
15790
+ tool: "propose_team_memory", summary, decision: "card-shown", source: "team-memory",
15791
+ });
15792
+ return json(res, 201, { status: "proposed", requestId: proposed.entry.id, messageId: card.id, summary });
15793
+ }
15787
15794
  if (method === "POST" && path === "/api/internal/profile-requests") {
15788
15795
  const parsed = z.object({
15789
15796
  fromBotId: z.string().min(1).max(128),
@@ -15849,39 +15856,6 @@ const handleRequest = async (req, res) => {
15849
15856
  });
15850
15857
  return json(res, 201, proposed);
15851
15858
  }
15852
- if (method === "POST" && path === "/api/internal/tightening-requests") {
15853
- const parsed = z.object({
15854
- fromBotId: z.string().min(1).max(128),
15855
- fromThreadId: z.string().min(1).max(128),
15856
- forBotId: z.string().max(128).optional(),
15857
- intents: z.unknown(),
15858
- reason: z.unknown(),
15859
- }).strict().safeParse(await readInternalBody());
15860
- if (!parsed.success)
15861
- return json(res, 400, { error: "invalid tightening proposal" });
15862
- const body = parsed.data;
15863
- const from = store.bot(body.fromBotId);
15864
- if (!from)
15865
- return json(res, 403, { error: "unknown sender" });
15866
- const owner = connectorThread(from.id, body.fromThreadId);
15867
- if (!owner)
15868
- return json(res, 403, { error: "source conversation does not belong to sender" });
15869
- const targetBotId = body.forBotId?.trim() || from.id;
15870
- const proposed = tighteningRequests.submit({
15871
- botId: from.id,
15872
- threadId: body.fromThreadId,
15873
- targetBotId,
15874
- intents: body.intents,
15875
- reason: body.reason,
15876
- from: owner.group ? { botId: from.id, name: from.name, color: from.color } : undefined,
15877
- });
15878
- appendDecision(DATA_DIR, {
15879
- threadId: body.fromThreadId, requestId: proposed.requestId, botId: from.id, botName: from.name,
15880
- tool: "tighten_permissions", summary: proposed.detail, decision: proposed.state === "applied" ? "auto-approved" : "card-shown",
15881
- source: proposed.state === "applied" ? "full-access" : "tightening",
15882
- });
15883
- return json(res, 201, proposed);
15884
- }
15885
15859
  if (method === "GET" && path === "/api/internal/team-setup-catalog") {
15886
15860
  let chief = store.bot(internalCapability.botId);
15887
15861
  if (!chief.chiefOfStaff || chief.hidden)
@@ -16234,6 +16208,7 @@ const handleRequest = async (req, res) => {
16234
16208
  if (internalCapability.generation === EXTERNAL_RUNTIME_GENERATION && !threadBusy(from.id, fromThreadId)) {
16235
16209
  drainThreadDelegations(fromThreadId);
16236
16210
  }
16211
+ delegatedThisTurn(queued.id);
16237
16212
  return json(res, 200, { busy: true, taskId: queued.id, toBotName: target.name, receipt: peerDeliveryReceipt({
16238
16213
  botId: toBotId, botName: target.name, outcome: "queued", taskId: queued.id,
16239
16214
  detail: "the teammate was busy; the ask was queued as a delegation instead",
@@ -16393,6 +16368,7 @@ const handleRequest = async (req, res) => {
16393
16368
  kind: "activity",
16394
16369
  tool: { name: `@${currentTarget.name} is still working — ask converted to a delegation` },
16395
16370
  });
16371
+ delegatedThisTurn(taskId);
16396
16372
  return json(res, 200, { timeout: true, taskId, toBotName: currentTarget.name, waitedMs: ASK_BOT_TIMEOUT_MS,
16397
16373
  receipt: peerDeliveryReceipt({ botId: toBotId, botName: currentTarget.name, outcome: "injected", taskId,
16398
16374
  detail: "the teammate's turn started and is still running; only the synchronous wait ended" }) });
@@ -16431,6 +16407,9 @@ const handleRequest = async (req, res) => {
16431
16407
  const delegationMatch = method === "GET" ? path.match(/^\/api\/internal\/delegations\/([\w-]{4,64})$/) : null;
16432
16408
  if (delegationMatch) {
16433
16409
  const taskId = delegationMatch[1];
16410
+ if (internalCapability.delegatedThisTurn?.has(taskId)) {
16411
+ 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.` });
16412
+ }
16434
16413
  const fromThreadId = internalCapability.threadId;
16435
16414
  const from = internalSender;
16436
16415
  if (!connectorThread(from.id, fromThreadId))
@@ -16472,7 +16451,10 @@ const handleRequest = async (req, res) => {
16472
16451
  if (!sender || (target && !canReachPeer(sender, target))) {
16473
16452
  return json(res, 403, { error: "Result withheld: team access changed while the teammate was working" });
16474
16453
  }
16475
- if (Date.now() >= deadline) {
16454
+ // Nothing moves while the teammate's card waits on the person, so
16455
+ // say so now rather than holding the long-poll to its deadline.
16456
+ const awaitingPerson = running && runningEntry ? openPersonCard(runningEntry[0]) : undefined;
16457
+ if (Date.now() >= deadline || awaitingPerson) {
16476
16458
  if (running && runningEntry) {
16477
16459
  const recent = summarizeDelegatedActivity(store.messagesFor(runningEntry[0]), running.startedAtMs ?? Date.now());
16478
16460
  return json(res, 200, {
@@ -16480,6 +16462,12 @@ const handleRequest = async (req, res) => {
16480
16462
  toBotName: store.bot(toBotId)?.name ?? toBotId,
16481
16463
  elapsedMs: Math.max(0, Date.now() - (running.startedAtMs ?? Date.now())),
16482
16464
  recentActivity: recent,
16465
+ ...(awaitingPerson ? {
16466
+ awaitingPerson: {
16467
+ ...awaitingPerson,
16468
+ threadTitle: store.taskByThread(toBotId, runningEntry[0])?.title,
16469
+ },
16470
+ } : {}),
16483
16471
  });
16484
16472
  }
16485
16473
  const queuedTarget = store.bot(toBotId);
@@ -16596,6 +16584,7 @@ const handleRequest = async (req, res) => {
16596
16584
  detail: `${refusal} — nothing was queued`,
16597
16585
  }) });
16598
16586
  }
16587
+ delegatedThisTurn(queued.id);
16599
16588
  const targetName = store.bot(toBotId)?.name ?? toBotId;
16600
16589
  // The queue normally drains when the source thread's turn completes. A
16601
16590
  // caller this server did not spawn (an external runtime) has no live
@@ -16648,20 +16637,17 @@ const handleRequest = async (req, res) => {
16648
16637
  })).filter(g => g.members.length);
16649
16638
  return json(res, 200, { currentRoom: source ? { id: source.id, name: source.name, workingFolder: source.cwd || null } : null,
16650
16639
  bots: reachablePeers(store.bots, internalSender).map(bot => ({ id: bot.id, name: bot.name, title: bot.title, section: bot.section, busy: bot.busy })),
16651
- rooms, note: "Without group_id: use this room when in a room, otherwise each distinct assignment starts a fresh thread for that teammate. Give a self-contained brief. Each bot uses its own environment and permissions. Files are not transferred: pass absolute paths only when accessible to the recipient, otherwise pass the content." });
16640
+ rooms, note: "Each bot uses its own environment and permissions. Files are not transferred: pass absolute paths only when accessible to the recipient, otherwise pass the content." });
16652
16641
  }
16653
16642
  if (method === "POST" && path === "/api/internal/coordinate-bots") {
16654
16643
  const parsed = z.object({
16655
16644
  groupId: z.string().min(1).max(128).optional(),
16656
16645
  botIds: z.array(z.string().min(1).max(128)).min(1).max(4).refine(ids => new Set(ids).size === ids.length),
16657
- message: z.string().trim().min(1).max(4000), requestKey: z.string().regex(/^[\w-]{1,100}$/),
16646
+ message: z.string().trim().min(1).max(4000),
16658
16647
  rework: z.boolean().default(false),
16659
- // Only ever a name for a thread, so it travels under the same
16660
- // one-line rule as a peer thread title.
16661
- label: z.string().trim().min(1).max(60).refine(fitsOnOneLine).optional(),
16662
16648
  }).safeParse(await readInternalBody());
16663
16649
  if (!parsed.success)
16664
- return json(res, 400, { error: "Provide 1-4 distinct botIds, message (1-4000 characters), a short requestKey (letters, digits, underscores or hyphens) and an optional one-line label of at most 60 characters." });
16650
+ return json(res, 400, { error: "Provide 1-4 distinct botIds and a message of 1-4000 characters." });
16665
16651
  const groupId = parsed.data.groupId ?? source?.id;
16666
16652
  const destination = groupId ? store.group(groupId) : undefined;
16667
16653
  if (groupId && !destination)
@@ -16713,58 +16699,63 @@ const handleRequest = async (req, res) => {
16713
16699
  });
16714
16700
  approvalGranted = true;
16715
16701
  }
16716
- const accepted = [];
16717
- const errors = [];
16702
+ // A room shows one brief per call, addressed to the call's recipients.
16703
+ const requestBatchKey = destination ? randomUUID() : undefined;
16718
16704
  // One receipt per recipient, in the order the caller addressed
16719
16705
  // them. Dispatch is asynchronous (RoomHandoffs.tick), so a fresh
16720
- // enqueue is honestly "queued"; a duplicate inherits the state of
16721
- // the node that already carries this request_key.
16706
+ // enqueue is honestly "queued"; a repeat reports the request it
16707
+ // repeats.
16722
16708
  const receipts = [];
16709
+ // Why nothing was sent, in full: a receipt's detail is clipped.
16710
+ const refused = [];
16723
16711
  for (const target of targets) {
16724
16712
  let createdThread;
16725
16713
  try {
16726
16714
  requireActiveInternalCapability();
16715
+ // Outside a room this conversation has one thread with each
16716
+ // teammate: the first request opens it, everything after
16717
+ // continues it and runs once the work already there is done.
16718
+ const continued = destination ? undefined : store.workThread(target.botId, internalSender.id, address.threadId);
16727
16719
  if (!destination) {
16728
- const task = store.createTask(target.botId, `@${internalSender.name}${parsed.data.label ? ` · ${parsed.data.label}` : " · work"}`, false, undefined, { botId: internalSender.id, name: internalSender.name, kind: "work", at: Date.now() });
16720
+ const task = continued ?? store.createTask(target.botId, `@${internalSender.name} · work`, false, undefined, { botId: internalSender.id, name: internalSender.name, kind: "work", threadId: address.threadId, at: Date.now() });
16729
16721
  if (!task)
16730
16722
  throw new Error("The recipient no longer exists");
16731
- target.threadId = createdThread = task.threadId;
16723
+ target.threadId = task.threadId;
16724
+ if (!continued)
16725
+ createdThread = task.threadId;
16732
16726
  applyDelegatedLevel(internalSender, internalCapability.threadId, store.bot(target.botId), target.threadId);
16733
16727
  }
16734
- const { node, duplicate } = roomHandoffs.enqueue(address, internalCapability.generation, internalCapability.roomHandoffId, target, parsed.data.requestKey + ":" + target.botId, parsed.data.message, approvalGranted, parsed.data.rework, [...store.messagesFor(address.threadId)].reverse().find(m => m.role === "user" && m.kind === "text")?.text ?? "", destination ? parsed.data.requestKey : undefined);
16735
- // A retry keeps its original task; discard the speculative row.
16736
- if (duplicate && createdThread && createdThread !== node.threadId) {
16728
+ const { node, duplicate } = roomHandoffs.enqueue(address, internalCapability.generation, internalCapability.roomHandoffId, target, target.botId, parsed.data.message, approvalGranted, parsed.data.rework, [...store.messagesFor(address.threadId)].reverse().find(m => m.role === "user" && m.kind === "text")?.text ?? "", requestBatchKey);
16729
+ // A repeat lands on the request already made, possibly in a
16730
+ // thread the person archived meanwhile; a thread opened for it
16731
+ // is never used.
16732
+ if (duplicate && createdThread)
16737
16733
  store.deleteTask(target.botId, createdThread);
16738
- }
16739
- if (!duplicate && createdThread)
16734
+ else if (createdThread)
16740
16735
  threadStarters.set(createdThread, openerFrom(address.threadId));
16741
16736
  createdThread = undefined; // The durable coordinator now owns this task.
16742
- accepted.push({ requestId: node.id, botId: node.botId, duplicate, status: node.status });
16743
- // A duplicate request_key lands on the node enqueue already
16744
- // made. Only a live node can still report: a failed or
16745
- // cancelled one already fired its report and tick() skips it,
16746
- // so promising "its result will resume you" would be the one
16747
- // lie a receipt must never tell.
16737
+ // Only a live request can still report: a finished one already
16738
+ // fired its report and tick() skips it, so a repeat of it sends
16739
+ // nothing and says how to run it again.
16740
+ const title = destination ? undefined : store.taskByThread(node.botId, node.threadId)?.title;
16741
+ const finished = {
16742
+ completed: "it already finished and its result stands; send it with rework=true to run it again",
16743
+ failed: "it failed; send it with rework=true to retry", cancelled: "it was cancelled; send it with rework=true to retry",
16744
+ };
16745
+ const notSent = duplicate && finished[node.status] ? `not sent again: ${finished[node.status]}` : undefined;
16746
+ if (notSent)
16747
+ refused.push(notSent);
16748
16748
  receipts.push(peerDeliveryReceipt({
16749
16749
  botId: node.botId,
16750
16750
  botName: store.bot(node.botId)?.name,
16751
- outcome: duplicate
16752
- ? node.status === "running" || node.status === "completed"
16753
- ? "injected"
16754
- : node.status === "failed" || node.status === "cancelled"
16755
- ? "failed"
16756
- : "queued"
16757
- : "queued",
16758
- detail: duplicate
16759
- ? node.status === "running"
16760
- ? "duplicate request_key — this teammate is already running that exact assignment"
16761
- : node.status === "completed"
16762
- ? "duplicate request_key — this teammate already completed that assignment; the earlier result stands"
16763
- : node.status === "failed" || node.status === "cancelled"
16764
- ? `duplicate request_key — the earlier assignment ${node.status === "cancelled" ? "was cancelled" : "failed"} and will not rerun or resume you; resend with a new request_key to retry`
16765
- : "duplicate request_key — already in flight; its result will resume you automatically"
16766
- : "handed to the coordinator; the teammate's turn has not started yet",
16751
+ outcome: notSent ? "failed" : !duplicate || node.status === "queued" ? "queued" : "injected",
16752
+ detail: notSent ?? (duplicate
16753
+ ? `already sent: it is ${node.status === "queued" ? "queued" : "under way"} and its result will resume you`
16754
+ : title
16755
+ ? `sent to "${title}"; it runs after anything still running there`
16756
+ : "handed to the coordinator; the teammate's turn has not started yet"),
16767
16757
  requestId: node.id,
16758
+ ...(destination ? {} : { threadId: node.threadId }),
16768
16759
  }));
16769
16760
  if (!duplicate) {
16770
16761
  const recipient = store.bot(target.botId);
@@ -16781,14 +16772,13 @@ const handleRequest = async (req, res) => {
16781
16772
  store.deleteTask(target.botId, createdThread);
16782
16773
  }
16783
16774
  const said = error instanceof Error ? error.message : String(error);
16784
- errors.push({ botId: target.botId, error: said });
16785
- receipts.push(peerDeliveryReceipt({
16786
- botId: target.botId, botName: store.bot(target.botId)?.name, outcome: "failed", detail: said,
16787
- }));
16775
+ refused.push(said);
16776
+ receipts.push(peerDeliveryReceipt({ botId: target.botId, botName: store.bot(target.botId)?.name, outcome: "failed", detail: said }));
16788
16777
  }
16789
16778
  }
16790
- return json(res, accepted.length ? 200 : 409, { accepted, errors, receipts,
16791
- ...(accepted.length ? { message: "End your turn after sending all work. These actual teammates will reply and resume you automatically. Do not poll or wait." } : { error: errors.map(e => e.error).join("; ") }),
16779
+ const sent = refused.length < receipts.length;
16780
+ return json(res, sent ? 200 : 409, { receipts,
16781
+ ...(sent ? { message: "End your turn after sending all work. These actual teammates will reply and resume you automatically. Do not poll or wait." } : { error: refused.join("; ") }),
16792
16782
  });
16793
16783
  }
16794
16784
  }
@@ -16823,6 +16813,12 @@ const handleRequest = async (req, res) => {
16823
16813
  if (!groupId || !message) {
16824
16814
  return json(res, 400, { error: "post_to_room needs group_id (from list_rooms) and message" });
16825
16815
  }
16816
+ // One turn's worth of posts is a handful; past it the turn has
16817
+ // stopped reporting and started broadcasting. Counted per turn, on
16818
+ // top of the room's own budget below.
16819
+ if ((internalCapability.roomPosts ?? 0) >= MAX_ROOM_POSTS_PER_TURN) {
16820
+ 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.` });
16821
+ }
16826
16822
  if (message.length > ROOM_POST_MAX_CHARS) {
16827
16823
  return json(res, 400, {
16828
16824
  error: `a room post is at most ${ROOM_POST_MAX_CHARS} characters — post the short version and keep the detail in your own reply`,
@@ -16951,6 +16947,7 @@ const handleRequest = async (req, res) => {
16951
16947
  peerPost: unattended ? { unattended: true } : {},
16952
16948
  ...(attachedVoiceNote ? { attachments: stagedAttachments } : {}),
16953
16949
  });
16950
+ internalCapability.roomPosts = (internalCapability.roomPosts ?? 0) + 1;
16954
16951
  detachParkedAudio?.();
16955
16952
  store.patchGroup(room.id, { unread: true });
16956
16953
  // The same visibility contract the peer tools keep: whatever a bot
@@ -16994,7 +16991,7 @@ const handleRequest = async (req, res) => {
16994
16991
  if (!fitsOnOneLine(title))
16995
16992
  return json(res, 400, { error: "title must fit on one line" });
16996
16993
  if (internalCapability.openedThreads >= MAX_THREADS_OPENED_PER_TURN) {
16997
- return json(res, 429, { error: `you can open at most ${MAX_THREADS_OPENED_PER_TURN} threads in one turn` });
16994
+ 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.` });
16998
16995
  }
16999
16996
  const toBotId = typeof body.toBotId === "string" && body.toBotId.trim() ? body.toBotId.trim() : from.id;
17000
16997
  const target = store.bot(toBotId);
@@ -17093,6 +17090,7 @@ const handleRequest = async (req, res) => {
17093
17090
  }
17094
17091
  store.setTaskOpenedBy(target.id, task.threadId, { botId: from.id, name: from.name, delegationId: queued.id, at: task.openedBy?.at ?? Date.now() });
17095
17092
  internalCapability.openedThreads += 1;
17093
+ delegatedThisTurn(queued.id);
17096
17094
  // An honest forecast, not a promise: the handoff starts when this
17097
17095
  // turn ends, and by then the target's slots are taken by whatever is
17098
17096
  // running there plus the threads this turn already opened ahead.
@@ -17122,8 +17120,8 @@ const handleRequest = async (req, res) => {
17122
17120
  if (!chief.chiefOfStaff) {
17123
17121
  return json(res, 403, { error: "only a section's Chief of Staff can create operator bots" });
17124
17122
  }
17125
- if (internalCapability.createdBots >= 4) {
17126
- return json(res, 429, { error: "you can create at most 4 bots in one turn" });
17123
+ if (internalCapability.createdBots >= MAX_CREATED_BOTS_PER_TURN) {
17124
+ 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.` });
17127
17125
  }
17128
17126
  if (store.bots.length >= MAX_WORKSPACE_BOTS) {
17129
17127
  return json(res, 409, { error: `this workspace is limited to ${MAX_WORKSPACE_BOTS} bots` });
@@ -17172,8 +17170,9 @@ const handleRequest = async (req, res) => {
17172
17170
  if (store.bot(chief.id) !== chief || chief.hidden || !chief.chiefOfStaff || !connectorThread(chief.id, fromThreadId)) {
17173
17171
  return json(res, 403, { error: "only an active Chief of Staff can create operator bots" });
17174
17172
  }
17175
- if (internalCapability.createdBots >= 4)
17176
- return json(res, 429, { error: "you can create at most 4 bots in one turn" });
17173
+ if (internalCapability.createdBots >= MAX_CREATED_BOTS_PER_TURN) {
17174
+ 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.` });
17175
+ }
17177
17176
  if (store.bots.length >= MAX_WORKSPACE_BOTS)
17178
17177
  return json(res, 409, { error: `this workspace is limited to ${MAX_WORKSPACE_BOTS} bots` });
17179
17178
  const duplicate = store.bots.find((candidate) => !candidate.hidden &&
@@ -17292,6 +17291,36 @@ const handleRequest = async (req, res) => {
17292
17291
  const updated = updateChannel(room.id, patch);
17293
17292
  return json(res, 200, { ok: true, memberIds: updated.memberIds, memberCount: updated.memberIds.length, message: `Updated room “${updated.name}”.` });
17294
17293
  }
17294
+ if (method === "POST" && path === "/api/internal/mcp-servers") {
17295
+ // Same single-flight lock as POST /api/mcp/servers: two writers must
17296
+ // not interleave a read of cfg.mcpServers with a save.
17297
+ if (mcpConfigBusy)
17298
+ return json(res, 409, { error: "MCP servers are already being updated." });
17299
+ mcpConfigBusy = true;
17300
+ try {
17301
+ const body = await readInternalBody();
17302
+ const added = addDisabledMcpServer(cfg.mcpServers ?? {}, body);
17303
+ if (!added.ok)
17304
+ return json(res, added.status, { error: added.error });
17305
+ const refusal = mcpPolicyRefusal(added.name, added.server);
17306
+ if (refusal)
17307
+ return json(res, 403, { error: refusal, code: "managed_policy" });
17308
+ persistMcpServers(added.next);
17309
+ const server = added.server;
17310
+ const remote = isRemoteMcpServer(server);
17311
+ return json(res, 201, {
17312
+ name: added.name,
17313
+ enabled: false,
17314
+ transport: remote ? server.type : "command",
17315
+ target: remote ? server.url : server.command,
17316
+ envKeys: remote ? [] : Object.keys(server.env).sort(),
17317
+ headerKeys: remote ? Object.keys(server.headers).sort() : [],
17318
+ });
17319
+ }
17320
+ finally {
17321
+ mcpConfigBusy = false;
17322
+ }
17323
+ }
17295
17324
  if (method === "POST" && path === "/api/internal/request-credential") {
17296
17325
  const body = await readInternalBody();
17297
17326
  const from = internalSender;
@@ -17386,7 +17415,7 @@ const handleRequest = async (req, res) => {
17386
17415
  // Reading a streamed MCP body yields to ordinary settings requests.
17387
17416
  // Re-read the live bot immediately before relay so turning Connected
17388
17417
  // Apps off wins over a request that authenticated under the old value.
17389
- const currentSender = store.bot(internalCapability.botId);
17418
+ let currentSender = store.bot(internalCapability.botId);
17390
17419
  if (!currentSender || currentSender.composio === false || !composio.configured(cfg)) {
17391
17420
  return json(res, 403, { error: "connected apps are not enabled for this bot" });
17392
17421
  }
@@ -17397,6 +17426,7 @@ const handleRequest = async (req, res) => {
17397
17426
  // Rows are fire-and-forget: a log failure must never take the call
17398
17427
  // (or its refusal) down with it.
17399
17428
  const call = connectorCallFromFrame(body);
17429
+ let serviceSlugs = [];
17400
17430
  // A bot with no grants record keeps the pre-grants relay: even an
17401
17431
  // unreadable frame passes through exactly as it always did.
17402
17432
  if (call.kind === "unrecognized" && currentSender.connectorTools !== undefined) {
@@ -17423,7 +17453,11 @@ const handleRequest = async (req, res) => {
17423
17453
  // underscored service (bland_ai) keeps its own tools instead of
17424
17454
  // a plain-prefix grant (bland) capturing them; an unreachable
17425
17455
  // catalog reads as empty and the plain split stands.
17426
- const serviceSlugs = await composio.connectedServiceSlugs(cfg);
17456
+ serviceSlugs = await composio.connectedServiceSlugs(cfg);
17457
+ requireActiveInternalCapability();
17458
+ currentSender = store.bot(internalCapability.botId);
17459
+ if (!currentSender || currentSender.composio === false || !composio.configured(cfg))
17460
+ return json(res, 403, { error: "connected apps are no longer enabled for this bot" });
17427
17461
  const verdict = evaluateConnectorTools(call.names, currentSender.connectorTools, serviceSlugs);
17428
17462
  if (!verdict.allowed) {
17429
17463
  for (const denial of verdict.denials) {
@@ -17463,9 +17497,174 @@ const handleRequest = async (req, res) => {
17463
17497
  rule: verdict.rule,
17464
17498
  });
17465
17499
  }
17466
- const upstream = await composio.relayMcp(cfg, body, Array.isArray(req.headers["mcp-session-id"])
17467
- ? req.headers["mcp-session-id"][0]
17468
- : req.headers["mcp-session-id"]);
17500
+ // ── the outbound gate ──
17501
+ // A tools/call that sends something (shared/outbound.ts) is its own
17502
+ // confirmation in every approval mode, Full included: it waits on a
17503
+ // card, or spends the bot's daily allowance. Everything else relays
17504
+ // untouched. A refusal is an ordinary tool error so the bot reads
17505
+ // it and carries on, rather than a transport failure it retries.
17506
+ const rpc = body && typeof body === "object" && !Array.isArray(body)
17507
+ ? body
17508
+ : null;
17509
+ const rpcParams = rpc?.params && typeof rpc.params === "object" && !Array.isArray(rpc.params)
17510
+ ? rpc.params
17511
+ : null;
17512
+ // The app tools this call would actually run, seen through Composio's
17513
+ // meta tools (a multi-execute list, workbench code), and the ones
17514
+ // among them that send.
17515
+ const outboundCalls = rpc?.method === "tools/call" && typeof rpcParams?.name === "string"
17516
+ ? outboundCallsIn(rpcParams.name, rpcParams.arguments)
17517
+ : [];
17518
+ const outboundTool = outboundCalls.length ? outboundCalls[0].slug : null;
17519
+ const refuseOutbound = (text) => {
17520
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
17521
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id: rpc?.id ?? null, result: { content: [{ type: "text", text }], isError: true } }));
17522
+ };
17523
+ // ── connection scopes ──
17524
+ // Checked first: an app this bot may not touch is refused outright,
17525
+ // never turned into a question for the person.
17526
+ const connectorCalls = rpc?.method === "tools/call" && typeof rpcParams?.name === "string"
17527
+ ? connectorCallsIn(rpcParams.name, rpcParams.arguments)
17528
+ : [];
17529
+ const access = connectorAccessDecision(currentSender.connectorScopes, connectorCalls, serviceSlugs);
17530
+ if (!access.ok) {
17531
+ const described = describeTool(access.slug);
17532
+ const appName = described.app ?? access.toolkit;
17533
+ appendDecision(DATA_DIR, {
17534
+ threadId: internalCapability.threadId, botId: currentSender.id, botName: currentSender.name,
17535
+ tool: access.slug, decision: "auto-denied", source: "connector-scope", rule: `scope:${access.toolkit}:${access.reason}`,
17536
+ });
17537
+ return refuseOutbound(access.reason === "app"
17538
+ ? `OpenMausBot did not run this: ${currentSender.name} has not been given access to ${appName}. Ask the user to allow it under the bot's Access settings, and do not retry.`
17539
+ : `OpenMausBot did not run this: ${appName} is read-only for ${currentSender.name}, and ${described.label} would write to it. Ask the user before trying again.`);
17540
+ }
17541
+ const opaque = connectorCalls.some(call => call.slug === "COMPOSIO_PROXY_EXECUTE");
17542
+ if (opaque && currentSender.connectorTools !== undefined)
17543
+ return refuseOutbound("OpenMausBot cannot verify this code against this bot's tool grants. Use a direct app tool.");
17544
+ let outboundApproved = false;
17545
+ if (outboundTool) {
17546
+ const policy = currentSender.outbound ?? DEFAULT_OUTBOUND_POLICY;
17547
+ const threadId = internalCapability.threadId;
17548
+ const { app } = describeTool(outboundTool);
17549
+ const summary = outboundCalls.map((call) => {
17550
+ const described = describeTool(call.slug);
17551
+ const argsText = call.arguments === undefined ? "" : JSON.stringify(call.arguments);
17552
+ return `${described.app ? `${described.app} · ` : ""}${described.label}${argsText ? `\n${argsText.slice(0, 400)}${argsText.length > 400 ? "… [arguments truncated]" : ""}` : ""}`;
17553
+ }).join("\n\n");
17554
+ const capNote = (count) => `OpenMausBot did not send this. ${currentSender.name} has reached its daily limit of ${count} outbound action${count === 1 ? "" : "s"}. Ask the user to raise the limit under the bot's Permissions before trying again.`;
17555
+ if (policy.policy === "allow" && !opaque) {
17556
+ const today = outboundCounts.today(currentSender.id);
17557
+ if (today + outboundCalls.length > policy.dailyCap) {
17558
+ appendDecision(DATA_DIR, {
17559
+ threadId, botId: currentSender.id, botName: currentSender.name,
17560
+ tool: outboundTool, summary, decision: "auto-denied", source: "outbound", rule: `daily-cap:${policy.dailyCap}`,
17561
+ });
17562
+ return refuseOutbound(capNote(policy.dailyCap));
17563
+ }
17564
+ }
17565
+ else {
17566
+ const owner = connectorThread(currentSender.id, threadId);
17567
+ const held = outboundRequests.open({ botId: currentSender.id, threadId, tool: outboundTool, timeoutMs: OUTBOUND_HOLD_MS });
17568
+ const card = store.appendMessage(threadId, {
17569
+ role: "bot",
17570
+ kind: "options",
17571
+ ...(owner?.group ? { from: { botId: currentSender.id, name: currentSender.name, color: currentSender.color } } : {}),
17572
+ card: {
17573
+ title: "Send on your behalf?",
17574
+ subtitle: summary,
17575
+ options: ["Allow", "Deny"],
17576
+ requestId: held.requestId,
17577
+ tool: outboundTool,
17578
+ held: HELD_NOTE["approval.held.outbound"],
17579
+ heldCode: "approval.held.outbound",
17580
+ outboundRequest: { tool: outboundTool, app },
17581
+ },
17582
+ });
17583
+ appendDecision(DATA_DIR, {
17584
+ threadId, requestId: held.requestId, botId: currentSender.id, botName: currentSender.name,
17585
+ tool: outboundTool, summary, decision: "card-shown", source: "outbound",
17586
+ });
17587
+ if (owner?.group) {
17588
+ if (activeGroupTurnForBot(currentSender.id)?.threadId === threadId)
17589
+ store.setActivity(currentSender.id, "waiting-on-you");
17590
+ }
17591
+ else if (store.taskByThread(currentSender.id, threadId)?.busy) {
17592
+ store.setTaskActivity(currentSender.id, threadId, "waiting-on-you");
17593
+ }
17594
+ notify(buildNotification("approval", currentSender, threadId, summary, { avatarUrl: currentSender.avatarUrl }));
17595
+ const answer = await held.answer;
17596
+ outboundRequests.forget(held.requestId);
17597
+ if (internalCapabilityIsActive(internalCapability)) {
17598
+ const waiting = store.bot(currentSender.id);
17599
+ if (owner?.group) {
17600
+ if (activeGroupTurnForBot(currentSender.id)?.threadId === threadId && waiting?.activity === "waiting-on-you")
17601
+ store.setActivity(waiting.id, "working");
17602
+ }
17603
+ else if (store.taskByThread(currentSender.id, threadId)?.activity === "waiting-on-you") {
17604
+ store.setTaskActivity(currentSender.id, threadId, "working");
17605
+ }
17606
+ }
17607
+ if (answer !== "allow") {
17608
+ if (answer === "timeout") {
17609
+ const stale = store.messagesFor(threadId).find((message) => message.id === card.id);
17610
+ if (stale?.card && !stale.card.answered) {
17611
+ store.patchMessage(threadId, stale.id, { card: { ...stale.card, answered: "unavailable", dismissed: true } });
17612
+ }
17613
+ }
17614
+ return refuseOutbound(answer === "timeout"
17615
+ ? "OpenMausBot did not send this: nobody answered the approval in time. Ask the user before trying again."
17616
+ : "OpenMausBot did not send this: the user declined. Do not retry it; ask the user what they would like instead.");
17617
+ }
17618
+ outboundApproved = true;
17619
+ requireActiveInternalCapability();
17620
+ }
17621
+ }
17622
+ let dispatchRefusal;
17623
+ const refuseDispatch = (text) => {
17624
+ dispatchRefusal = text;
17625
+ throw new Error(text);
17626
+ };
17627
+ let upstream;
17628
+ try {
17629
+ upstream = await composio.relayMcp(cfg, body, Array.isArray(req.headers["mcp-session-id"])
17630
+ ? req.headers["mcp-session-id"][0]
17631
+ : req.headers["mcp-session-id"], () => {
17632
+ requireActiveInternalCapability();
17633
+ const fresh = store.bot(internalCapability.botId);
17634
+ if (!fresh || fresh.composio === false || !composio.configured(cfg))
17635
+ refuseDispatch("Connected apps were disabled while this call waited.");
17636
+ const freshAccess = connectorAccessDecision(fresh.connectorScopes, connectorCalls, serviceSlugs);
17637
+ if (!freshAccess.ok || (opaque && fresh.connectorTools !== undefined) ||
17638
+ (call.kind === "tools" && !evaluateConnectorTools(call.names, fresh.connectorTools, serviceSlugs).allowed) ||
17639
+ (call.kind === "unrecognized" && fresh.connectorTools !== undefined)) {
17640
+ refuseDispatch("This bot's connected-app permissions changed. The call was not run.");
17641
+ }
17642
+ if (outboundTool) {
17643
+ const policy = fresh.outbound ?? DEFAULT_OUTBOUND_POLICY;
17644
+ if (policy.policy === "allow" && !opaque) {
17645
+ let reserved;
17646
+ try {
17647
+ reserved = outboundCounts.reserve(fresh.id, outboundCalls.length, policy.dailyCap);
17648
+ }
17649
+ catch {
17650
+ refuseDispatch("The daily allowance could not be saved. Nothing was sent.");
17651
+ }
17652
+ if (reserved === null)
17653
+ refuseDispatch("This bot has reached its daily outbound limit. Nothing was sent.");
17654
+ appendDecision(DATA_DIR, { threadId: internalCapability.threadId, botId: fresh.id, botName: fresh.name,
17655
+ tool: outboundTool, decision: "auto-approved", source: "outbound", rule: `daily-allowance:${reserved}/${policy.dailyCap}` });
17656
+ }
17657
+ else if (!outboundApproved) {
17658
+ refuseDispatch("The outbound policy changed. Ask for approval again before sending.");
17659
+ }
17660
+ }
17661
+ });
17662
+ }
17663
+ catch (error) {
17664
+ if (dispatchRefusal)
17665
+ return refuseOutbound(dispatchRefusal);
17666
+ throw error;
17667
+ }
17469
17668
  const headers = {
17470
17669
  "content-type": upstream.contentType,
17471
17670
  "cache-control": "no-store",
@@ -17543,6 +17742,16 @@ const handleRequest = async (req, res) => {
17543
17742
  if (!composio.configured(cfg) || owner.bot.composio === false) {
17544
17743
  return json(res, 409, { error: "connected apps are not enabled for this bot" });
17545
17744
  }
17745
+ if (owner.bot.connectorScopes) {
17746
+ const outside = slugs.find((slug) => !owner.bot.connectorScopes?.apps[slug]);
17747
+ if (outside) {
17748
+ appendDecision(DATA_DIR, {
17749
+ threadId, botId: owner.bot.id, botName: owner.bot.name,
17750
+ tool: "COMPOSIO_MANAGE_CONNECTIONS", summary: outside, decision: "auto-denied", source: "connector-scope", rule: `scope:${outside}:app`,
17751
+ });
17752
+ return json(res, 403, { error: `${owner.bot.name} has not been given access to ${outside}; allow it under the bot's Access settings first` });
17753
+ }
17754
+ }
17546
17755
  const connectionState = await composio.connectionStatus(cfg, slugs).catch(() => ({}));
17547
17756
  requireActiveInternalCapability();
17548
17757
  const messageIds = [];
@@ -17964,12 +18173,10 @@ const handleRequest = async (req, res) => {
17964
18173
  const limit = pageSize(url.searchParams.get("messages"));
17965
18174
  if (limit === null)
17966
18175
  return json(res, 400, { error: "messages must be a non-negative whole number" });
17967
- // wireBot(), not publicBot(): publicBot() pulls the whole transcript via
17968
- // messagesFor() just to have it overwritten below by messagePage(),
17969
- // which — for a bounded request — never needs the full transcript.
17970
- // tasks stays explicit, because that is the one field publicBot() adds
17971
- // that messagePage() does not: wireBot() omits the key entirely for a
17972
- // bot record carrying no tasks, where publicBot() always sent [].
18176
+ // wireBot(), not publicBot(): this snapshot answers with the page it
18177
+ // was asked for, not the default one. tasks stays explicit, as in
18178
+ // publicBot(): wireBot() omits the key entirely for a bot record
18179
+ // carrying no tasks, where clients expect [].
17973
18180
  // A member on a workspace with a restricted bot gets only what they may
17974
18181
  // see (bot-visibility.ts); for everyone else these filters keep all.
17975
18182
  const shownBots = store.bots.filter((bot) => visible.bot(bot.id));
@@ -17993,47 +18200,6 @@ const handleRequest = async (req, res) => {
17993
18200
  });
17994
18201
  }
17995
18202
  // scrollback: the page before a message the client already holds
17996
- // #1655: the consent card's answer, per conversation. Explicit and
17997
- // revocable; a thread allowlisted in config carries the same standing
17998
- // consent without ever seeing a card.
17999
- m = path.match(/^\/api\/threads\/([\w-]+)\/cloud-overflow$/);
18000
- if (m && method === "POST") {
18001
- const threadId = m[1];
18002
- if (!store.botByThread(threadId))
18003
- return json(res, 404, { error: "no such conversation" });
18004
- const body = await readBody(req);
18005
- if (!body || typeof body !== "object" || Array.isArray(body))
18006
- return json(res, 400, { error: "body must be a JSON object" });
18007
- if (typeof body.consent !== "boolean")
18008
- return json(res, 400, { error: "consent must be a boolean" });
18009
- if (body.consent) {
18010
- // Consent answers the priced card, never a guess: without a prior
18011
- // offer the rate was never shown, so a seat must not start yet.
18012
- // The grant binds to the rate the card showed (#1655): a config
18013
- // change re-offers instead of starting at a new price silently.
18014
- const offeredRate = cloudOverflowConsent.offeredRate(threadId);
18015
- if (offeredRate === null)
18016
- return json(res, 409, { error: "no cloud overflow offer" });
18017
- cloudOverflowConsent.grant(threadId, offeredRate);
18018
- }
18019
- else {
18020
- cloudOverflowConsent.revoke(threadId);
18021
- // Revocation must reach the machine (#1655): the ledger alone
18022
- // would leave this conversation's seat billing to its idle
18023
- // timeout. A start still waking is caught by its own revoke
18024
- // check once readyBox resolves.
18025
- const seatBot = store.botByThread(threadId);
18026
- const lease = seatBot ? cloudSeatLeases.get(seatBot.id) : undefined;
18027
- if (seatBot && lease && lease.threadId === threadId) {
18028
- stopCloudSeat(seatBot.id, lease, "Cloud computer stopped because overflow consent was revoked — billing pauses while it sleeps.");
18029
- }
18030
- }
18031
- store.appendMessage(threadId, {
18032
- role: "bot", kind: "activity",
18033
- tool: { name: cloudOverflowConsentText(body.consent, cloudOverflowIdleStopMs(cfg)), ok: true },
18034
- });
18035
- return json(res, 200, { consented: body.consent });
18036
- }
18037
18203
  m = path.match(/^\/api\/threads\/([\w-]+)\/messages$/);
18038
18204
  if (m && method === "GET") {
18039
18205
  const threadId = m[1];
@@ -18783,8 +18949,11 @@ const handleRequest = async (req, res) => {
18783
18949
  return json(res, 400, { error: "Import backups alongside your existing bots; project mode is only for templates" });
18784
18950
  try {
18785
18951
  const imported = importTeamBackup(store, routines, body, await defaultSelection(), { visibility: importVisibility });
18952
+ // A restore writes its transcripts without store events, after the
18953
+ // store's own frames announced each bot and room, so these frames
18954
+ // are what carries the restored pages to other clients.
18786
18955
  const bots = imported.bots.map((bot) => publicBot(bot));
18787
- const groups = imported.groups.map((group) => ({ ...publicGroupState(group), ...messagePage(group.threadId, undefined) }));
18956
+ const groups = imported.groups.map((group) => groupWithThread(group));
18788
18957
  for (const bot of bots)
18789
18958
  broadcast({ kind: "bot", bot });
18790
18959
  for (const group of groups)
@@ -18934,9 +19103,7 @@ const handleRequest = async (req, res) => {
18934
19103
  // Who opened it decides who may answer its cards on a shared workspace.
18935
19104
  if (auth.kind === "session")
18936
19105
  threadStarters.set(task.threadId, actorKey(auth));
18937
- const fresh = groupWithThread(store.group(group.id));
18938
- broadcast({ kind: "group", group: fresh });
18939
- return json(res, 201, { group: fresh, task });
19106
+ return json(res, 201, { group: groupWithThread(store.group(group.id)), task });
18940
19107
  }
18941
19108
  m = path.match(/^\/api\/groups\/([\w-]+)\/tasks\/([\w-]+)$/);
18942
19109
  if (m && method === "POST") {
@@ -18961,15 +19128,6 @@ const handleRequest = async (req, res) => {
18961
19128
  if (!switched)
18962
19129
  return json(res, 404, { error: "no such channel task" });
18963
19130
  const switchedSettings = { ...publicGroupState(switched), tasks: store.groupTasks(switched.id) };
18964
- // The frame says the channel moved; it is not a transcript delivery.
18965
- // A long room's whole history over SSE is the payload `?messages=`
18966
- // exists to avoid, and it would also overwrite a bounded snapshot on
18967
- // every other client. One default page is enough to render the switch;
18968
- // anything earlier pages back through /api/threads/:id/messages.
18969
- broadcast({
18970
- kind: "group",
18971
- group: { ...switchedSettings, ...messagePage(switched.threadId, DEFAULT_PAGE) },
18972
- });
18973
19131
  // "0" predates paging and means settings only — no `messages` key at
18974
19132
  // all, which clients tell apart from an empty page. A positive page is
18975
19133
  // the transcript a client can actually hold; omitting the parameter
@@ -18977,9 +19135,7 @@ const handleRequest = async (req, res) => {
18977
19135
  // materialises it.
18978
19136
  const responseGroup = requestedMessages === "0"
18979
19137
  ? switchedSettings
18980
- : switchLimit === undefined
18981
- ? groupWithThread(switched)
18982
- : { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
19138
+ : { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
18983
19139
  return json(res, 200, { group: responseGroup });
18984
19140
  }
18985
19141
  if (m && method === "PATCH") {
@@ -19052,14 +19208,12 @@ const handleRequest = async (req, res) => {
19052
19208
  lastReply.delete(m[2]);
19053
19209
  cancelTeamSetupResumesForThread(m[2]);
19054
19210
  const updated = store.deleteGroupTask(group.id, m[2]);
19055
- if (updated)
19056
- clearTurnDigestState(m[2]);
19057
19211
  if (!updated)
19058
19212
  return json(res, 400, { error: "a channel keeps at least one task" });
19213
+ clearTurnDigestState(m[2]);
19214
+ revokeInternalCapabilitiesForThread(m[2]);
19059
19215
  rejectDeletedThreadSkillStages(stagedSkillCleanups);
19060
- const fresh = groupWithThread(updated);
19061
- broadcast({ kind: "group", group: fresh });
19062
- return json(res, 200, { group: fresh });
19216
+ return json(res, 200, { group: groupWithThread(updated) });
19063
19217
  }
19064
19218
  m = path.match(/^\/api\/groups\/([\w-]+)$/);
19065
19219
  if (m && method === "PATCH") {
@@ -20045,6 +20199,43 @@ const handleRequest = async (req, res) => {
20045
20199
  patch.section = section ?? undefined;
20046
20200
  if (body.chiefOfStaff === false)
20047
20201
  patch.chiefOfStaff = false;
20202
+ // where a task carries on when this engine is unavailable, in order
20203
+ if (body.fallback !== undefined) {
20204
+ if (!Array.isArray(body.fallback) || body.fallback.length > 5) {
20205
+ return json(res, 400, { error: "fallback must be a list of up to five { instanceId, model } entries" });
20206
+ }
20207
+ const chain = [];
20208
+ for (const entry of body.fallback) {
20209
+ const row = entry && typeof entry === "object" ? entry : null;
20210
+ if (!row || typeof row.instanceId !== "string" || !/^[\w.-]+$/.test(row.instanceId) || typeof row.model !== "string" || !row.model.trim() || row.model.length > 500) {
20211
+ return json(res, 400, { error: "fallback must be a list of up to five { instanceId, model } entries" });
20212
+ }
20213
+ if (!registry.get(row.instanceId))
20214
+ return json(res, 400, { error: `fallback engine "${row.instanceId}" is not available` });
20215
+ if (chain.some((seen) => seen.instanceId === row.instanceId))
20216
+ continue;
20217
+ chain.push({ instanceId: row.instanceId, model: row.model.trim() });
20218
+ }
20219
+ patch.fallback = chain;
20220
+ }
20221
+ // which connected apps this bot may use; null clears back to all of them
20222
+ if (body.connectorScopes !== undefined) {
20223
+ if (body.connectorScopes === null)
20224
+ patch.connectorScopes = undefined;
20225
+ else {
20226
+ const scopes = normalizeConnectorScopes(body.connectorScopes);
20227
+ if (!scopes)
20228
+ return json(res, 400, { error: "connectorScopes must be { apps: { <toolkit>: read | write } } or null" });
20229
+ patch.connectorScopes = scopes;
20230
+ }
20231
+ }
20232
+ // sending on the person's behalf: ask every time, or a daily allowance
20233
+ if (body.outbound !== undefined) {
20234
+ const policy = normalizeOutboundPolicy(body.outbound);
20235
+ if (!policy)
20236
+ return json(res, 400, { error: "outbound must be { policy: ask | allow, dailyCap: 1..1000 }" });
20237
+ patch.outbound = policy;
20238
+ }
20048
20239
  // per-bot gate on the workspace's connected apps (Composio)
20049
20240
  if (body.composio !== undefined) {
20050
20241
  if (typeof body.composio !== "boolean")
@@ -20356,6 +20547,16 @@ const handleRequest = async (req, res) => {
20356
20547
  // desktop capability or a paired session, which is what the refusal
20357
20548
  // points at.
20358
20549
  const loosened = [];
20550
+ // These three fields were validated above before joining the broad patch.
20551
+ const trustPatch = patch;
20552
+ if (body.outbound !== undefined && trustPatch.outbound?.policy === "allow" &&
20553
+ (existingBot?.outbound?.policy !== "allow" || trustPatch.outbound.dailyCap > existingBot.outbound.dailyCap))
20554
+ loosened.push("outbound");
20555
+ if (body.connectorScopes !== undefined && existingBot?.connectorScopes &&
20556
+ (!trustPatch.connectorScopes || Object.entries(trustPatch.connectorScopes.apps).some(([slug, scope]) => !existingBot.connectorScopes.apps[slug] || (scope === "write" && existingBot.connectorScopes.apps[slug] === "read"))))
20557
+ loosened.push("connectorScopes");
20558
+ if (body.fallback !== undefined && trustPatch.fallback?.some(candidate => !existingBot?.fallback?.some(previous => previous.instanceId === candidate.instanceId && previous.model === candidate.model)))
20559
+ loosened.push("fallback");
20359
20560
  if (Array.isArray(patch.managedSections) && patch.managedSections.some(section => !(existingBot?.managedSections ?? []).includes(section))) {
20360
20561
  loosened.push("managedSections");
20361
20562
  }
@@ -20731,6 +20932,71 @@ const handleRequest = async (req, res) => {
20731
20932
  // it. That keeps one bot from silently changing every teammate's future
20732
20933
  // turns. The section query parameter is required even for General (""),
20733
20934
  // so a malformed client cannot accidentally read or replace that brief.
20935
+ // ── team memory: the section's shared people, places, decisions, terms ──
20936
+ // The person's view and their edits. Same section rule as the brief:
20937
+ // the query parameter is required even for General.
20938
+ if (path === "/api/team-memory" && (method === "GET" || method === "POST")) {
20939
+ if (!url.searchParams.has("section"))
20940
+ return json(res, 400, { error: "section is required" });
20941
+ const section = sectionContextKey(url.searchParams.get("section") ?? "");
20942
+ if (section.length > 60)
20943
+ return json(res, 400, { error: "section must be at most 60 characters" });
20944
+ if (method === "GET") {
20945
+ return json(res, 200, { section, label: sectionContextLabel(section), entries: teamMemory.list(section) });
20946
+ }
20947
+ const body = await readBody(req);
20948
+ if (!body || typeof body !== "object" || !TEAM_MEMORY_KINDS.includes(String(body.kind))) {
20949
+ return json(res, 400, { error: `kind must be one of ${TEAM_MEMORY_KINDS.join(", ")}` });
20950
+ }
20951
+ try {
20952
+ const proposed = teamMemory.propose(section, { kind: body.kind, name: String(body.name ?? ""), detail: String(body.detail ?? ""), aliases: Array.isArray(body.aliases) ? body.aliases.map(String) : undefined }, { botId: "", botName: "you", threadId: "", at: Date.now() });
20953
+ // the person's own entry never waits on the person
20954
+ if (proposed.status === "proposed")
20955
+ teamMemory.resolve(section, proposed.entry.id, "accept");
20956
+ return json(res, 201, { entries: teamMemory.list(section) });
20957
+ }
20958
+ catch (error) {
20959
+ return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
20960
+ }
20961
+ }
20962
+ m = path.match(/^\/api\/team-memory\/([\w-]+)$/);
20963
+ if (m && (method === "PATCH" || method === "DELETE")) {
20964
+ if (!url.searchParams.has("section"))
20965
+ return json(res, 400, { error: "section is required" });
20966
+ const section = sectionContextKey(url.searchParams.get("section") ?? "");
20967
+ if (method === "DELETE") {
20968
+ if (!teamMemory.remove(section, m[1]))
20969
+ return json(res, 404, { error: "no such entry" });
20970
+ return json(res, 200, { entries: teamMemory.list(section) });
20971
+ }
20972
+ const body = await readBody(req);
20973
+ if (!body || typeof body !== "object")
20974
+ return json(res, 400, { error: "body must be a JSON object" });
20975
+ if (body.accept === true) {
20976
+ const result = teamMemory.resolve(section, m[1], "accept");
20977
+ if (!result.claimed)
20978
+ return json(res, 404, { error: "no such entry" });
20979
+ return json(res, 200, { entries: teamMemory.list(section) });
20980
+ }
20981
+ const patch = {};
20982
+ if (typeof body.name === "string")
20983
+ patch.name = body.name;
20984
+ if (typeof body.detail === "string")
20985
+ patch.detail = body.detail;
20986
+ if (Array.isArray(body.aliases))
20987
+ patch.aliases = body.aliases.map(String);
20988
+ if (typeof body.kind === "string" && TEAM_MEMORY_KINDS.includes(body.kind))
20989
+ patch.kind = body.kind;
20990
+ try {
20991
+ const updated = teamMemory.update(section, m[1], patch);
20992
+ if (!updated)
20993
+ return json(res, 404, { error: "no such entry" });
20994
+ return json(res, 200, { entry: updated, entries: teamMemory.list(section) });
20995
+ }
20996
+ catch (error) {
20997
+ return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
20998
+ }
20999
+ }
20734
21000
  if (path === "/api/section-context" && (method === "GET" || method === "PUT")) {
20735
21001
  if (!url.searchParams.has("section"))
20736
21002
  return json(res, 400, { error: "section is required" });
@@ -20911,54 +21177,6 @@ const handleRequest = async (req, res) => {
20911
21177
  broadcast({ kind: "bot", bot: visible });
20912
21178
  return json(res, 200, { bot: visible });
20913
21179
  }
20914
- // ── workspace checkpoints: per-turn shadow-git snapshots ────────────
20915
- // The list endpoint is the source of truth (turns store nothing), and
20916
- // `enabled` tells the UI whether snapshots can happen here at all —
20917
- // false for refused folders (home, Desktop…), a missing git, or a bot
20918
- // whose checkpoints failed earlier this session.
20919
- m = path.match(/^\/api\/bots\/([\w-]+)\/checkpoints$/);
20920
- if (m && method === "GET") {
20921
- if (!store.bot(m[1]))
20922
- return json(res, 404, { error: "no such bot" });
20923
- const cwd = url.searchParams.get("cwd") ?? "";
20924
- if (!cwd.trim())
20925
- return json(res, 400, { error: "cwd query parameter required" });
20926
- return json(res, 200, {
20927
- checkpoints: await checkpoints.listCheckpoints(m[1], cwd),
20928
- enabled: await checkpoints.checkpointsEnabled(m[1], cwd),
20929
- });
20930
- }
20931
- m = path.match(/^\/api\/bots\/([\w-]+)\/checkpoints\/restore$/);
20932
- if (m && method === "POST") {
20933
- const bot = store.bot(m[1]);
20934
- if (!bot)
20935
- return json(res, 404, { error: "no such bot" });
20936
- const parsed = z
20937
- .object({ cwd: z.string().min(1), hash: z.string().regex(/^[0-9a-f]{40}$/) })
20938
- .safeParse(await readBody(req));
20939
- if (!parsed.success) {
20940
- return json(res, 400, { error: "cwd (absolute path) and hash (full 40-character checkpoint hash) required" });
20941
- }
20942
- // Claim synchronously with the busy check. startTurn checks the same
20943
- // lease before reserving the bot, so no turn can enter during the
20944
- // awaited Git operation.
20945
- if (bot.busy)
20946
- return json(res, 409, { error: "the bot is working — stop the turn before restoring files" });
20947
- if (checkpointRestoreLeases.has(bot.id)) {
20948
- return json(res, 409, { error: "this bot's project files are already being restored" });
20949
- }
20950
- checkpointRestoreLeases.add(bot.id);
20951
- let result;
20952
- try {
20953
- result = await checkpoints.restore(bot.id, parsed.data.cwd, parsed.data.hash);
20954
- }
20955
- finally {
20956
- checkpointRestoreLeases.delete(bot.id);
20957
- }
20958
- if (!result.ok)
20959
- return json(res, 400, { error: result.error });
20960
- return json(res, 200, { ok: true });
20961
- }
20962
21180
  // onboarding/ask cards persist their answered/dismissed state
20963
21181
  m = path.match(/^\/api\/bots\/([\w-]+)\/cards\/([\w-]+)$/);
20964
21182
  if (m && method === "PATCH") {
@@ -21332,6 +21550,8 @@ const handleRequest = async (req, res) => {
21332
21550
  return json(res, 403, { error: refusal });
21333
21551
  }
21334
21552
  return await answeringCardAs(auth, bot.threadId, String(body.requestId), async () => {
21553
+ if (resolveAndSendTrust(res, bot.threadId, String(body.requestId), behavior))
21554
+ return;
21335
21555
  if (await resolveAndSendTeamSetup(res, {
21336
21556
  botId: bot.id, threadId: bot.threadId, requestId: String(body.requestId), behavior,
21337
21557
  }, auth.kind === "loopback" ? DESKTOP_MANAGED || Boolean(req.headers.origin) && !store.bots.some((bot) => bot.busy || activeGroupTurnForBot(bot.id)) : auth.scopes.includes("admin")))
@@ -21360,14 +21580,6 @@ const handleRequest = async (req, res) => {
21360
21580
  behavior,
21361
21581
  }))
21362
21582
  return;
21363
- if (resolveAndSendTightening(res, {
21364
- botId: bot.id,
21365
- botName: bot.name,
21366
- threadId: bot.threadId,
21367
- requestId: String(body.requestId),
21368
- behavior,
21369
- }))
21370
- return;
21371
21583
  if (sendSkillResolution(res, resolveSkillRequest({
21372
21584
  botId: bot.id,
21373
21585
  botName: bot.name,
@@ -21434,6 +21646,8 @@ const handleRequest = async (req, res) => {
21434
21646
  return json(res, 403, { error: refusal });
21435
21647
  }
21436
21648
  return await answeringCardAs(auth, threadId, requestId, async () => {
21649
+ if (resolveAndSendTrust(res, threadId, requestId, behavior))
21650
+ return;
21437
21651
  const skillCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.skillRequest);
21438
21652
  if (skillCard?.card?.skillRequest) {
21439
21653
  const skillBotId = skillCard.from?.botId ?? store.botByThread(threadId)?.id;
@@ -21506,21 +21720,6 @@ const handleRequest = async (req, res) => {
21506
21720
  }))
21507
21721
  return;
21508
21722
  }
21509
- const tighteningCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.tighteningRequest);
21510
- if (tighteningCard?.card?.tighteningRequest) {
21511
- const tighteningBotId = tighteningCard.from?.botId ?? store.botByThread(threadId)?.id;
21512
- if (!tighteningBotId)
21513
- return json(res, 400, { error: "this tightening request has no valid owner" });
21514
- const tighteningOwner = store.bot(tighteningBotId);
21515
- if (resolveAndSendTightening(res, {
21516
- botId: tighteningBotId,
21517
- botName: tighteningOwner?.name,
21518
- threadId,
21519
- requestId,
21520
- behavior,
21521
- }))
21522
- return;
21523
- }
21524
21723
  // peer-approval intercept (see /api/bots/:id/respond above). A peer card
21525
21724
  // belongs to the bus rather than to a speaker, so resolve it before we go
21526
21725
  // looking for one — a room between turns has no speaker to find.
@@ -21643,15 +21842,8 @@ const handleRequest = async (req, res) => {
21643
21842
  return json(res, 200, { ok: true });
21644
21843
  }
21645
21844
  // ── tasks: a bot's separate contexts ────────────────────────────────
21646
- // The bot record answers with its messages because switching tasks
21647
- // changes which transcript is live, and a partial patch would leave
21648
- // the client showing the previous task's conversation.
21649
- const botWithThread = (bot) => ({
21650
- ...wireBot(bot),
21651
- messages: store.messagesFor(bot.threadId),
21652
- activeLeafId: store.activeLeaf(bot.threadId),
21653
- tasks: store.tasks(bot.id).map(wireTask),
21654
- });
21845
+ // Replies carry the open thread's newest page: a create or delete can
21846
+ // change which transcript is live. The frames come from store.onChange.
21655
21847
  // Folders organize one bot's threads; they never own settings,
21656
21848
  // transcripts or working directories. The project wire names stay stable.
21657
21849
  m = path.match(/^\/api\/bots\/([\w-]+)\/projects\/order$/);
@@ -21668,7 +21860,7 @@ const handleRequest = async (req, res) => {
21668
21860
  const projects = store.reorderProjects(bot.id, body.projectIds);
21669
21861
  if (!projects)
21670
21862
  return json(res, 400, { error: "projectIds must include each of this bot's folders exactly once" });
21671
- return json(res, 200, { projects, bot: botWithThread(bot) });
21863
+ return json(res, 200, { projects, bot: publicBot(bot) });
21672
21864
  }
21673
21865
  m = path.match(/^\/api\/bots\/([\w-]+)\/projects(?:\/([\w-]+))?$/);
21674
21866
  if (m && ((method === "POST" && !m[2]) || (method === "PATCH" && m[2]) || (method === "DELETE" && m[2]))) {
@@ -21679,7 +21871,7 @@ const handleRequest = async (req, res) => {
21679
21871
  return json(res, 404, { error: "no such folder" });
21680
21872
  if (method === "DELETE") {
21681
21873
  const updated = store.deleteProject(bot.id, m[2]);
21682
- return json(res, 200, { bot: botWithThread(updated) });
21874
+ return json(res, 200, { bot: publicBot(updated) });
21683
21875
  }
21684
21876
  const body = await readBody(req);
21685
21877
  if (!body || typeof body !== "object" || Array.isArray(body))
@@ -21702,7 +21894,7 @@ const handleRequest = async (req, res) => {
21702
21894
  const project = method === "POST"
21703
21895
  ? store.createProject(bot.id, patch.name, patch.emoji)
21704
21896
  : store.patchProject(bot.id, m[2], patch);
21705
- return json(res, method === "POST" ? 201 : 200, { project, bot: botWithThread(bot) });
21897
+ return json(res, method === "POST" ? 201 : 200, { project, bot: publicBot(bot) });
21706
21898
  }
21707
21899
  m = path.match(/^\/api\/bots\/([\w-]+)\/tasks$/);
21708
21900
  if (m && method === "POST") {
@@ -21737,9 +21929,7 @@ const handleRequest = async (req, res) => {
21737
21929
  // Who opened it decides who may answer its cards on a shared workspace.
21738
21930
  if (auth.kind === "session")
21739
21931
  threadStarters.set(task.threadId, actorKey(auth));
21740
- const fresh = botWithThread(store.bot(bot.id));
21741
- broadcast({ kind: "bot", bot: fresh });
21742
- return json(res, 201, { bot: fresh, task: wireTask(task) });
21932
+ return json(res, 201, { bot: publicBot(store.bot(bot.id)), task: wireTask(task) });
21743
21933
  }
21744
21934
  m = path.match(/^\/api\/bots\/([\w-]+)\/tasks\/([\w-]+)$/);
21745
21935
  if (m && method === "POST") {
@@ -21758,18 +21948,13 @@ const handleRequest = async (req, res) => {
21758
21948
  if (!switched)
21759
21949
  return json(res, 404, { error: "no such task" });
21760
21950
  const switchedSettings = { ...wireBot(switched), tasks: store.tasks(switched.id).map(wireTask) };
21761
- // Bounded for the same reason as the channel switch above.
21762
- broadcast({
21763
- kind: "bot",
21764
- bot: { ...switchedSettings, ...messagePage(switched.threadId, DEFAULT_PAGE) },
21765
- });
21766
21951
  // "0" is settings only, a positive page is a bounded transcript, and no
21767
21952
  // parameter is the whole thread — the one branch that materialises it.
21953
+ // Search results land through that branch (src/lib/focus-message.ts):
21954
+ // the hit can be older than one page.
21768
21955
  const responseBot = requestedMessages === "0"
21769
21956
  ? switchedSettings
21770
- : switchLimit === undefined
21771
- ? botWithThread(switched)
21772
- : { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
21957
+ : { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
21773
21958
  return json(res, 200, { bot: responseBot });
21774
21959
  }
21775
21960
  if (m && method === "PATCH") {
@@ -21844,9 +22029,7 @@ const handleRequest = async (req, res) => {
21844
22029
  const task = store.refreshTaskPermissions(profile.id, m[2]);
21845
22030
  if (!task)
21846
22031
  return json(res, 404, { error: "no such task" });
21847
- const fresh = botWithThread(store.bot(profile.id));
21848
- broadcast({ kind: "bot", bot: fresh });
21849
- return json(res, 200, { task: wireTask(task), bot: fresh });
22032
+ return json(res, 200, { task: wireTask(task), bot: publicBot(store.bot(profile.id)) });
21850
22033
  }
21851
22034
  const patch = {};
21852
22035
  if (body.projectId !== undefined) {
@@ -21952,9 +22135,7 @@ const handleRequest = async (req, res) => {
21952
22135
  const task = patch.modelSelection
21953
22136
  ? store.switchTaskModel(m[1], m[2], patch.modelSelection, body.updateBotDefault === true, body.resetApprovalToAsk === true, { ...patch, ...hostedModels?.resetTask(current.modelSelection, patch.modelSelection) })
21954
22137
  : store.patchTask(m[1], m[2], patch);
21955
- const fresh = botWithThread(store.bot(m[1]));
21956
- broadcast({ kind: "bot", bot: fresh });
21957
- return json(res, 200, { task: wireTask(task), bot: fresh });
22138
+ return json(res, 200, { task: wireTask(task), bot: publicBot(store.bot(m[1])) });
21958
22139
  }
21959
22140
  if (m && method === "DELETE") {
21960
22141
  const bot = store.bot(m[1]);
@@ -21977,12 +22158,11 @@ const handleRequest = async (req, res) => {
21977
22158
  if (!updated)
21978
22159
  return json(res, 404, { error: "no such task" });
21979
22160
  clearTurnDigestState(m[2]);
22161
+ revokeInternalCapabilitiesForThread(m[2]);
21980
22162
  handoffs.forget(m[2]);
21981
22163
  settleDirectFollowup(directTurnGenerationByThread.get(m[2]));
21982
22164
  rejectDeletedThreadSkillStages(stagedSkillCleanups);
21983
- const fresh = botWithThread(updated);
21984
- broadcast({ kind: "bot", bot: fresh });
21985
- return json(res, 200, { bot: fresh });
22165
+ return json(res, 200, { bot: publicBot(updated) });
21986
22166
  }
21987
22167
  // Regenerate title (#1858): name the thread again from its conversation
21988
22168
  // as it is now. The person asked, so the result replaces any title, but
@@ -22663,6 +22843,34 @@ const handleRequest = async (req, res) => {
22663
22843
  res.end(decisionsCsv(readDecisionRange(DATA_DIR, range)));
22664
22844
  return;
22665
22845
  }
22846
+ // ── a bot's activity: what it did, with the outcome ──
22847
+ // Read-only over the same two logs as the inspector and the decision
22848
+ // route above. The bot's own threads (its DM and every task) carry the
22849
+ // tool runs; the decision log carries the bot's requests wherever they
22850
+ // happened, since those rows name the bot.
22851
+ m = path.match(/^\/api\/bots\/([\w-]+)\/activity$/);
22852
+ if (m && method === "GET") {
22853
+ const bot = store.bot(m[1]);
22854
+ if (!bot)
22855
+ return json(res, 404, { error: "no such bot" });
22856
+ const rawLimit = url.searchParams.get("limit");
22857
+ const parsedLimit = rawLimit === null ? undefined : Number(rawLimit);
22858
+ if (parsedLimit !== undefined && (!Number.isInteger(parsedLimit) || parsedLimit <= 0)) {
22859
+ return json(res, 400, { error: "limit must be a positive whole number" });
22860
+ }
22861
+ const threadIds = [...new Set([bot.threadId, ...store.tasks(bot.id).map((task) => task.threadId)])];
22862
+ return json(res, 200, {
22863
+ rows: readBotActivity({ dataDir: DATA_DIR, eventsDir: EVENTS_DIR, botId: bot.id, threadIds, limit: parsedLimit ?? 300 }),
22864
+ });
22865
+ }
22866
+ // ── a bot's outbound allowance: the policy, and how much of it is spent ──
22867
+ m = path.match(/^\/api\/bots\/([\w-]+)\/outbound$/);
22868
+ if (m && method === "GET") {
22869
+ const bot = store.bot(m[1]);
22870
+ if (!bot)
22871
+ return json(res, 404, { error: "no such bot" });
22872
+ return json(res, 200, { policy: bot.outbound ?? DEFAULT_OUTBOUND_POLICY, today: outboundCounts.today(bot.id) });
22873
+ }
22666
22874
  // ── provider instances (model picker) ──
22667
22875
  if (method === "GET" && path === "/api/instances") {
22668
22876
  // Rescan PATH first: this endpoint is how the app answers "what can I
@@ -22947,7 +23155,7 @@ const handleRequest = async (req, res) => {
22947
23155
  catch { /* Allow repairing an unused malformed account. */ }
22948
23156
  entry.config = { ...entry.config, configDir: body.configDir };
22949
23157
  if (previousDir !== configuredAccountDirectory(entry)) {
22950
- const used = store.bots.some((bot) => bot.modelSelection.instanceId === instanceId || store.tasks(bot.id).some((task) => task.modelSelection?.instanceId === instanceId || task.resumeCursors[instanceId] || task.lastInstanceId === instanceId));
23158
+ const used = store.bots.some((bot) => bot.modelSelection.instanceId === instanceId || bot.fallback?.some(candidate => candidate.instanceId === instanceId) || store.tasks(bot.id).some((task) => task.modelSelection?.instanceId === instanceId || task.resumeCursors[instanceId] || task.lastInstanceId === instanceId));
22951
23159
  if (used)
22952
23160
  return json(res, 409, { error: "This account is used by bots or conversation history. Add another account and select it for the bot instead." });
22953
23161
  assertSeparateClaudeAccount(instances, instanceId, entry);
@@ -22971,7 +23179,7 @@ const handleRequest = async (req, res) => {
22971
23179
  if (instances[instanceId].driver !== "claudeAgent" || instanceId === "claude") {
22972
23180
  return json(res, 400, { error: "Only added Claude accounts can be removed here." });
22973
23181
  }
22974
- if (cfg.defaultModelSelection?.instanceId === instanceId || store.bots.some((bot) => bot.modelSelection.instanceId === instanceId || store.tasks(bot.id).some((task) => task.modelSelection?.instanceId === instanceId)) ||
23182
+ if (cfg.defaultModelSelection?.instanceId === instanceId || store.bots.some((bot) => bot.modelSelection.instanceId === instanceId || bot.fallback?.some(candidate => candidate.instanceId === instanceId) || store.tasks(bot.id).some((task) => task.modelSelection?.instanceId === instanceId)) ||
22975
23183
  busyProviderSelections().some((selection) => selection.instanceId === instanceId)) {
22976
23184
  return json(res, 409, { error: "Choose another account for the bots and default model using this account before removing it." });
22977
23185
  }
@@ -23579,6 +23787,15 @@ const handleRequest = async (req, res) => {
23579
23787
  if (conflict)
23580
23788
  return json(res, 409, { error: conflict });
23581
23789
  }
23790
+ // A change names only the OpenCode provider keys it saves or removes;
23791
+ // the rest stay. Merged with no await before the save below, so it
23792
+ // starts from what is saved at that moment.
23793
+ if (patch.opencodeGo?.providerKeys) {
23794
+ const merged = mergeOpenCodeProviderKeys(cfg, patch.opencodeGo.providerKeys);
23795
+ if (!merged.ok)
23796
+ return json(res, 400, { error: merged.error });
23797
+ patch.opencodeGo = { ...patch.opencodeGo, providerKeys: merged.keys };
23798
+ }
23582
23799
  const browserCleanupRequests = [];
23583
23800
  if (profileControlConflict())
23584
23801
  return json(res, 409, { error: "Release browser control before deleting its profile." });
@@ -24517,6 +24734,8 @@ const gracefulShutdown = createGracefulShutdown({
24517
24734
  // the shutdown deadline), immediately before the process exits, so no new
24518
24735
  // server can overlap with a still-mutating old one.
24519
24736
  exit: (code) => {
24737
+ // Streamed text the bus is still merging reaches the log and clients.
24738
+ bus.flush();
24520
24739
  try {
24521
24740
  sessions.close();
24522
24741
  }
@@ -24529,8 +24748,8 @@ const gracefulShutdown = createGracefulShutdown({
24529
24748
  closeMessageDb();
24530
24749
  mcpOAuth.dispose();
24531
24750
  releaseDataDirLeaseAtExit();
24532
- // A Cloud home's launcher starts the server again on this code only.
24533
- process.exit(cloudHomeRestartRequested && code === 0 ? CLOUD_HOME_RESTART_EXIT_CODE : code);
24751
+ // Every launcher in this repo starts the server again on this code (server/restart.ts).
24752
+ process.exit(restartRequested && code === 0 ? RESTART_EXIT_CODE : code);
24534
24753
  },
24535
24754
  });
24536
24755
  for (const signal of ["SIGINT", "SIGTERM"]) {