openmausbot 0.1.95 → 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 (140) hide show
  1. package/dist/assets/{DesktopViewer-BwWpc4RK.js → DesktopViewer-DsnA23KM.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-jsQkIqTn.js → abnfDiagram-O67JEVCF-Dkbd2hcj.js} +1 -1
  3. package/dist/assets/{arc-7kei4Hsu.js → arc-Duj5Coy2.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CRWDFDMq.js → architectureDiagram-NJMV4G6O-uYUsDawP.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CTKW_IKs.js → blockDiagram-BEXU5L5S-CWDlTpXe.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-BBTW7-6M.js → c4Diagram-YGBWAQC7-B0t15PDc.js} +1 -1
  7. package/dist/assets/channel-BZxwC-Gw.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-M_Ox-MkD.js → chunk-2Q5K7J3B-BJSCL-HV.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-D2YiN-Ud.js → chunk-3FUC2YCW-D3_ILccd.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-BmVYJ0f8.js → chunk-5VM5RSS4-D3dmwT_S.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-BtiLVVnZ.js → chunk-GTNCS2PH-Br73HFPD.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-DFYSb6J6.js → chunk-GWA4HPMP-uSsjxubC.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-mknKBzgV.js → chunk-JWPE2WC7-C40VWFyr.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-DgSYGiOu.js → chunk-NETBCI7D-D6WNNYwU.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-C7ZvuguH.js → chunk-WEXAMYUT-DOEhq64P.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-CPqxINpA.js → chunk-XXDRQBXY-BqJEi6Pi.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-Cd6xfJaC.js → classDiagram-v2-NBCMYWYE-ByU_GnxT.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-BSXY5qRh.js → cose-bilkent-JH36ORCC-D-Uglxqq.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-D_lhEMj1.js → cynefin-OW5HDTMX-CZWO7aYj.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-Da9FrFwk.js → cynefinDiagram-VND7K2PF-C70-w0la.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-T77jMOXe.js → dagre-6A5THRUB-CnhjhExB.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-Dkj96X5E.js → diagram-22UHCM2B-bnjWCNb4.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-CMi9zlD_.js → diagram-3UASUU5V-A4uTWoFV.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-CXK6YUfS.js → diagram-ATOU4E4O-Zi6m3NUl.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-BE7B1u9Q.js → diagram-CDSNMT55-DtCcmd5k.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-CCMQphKr.js → diagram-MLGK6HIB-DofuETS3.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-iHJ_-tGH.js → diagram-MPIPVDR6-Dw2T9PuU.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-DONsCWQ7.js → ebnfDiagram-ZINNZB2B-DbrwPiem.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-CU_WOaXk.js → elk-276RUBZZ-BvCt1dCb.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-BeYwM04G.js → erDiagram-OPXOYQCR-Y-QKqPnU.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-Bdw9DqEl.js → flowDiagram-KWPJA3E3-CDkL1B5w.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-C9iwq3qm.js → ganttDiagram-FUAMR5RP-CKV5D2F6.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-Dx-VWzUg.js → gitGraphDiagram-X574FWY7-nL9iFTCv.js} +1 -1
  34. package/dist/assets/{index-KygHRE3E.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-BG7uaEd4.js → infoDiagram-VRGFBTTK-C7aHjzsf.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-D72WAXnM.js → ishikawaDiagram-OU5B5YK6-KUZsNBTh.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DKH1gjYT.js → journeyDiagram-ZHPQQLJL-CGUuodFl.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-3upm9JUT.js → kanban-definition-PNTS6WVX-Cy6r868Z.js} +1 -1
  41. package/dist/assets/{linear-CcR4pc0-.js → linear-Bl669148.js} +1 -1
  42. package/dist/assets/{mermaid.core-C6yU5FSS.js → mermaid.core-86Xqz5Mr.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-DOI2xEuv.js → mindmap-definition-NLK3R4M7-G4DzHLrd.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-BVWJSgLd.js → pegDiagram-GJSIUBJH-K4eruzY0.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-jtbYezhq.js → pieDiagram-5QR66LMP-DYdR9PPq.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-g0u8mtPX.js → quadrantDiagram-O4NWA36T-BCAh0nEy.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-k8689Wny.js → railroadDiagram-XR7U4H2S-p0dGRc4g.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-DsJ22PDt.js → requirementDiagram-PLB6GJNP-tLeBG0aL.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-CneWBRgG.js → sankeyDiagram-IPEJSGJF-QRuN16rV.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-DWsFCWap.js → sequenceDiagram-PO4LG4MO-ueT1nVoa.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-Bvf0YE3S.js → sizeCapture-INFHLROL-wRS2zWg2.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-Bxb3Iei8.js → stateDiagram-v2-GCMORJYK-CYzA_hE1.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DLkk7W4A.js → swimlanes-2SLR337P-kDnv6LMc.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-C7voMDle.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-KR9bIHYC.js → timeline-definition-EJHVYXUP-D1y2UbbH.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-DfsOrj8t.js → usecaseDiagram-POWQR4AR-IqB4v9HX.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-DRSTzG3h.js → vennDiagram-UO4OBE2U-BEi7KYVF.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DtEdhPgT.js → wardleyDiagram-VNRHLVJA-Dkybo-Yf.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-B-XhTxjG.js → xychartDiagram-PMCCYNJV-BAPFJLpg.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/cloud-home-start.js +25 -12
  62. package/dist-server/companion/src/listener.js +39 -18
  63. package/dist-server/container-mcp.js +14 -27
  64. package/dist-server/drivers/agents-proxy.js +103 -44
  65. package/dist-server/harness-mcp-proxy.js +1 -2
  66. package/dist-server/index.js +2633 -3396
  67. package/dist-server/openmausbot.js +606 -463
  68. package/dist-server/pair-cli.js +606 -463
  69. package/dist-server/server/admin-activity.js +1 -4
  70. package/dist-server/server/auto-approve.js +19 -4
  71. package/dist-server/server/boat.js +4 -4
  72. package/dist-server/server/checkpoints.js +90 -148
  73. package/dist-server/server/chief-of-staff.js +7 -6
  74. package/dist-server/server/cli.js +33 -26
  75. package/dist-server/server/cloud-home-start.js +12 -15
  76. package/dist-server/server/cloud-home.js +0 -4
  77. package/dist-server/server/cloud-move-http.js +44 -30
  78. package/dist-server/server/cloud-move.js +20 -18
  79. package/dist-server/server/composio.js +10 -3
  80. package/dist-server/server/config.js +92 -64
  81. package/dist-server/server/container-computer.js +16 -5
  82. package/dist-server/server/contracts.js +0 -9
  83. package/dist-server/server/decider/jev.js +1 -4
  84. package/dist-server/server/decider/log.js +1 -3
  85. package/dist-server/server/decision-log.js +1 -4
  86. package/dist-server/server/delegations.js +3 -2
  87. package/dist-server/server/drivers/acp/core.js +4 -3
  88. package/dist-server/server/drivers/acp/opencode-go.js +17 -16
  89. package/dist-server/server/drivers/agents-call.js +46 -63
  90. package/dist-server/server/drivers/agents-catalog.js +66 -0
  91. package/dist-server/server/drivers/agents-proxy.js +2 -1
  92. package/dist-server/server/drivers/claude-login-auth.js +2 -11
  93. package/dist-server/server/drivers/codex-device-auth.js +3 -1
  94. package/dist-server/server/drivers/codex.js +5 -4
  95. package/dist-server/server/drivers/grok.js +1 -1
  96. package/dist-server/server/drivers/openai-chat-protocol.js +4 -2
  97. package/dist-server/server/drivers/openai-chat.js +15 -5
  98. package/dist-server/server/drivers/prompt-split.js +4 -4
  99. package/dist-server/server/harness/bus.js +49 -0
  100. package/dist-server/server/harness-mcp-proxy.js +1 -1
  101. package/dist-server/server/index.js +448 -762
  102. package/dist-server/server/mcp-registry.js +35 -0
  103. package/dist-server/server/message-file.js +4 -4
  104. package/dist-server/server/peer-delivery.js +4 -19
  105. package/dist-server/server/peer-roster.js +30 -4
  106. package/dist-server/server/profile-versions.js +0 -31
  107. package/dist-server/server/provider-key-check.js +2 -1
  108. package/dist-server/server/restart.js +40 -0
  109. package/dist-server/server/room-handoffs.js +12 -0
  110. package/dist-server/server/routes/bot-memory.js +0 -24
  111. package/dist-server/server/routine-requests.js +1 -4
  112. package/dist-server/server/routines.js +5 -4
  113. package/dist-server/server/server-launcher.js +53 -0
  114. package/dist-server/server/store.js +1 -1
  115. package/dist-server/server/surface.js +23 -27
  116. package/dist-server/server/system-prompt.js +13 -3
  117. package/dist-server/server/team-manifest.js +1 -6
  118. package/dist-server/server/tunnel.js +4 -1
  119. package/dist-server/server/turn-images.js +1 -21
  120. package/dist-server/server/turn-resources.js +19 -150
  121. package/dist-server/server/usage-ledger.js +3 -1
  122. package/dist-server/server/workspace.js +2 -3
  123. package/dist-server/server-launcher.js +72 -0
  124. package/dist-server/shared/ask-question.js +1 -1
  125. package/dist-server/shared/cloud-computer.js +12 -0
  126. package/dist-server/shared/failed-turn.js +25 -0
  127. package/dist-server/shared/live-approval.js +2 -2
  128. package/dist-server/shared/package-format.js +3 -3
  129. package/dist-server/shared/pairing-link.js +144 -0
  130. package/dist-server/vps-container-mcp.js +14 -27
  131. package/dist-server/workspace-backup.worker.js +14 -27
  132. package/package.json +1 -1
  133. package/dist/assets/channel-DS2NbT5x.js +0 -1
  134. package/dist/assets/index-NF7XCHAy.css +0 -1
  135. package/dist/assets/index-X_5ydXIE.js +0 -610
  136. package/dist/assets/swimlanesDiagram-TC7HE7FX-C6bZtBXk.js +0 -8
  137. package/dist-server/server/claim-idle.js +0 -33
  138. package/dist-server/server/cloud-overflow.js +0 -167
  139. package/dist-server/server/tightening-requests.js +0 -435
  140. package/dist-server/shared/tightening-request.js +0 -87
@@ -27,6 +27,9 @@ import { assertRequestTarget, guardedRequestPath, requestConflict, requestNeedsI
27
27
  import { botAvatarUrlFromStoredPath } from "../shared/bot-avatar.js";
28
28
  import { BOT_PROFILE_LIMITS } from "../shared/bot-profile.js";
29
29
  import { CLOUD_COMPUTER_BUSY_ERROR } from "../shared/computer-contention.js";
30
+ import { failedTurnTool } from "../shared/failed-turn.js";
31
+ import { phonePairingLink } from "../shared/pairing-link.js";
32
+ import { canWorkOnCloud } from "../shared/cloud-computer.js";
30
33
  import { approvalModeFor, modelSwitchNeedsAsk, isEmergencyApprovalDowngrade, isApprovalMode, } from "../shared/approval-mode.js";
31
34
  import { createApprovalModeSupport } from "./harness-capabilities.js";
32
35
  import { escapeAttribute } from "../src/lib/composer-attachments.js";
@@ -58,7 +61,7 @@ import { isPersistentQuestionCard, QUESTION_DISMISS_MESSAGE, shouldSettleRequest
58
61
  import { parseToolScope, toolScopeWidens } from "../shared/tool-scope.js";
59
62
  import { boatCreateRecoverySnapshot, retireDeletedBoatCreate } from "./boat-create-idempotency.js";
60
63
  import { boatDeletionSnapshot } from "./boat-delete-journal.js";
61
- import { liveDecisionRefusal } from "../shared/live-approval.js";
64
+ import { liveCardKind, liveDecisionRefusal } from "../shared/live-approval.js";
62
65
  import { boatAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourceChangeError, } from "./cloud-backend.js";
63
66
  import * as composio from "./composio.js";
64
67
  import { connectorCallFromFrame, connectorRefusalText, connectorUnrecognizedText, evaluateConnectorTools } from "./connector-verdict.js";
@@ -69,7 +72,7 @@ import { appendAboutMe, commitLearned, planLearned } from "./profile-learned.js"
69
72
  import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, livePeerRosterBlock, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.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 { cacheUntilConfigChanges, ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmIdleTimeoutMinutes, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, computerClaimIdleReleaseEnabled, cloudOverflowAllowlistedThreads, cloudOverflowEnabled, cloudOverflowIdleStopMs, cloudOverflowPerSecondCostUsd, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, stripWorkspaceCredentialEnv, PROVIDER_CREDENTIAL_ENV, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, skillsLibraryEnabled, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, CLAUDE_API_INSTANCE, liveSettingsFor, } from "./config.js";
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";
@@ -85,16 +88,16 @@ import { entitled } from "./enterprise.js";
85
88
  import { HOSTED_CONTRACT_HEADER, HOSTED_CONTRACT_METADATA, HOSTED_CONTRACT_VERSION } from "./hosted-contract.js";
86
89
  import { describeSpawnFailure, execCli } from "./procs.js";
87
90
  import { blockedTarget, buildNotification, buildSpendNotification } from "./notify.js";
88
- import { isModelVariant, TurnNotStartedError, newId, usesCloudComputer, } from "./contracts.js";
91
+ 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, TEAM_MEMORY_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";
@@ -208,7 +210,6 @@ import { shouldMountLocalComputer } from "./local-routing.js";
208
210
  import { autoLocalVmAttachable } from "./container-computer.js";
209
211
  import { startAutoVmClaim } from "./auto-vm-claims.js";
210
212
  import { computerFreeAfterText, computerParkedText, computerStoppedWaitingText, computerWaitingText } from "./computer-wait.js";
211
- import { CloudOverflowConsent, CloudSeatLease, cloudOverflowAction, cloudOverflowConsentText, cloudOverflowOfferText, cloudSeatStartedText, cloudSeatStoppedText } from "./cloud-overflow.js";
212
213
  import { modelContextWindow } from "./model-context-window.js";
213
214
  import { cloudPlaceDriverError, parseSurface, PlaceUnavailableError, placeUnavailable, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
214
215
  import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
@@ -775,11 +776,16 @@ function approvalShape(shape) {
775
776
  /** Who opens a routine's results conversation on a Cloud home: the writer
776
777
  * of this request, else the owner for a routine that is theirs (they wrote
777
778
  * it as it stands, or they are its writer: cloud-owner.ts), else its last
778
- * 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. */
779
783
  function routineOpener(routineId) {
780
784
  if (routineWriterInFlight)
781
785
  return routineWriterInFlight;
782
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;
783
789
  if (routine && CLOUD_OWNER_KEY && cloudRoutineAuthors?.authored(routineId, routine))
784
790
  return CLOUD_OWNER_KEY;
785
791
  const writer = cloudRoutineAuthors?.writer(routineId);
@@ -1247,11 +1253,11 @@ function memberFrameContext(visible) {
1247
1253
  webhookBot: (webhookId) => webhooks.list().find((webhook) => webhook.id === webhookId)?.botId,
1248
1254
  freshBot: (botId) => {
1249
1255
  const bot = store.bot(botId);
1250
- return bot ? { ...wireBot(bot), tasks: store.tasks(bot.id).map(wireTask), ...messagePage(bot.threadId, DEFAULT_PAGE) } : undefined;
1256
+ return bot ? publicBot(bot) : undefined;
1251
1257
  },
1252
1258
  freshGroup: (groupId) => {
1253
1259
  const group = store.group(groupId);
1254
- return group ? { ...publicGroupState(group), ...messagePage(group.threadId, DEFAULT_PAGE) } : undefined;
1260
+ return group ? groupWithThread(group) : undefined;
1255
1261
  },
1256
1262
  };
1257
1263
  }
@@ -1522,6 +1528,8 @@ const openCodeKeysAllowed = () => openCodeProviderKeysAllowed({
1522
1528
  sharedSignIn: sharedSignIn(signInAllowList()),
1523
1529
  });
1524
1530
  setOpenCodeProviderKeyPolicy(openCodeKeysAllowed);
1531
+ // Keys the owner saved for OpenCode in Settings reach it on every server.
1532
+ setOpenCodeOwnProviderKeys(() => openCodeProviderKeys(cfg));
1525
1533
  let openCodeKeysLastAllowed = openCodeKeysAllowed();
1526
1534
  /** OpenCode lists a provider's models only while it may read that
1527
1535
  * provider's key. When enrollment or the sign-in list changes the answer,
@@ -1754,6 +1762,8 @@ utilityParentPort?.on("message", event => {
1754
1762
  });
1755
1763
  });
1756
1764
  const MAX_ATTACHED_FILES_PER_TURN = 10;
1765
+ const MAX_ROOM_POSTS_PER_TURN = 3;
1766
+ const MAX_CREATED_BOTS_PER_TURN = 4;
1757
1767
  // A capability lives for the exact provider-turn generation, including while
1758
1768
  // that turn is parked on a human approval. The long ceiling is only an orphan
1759
1769
  // backstop for an impossible-to-settle adapter; normal terminal paths revoke
@@ -1810,15 +1820,52 @@ function beginInternalCapabilityGeneration(threadId, generation = randomUUID())
1810
1820
  activeInternalGenerationByThread.set(threadId, generation);
1811
1821
  return generation;
1812
1822
  }
1813
- 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 } = {}) {
1814
1854
  if (activeInternalGenerationByThread.get(capability.threadId) !== capability.generation) {
1815
1855
  throw new Error("cannot mint an integration capability for an inactive turn");
1816
1856
  }
1817
- const token = randomBytes(24).toString("hex");
1818
- internalCapabilities.set(token, {
1819
- ...capability,
1820
- orphanExpiresAt: Date.now() + INTERNAL_CAPABILITY_ORPHAN_MS,
1821
- });
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
+ }
1822
1869
  return token;
1823
1870
  }
1824
1871
  /** A turn that was not provably the owner's has ended: judge its bot's
@@ -1852,7 +1899,9 @@ function revokeInternalCapabilityGeneration(threadId, generation) {
1852
1899
  }
1853
1900
  internalGenerationByProviderTurn.deleteGeneration(threadId, generation);
1854
1901
  }
1855
- 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) {
1856
1905
  computerSelectionTurns.delete(threadId);
1857
1906
  const generation = activeInternalGenerationByThread.get(threadId);
1858
1907
  if (generation)
@@ -1865,9 +1914,16 @@ function revokeInternalCapabilitiesForThread(threadId) {
1865
1914
  internalCapabilities.delete(token);
1866
1915
  }
1867
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
+ }
1868
1923
  function revokeAllInternalCapabilities() {
1869
1924
  computerSelectionTurns.clear();
1870
1925
  internalCapabilities.clear();
1926
+ sessionCredentials.clear();
1871
1927
  // foreignTurns stays: a turn that is not provably the owner's can still
1872
1928
  // write while its engine is torn down, and its own end (which revokes its
1873
1929
  // generation by id) is what judges it (server/lending-memory.ts).
@@ -2031,6 +2087,15 @@ class ComputerWaitParked extends Error {
2031
2087
  this.generation = generation;
2032
2088
  }
2033
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
+ }
2034
2099
  const directTurnDispatchClaims = new Map();
2035
2100
  const directTurnGenerationByThread = new Map();
2036
2101
  // Only the latest direct request per thread is retained. A fresh user turn
@@ -2073,173 +2138,14 @@ const turnResources = new TurnResources();
2073
2138
  const sharedComputerControl = new SharedComputerControl(turnResources, () => store.bots.some(bot => botComputerControlSnapshot(bot.id).held));
2074
2139
  const turnResourceOwners = new Map();
2075
2140
  const turnComputerResources = new Map();
2076
- /** Screen-touching computer calls that started before their turn's computer
2077
- * claim was bound (#1653 over the lazy attach of #1361): the start event
2078
- * finds no claim to fence, so it waits here and bindTurnComputer carries it
2079
- * into the fresh claim, keeping the quiet window from releasing a seat
2080
- * under a call the desktop is already executing. */
2081
- const pendingComputerCallStarts = new Map();
2082
2141
  const teamComputerTurns = new Map();
2083
2142
  const settlingResourceOwners = new Map();
2084
- /** Idle release for computer claims (#1653), disabled until the feature
2085
- * flag is on: a seat that stays screen-quiet for the policy's window is
2086
- * released to waiting turns while its holder's turn still lives, and the
2087
- * previous holder re-claims directly inside the reclaim window — it
2088
- * yields once another turn holds the seat. The durable VM profile keeps
2089
- * its login state, so that re-claim is cheap. Read per claim: a flag
2090
- * change applies to new claims, never to a seat held under the old
2091
- * setting. computer:phone is excluded: it is the #1663 exclusivity lock,
2092
- * claimed once per turn with no activity signal to renew it. */
2093
- const COMPUTER_CLAIM_IDLE_POLICY = new IdleReleasePolicy();
2094
- function computerClaimIdlePolicy(resource) {
2095
- return resource.startsWith("computer:") && resource !== "computer:phone" && computerClaimIdleReleaseEnabled(cfg) ? COMPUTER_CLAIM_IDLE_POLICY : undefined;
2096
- }
2097
- /** #1655: the consent ledger for local-wait cloud overflow, and the live
2098
- * cloud seats it has started. One seat per bot — it is the bot's own Box,
2099
- * shared by that bot's threads the way every other computer is. */
2100
- const cloudOverflowConsent = new CloudOverflowConsent();
2101
- const cloudSeatLeases = new Map();
2102
- let cloudSeatSweepTimer;
2103
- /** One stop attempt per seat at a time (#1655): the sweep ticks faster
2104
- * than a Box sleeps, and a second stop call would race the first. */
2105
- const cloudSeatStopsInFlight = new Set();
2106
- /** The waits that may overflow: a turn queued for a local desktop, never
2107
- * one already bound for a cloud or VPS seat. */
2108
- function localComputerWait(resource) {
2109
- return resource === "computer:host" || resource.startsWith("computer:vm:");
2110
- }
2111
- function cloudOverflowSituation(owner, resource, started) {
2112
- if (!localComputerWait(resource))
2113
- return null;
2114
- if (!store.botByThread(owner.threadId))
2115
- return null;
2116
- const perSecondCostUsd = cloudOverflowPerSecondCostUsd(cfg);
2117
- return {
2118
- featureEnabled: cloudOverflowEnabled(cfg),
2119
- // An organisation that refuses the Box kind has nothing to offer, and
2120
- // the decision must fail closed before any card is written.
2121
- cloudConfigured: boat.boatConfigured(cfg) && managedPolicy.computerRefusal("box") === undefined,
2122
- perSecondCostUsd,
2123
- // Consent answers the priced card (#1655): a grant counts only at the
2124
- // rate its card showed, so a config change re-offers instead of
2125
- // starting at a price nobody approved.
2126
- consented: cloudOverflowConsent.consented(owner.threadId, cloudOverflowAllowlistedThreads(cfg), perSecondCostUsd ?? undefined),
2127
- offered: cloudOverflowConsent.offered(owner.threadId, perSecondCostUsd ?? undefined),
2128
- started,
2129
- };
2130
- }
2131
2143
  function claimTurnResource(owner, resource) {
2132
- const idle = computerClaimIdlePolicy(resource);
2133
- if (!turnResources.claim(resource, owner, idle ? { idle } : {}))
2144
+ if (!turnResources.claim(resource, owner))
2134
2145
  return false;
2135
2146
  turnResourceOwners.set(owner.threadId, owner);
2136
2147
  return true;
2137
2148
  }
2138
- /** Start the waiting bot's cloud seat (#1655). True once handled — started
2139
- * or terminally failed — so the wait never retries every poll; false when
2140
- * another turn of this bot owns the Box lifecycle and a later poll may
2141
- * still land. */
2142
- async function startCloudSeat(owner) {
2143
- const bot = store.botByThread(owner.threadId);
2144
- if (!bot)
2145
- return true;
2146
- const perSecondCostUsd = cloudOverflowPerSecondCostUsd(cfg);
2147
- if (!perSecondCostUsd)
2148
- return true;
2149
- const boxBotResource = "computer:box-bot:" + bot.id;
2150
- if (!claimTurnResource(owner, boxBotResource))
2151
- return false;
2152
- try {
2153
- broadcast({ kind: "computer", botId: bot.id, state: "waking" });
2154
- const machine = await boat.readyBoat(cfg, bot.id);
2155
- if (!machine)
2156
- throw new Error("the cloud computer did not wake");
2157
- // Consent can be revoked while the Box wakes (#1655): a seat nobody
2158
- // consented to must not bill. It is released and put straight back to
2159
- // sleep — and if even that fails, leased as already idle so the sweep
2160
- // keeps retrying with backoff.
2161
- if (!cloudOverflowConsent.consented(owner.threadId, cloudOverflowAllowlistedThreads(cfg), perSecondCostUsd)) {
2162
- turnResources.releaseOne(boxBotResource, owner);
2163
- const idleStopMs = cloudOverflowIdleStopMs(cfg);
2164
- void boat.sleepBoat(cfg, bot.id).catch(() => {
2165
- cloudSeatLeases.set(bot.id, new CloudSeatLease({ botId: bot.id, threadId: owner.threadId, generation: owner.generation, now: Date.now() - idleStopMs - 1, idleStopMs }));
2166
- ensureCloudSeatSweep();
2167
- });
2168
- store.appendMessage(owner.threadId, {
2169
- role: "bot", kind: "activity",
2170
- tool: { name: "Cloud overflow consent was revoked while the cloud computer woke; it stops unbilled and the local wait continues.", ok: true },
2171
- });
2172
- return true;
2173
- }
2174
- cloudSeatLeases.set(bot.id, new CloudSeatLease({ botId: bot.id, threadId: owner.threadId, generation: owner.generation, idleStopMs: cloudOverflowIdleStopMs(cfg) }));
2175
- ensureCloudSeatSweep();
2176
- store.appendMessage(owner.threadId, {
2177
- role: "bot", kind: "activity",
2178
- tool: { name: cloudSeatStartedText({ perSecondCostUsd, idleStopMs: cloudOverflowIdleStopMs(cfg) }), ok: true },
2179
- });
2180
- return true;
2181
- }
2182
- catch (error) {
2183
- turnResources.releaseOne(boxBotResource, owner);
2184
- store.appendMessage(owner.threadId, {
2185
- role: "bot", kind: "activity",
2186
- tool: { name: "Cloud computer could not start (" + (error instanceof Error ? error.message : String(error)) + "); the local wait continues.", ok: false },
2187
- });
2188
- return true;
2189
- }
2190
- }
2191
- /** A per-second-billed seat must stop on idleness even when nothing else
2192
- * touches the server, so this slice carries the one timer — unref'd, it
2193
- * can never hold the process open, and it dies with the last lease. */
2194
- function ensureCloudSeatSweep() {
2195
- if (cloudSeatSweepTimer)
2196
- return;
2197
- cloudSeatSweepTimer = setInterval(() => {
2198
- sweepIdleCloudSeats();
2199
- if (!cloudSeatLeases.size && cloudSeatSweepTimer) {
2200
- clearInterval(cloudSeatSweepTimer);
2201
- cloudSeatSweepTimer = undefined;
2202
- }
2203
- }, 15_000);
2204
- cloudSeatSweepTimer.unref?.();
2205
- }
2206
- /** Stop every cloud seat idle past its window (#1655): the machine is
2207
- * archived, billing pauses, and the transcript says which wait's seat it
2208
- * was. Only real computer tool completions refresh a lease — the poller's
2209
- * preview frames never reach the touch call. */
2210
- /** Stop one running cloud seat (#1655): the Box sleeps, and only a
2211
- * successful stop clears the lease and its Box claim — a failed stop
2212
- * stays leased and retryable with backoff, because the seat still bills
2213
- * until it sleeps. The claim releases for the lease's own generation,
2214
- * never a newer turn's. */
2215
- function stopCloudSeat(botId, lease, stoppedName, now = Date.now()) {
2216
- if (cloudSeatStopsInFlight.has(botId))
2217
- return;
2218
- if (cloudSeatLeases.get(botId) !== lease)
2219
- return;
2220
- cloudSeatStopsInFlight.add(botId);
2221
- void boat.sleepBoat(cfg, botId)
2222
- .then(() => {
2223
- if (cloudSeatLeases.get(botId) === lease)
2224
- cloudSeatLeases.delete(botId);
2225
- // The stopped seat's Box claim goes with it, so a still-waiting
2226
- // turn may start a fresh seat for the same bot.
2227
- turnResources.releaseOne(`computer:box-bot:${botId}`, { threadId: lease.threadId, generation: lease.generation });
2228
- store.appendMessage(lease.threadId, { role: "bot", kind: "activity", tool: { name: stoppedName, ok: true } });
2229
- })
2230
- .catch(() => {
2231
- lease.deferSleep(now);
2232
- store.appendMessage(lease.threadId, { role: "bot", kind: "activity", tool: { name: "Cloud computer could not stop — the sweep retries with backoff; stop it from Settings if it stays awake. Billing continues until it sleeps.", ok: false } });
2233
- })
2234
- .finally(() => cloudSeatStopsInFlight.delete(botId));
2235
- }
2236
- function sweepIdleCloudSeats(now = Date.now()) {
2237
- for (const [botId, lease] of cloudSeatLeases) {
2238
- if (!lease.idleElapsed(now) || !lease.sleepDue(now))
2239
- continue;
2240
- stopCloudSeat(botId, lease, cloudSeatStoppedText(lease.idleFor(now)), now);
2241
- }
2242
- }
2243
2149
  function releaseTurnResources(owner) {
2244
2150
  if (!owner)
2245
2151
  return;
@@ -2252,8 +2158,6 @@ function releaseTurnResources(owner) {
2252
2158
  turnResourceOwners.delete(owner.threadId);
2253
2159
  if (turnComputerResources.get(owner.threadId)?.owner.generation === owner.generation)
2254
2160
  turnComputerResources.delete(owner.threadId);
2255
- if (pendingComputerCallStarts.get(owner.threadId)?.generation === owner.generation)
2256
- pendingComputerCallStarts.delete(owner.threadId);
2257
2161
  const teamTurn = teamComputerTurns.get(owner.threadId);
2258
2162
  if (teamTurn?.owner.generation === owner.generation) {
2259
2163
  stopScreenPoller(teamTurn.botId, owner.threadId);
@@ -2283,10 +2187,6 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2283
2187
  // turn.wait_* events carry the same facts for the inspector log.
2284
2188
  let waitedSince;
2285
2189
  let waitEnded = false;
2286
- // #1655: whether this wait already started (or terminally failed to
2287
- // start) a cloud seat; the decision is re-evaluated every poll because
2288
- // consent can arrive mid-wait.
2289
- let overflowHandled = false;
2290
2190
  const waitEventBase = () => {
2291
2191
  const selection = store.botByThread(owner.threadId)?.modelSelection;
2292
2192
  const instance = selection ? registry.get(selection.instanceId) : null;
@@ -2360,32 +2260,7 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2360
2260
  position,
2361
2261
  ...(holder ? { holder } : {}),
2362
2262
  });
2363
- // #1655: the wait is where an overflow choice gets made. The card
2364
- // names the per-second cost before anything can start, and only a
2365
- // turn genuinely waiting on a local desktop gets one. Fail closed:
2366
- // feature off, cloud unusable, or no operator-set rate means no
2367
- // card at all — Auto never offers, never starts.
2368
- const overflow = cloudOverflowSituation(owner, resource, overflowHandled);
2369
- const perSecondCostUsd = overflow?.perSecondCostUsd ?? null;
2370
- if (overflow && perSecondCostUsd !== null && cloudOverflowAction(overflow).kind === "offer") {
2371
- store.appendMessage(owner.threadId, {
2372
- role: "bot", kind: "activity",
2373
- tool: { name: cloudOverflowOfferText({ perSecondCostUsd, idleStopMs: cloudOverflowIdleStopMs(cfg) }), ok: true },
2374
- });
2375
- cloudOverflowConsent.markOffered(owner.threadId, perSecondCostUsd);
2376
- }
2377
- }
2378
- // #1655: consent can arrive mid-wait — the card's answer, or a
2379
- // thread the operator allowlisted — and only then may the cloud
2380
- // seat start, its cost chip landing beside the waiting one. The
2381
- // local wait itself is untouched: it still ends by acquisition,
2382
- // stop, or the ceiling (parking, once that stack lands).
2383
- if (!overflowHandled) {
2384
- const situation = cloudOverflowSituation(owner, resource, overflowHandled);
2385
- if (situation && cloudOverflowAction(situation).kind === "start")
2386
- overflowHandled = await startCloudSeat(owner);
2387
2263
  }
2388
- sweepIdleCloudSeats();
2389
2264
  if (Date.now() >= deadline) {
2390
2265
  // ADR-2 (#1651): the ceiling parks the turn — settled, with a
2391
2266
  // registered resume — instead of failing it. The owner mark lets the
@@ -2407,15 +2282,6 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2407
2282
  }
2408
2283
  turnResourceOwners.set(owner.threadId, owner);
2409
2284
  turnComputerResources.set(owner.threadId, { owner, resource });
2410
- // A screen call that started before this bind (#1653) fenced nothing
2411
- // because no claim existed yet: carry its pending start into the fresh
2412
- // claim so the idle window still defers to the running call.
2413
- const pendingStarts = pendingComputerCallStarts.get(owner.threadId);
2414
- if (pendingStarts?.generation === owner.generation && pendingStarts.count > 0) {
2415
- pendingComputerCallStarts.delete(owner.threadId);
2416
- for (let i = 0; i < pendingStarts.count; i += 1)
2417
- turnResources.beginComputerCall(resource, owner);
2418
- }
2419
2285
  }
2420
2286
  function botForThread(botId, threadId) {
2421
2287
  return directTurnBots.get(threadId) ?? store.projectBotForTask(botId, threadId) ?? store.bot(botId);
@@ -2461,15 +2327,9 @@ function canAdmitDirectTurn(botId, threadId) {
2461
2327
  /** Routine and webhook dispatch shares startTurn's admission preconditions
2462
2328
  * instead of waiting for whole-bot idleness: a free thread slot and no
2463
2329
  * active group turn. A group turn blocks scheduled starts the same way it
2464
- * blocks every other turn kind; it does not consume a capacity slot.
2465
- *
2466
- * A fresh dispatch also pins to the bot's own project folder, exactly like
2467
- * startTurn's cwd resolution: a free thread slot alone can miss a second
2468
- * task of this bot landing in that one folder (#F-collide). Predicting that
2469
- * collision here lets it defer through the same busy-target machinery as
2470
- * capacity and group-turn contention, instead of reaching startTurn's own
2471
- * workspace claim — which throws deep inside a detached dispatch that no
2472
- * caller here awaits, so the run would otherwise fail permanently. */
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). */
2473
2333
  function unattendedDispatchState(botId) {
2474
2334
  const bot = store.bot(botId);
2475
2335
  const decision = admit("unattended", {}, {
@@ -2479,16 +2339,6 @@ function unattendedDispatchState(botId) {
2479
2339
  });
2480
2340
  if (decision.action === "refuse")
2481
2341
  return decision.code === "missing" ? "missing" : "busy";
2482
- if (bot?.cwd) {
2483
- try {
2484
- if (turnResources.blocker(workspaceResource(bot.cwd), { threadId: "", generation: "" }))
2485
- return "busy";
2486
- }
2487
- catch {
2488
- // An unresolvable folder is startTurn's own admission check to
2489
- // report; this prediction only ever adds a defer, never a refusal.
2490
- }
2491
- }
2492
2342
  return "ready";
2493
2343
  }
2494
2344
  function requestedTaskBot(botId, rawThreadId) {
@@ -2839,8 +2689,12 @@ const routineRequestEnvelopeSchema = z.discriminatedUnion("action", [
2839
2689
  ]);
2840
2690
  /** The loopback endpoint a bot's computer proxy polls before acting. With a
2841
2691
  * Boat id, the same turn-scoped token also serves that Boat's tools at
2842
- * /api/internal/computer/mcp, and is revoked when the turn ends. */
2843
- function controlIntegration(botId, threadId, generation, localVmTarget, boxId) {
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;
2844
2698
  return {
2845
2699
  url: `http://127.0.0.1:${PORT}/api/internal/computer-control?botId=${encodeURIComponent(botId)}`,
2846
2700
  token: mintInternalCapability({
@@ -2855,7 +2709,7 @@ function controlIntegration(botId, threadId, generation, localVmTarget, boxId) {
2855
2709
  skillAuthoring: false,
2856
2710
  createdBots: 0,
2857
2711
  openedThreads: 0,
2858
- }),
2712
+ }, { perTurn: machine !== "this computer" }),
2859
2713
  };
2860
2714
  }
2861
2715
  function askBotAndWait(targetBotId, message, depth, fromBotId, fromThreadId, targetThreadId) {
@@ -3153,9 +3007,6 @@ function packageImportDeps(selection) {
3153
3007
  return avatarUrl;
3154
3008
  },
3155
3009
  },
3156
- broadcast: (event) => event.kind === "bot"
3157
- ? broadcast({ kind: "bot", bot: publicBot(event.bot) })
3158
- : broadcast({ kind: "group", group: publicGroupState(event.group) }),
3159
3010
  defaultSelection: () => selection,
3160
3011
  presets: presetStore,
3161
3012
  };
@@ -3294,7 +3145,7 @@ const wireTask = (task) => {
3294
3145
  return asleep ? { ...coordinated, snoozedUntil } : coordinated;
3295
3146
  };
3296
3147
  const wireBot = (bot) => {
3297
- 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;
3298
3149
  // An elevated selection is inert until the desktop confirms its exact
3299
3150
  // private reply. Every ordinary client sees the effective Ask state during
3300
3151
  // that two-phase window, never a grant that may still roll back.
@@ -3316,7 +3167,7 @@ function toolScopeForTurn(botId) {
3316
3167
  /** The correlated private response carries the requested value so Electron
3317
3168
  * can validate it before sending the confirmation that makes it effective. */
3318
3169
  const wireTrustedApprovalBot = (bot) => {
3319
- 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;
3320
3171
  return { ...rest, approvalMode: approvalModeFor(rest), avatarUrl: rest.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
3321
3172
  };
3322
3173
  /** A settings-based preview, not a receipt of a dispatched turn. No
@@ -3389,6 +3240,7 @@ function previewSystemPrompt(bot) {
3389
3240
  { id: "profile", label: "Profile changes", text: agentsMounted ? PROFILE_PROMPT : "" },
3390
3241
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
3391
3242
  { id: "team-memory", label: "Team memory", text: teamMemory.systemPrompt(bot.section) + (agentsMounted ? TEAM_MEMORY_PROMPT : "") },
3243
+ teamAvailabilityPart(agentsMounted && coordination ? peers : []),
3392
3244
  { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: agentsMounted, fileTools: Boolean(privateWorkspace), enabled: bot.memoryEnabled !== false }) },
3393
3245
  { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id, skillsLibraryEnabled(cfg) ? bot.assignedSkills : undefined) : "" },
3394
3246
  ]);
@@ -3966,10 +3818,11 @@ function handleDesktopTrustedApprovalMessage(raw) {
3966
3818
  * fetched immediately and a deleted/guessed attachment id cannot become a
3967
3819
  * dangling profile reference. */
3968
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. */
3969
3823
  const publicBot = (bot) => ({
3970
3824
  ...wireBot(bot),
3971
- messages: store.messagesFor(bot.threadId),
3972
- activeLeafId: store.activeLeaf(bot.threadId),
3825
+ ...messagePage(bot.threadId, DEFAULT_PAGE),
3973
3826
  tasks: store.tasks(bot.id).map(wireTask),
3974
3827
  });
3975
3828
  const publicBotQueuedMessages = () => queuedSteerSnapshot((botId, threadId) => Boolean(store.taskByThread(botId, threadId)));
@@ -4291,7 +4144,9 @@ function outstandingAssignmentsPrompt(threadId) {
4291
4144
  requestId: node.id,
4292
4145
  bot: store.bot(node.botId)?.name ?? "Teammate",
4293
4146
  assignment: node.text.slice(0, 1_000),
4294
- 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",
4295
4150
  }));
4296
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.`;
4297
4152
  }
@@ -4804,18 +4659,33 @@ function activeGroupTurnForBot(botId) {
4804
4659
  }
4805
4660
  return null;
4806
4661
  }
4662
+ /** A room with its open thread's newest page, for HTTP replies and a
4663
+ * member stream's first frame of it. */
4807
4664
  const groupWithThread = (group) => ({
4808
4665
  ...publicGroupState(group),
4809
- messages: store.messagesFor(group.threadId),
4810
- activeLeafId: store.activeLeaf(group.threadId),
4666
+ ...messagePage(group.threadId, DEFAULT_PAGE),
4811
4667
  ...(group.dm ? {} : { tasks: store.groupTasks(group.id) }),
4812
4668
  });
4813
4669
  // The store tells us what it wrote; this is the ONE place that turns those
4814
4670
  // into SSE frames. No mutation path can persist without emitting — the
4815
4671
  // property holds by construction, not by every call site remembering to
4816
- // broadcast. Bot frames are the slim wire shape (no transcript); the few
4817
- // endpoints whose callers need the transcript (task create/switch, imports)
4818
- // still send their richer payload on top.
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
+ }
4819
4689
  store.onChange((change) => {
4820
4690
  // Who owns which thread, and what each member may see, follow the fleet.
4821
4691
  if (change.type !== "message" && change.type !== "message.patch" && change.type !== "thread" && change.type !== "sections")
@@ -4824,11 +4694,12 @@ store.onChange((change) => {
4824
4694
  case "sections":
4825
4695
  broadcast({ kind: "sections", sections: store.sections });
4826
4696
  break;
4697
+ // Live frames never carry screenshot pixels; clients load them by URL.
4827
4698
  case "message":
4828
- broadcast({ kind: "message", threadId: change.threadId, message: change.message });
4699
+ broadcast({ kind: "message", threadId: change.threadId, message: slimMessage(change.message) });
4829
4700
  break;
4830
4701
  case "message.patch":
4831
- broadcast({ kind: "message.patch", threadId: change.threadId, message: change.message });
4702
+ broadcast({ kind: "message.patch", threadId: change.threadId, message: slimMessage(change.message) });
4832
4703
  break;
4833
4704
  case "thread":
4834
4705
  broadcast({ kind: "thread", threadId: change.threadId, activeLeafId: change.activeLeafId });
@@ -4848,7 +4719,7 @@ store.onChange((change) => {
4848
4719
  case "bot": {
4849
4720
  const bot = store.bot(change.botId);
4850
4721
  if (bot)
4851
- broadcast({ kind: "bot", bot: wireBot(bot) });
4722
+ broadcast({ kind: "bot", bot: { ...wireBot(bot), ...pageIfThreadChanged(bot.id, bot.threadId) } });
4852
4723
  // A new audience narrows the rooms this bot is in, for good.
4853
4724
  if (bot)
4854
4725
  for (const group of store.groups.filter((candidate) => candidate.memberIds.includes(bot.id)))
@@ -4856,6 +4727,7 @@ store.onChange((change) => {
4856
4727
  break;
4857
4728
  }
4858
4729
  case "bot.deleted":
4730
+ sentThreads.delete(change.botId);
4859
4731
  try {
4860
4732
  commandAllowlist.clear(change.botId);
4861
4733
  }
@@ -4867,12 +4739,13 @@ store.onChange((change) => {
4867
4739
  case "group": {
4868
4740
  const group = store.group(change.groupId);
4869
4741
  if (group)
4870
- broadcast({ kind: "group", group: publicGroupState(group) });
4742
+ broadcast({ kind: "group", group: { ...publicGroupState(group), ...pageIfThreadChanged(group.id, group.threadId) } });
4871
4743
  if (group)
4872
4744
  settleRoomFloor(group);
4873
4745
  break;
4874
4746
  }
4875
4747
  case "group.deleted":
4748
+ sentThreads.delete(change.groupId);
4876
4749
  broadcast({ kind: "group.deleted", groupId: change.groupId });
4877
4750
  break;
4878
4751
  }
@@ -4897,12 +4770,13 @@ function pageSize(raw) {
4897
4770
  return null;
4898
4771
  return Math.min(size, MESSAGE_PAGE_MAX);
4899
4772
  }
4900
- /** A screen message without its pixels. The client fetches those from
4901
- * `/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. */
4902
4776
  function slimMessage(message) {
4903
4777
  if (message.kind !== "screen" || !message.png)
4904
4778
  return message;
4905
- const { png: _png, mime: _mime, ...rest } = message;
4779
+ const { png: _png, ...rest } = message;
4906
4780
  return { ...rest, hasImage: true };
4907
4781
  }
4908
4782
  /** `limit === undefined` is the original, unpaginated shape. A bounded,
@@ -5309,7 +5183,7 @@ always, rememberCommand) {
5309
5183
  }
5310
5184
  /** A decision or answer on a provider/peer card, made on behalf of `auth`
5311
5185
  * (a Live call answers as the person who started the call). Harness-native
5312
- * proposals (skill, routine, profile, default model, tightening, team setup)
5186
+ * proposals (skill, routine, profile, default model, team setup)
5313
5187
  * are not handled here; they are reviewed on screen (liveDecisionRefusal).
5314
5188
  * Mirrors POST /api/threads/:id/respond. */
5315
5189
  async function respondToCard(input) {
@@ -5468,10 +5342,7 @@ const watchdog = new TurnWatchdog({
5468
5342
  repeats.settle(turn.threadId);
5469
5343
  const bot = botForThread(turn.botId, turn.threadId);
5470
5344
  const routineRun = activeRoutineRunForThread(turn.threadId);
5471
- const instance = bot
5472
- ? runningTurnInstance(bot, turn.threadId)
5473
- : runningTurnEngines.get(turn.threadId) ?? null;
5474
- void instance?.adapter.interruptTurn(turn.threadId).catch(() => { });
5345
+ void runningTurnInstance(bot, turn.threadId)?.adapter.interruptTurn(turn.threadId).catch(() => { });
5475
5346
  const minutes = Math.round(TURN_STALL_MS / 60_000);
5476
5347
  if (routineRun?.target === "bot") {
5477
5348
  routines?.failThread(turn.threadId, `No activity for ${minutes} minutes — the routine was stopped`);
@@ -5481,7 +5352,7 @@ const watchdog = new TurnWatchdog({
5481
5352
  store.appendMessage(turn.threadId, {
5482
5353
  role: "bot",
5483
5354
  kind: "activity",
5484
- 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`),
5485
5356
  });
5486
5357
  // a routine's stall reports through its own failure path
5487
5358
  if (bot && routineRun?.target !== "bot") {
@@ -5761,6 +5632,8 @@ bus.subscribe((event) => {
5761
5632
  });
5762
5633
  // Per-thread state the digest needs from before the turn: when it started,
5763
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.
5764
5637
  const turnStartedAt = new Map();
5765
5638
  const turnCheckpoints = new Map();
5766
5639
  // The turn a thread is in, from the last event that named one. Not every
@@ -5768,9 +5641,18 @@ const turnCheckpoints = new Map();
5768
5641
  // a digest can only count activity it can attribute — so an unstamped tool
5769
5642
  // row is attributed to the thread's live turn instead of to nothing.
5770
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
+ }
5771
5653
  function clearTurnDigestState(threadId) {
5772
5654
  turnStartedAt.delete(threadId);
5773
- turnCheckpoints.delete(threadId);
5655
+ forgetTurnCheckpoint(threadId);
5774
5656
  liveTurnByThread.delete(threadId);
5775
5657
  memoryRowsByThread.delete(threadId);
5776
5658
  }
@@ -5839,9 +5721,8 @@ function ingestEngineHook(capability, body) {
5839
5721
  }
5840
5722
  return { ok: true };
5841
5723
  }
5842
- /** Persist observed tools and memory immediately. Keep a direct turn's
5843
- * workspace claimed while its bounded file snapshot settles, then enrich
5844
- * that same row only if its task and active branch still exist. */
5724
+ /** Persist observed tools and memory immediately, then enrich that same
5725
+ * row only if its task and active branch still exist. */
5845
5726
  async function scheduleTurnDigest(input) {
5846
5727
  const at = Date.now();
5847
5728
  const startedAt = turnStartedAt.get(input.threadId);
@@ -5886,6 +5767,8 @@ async function scheduleTurnDigest(input) {
5886
5767
  }
5887
5768
  if (!checkpoint)
5888
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.
5889
5772
  const controller = new AbortController();
5890
5773
  let timer;
5891
5774
  const capture = async () => {
@@ -5909,6 +5792,12 @@ async function scheduleTurnDigest(input) {
5909
5792
  catch (error) {
5910
5793
  console.error(`digest: could not record turn ${input.turnId} on ${input.threadId}:`, error instanceof Error ? error.message : error);
5911
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
+ }
5912
5801
  }
5913
5802
  // Bots currently working with nobody at the keyboard — a webhook turn, or a
5914
5803
  // turn a webhook-driven bot handed to a teammate. Auto mode is a decision
@@ -6044,9 +5933,6 @@ const vpsJoinRequests = new Map();
6044
5933
  const orphanBoatLifecycleBusyIds = new Set();
6045
5934
  const boatInventoryRequestsBusyIds = new Set();
6046
5935
  const computerProviderConfigTransitions = new Set();
6047
- // A restore mutates and cleans a project work tree. Claim the bot across the
6048
- // entire async Git operation so a turn cannot start in that folder midway.
6049
- const checkpointRestoreLeases = new Set();
6050
5936
  /** The effective idle window, read from config so a Settings change applies
6051
5937
  * to both new and already-armed timers (see the config route). */
6052
5938
  const localVmIdleMs = () => localVmIdleTimeoutMinutes(cfg) * 60_000;
@@ -6111,10 +5997,6 @@ async function computerCallGate(internalCapability) {
6111
5997
  };
6112
5998
  }
6113
5999
  const computer = turnComputerResources.get(internalCapability.threadId);
6114
- // The screen call arriving here re-claims an idle-released seat directly
6115
- // (#1653): inside the reclaim window the previous holder picks its seat
6116
- // back up without re-entering the wait, and the successful claim
6117
- // restarts the quiet window. A seat another turn holds still refuses.
6118
6000
  if (!snapshot.held && computer && computer.owner.generation === internalCapability.generation &&
6119
6001
  !claimTurnResource(computer.owner, computer.resource)) {
6120
6002
  return {
@@ -6194,10 +6076,10 @@ async function attachTeamBoat(computer, botId, owner, canMount, remoteAgent) {
6194
6076
  if (!machine)
6195
6077
  throw new Error("The team's Boat computer is missing; explicitly create or retry it from the Team map");
6196
6078
  await bindTurnComputer(owner, `computer:box:${machine.id}`, true);
6197
- const action = boat.boatTurnLifecycleAction({ explicitCloud: true, canMount: true, state: typeof machine.state === "string" ? machine.state : null });
6079
+ const action = boat.boatTurnLifecycleAction({ explicitCloud: true, state: typeof machine.state === "string" ? machine.state : null });
6198
6080
  if (action === "wake")
6199
6081
  machine = await boat.readyBoat(cfg, ownerId);
6200
- 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") {
6201
6083
  throw new Error("The team computer is not ready; check it in the Team map");
6202
6084
  }
6203
6085
  if (turnResourceOwners.get(owner.threadId)?.generation !== owner.generation ||
@@ -6208,6 +6090,10 @@ async function attachTeamBoat(computer, botId, owner, canMount, remoteAgent) {
6208
6090
  capture: () => boat.screenshotBoat(cfg, ownerId, machine.id),
6209
6091
  };
6210
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
+ }
6211
6097
  /** How a turn's engine reaches a cloud computer. The Computer engine runs its
6212
6098
  * whole turn on the Boat. Every other engine keeps its own model and sign-in
6213
6099
  * and gets the Boat as one more stdio computer server (harness-mcp-proxy
@@ -6217,7 +6103,7 @@ async function attachTeamBoat(computer, botId, owner, canMount, remoteAgent) {
6217
6103
  function cloudComputerMount(botId, owner, boxId, remoteAgent) {
6218
6104
  if (remoteAgent)
6219
6105
  return { computer: { kind: "box", boxId } };
6220
- const control = controlIntegration(botId, owner.threadId, owner.generation, undefined, boxId);
6106
+ const control = controlIntegration(botId, owner.threadId, owner.generation, { boxId });
6221
6107
  return {
6222
6108
  localComputer: {
6223
6109
  command: process.execPath,
@@ -6245,7 +6131,7 @@ async function mountHostComputer(owner, botId, providerSupportsLocal) {
6245
6131
  : "CUA Driver is not ready for this computer — check permissions and restart OpenMausBot");
6246
6132
  }
6247
6133
  await bindTurnComputer(owner, "computer:host");
6248
- return gatedLocalComputer(cua, controlIntegration(botId, owner.threadId, owner.generation));
6134
+ return gatedLocalComputer(cua, controlIntegration(botId, owner.threadId, owner.generation, "this computer"));
6249
6135
  }
6250
6136
  /** The bot's own VPS desktop, mounted into the local agent. The desktop lease
6251
6137
  * is claimed on the first computer call, through the computer-control gate
@@ -6285,7 +6171,9 @@ async function mountBotVps(bot, owner, opts) {
6285
6171
  }
6286
6172
  /** The bot's own Boat. Explicit Cloud is the consent boundary: it may create a
6287
6173
  * missing machine and wake an archived one (~8s, and it un-pauses billing);
6288
- * 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). */
6289
6177
  async function attachBotBoat(bot, owner, opts) {
6290
6178
  // Explicit cloud turns can provision/wake the same bot's Boat. Claim before
6291
6179
  // any network await so setup itself cannot race another turn.
@@ -6304,7 +6192,6 @@ async function attachBotBoat(bot, owner, opts) {
6304
6192
  }
6305
6193
  const lifecycleOf = (explicitCloud) => boat.boatTurnLifecycleAction({
6306
6194
  explicitCloud,
6307
- canMount: opts.canMount,
6308
6195
  state: typeof b?.state === "string" ? b.state : null,
6309
6196
  });
6310
6197
  let lifecycle = lifecycleOf(opts.explicitCloud);
@@ -6328,7 +6215,7 @@ async function attachBotBoat(bot, owner, opts) {
6328
6215
  await bindTurnComputer(owner, `computer:box:${machine.id}`, opts.remoteAgent);
6329
6216
  return {
6330
6217
  capture: () => boat.screenshotBoat(cfg, bot.id, machine.id),
6331
- mount: opts.canMount ? cloudComputerMount(bot.id, owner, machine.id, opts.remoteAgent) : null,
6218
+ mount: cloudComputerMount(bot.id, owner, machine.id, opts.remoteAgent),
6332
6219
  };
6333
6220
  }
6334
6221
  function managedBoatOwners() {
@@ -6447,7 +6334,8 @@ async function computerPreviewSurface(bot, threadId) {
6447
6334
  * A Cloud home lists only the places it has (cloud-home.ts), so a bot never
6448
6335
  * tells the person to set up this computer or a Local VM there. */
6449
6336
  async function selectableComputers(bot) {
6450
- const caps = registry.get(bot.modelSelection.instanceId)?.adapter.capabilities;
6337
+ const instance = registry.get(bot.modelSelection.instanceId);
6338
+ const caps = instance?.adapter.capabilities;
6451
6339
  const off = bot.computer === "off";
6452
6340
  const localEngine = caps?.remoteAgent !== true;
6453
6341
  const surfaces = ["cloud", "vm", "local", "browser"].filter(surface => !CLOUD_HOME || cloudHomeOffersPlace(surface));
@@ -6465,16 +6353,16 @@ async function selectableComputers(bot) {
6465
6353
  reason = "Computer access is Off in this bot's settings.";
6466
6354
  else if (surface === "cloud") {
6467
6355
  if (bot.cloudBackend === "vps") {
6468
- 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;
6469
6357
  ready = status?.ready === true;
6470
6358
  canStart = Boolean(status?.daemonUp && status.managed && status.container === "stopped" &&
6471
6359
  status.image && status.imageMatches && status.network === "private" && status.mounts === "none" && status.security === "hardened");
6472
6360
  canCreate = Boolean(status?.configured && status.daemonUp && status.container === "missing");
6473
6361
  reason = status?.problem ?? reason;
6474
6362
  }
6475
- else if (boat.boatConfigured(cfg) && caps !== undefined && usesCloudComputer(caps)) {
6363
+ else if (boat.boatConfigured(cfg) && canWorkOnCloud(cloudEngine(instance), "box")) {
6476
6364
  const status = await boat.boatStatus(cfg, bot.id);
6477
- 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 });
6478
6366
  ready = lifecycle === "attach";
6479
6367
  canStart = lifecycle === "wake";
6480
6368
  canCreate = lifecycle === "provision";
@@ -6553,7 +6441,7 @@ function continueComputerSelection(threadId, generation, succeeded) {
6553
6441
  const runningTurnEngines = new Map();
6554
6442
  function runningTurnInstance(bot, threadId) {
6555
6443
  // A turn always runs on the bot's own engine, wherever it works.
6556
- return runningTurnEngines.get(threadId) ?? registry.get(bot.modelSelection.instanceId);
6444
+ return runningTurnEngines.get(threadId) ?? (bot ? registry.get(bot.modelSelection.instanceId) : null);
6557
6445
  }
6558
6446
  function providerTransitionForTurn(bot, runOn, threadId) {
6559
6447
  const provider = turnProvider(bot, runOn, threadId);
@@ -6934,38 +6822,6 @@ bus.subscribe((event) => {
6934
6822
  if (touches || /computer|screenshot|click|type_text|press_key|scroll|open_url|wait_for|browser_/i.test(toolName)) {
6935
6823
  pokeScreenPoller(event.threadId, touches, surface);
6936
6824
  }
6937
- // A completed screen-touching computer tool is real screen
6938
- // activity (#1653): it restarts the idle clock on that turn's
6939
- // computer claim. The poller's own frames never arrive here, so
6940
- // they cannot keep a quiet seat held.
6941
- if (touches && surface === "computer") {
6942
- const computer = turnComputerResources.get(event.threadId);
6943
- if (computer) {
6944
- // A quiet window cannot expire while this call executes.
6945
- turnResources.endComputerCall(computer.resource, computer.owner);
6946
- turnResources.activity(computer.resource, computer.owner);
6947
- }
6948
- else {
6949
- // The call never forwarded onto a bound desktop, or the lazy
6950
- // bind never landed: drop its pending start so it cannot
6951
- // fence a claim taken later (#1653).
6952
- const pending = pendingComputerCallStarts.get(event.threadId);
6953
- if (pending) {
6954
- if (pending.count > 1)
6955
- pendingComputerCallStarts.set(event.threadId, { ...pending, count: pending.count - 1 });
6956
- else
6957
- pendingComputerCallStarts.delete(event.threadId);
6958
- }
6959
- }
6960
- // Real cloud-screen work refreshes the #1655 idle stop for
6961
- // that seat; the poller's own frames never arrive here, so
6962
- // preview traffic cannot keep a paid machine awake.
6963
- if (computer?.resource.startsWith("computer:box:")) {
6964
- const seatBot = store.botByThread(event.threadId);
6965
- if (seatBot)
6966
- cloudSeatLeases.get(seatBot.id)?.touch();
6967
- }
6968
- }
6969
6825
  }
6970
6826
  }
6971
6827
  break;
@@ -6988,26 +6844,6 @@ bus.subscribe((event) => {
6988
6844
  });
6989
6845
  if (event.itemId)
6990
6846
  toolMessageByItem.set(`${event.threadId}:${event.itemId}`, message.id);
6991
- // A screen-touching computer tool opening is an in-flight call on
6992
- // that turn's claim (#1653): the seat cannot be idle-released out
6993
- // from under I/O the desktop is still executing. Its completion —
6994
- // or the turn settling — drops the fence.
6995
- if (screenTouchingTool(name) && screenSurfaceForTool(name) === "computer") {
6996
- const computer = turnComputerResources.get(event.threadId);
6997
- if (computer) {
6998
- turnResources.beginComputerCall(computer.resource, computer.owner);
6999
- }
7000
- else {
7001
- // A lazy attach (#1361) may not have seated the claim yet: the
7002
- // gate finishes the bind before the call forwards, so hold the
7003
- // start here and let bindTurnComputer fence the fresh claim.
7004
- const generation = activeInternalGenerationByThread.get(event.threadId);
7005
- if (generation) {
7006
- const pending = pendingComputerCallStarts.get(event.threadId);
7007
- pendingComputerCallStarts.set(event.threadId, pending?.generation === generation ? { generation, count: pending.count + 1 } : { generation, count: 1 });
7008
- }
7009
- }
7010
- }
7011
6847
  }
7012
6848
  break;
7013
6849
  case "request.opened": {
@@ -7019,8 +6855,9 @@ bus.subscribe((event) => {
7019
6855
  // A permission request here is one the provider left for a person: its
7020
6856
  // own mode already ran (Ask, Edits, Auto's reviewer, Custom's config).
7021
6857
  // Only the person's explicit Full access or exact saved command answers.
7022
- // The app does not guess whether the action is safe. A QUESTION
7023
- // always reaches the human — even Full access never invents an answer.
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.
7024
6861
  const asker = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
7025
6862
  const unattended = permission && asker && event.requestId ? isUnattended(asker.id, event.threadId) : false;
7026
6863
  // A turn a guest drives on a Cloud home is Ask, room turns included,
@@ -7082,9 +6919,9 @@ bus.subscribe((event) => {
7082
6919
  if (outcome !== "unavailable")
7083
6920
  pushMessage({
7084
6921
  role: "bot", kind: "activity",
7085
- tool: { name: outcome === "rejected"
7086
- ? `The provider rejected this action despite ${verdict.source === "command-allowlist" ? "the saved command permission" : "Full access"}.`
7087
- : "error: could not deliver approval to the provider; retry the task after reconnecting.", ok: false },
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."),
7088
6925
  });
7089
6926
  return;
7090
6927
  }
@@ -7176,6 +7013,8 @@ bus.subscribe((event) => {
7176
7013
  store.setTaskActivity(bot.id, event.threadId, "waiting-on-you");
7177
7014
  else if (asker.busy)
7178
7015
  store.setActivity(asker.id, "waiting-on-you");
7016
+ if (event.requestId)
7017
+ showWaitingOnPersonChip(event.threadId, event.requestId, asker, permission ? "approval" : "answer");
7179
7018
  const notificationBot = (routineRun && routineSourceOwner(routineRun)?.bot) || asker;
7180
7019
  notify(buildNotification(permission ? "approval" : "question", notificationBot, (routineRun && routineSourceThread(routineRun)) || event.threadId, event.summary));
7181
7020
  }
@@ -7185,6 +7024,8 @@ bus.subscribe((event) => {
7185
7024
  case "request.resolved": {
7186
7025
  if (event.requestId)
7187
7026
  pendingCommandRules.delete(`${event.threadId}:${event.requestId}`);
7027
+ if (event.requestId)
7028
+ settleWaitingOnPersonChips(event.threadId, event.requestId);
7188
7029
  // answered (by whoever): the turn is working again, unless it settled
7189
7030
  const waiting = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
7190
7031
  if (bot && store.taskByThread(bot.id, event.threadId)?.activity === "waiting-on-you") {
@@ -7230,14 +7071,7 @@ bus.subscribe((event) => {
7230
7071
  pushMessage({
7231
7072
  role: "bot",
7232
7073
  kind: "activity",
7233
- tool: {
7234
- // a setup error carries its fix, which must not be cut mid-command
7235
- name: `error: ${event.message.slice(0, event.setup ? 320 : 160)}`,
7236
- ok: false,
7237
- setup: event.setup,
7238
- ...(event.terminal ? { terminal: true } : {}),
7239
- ...(event.claudeUpdate ? { claudeUpdate: true } : {}),
7240
- },
7074
+ tool: failedTurnTool(event.message, event),
7241
7075
  });
7242
7076
  // a setup error means the engine could not even start: the bot is
7243
7077
  // dead until something changes, not merely idle. The next successful
@@ -7255,6 +7089,7 @@ bus.subscribe((event) => {
7255
7089
  }
7256
7090
  break;
7257
7091
  case "turn.completed": {
7092
+ settleWaitingOnPersonChips(event.threadId);
7258
7093
  // A peer-started turn settles as coordination, not as news. What keeps
7259
7094
  // that classification from outliving its turn is the rewrite at
7260
7095
  // dispatch, not this line — releasing it here too is hygiene, so a
@@ -7472,8 +7307,8 @@ bus.subscribe((event) => {
7472
7307
  // still be in flight).
7473
7308
  //
7474
7309
  // Keep the thread busy until its bounded final capture releases
7475
- // the screen AND workspace. Otherwise an accepted follow-up races
7476
- // these claims and fails as though another thread owned its folder.
7310
+ // the screen: an accepted follow-up must not take the desktop
7311
+ // before the END-state frame is captured.
7477
7312
  const settleLeafId = store.activePath(event.threadId).at(-1)?.id;
7478
7313
  let timeout;
7479
7314
  const screenSettled = Promise.race([
@@ -7565,6 +7400,57 @@ bus.subscribe((event) => {
7565
7400
  * channel that only ever shows requests is half a record. Mirror the
7566
7401
  * reply on success; mirror a failed/stopped terminal chip otherwise. */
7567
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
+ }
7568
7454
  // Peer wake: when a delegated reply lands, resume the source bot so it can
7569
7455
  // fold the result in and answer the user instead of sitting idle. Mirrors
7570
7456
  // the cardContinuation resume pattern used for connector/credential cards.
@@ -7608,10 +7494,7 @@ function dispatchDelegationWake(botId, threadId, targetName, failureReason, rout
7608
7494
  store.appendMessage(threadId, {
7609
7495
  role: "bot",
7610
7496
  kind: "activity",
7611
- tool: {
7612
- name: `error: could not resume after delegation — ${message.slice(0, 120)}`,
7613
- ok: false,
7614
- },
7497
+ tool: failedTurnTool(`could not resume after delegation — ${message}`),
7615
7498
  });
7616
7499
  routines?.failThread(threadId, `Could not resume after delegation: ${message}`);
7617
7500
  });
@@ -7713,7 +7596,7 @@ function reportIncident(input) {
7713
7596
  store.appendMessage(incidents.threadId, {
7714
7597
  role: "bot",
7715
7598
  kind: "activity",
7716
- 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}`),
7717
7600
  });
7718
7601
  tellThePerson();
7719
7602
  });
@@ -8112,7 +7995,7 @@ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel,
8112
7995
  store.appendMessage(sourceThreadId, {
8113
7996
  role: "bot",
8114
7997
  kind: "activity",
8115
- 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}`),
8116
7999
  });
8117
8000
  };
8118
8001
  return startTurn(toBotId, text, {
@@ -8224,10 +8107,7 @@ function drainQueuedSends() {
8224
8107
  }).catch((err) => {
8225
8108
  store.appendMessage(threadId, {
8226
8109
  role: "bot", kind: "activity",
8227
- tool: {
8228
- name: `error: queued message could not start — ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`,
8229
- ok: false,
8230
- },
8110
+ tool: failedTurnTool(`queued message could not start — ${err instanceof Error ? err.message : String(err)}`),
8231
8111
  });
8232
8112
  resolve();
8233
8113
  // M2: only this group left the queue, and a turn that never
@@ -8284,10 +8164,7 @@ function drainAsideLane() {
8284
8164
  }).catch((err) => {
8285
8165
  store.appendMessage(threadId, {
8286
8166
  role: "bot", kind: "activity",
8287
- tool: {
8288
- name: `error: queued aside could not start — ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`,
8289
- ok: false,
8290
- },
8167
+ tool: failedTurnTool(`queued aside could not start — ${err instanceof Error ? err.message : String(err)}`),
8291
8168
  });
8292
8169
  resolve();
8293
8170
  }).catch(reject);
@@ -8579,7 +8456,7 @@ async function startOrQueueOpenedThread(botId, threadId, text, unattended) {
8579
8456
  store.appendMessage(threadId, {
8580
8457
  role: "bot",
8581
8458
  kind: "activity",
8582
- tool: { name: `error: this thread could not start — ${why.slice(0, 120)}`, ok: false },
8459
+ tool: failedTurnTool(`this thread could not start — ${why}`),
8583
8460
  });
8584
8461
  return { state: "failed", error: why };
8585
8462
  }
@@ -8782,11 +8659,6 @@ async function startTurn(botId, text, opts) {
8782
8659
  // A workspace at its monthly spend limit starts no turn of any kind: a
8783
8660
  // person's message, a routine, a peer hop or a webhook all stop here.
8784
8661
  assertWithinBudget(cfg, DATA_DIR);
8785
- if (checkpointRestoreLeases.has(botId)) {
8786
- throw Object.assign(new Error("this bot's project files are being restored — wait for the restore to finish"), {
8787
- status: 409,
8788
- });
8789
- }
8790
8662
  if (boatLifecycleBusyBots.has(botId)) {
8791
8663
  throw Object.assign(new Error("this bot's cloud computer is being changed — wait for it to finish"), { status: 409 });
8792
8664
  }
@@ -8803,10 +8675,10 @@ async function startTurn(botId, text, opts) {
8803
8675
  // their results still return here (outstandingAssignmentsPrompt tells this
8804
8676
  // turn which are still out). Stop, in this conversation, is the gesture
8805
8677
  // that ends coordination — see interruptDirectThread.
8806
- // Retire anything a previous turn left behind before minting this turn's
8807
- // integrations. Completion and interrupt paths do the same; this is the
8808
- // final backstop against a retained proxy process.
8809
- revokeInternalCapabilitiesForThread(threadId);
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);
8810
8682
  // a webhook turn, or one inherited from a bot already running unattended
8811
8683
  if (opts?.automationSource === "webhook" || opts?.unattended)
8812
8684
  markUnattended(bot.id, threadId);
@@ -9231,14 +9103,16 @@ async function startTurn(botId, text, opts) {
9231
9103
  const pinnedCwd = privateWorkspace
9232
9104
  ? store.pinTaskCwd(bot.id, threadId, privateWorkspace, { privateOnly: cloudGuestOpened(threadId) })
9233
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.
9234
9111
  const cwd = pinnedCwd ?? undefined;
9235
- if (cwd && !claimTurnResource(resourceOwner, workspaceResource(cwd))) {
9236
- throw Object.assign(new Error("another thread is working in this project folder — wait for it to finish or choose a separate folder"), { status: 409, code: "workspace_busy" });
9237
- }
9238
9112
  // Checkpoint explicit project folders, where a bot can overwrite the
9239
9113
  // user's work. Its private OpenMaus workspace is app-owned and changes
9240
9114
  // on nearly every ordinary chat; snapshotting it would add hidden disk
9241
- // and process overhead without a user project to restore.
9115
+ // and process overhead without a user project to list changes for.
9242
9116
  const checkpointCwd = cwd && cwd !== privateWorkspace ? cwd : undefined;
9243
9117
  // dweb is opt-in: without an explicit daemon URL, do not advertise
9244
9118
  // tools that would fail on every call or spawn an unnecessary proxy.
@@ -9261,8 +9135,6 @@ async function startTurn(botId, text, opts) {
9261
9135
  // landed. A team computer or a cloud routine is not this conversation's
9262
9136
  // choice, so those ignore the pin.
9263
9137
  const dispatchTask = store.taskByThread(bot.id, threadId);
9264
- if (plan.clearPin && dispatchTask)
9265
- store.patchTask(bot.id, threadId, { surface: undefined });
9266
9138
  if (plan.computer !== undefined && plan.computer !== "cloud" && instance.adapter.capabilities.remoteAgent === true) {
9267
9139
  throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
9268
9140
  }
@@ -9277,16 +9149,17 @@ async function startTurn(botId, text, opts) {
9277
9149
  // Where an explicit place came from, so its failure names the one
9278
9150
  // control that changes it (and a failed Auto-recorded pin is cleared).
9279
9151
  // Auto and a team's shared computer are nobody's per-turn choice.
9280
- const placeSource = teamComputer || wants === undefined || wants === "off" ? undefined
9152
+ const namedPlace = teamComputer || wants === undefined || wants === "off" ? undefined : wants;
9153
+ const placeSource = !namedPlace ? undefined
9281
9154
  : opts?.runOn === "cloud" ? "routine"
9282
9155
  : plan.pinned ? (dispatchTask?.surfaceSource === "auto" ? "auto-pin" : "pin")
9283
9156
  : "works-on";
9284
9157
  // An engine that cannot use the cloud computer is refused here, before
9285
9158
  // anything is created or woken — never handed to another engine.
9286
9159
  if (wants === "cloud" && placeSource) {
9287
- const unsupported = cloudPlaceDriverError(instance.adapter.capabilities, cloudBackend, placeSource);
9160
+ const unsupported = cloudPlaceDriverError(cloudEngine(instance), cloudBackend, placeSource);
9288
9161
  if (unsupported)
9289
- throw new PlaceUnavailableError("cloud", placeSource, unsupported);
9162
+ throw new PlaceUnavailableError("cloud", unsupported);
9290
9163
  }
9291
9164
  let previewCapture = null;
9292
9165
  let browserCapture = null;
@@ -9435,7 +9308,7 @@ async function startTurn(botId, text, opts) {
9435
9308
  store.appendMessage(threadId, {
9436
9309
  role: "bot",
9437
9310
  kind: "activity",
9438
- tool: { name: `error: ${message.slice(0, 160)}`, ok: false },
9311
+ tool: failedTurnTool(message),
9439
9312
  });
9440
9313
  if (opts?.automationSource === undefined && !opts?.commsDepth && !opts?.cardContinuation) {
9441
9314
  notify(buildNotification("turn-failed", bot, threadId, redactSecretsInText(message), { avatarUrl: bot.avatarUrl }));
@@ -9557,7 +9430,7 @@ async function startTurn(botId, text, opts) {
9557
9430
  // the person explicitly opted this bot into remote lifecycle actions.
9558
9431
  // On Auto an engine that cannot use a computer simply skips it.
9559
9432
  if ((wants === "cloud" || (wants === undefined && managedPolicy.computerAllowed("vps"))) && cloudBackend === "vps" &&
9560
- !cloudPlaceDriverError(instance.adapter.capabilities, "vps")) {
9433
+ canWorkOnCloud(cloudEngine(instance), "vps")) {
9561
9434
  // The VPS "computer" is the desktop inside this bot's managed
9562
9435
  // container, and only screen work needs that desktop to itself.
9563
9436
  // So the lease is claimed on the first computer call, through the
@@ -9595,26 +9468,27 @@ async function startTurn(botId, text, opts) {
9595
9468
  autoVpsProblem = mounted.problem ?? "the VPS computer could not be reached";
9596
9469
  }
9597
9470
  }
9598
- // Cloud is strict when selected. Every engine that can use it may also
9599
- // reuse an already-ready Boat on Auto.
9600
9471
  if (teamComputer) {
9601
- const attached = await attachTeamBoat(teamComputer, bot.id, resourceOwner, usesCloudComputer(instance.adapter.capabilities), instance.adapter.capabilities.remoteAgent === true);
9472
+ const attached = await attachTeamBoat(teamComputer, bot.id, resourceOwner, canWorkOnCloud(cloudEngine(instance), "box"), instance.adapter.capabilities.remoteAgent === true);
9602
9473
  Object.assign(integrations, attached.mount);
9603
9474
  previewCapture = attached.capture;
9604
9475
  computerKind = "box";
9605
9476
  }
9606
- if (!teamComputer && usesCloudComputer(instance.adapter.capabilities) && (wants === "cloud" || (wants === undefined && !computerPlaceRefusal("box"))) && cloudBackend === "box" && boat.boatConfigured(cfg)) {
9607
- const attached = await attachBotBoat(bot, resourceOwner, {
9608
- explicitCloud: wants === "cloud",
9609
- canMount: usesCloudComputer(instance.adapter.capabilities),
9610
- remoteAgent: instance.adapter.capabilities.remoteAgent === true,
9611
- });
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 });
9612
9488
  if (attached) {
9613
9489
  previewCapture = attached.capture;
9614
- if (attached.mount) {
9615
- Object.assign(integrations, attached.mount);
9616
- computerKind = "box";
9617
- }
9490
+ Object.assign(integrations, attached.mount);
9491
+ computerKind = "box";
9618
9492
  }
9619
9493
  }
9620
9494
  if (wants === "cloud" && cloudBackend === "box" && !boat.boatConfigured(cfg)) {
@@ -9625,10 +9499,11 @@ async function startTurn(botId, text, opts) {
9625
9499
  }
9626
9500
  }
9627
9501
  catch (error) {
9628
- if (!placeSource || wants === undefined || wants === "off" || error instanceof DirectTurnSetupCancelled ||
9629
- error instanceof ComputerWaitParked || error instanceof PlaceUnavailableError)
9502
+ // Auto, Off and a team's shared computer name no place; they fail as
9503
+ // they are.
9504
+ if (!namedPlace || !placeSource)
9630
9505
  throw error;
9631
- throw placeUnavailable(wants, placeSource, error instanceof Error ? error.message : String(error));
9506
+ throw asPlaceFailure(error, namedPlace, placeSource);
9632
9507
  }
9633
9508
  // Auto-only host fallback. Electron owns cua-driver/TCC attribution;
9634
9509
  // the harness only reads its already-running connection descriptor.
@@ -9671,7 +9546,7 @@ async function startTurn(botId, text, opts) {
9671
9546
  pinAutoSurface("local");
9672
9547
  },
9673
9548
  });
9674
- integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
9549
+ integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId, "this computer"));
9675
9550
  computerKind = "local";
9676
9551
  }
9677
9552
  }
@@ -9739,14 +9614,17 @@ async function startTurn(botId, text, opts) {
9739
9614
  // snapshot() absorbs failures, so checkpointing may delay but never fail
9740
9615
  // a turn.
9741
9616
  if (checkpointCwd) {
9742
- 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 });
9743
9620
  if (!directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId)) {
9621
+ if (before)
9622
+ void checkpoints.release(bot.id, checkpointCwd, dispatchClaimId);
9744
9623
  throw new DirectTurnSetupCancelled("turn stopped during checkpoint");
9745
9624
  }
9625
+ forgetTurnCheckpoint(threadId);
9746
9626
  if (before)
9747
- turnCheckpoints.set(threadId, { cwd: checkpointCwd, hash: before });
9748
- else
9749
- turnCheckpoints.delete(threadId);
9627
+ turnCheckpoints.set(threadId, { botId: bot.id, cwd: checkpointCwd, hash: before, pin: dispatchClaimId });
9750
9628
  }
9751
9629
  if (!directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId)) {
9752
9630
  throw new DirectTurnSetupCancelled("turn stopped before dispatch");
@@ -9805,8 +9683,9 @@ async function startTurn(botId, text, opts) {
9805
9683
  // Pin on use (issue #1650): every computer that registers a claim
9806
9684
  // slot — a ready Local VM, the VPS, the host fallback, a VM created
9807
9685
  // for this turn — records its pin inside the claim itself, so a turn
9808
- // that only mounted tools records nothing. A Box attached during
9809
- // dispatch and the built-in browser (no seat to claim) pin here.
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.
9810
9689
  if (autoPinAllowed()) {
9811
9690
  const claimSlot = autoVmClaims.get(threadId);
9812
9691
  const pinsAtClaim = claimSlot?.owner.generation === resourceOwner.generation;
@@ -9861,6 +9740,9 @@ async function startTurn(botId, text, opts) {
9861
9740
  { id: "learn", label: "Skill authoring", text: learnPrompt },
9862
9741
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
9863
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 : []),
9864
9746
  // what the bot said lately in its other conversations, so a task
9865
9747
  // never redoes — or forgets — what another one already did
9866
9748
  { id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(recentWorkSources(bot), bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId, ...recentWorkFilter() })) },
@@ -10132,7 +10014,7 @@ async function startTurn(botId, text, opts) {
10132
10014
  role: "bot",
10133
10015
  kind: "activity",
10134
10016
  turnSucceeded: false,
10135
- tool: { name: `error: ${message.slice(0, 160)}`, ok: false },
10017
+ tool: failedTurnTool(message),
10136
10018
  });
10137
10019
  // Worth a buzz for the same reason a routine failure is, and the rule
10138
10020
  // notify.ts encodes: the bot is not working, and the cause is usually
@@ -10400,9 +10282,10 @@ routines = new RoutineManager({
10400
10282
  // nobody's is confined to a folder of its own, never the bot's project.
10401
10283
  if (task && CLOUD_HOME && routineId)
10402
10284
  threadStarters.set(task.threadId, routineOpener(routineId));
10403
- const bot = store.bot(botId);
10404
- if (task && bot)
10405
- 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).
10406
10289
  return task;
10407
10290
  },
10408
10291
  joinConversation: (run) => {
@@ -10457,11 +10340,8 @@ routines = new RoutineManager({
10457
10340
  discardDelegations(commsBus, threadId);
10458
10341
  cancelDirectTurnDispatch(botId, threadId);
10459
10342
  revokeInternalCapabilitiesForThread(threadId);
10460
- const instance = bot
10461
- ? runningTurnInstance(bot, threadId)
10462
- : runningTurnEngines.get(threadId) ?? null;
10463
10343
  try {
10464
- await instance?.adapter.interruptTurn(threadId);
10344
+ await runningTurnInstance(bot, threadId)?.adapter.interruptTurn(threadId);
10465
10345
  }
10466
10346
  finally {
10467
10347
  closeOpenApprovals(threadId);
@@ -10624,9 +10504,9 @@ async function cloudRoutineReadiness(botId, threadId) {
10624
10504
  if (!instance)
10625
10505
  return { ready: false, reason: "The target bot's model is unavailable. Choose another model in the bot's settings." };
10626
10506
  // The same rule a cloud turn applies, before anything is provisioned.
10627
- const unsupported = cloudPlaceDriverError(instance.adapter.capabilities, "box");
10507
+ const unsupported = cloudPlaceDriverError(cloudEngine(instance), "box", "routine");
10628
10508
  if (unsupported)
10629
- return { ready: false, reason: "This model can't use a computer. Choose another model, or change where this routine runs." };
10509
+ return { ready: false, reason: unsupported };
10630
10510
  try {
10631
10511
  if ((await instance.snapshot()).state !== "available") {
10632
10512
  return { ready: false, reason: "The target bot's model is not ready to use the Boat cloud computer." };
@@ -10998,52 +10878,6 @@ const modelRequests = new ModelRequestService({
10998
10878
  validateModel: validateModelProposal,
10999
10879
  driverCapabilities: driverCapabilitiesFor,
11000
10880
  });
11001
- const tighteningRequests = new TighteningRequestService({
11002
- store,
11003
- autoApply: fullAccessForSource,
11004
- canPersist: proposalPersistence,
11005
- // Same reach as a profile change: a Chief may tighten a section peer;
11006
- // anyone else only itself. Re-checked at confirm.
11007
- validateTarget: (proposerBotId, targetBotId) => {
11008
- const proposer = store.bot(proposerBotId);
11009
- const target = store.bot(targetBotId);
11010
- if (!target)
11011
- return "that bot no longer exists";
11012
- if (!proposer?.chiefOfStaff)
11013
- return "only a section's Chief of Staff can change another bot's permissions";
11014
- if (!canReachPeer(proposer, target))
11015
- return "that bot is not in a team this Chief is allowed to manage";
11016
- return null;
11017
- },
11018
- // The card judges the effective mounts, so the snapshot resolves the same
11019
- // way the engine does: through the config-aware custom-server filter.
11020
- mountedMcpServers: (bot) => Object.keys(engineMcpServers(bot)),
11021
- enabledSkills: (botId) => {
11022
- const record = store.bot(botId);
11023
- return (record ? listBotSkills(record) : listSkills(botId)).filter((skill) => skill.enabled).map((skill) => skill.name);
11024
- },
11025
- disableSkill: (botId, name) => {
11026
- const record = store.bot(botId);
11027
- // Private skills win collisions, including disabled/private copies.
11028
- // Disabling one must not reveal a same-named shared skill instead.
11029
- if (skillsLibraryEnabled(cfg) && !listSkills(botId).some((skill) => skill.name === name)
11030
- && record?.assignedSkills?.includes(name)) {
11031
- try {
11032
- const saved = store.patchBot(botId, { assignedSkills: record.assignedSkills.filter((assigned) => assigned !== name) });
11033
- return saved ? { ok: true } : { ok: false, error: "that bot no longer exists" };
11034
- }
11035
- catch (error) {
11036
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
11037
- }
11038
- }
11039
- const disabled = setSkillEnabled(botId, name, false);
11040
- return typeof disabled === "object" && "error" in disabled ? { ok: false, error: disabled.error } : { ok: true };
11041
- },
11042
- // The owner's own routes stop a busy bot before touching its approval
11043
- // level or MCP mounts; the card asks for the same, except an emergency
11044
- // full/custom → ask downgrade, which applies first and stops after.
11045
- targetBusy: (botId) => Boolean(store.bot(botId)?.busy || activeGroupTurnForBot(botId)),
11046
- });
11047
10881
  const teamSetupTeams = () => [...new Set(["", ...readSections(), ...store.bots.map((bot) => sectionKey(bot.section)), ...store.groups.map((group) => sectionKey(group.section))])];
11048
10882
  const teamSetupRequests = new TeamSetupRequestService({
11049
10883
  store, teams: teamSetupTeams, canAccessTeam, canPersist: proposalPersistence, maxBots: MAX_WORKSPACE_BOTS,
@@ -11355,46 +11189,6 @@ function resolveAndSendModel(res, args) {
11355
11189
  json(res, 200, { ok: true, outcome: "rejected" });
11356
11190
  return true;
11357
11191
  }
11358
- function resolveAndSendTightening(res, args) {
11359
- const card = store.messagesFor(args.threadId).find((message) => message.card?.requestId === args.requestId && message.card.tighteningRequest)?.card;
11360
- if (!card)
11361
- return false;
11362
- const result = tighteningRequests.resolve(args);
11363
- if (!result.claimed)
11364
- return false;
11365
- if (result.state === "applied" || result.state === "denied") {
11366
- appendDecision(DATA_DIR, {
11367
- threadId: args.threadId, requestId: args.requestId, botId: args.botId, botName: args.botName,
11368
- tool: "tighten_permissions", summary: card.subtitle,
11369
- decision: result.state === "applied" ? "user-approved" : "user-denied", source: "user",
11370
- });
11371
- }
11372
- if (result.state === "applied") {
11373
- const target = store.bot(result.targetBotId);
11374
- if (target)
11375
- broadcast({ kind: "bot", bot: wireBot(target) });
11376
- if (result.emergencyStop) {
11377
- // Mirror the owner's own route: the fail-closed Ask is already
11378
- // persisted, then the exact turn still holding the elevated
11379
- // per-turn snapshot is stopped. The response never waits on it.
11380
- void stopBotForEmergencyApprovalDowngrade(result.targetBotId).catch((error) => {
11381
- console.warn(`[tightening] emergency stop failed for bot ${result.targetBotId}: ${error instanceof Error ? error.message : String(error)}. The fail-closed Ask is persisted, but the in-flight turn keeps its elevated access until it ends.`);
11382
- });
11383
- }
11384
- json(res, 200, { ok: true, outcome: "allowed-once", tighteningFields: result.fields });
11385
- return true;
11386
- }
11387
- if (result.state === "invalid") {
11388
- json(res, result.status, { error: result.error });
11389
- return true;
11390
- }
11391
- if (result.state === "already_settled") {
11392
- json(res, 200, { ok: true, outcome: result.behavior === "allow" ? "allowed-once" : "rejected", alreadySettled: true });
11393
- return true;
11394
- }
11395
- json(res, 200, { ok: true, outcome: "rejected" });
11396
- return true;
11397
- }
11398
11192
  // Webhook definitions are independent from calendar schedules, but every
11399
11193
  // delivery joins the same RoutineManager queue. That keeps unattended work
11400
11194
  // ordered behind a busy MAUS and gives webhook runs the same durable receipts.
@@ -11589,7 +11383,7 @@ setupRetry = 0) {
11589
11383
  onDispatchError?.(`${bot.name}'s approval level is still being confirmed — skipped this round`);
11590
11384
  return true;
11591
11385
  }
11592
- revokeInternalCapabilitiesForThread(threadId);
11386
+ revokeEarlierTurnCapabilities(threadId);
11593
11387
  spoken.add(botId);
11594
11388
  // Must be the SAME resolver the readiness re-check uses below, or a Chief's
11595
11389
  // delegated Full elevation makes the two disagree by construction: every
@@ -11617,7 +11411,7 @@ setupRetry = 0) {
11617
11411
  role: "bot",
11618
11412
  kind: "activity",
11619
11413
  from: { botId: bot.id, name: bot.name, color: bot.color },
11620
- tool: { name: `error: ${message}`, ok: false },
11414
+ tool: failedTurnTool(message),
11621
11415
  });
11622
11416
  onDispatchError?.(message);
11623
11417
  return true;
@@ -11749,7 +11543,7 @@ setupRetry = 0) {
11749
11543
  role: "bot",
11750
11544
  kind: "activity",
11751
11545
  from: { botId: bot.id, name: bot.name, color: bot.color },
11752
- tool: { name: `error: ${message}`, ok: false },
11546
+ tool: failedTurnTool(message),
11753
11547
  });
11754
11548
  onDispatchError?.(message);
11755
11549
  return true;
@@ -11906,9 +11700,9 @@ setupRetry = 0) {
11906
11700
  // The same one rule as a bot thread, before anything is provisioned: an
11907
11701
  // engine that cannot use the cloud computer is refused, never swapped.
11908
11702
  if (!roomTeamComputer && roomPlan.computer === "cloud") {
11909
- const unsupported = cloudPlaceDriverError(instance.adapter.capabilities, turnProvider(readyBot) === "vps" ? "vps" : "box");
11703
+ const unsupported = cloudPlaceDriverError(cloudEngine(instance), turnProvider(readyBot) === "vps" ? "vps" : "box");
11910
11704
  if (unsupported)
11911
- throw new PlaceUnavailableError("cloud", "works-on", unsupported);
11705
+ throw new PlaceUnavailableError("cloud", unsupported);
11912
11706
  }
11913
11707
  // One place per room turn as well: a team computer reached on Auto means
11914
11708
  // no separate built-in browser.
@@ -11928,7 +11722,7 @@ setupRetry = 0) {
11928
11722
  return false;
11929
11723
  }
11930
11724
  if (roomTeamComputer) {
11931
- const attached = await attachTeamBoat(roomTeamComputer, readyBot.id, resourceOwner, usesCloudComputer(instance.adapter.capabilities), instance.adapter.capabilities.remoteAgent === true);
11725
+ const attached = await attachTeamBoat(roomTeamComputer, readyBot.id, resourceOwner, canWorkOnCloud(cloudEngine(instance), "box"), instance.adapter.capabilities.remoteAgent === true);
11932
11726
  if (isCancelled?.() || groupSpeakers.get(threadId) !== roomSpeaker ||
11933
11727
  activeInternalGenerationByThread.get(threadId) !== internalGeneration)
11934
11728
  return false;
@@ -11974,11 +11768,10 @@ setupRetry = 0) {
11974
11768
  if (!boat.boatConfigured(cfg))
11975
11769
  throw new Error(BOAT_NOT_CONFIGURED);
11976
11770
  const remoteAgent = instance.adapter.capabilities.remoteAgent === true;
11977
- const attached = await attachBotBoat(readyBot, resourceOwner, { explicitCloud: true,
11978
- canMount: usesCloudComputer(instance.adapter.capabilities), remoteAgent });
11771
+ const attached = await attachBotBoat(readyBot, resourceOwner, { explicitCloud: true, remoteAgent });
11979
11772
  if (!roomSetupIsCurrent())
11980
11773
  return false;
11981
- if (!attached?.mount)
11774
+ if (!attached)
11982
11775
  throw new Error("the cloud computer could not be created or reached");
11983
11776
  Object.assign(integrations, attached.mount);
11984
11777
  roomScreenBotId = readyBot.id;
@@ -12023,17 +11816,15 @@ setupRetry = 0) {
12023
11816
  if (owner?.threadId !== threadId || owner.botId !== readyBot.id) {
12024
11817
  throw new Error("the Local VM lease expired while preparing the turn");
12025
11818
  }
12026
- integrations.localComputer = containerComputerMcp(vm.runtime, controlIntegration(readyBot.id, threadId, internalGeneration, target), target);
11819
+ integrations.localComputer = containerComputerMcp(vm.runtime, controlIntegration(readyBot.id, threadId, internalGeneration, { localVmTarget: target }), target);
12027
11820
  }
12028
11821
  }
12029
11822
  catch (error) {
12030
11823
  const place = roomPlan.computer;
12031
11824
  if (place !== "cloud" && place !== "vm" && place !== "local")
12032
11825
  throw error;
12033
- if (error instanceof DirectTurnSetupCancelled || error instanceof ComputerWaitParked || error instanceof PlaceUnavailableError)
12034
- throw error;
12035
11826
  // A room row is not cut, so the cause keeps all its words.
12036
- throw placeUnavailable(place, "works-on", error instanceof Error ? error.message : String(error), Infinity);
11827
+ throw asPlaceFailure(error, place, "works-on", Infinity);
12037
11828
  }
12038
11829
  const roster = readyGroup.memberIds
12039
11830
  .map((id) => store.bot(id))
@@ -12044,8 +11835,9 @@ setupRetry = 0) {
12044
11835
  // are who it cannot. The 1:1 prompt has carried a peer roster since #774,
12045
11836
  // and a room turn had nothing — the only advice it gave ("mention them
12046
11837
  // like @Name") sends the model after a teammate who will never see it.
12047
- // Same reachability rule as list_bots, minus the room's own members.
12048
- const outsideRoom = integrations.agents
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
12049
11841
  ? reachablePeers(store.bots, bot).filter((peer) => !readyGroup.memberIds.includes(peer.id))
12050
11842
  : [];
12051
11843
  const system = [
@@ -12055,7 +11847,7 @@ setupRetry = 0) {
12055
11847
  `Room members: ${roster}, and ${userName} (the human).`,
12056
11848
  readyGroup.bulletin.trim() && `Room bulletin (shared instructions for everyone):\n${readyGroup.bulletin.trim()}`,
12057
11849
  `Reply as yourself, briefly and conversationally. To bring a teammate in, mention them like @Name — they'll see the conversation and respond.`,
12058
- outsideRoom.length > 0 && orchestration && !orchestration.roomHandoffId && roomPeerRosterSystemPrompt(outsideRoom),
11850
+ outsideRoom.length > 0 && roomPeerRosterSystemPrompt(outsideRoom),
12059
11851
  integrations.agents && (CREDENTIAL_PROMPT + (orchestration && !orchestration.roomHandoffId ? THREADS_PROMPT : "")).trim(),
12060
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.",
12061
11853
  integrations.agents && ROUTINE_PROMPT.trim(),
@@ -12143,6 +11935,7 @@ setupRetry = 0) {
12143
11935
  { id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
12144
11936
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
12145
11937
  { id: "recall", label: "Recall", text: integrations.agents && bot.memoryEnabled !== false ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
11938
+ teamAvailabilityPart(outsideRoom),
12146
11939
  { id: "recent", label: "Recent work", text: recentWorkPrompt(recentLines) },
12147
11940
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
12148
11941
  { id: "team-memory", label: "Team memory", text: teamMemory.systemPrompt(bot.section) + (integrations.agents ? TEAM_MEMORY_PROMPT : "") },
@@ -12323,7 +12116,7 @@ setupRetry = 0) {
12323
12116
  role: "bot",
12324
12117
  kind: "activity",
12325
12118
  from: { botId: bot.id, name: bot.name, color: bot.color },
12326
- tool: { name: `error: ${message.slice(0, 140)}`, ok: false },
12119
+ tool: failedTurnTool(message),
12327
12120
  });
12328
12121
  onDispatchError?.(message);
12329
12122
  watchdog.settle(threadId);
@@ -12516,7 +12309,7 @@ setupRetry = 0) {
12516
12309
  store.appendMessage(threadId, {
12517
12310
  role: "bot", kind: "activity",
12518
12311
  from: { botId: bot.id, name: bot.name, color: bot.color },
12519
- tool: { name: `error: ${message}`, ok: false },
12312
+ tool: failedTurnTool(message),
12520
12313
  });
12521
12314
  onDispatchError?.(message);
12522
12315
  if (orchestration) {
@@ -13125,10 +12918,7 @@ function drainQueuedChannelSends() {
13125
12918
  store.appendMessage(threadId, {
13126
12919
  role: "bot",
13127
12920
  kind: "activity",
13128
- tool: {
13129
- name: `error: queued channel message could not start — ${(error instanceof Error ? error.message : String(error)).slice(0, 120)}`,
13130
- ok: false,
13131
- },
12921
+ tool: failedTurnTool(`queued channel message could not start — ${error instanceof Error ? error.message : String(error)}`),
13132
12922
  });
13133
12923
  }
13134
12924
  // A message with no eligible responder creates no operation. Continue
@@ -13335,9 +13125,9 @@ function proposalPersistence(botId, threadId) {
13335
13125
  return { ok: true };
13336
13126
  // Only cards on the visible branch can be acted on from the composer.
13337
13127
  // Abandoned branches must not permanently consume the proposal quota.
13338
- // Routine, profile, default-model, tightening and team-setup proposals
13128
+ // Routine, profile, default-model and team-setup proposals
13339
13129
  // share one budget per bot per thread, so one thread cannot pile up 8 of each.
13340
- const openRequests = store.activePath(threadId).filter((message) => (message.card?.routineRequest?.botId === botId || message.card?.profileRequest?.botId === botId || message.card?.modelRequest?.botId === botId || message.card?.tighteningRequest?.botId === botId || message.card?.teamSetupRequest?.botId === botId) &&
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) &&
13341
13131
  !message.card.answered &&
13342
13132
  !message.card.dismissed && !message.card.expired).length;
13343
13133
  return openRequests >= 8
@@ -14199,7 +13989,8 @@ function configStatus() {
14199
13989
  // not a saved key
14200
13990
  box: boat.describeBoatAccount(cfg),
14201
13991
  vps: { configured: Boolean(vpsSshAlias(cfg)), sshAlias: vpsSshAlias(cfg) ?? "" },
14202
- 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)) },
14203
13994
  // the chosen voice is a setting, not a secret; the key is reported the
14204
13995
  // same configured-or-not way as every other credential
14205
13996
  tts: tts.describeVoice(cfg),
@@ -14281,6 +14072,7 @@ function configForAccess(status, admin) {
14281
14072
  edition: { edition: status.edition.edition, features: status.edition.features },
14282
14073
  signIn: { admins: [], members: [] },
14283
14074
  vps: { configured: status.vps.configured, sshAlias: "" },
14075
+ opencodeGo: { configured: status.opencodeGo.configured, providerKeys: [] },
14284
14076
  profile: { name: status.profile.name, email: "" },
14285
14077
  browserProfiles: status.browserProfiles.map((profile) => Object.fromEntries(Object.entries(profile).filter(([key]) => key !== "partitionId"))),
14286
14078
  };
@@ -14508,7 +14300,7 @@ async function reloadProviders() {
14508
14300
  if (store.taskByThread(botId, threadId)) {
14509
14301
  store.appendMessage(threadId, {
14510
14302
  role: "bot", kind: "activity",
14511
- tool: { name: "error: turn interrupted — provider settings changed", ok: false },
14303
+ tool: failedTurnTool("turn interrupted — provider settings changed"),
14512
14304
  });
14513
14305
  store.setTaskActivity(botId, threadId, "idle");
14514
14306
  }
@@ -14611,7 +14403,7 @@ const workspaceBackupAccess = {
14611
14403
  !routines?.isTicking && !calendarCalls?.isTicking &&
14612
14404
  !localVmImageBusy && !localVmProvisionBusy && !localVmModeChangeBusy &&
14613
14405
  !localVmLifecycleBusy.size && !boatLifecycleBusyBots.size && !vpsPreviewRequests.size && !orphanBoatLifecycleBusyIds.size &&
14614
- !computerProviderConfigTransitions.size && !checkpointRestoreLeases.size &&
14406
+ !computerProviderConfigTransitions.size &&
14615
14407
  teamComputers.list().every(computer => !teamComputerInUse(computer)) &&
14616
14408
  store.bots.every((bot) => !botHasActiveTurn(bot.id) && !routines?.activeRunForBot(bot.id) && !botComputerControlSnapshot(bot.id).held) &&
14617
14409
  store.groups.every((group) => !groupIsWorking(group)),
@@ -14634,18 +14426,24 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
14634
14426
  restored: workspaceRestore,
14635
14427
  ...workspaceBackupAccess,
14636
14428
  });
14637
- // Move to Cloud (server/cloud-move-http.ts): every server sizes its own
14638
- // workspace for the desktop; only a Cloud home receives one. After its
14639
- // restore commits, the Cloud home restarts so startup installs it.
14640
- let cloudHomeRestartRequested = false;
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;
14641
14438
  const cloudMoveRoutes = createCloudMoveRoutes({
14642
14439
  dataDir: DATA_DIR,
14643
14440
  appVersion: serverVersion(),
14644
- cloudHome: Boolean(CLOUD_HOME),
14441
+ environmentId: ENVIRONMENT_ID,
14442
+ sharedWorkspace: () => workspaceShared({ hosted: HOSTED_WORKSPACE, cloudHome: Boolean(CLOUD_HOME), signIn: signInAllowList() }),
14645
14443
  readBody,
14646
14444
  restored: workspaceRestore,
14647
14445
  ...workspaceBackupAccess,
14648
- restart: () => { cloudHomeRestartRequested = true; gracefulShutdown(); },
14446
+ restart: () => { restartRequested = true; gracefulShutdown(); },
14649
14447
  });
14650
14448
  // Route modules (server/routes/README.md). `workspaceAccess` is assigned at
14651
14449
  // boot, after this line, so the dependency reads it per request.
@@ -15135,9 +14933,7 @@ const handleRequest = async (req, res) => {
15135
14933
  // scheme the native companion scanners accept; it carries the
15136
14934
  // credential encoding because those scanners cannot take a typed code.
15137
14935
  const serverName = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED }).label;
15138
- const invite = base
15139
- ? `openmausbot://pair?address=${encodeURIComponent(base)}&token=${encodeURIComponent(opened.credential)}&name=${encodeURIComponent(serverName)}`
15140
- : null;
14936
+ const invite = base ? phonePairingLink({ address: base, token: opened.credential, name: serverName }) : null;
15141
14937
  return json(res, 200, {
15142
14938
  id: opened.id,
15143
14939
  code,
@@ -15401,6 +15197,9 @@ const handleRequest = async (req, res) => {
15401
15197
  throw Object.assign(new Error("the internal turn capability has expired"), { status: 401 });
15402
15198
  }
15403
15199
  };
15200
+ const delegatedThisTurn = (taskId) => {
15201
+ (internalCapability.delegatedThisTurn ??= new Set()).add(taskId);
15202
+ };
15404
15203
  // Where an entry came from, as the person will read it in MEMORY.md:
15405
15204
  // the room or the thread title, never a bare id unless nothing else
15406
15205
  // names the conversation.
@@ -16057,39 +15856,6 @@ const handleRequest = async (req, res) => {
16057
15856
  });
16058
15857
  return json(res, 201, proposed);
16059
15858
  }
16060
- if (method === "POST" && path === "/api/internal/tightening-requests") {
16061
- const parsed = z.object({
16062
- fromBotId: z.string().min(1).max(128),
16063
- fromThreadId: z.string().min(1).max(128),
16064
- forBotId: z.string().max(128).optional(),
16065
- intents: z.unknown(),
16066
- reason: z.unknown(),
16067
- }).strict().safeParse(await readInternalBody());
16068
- if (!parsed.success)
16069
- return json(res, 400, { error: "invalid tightening proposal" });
16070
- const body = parsed.data;
16071
- const from = store.bot(body.fromBotId);
16072
- if (!from)
16073
- return json(res, 403, { error: "unknown sender" });
16074
- const owner = connectorThread(from.id, body.fromThreadId);
16075
- if (!owner)
16076
- return json(res, 403, { error: "source conversation does not belong to sender" });
16077
- const targetBotId = body.forBotId?.trim() || from.id;
16078
- const proposed = tighteningRequests.submit({
16079
- botId: from.id,
16080
- threadId: body.fromThreadId,
16081
- targetBotId,
16082
- intents: body.intents,
16083
- reason: body.reason,
16084
- from: owner.group ? { botId: from.id, name: from.name, color: from.color } : undefined,
16085
- });
16086
- appendDecision(DATA_DIR, {
16087
- threadId: body.fromThreadId, requestId: proposed.requestId, botId: from.id, botName: from.name,
16088
- tool: "tighten_permissions", summary: proposed.detail, decision: proposed.state === "applied" ? "auto-approved" : "card-shown",
16089
- source: proposed.state === "applied" ? "full-access" : "tightening",
16090
- });
16091
- return json(res, 201, proposed);
16092
- }
16093
15859
  if (method === "GET" && path === "/api/internal/team-setup-catalog") {
16094
15860
  let chief = store.bot(internalCapability.botId);
16095
15861
  if (!chief.chiefOfStaff || chief.hidden)
@@ -16442,6 +16208,7 @@ const handleRequest = async (req, res) => {
16442
16208
  if (internalCapability.generation === EXTERNAL_RUNTIME_GENERATION && !threadBusy(from.id, fromThreadId)) {
16443
16209
  drainThreadDelegations(fromThreadId);
16444
16210
  }
16211
+ delegatedThisTurn(queued.id);
16445
16212
  return json(res, 200, { busy: true, taskId: queued.id, toBotName: target.name, receipt: peerDeliveryReceipt({
16446
16213
  botId: toBotId, botName: target.name, outcome: "queued", taskId: queued.id,
16447
16214
  detail: "the teammate was busy; the ask was queued as a delegation instead",
@@ -16601,6 +16368,7 @@ const handleRequest = async (req, res) => {
16601
16368
  kind: "activity",
16602
16369
  tool: { name: `@${currentTarget.name} is still working — ask converted to a delegation` },
16603
16370
  });
16371
+ delegatedThisTurn(taskId);
16604
16372
  return json(res, 200, { timeout: true, taskId, toBotName: currentTarget.name, waitedMs: ASK_BOT_TIMEOUT_MS,
16605
16373
  receipt: peerDeliveryReceipt({ botId: toBotId, botName: currentTarget.name, outcome: "injected", taskId,
16606
16374
  detail: "the teammate's turn started and is still running; only the synchronous wait ended" }) });
@@ -16639,6 +16407,9 @@ const handleRequest = async (req, res) => {
16639
16407
  const delegationMatch = method === "GET" ? path.match(/^\/api\/internal\/delegations\/([\w-]{4,64})$/) : null;
16640
16408
  if (delegationMatch) {
16641
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
+ }
16642
16413
  const fromThreadId = internalCapability.threadId;
16643
16414
  const from = internalSender;
16644
16415
  if (!connectorThread(from.id, fromThreadId))
@@ -16680,7 +16451,10 @@ const handleRequest = async (req, res) => {
16680
16451
  if (!sender || (target && !canReachPeer(sender, target))) {
16681
16452
  return json(res, 403, { error: "Result withheld: team access changed while the teammate was working" });
16682
16453
  }
16683
- 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) {
16684
16458
  if (running && runningEntry) {
16685
16459
  const recent = summarizeDelegatedActivity(store.messagesFor(runningEntry[0]), running.startedAtMs ?? Date.now());
16686
16460
  return json(res, 200, {
@@ -16688,6 +16462,12 @@ const handleRequest = async (req, res) => {
16688
16462
  toBotName: store.bot(toBotId)?.name ?? toBotId,
16689
16463
  elapsedMs: Math.max(0, Date.now() - (running.startedAtMs ?? Date.now())),
16690
16464
  recentActivity: recent,
16465
+ ...(awaitingPerson ? {
16466
+ awaitingPerson: {
16467
+ ...awaitingPerson,
16468
+ threadTitle: store.taskByThread(toBotId, runningEntry[0])?.title,
16469
+ },
16470
+ } : {}),
16691
16471
  });
16692
16472
  }
16693
16473
  const queuedTarget = store.bot(toBotId);
@@ -16804,6 +16584,7 @@ const handleRequest = async (req, res) => {
16804
16584
  detail: `${refusal} — nothing was queued`,
16805
16585
  }) });
16806
16586
  }
16587
+ delegatedThisTurn(queued.id);
16807
16588
  const targetName = store.bot(toBotId)?.name ?? toBotId;
16808
16589
  // The queue normally drains when the source thread's turn completes. A
16809
16590
  // caller this server did not spawn (an external runtime) has no live
@@ -17032,6 +16813,12 @@ const handleRequest = async (req, res) => {
17032
16813
  if (!groupId || !message) {
17033
16814
  return json(res, 400, { error: "post_to_room needs group_id (from list_rooms) and message" });
17034
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
+ }
17035
16822
  if (message.length > ROOM_POST_MAX_CHARS) {
17036
16823
  return json(res, 400, {
17037
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`,
@@ -17160,6 +16947,7 @@ const handleRequest = async (req, res) => {
17160
16947
  peerPost: unattended ? { unattended: true } : {},
17161
16948
  ...(attachedVoiceNote ? { attachments: stagedAttachments } : {}),
17162
16949
  });
16950
+ internalCapability.roomPosts = (internalCapability.roomPosts ?? 0) + 1;
17163
16951
  detachParkedAudio?.();
17164
16952
  store.patchGroup(room.id, { unread: true });
17165
16953
  // The same visibility contract the peer tools keep: whatever a bot
@@ -17203,7 +16991,7 @@ const handleRequest = async (req, res) => {
17203
16991
  if (!fitsOnOneLine(title))
17204
16992
  return json(res, 400, { error: "title must fit on one line" });
17205
16993
  if (internalCapability.openedThreads >= MAX_THREADS_OPENED_PER_TURN) {
17206
- return json(res, 429, { error: `you can open at most ${MAX_THREADS_OPENED_PER_TURN} threads in one turn` });
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.` });
17207
16995
  }
17208
16996
  const toBotId = typeof body.toBotId === "string" && body.toBotId.trim() ? body.toBotId.trim() : from.id;
17209
16997
  const target = store.bot(toBotId);
@@ -17302,6 +17090,7 @@ const handleRequest = async (req, res) => {
17302
17090
  }
17303
17091
  store.setTaskOpenedBy(target.id, task.threadId, { botId: from.id, name: from.name, delegationId: queued.id, at: task.openedBy?.at ?? Date.now() });
17304
17092
  internalCapability.openedThreads += 1;
17093
+ delegatedThisTurn(queued.id);
17305
17094
  // An honest forecast, not a promise: the handoff starts when this
17306
17095
  // turn ends, and by then the target's slots are taken by whatever is
17307
17096
  // running there plus the threads this turn already opened ahead.
@@ -17331,8 +17120,8 @@ const handleRequest = async (req, res) => {
17331
17120
  if (!chief.chiefOfStaff) {
17332
17121
  return json(res, 403, { error: "only a section's Chief of Staff can create operator bots" });
17333
17122
  }
17334
- if (internalCapability.createdBots >= 4) {
17335
- 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.` });
17336
17125
  }
17337
17126
  if (store.bots.length >= MAX_WORKSPACE_BOTS) {
17338
17127
  return json(res, 409, { error: `this workspace is limited to ${MAX_WORKSPACE_BOTS} bots` });
@@ -17381,8 +17170,9 @@ const handleRequest = async (req, res) => {
17381
17170
  if (store.bot(chief.id) !== chief || chief.hidden || !chief.chiefOfStaff || !connectorThread(chief.id, fromThreadId)) {
17382
17171
  return json(res, 403, { error: "only an active Chief of Staff can create operator bots" });
17383
17172
  }
17384
- if (internalCapability.createdBots >= 4)
17385
- 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
+ }
17386
17176
  if (store.bots.length >= MAX_WORKSPACE_BOTS)
17387
17177
  return json(res, 409, { error: `this workspace is limited to ${MAX_WORKSPACE_BOTS} bots` });
17388
17178
  const duplicate = store.bots.find((candidate) => !candidate.hidden &&
@@ -17501,6 +17291,36 @@ const handleRequest = async (req, res) => {
17501
17291
  const updated = updateChannel(room.id, patch);
17502
17292
  return json(res, 200, { ok: true, memberIds: updated.memberIds, memberCount: updated.memberIds.length, message: `Updated room “${updated.name}”.` });
17503
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
+ }
17504
17324
  if (method === "POST" && path === "/api/internal/request-credential") {
17505
17325
  const body = await readInternalBody();
17506
17326
  const from = internalSender;
@@ -18353,12 +18173,10 @@ const handleRequest = async (req, res) => {
18353
18173
  const limit = pageSize(url.searchParams.get("messages"));
18354
18174
  if (limit === null)
18355
18175
  return json(res, 400, { error: "messages must be a non-negative whole number" });
18356
- // wireBot(), not publicBot(): publicBot() pulls the whole transcript via
18357
- // messagesFor() just to have it overwritten below by messagePage(),
18358
- // which — for a bounded request — never needs the full transcript.
18359
- // tasks stays explicit, because that is the one field publicBot() adds
18360
- // that messagePage() does not: wireBot() omits the key entirely for a
18361
- // bot record carrying no tasks, where publicBot() always sent [].
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 [].
18362
18180
  // A member on a workspace with a restricted bot gets only what they may
18363
18181
  // see (bot-visibility.ts); for everyone else these filters keep all.
18364
18182
  const shownBots = store.bots.filter((bot) => visible.bot(bot.id));
@@ -18382,47 +18200,6 @@ const handleRequest = async (req, res) => {
18382
18200
  });
18383
18201
  }
18384
18202
  // scrollback: the page before a message the client already holds
18385
- // #1655: the consent card's answer, per conversation. Explicit and
18386
- // revocable; a thread allowlisted in config carries the same standing
18387
- // consent without ever seeing a card.
18388
- m = path.match(/^\/api\/threads\/([\w-]+)\/cloud-overflow$/);
18389
- if (m && method === "POST") {
18390
- const threadId = m[1];
18391
- if (!store.botByThread(threadId))
18392
- return json(res, 404, { error: "no such conversation" });
18393
- const body = await readBody(req);
18394
- if (!body || typeof body !== "object" || Array.isArray(body))
18395
- return json(res, 400, { error: "body must be a JSON object" });
18396
- if (typeof body.consent !== "boolean")
18397
- return json(res, 400, { error: "consent must be a boolean" });
18398
- if (body.consent) {
18399
- // Consent answers the priced card, never a guess: without a prior
18400
- // offer the rate was never shown, so a seat must not start yet.
18401
- // The grant binds to the rate the card showed (#1655): a config
18402
- // change re-offers instead of starting at a new price silently.
18403
- const offeredRate = cloudOverflowConsent.offeredRate(threadId);
18404
- if (offeredRate === null)
18405
- return json(res, 409, { error: "no cloud overflow offer" });
18406
- cloudOverflowConsent.grant(threadId, offeredRate);
18407
- }
18408
- else {
18409
- cloudOverflowConsent.revoke(threadId);
18410
- // Revocation must reach the machine (#1655): the ledger alone
18411
- // would leave this conversation's seat billing to its idle
18412
- // timeout. A start still waking is caught by its own revoke
18413
- // check once readyBox resolves.
18414
- const seatBot = store.botByThread(threadId);
18415
- const lease = seatBot ? cloudSeatLeases.get(seatBot.id) : undefined;
18416
- if (seatBot && lease && lease.threadId === threadId) {
18417
- stopCloudSeat(seatBot.id, lease, "Cloud computer stopped because overflow consent was revoked — billing pauses while it sleeps.");
18418
- }
18419
- }
18420
- store.appendMessage(threadId, {
18421
- role: "bot", kind: "activity",
18422
- tool: { name: cloudOverflowConsentText(body.consent, cloudOverflowIdleStopMs(cfg)), ok: true },
18423
- });
18424
- return json(res, 200, { consented: body.consent });
18425
- }
18426
18203
  m = path.match(/^\/api\/threads\/([\w-]+)\/messages$/);
18427
18204
  if (m && method === "GET") {
18428
18205
  const threadId = m[1];
@@ -19172,8 +18949,11 @@ const handleRequest = async (req, res) => {
19172
18949
  return json(res, 400, { error: "Import backups alongside your existing bots; project mode is only for templates" });
19173
18950
  try {
19174
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.
19175
18955
  const bots = imported.bots.map((bot) => publicBot(bot));
19176
- const groups = imported.groups.map((group) => ({ ...publicGroupState(group), ...messagePage(group.threadId, undefined) }));
18956
+ const groups = imported.groups.map((group) => groupWithThread(group));
19177
18957
  for (const bot of bots)
19178
18958
  broadcast({ kind: "bot", bot });
19179
18959
  for (const group of groups)
@@ -19323,9 +19103,7 @@ const handleRequest = async (req, res) => {
19323
19103
  // Who opened it decides who may answer its cards on a shared workspace.
19324
19104
  if (auth.kind === "session")
19325
19105
  threadStarters.set(task.threadId, actorKey(auth));
19326
- const fresh = groupWithThread(store.group(group.id));
19327
- broadcast({ kind: "group", group: fresh });
19328
- return json(res, 201, { group: fresh, task });
19106
+ return json(res, 201, { group: groupWithThread(store.group(group.id)), task });
19329
19107
  }
19330
19108
  m = path.match(/^\/api\/groups\/([\w-]+)\/tasks\/([\w-]+)$/);
19331
19109
  if (m && method === "POST") {
@@ -19350,15 +19128,6 @@ const handleRequest = async (req, res) => {
19350
19128
  if (!switched)
19351
19129
  return json(res, 404, { error: "no such channel task" });
19352
19130
  const switchedSettings = { ...publicGroupState(switched), tasks: store.groupTasks(switched.id) };
19353
- // The frame says the channel moved; it is not a transcript delivery.
19354
- // A long room's whole history over SSE is the payload `?messages=`
19355
- // exists to avoid, and it would also overwrite a bounded snapshot on
19356
- // every other client. One default page is enough to render the switch;
19357
- // anything earlier pages back through /api/threads/:id/messages.
19358
- broadcast({
19359
- kind: "group",
19360
- group: { ...switchedSettings, ...messagePage(switched.threadId, DEFAULT_PAGE) },
19361
- });
19362
19131
  // "0" predates paging and means settings only — no `messages` key at
19363
19132
  // all, which clients tell apart from an empty page. A positive page is
19364
19133
  // the transcript a client can actually hold; omitting the parameter
@@ -19366,9 +19135,7 @@ const handleRequest = async (req, res) => {
19366
19135
  // materialises it.
19367
19136
  const responseGroup = requestedMessages === "0"
19368
19137
  ? switchedSettings
19369
- : switchLimit === undefined
19370
- ? groupWithThread(switched)
19371
- : { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
19138
+ : { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
19372
19139
  return json(res, 200, { group: responseGroup });
19373
19140
  }
19374
19141
  if (m && method === "PATCH") {
@@ -19441,14 +19208,12 @@ const handleRequest = async (req, res) => {
19441
19208
  lastReply.delete(m[2]);
19442
19209
  cancelTeamSetupResumesForThread(m[2]);
19443
19210
  const updated = store.deleteGroupTask(group.id, m[2]);
19444
- if (updated)
19445
- clearTurnDigestState(m[2]);
19446
19211
  if (!updated)
19447
19212
  return json(res, 400, { error: "a channel keeps at least one task" });
19213
+ clearTurnDigestState(m[2]);
19214
+ revokeInternalCapabilitiesForThread(m[2]);
19448
19215
  rejectDeletedThreadSkillStages(stagedSkillCleanups);
19449
- const fresh = groupWithThread(updated);
19450
- broadcast({ kind: "group", group: fresh });
19451
- return json(res, 200, { group: fresh });
19216
+ return json(res, 200, { group: groupWithThread(updated) });
19452
19217
  }
19453
19218
  m = path.match(/^\/api\/groups\/([\w-]+)$/);
19454
19219
  if (m && method === "PATCH") {
@@ -21412,54 +21177,6 @@ const handleRequest = async (req, res) => {
21412
21177
  broadcast({ kind: "bot", bot: visible });
21413
21178
  return json(res, 200, { bot: visible });
21414
21179
  }
21415
- // ── workspace checkpoints: per-turn shadow-git snapshots ────────────
21416
- // The list endpoint is the source of truth (turns store nothing), and
21417
- // `enabled` tells the UI whether snapshots can happen here at all —
21418
- // false for refused folders (home, Desktop…), a missing git, or a bot
21419
- // whose checkpoints failed earlier this session.
21420
- m = path.match(/^\/api\/bots\/([\w-]+)\/checkpoints$/);
21421
- if (m && method === "GET") {
21422
- if (!store.bot(m[1]))
21423
- return json(res, 404, { error: "no such bot" });
21424
- const cwd = url.searchParams.get("cwd") ?? "";
21425
- if (!cwd.trim())
21426
- return json(res, 400, { error: "cwd query parameter required" });
21427
- return json(res, 200, {
21428
- checkpoints: await checkpoints.listCheckpoints(m[1], cwd),
21429
- enabled: await checkpoints.checkpointsEnabled(m[1], cwd),
21430
- });
21431
- }
21432
- m = path.match(/^\/api\/bots\/([\w-]+)\/checkpoints\/restore$/);
21433
- if (m && method === "POST") {
21434
- const bot = store.bot(m[1]);
21435
- if (!bot)
21436
- return json(res, 404, { error: "no such bot" });
21437
- const parsed = z
21438
- .object({ cwd: z.string().min(1), hash: z.string().regex(/^[0-9a-f]{40}$/) })
21439
- .safeParse(await readBody(req));
21440
- if (!parsed.success) {
21441
- return json(res, 400, { error: "cwd (absolute path) and hash (full 40-character checkpoint hash) required" });
21442
- }
21443
- // Claim synchronously with the busy check. startTurn checks the same
21444
- // lease before reserving the bot, so no turn can enter during the
21445
- // awaited Git operation.
21446
- if (bot.busy)
21447
- return json(res, 409, { error: "the bot is working — stop the turn before restoring files" });
21448
- if (checkpointRestoreLeases.has(bot.id)) {
21449
- return json(res, 409, { error: "this bot's project files are already being restored" });
21450
- }
21451
- checkpointRestoreLeases.add(bot.id);
21452
- let result;
21453
- try {
21454
- result = await checkpoints.restore(bot.id, parsed.data.cwd, parsed.data.hash);
21455
- }
21456
- finally {
21457
- checkpointRestoreLeases.delete(bot.id);
21458
- }
21459
- if (!result.ok)
21460
- return json(res, 400, { error: result.error });
21461
- return json(res, 200, { ok: true });
21462
- }
21463
21180
  // onboarding/ask cards persist their answered/dismissed state
21464
21181
  m = path.match(/^\/api\/bots\/([\w-]+)\/cards\/([\w-]+)$/);
21465
21182
  if (m && method === "PATCH") {
@@ -21863,14 +21580,6 @@ const handleRequest = async (req, res) => {
21863
21580
  behavior,
21864
21581
  }))
21865
21582
  return;
21866
- if (resolveAndSendTightening(res, {
21867
- botId: bot.id,
21868
- botName: bot.name,
21869
- threadId: bot.threadId,
21870
- requestId: String(body.requestId),
21871
- behavior,
21872
- }))
21873
- return;
21874
21583
  if (sendSkillResolution(res, resolveSkillRequest({
21875
21584
  botId: bot.id,
21876
21585
  botName: bot.name,
@@ -22011,21 +21720,6 @@ const handleRequest = async (req, res) => {
22011
21720
  }))
22012
21721
  return;
22013
21722
  }
22014
- const tighteningCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.tighteningRequest);
22015
- if (tighteningCard?.card?.tighteningRequest) {
22016
- const tighteningBotId = tighteningCard.from?.botId ?? store.botByThread(threadId)?.id;
22017
- if (!tighteningBotId)
22018
- return json(res, 400, { error: "this tightening request has no valid owner" });
22019
- const tighteningOwner = store.bot(tighteningBotId);
22020
- if (resolveAndSendTightening(res, {
22021
- botId: tighteningBotId,
22022
- botName: tighteningOwner?.name,
22023
- threadId,
22024
- requestId,
22025
- behavior,
22026
- }))
22027
- return;
22028
- }
22029
21723
  // peer-approval intercept (see /api/bots/:id/respond above). A peer card
22030
21724
  // belongs to the bus rather than to a speaker, so resolve it before we go
22031
21725
  // looking for one — a room between turns has no speaker to find.
@@ -22148,15 +21842,8 @@ const handleRequest = async (req, res) => {
22148
21842
  return json(res, 200, { ok: true });
22149
21843
  }
22150
21844
  // ── tasks: a bot's separate contexts ────────────────────────────────
22151
- // The bot record answers with its messages because switching tasks
22152
- // changes which transcript is live, and a partial patch would leave
22153
- // the client showing the previous task's conversation.
22154
- const botWithThread = (bot) => ({
22155
- ...wireBot(bot),
22156
- messages: store.messagesFor(bot.threadId),
22157
- activeLeafId: store.activeLeaf(bot.threadId),
22158
- tasks: store.tasks(bot.id).map(wireTask),
22159
- });
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.
22160
21847
  // Folders organize one bot's threads; they never own settings,
22161
21848
  // transcripts or working directories. The project wire names stay stable.
22162
21849
  m = path.match(/^\/api\/bots\/([\w-]+)\/projects\/order$/);
@@ -22173,7 +21860,7 @@ const handleRequest = async (req, res) => {
22173
21860
  const projects = store.reorderProjects(bot.id, body.projectIds);
22174
21861
  if (!projects)
22175
21862
  return json(res, 400, { error: "projectIds must include each of this bot's folders exactly once" });
22176
- return json(res, 200, { projects, bot: botWithThread(bot) });
21863
+ return json(res, 200, { projects, bot: publicBot(bot) });
22177
21864
  }
22178
21865
  m = path.match(/^\/api\/bots\/([\w-]+)\/projects(?:\/([\w-]+))?$/);
22179
21866
  if (m && ((method === "POST" && !m[2]) || (method === "PATCH" && m[2]) || (method === "DELETE" && m[2]))) {
@@ -22184,7 +21871,7 @@ const handleRequest = async (req, res) => {
22184
21871
  return json(res, 404, { error: "no such folder" });
22185
21872
  if (method === "DELETE") {
22186
21873
  const updated = store.deleteProject(bot.id, m[2]);
22187
- return json(res, 200, { bot: botWithThread(updated) });
21874
+ return json(res, 200, { bot: publicBot(updated) });
22188
21875
  }
22189
21876
  const body = await readBody(req);
22190
21877
  if (!body || typeof body !== "object" || Array.isArray(body))
@@ -22207,7 +21894,7 @@ const handleRequest = async (req, res) => {
22207
21894
  const project = method === "POST"
22208
21895
  ? store.createProject(bot.id, patch.name, patch.emoji)
22209
21896
  : store.patchProject(bot.id, m[2], patch);
22210
- return json(res, method === "POST" ? 201 : 200, { project, bot: botWithThread(bot) });
21897
+ return json(res, method === "POST" ? 201 : 200, { project, bot: publicBot(bot) });
22211
21898
  }
22212
21899
  m = path.match(/^\/api\/bots\/([\w-]+)\/tasks$/);
22213
21900
  if (m && method === "POST") {
@@ -22242,9 +21929,7 @@ const handleRequest = async (req, res) => {
22242
21929
  // Who opened it decides who may answer its cards on a shared workspace.
22243
21930
  if (auth.kind === "session")
22244
21931
  threadStarters.set(task.threadId, actorKey(auth));
22245
- const fresh = botWithThread(store.bot(bot.id));
22246
- broadcast({ kind: "bot", bot: fresh });
22247
- return json(res, 201, { bot: fresh, task: wireTask(task) });
21932
+ return json(res, 201, { bot: publicBot(store.bot(bot.id)), task: wireTask(task) });
22248
21933
  }
22249
21934
  m = path.match(/^\/api\/bots\/([\w-]+)\/tasks\/([\w-]+)$/);
22250
21935
  if (m && method === "POST") {
@@ -22263,18 +21948,13 @@ const handleRequest = async (req, res) => {
22263
21948
  if (!switched)
22264
21949
  return json(res, 404, { error: "no such task" });
22265
21950
  const switchedSettings = { ...wireBot(switched), tasks: store.tasks(switched.id).map(wireTask) };
22266
- // Bounded for the same reason as the channel switch above.
22267
- broadcast({
22268
- kind: "bot",
22269
- bot: { ...switchedSettings, ...messagePage(switched.threadId, DEFAULT_PAGE) },
22270
- });
22271
21951
  // "0" is settings only, a positive page is a bounded transcript, and no
22272
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.
22273
21955
  const responseBot = requestedMessages === "0"
22274
21956
  ? switchedSettings
22275
- : switchLimit === undefined
22276
- ? botWithThread(switched)
22277
- : { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
21957
+ : { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
22278
21958
  return json(res, 200, { bot: responseBot });
22279
21959
  }
22280
21960
  if (m && method === "PATCH") {
@@ -22349,9 +22029,7 @@ const handleRequest = async (req, res) => {
22349
22029
  const task = store.refreshTaskPermissions(profile.id, m[2]);
22350
22030
  if (!task)
22351
22031
  return json(res, 404, { error: "no such task" });
22352
- const fresh = botWithThread(store.bot(profile.id));
22353
- broadcast({ kind: "bot", bot: fresh });
22354
- return json(res, 200, { task: wireTask(task), bot: fresh });
22032
+ return json(res, 200, { task: wireTask(task), bot: publicBot(store.bot(profile.id)) });
22355
22033
  }
22356
22034
  const patch = {};
22357
22035
  if (body.projectId !== undefined) {
@@ -22457,9 +22135,7 @@ const handleRequest = async (req, res) => {
22457
22135
  const task = patch.modelSelection
22458
22136
  ? store.switchTaskModel(m[1], m[2], patch.modelSelection, body.updateBotDefault === true, body.resetApprovalToAsk === true, { ...patch, ...hostedModels?.resetTask(current.modelSelection, patch.modelSelection) })
22459
22137
  : store.patchTask(m[1], m[2], patch);
22460
- const fresh = botWithThread(store.bot(m[1]));
22461
- broadcast({ kind: "bot", bot: fresh });
22462
- return json(res, 200, { task: wireTask(task), bot: fresh });
22138
+ return json(res, 200, { task: wireTask(task), bot: publicBot(store.bot(m[1])) });
22463
22139
  }
22464
22140
  if (m && method === "DELETE") {
22465
22141
  const bot = store.bot(m[1]);
@@ -22482,12 +22158,11 @@ const handleRequest = async (req, res) => {
22482
22158
  if (!updated)
22483
22159
  return json(res, 404, { error: "no such task" });
22484
22160
  clearTurnDigestState(m[2]);
22161
+ revokeInternalCapabilitiesForThread(m[2]);
22485
22162
  handoffs.forget(m[2]);
22486
22163
  settleDirectFollowup(directTurnGenerationByThread.get(m[2]));
22487
22164
  rejectDeletedThreadSkillStages(stagedSkillCleanups);
22488
- const fresh = botWithThread(updated);
22489
- broadcast({ kind: "bot", bot: fresh });
22490
- return json(res, 200, { bot: fresh });
22165
+ return json(res, 200, { bot: publicBot(updated) });
22491
22166
  }
22492
22167
  // Regenerate title (#1858): name the thread again from its conversation
22493
22168
  // as it is now. The person asked, so the result replaces any title, but
@@ -24112,6 +23787,15 @@ const handleRequest = async (req, res) => {
24112
23787
  if (conflict)
24113
23788
  return json(res, 409, { error: conflict });
24114
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
+ }
24115
23799
  const browserCleanupRequests = [];
24116
23800
  if (profileControlConflict())
24117
23801
  return json(res, 409, { error: "Release browser control before deleting its profile." });
@@ -25050,6 +24734,8 @@ const gracefulShutdown = createGracefulShutdown({
25050
24734
  // the shutdown deadline), immediately before the process exits, so no new
25051
24735
  // server can overlap with a still-mutating old one.
25052
24736
  exit: (code) => {
24737
+ // Streamed text the bus is still merging reaches the log and clients.
24738
+ bus.flush();
25053
24739
  try {
25054
24740
  sessions.close();
25055
24741
  }
@@ -25062,8 +24748,8 @@ const gracefulShutdown = createGracefulShutdown({
25062
24748
  closeMessageDb();
25063
24749
  mcpOAuth.dispose();
25064
24750
  releaseDataDirLeaseAtExit();
25065
- // A Cloud home's launcher starts the server again on this code only.
25066
- process.exit(cloudHomeRestartRequested && code === 0 ? CLOUD_HOME_RESTART_EXIT_CODE : code);
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);
25067
24753
  },
25068
24754
  });
25069
24755
  for (const signal of ["SIGINT", "SIGTERM"]) {