openmausbot 0.1.93 → 0.1.95

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 (192) hide show
  1. package/dist/assets/{DesktopViewer-CKNR3SEW.js → DesktopViewer-BwWpc4RK.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-Ckrm1I1X.js → abnfDiagram-O67JEVCF-jsQkIqTn.js} +1 -1
  3. package/dist/assets/{arc-BBdBuCgB.js → arc-7kei4Hsu.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CDdA3Hjs.js → architectureDiagram-NJMV4G6O-CRWDFDMq.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-AUrsx1YR.js → blockDiagram-BEXU5L5S-CTKW_IKs.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-BEqQ0Unh.js → c4Diagram-YGBWAQC7-BBTW7-6M.js} +1 -1
  7. package/dist/assets/channel-DS2NbT5x.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-CEEuvd4C.js → chunk-2Q5K7J3B-M_Ox-MkD.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-DyANCiHl.js → chunk-3FUC2YCW-D2YiN-Ud.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-Dx_Slpw9.js → chunk-5VM5RSS4-BmVYJ0f8.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-Dl7h7iiD.js → chunk-GTNCS2PH-BtiLVVnZ.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-BLx07SPb.js → chunk-GWA4HPMP-DFYSb6J6.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-BMxDj3n7.js → chunk-JWPE2WC7-mknKBzgV.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-CN66PT4H.js → chunk-NETBCI7D-DgSYGiOu.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-6HwhsDKw.js → chunk-WEXAMYUT-C7ZvuguH.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-gWnLt7SN.js → chunk-XXDRQBXY-CPqxINpA.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-CMS5l2ea.js → classDiagram-v2-NBCMYWYE-Cd6xfJaC.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-CV7sQwIG.js → cose-bilkent-JH36ORCC-BSXY5qRh.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-CSyl-7vl.js → cynefin-OW5HDTMX-D_lhEMj1.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-BCLKL6az.js → cynefinDiagram-VND7K2PF-Da9FrFwk.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-DiaZs6S1.js → dagre-6A5THRUB-T77jMOXe.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-BeTtmt3I.js → diagram-22UHCM2B-Dkj96X5E.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-CsdBG-QQ.js → diagram-3UASUU5V-CMi9zlD_.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-BczvdbR1.js → diagram-ATOU4E4O-CXK6YUfS.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-DHTTTMhi.js → diagram-CDSNMT55-BE7B1u9Q.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-RTjHyd8z.js → diagram-MLGK6HIB-CCMQphKr.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-BDenHEH_.js → diagram-MPIPVDR6-iHJ_-tGH.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-BSiRSbtL.js → ebnfDiagram-ZINNZB2B-DONsCWQ7.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-BCAElss-.js → elk-276RUBZZ-CU_WOaXk.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-C4_1ZPJe.js → erDiagram-OPXOYQCR-BeYwM04G.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-Bcb4Z75S.js → flowDiagram-KWPJA3E3-Bdw9DqEl.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-BiPOzxzb.js → ganttDiagram-FUAMR5RP-C9iwq3qm.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-B1wtF3ZS.js → gitGraphDiagram-X574FWY7-Dx-VWzUg.js} +1 -1
  34. package/dist/assets/{index-BkYwilkG.js → index-KygHRE3E.js} +1 -1
  35. package/dist/assets/index-NF7XCHAy.css +1 -0
  36. package/dist/assets/index-X_5ydXIE.js +610 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-A19No71U.js → infoDiagram-VRGFBTTK-BG7uaEd4.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BQsqJ_cV.js → ishikawaDiagram-OU5B5YK6-D72WAXnM.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DAO0pVan.js → journeyDiagram-ZHPQQLJL-DKH1gjYT.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-NepmKEe2.js → kanban-definition-PNTS6WVX-3upm9JUT.js} +1 -1
  41. package/dist/assets/{linear-bq1PTDTW.js → linear-CcR4pc0-.js} +1 -1
  42. package/dist/assets/{mermaid.core-s4LTbQle.js → mermaid.core-C6yU5FSS.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-LtdbgHFH.js → mindmap-definition-NLK3R4M7-DOI2xEuv.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-tdbaCxiK.js → pegDiagram-GJSIUBJH-BVWJSgLd.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-DvkbpW8l.js → pieDiagram-5QR66LMP-jtbYezhq.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-Do4KaH-E.js → quadrantDiagram-O4NWA36T-g0u8mtPX.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-BQWULOAs.js → railroadDiagram-XR7U4H2S-k8689Wny.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-w9kei1FU.js → requirementDiagram-PLB6GJNP-DsJ22PDt.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-D2gRy0e8.js → sankeyDiagram-IPEJSGJF-CneWBRgG.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-a2sO5_Eo.js → sequenceDiagram-PO4LG4MO-DWsFCWap.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-CiPJmFEB.js → sizeCapture-INFHLROL-Bvf0YE3S.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-DAkUUlL7.js → stateDiagram-v2-GCMORJYK-Bxb3Iei8.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DANf2R8Y.js → swimlanes-2SLR337P-DLkk7W4A.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-C6bZtBXk.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BKpQmzrT.js → timeline-definition-EJHVYXUP-KR9bIHYC.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-BtI7qLN-.js → usecaseDiagram-POWQR4AR-DfsOrj8t.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-gXJXrAG_.js → vennDiagram-UO4OBE2U-DRSTzG3h.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-GU8Po5-n.js → wardleyDiagram-VNRHLVJA-DtEdhPgT.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-BF4J30g6.js → xychartDiagram-PMCCYNJV-B-XhTxjG.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/cloud-home-start.js +3 -1
  62. package/dist-server/companion/src/routes.js +60 -0
  63. package/dist-server/container-mcp.js +94 -77
  64. package/dist-server/drivers/agents-proxy.js +96 -39
  65. package/dist-server/drivers/pi-mcp-extension.ts +245 -299
  66. package/dist-server/enterprise/server/index.js +3 -1
  67. package/dist-server/{browser-proxy.js → harness-mcp-proxy.js} +35 -19
  68. package/dist-server/index.js +18242 -13645
  69. package/dist-server/local-computer.js +3 -1
  70. package/dist-server/mcp-gate.js +147 -19
  71. package/dist-server/mcp-remote-proxy.js +437 -0
  72. package/dist-server/openmausbot.js +9618 -10058
  73. package/dist-server/pair-cli.js +9618 -10058
  74. package/dist-server/permission-proxy.js +65 -1
  75. package/dist-server/proxy-paths.js +3 -1
  76. package/dist-server/server/account-signin.js +0 -2
  77. package/dist-server/server/activity.js +201 -0
  78. package/dist-server/server/auto-approve.js +39 -11
  79. package/dist-server/server/automatic-recovery.js +1 -1
  80. package/dist-server/server/boat-create-idempotency.js +0 -7
  81. package/dist-server/server/boat-delete-journal.js +0 -4
  82. package/dist-server/server/bot-package.js +1 -19
  83. package/dist-server/server/browser-live.js +14 -5
  84. package/dist-server/server/browser-runtime.js +7 -2
  85. package/dist-server/server/channel-queue.js +4 -1
  86. package/dist-server/server/chief-of-staff.js +2 -3
  87. package/dist-server/server/cli.js +32 -12
  88. package/dist-server/server/cloud-backend.js +0 -4
  89. package/dist-server/server/cloud-computer-tools.js +110 -0
  90. package/dist-server/server/cloud-home.js +0 -1
  91. package/dist-server/server/cloud-lending.js +0 -3
  92. package/dist-server/server/cloud-move-http.js +9 -3
  93. package/dist-server/server/cloud-move.js +18 -0
  94. package/dist-server/server/composio.js +31 -2
  95. package/dist-server/server/computer-control.js +0 -15
  96. package/dist-server/server/computer-observation.js +0 -74
  97. package/dist-server/server/computer-wait.js +33 -13
  98. package/dist-server/server/config.js +73 -11
  99. package/dist-server/server/container-computer.js +7 -19
  100. package/dist-server/server/contracts.js +9 -0
  101. package/dist-server/server/control-client.js +4 -10
  102. package/dist-server/server/decision-log.js +9 -6
  103. package/dist-server/server/delegations.js +47 -22
  104. package/dist-server/server/drivers/acp/core.js +64 -13
  105. package/dist-server/server/drivers/acp/grok.js +208 -3
  106. package/dist-server/server/drivers/agents-call.js +70 -29
  107. package/dist-server/server/drivers/agents-catalog.js +34 -12
  108. package/dist-server/server/drivers/agents-proxy.js +8 -30
  109. package/dist-server/server/drivers/antigravity.js +1 -4
  110. package/dist-server/server/drivers/boatagent.js +25 -4
  111. package/dist-server/server/drivers/cerebras.js +3 -3
  112. package/dist-server/server/drivers/chat-mcp-tools.js +29 -19
  113. package/dist-server/server/drivers/claude.js +33 -19
  114. package/dist-server/server/drivers/codex.js +84 -10
  115. package/dist-server/server/drivers/openai-chat.js +14 -9
  116. package/dist-server/server/drivers/pi.js +48 -4
  117. package/dist-server/server/drivers/retry.js +8 -4
  118. package/dist-server/server/engine-install.js +1 -5
  119. package/dist-server/server/environment.js +3 -0
  120. package/dist-server/server/guarded-requests.js +2 -1
  121. package/dist-server/server/harness/registry.js +0 -1
  122. package/dist-server/server/{browser-proxy.js → harness-mcp-proxy.js} +40 -20
  123. package/dist-server/server/index.js +1929 -507
  124. package/dist-server/server/lending-memory.js +12 -8
  125. package/dist-server/server/live-call-controller.js +868 -0
  126. package/dist-server/server/live-call.js +216 -0
  127. package/dist-server/server/mcp-gate-config.js +43 -12
  128. package/dist-server/server/mcp-gate.js +127 -25
  129. package/dist-server/server/mcp-http.js +46 -8
  130. package/dist-server/server/mcp-remote-proxy.js +108 -0
  131. package/dist-server/server/memory-entries.js +38 -8
  132. package/dist-server/server/memory-organize.js +1 -8
  133. package/dist-server/server/memory-topics.js +4 -4
  134. package/dist-server/server/memory-upkeep.js +13 -15
  135. package/dist-server/server/message-file.js +5 -9
  136. package/dist-server/server/new-bot-defaults.js +3 -0
  137. package/dist-server/server/outbound-counts.js +79 -0
  138. package/dist-server/server/outbound-requests.js +60 -0
  139. package/dist-server/server/package-import.js +1 -1
  140. package/dist-server/server/peer-delivery.js +1 -0
  141. package/dist-server/server/peer-provenance.js +4 -2
  142. package/dist-server/server/permission-proxy.js +15 -1
  143. package/dist-server/server/plan-usage.js +797 -0
  144. package/dist-server/server/proxy-paths.js +3 -1
  145. package/dist-server/server/redact.js +2 -0
  146. package/dist-server/server/request-auth.js +3 -2
  147. package/dist-server/server/room-handoffs.js +5 -13
  148. package/dist-server/server/routes/live.js +98 -0
  149. package/dist-server/server/routine-requests.js +1 -1
  150. package/dist-server/server/routines.js +5 -1
  151. package/dist-server/server/setup-mode.js +0 -2
  152. package/dist-server/server/skill-learn.js +0 -1
  153. package/dist-server/server/skill-library.js +195 -3
  154. package/dist-server/server/skills-library-migration.js +235 -0
  155. package/dist-server/server/skills.js +66 -3
  156. package/dist-server/server/steer-queue.js +13 -1
  157. package/dist-server/server/store.js +41 -5
  158. package/dist-server/server/surface.js +74 -23
  159. package/dist-server/server/system-prompt.js +4 -4
  160. package/dist-server/server/team-backup.js +2 -1
  161. package/dist-server/server/team-memory.js +209 -0
  162. package/dist-server/server/thread-events.js +3 -3
  163. package/dist-server/server/tunnel.js +20 -1
  164. package/dist-server/server/turn-log.js +10 -0
  165. package/dist-server/server/turn-resources.js +124 -0
  166. package/dist-server/server/vps-computer.js +4 -14
  167. package/dist-server/server/workspace.js +85 -47
  168. package/dist-server/shared/activity.js +3 -0
  169. package/dist-server/shared/ask-question.js +13 -0
  170. package/dist-server/shared/bot-avatar.js +8 -0
  171. package/dist-server/shared/call-consent.js +28 -0
  172. package/dist-server/shared/connector-scopes.js +78 -0
  173. package/dist-server/shared/live-approval.js +124 -0
  174. package/dist-server/shared/live-call.js +176 -0
  175. package/dist-server/shared/outbound.js +128 -0
  176. package/dist-server/shared/skill-md.js +10 -0
  177. package/dist-server/shared/team-backup.js +3 -0
  178. package/dist-server/shared/tool-scope-support.js +25 -0
  179. package/dist-server/shared/tool-scope.js +136 -0
  180. package/dist-server/shared/tool-surface.js +3 -2
  181. package/dist-server/src/lib/thread-refs.js +406 -0
  182. package/dist-server/vps-container-mcp.js +94 -77
  183. package/dist-server/workspace-backup.worker.js +89 -100
  184. package/enterprise/server/index.js +3 -1
  185. package/package.json +1 -1
  186. package/dist/assets/channel-BU94VpxE.js +0 -1
  187. package/dist/assets/index-Biji-bfL.css +0 -1
  188. package/dist/assets/index-DZlhoES0.js +0 -597
  189. package/dist/assets/swimlanesDiagram-TC7HE7FX-CdHxG8aM.js +0 -8
  190. package/dist-server/server/drivers/chat-boat-tools.js +0 -90
  191. package/dist-server/server/drivers/grokagent.js +0 -4
  192. package/dist-server/server/openmaus-status-capsule.js +0 -329
@@ -30,8 +30,9 @@ import { CLOUD_COMPUTER_BUSY_ERROR } from "../shared/computer-contention.js";
30
30
  import { approvalModeFor, modelSwitchNeedsAsk, isEmergencyApprovalDowngrade, isApprovalMode, } from "../shared/approval-mode.js";
31
31
  import { createApprovalModeSupport } from "./harness-capabilities.js";
32
32
  import { escapeAttribute } from "../src/lib/composer-attachments.js";
33
+ import { threadRefUrl } from "../src/lib/thread-refs.js";
33
34
  import { CREDENTIAL_TARGETS, credentialResumeOutcome, credentialIsConfigured, isPendingCredentialRequest, isCredentialTargetId, } from "../shared/credential-request.js";
34
- import { approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval, delegationInheritsFullAccess } from "./auto-approve.js";
35
+ import { HELD_NOTE, approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval, delegatedApprovalMode } from "./auto-approve.js";
35
36
  import { CommandAllowlistStore, commandAllowlistCandidate } from "./command-allowlist.js";
36
37
  import { updateClaudeCli } from "./claude-update.js";
37
38
  import { configuredAccountDirectory, assertSeparateClaudeAccount, claudeAccountInfo, createClaudeAccountSchema, instanceSettingsSchema, newClaudeAccount } from "./claude-accounts.js";
@@ -50,10 +51,14 @@ import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
50
51
  import { groupTurnCwd } from "./room-cwd.js";
51
52
  import { RoomTurnDeadline, RoomTurnStallRegistry, roomTurnTimeoutMessage } from "./room-turn-timeout.js";
52
53
  import * as boat from "./boat.js";
54
+ import { cloudComputerRpc } from "./cloud-computer-tools.js";
53
55
  import { TeamComputers, teamComputerAssignment, teamComputerCreate, teamComputerOwner } from "./team-computers.js";
54
56
  import { isEffortLevel } from "../shared/wire.js";
57
+ import { isPersistentQuestionCard, QUESTION_DISMISS_MESSAGE, shouldSettleRequestCard } from "../shared/ask-question.js";
58
+ import { parseToolScope, toolScopeWidens } from "../shared/tool-scope.js";
55
59
  import { boatCreateRecoverySnapshot, retireDeletedBoatCreate } from "./boat-create-idempotency.js";
56
60
  import { boatDeletionSnapshot } from "./boat-delete-journal.js";
61
+ import { liveDecisionRefusal } from "../shared/live-approval.js";
57
62
  import { boatAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourceChangeError, } from "./cloud-backend.js";
58
63
  import * as composio from "./composio.js";
59
64
  import { connectorCallFromFrame, connectorRefusalText, connectorUnrecognizedText, evaluateConnectorTools } from "./connector-verdict.js";
@@ -62,15 +67,15 @@ import { buildRecall } from "./recall.js";
62
67
  import { createMemoryUpkeep, upkeepEnabled } from "./memory-upkeep.js";
63
68
  import { appendAboutMe, commitLearned, planLearned } from "./profile-learned.js";
64
69
  import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, livePeerRosterBlock, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
65
- import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
66
70
  import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerExec, containerRuntimeStatus, localVmWakeAction, localVmWorkspaceExists, perBotLocalVmTarget, poolLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, VM_WORKSPACE_GUEST, } from "./container-computer.js";
67
71
  import { attachForTurn, saveBotAttachment } from "./bot-attachment.js";
68
- import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmIdleTimeoutMinutes, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, computerClaimIdleReleaseEnabled, cloudOverflowAllowlistedThreads, cloudOverflowEnabled, cloudOverflowIdleStopMs, cloudOverflowPerSecondCostUsd, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, stripWorkspaceCredentialEnv, PROVIDER_CREDENTIAL_ENV, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, CLAUDE_API_INSTANCE, } from "./config.js";
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";
69
73
  import { sweepThreadEventLogs } from "./thread-retention.js";
70
74
  import { ComputerControl } from "./computer-control.js";
71
75
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
72
76
  import { registerEnginesBinDir } from "./engine-install.js";
73
77
  import { appendUsage, parseUsageRange, readUsage, summarizeUsage, usageCsv, USAGE_GROUPINGS, flushUsageLedger } from "./usage-ledger.js";
78
+ import { loadPlanUsage, planAccountsFromInstances } from "./plan-usage.js";
74
79
  import { GroupUsageReader } from "./group-thread-usage.js";
75
80
  import { ledgerCost } from "./model-prices.js";
76
81
  import { checkProviderKey, PROVIDER_KEY_KINDS } from "./provider-key-check.js";
@@ -80,7 +85,7 @@ import { entitled } from "./enterprise.js";
80
85
  import { HOSTED_CONTRACT_HEADER, HOSTED_CONTRACT_METADATA, HOSTED_CONTRACT_VERSION } from "./hosted-contract.js";
81
86
  import { describeSpawnFailure, execCli } from "./procs.js";
82
87
  import { blockedTarget, buildNotification, buildSpendNotification } from "./notify.js";
83
- import { isModelVariant, TurnNotStartedError, newId, } from "./contracts.js";
88
+ import { isModelVariant, TurnNotStartedError, newId, usesCloudComputer, } from "./contracts.js";
84
89
  import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.js";
85
90
  import { recoveryCapabilityError } from "./automatic-recovery.js";
86
91
  import { decodeGeneratedImage } from "./generated-image.js";
@@ -102,7 +107,7 @@ import { attemptAsideInjection, drainAsideMessages, queueAsideMessage, restoreAs
102
107
  import { promptWithReply, transcriptText } from "./replies.js";
103
108
  import { admit, DRAIN_COALESCE_MAX_ITEMS } from "./admission.js";
104
109
  import { _loadPending, buildDelegationFailurePrompt, buildDelegationRevivalPrompt, DELEGATION_TTL_MS, DelegationWakeBudget, discardDelegations, drainDelegations, expireStaleDelegations, findDelegationReceipt, pendingDelegationInfo, pendingDelegationSnapshot, pendingThreads, queueDelegation, recordDelegationReceipt, releaseDelegationsWaitingOn, summarizeDelegatedActivity } from "./delegations.js";
105
- import { cancelSteeredMessage, drainSteeredMessages, hasQueuedSteeredMessages, holdSteeredQueue, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, restoreHeldSteeredQueue, restoreSteeredMessages, settleHeldSteeredQueue, } from "./steer-queue.js";
110
+ import { cancelSteeredMessage, drainSteeredMessages, hasQueuedSteeredMessages, holdSteeredQueue, isSteeredMessageQueued, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, restoreHeldSteeredQueue, restoreSteeredMessages, settleHeldSteeredQueue, } from "./steer-queue.js";
106
111
  import { cancelChannelMessage, drainChannelMessages, holdChannelQueue, headChannelGroup, queuedChannelMessage, queueChannelMessage, restoreChannelMessages, restoreHeldChannelQueue, resolveHeldReplyTarget, settleHeldChannelQueueHead, } from "./channel-queue.js";
107
112
  import { acceptedSendMatch, parseSendId, sendFingerprint, SendSequencer, } from "./send-idempotency.js";
108
113
  import { EventBus } from "./harness/bus.js";
@@ -123,7 +128,10 @@ import { isProjectEmoji, mentionedBots, parseConnectorTools, roomResponders, sec
123
128
  import * as tts from "./tts/index.js";
124
129
  import { createDecider, deciderIncludedHere, deciderReady, deciderSavePatch, describeDecider } from "./decider/index.js";
125
130
  import { decideRoomResponder } from "./decider/room-routing.js";
131
+ import { createLiveSession, liveAttachUrl, LiveSessionError } from "./live-call.js";
132
+ import { LiveCallController, LiveCallSignedOutError } from "./live-call-controller.js";
126
133
  import { narrateTool, toUtterances } from "./tts/speech-text.js";
134
+ import { turnStartLogLine } from "./turn-log.js";
127
135
  import { buildRecoveryText, buildTurnContext, engineIsFresh, NATIVELY_REPLAYING_DRIVER_KINDS, peerMessageText } from "./turn-context.js";
128
136
  import { Handoffs, handedStateUsable, recordHanded, renderUnseen, sessionStart, unseenMessages, withUnseenMessages } from "./delta-context.js";
129
137
  import { extractTurnImages } from "./turn-images.js";
@@ -135,14 +143,14 @@ import { ensureWorkspace, ensureTaskWorkspace, workspaceLocationsPrompt, support
135
143
  import { listMemoryTopics, memoryDate, readMemoryFile, readMemoryTopic, writeMemoryFile, writeMemoryTopic } from "./workspace.js";
136
144
  import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, journalMemoryWrite, } from "./memory-journal.js";
137
145
  import { readSectionContext, readSections, sectionContextKey, sectionContextLabel, sectionContextSystemPrompt, writeSectionContext, SECTION_CONTEXT_MAX_BYTES, } from "./section-context.js";
138
- import { applyStagedSkillWrite, applySkillWriteWithReceipt, getStagedSkillWrite, installOrgSkill, installSkill, parseSkillMd, scanSkillText, listSkills, listStagedSkillWrites, isSkillName, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillPackageStamps, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
146
+ import { applyStagedSkillWrite, applySkillWriteWithReceipt, getStagedSkillWrite, installOrgSkill, installSkill, listBotSkillsWithOrigin, resolveBotSkills, parseSkillMd, scanSkillText, listSkills, listStagedSkillWrites, isSkillName, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillPackageStamps, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
139
147
  import { fetchSkillFromSource } from "./skill-fetch.js";
140
148
  import { pickBotName } from "./names.js";
141
149
  import { botSkillTemplateSchema, newBotDefaultsSchema, resolveBotCreationDefaults } from "./new-bot-defaults.js";
142
150
  import { expandLearnTurnText, learnSource } from "./skill-learn.js";
143
151
  import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
144
152
  import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-folder.js";
145
- import { buildSystemPrompt, userProfileSystemPrompt, cloudHomePrompt, computerPrompt, composioSystemPrompt, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, resolveComputerPromptKind, } from "./system-prompt.js";
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";
146
154
  import { readCuaConnection, readCuaUnavailableReason, gatedLocalComputer } from "./local-computer.js";
147
155
  import { discoverExistingPerBotLocalVms, discoverExistingPoolLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
148
156
  import { localVmStopReason, recordLocalVmIdleStop } from "./local-vm-stop-reason.js";
@@ -175,12 +183,19 @@ import { fetchGithubTeam, fetchLibraryTeam, fetchTeamCatalog } from "./team-libr
175
183
  import { BOT_PACKAGE_MAX_SKILLS, isBotPackage, packageSummary, parsePackageDocument, renderBotPackageMarkdown } from "./bot-package.js";
176
184
  import { createTeamManifest, parseTeamManifest } from "./team-manifest.js";
177
185
  import { readThreadEvents } from "./thread-events.js";
186
+ import { TeamMemory, TEAM_MEMORY_KINDS } from "./team-memory.js";
187
+ import { describeTool, readBotActivity } from "./activity.js";
188
+ import { OutboundCounts } from "./outbound-counts.js";
189
+ import { OutboundRequestService } from "./outbound-requests.js";
190
+ import { DEFAULT_OUTBOUND_POLICY, connectorCallsIn, normalizeOutboundPolicy, outboundCallsIn } from "../shared/outbound.js";
191
+ import { connectorAccessDecision, describeConnectorScopes, normalizeConnectorScopes } from "../shared/connector-scopes.js";
178
192
  import { bindThreadLogCapProvider } from "./thread-log-rotation.js";
179
193
  import { listenWebhookIngress, webhookCredential } from "./webhook-ingress.js";
180
194
  import { assertModelVariantSupported, memberTurnSelection } from "./member-turn.js";
181
195
  import { WebhookManager } from "./webhooks.js";
182
196
  import { SPAWNED_PROXIES } from "./proxy-paths.js";
183
- import { loadBundledSkills, loadUserSkills, mergeSkills, renderSkillInstructions, selectBundledSkills } from "./skill-library.js";
197
+ import { installLibrarySkill, listLibrarySkills, loadBundledSkills, loadUserSkills, mergeSkills, readLibrarySkillFile, readSkillLibraryIndex, renderSkillInstructions, selectBundledSkills, setLibrarySkillReviewState, } from "./skill-library.js";
198
+ import { runSkillsLibraryBootSweep } from "./skills-library-migration.js";
184
199
  import { installedPlaybookInstructions } from "./installed-playbooks.js";
185
200
  import { createBotPackageExport, createLibraryPackageExport, createTeamPackageExport, picture as sharedPicture, TeamExportError } from "./package-export.js";
186
201
  import { importPackageDocument, importTeamManifest, PackageImportError } from "./package-import.js";
@@ -192,10 +207,10 @@ import { MAX_TEAM_BACKUP_BYTES } from "../shared/team-backup.js";
192
207
  import { shouldMountLocalComputer } from "./local-routing.js";
193
208
  import { autoLocalVmAttachable } from "./container-computer.js";
194
209
  import { startAutoVmClaim } from "./auto-vm-claims.js";
195
- import { computerFreeAfterText, computerStillBusyText, computerStoppedWaitingText, computerWaitingText } from "./computer-wait.js";
210
+ import { computerFreeAfterText, computerParkedText, computerStoppedWaitingText, computerWaitingText } from "./computer-wait.js";
196
211
  import { CloudOverflowConsent, CloudSeatLease, cloudOverflowAction, cloudOverflowConsentText, cloudOverflowOfferText, cloudSeatStartedText, cloudSeatStoppedText } from "./cloud-overflow.js";
197
212
  import { modelContextWindow } from "./model-context-window.js";
198
- import { parseSurface, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
213
+ import { cloudPlaceDriverError, parseSurface, PlaceUnavailableError, placeUnavailable, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
199
214
  import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
200
215
  import { createGracefulShutdown } from "./graceful-shutdown.js";
201
216
  import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
@@ -227,6 +242,7 @@ import { createDesktopViewer, desktopViewerUrl } from "./routes/desktop-viewer.j
227
242
  import { localDesktopTarget, localVmViewerStatus, viewerTargetId } from "./desktop-viewer-targets.js";
228
243
  import { createAntigravityLeftoverRoutes } from "./routes/antigravity-leftovers.js";
229
244
  import { findAntigravityLeftovers, removeAntigravityLeftovers } from "./drivers/antigravity-temp.js";
245
+ import { createLiveRoutes } from "./routes/live.js";
230
246
  const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799);
231
247
  const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1);
232
248
  // Behind a proxy or tunnel, the base URL senders should use (docs/self-hosting.md).
@@ -282,10 +298,12 @@ const workspaceMaintenance = new WorkspaceBackupMaintenance();
282
298
  // Remote clients (server/request-auth.ts, server/sessions.ts): a stable identity
283
299
  // for this server, the paired sessions, and the cookie the served UI uses.
284
300
  const ENVIRONMENT_ID = loadEnvironmentId(DATA_DIR);
285
- function signInAllowList() {
286
- const current = loadConfig().signIn;
287
- return { admins: parseAllowList(current?.admins?.join(",")), members: parseAllowList(current?.members?.join(",")) };
288
- }
301
+ // Every stream frame and request rechecks email sessions against this list,
302
+ // so config.json is parsed again only when the file changes.
303
+ const signInAllowList = cacheUntilConfigChanges((config) => ({
304
+ admins: parseAllowList(config.signIn?.admins?.join(",")),
305
+ members: parseAllowList(config.signIn?.members?.join(",")),
306
+ }));
289
307
  const sessions = new SessionRegistry({
290
308
  file: join(DATA_DIR, "sessions.json"),
291
309
  emailScopesSnapshot: () => {
@@ -930,6 +948,9 @@ function cardAnswerRefusal(auth, threadId, requestId, behavior) {
930
948
  ? "A local service can only decline this request. Approve or answer it in OpenMausBot while signed in."
931
949
  : null;
932
950
  }
951
+ if (!auth.scopes.includes("admin") && store.messagesFor(threadId).some(message => message.card?.requestId === requestId && message.card.teamMemoryRequest)) {
952
+ return "Only a workspace admin can review shared team memory.";
953
+ }
933
954
  // A Cloud home is one person's: a device paired with chat-only access (a
934
955
  // guest) may read along but never answer, or its words would reach the
935
956
  // owner's turn, and through it a lent Mac (docs/cloud-pro.md).
@@ -993,9 +1014,10 @@ function cloudCardAnswerer(card) {
993
1014
  return card.requestId ? cloudCardAnswersInFlight.get(card.requestId) : undefined;
994
1015
  }
995
1016
  /** Answer a card as `auth`: the decision rows written meanwhile name the
996
- * answerer, and a card this answer settled records who settled it. A card
997
- * that was already settled keeps whatever it said. */
998
- async function answeringCardAs(auth, threadId, requestId, work) {
1017
+ * answerer, and a card this answer settled records who settled it (and
1018
+ * `via: "call"` when it was decided by voice on a Live call). A card that
1019
+ * was already settled keeps whatever it said. */
1020
+ async function answeringCardAs(auth, threadId, requestId, work, via) {
999
1021
  const open = (() => {
1000
1022
  const card = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId)?.card;
1001
1023
  return Boolean(card && !card.answered && !card.dismissed && !card.expired);
@@ -1003,13 +1025,13 @@ async function answeringCardAs(auth, threadId, requestId, work) {
1003
1025
  if (CLOUD_HOME && open)
1004
1026
  cloudCardAnswersInFlight.set(requestId, auth.kind === "session" ? actorKey(auth) : undefined);
1005
1027
  try {
1006
- await withDecisionActor(decisionActorFor(auth), work);
1028
+ await withDecisionActor(decisionActorFor(auth), work, via);
1007
1029
  }
1008
1030
  finally {
1009
1031
  const message = open ? store.messagesFor(threadId).find((candidate) => candidate.card?.requestId === requestId) : undefined;
1010
1032
  const card = message?.card;
1011
1033
  if (message && card && !card.answeredBy && card.answered !== "unavailable" && (card.answered || card.dismissed)) {
1012
- store.patchMessage(threadId, message.id, { card: { ...card, answeredBy: cardAnswererFor(auth) } });
1034
+ store.patchMessage(threadId, message.id, { card: { ...card, answeredBy: { ...cardAnswererFor(auth), ...(via ? { via } : {}) } } });
1013
1035
  }
1014
1036
  if (CLOUD_HOME && open)
1015
1037
  cloudCardAnswersInFlight.delete(requestId);
@@ -1519,6 +1541,25 @@ function resyncOpenCodeProviderKeys() {
1519
1541
  await registry.load(providerConfigs(), decorateHostedProvider);
1520
1542
  const bundledSkills = loadBundledSkills();
1521
1543
  const availableSkills = () => mergeSkills(bundledSkills, loadUserSkills(join(DATA_DIR, "skills")));
1544
+ // Skills library (features.skillsLibrary). While the flag is off both
1545
+ // helpers reduce exactly to today's per-bot calls; assignments recorded on
1546
+ // bot records stay inert until it is on.
1547
+ const listBotSkills = (bot) => skillsLibraryEnabled(cfg) ? resolveBotSkills(bot.id, bot.assignedSkills) : listSkills(bot.id);
1548
+ const readBotSkillFile = (bot, name) => {
1549
+ const own = readSkillFile(bot.id, name);
1550
+ if (own !== null || !skillsLibraryEnabled(cfg))
1551
+ return own;
1552
+ // A private listing whose stored bytes no longer verify must not fall
1553
+ // through to a same-named library skill: private wins collisions, so a
1554
+ // broken private file stays unreadable instead of exporting another
1555
+ // bot's library instructions under this bot's name.
1556
+ if (listSkills(bot.id).some((skill) => skill.name === name))
1557
+ return null;
1558
+ // The library fallback is by assignment only, never by name alone.
1559
+ if (!bot.assignedSkills?.includes(name))
1560
+ return null;
1561
+ return readLibrarySkillFile(name);
1562
+ };
1522
1563
  // SAFETY: Electron's utility-process runtime is the only environment that
1523
1564
  // supplies parentPort; plain Node intentionally leaves it absent.
1524
1565
  const utilityParentPort = process.parentPort;
@@ -1975,7 +2016,20 @@ function hooksIntegration(botId, threadId, generation) {
1975
2016
  }
1976
2017
  class DirectTurnSetupCancelled extends Error {
1977
2018
  }
1978
- class ComputerWaitGaveUp extends Error {
2019
+ /** The computer wait ceiling fired (ADR-2, #1651). The turn parks — settled
2020
+ * with a registered resume — instead of failing; the carried resource names
2021
+ * the seat the resume must wait to free. */
2022
+ class ComputerWaitParked extends Error {
2023
+ resource;
2024
+ /** The parked turn's generation: the drain distinguishes the turn's own
2025
+ * settle window (keep, retry later) from a newer turn that superseded it
2026
+ * (delete). */
2027
+ generation;
2028
+ constructor(message, resource, generation) {
2029
+ super(message);
2030
+ this.resource = resource;
2031
+ this.generation = generation;
2032
+ }
1979
2033
  }
1980
2034
  const directTurnDispatchClaims = new Map();
1981
2035
  const directTurnGenerationByThread = new Map();
@@ -2019,6 +2073,12 @@ const turnResources = new TurnResources();
2019
2073
  const sharedComputerControl = new SharedComputerControl(turnResources, () => store.bots.some(bot => botComputerControlSnapshot(bot.id).held));
2020
2074
  const turnResourceOwners = new Map();
2021
2075
  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();
2022
2082
  const teamComputerTurns = new Map();
2023
2083
  const settlingResourceOwners = new Map();
2024
2084
  /** Idle release for computer claims (#1653), disabled until the feature
@@ -2028,10 +2088,11 @@ const settlingResourceOwners = new Map();
2028
2088
  * yields once another turn holds the seat. The durable VM profile keeps
2029
2089
  * its login state, so that re-claim is cheap. Read per claim: a flag
2030
2090
  * change applies to new claims, never to a seat held under the old
2031
- * setting. */
2091
+ * setting. computer:phone is excluded: it is the #1663 exclusivity lock,
2092
+ * claimed once per turn with no activity signal to renew it. */
2032
2093
  const COMPUTER_CLAIM_IDLE_POLICY = new IdleReleasePolicy();
2033
2094
  function computerClaimIdlePolicy(resource) {
2034
- return resource.startsWith("computer:") && computerClaimIdleReleaseEnabled(cfg) ? COMPUTER_CLAIM_IDLE_POLICY : undefined;
2095
+ return resource.startsWith("computer:") && resource !== "computer:phone" && computerClaimIdleReleaseEnabled(cfg) ? COMPUTER_CLAIM_IDLE_POLICY : undefined;
2035
2096
  }
2036
2097
  /** #1655: the consent ledger for local-wait cloud overflow, and the live
2037
2098
  * cloud seats it has started. One seat per bot — it is the bot's own Box,
@@ -2191,11 +2252,17 @@ function releaseTurnResources(owner) {
2191
2252
  turnResourceOwners.delete(owner.threadId);
2192
2253
  if (turnComputerResources.get(owner.threadId)?.owner.generation === owner.generation)
2193
2254
  turnComputerResources.delete(owner.threadId);
2255
+ if (pendingComputerCallStarts.get(owner.threadId)?.generation === owner.generation)
2256
+ pendingComputerCallStarts.delete(owner.threadId);
2194
2257
  const teamTurn = teamComputerTurns.get(owner.threadId);
2195
2258
  if (teamTurn?.owner.generation === owner.generation) {
2196
2259
  stopScreenPoller(teamTurn.botId, owner.threadId);
2197
2260
  teamComputerTurns.delete(owner.threadId);
2198
2261
  }
2262
+ // A freed seat is the parked-resume drain's trigger (#1651). Deferred past
2263
+ // this synchronous settle so the drain reads post-cleanup state.
2264
+ if (pendingComputerResumes.size > 0)
2265
+ queueMicrotask(drainComputerResumes);
2199
2266
  }
2200
2267
  async function bindTurnComputer(owner, resource, exclusive = false) {
2201
2268
  // Every computer claim passes here: a kind the organisation disallows, or a
@@ -2210,7 +2277,7 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2210
2277
  // bot running a titled thread, or a room. Read once, when the wait begins.
2211
2278
  let holder;
2212
2279
  let holderThreadRef;
2213
- const deadline = Date.now() + GROUP_GOAL_WAIT_MAX_MS;
2280
+ const deadline = Date.now() + COMPUTER_WAIT_MAX_MS;
2214
2281
  // The wait is history the transcript keeps (#1647): the waiting chip stays
2215
2282
  // exactly as written, a resolution line is appended beside it, and the
2216
2283
  // turn.wait_* events carry the same facts for the inspector log.
@@ -2236,15 +2303,18 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2236
2303
  return;
2237
2304
  waitEnded = true;
2238
2305
  const waitedMs = waitedSince === undefined ? 0 : Date.now() - waitedSince;
2306
+ // Acquisition is the only wait that completes: it is the sample the
2307
+ // per-resource estimate is built from (#1652).
2308
+ if (outcome === "acquired")
2309
+ turnResources.noteWait(resource, waitedMs);
2239
2310
  const text = outcome === "acquired"
2240
2311
  ? computerFreeAfterText(holder, waitedMs)
2241
- : outcome === "gave_up"
2242
- ? computerStillBusyText(holder, waitedMs)
2312
+ : outcome === "parked"
2313
+ ? computerParkedText(holder, COMPUTER_WAIT_MAX_MS)
2243
2314
  : computerStoppedWaitingText(holder, waitedMs);
2244
2315
  store.appendMessage(owner.threadId, {
2245
2316
  role: "bot", kind: "activity",
2246
- ...(outcome === "gave_up" ? { turnSucceeded: false } : {}),
2247
- tool: { name: text, ok: outcome !== "gave_up" },
2317
+ tool: { name: text, ok: true },
2248
2318
  ...(holderThreadRef ? { threadRef: holderThreadRef } : {}),
2249
2319
  });
2250
2320
  bus.publish({
@@ -2263,6 +2333,10 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2263
2333
  if (!exclusive || claimTurnResource(owner, resource))
2264
2334
  break;
2265
2335
  if (!waitingMessage) {
2336
+ // Arrival in the waitlist is the chip's timestamp: the position it
2337
+ // reads is where this turn sits the moment it starts waiting (#1652).
2338
+ const position = turnResources.startWaiting(resource, owner);
2339
+ const estimateMs = turnResources.waitEstimateMs(resource);
2266
2340
  const blocker = turnResources.blocker(resource, owner);
2267
2341
  const holderBot = blocker && store.botByThread(blocker.threadId);
2268
2342
  const holderTask = holderBot && blocker && store.taskByThread(holderBot.id, blocker.threadId);
@@ -2275,7 +2349,7 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2275
2349
  : undefined;
2276
2350
  waitingMessage = store.appendMessage(owner.threadId, {
2277
2351
  role: "bot", kind: "activity",
2278
- tool: { name: computerWaitingText(holder) },
2352
+ tool: { name: computerWaitingText(holder, { position, estimateMs }) },
2279
2353
  ...(holderThreadRef ? { threadRef: holderThreadRef } : {}),
2280
2354
  });
2281
2355
  waitedSince = Date.now();
@@ -2283,6 +2357,7 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2283
2357
  ...waitEventBase(),
2284
2358
  type: "turn.wait_started",
2285
2359
  resource,
2360
+ position,
2286
2361
  ...(holder ? { holder } : {}),
2287
2362
  });
2288
2363
  // #1655: the wait is where an overflow choice gets made. The card
@@ -2312,8 +2387,12 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2312
2387
  }
2313
2388
  sweepIdleCloudSeats();
2314
2389
  if (Date.now() >= deadline) {
2315
- endWait("gave_up");
2316
- throw new ComputerWaitGaveUp(computerStillBusyText(holder, GROUP_GOAL_WAIT_MAX_MS));
2390
+ // ADR-2 (#1651): the ceiling parks the turn — settled, with a
2391
+ // registered resume — instead of failing it. The owner mark lets the
2392
+ // live lazy-claim rejection path park the same way.
2393
+ endWait("parked");
2394
+ owner.computerParkedOn = resource;
2395
+ throw new ComputerWaitParked(computerParkedText(holder, COMPUTER_WAIT_MAX_MS), resource, owner.generation);
2317
2396
  }
2318
2397
  await new Promise(resolve => setTimeout(resolve, 100));
2319
2398
  }
@@ -2322,9 +2401,21 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2322
2401
  // Never patch the waiting chip over: the queue position this turn held
2323
2402
  // stays visible beside what the wait came to.
2324
2403
  endWait(active() && turnResources.owns(resource, owner) ? "acquired" : "stopped");
2404
+ // Whatever the wait came to, this turn is no longer waiting: an owner
2405
+ // left at the front would block the grant for everyone behind it.
2406
+ turnResources.stopWaiting(resource, owner);
2325
2407
  }
2326
2408
  turnResourceOwners.set(owner.threadId, owner);
2327
2409
  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
+ }
2328
2419
  }
2329
2420
  function botForThread(botId, threadId) {
2330
2421
  return directTurnBots.get(threadId) ?? store.projectBotForTask(botId, threadId) ?? store.bot(botId);
@@ -2413,7 +2504,13 @@ function requestedTaskBot(botId, rawThreadId) {
2413
2504
  throw Object.assign(new Error("no such task"), { status: 404 });
2414
2505
  return task;
2415
2506
  }
2416
- async function interruptDirectThread(botId, threadId) {
2507
+ async function interruptDirectThread(botId, threadId, options) {
2508
+ // A parked turn has settled its provider but still owns a future resume.
2509
+ // Stop cancels that work too. The parking teardown itself preserves it.
2510
+ if (!options?.preserveComputerResume && pendingComputerResumes.delete(threadId)
2511
+ && store.taskByThread(botId, threadId)?.activity === "parked.computer") {
2512
+ store.setTaskActivity(botId, threadId, "idle");
2513
+ }
2417
2514
  const requestOwner = directRequestOwners.get(threadId);
2418
2515
  if (requestOwner) {
2419
2516
  requestOwner.stopped = true;
@@ -2455,7 +2552,7 @@ function noteTeammatesLeftRunning(botId, threadId, running) {
2455
2552
  }
2456
2553
  }
2457
2554
  async function interruptAllDirectThreads(botId) {
2458
- const threads = store.tasks(botId).filter((task) => task.busy || directTurnDispatchClaims.has(task.threadId) || roomHandoffs.activeDirect(task.threadId));
2555
+ const threads = store.tasks(botId).filter((task) => task.busy || directTurnDispatchClaims.has(task.threadId) || roomHandoffs.activeDirect(task.threadId) || pendingComputerResumes.has(task.threadId));
2459
2556
  // Revoke every sibling before yielding to any provider teardown.
2460
2557
  for (const task of threads) {
2461
2558
  cancelDirectTurnDispatch(botId, task.threadId);
@@ -2638,8 +2735,8 @@ async function browserIntegration(botId, profile, turn) {
2638
2735
  const token = mintInternalCapability({ botId, ...turn, browserSession: session,
2639
2736
  kind: "browser", depth: 0, skillAuthoring: false, createdBots: 0, openedThreads: 0 });
2640
2737
  return { profile: partitionId, session, spec, integration: {
2641
- command: process.execPath, args: [SPAWNED_PROXIES.browser], env: {
2642
- ...AGENTS_NODE_FLAG, OMB_BROWSER_TOKEN: token, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`,
2738
+ command: process.execPath, args: [SPAWNED_PROXIES.harnessMcp, "browser"], env: {
2739
+ ...AGENTS_NODE_FLAG, OMB_MCP_TOKEN: token, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`,
2643
2740
  },
2644
2741
  } };
2645
2742
  }
@@ -2740,8 +2837,10 @@ const routineRequestEnvelopeSchema = z.discriminatedUnion("action", [
2740
2837
  }).strict(),
2741
2838
  ...["pause", "resume", "run_now", "delete"].map((action) => z.object({ ...routineRequestSourceSchema, action: z.literal(action), routineId: z.unknown(), forBotId: z.unknown().optional() }).strict()),
2742
2839
  ]);
2743
- /** The loopback endpoint a bot's computer proxy polls before acting. */
2744
- function controlIntegration(botId, threadId, generation, localVmTarget) {
2840
+ /** The loopback endpoint a bot's computer proxy polls before acting. With a
2841
+ * 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) {
2745
2844
  return {
2746
2845
  url: `http://127.0.0.1:${PORT}/api/internal/computer-control?botId=${encodeURIComponent(botId)}`,
2747
2846
  token: mintInternalCapability({
@@ -2751,6 +2850,7 @@ function controlIntegration(botId, threadId, generation, localVmTarget) {
2751
2850
  depth: 0,
2752
2851
  kind: "computer",
2753
2852
  ...(localVmTarget ? { localVmTarget } : {}),
2853
+ ...(boxId ? { boxId } : {}),
2754
2854
  ...(teamComputerTurns.get(threadId) ? { teamComputerId: teamComputerTurns.get(threadId).computerId } : {}),
2755
2855
  skillAuthoring: false,
2756
2856
  createdBots: 0,
@@ -2937,7 +3037,7 @@ function checkedExportSkillNames(value, bots) {
2937
3037
  return { ok: false, error: "skillIds must not contain duplicates" };
2938
3038
  if (names.length > BOT_PACKAGE_MAX_SKILLS)
2939
3039
  return { ok: false, error: `skillIds must contain at most ${BOT_PACKAGE_MAX_SKILLS} names` };
2940
- const available = new Set(bots.flatMap((bot) => listSkills(bot.id).map((skill) => skill.name)));
3040
+ const available = new Set(bots.flatMap((bot) => listBotSkills(bot).map((skill) => skill.name)));
2941
3041
  const unknown = names.find((name) => !available.has(name));
2942
3042
  if (unknown)
2943
3043
  return { ok: false, error: `skillIds contains unknown imported skill "${unknown}"` };
@@ -2950,10 +3050,10 @@ function collectExportSkills(bots, names) {
2950
3050
  return byBot;
2951
3051
  for (const bot of bots) {
2952
3052
  const assigned = [];
2953
- for (const listing of listSkills(bot.id)) {
3053
+ for (const listing of listBotSkills(bot)) {
2954
3054
  if (!selected.has(listing.name))
2955
3055
  continue;
2956
- const instructions = readSkillFile(bot.id, listing.name);
3056
+ const instructions = readBotSkillFile(bot, listing.name);
2957
3057
  if (instructions === null) {
2958
3058
  throw new Error(`Skill "${listing.name}" changed or is unavailable and cannot be exported safely`);
2959
3059
  }
@@ -2981,7 +3081,7 @@ function collectExportSkills(bots, names) {
2981
3081
  * skill choices from it, so a refused choice can always be changed. */
2982
3082
  function collectTeamSkills(bots, selection) {
2983
3083
  const all = selection === undefined || selection === "all";
2984
- const available = [...new Set(bots.flatMap((bot) => listSkills(bot.id).map((skill) => skill.name)))].sort();
3084
+ const available = [...new Set(bots.flatMap((bot) => listBotSkills(bot).map((skill) => skill.name)))].sort();
2985
3085
  if (!all && (!Array.isArray(selection) || selection.some((name) => typeof name !== "string" || !isSkillName(name)))) {
2986
3086
  return { ok: false, error: "skills must be \"all\" or a list of skill names", available };
2987
3087
  }
@@ -2993,10 +3093,10 @@ function collectTeamSkills(bots, selection) {
2993
3093
  const skipped = [];
2994
3094
  for (const bot of bots) {
2995
3095
  const assigned = [];
2996
- for (const listing of listSkills(bot.id)) {
3096
+ for (const listing of listBotSkills(bot)) {
2997
3097
  if (!chosen.has(listing.name))
2998
3098
  continue;
2999
- const instructions = readSkillFile(bot.id, listing.name);
3099
+ const instructions = readBotSkillFile(bot, listing.name);
3000
3100
  if (instructions === null) {
3001
3101
  if (!all)
3002
3102
  return { ok: false, error: `Skill "${listing.name}" changed or is unavailable and cannot be shared safely`, available };
@@ -3133,6 +3233,26 @@ const sendSequencer = new SendSequencer();
3133
3233
  bootSelection = await defaultSelection();
3134
3234
  store.seedIfEmpty();
3135
3235
  hostedModels?.reconcile(store);
3236
+ // Skills library boot sweep (features.skillsLibrary): migrate per-bot
3237
+ // copies into the shared library and apply assignments. Flag-off boots
3238
+ // never enter this block, keeping per-bot behavior byte-identical.
3239
+ if (skillsLibraryEnabled(cfg)) {
3240
+ try {
3241
+ const sweep = runSkillsLibraryBootSweep({
3242
+ bots: store.bots,
3243
+ patch: (botId, assignedSkills) => {
3244
+ store.patchBot(botId, { assignedSkills });
3245
+ },
3246
+ });
3247
+ const applied = sweep.outcomes.filter((outcome) => outcome.outcome !== "skipped").length;
3248
+ if (sweep.outcomes.length) {
3249
+ console.log(`[skills-library] boot sweep: ${applied} skill(s) migrated or deduplicated, ${sweep.outcomes.length - applied} skipped`);
3250
+ }
3251
+ }
3252
+ catch (error) {
3253
+ console.error(`[skills-library] boot sweep failed: ${error instanceof Error ? error.message : String(error)}`);
3254
+ }
3255
+ }
3136
3256
  // A committed profile cleanup means both its config deletion and bot-reference
3137
3257
  // cleanup were intended to be durable. Reconcile stale secondary references
3138
3258
  // before Electron can ACK and remove the journal: a crash between those writes
@@ -3174,7 +3294,7 @@ const wireTask = (task) => {
3174
3294
  return asleep ? { ...coordinated, snoozedUntil } : coordinated;
3175
3295
  };
3176
3296
  const wireBot = (bot) => {
3177
- const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, ...rest } = bot;
3297
+ const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, assignedSkills: _assignedSkills, ...rest } = bot;
3178
3298
  // An elevated selection is inert until the desktop confirms its exact
3179
3299
  // private reply. Every ordinary client sees the effective Ask state during
3180
3300
  // that two-phase window, never a grant that may still roll back.
@@ -3184,10 +3304,19 @@ const wireBot = (bot) => {
3184
3304
  return { ...visible, waitingForTeammates: activeCoordinationForThread(bot.threadId) && !threadBusy(bot.id, bot.threadId),
3185
3305
  avatarUrl: visible.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
3186
3306
  };
3307
+ function toolScopeForTurn(botId) {
3308
+ const bot = store.bot(botId);
3309
+ if (!bot)
3310
+ throw Object.assign(new Error("no such bot"), { status: 404 });
3311
+ const parsed = parseToolScope(bot.toolScope);
3312
+ if (!parsed.ok)
3313
+ throw Object.assign(new Error("This bot's tool selection is invalid. Open Access settings and save a valid selection before starting a turn."), { status: 409, code: "tool_scope" });
3314
+ return parsed.scope;
3315
+ }
3187
3316
  /** The correlated private response carries the requested value so Electron
3188
3317
  * can validate it before sending the confirmation that makes it effective. */
3189
3318
  const wireTrustedApprovalBot = (bot) => {
3190
- const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, ...rest } = bot;
3319
+ const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, assignedSkills: _assignedSkills, ...rest } = bot;
3191
3320
  return { ...rest, approvalMode: approvalModeFor(rest), avatarUrl: rest.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
3192
3321
  };
3193
3322
  /** A settings-based preview, not a receipt of a dispatched turn. No
@@ -3203,7 +3332,7 @@ function previewSystemPrompt(bot) {
3203
3332
  ]
3204
3333
  .filter(Boolean)
3205
3334
  .join(" ");
3206
- const instance = turnInstance(bot);
3335
+ const instance = registry.get(bot.modelSelection.instanceId);
3207
3336
  const caps = instance?.adapter.capabilities;
3208
3337
  const teamComputer = inheritedTeamComputer(bot);
3209
3338
  const previewComputer = teamComputer ? "cloud" : bot.computer;
@@ -3211,12 +3340,11 @@ function previewSystemPrompt(bot) {
3211
3340
  kind: previewComputer === "vm" ? "vm" : previewComputer === "cloud"
3212
3341
  ? bot.cloudBackend === "vps" ? "vps" : "box" : previewComputer === "local" ? "local" : null,
3213
3342
  driverKind: instance?.driverKind,
3214
- cloudComputerMcp: caps?.cloudComputerMcp,
3215
3343
  vmPrivate: localVmMode(cfg) === "per-bot",
3216
3344
  });
3217
3345
  const peers = reachablePeers(store.bots, bot);
3218
3346
  const coordination = bot.chiefOfStaff
3219
- ? chiefOfStaffSystemPrompt(bot.id, store.bots, true, openMausStatusSystemPrompt())
3347
+ ? chiefOfStaffSystemPrompt(bot.id, store.bots, true)
3220
3348
  : peers.length > 0
3221
3349
  ? peerRosterSystemPrompt(peers)
3222
3350
  : "";
@@ -3252,7 +3380,7 @@ function previewSystemPrompt(bot) {
3252
3380
  browser: previewPlan.computer === undefined ? false : previewPlan.browser,
3253
3381
  }, { note: previewPlan.note, cloudHome: Boolean(CLOUD_HOME) }) },
3254
3382
  { id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(agentsMounted && lendingEnabled()) : "" },
3255
- { id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? composioSystemPrompt(bot.connectorTools) : "" },
3383
+ { id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? composioSystemPrompt(bot.connectorTools) + describeConnectorScopes(bot.connectorScopes) : "" },
3256
3384
  { id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(engineMcpServers(bot))) : "" },
3257
3385
  { id: "browser", label: "Browser", text: previewPlan.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
3258
3386
  { id: "coordination", label: "Team", text: agentsMounted && coordination ? ` ${coordination}` : "" },
@@ -3260,8 +3388,9 @@ function previewSystemPrompt(bot) {
3260
3388
  { id: "routine", label: "Routines", text: agentsMounted ? ROUTINE_PROMPT : "" },
3261
3389
  { id: "profile", label: "Profile changes", text: agentsMounted ? PROFILE_PROMPT : "" },
3262
3390
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
3391
+ { id: "team-memory", label: "Team memory", text: teamMemory.systemPrompt(bot.section) + (agentsMounted ? TEAM_MEMORY_PROMPT : "") },
3263
3392
  { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: agentsMounted, fileTools: Boolean(privateWorkspace), enabled: bot.memoryEnabled !== false }) },
3264
- { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id) : "" },
3393
+ { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id, skillsLibraryEnabled(cfg) ? bot.assignedSkills : undefined) : "" },
3265
3394
  ]);
3266
3395
  const totalBytes = built.sections.reduce((n, s) => n + s.bytes, 0);
3267
3396
  return {
@@ -3326,7 +3455,7 @@ async function botOverview(bot) {
3326
3455
  webhooks: webhooks.list()
3327
3456
  .filter((webhook) => webhook.botId === bot.id)
3328
3457
  .map((webhook) => ({ name: webhook.name, enabled: webhook.enabled })),
3329
- skills: listSkills(bot.id).map((skill) => ({
3458
+ skills: listBotSkills(bot).map((skill) => ({
3330
3459
  name: skill.name,
3331
3460
  description: skill.description,
3332
3461
  enabled: skill.enabled,
@@ -3345,9 +3474,9 @@ async function botOverview(bot) {
3345
3474
  * this too, but no provider dispatch or later permission callback relies on
3346
3475
  * persistence having been produced exclusively by that route. Delegation
3347
3476
  * uses the receiving bot's grant, never the sender's (approvalModeForOrigin) —
3348
- * with one deliberate exception: a Chief of Staff with Full access makes the
3349
- * threads it delegates Full too (delegatedFullAccess), so the grant the
3350
- * person gave the Chief covers the work the Chief hands out. */
3477
+ * with one deliberate exception: a Chief of Staff's level flows down to the
3478
+ * threads it delegates (delegatedLevel), so the level the person gave the
3479
+ * Chief covers the work the Chief hands out. */
3351
3480
  const approvalModeForTurn = (bot, peerInitiated = false, threadId = bot.threadId) => {
3352
3481
  // On a Cloud home a turn a guest drives runs in Ask, whatever the bot's
3353
3482
  // own level. Judged by the conversation the turn runs in (a room's, for a
@@ -3360,51 +3489,62 @@ const approvalModeForTurn = (bot, peerInitiated = false, threadId = bot.threadId
3360
3489
  }
3361
3490
  return mode;
3362
3491
  };
3363
- /** Full belongs to the requesting conversation, not whichever sibling is
3492
+ /** A level belongs to the requesting conversation, not whichever sibling is
3364
3493
  * selected in the UI or the bot's default for future conversations. */
3365
- function fullAccessForSource(botId, threadId) {
3494
+ function sourceApprovalMode(botId, threadId) {
3366
3495
  const owner = connectorThread(botId, threadId);
3367
3496
  if (!owner)
3368
- return false;
3497
+ return "ask";
3369
3498
  const bot = store.projectBotForTask(botId, threadId) ?? owner.bot;
3370
3499
  // Origin changes Custom to Auto, never Full; no live-turn state is needed.
3371
- return approvalModeForTurn(bot, false, threadId) === "full";
3500
+ return approvalModeForTurn(bot, false, threadId);
3501
+ }
3502
+ function fullAccessForSource(botId, threadId) {
3503
+ return sourceApprovalMode(botId, threadId) === "full";
3372
3504
  }
3373
3505
  function peerReviewRequired(bot, threadId) {
3374
3506
  return Boolean(bot.approvePeerComms && !fullAccessForSource(bot.id, threadId));
3375
3507
  }
3376
- /** Full access flows down a Chief of Staff's delegation. The person gave the
3377
- * Chief Full access so its work runs without prompts; a teammate stopping
3378
- * that same work to ask defeats the grant — and in practice the person was
3379
- * answering every one of those cards, all day, for the whole team. So a
3380
- * teammate a Full-access Chief delegates to runs Full for that work: the
3381
- * recipient switches, whatever its own level says. The recipient's engine
3382
- * has to implement Full (supportsApprovalMode); otherwise the work keeps the
3383
- * recipient's own level, as before. Only a Chief passes access on — an
3384
- * ordinary bot's delegation still uses the recipient's setting. */
3385
- function delegatedFullAccess(from, fromThreadId, target) {
3386
- return delegationInheritsFullAccess({
3508
+ /** A Chief of Staff's level flows down its delegation. The person set the
3509
+ * Chief's level so its work runs that way; a teammate the Chief brings in
3510
+ * starting at "Ask for approval" meant switching every such thread by hand,
3511
+ * all day, for the whole team. So a teammate a Chief delegates to starts at
3512
+ * the Chief's level for that work: Auto-accept edits, Approve for me or Full
3513
+ * access (delegatedApprovalMode: never lower than the teammate's own level,
3514
+ * capped at what its engine implements). Only a Chief passes its level on —
3515
+ * an ordinary bot's delegation still uses the recipient's setting. Returns
3516
+ * null to keep the recipient's own level. */
3517
+ function delegatedLevel(from, fromThreadId, target, recipientMode) {
3518
+ return delegatedApprovalMode({
3387
3519
  senderIsChief: Boolean(from.chiefOfStaff),
3388
- senderHasFullAccess: fullAccessForSource(from.id, fromThreadId),
3520
+ senderMode: sourceApprovalMode(from.id, fromThreadId),
3389
3521
  sameBot: from.id === target.id,
3522
+ recipientMode,
3390
3523
  recipientDriverKind: registry.cliTarget(target.modelSelection.instanceId)?.driverKind,
3391
3524
  });
3392
3525
  }
3393
- /** Make a delegated thread Full and say so in it once, so the level the
3394
- * chip shows and the level the turns run at agree, and the person can see
3395
- * where the access came from. */
3396
- function grantDelegatedFullAccess(from, target, threadId) {
3397
- if (store.taskByThread(target.id, threadId)?.approvalMode === "full")
3526
+ /** The teammate's own level for a thread of its own, before any delegation. */
3527
+ function ownThreadLevel(target, threadId) {
3528
+ return approvalModeForTurn(store.projectBotForTask(target.id, threadId) ?? target, false, threadId);
3529
+ }
3530
+ const DELEGATED_LEVEL_NAMES = { edits: "Auto-accept edits", auto: "Approve for me", full: "Full access" };
3531
+ /** Start a thread the Chief opened at the Chief's level, and say so in it
3532
+ * once, so the level the chip shows and the level the turns run at agree,
3533
+ * and the person can see where it came from (and change it there). */
3534
+ function applyDelegatedLevel(from, fromThreadId, target, threadId) {
3535
+ const level = delegatedLevel(from, fromThreadId, target, ownThreadLevel(target, threadId));
3536
+ if (!level || store.taskByThread(target.id, threadId)?.approvalMode === level)
3398
3537
  return;
3399
- store.patchTask(target.id, threadId, { approvalMode: "full", autoApprove: false, alwaysAllow: [] });
3538
+ store.patchTask(target.id, threadId, { approvalMode: level, autoApprove: false, alwaysAllow: [] });
3539
+ const name = DELEGATED_LEVEL_NAMES[level] ?? level;
3400
3540
  store.appendMessage(threadId, {
3401
3541
  role: "bot",
3402
3542
  kind: "activity",
3403
- tool: { name: `Full access — delegated by ${from.name}, a Chief of Staff with Full access`, ok: true },
3543
+ tool: { name: `${name} — delegated by ${from.name}, a Chief of Staff on ${name}`, ok: true },
3404
3544
  });
3405
3545
  }
3406
- /** A room member's level for one turn. Work a Full-access Chief hands out
3407
- * in a room runs Full for that turn: the room thread is shared, so the
3546
+ /** A room member's level for one turn. Work a Chief hands out in a room runs
3547
+ * at the Chief's level for that turn: the room thread is shared, so the
3408
3548
  * level is not stored on it — it rides the handoff. */
3409
3549
  function roomTurnApprovalMode(bot, threadId, orchestration) {
3410
3550
  // On a Cloud home a room turn a guest drives runs in Ask (cloudGuestDriven).
@@ -3413,9 +3553,8 @@ function roomTurnApprovalMode(bot, threadId, orchestration) {
3413
3553
  const handoff = orchestration?.roomHandoffId ? roomHandoffs.nodes.get(orchestration.roomHandoffId) : undefined;
3414
3554
  const source = handoff?.parentId ? roomHandoffs.nodes.get(handoff.parentId) : undefined;
3415
3555
  const from = source ? store.bot(source.botId) : undefined;
3416
- if (from && source && delegatedFullAccess(from, source.threadId, bot))
3417
- return "full";
3418
- return approvalModeForTurn(bot, Boolean(orchestration?.roomHandoffId), threadId);
3556
+ const own = approvalModeForTurn(bot, Boolean(orchestration?.roomHandoffId), threadId);
3557
+ return (from && source && delegatedLevel(from, source.threadId, bot, own)) || own;
3419
3558
  }
3420
3559
  /** Privileged approval-mode transitions are deliberately absent from the
3421
3560
  * loopback HTTP authority model: a bot with shell access can curl that
@@ -4081,6 +4220,7 @@ function updateChannel(groupId, value) {
4081
4220
  const channelTaskBlocked = (group) => groupIsWorking(group) ||
4082
4221
  store.groupTasks(group.id).some((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
4083
4222
  message.card?.requestId &&
4223
+ (!isPersistentQuestionCard(message.card) || runningTurnEngines.has(task.threadId)) &&
4084
4224
  !message.card.answered &&
4085
4225
  !message.card.dismissed && !message.card.expired));
4086
4226
  // Recovery can synchronously emit room changes. Load coordination state
@@ -4153,7 +4293,7 @@ function outstandingAssignmentsPrompt(threadId) {
4153
4293
  assignment: node.text.slice(0, 1_000),
4154
4294
  status: node.status === "queued" ? "waiting for that teammate to be free" : "working on it now",
4155
4295
  }));
4156
- return ` Assignments you already sent are still outstanding, and nothing in this conversation cancelled them: ${JSON.stringify(listed)}. Do not send them again, do not poll or wait for them, and do not tell the user they were lost. Each result returns to this conversation on its own and resumes you then. Answer the message above with that work still in flight.`;
4296
+ 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.`;
4157
4297
  }
4158
4298
  const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
4159
4299
  validate: (node, parent) => roomHandoffProblem(node, parent) ??
@@ -4227,9 +4367,10 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
4227
4367
  }
4228
4368
  // A successful direct work thread closes after its result is reported:
4229
4369
  // folded out of the sidebar, never deleted, and reopened if addressed.
4230
- // Failed or withheld work stays visible.
4370
+ // Failed or withheld work stays visible, and so does a thread whose
4371
+ // next request is already waiting there.
4231
4372
  const childTask = store.taskByThread(child.botId, child.threadId);
4232
- if (!child.groupId && child.status === "completed" && !problem && !childTask?.closedBy
4373
+ if (!child.groupId && child.status === "completed" && !problem && !childTask?.closedBy && !roomHandoffs.activeDirect(child.threadId)
4233
4374
  && childTask?.openedBy?.kind === "work" && childTask.openedBy.botId === parent.botId) {
4234
4375
  store.setTaskClosedBy(child.botId, child.threadId, { botId: parent.botId, name: store.bot(parent.botId)?.name ?? childTask.openedBy.name, at: Date.now() });
4235
4376
  }
@@ -4617,6 +4758,7 @@ function cancelGroupTurnOperations(groupId, threadId, outcome = {
4617
4758
  status: "stopped",
4618
4759
  detail: "Stopped by you.",
4619
4760
  }) {
4761
+ pendingComputerResumes.delete(threadId);
4620
4762
  cancelTeamSetupResumesForThread(threadId);
4621
4763
  roomHandoffs.cancelRoom(groupId, threadId);
4622
4764
  for (const operation of groupTurnOperations.get(groupId) ?? []) {
@@ -5115,7 +5257,7 @@ always, rememberCommand) {
5115
5257
  // An answered question keeps its words. `request.resolved` only records
5116
5258
  // the behavior, so without this the card reads "answer" forever and the
5117
5259
  // person can no longer see what they told the bot.
5118
- if (outcome !== "unavailable" && behavior === "answer" && message && cardMessage) {
5260
+ if (outcome === "answered" && behavior === "answer" && message && cardMessage) {
5119
5261
  const settled = store.messagesFor(threadId).find((m) => m.id === cardMessage.id)?.card;
5120
5262
  if (settled) {
5121
5263
  store.patchMessage(threadId, cardMessage.id, { card: { ...settled, answeredText: message } });
@@ -5147,19 +5289,81 @@ always, rememberCommand) {
5147
5289
  const existing = messageId
5148
5290
  ? thread.find((m) => m.id === messageId)
5149
5291
  : thread.find((m) => m.card?.requestId === requestId);
5150
- if (existing?.card && !existing.card.answered) {
5292
+ const persistentQuestion = isPersistentQuestionCard(existing?.card);
5293
+ if (existing?.card && !existing.card.answered && !persistentQuestion) {
5151
5294
  store.patchMessage(threadId, existing.id, { card: { ...existing.card, answered: "unavailable", dismissed: true } });
5152
5295
  }
5153
5296
  if (messageId)
5154
5297
  askMessageByRequest.delete(`${threadId}:${requestId}`);
5155
- store.appendMessage(threadId, {
5156
- role: "bot",
5157
- kind: "activity",
5158
- tool: { name: "Couldn't deliver that answer — the request is no longer open, so the action was not run", ok: false },
5159
- });
5298
+ // The card's own answered state gates the message too: a replayed respond
5299
+ // for a card that already settled must not read as a failed delivery —
5300
+ // nothing was lost, the earlier answer already ran.
5301
+ if (!persistentQuestion && !existing?.card?.answered)
5302
+ store.appendMessage(threadId, {
5303
+ role: "bot",
5304
+ kind: "activity",
5305
+ tool: { name: "Couldn't deliver that answer — the request is no longer open, so the action was not run", ok: false },
5306
+ });
5160
5307
  }
5161
5308
  return outcome;
5162
5309
  }
5310
+ /** A decision or answer on a provider/peer card, made on behalf of `auth`
5311
+ * (a Live call answers as the person who started the call). Harness-native
5312
+ * proposals (skill, routine, profile, default model, tightening, team setup)
5313
+ * are not handled here; they are reviewed on screen (liveDecisionRefusal).
5314
+ * Mirrors POST /api/threads/:id/respond. */
5315
+ async function respondToCard(input) {
5316
+ const { auth, threadId, requestId, behavior, message } = input;
5317
+ // A call outlives the request that started it: the session must still be valid.
5318
+ if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
5319
+ return { ok: false, status: 401, error: "The session that started this call has ended." };
5320
+ }
5321
+ const refusal = cardAnswerRefusal(auth, threadId, requestId, behavior);
5322
+ if (refusal)
5323
+ return { ok: false, status: 403, error: refusal };
5324
+ const bot = store.botByThread(threadId);
5325
+ if (!bot)
5326
+ return { ok: false, status: 404, error: "The chat is gone." };
5327
+ const cardMessage = store.messagesFor(threadId).find((candidate) => candidate.card?.requestId === requestId);
5328
+ const card = cardMessage?.card;
5329
+ // Refused before anything runs: answerRequest would close a proposal it
5330
+ // cannot deliver as "unavailable", and would add a false "not run" line to
5331
+ // a card someone settled on screen a moment ago.
5332
+ const refused = liveDecisionRefusal(card);
5333
+ if (refused)
5334
+ return { ok: false, status: 409, error: refused };
5335
+ let result = { ok: true };
5336
+ await answeringCardAs(auth, threadId, requestId, async () => {
5337
+ // peer-approval intercept, as the route: only a card on this thread
5338
+ if (card && resolvePeerComms(approvalBus, requestId, behavior))
5339
+ return;
5340
+ const outcome = await answerRequest(threadId, botForThread(bot.id, threadId)?.modelSelection.instanceId ?? "", requestId, behavior, message, { id: bot.id, name: bot.name });
5341
+ if (outcome === "unavailable" && isPersistentQuestionCard(card) && behavior === "answer" && cardMessage) {
5342
+ const answer = message?.trim() ?? "";
5343
+ if (!answer) {
5344
+ result = { ok: false, status: 400, error: "A question answer is required." };
5345
+ }
5346
+ else if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
5347
+ result = { ok: false, status: 401, error: "The session that started this call has ended." };
5348
+ }
5349
+ else {
5350
+ try {
5351
+ await deliverLateQuestionAnswer(auth, threadId, requestId, answer, cardMessage, bot, "call");
5352
+ }
5353
+ catch (error) {
5354
+ result = { ok: false, status: 409, error: error instanceof Error ? error.message : "The late answer could not be queued." };
5355
+ }
5356
+ }
5357
+ }
5358
+ else if (outcome === "unavailable") {
5359
+ result = { ok: false, status: 409, error: "The request is no longer open." };
5360
+ }
5361
+ else if (behavior === "answer" && outcome !== "answered") {
5362
+ result = { ok: false, status: 409, error: "The provider did not accept that answer. Try an offered option." };
5363
+ }
5364
+ }, "call");
5365
+ return result;
5366
+ }
5163
5367
  /** Close every provider-owned approval still open on a thread. Interrupting a
5164
5368
  * turn kills the process that raised its questions, so those cards can never
5165
5369
  * be answered. Routine proposals are harness-owned and durable, so they stay
@@ -5177,6 +5381,12 @@ function closeOpenApprovals(threadId) {
5177
5381
  continue;
5178
5382
  if (card.routineRequest || card.skillRequest)
5179
5383
  continue;
5384
+ if (isPersistentQuestionCard(card)) {
5385
+ // Keep the durable card; its in-flight adapter mapping cannot survive
5386
+ // turn cleanup, and the response route can resume it from the transcript.
5387
+ askMessageByRequest.delete(`${threadId}:${card.requestId}`);
5388
+ continue;
5389
+ }
5180
5390
  store.patchMessage(threadId, message.id, { card: { ...card, answered: "unavailable", dismissed: true } });
5181
5391
  askMessageByRequest.delete(`${threadId}:${card.requestId}`);
5182
5392
  }
@@ -5238,6 +5448,9 @@ const ASK_BOT_TIMEOUT_MS = Math.max(5_000, Number(process.env.OMB_ASK_BOT_TIMEOU
5238
5448
  // free up and reassigns, and a chat round moves on with a chip that says so —
5239
5449
  // the wait ends as data, not as a dead room. Tests shrink it.
5240
5450
  const GROUP_GOAL_WAIT_MAX_MS = Math.max(1_000, Number(process.env.OMB_GOAL_WAIT_MAX_MS) || 30 * 60_000);
5451
+ // ADR-2 (#1651): the computer wait has its own ceiling, decoupled from the
5452
+ // room cap above, and the deadline parks the turn instead of failing it.
5453
+ const COMPUTER_WAIT_MAX_MS = Math.max(1_000, Number(process.env.OMB_COMPUTER_WAIT_MAX_MS) || 30 * 60_000);
5241
5454
  // Reassigning around a busy teammate is bounded too: after this many
5242
5455
  // exhausted waits in one run the team is blocked on availability, not stuck.
5243
5456
  const GROUP_GOAL_MAX_WAIT_EXHAUSTIONS = 3;
@@ -5256,8 +5469,8 @@ const watchdog = new TurnWatchdog({
5256
5469
  const bot = botForThread(turn.botId, turn.threadId);
5257
5470
  const routineRun = activeRoutineRunForThread(turn.threadId);
5258
5471
  const instance = bot
5259
- ? runningTurnInstance(bot, turn.threadId, routineRun?.runOn)
5260
- : routineRun?.runOn === "cloud" ? registry.instances().find((candidate) => candidate.driverKind === "boxAgent") : null;
5472
+ ? runningTurnInstance(bot, turn.threadId)
5473
+ : runningTurnEngines.get(turn.threadId) ?? null;
5261
5474
  void instance?.adapter.interruptTurn(turn.threadId).catch(() => { });
5262
5475
  const minutes = Math.round(TURN_STALL_MS / 60_000);
5263
5476
  if (routineRun?.target === "bot") {
@@ -5328,6 +5541,7 @@ const watchdog = new TurnWatchdog({
5328
5541
  // connector and credential continuations.
5329
5542
  drainQueuedSends();
5330
5543
  drainConnectorResumes();
5544
+ drainComputerResumes();
5331
5545
  drainSecretResumes();
5332
5546
  drainTeamSetupResumes();
5333
5547
  }
@@ -5990,10 +6204,29 @@ async function attachTeamBoat(computer, botId, owner, canMount, remoteAgent) {
5990
6204
  !turnResources.owns(`computer:box:${machine.id}`, owner))
5991
6205
  throw new Error("This computer turn ended while its machine was starting");
5992
6206
  return {
5993
- integration: { kind: "box", boxId: machine.id, token: boat.boatAccount(cfg)?.token ?? "", control: controlIntegration(botId, owner.threadId, owner.generation) },
6207
+ mount: cloudComputerMount(botId, owner, machine.id, remoteAgent),
5994
6208
  capture: () => boat.screenshotBoat(cfg, ownerId, machine.id),
5995
6209
  };
5996
6210
  }
6211
+ /** How a turn's engine reaches a cloud computer. The Computer engine runs its
6212
+ * whole turn on the Boat. Every other engine keeps its own model and sign-in
6213
+ * and gets the Boat as one more stdio computer server (harness-mcp-proxy
6214
+ * computer), in the same slot a Local VM or VPS uses: one mechanism on a
6215
+ * desktop, a headless server and an OMB Cloud. The agent process holds only a
6216
+ * turn-scoped capability naming this Boat, never a Boat credential. */
6217
+ function cloudComputerMount(botId, owner, boxId, remoteAgent) {
6218
+ if (remoteAgent)
6219
+ return { computer: { kind: "box", boxId } };
6220
+ const control = controlIntegration(botId, owner.threadId, owner.generation, undefined, boxId);
6221
+ return {
6222
+ localComputer: {
6223
+ command: process.execPath,
6224
+ args: [SPAWNED_PROXIES.harnessMcp, "computer"],
6225
+ env: { ...AGENTS_NODE_FLAG, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`, OMB_MCP_TOKEN: control.token },
6226
+ platform: "linux",
6227
+ },
6228
+ };
6229
+ }
5997
6230
  /** "Works on: This computer", strict. One mount for a bot thread and a group
5998
6231
  * chat, so the two cannot drift on what they check or how they fail. */
5999
6232
  async function mountHostComputer(owner, botId, providerSupportsLocal) {
@@ -6095,9 +6328,7 @@ async function attachBotBoat(bot, owner, opts) {
6095
6328
  await bindTurnComputer(owner, `computer:box:${machine.id}`, opts.remoteAgent);
6096
6329
  return {
6097
6330
  capture: () => boat.screenshotBoat(cfg, bot.id, machine.id),
6098
- integration: opts.canMount
6099
- ? { kind: "box", boxId: machine.id, token: boat.boatAccount(cfg)?.token ?? "", control: controlIntegration(bot.id, owner.threadId, owner.generation) }
6100
- : null,
6331
+ mount: opts.canMount ? cloudComputerMount(bot.id, owner, machine.id, opts.remoteAgent) : null,
6101
6332
  };
6102
6333
  }
6103
6334
  function managedBoatOwners() {
@@ -6174,17 +6405,6 @@ function turnProvider(bot, runOn, threadId) {
6174
6405
  return "box";
6175
6406
  return bot.cloudBackend === "vps" ? "vps" : wants === "cloud" ? "box" : null;
6176
6407
  }
6177
- /** A driver with a Boat bridge keeps the selected model. Other engines use
6178
- * Boat's native runner. Start and interrupt must resolve the same owner. */
6179
- function turnInstance(bot, runOn, threadId) {
6180
- const selected = registry.get(bot.modelSelection.instanceId);
6181
- if (selected?.adapter.capabilities.cloudComputerMcp)
6182
- return selected;
6183
- const onBoat = turnProvider(bot, runOn, threadId) === "box";
6184
- return onBoat
6185
- ? registry.instances().find((candidate) => candidate.driverKind === "boxAgent") ?? null
6186
- : registry.get(bot.modelSelection.instanceId);
6187
- }
6188
6408
  /** Preview requests carry the selected conversation, not whichever thread
6189
6409
  * happens to be the bot's default. A query never grants lifecycle authority. */
6190
6410
  function computerPreviewBot(botId, url) {
@@ -6252,7 +6472,7 @@ async function selectableComputers(bot) {
6252
6472
  canCreate = Boolean(status?.configured && status.daemonUp && status.container === "missing");
6253
6473
  reason = status?.problem ?? reason;
6254
6474
  }
6255
- else if (boat.boatConfigured(cfg) && (caps?.usesCloudComputer === true || registry.instances().some(instance => instance.driverKind === "boxAgent"))) {
6475
+ else if (boat.boatConfigured(cfg) && caps !== undefined && usesCloudComputer(caps)) {
6256
6476
  const status = await boat.boatStatus(cfg, bot.id);
6257
6477
  const lifecycle = boat.boatTurnLifecycleAction({ explicitCloud: true, canMount: true, state: status.box?.state ?? null });
6258
6478
  ready = lifecycle === "attach";
@@ -6331,8 +6551,9 @@ function continueComputerSelection(threadId, generation, succeeded) {
6331
6551
  /** The engine that dispatched each live turn. A bot's settings may change
6332
6552
  * mid-turn; an interrupt or steer must reach the engine actually running. */
6333
6553
  const runningTurnEngines = new Map();
6334
- function runningTurnInstance(bot, threadId, runOn) {
6335
- return runningTurnEngines.get(threadId) ?? turnInstance(bot, runOn, threadId);
6554
+ function runningTurnInstance(bot, threadId) {
6555
+ // A turn always runs on the bot's own engine, wherever it works.
6556
+ return runningTurnEngines.get(threadId) ?? registry.get(bot.modelSelection.instanceId);
6336
6557
  }
6337
6558
  function providerTransitionForTurn(bot, runOn, threadId) {
6338
6559
  const provider = turnProvider(bot, runOn, threadId);
@@ -6719,8 +6940,23 @@ bus.subscribe((event) => {
6719
6940
  // they cannot keep a quiet seat held.
6720
6941
  if (touches && surface === "computer") {
6721
6942
  const computer = turnComputerResources.get(event.threadId);
6722
- if (computer)
6943
+ if (computer) {
6944
+ // A quiet window cannot expire while this call executes.
6945
+ turnResources.endComputerCall(computer.resource, computer.owner);
6723
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
+ }
6724
6960
  // Real cloud-screen work refreshes the #1655 idle stop for
6725
6961
  // that seat; the poller's own frames never arrive here, so
6726
6962
  // preview traffic cannot keep a paid machine awake.
@@ -6752,6 +6988,26 @@ bus.subscribe((event) => {
6752
6988
  });
6753
6989
  if (event.itemId)
6754
6990
  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
+ }
6755
7011
  }
6756
7012
  break;
6757
7013
  case "request.opened": {
@@ -6872,6 +7128,7 @@ bus.subscribe((event) => {
6872
7128
  subtitle: event.summary,
6873
7129
  options: event.choices?.length ? event.choices : permission ? ["Allow", "Deny"] : [],
6874
7130
  requestId: event.requestId,
7131
+ requestType: permission ? "permission" : "question",
6875
7132
  tool: permission ? event.tool : undefined,
6876
7133
  questionRequest: questions
6877
7134
  ? { version: 1, questions, ...(event.origin === "output" ? { origin: "output" } : {}) }
@@ -6939,9 +7196,15 @@ bus.subscribe((event) => {
6939
7196
  if (messageId) {
6940
7197
  const existing = store.messagesFor(event.threadId).find((m) => m.id === messageId);
6941
7198
  if (existing?.card && !existing.card.answered) {
6942
- store.patchMessage(event.threadId, messageId, {
6943
- card: { ...existing.card, answered: event.behavior, dismissed: event.source !== "user" },
6944
- });
7199
+ if (shouldSettleRequestCard(existing.card, event.source)) {
7200
+ store.patchMessage(event.threadId, messageId, {
7201
+ card: {
7202
+ ...existing.card,
7203
+ answered: event.behavior,
7204
+ dismissed: isPersistentQuestionCard(existing.card) ? false : event.source !== "user",
7205
+ },
7206
+ });
7207
+ }
6945
7208
  }
6946
7209
  if (event.requestId)
6947
7210
  askMessageByRequest.delete(`${event.threadId}:${event.requestId}`);
@@ -7069,9 +7332,13 @@ bus.subscribe((event) => {
7069
7332
  // interrupted the turn; Claude settles that interrupt as
7070
7333
  // exit_before_result, not "interrupted", so this generation's marker
7071
7334
  // keeps the same failure from reporting a second incident.
7335
+ // A turn parked at the computer ceiling (#1651) settles itself the
7336
+ // same way: interrupted for a resume, not broken.
7072
7337
  const lazyClaimAlreadyReported = event.stopReason === "exit_before_result" &&
7073
7338
  turnResourceOwners.get(event.threadId)?.lazyClaimFailureReported === true;
7074
- if (!event.ok && event.stopReason !== "interrupted" && !lazyClaimAlreadyReported && !routines?.runForThread(event.threadId)) {
7339
+ const computerParked = event.stopReason === "exit_before_result" &&
7340
+ turnResourceOwners.get(event.threadId)?.computerParkedOn !== undefined;
7341
+ if (!event.ok && event.stopReason !== "interrupted" && !lazyClaimAlreadyReported && !computerParked && !routines?.runForThread(event.threadId)) {
7075
7342
  const broken = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
7076
7343
  if (broken)
7077
7344
  reportIncident({ kind: "failed", bot: broken, threadId: event.threadId, detail: event.stopReason?.trim() || "the run ended without a result" });
@@ -7101,7 +7368,7 @@ bus.subscribe((event) => {
7101
7368
  if (!isCurrent())
7102
7369
  return;
7103
7370
  if (store.taskByThread(bot.id, event.threadId)?.activity !== "dead") {
7104
- store.setTaskActivity(bot.id, event.threadId, "idle");
7371
+ store.setTaskActivity(bot.id, event.threadId, resourceOwner?.computerParkedOn !== undefined ? "parked.computer" : "idle");
7105
7372
  }
7106
7373
  directTurnBots.delete(event.threadId);
7107
7374
  if (continueComputerSelection(event.threadId, generation, event.ok))
@@ -7112,6 +7379,7 @@ bus.subscribe((event) => {
7112
7379
  retryDelegationsWaitingOn(bot.id);
7113
7380
  drainQueuedSends();
7114
7381
  drainConnectorResumes();
7382
+ drainComputerResumes();
7115
7383
  drainSecretResumes();
7116
7384
  drainTeamSetupResumes();
7117
7385
  drainDelegationWakes();
@@ -7495,7 +7763,7 @@ function markTaskContextExternallyUpdated(bot, threadId) {
7495
7763
  const owner = task.lastInstanceId;
7496
7764
  const record = owner ? task.handedMessages?.[owner] : undefined;
7497
7765
  if (owner && record?.session !== undefined && record.session === task.resumeCursors[owner] &&
7498
- turnInstance(botForThread(bot.id, threadId) ?? bot, undefined, threadId)?.instanceId === owner &&
7766
+ (botForThread(bot.id, threadId) ?? bot).modelSelection.instanceId === owner &&
7499
7767
  registry.get(owner)?.adapter.capabilities.strictResume) {
7500
7768
  store.patchTask(bot.id, threadId, { unread: true });
7501
7769
  return;
@@ -7619,7 +7887,7 @@ function finalizeDelegationWatch(threadId, ok, reply = "", failureName = "Delega
7619
7887
  const source = watched.sourceBotId
7620
7888
  ? store.bot(watched.sourceBotId)
7621
7889
  : (watched.sourceThreadId ? store.botByThread(watched.sourceThreadId) : undefined);
7622
- if (source && target && !canReachPeer(source, target)) {
7890
+ if (!watched.oneWay && source && target && !canReachPeer(source, target)) {
7623
7891
  ok = false;
7624
7892
  reply = "";
7625
7893
  failureName = "Result withheld: team or peer access changed while the teammate was working";
@@ -7637,6 +7905,16 @@ function finalizeDelegationWatch(threadId, ok, reply = "", failureName = "Delega
7637
7905
  result: ok ? reply : failureName,
7638
7906
  });
7639
7907
  }
7908
+ if (watched.oneWay) {
7909
+ if (!ok && store.taskByThread(watched.toBotId, threadId)) {
7910
+ store.appendMessage(threadId, {
7911
+ role: "bot",
7912
+ kind: "activity",
7913
+ tool: { name: `Send failed — ${failureName}`, ok: false, ...(watched.taskId ? { handoffId: watched.taskId } : {}) },
7914
+ });
7915
+ }
7916
+ return true;
7917
+ }
7640
7918
  let channel = watched.channelId ? store.group(watched.channelId) : undefined;
7641
7919
  let terminalThreadId = watched.sourceThreadId;
7642
7920
  if (source && watched.sourceThreadId) {
@@ -7780,7 +8058,7 @@ bus.subscribe((event) => {
7780
8058
  /** How a drained delegation becomes a real turn on the target. Shared by
7781
8059
  * the settle-time drain and the boot-time drain of what a previous process
7782
8060
  * left queued. */
7783
- const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel, taskId, sourceBotId, openedThreadId) => {
8061
+ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel, taskId, sourceBotId, openedThreadId, oneWay) => {
7784
8062
  // startTurn REJECTS on an ordinary condition — busy target, deleted bot,
7785
8063
  // unavailable provider. Unhandled, that rejection is fatal to the
7786
8064
  // harness (Node's default), which in the packaged app kills the server
@@ -7801,7 +8079,7 @@ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel,
7801
8079
  ? { botId: opener.id, name: opener.name, unattended: unattended || undefined }
7802
8080
  : undefined;
7803
8081
  const text = openedThreadId && opener
7804
- ? withPeerProvenance(rawText, { botName: opener.name, delivery: "start_thread", unattended })
8082
+ ? withPeerProvenance(rawText, { botName: opener.name, delivery: oneWay ? "send_to_bot" : "start_thread", unattended })
7805
8083
  : rawText;
7806
8084
  if (targetThreadId) {
7807
8085
  delegationWatch.set(targetThreadId, {
@@ -7813,6 +8091,7 @@ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel,
7813
8091
  sourceBotId,
7814
8092
  routineRunId: activeRoutineRunForThread(sourceThreadId)?.id,
7815
8093
  startedAtMs: Date.now(),
8094
+ ...(oneWay ? { oneWay: true } : {}),
7816
8095
  });
7817
8096
  }
7818
8097
  let failureReported = false;
@@ -7898,8 +8177,7 @@ bus.subscribe((event) => {
7898
8177
  // that turn queued to run anyway, minutes later, on an unrelated turn.
7899
8178
  if (!event.ok)
7900
8179
  discardDelegations(commsBus, event.threadId);
7901
- else
7902
- drainThreadDelegations(event.threadId);
8180
+ drainThreadDelegations(event.threadId);
7903
8181
  // A settling bot frees itself as a delegation TARGET too: handoffs that
7904
8182
  // found it busy earlier were kept queued — waiting until it's free or the
7905
8183
  // 24-hour expiry, not counting retries — on their own source threads, and
@@ -8020,7 +8298,7 @@ function drainAsideLane() {
8020
8298
  }
8021
8299
  /** Keep a person's words off the transcript until a direct-thread slot is
8022
8300
  * available. Reuse the existing cancellable, idempotent composer queue. */
8023
- async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender, trigger) {
8301
+ async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender, trigger, via) {
8024
8302
  const decision = admit("direct", {}, {
8025
8303
  // A room turn holds the bot exactly like the sibling opened-thread queue
8026
8304
  // below: the drain's own block check waits it out, so the words queue
@@ -8038,12 +8316,235 @@ async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId,
8038
8316
  prompt: promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"),
8039
8317
  sender,
8040
8318
  trigger,
8319
+ via,
8041
8320
  });
8042
8321
  return { ok: true, queued: true, queueId: queued.id, threadId, reason: decision.reason };
8043
8322
  }
8044
- const message = await startTurn(botId, text, { threadId, replyTo, sendId, sender, trigger });
8323
+ const message = await startTurn(botId, text, { threadId, replyTo, sendId, sender, trigger, via });
8045
8324
  return { ok: true, threadId, message };
8046
8325
  }
8326
+ /** Refusal from acceptDirectSend: the route turns it into its HTTP answer. */
8327
+ class DirectSendRefused extends Error {
8328
+ status;
8329
+ body;
8330
+ constructor(status, body) {
8331
+ super(typeof body.error === "string" ? body.error : "send refused");
8332
+ this.status = status;
8333
+ this.body = body;
8334
+ }
8335
+ }
8336
+ /** The refusals a direct send meets before it is sequenced, or null. */
8337
+ function directSendRefusal(botId, threadId) {
8338
+ // The send is acknowledged before the turn starts, so a workspace at its
8339
+ // spend limit is refused here, where the person can see it.
8340
+ try {
8341
+ assertWithinBudget(cfg, DATA_DIR);
8342
+ }
8343
+ catch (error) {
8344
+ return new DirectSendRefused(409, { error: error instanceof Error ? error.message : String(error), code: "spend_cap" });
8345
+ }
8346
+ if (!store.taskByThread(botId, threadId)) {
8347
+ return new DirectSendRefused(409, { error: "the bot switched tasks before it could receive the message" });
8348
+ }
8349
+ return null;
8350
+ }
8351
+ /** The one path a person's direct message takes into a bot turn: spend cap,
8352
+ * idempotency, steer into a running turn, queue, or start. Used by
8353
+ * POST /api/bots/:id/messages and by Live calls (via "call").
8354
+ * `guardedStart` is POST /api/bots/:id/messages/guarded's own start, in
8355
+ * place of steer, queue or start: it checks its preconditions against the
8356
+ * task as it stands, then starts a turn or refuses. */
8357
+ async function acceptDirectSend(input, guardedStart) {
8358
+ const { botId, threadId, text, sendId, replyTo, sender, trigger, via, personPresent } = input;
8359
+ const refused = directSendRefusal(botId, threadId);
8360
+ if (refused)
8361
+ throw refused;
8362
+ return sendSequencer.run(sendId ? `bot:${botId}:${threadId}:${sendId}` : undefined, sendFingerprint(text, replyTo?.id), async () => {
8363
+ if (sendId) {
8364
+ if (cancelledChatFollowup("bot", botId, threadId, sendId)) {
8365
+ throw Object.assign(new Error("this queued sendId was cancelled; send a new message to try again"), { status: 409 });
8366
+ }
8367
+ const accepted = acceptedSendMatch(store.messagesFor(threadId), sendId, text, replyTo?.id);
8368
+ if (accepted.kind === "conflict") {
8369
+ throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
8370
+ }
8371
+ if (accepted.kind === "match") {
8372
+ const canonical = {
8373
+ ok: true,
8374
+ threadId,
8375
+ message: accepted.message,
8376
+ };
8377
+ return accepted.message.steered
8378
+ ? { ...canonical, steered: true }
8379
+ : canonical;
8380
+ }
8381
+ const queued = queuedSteeredMessage(botId, threadId, sendId);
8382
+ if (queued) {
8383
+ if (queued.text !== text || queued.replyToId !== replyTo?.id) {
8384
+ throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
8385
+ }
8386
+ return { ok: true, queued: true, queueId: queued.id, threadId, reason: queued.reason };
8387
+ }
8388
+ }
8389
+ const currentAtStart = store.projectBotForTask(botId, threadId);
8390
+ if (!currentAtStart)
8391
+ throw Object.assign(new Error("no such bot"), { status: 404 });
8392
+ if (!store.taskByThread(currentAtStart.id, threadId)) {
8393
+ throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
8394
+ }
8395
+ if (guardedStart)
8396
+ return guardedStart(currentAtStart);
8397
+ // Claude can accept the message inside its live turn. If the write
8398
+ // loses a race with turn settlement, or the engine cannot steer, the
8399
+ // existing server-side queue records it atomically for the next turn.
8400
+ if (currentAtStart.busy) {
8401
+ const instance = runningTurnInstance(currentAtStart, threadId);
8402
+ let steered = "refused";
8403
+ // A live text steer has no image side channel. Keep an attachment
8404
+ // message intact for the next ordinary turn, where central image
8405
+ // admission can hand it to the provider natively.
8406
+ const carriesImages = extractTurnImages(text).images.length > 0;
8407
+ const steerTarget = handoffs.current(threadId);
8408
+ const busyAdmission = admit("direct-busy", {
8409
+ carriesImages,
8410
+ pendingComputerSelection: Boolean(computerSelectionTurns.get(threadId)?.selected),
8411
+ engineCanSteer: Boolean(instance?.adapter.capabilities.queueing && instance.adapter.steer),
8412
+ });
8413
+ // steer was offered only when a live instance could take it;
8414
+ // the second check carries that fact to the type system.
8415
+ if (busyAdmission.action === "steer" && instance?.adapter.steer) {
8416
+ steered = await instance.adapter
8417
+ .steer(threadId, promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"))
8418
+ .catch(() => "indeterminate");
8419
+ }
8420
+ // steer() is awaited adapter work. The turn can settle, the task can
8421
+ // switch, or the whole bot can be deleted before its acknowledgement
8422
+ // arrives. Re-read every ownership invariant before appending even a
8423
+ // successful steer; otherwise that late acknowledgement writes a user
8424
+ // message into a task the bot no longer owns. A conflict leaves the
8425
+ // text in the client's composer/outbox to resend deliberately.
8426
+ const current = store.projectBotForTask(botId, threadId);
8427
+ if (!current)
8428
+ throw Object.assign(new Error("no such bot"), { status: 404 });
8429
+ if (!store.taskByThread(botId, threadId)) {
8430
+ throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
8431
+ }
8432
+ const delivered = steered !== "refused";
8433
+ if (delivered) {
8434
+ if (steered === "steered" && !current.busy) {
8435
+ throw Object.assign(new Error("the running turn ended before the steered message could be recorded"), { status: 409 });
8436
+ }
8437
+ // "indeterminate" falls through to the same record: the words
8438
+ // may already be folded into a turn whose acknowledgement was
8439
+ // lost, and handing them back for a resend could run them
8440
+ // twice. Recording them once is the honest outcome.
8441
+ // A person steering a webhook turn is present, and auto mode may
8442
+ // follow them again; words that do not prove a person never lift it.
8443
+ if (personPresent)
8444
+ clearUnattended(threadId);
8445
+ const message = store.appendMessage(threadId, {
8446
+ role: "user",
8447
+ kind: "text",
8448
+ text,
8449
+ replyToId: replyTo?.id,
8450
+ sendId,
8451
+ steered: true,
8452
+ sender,
8453
+ ...(via ? { via } : {}),
8454
+ });
8455
+ // Offered to the next turn again unless the person stops this one.
8456
+ handoffs.steered(threadId, steerTarget, instance?.instanceId, message.id);
8457
+ return { ok: true, steered: true, threadId, message };
8458
+ }
8459
+ if (!current.busy) {
8460
+ return startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender, trigger, via);
8461
+ }
8462
+ const queued = queueSteeredMessage(current.id, threadId, text, {
8463
+ replyToId: replyTo?.id,
8464
+ sendId,
8465
+ prompt: promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"),
8466
+ sender,
8467
+ trigger,
8468
+ via,
8469
+ });
8470
+ return { ok: true, queued: true, queueId: queued.id, threadId };
8471
+ }
8472
+ return startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender, trigger, via);
8473
+ });
8474
+ }
8475
+ /** A provider can finish its run before the owner answers. Persist that answer
8476
+ * as a normal reply and run/queue it against the original conversation. */
8477
+ async function deliverLateQuestionAnswer(auth, threadId, requestId, answer, cardMessage, owner, via) {
8478
+ const group = store.groupByThread(threadId);
8479
+ const roomTarget = group
8480
+ ? group.dm
8481
+ ? group.threadId === threadId
8482
+ : Boolean(store.groupTaskByThread(group.id, threadId))
8483
+ : false;
8484
+ if (group && (!roomTarget || !group.memberIds.includes(owner.id))) {
8485
+ throw Object.assign(new Error("the bot that asked this question is no longer in this conversation"), { status: 409 });
8486
+ }
8487
+ if (!group && !store.taskByThread(owner.id, threadId)) {
8488
+ throw Object.assign(new Error("the question's conversation no longer exists"), { status: 409 });
8489
+ }
8490
+ const text = group ? `@${owner.name}: ${answer}` : answer;
8491
+ const sendId = `late-question-${createHash("sha256").update(`${threadId}:${requestId}`).digest("hex").slice(0, 32)}`;
8492
+ const key = group
8493
+ ? `group:${group.id}:${threadId}:${sendId}`
8494
+ : `bot:${owner.id}:${threadId}:${sendId}`;
8495
+ const receipt = await sendSequencer.run(key, sendFingerprint(text, cardMessage.id, group ? "chat" : undefined), async () => {
8496
+ const accepted = acceptedSendMatch(store.messagesFor(threadId), sendId, text, cardMessage.id, group ? "chat" : undefined);
8497
+ if (accepted.kind === "conflict")
8498
+ throw Object.assign(new Error("this question already has a different recorded answer"), { status: 409 });
8499
+ if (accepted.kind === "match")
8500
+ return { queued: false };
8501
+ const queuedDirect = group ? null : queuedSteeredMessage(owner.id, threadId, sendId);
8502
+ if (queuedDirect) {
8503
+ if (queuedDirect.text !== text || queuedDirect.replyToId !== cardMessage.id) {
8504
+ throw Object.assign(new Error("this question already has a different queued answer"), { status: 409 });
8505
+ }
8506
+ return { queued: true };
8507
+ }
8508
+ const queuedRoom = group ? queuedChannelMessage(group.id, threadId, sendId) : null;
8509
+ if (queuedRoom) {
8510
+ if (queuedRoom.text !== text || queuedRoom.replyToId !== cardMessage.id || queuedRoom.mode !== "chat") {
8511
+ throw Object.assign(new Error("this question already has a different queued answer"), { status: 409 });
8512
+ }
8513
+ return { queued: true };
8514
+ }
8515
+ const latestCard = store.messagesFor(threadId).find((message) => message.id === cardMessage.id)?.card;
8516
+ if (!latestCard || latestCard.answered || latestCard.dismissed) {
8517
+ throw Object.assign(new Error("this question has already been answered"), { status: 409 });
8518
+ }
8519
+ assertWithinBudget(cfg, DATA_DIR);
8520
+ const sender = messageSender(auth);
8521
+ const trigger = usageTriggerFor(auth);
8522
+ if (group) {
8523
+ const current = store.group(group.id);
8524
+ if (!current || !roomTarget)
8525
+ throw Object.assign(new Error("the room switched tasks before it could receive the answer"), { status: 409 });
8526
+ if (groupIsWorking(current)) {
8527
+ const queued = queueChannelMessage(current.id, threadId, text, {
8528
+ replyToId: cardMessage.id,
8529
+ sendId,
8530
+ mode: "chat",
8531
+ sender,
8532
+ trigger,
8533
+ });
8534
+ return { queued: true, queueId: queued.id };
8535
+ }
8536
+ startGroupTurn(current.id, text, cardMessage, sendId, "chat", undefined, { threadId, sender, trigger });
8537
+ return { queued: false };
8538
+ }
8539
+ const direct = await startOrQueueDirectMessage(owner.id, threadId, text, cardMessage, sendId, sender, trigger, via);
8540
+ return { queued: "queued" in direct && Boolean(direct.queued) };
8541
+ });
8542
+ const latest = store.messagesFor(threadId).find((message) => message.id === cardMessage.id)?.card;
8543
+ if (latest && !latest.answered && !latest.dismissed) {
8544
+ store.patchMessage(threadId, cardMessage.id, { card: { ...latest, answered: "answer", answeredText: answer, dismissed: false } });
8545
+ }
8546
+ return receipt;
8547
+ }
8047
8548
  /** How many start_thread calls one turn may make. Same spirit as the
8048
8549
  * create-bot ceiling above: a handful is a plan, more is a fan-out. */
8049
8550
  const MAX_THREADS_OPENED_PER_TURN = 5;
@@ -8320,15 +8821,14 @@ async function startTurn(botId, text, opts) {
8320
8821
  if (hostedModels && !hostedModels.allows(bot.modelSelection))
8321
8822
  throw Object.assign(new Error(hostedModels.error()), { status: 409 });
8322
8823
  const plan = turnSurfacePlan(bot, opts?.runOn, threadId);
8323
- const instance = turnInstance(bot, opts?.runOn, threadId);
8824
+ const instance = registry.get(bot.modelSelection.instanceId);
8324
8825
  if (!instance) {
8325
- throw Object.assign(new Error(turnProvider(bot, opts?.runOn, threadId) === "box"
8326
- ? "the Cloud VM runner is unavailable — configure Boat in App Settings"
8327
- : `provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`), { status: 409 });
8826
+ throw Object.assign(new Error(`provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`), { status: 409 });
8328
8827
  }
8329
8828
  const policyRefusal = policyModelRefusal(instance);
8330
8829
  if (policyRefusal)
8331
8830
  throw Object.assign(new Error(policyRefusal), { status: 409, code: "managed_policy" });
8831
+ const toolScope = toolScopeForTurn(bot.id);
8332
8832
  // On a Cloud home a guest's turn never gets a shell or reads outside its
8333
8833
  // own folder (docs/cloud-pro.md): an engine that cannot run it that way is
8334
8834
  // refused, in one plain line, before anything is recorded.
@@ -8371,18 +8871,24 @@ async function startTurn(botId, text, opts) {
8371
8871
  .catch(() => undefined);
8372
8872
  }
8373
8873
  }
8374
- console.error(`[omb-turn] bot=${botId} text=${JSON.stringify(resolvedImages.text.slice(0, 70))} images=${turnImages.length} depth=${commsDepth} card=${Boolean(opts?.cardContinuation)}`);
8874
+ // Spoken words never reach the log. A direct send says so itself; a
8875
+ // drained queue (or a continuation) carries it on the user lines it runs.
8876
+ const turnLineIds = new Set(opts?.excludeMessageIds ?? []);
8877
+ const spoken = opts?.via === "call" || opts?.userMessage?.via === "call" ||
8878
+ (turnLineIds.size > 0 && store.messagesFor(threadId).some((message) => turnLineIds.has(message.id) && message.via === "call"));
8879
+ console.error(turnStartLogLine({
8880
+ botId, text: resolvedImages.text, images: turnImages.length, depth: commsDepth, card: Boolean(opts?.cardContinuation), spoken,
8881
+ }));
8375
8882
  const instanceId = instance.instanceId;
8376
8883
  if (providerInstancesChanging.has(instanceId)) {
8377
8884
  throw Object.assign(new Error("this provider account is being updated — try again shortly"), { status: 409 });
8378
8885
  }
8379
- const switchedEngine = instance.instanceId !== bot.modelSelection.instanceId;
8380
- const useInstanceDefaults = switchedEngine || (opts?.runOn === "cloud" && !instance.adapter.capabilities.cloudComputerMcp);
8381
- const model = useInstanceDefaults ? instance.models.default : bot.modelSelection.model;
8382
- // A native cloud runner borrows its instance defaults; a Boat bridge keeps
8383
- // the bot's selected model, effort and variant.
8384
- const effort = useInstanceDefaults ? undefined : bot.modelSelection.effort;
8385
- const variant = useInstanceDefaults ? undefined : bot.modelSelection.variant;
8886
+ // A turn always runs on the bot's own engine, model, effort and variant,
8887
+ // wherever it works: a cloud computer is a tool it mounts, never a reason
8888
+ // to swap the engine (the provider_not_configured incident).
8889
+ const model = bot.modelSelection.model;
8890
+ const effort = bot.modelSelection.effort;
8891
+ const variant = bot.modelSelection.variant;
8386
8892
  assertModelVariantSupported({ variant, effort }, instance.adapter.capabilities);
8387
8893
  // A selection can be persisted while its engine is offline. Re-check when
8388
8894
  // the engine returns so an old or unsupported value never reaches a CLI.
@@ -8409,6 +8915,8 @@ async function startTurn(botId, text, opts) {
8409
8915
  sendId: opts?.sendId,
8410
8916
  peerAsk: opts?.peerAsk,
8411
8917
  sender: opts?.sender,
8918
+ ...(opts?.via ? { via: opts.via } : {}),
8919
+ ...(opts?.relayed ? { relayed: true } : {}),
8412
8920
  });
8413
8921
  }
8414
8922
  const recoveryUserMessageId = opts?.coordination
@@ -8523,6 +9031,7 @@ async function startTurn(botId, text, opts) {
8523
9031
  retryDelegationsWaitingOn(bot.id);
8524
9032
  drainQueuedSends();
8525
9033
  drainConnectorResumes();
9034
+ drainComputerResumes();
8526
9035
  drainSecretResumes();
8527
9036
  drainTeamSetupResumes();
8528
9037
  drainDelegationWakes();
@@ -8703,24 +9212,23 @@ async function startTurn(botId, text, opts) {
8703
9212
  }
8704
9213
  const privateWorkspace = worksInWorkspace ? ensureTaskWorkspace(bot.id, threadId) : undefined;
8705
9214
  const skillInstructions = renderSkillInstructions(selectedSkills, {
8706
- includeRoot: worksInWorkspace && opts?.runOn !== "cloud",
9215
+ includeRoot: worksInWorkspace,
8707
9216
  });
8708
9217
  const packagePlaybooks = installedPlaybookInstructions(providerText, bot.playbooks);
8709
9218
  // An explicit working folder wins for new tasks; otherwise they use
8710
9219
  // the private bot workspace. A legacy task with an existing provider
8711
9220
  // session deliberately pins to null (the old home-folder behavior),
8712
9221
  // because moving a live session would break resume.
8713
- // A cloud run happens on the boat, where a host folder means nothing:
8714
- // pin the task to the default so the header chip never shows the
8715
- // bot's folder for a task that runs elsewhere.
8716
- if (opts?.runOn === "cloud")
8717
- store.pinTaskCwd(bot.id, threadId, undefined, { none: true });
9222
+ // A cloud run keeps the bot's own engine on this host, with the cloud
9223
+ // computer as one more tool, so it works in the same folder as any
9224
+ // other turn (only the Computer engine runs on the Boat, and it has no
9225
+ // workspace files).
8718
9226
  // On a Cloud home a conversation a guest opened is pinned to its own
8719
9227
  // folder when it first runs, not the bot's project folder the owner's
8720
9228
  // conversations share (what it leaves there reaches none of them). One
8721
9229
  // that ran before keeps its pin, unless the session that opened it was
8722
9230
  // revoked: then it was unpinned at boot (server/cloud-owner.ts).
8723
- const pinnedCwd = privateWorkspace && opts?.runOn !== "cloud"
9231
+ const pinnedCwd = privateWorkspace
8724
9232
  ? store.pinTaskCwd(bot.id, threadId, privateWorkspace, { privateOnly: cloudGuestOpened(threadId) })
8725
9233
  : null;
8726
9234
  const cwd = pinnedCwd ?? undefined;
@@ -8737,8 +9245,8 @@ async function startTurn(botId, text, opts) {
8737
9245
  const dwebUrl = process.env.DWEB_URL?.trim();
8738
9246
  if (dwebUrl)
8739
9247
  integrations.dweb = { url: dwebUrl };
8740
- // Cloud routines always use Boat/BoatAgent. The per-bot backend applies
8741
- // only to ordinary turns that mount a computer into the local agent.
9248
+ // Cloud routines always use the bot's Boat. The per-bot backend applies
9249
+ // only to ordinary turns.
8742
9250
  const teamComputer = inheritedTeamComputer(bot);
8743
9251
  const cloudBackend = teamComputer || opts?.runOn === "cloud" || bot.cloudBackend !== "vps" ? "box" : "vps";
8744
9252
  const mountsComputerMcp = instance.adapter.capabilities.computerMcp === true;
@@ -8766,6 +9274,20 @@ async function startTurn(botId, text, opts) {
8766
9274
  const placeRefusal = wantedKind && computerPlaceRefusal(wantedKind);
8767
9275
  if (placeRefusal)
8768
9276
  throw Object.assign(new Error(placeRefusal.message), { status: 409, code: placeRefusal.code });
9277
+ // Where an explicit place came from, so its failure names the one
9278
+ // control that changes it (and a failed Auto-recorded pin is cleared).
9279
+ // Auto and a team's shared computer are nobody's per-turn choice.
9280
+ const placeSource = teamComputer || wants === undefined || wants === "off" ? undefined
9281
+ : opts?.runOn === "cloud" ? "routine"
9282
+ : plan.pinned ? (dispatchTask?.surfaceSource === "auto" ? "auto-pin" : "pin")
9283
+ : "works-on";
9284
+ // An engine that cannot use the cloud computer is refused here, before
9285
+ // anything is created or woken — never handed to another engine.
9286
+ if (wants === "cloud" && placeSource) {
9287
+ const unsupported = cloudPlaceDriverError(instance.adapter.capabilities, cloudBackend, placeSource);
9288
+ if (unsupported)
9289
+ throw new PlaceUnavailableError("cloud", placeSource, unsupported);
9290
+ }
8769
9291
  let previewCapture = null;
8770
9292
  let browserCapture = null;
8771
9293
  let computerKind = null;
@@ -8893,6 +9415,22 @@ async function startTurn(botId, text, opts) {
8893
9415
  const surfaceLazyClaimRejection = (label) => (failure) => {
8894
9416
  if (activeInternalGenerationByThread.get(threadId) !== resourceOwner.generation || !threadBusy(bot.id, threadId))
8895
9417
  return;
9418
+ // The wait ceiling parked this turn (#1651): register the resume and
9419
+ // settle the live turn. No error entry, no incident — the parked chip
9420
+ // the wait appended is the whole story. The slot still fails, so the
9421
+ // gate keeps refusing screen calls for the rest of this generation
9422
+ // while the interrupt lands.
9423
+ if (resourceOwner.computerParkedOn) {
9424
+ registerComputerResume({
9425
+ botId: bot.id,
9426
+ threadId,
9427
+ resource: resourceOwner.computerParkedOn,
9428
+ generation: resourceOwner.generation,
9429
+ afterMessageId: store.activePath(threadId).findLast((entry) => entry.role === "user")?.id,
9430
+ });
9431
+ void interruptDirectThread(bot.id, threadId, { preserveComputerResume: true }).catch(() => { });
9432
+ return;
9433
+ }
8896
9434
  const message = `computer unavailable — ${label} could not be claimed for this turn (${failure})`;
8897
9435
  store.appendMessage(threadId, {
8898
9436
  role: "bot",
@@ -8993,31 +9531,33 @@ async function startTurn(botId, text, opts) {
8993
9531
  return true;
8994
9532
  }
8995
9533
  catch (error) {
8996
- if (strict)
9534
+ // A wait ceiling settled this turn as parked, not as an unavailable
9535
+ // Auto destination. Let dispatch register its resume instead of
9536
+ // continuing the provider without the requested computer.
9537
+ if (strict || error instanceof ComputerWaitParked)
8997
9538
  throw error;
8998
9539
  if (directTurnGenerationByThread.get(threadId) === dispatchClaimId)
8999
9540
  releaseLocalVmThread(threadId);
9000
9541
  return false;
9001
9542
  }
9002
9543
  };
9003
- if (wants === "vm") {
9004
- if (await attachLocalVm(true))
9005
- computerKind = "vm";
9006
- }
9007
- else if (wants === "local") {
9008
- integrations.localComputer = await mountHostComputer(resourceOwner, bot.id, mountsLocalComputer);
9009
- computerKind = "local";
9010
- }
9011
- // A VPS is a local-agent computer mount, never a remote agent runner.
9012
- // Explicit Cloud may prepare/start it. Auto remains read-only unless
9013
- // the person explicitly opted this bot into remote lifecycle actions.
9014
- if ((wants === "cloud" || (wants === undefined && managedPolicy.computerAllowed("vps"))) && cloudBackend === "vps") {
9015
- const unsupported = vps.vpsDriverError(instance.driverKind, mountsComputerMcp);
9016
- if (unsupported && wants === "cloud")
9017
- throw new Error(unsupported);
9018
- if (unsupported && wants === undefined)
9019
- autoVpsProblem = unsupported;
9020
- if (!unsupported) {
9544
+ // An explicit place that cannot be attached fails the turn with its
9545
+ // cause and the next action; it never quietly moves the work elsewhere.
9546
+ try {
9547
+ if (wants === "vm") {
9548
+ if (await attachLocalVm(true))
9549
+ computerKind = "vm";
9550
+ }
9551
+ else if (wants === "local") {
9552
+ integrations.localComputer = await mountHostComputer(resourceOwner, bot.id, mountsLocalComputer);
9553
+ computerKind = "local";
9554
+ }
9555
+ // A VPS is a local-agent computer mount, never a remote agent runner.
9556
+ // Explicit Cloud may prepare/start it. Auto remains read-only unless
9557
+ // the person explicitly opted this bot into remote lifecycle actions.
9558
+ // On Auto an engine that cannot use a computer simply skips it.
9559
+ if ((wants === "cloud" || (wants === undefined && managedPolicy.computerAllowed("vps"))) && cloudBackend === "vps" &&
9560
+ !cloudPlaceDriverError(instance.adapter.capabilities, "vps")) {
9021
9561
  // The VPS "computer" is the desktop inside this bot's managed
9022
9562
  // container, and only screen work needs that desktop to itself.
9023
9563
  // So the lease is claimed on the first computer call, through the
@@ -9055,34 +9595,40 @@ async function startTurn(botId, text, opts) {
9055
9595
  autoVpsProblem = mounted.problem ?? "the VPS computer could not be reached";
9056
9596
  }
9057
9597
  }
9058
- }
9059
- // Cloud is strict when selected. Native Boat and drivers with a Boat
9060
- // bridge may also reuse an already-ready Boat on Auto.
9061
- if (teamComputer) {
9062
- const attached = await attachTeamBoat(teamComputer, bot.id, resourceOwner, instance.adapter.capabilities.usesCloudComputer === true, instance.adapter.capabilities.remoteAgent === true);
9063
- integrations.computer = attached.integration;
9064
- previewCapture = attached.capture;
9065
- computerKind = "box";
9066
- }
9067
- if (!teamComputer && instance.adapter.capabilities.usesCloudComputer === true && (wants === "cloud" || wants === undefined) && cloudBackend === "box" && boat.boatConfigured(cfg)) {
9068
- const attached = await attachBotBoat(bot, resourceOwner, {
9069
- explicitCloud: wants === "cloud",
9070
- canMount: instance.adapter.capabilities.usesCloudComputer === true,
9071
- remoteAgent: instance.adapter.capabilities.remoteAgent === true,
9072
- });
9073
- if (attached) {
9598
+ // Cloud is strict when selected. Every engine that can use it may also
9599
+ // reuse an already-ready Boat on Auto.
9600
+ if (teamComputer) {
9601
+ const attached = await attachTeamBoat(teamComputer, bot.id, resourceOwner, usesCloudComputer(instance.adapter.capabilities), instance.adapter.capabilities.remoteAgent === true);
9602
+ Object.assign(integrations, attached.mount);
9074
9603
  previewCapture = attached.capture;
9075
- if (attached.integration) {
9076
- integrations.computer = attached.integration;
9077
- computerKind = "box";
9604
+ computerKind = "box";
9605
+ }
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
+ });
9612
+ if (attached) {
9613
+ previewCapture = attached.capture;
9614
+ if (attached.mount) {
9615
+ Object.assign(integrations, attached.mount);
9616
+ computerKind = "box";
9617
+ }
9078
9618
  }
9079
9619
  }
9620
+ if (wants === "cloud" && cloudBackend === "box" && !boat.boatConfigured(cfg)) {
9621
+ throw new Error(BOAT_NOT_CONFIGURED);
9622
+ }
9623
+ if (wants === "cloud" && cloudBackend === "box" && computerKind !== "box") {
9624
+ throw new Error("the cloud computer could not be created or reached");
9625
+ }
9080
9626
  }
9081
- if (wants === "cloud" && cloudBackend === "box" && !boat.boatConfigured(cfg)) {
9082
- throw new Error(BOAT_NOT_CONFIGURED);
9083
- }
9084
- if (wants === "cloud" && cloudBackend === "box" && !integrations.computer) {
9085
- throw new Error("the cloud computer could not be created or reached");
9627
+ catch (error) {
9628
+ if (!placeSource || wants === undefined || wants === "off" || error instanceof DirectTurnSetupCancelled ||
9629
+ error instanceof ComputerWaitParked || error instanceof PlaceUnavailableError)
9630
+ throw error;
9631
+ throw placeUnavailable(wants, placeSource, error instanceof Error ? error.message : String(error));
9086
9632
  }
9087
9633
  // Auto-only host fallback. Electron owns cua-driver/TCC attribution;
9088
9634
  // the harness only reads its already-running connection descriptor.
@@ -9173,7 +9719,7 @@ async function startTurn(botId, text, opts) {
9173
9719
  ? mentionedBots(providerText, sectionPeers)
9174
9720
  : [];
9175
9721
  const coordinationPrompt = bot.chiefOfStaff
9176
- ? chiefOfStaffSystemPrompt(bot.id, store.bots, Boolean(integrations.agents), openMausStatusSystemPrompt(), boundedCoordination)
9722
+ ? chiefOfStaffSystemPrompt(bot.id, store.bots, Boolean(integrations.agents), boundedCoordination)
9177
9723
  : integrations.agents && sectionPeers.length > 0
9178
9724
  // Ordinary bots could always CALL the peer tools; until now the
9179
9725
  // one generic sentence they got never named a teammate, so the
@@ -9282,7 +9828,6 @@ async function startTurn(botId, text, opts) {
9282
9828
  const computerPromptKind = resolveComputerPromptKind({
9283
9829
  kind: computerKind,
9284
9830
  driverKind: instance.driverKind,
9285
- cloudComputerMcp: instance.adapter.capabilities.cloudComputerMcp,
9286
9831
  vmPrivate: localVmMode(cfg) === "per-bot",
9287
9832
  });
9288
9833
  const coordinationNode = opts?.coordination ? roomHandoffs.nodes.get(opts.coordination.id) : undefined;
@@ -9295,14 +9840,14 @@ async function startTurn(botId, text, opts) {
9295
9840
  // to a turn whose engine actually mounted them (setupMode is already
9296
9841
  // false when they are not — see agentsMounted above)
9297
9842
  { id: "setup", label: "Setup", text: setupSystemPrompt(setupMode, { skills: skillAuthoring, cwd: liveBot?.cwd ?? bot.cwd }) },
9298
- { id: "files", label: "File locations", text: worksInWorkspace && opts?.runOn !== "cloud" ? workspaceLocationsPrompt(bot.id, cwd, liveBot?.cwd ?? bot.cwd) : "" },
9843
+ { id: "files", label: "File locations", text: worksInWorkspace ? workspaceLocationsPrompt(bot.id, cwd, liveBot?.cwd ?? bot.cwd) : "" },
9299
9844
  { id: "computer", label: "Computer", text: computerPrompt(computerPromptKind) },
9300
9845
  { id: "team-computer", label: "Team computer", text: teamComputerPrompt(teamComputer) },
9301
9846
  { id: "plan", label: "Surface", text: surfacePrompt({ computer: mountedComputer, browser: Boolean(integrations.browser) }, { pinned: plan.pinned, note: plan.note, canSelect: computerSelectionTurns.has(threadId), cloudHome: Boolean(CLOUD_HOME) }) },
9302
9847
  { id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
9303
9848
  // gated on the integration, not the key: the hint only goes to a
9304
9849
  // bot whose driver actually mounted the tools
9305
- { id: "composio", label: "Connected apps", text: integrations.composio ? composioSystemPrompt(liveBot?.connectorTools ?? bot.connectorTools) : "" },
9850
+ { id: "composio", label: "Connected apps", text: integrations.composio ? composioSystemPrompt(liveBot?.connectorTools ?? bot.connectorTools) + describeConnectorScopes((liveBot ?? bot).connectorScopes) : "" },
9306
9851
  { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
9307
9852
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
9308
9853
  { id: "coordination", label: "Team", text: coordinationPrompt ? ` ${coordinationPrompt}` : "" },
@@ -9315,11 +9860,14 @@ async function startTurn(botId, text, opts) {
9315
9860
  { id: "profile", label: "Profile changes", text: profilePrompt },
9316
9861
  { id: "learn", label: "Skill authoring", text: learnPrompt },
9317
9862
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
9863
+ { id: "team-memory", label: "Team memory", text: teamMemory.systemPrompt(bot.section) + (integrations.agents ? TEAM_MEMORY_PROMPT : "") },
9318
9864
  // what the bot said lately in its other conversations, so a task
9319
9865
  // never redoes — or forgets — what another one already did
9320
9866
  { id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(recentWorkSources(bot), bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId, ...recentWorkFilter() })) },
9321
9867
  { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace, enabled: bot.memoryEnabled !== false }) },
9322
- { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id) : "" },
9868
+ // liveBot was captured before awaited setup work; an assignment PUT
9869
+ // in that window must still reach this turn's prompt.
9870
+ { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id, skillsLibraryEnabled(cfg) ? (store.bot(bot.id)?.assignedSkills ?? bot.assignedSkills) : undefined) : "" },
9323
9871
  { id: "skill-instructions", label: "Skill instructions", text: skillInstructions },
9324
9872
  { id: "playbooks", label: "Playbooks", text: packagePlaybooks },
9325
9873
  { id: "webhook", label: "Webhook provenance", text: opts?.automationSource === "webhook" ? WEBHOOK_PROMPT : "" },
@@ -9358,11 +9906,12 @@ async function startTurn(botId, text, opts) {
9358
9906
  const dispatch = await guardTurnDispatch(instance.adapter.sendTurn({
9359
9907
  threadId,
9360
9908
  botId: bot.id,
9361
- startupRecovery: cfg.automaticRecovery?.enabled === true && !opts?.automaticRecoveryAttempted,
9909
+ startupRecovery: cfg.automaticRecovery?.enabled === true &&
9910
+ (opts?.automaticRecoveryIndex ?? 0) < ((liveBot ?? bot).fallback?.length || (cfg.automaticRecovery.backup ? 1 : 0)),
9362
9911
  text: withRecalled(recalled, dispatchContext.turnText),
9363
- refreshSystemPrompt: true,
9364
9912
  images: turnImages,
9365
9913
  approvalMode: approvalModeForTurn(bot, commsDepth > 0, threadId),
9914
+ toolScope,
9366
9915
  ...(guestConfined ? { guestConfined: true, confinedWhy: confinedWhy(threadId) } : {}),
9367
9916
  model,
9368
9917
  effort,
@@ -9437,6 +9986,7 @@ async function startTurn(botId, text, opts) {
9437
9986
  retryDelegationsWaitingOn(bot.id);
9438
9987
  drainQueuedSends();
9439
9988
  drainConnectorResumes();
9989
+ drainComputerResumes();
9440
9990
  drainSecretResumes();
9441
9991
  drainTeamSetupResumes();
9442
9992
  drainDelegationWakes();
@@ -9482,6 +10032,7 @@ async function startTurn(botId, text, opts) {
9482
10032
  if (ownsLatestGeneration) {
9483
10033
  drainQueuedSends();
9484
10034
  drainConnectorResumes();
10035
+ drainComputerResumes();
9485
10036
  drainSecretResumes();
9486
10037
  drainTeamSetupResumes();
9487
10038
  drainDelegationWakes();
@@ -9492,10 +10043,51 @@ async function startTurn(botId, text, opts) {
9492
10043
  settleDirectFollowup(dispatchClaimId);
9493
10044
  return;
9494
10045
  }
10046
+ if (e instanceof ComputerWaitParked) {
10047
+ settleDirectFollowup(dispatchClaimId, { ok: false, text: e.message });
10048
+ registerComputerResume({
10049
+ botId: bot.id,
10050
+ threadId,
10051
+ resource: e.resource,
10052
+ generation: e.generation,
10053
+ afterMessageId: store.activePath(threadId).findLast((entry) => entry.role === "user")?.id,
10054
+ });
10055
+ // Settled as parked, not failed (#1651): the chip the wait appended
10056
+ // is the whole transcript story, and the registered resume owns what
10057
+ // happens next on this thread.
10058
+ if (store.taskByThread(bot.id, threadId)?.activity !== "dead") {
10059
+ store.setTaskActivity(bot.id, threadId, "parked.computer");
10060
+ }
10061
+ directTurnBots.delete(threadId);
10062
+ retryDelegationsWaitingOn(bot.id);
10063
+ drainQueuedSends();
10064
+ drainConnectorResumes();
10065
+ drainComputerResumes();
10066
+ drainSecretResumes();
10067
+ drainTeamSetupResumes();
10068
+ drainDelegationWakes();
10069
+ return;
10070
+ }
10071
+ // A failed place never sticks. An Auto-recorded pin to it (a
10072
+ // select_computer switch, or where an earlier Auto turn landed) is the
10073
+ // machine's memory, not a choice: clear it so the next message runs on
10074
+ // Auto. A person's pin stays, and the message names the composer.
10075
+ if (e instanceof PlaceUnavailableError) {
10076
+ const pinned = store.taskByThread(bot.id, threadId);
10077
+ if (pinned?.surface === e.place && pinned.surfaceSource === "auto") {
10078
+ store.patchTask(bot.id, threadId, { surface: undefined, surfaceSource: undefined });
10079
+ }
10080
+ }
9495
10081
  let message = e instanceof Error ? e.message : String(e);
9496
- const backup = cfg.automaticRecovery?.enabled ? cfg.automaticRecovery.backup : undefined;
9497
10082
  const current = store.projectBotForTask(bot.id, threadId);
9498
- if (mayRecover && backup && current && !opts?.automaticRecoveryAttempted && (!opts?.cardContinuation || opts.coordination) &&
10083
+ const backups = cfg.automaticRecovery?.enabled
10084
+ ? current?.fallback?.length ? current.fallback : cfg.automaticRecovery.backup ? [cfg.automaticRecovery.backup] : []
10085
+ : [];
10086
+ const nextIndex = backups.findIndex((candidate, index) => index >= (opts?.automaticRecoveryIndex ?? 0) &&
10087
+ registry.get(candidate.instanceId)?.enabled &&
10088
+ (candidate.instanceId !== instanceId || candidate.model !== model));
10089
+ const backup = backups[nextIndex];
10090
+ if (mayRecover && backup && current && (!opts?.cardContinuation || opts.coordination) &&
9499
10091
  !directRequestOwners.get(threadId)?.stopped && !hasQueuedSteeredMessages(bot.id, threadId) &&
9500
10092
  JSON.stringify(current.modelSelection) === JSON.stringify(bot.modelSelection) &&
9501
10093
  (backup.instanceId !== instanceId || backup.model !== model) &&
@@ -9519,7 +10111,7 @@ async function startTurn(botId, text, opts) {
9519
10111
  name: `recovery: Automatic recovery: ${instance.displayName || instance.driverKind} could not start. Trying ${target.displayName || target.driverKind} · ${checked.selection.model} once in this thread.`, ok: true,
9520
10112
  } });
9521
10113
  await startTurn(bot.id, text, { ...opts, threadId, userMessage, editedMessageId: undefined,
9522
- automaticRecoveryAttempted: true });
10114
+ automaticRecoveryAttempted: true, automaticRecoveryIndex: nextIndex + 1 });
9523
10115
  // The backup now owns these callbacks; don't publish a failed
9524
10116
  // routine/peer result or release a queued receipt in between.
9525
10117
  directFollowupSettlers.delete(dispatchClaimId);
@@ -9536,13 +10128,12 @@ async function startTurn(botId, text, opts) {
9536
10128
  settleDirectFollowup(dispatchClaimId, { ok: false, text: message });
9537
10129
  // The wait already wrote its failure resolution; keep all dispatch
9538
10130
  // failure bookkeeping below without adding the same error twice.
9539
- if (!(e instanceof ComputerWaitGaveUp))
9540
- store.appendMessage(threadId, {
9541
- role: "bot",
9542
- kind: "activity",
9543
- turnSucceeded: false,
9544
- tool: { name: `error: ${message.slice(0, 160)}`, ok: false },
9545
- });
10131
+ store.appendMessage(threadId, {
10132
+ role: "bot",
10133
+ kind: "activity",
10134
+ turnSucceeded: false,
10135
+ tool: { name: `error: ${message.slice(0, 160)}`, ok: false },
10136
+ });
9546
10137
  // Worth a buzz for the same reason a routine failure is, and the rule
9547
10138
  // notify.ts encodes: the bot is not working, and the cause is usually
9548
10139
  // a setting only a person can change — an unattended user would
@@ -9565,6 +10156,7 @@ async function startTurn(botId, text, opts) {
9565
10156
  // drain would strand anything queued behind this turn
9566
10157
  drainQueuedSends();
9567
10158
  drainConnectorResumes();
10159
+ drainComputerResumes();
9568
10160
  drainSecretResumes();
9569
10161
  drainTeamSetupResumes();
9570
10162
  drainDelegationWakes();
@@ -9859,15 +10451,15 @@ routines = new RoutineManager({
9859
10451
  goalRunId: runId,
9860
10452
  });
9861
10453
  },
9862
- interruptTurn: async (botId, threadId, runOn) => {
10454
+ interruptTurn: async (botId, threadId) => {
9863
10455
  const bot = botForThread(botId, threadId);
9864
10456
  pendingDelegationWakes.delete(threadId);
9865
10457
  discardDelegations(commsBus, threadId);
9866
10458
  cancelDirectTurnDispatch(botId, threadId);
9867
10459
  revokeInternalCapabilitiesForThread(threadId);
9868
10460
  const instance = bot
9869
- ? runningTurnInstance(bot, threadId, runOn)
9870
- : runOn === "cloud" ? registry.instances().find((candidate) => candidate.driverKind === "boxAgent") ?? null : null;
10461
+ ? runningTurnInstance(bot, threadId)
10462
+ : runningTurnEngines.get(threadId) ?? null;
9871
10463
  try {
9872
10464
  await instance?.adapter.interruptTurn(threadId);
9873
10465
  }
@@ -10028,9 +10620,13 @@ async function cloudRoutineReadiness(botId, threadId) {
10028
10620
  reason: 'The Boat cloud computer needs a working Boat API key. For the bot’s configured computer, including a self-hosted VPS, set run_on="maus" instead.',
10029
10621
  };
10030
10622
  }
10031
- const instance = turnInstance(bot, "cloud", threadId);
10623
+ const instance = registry.get(bot.modelSelection.instanceId);
10032
10624
  if (!instance)
10033
- return { ready: false, reason: "The Cloud VM runner is unavailable. Restart OpenMausBot and try again." };
10625
+ return { ready: false, reason: "The target bot's model is unavailable. Choose another model in the bot's settings." };
10626
+ // The same rule a cloud turn applies, before anything is provisioned.
10627
+ const unsupported = cloudPlaceDriverError(instance.adapter.capabilities, "box");
10628
+ if (unsupported)
10629
+ return { ready: false, reason: "This model can't use a computer. Choose another model, or change where this routine runs." };
10034
10630
  try {
10035
10631
  if ((await instance.snapshot()).state !== "available") {
10036
10632
  return { ready: false, reason: "The target bot's model is not ready to use the Boat cloud computer." };
@@ -10323,6 +10919,69 @@ const validateModelProposal = (selection, current) => {
10323
10919
  }
10324
10920
  return null;
10325
10921
  };
10922
+ const teamMemory = new TeamMemory(join(DATA_DIR, "team-memory.json"));
10923
+ const outboundRequests = new OutboundRequestService();
10924
+ const outboundCounts = new OutboundCounts(join(DATA_DIR, "outbound-counts.json"));
10925
+ const OUTBOUND_HOLD_MS = 9 * 60_000;
10926
+ function resolveAndSendTrust(res, threadId, requestId, behavior) {
10927
+ const teamMemoryCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.teamMemoryRequest);
10928
+ if (teamMemoryCard?.card?.teamMemoryRequest) {
10929
+ const request = teamMemoryCard.card.teamMemoryRequest;
10930
+ const proposer = teamMemoryCard.from?.botId ? store.bot(teamMemoryCard.from.botId) : store.botByThread(threadId);
10931
+ const result = teamMemory.resolve(request.section, request.entryId, behavior === "allow" ? "accept" : "reject");
10932
+ if (!result.claimed) {
10933
+ if (!teamMemoryCard.card.answered) {
10934
+ store.patchMessage(threadId, teamMemoryCard.id, { card: { ...teamMemoryCard.card, answered: "unavailable", dismissed: true } });
10935
+ }
10936
+ json(res, 200, { ok: true, outcome: "unavailable" });
10937
+ return true;
10938
+ }
10939
+ if (result.state === "already_settled") {
10940
+ json(res, 200, { ok: true, outcome: "allowed-once", alreadySettled: true });
10941
+ return true;
10942
+ }
10943
+ store.patchMessage(threadId, teamMemoryCard.id, {
10944
+ card: { ...teamMemoryCard.card, answered: result.state === "accepted" ? "allow" : "deny" },
10945
+ });
10946
+ appendDecision(DATA_DIR, {
10947
+ threadId, requestId, botId: proposer?.id, botName: proposer?.name,
10948
+ tool: "propose_team_memory", summary: teamMemoryCard.card.subtitle,
10949
+ decision: result.state === "accepted" ? "user-approved" : "user-denied", source: "user",
10950
+ });
10951
+ json(res, 200, { ok: true, outcome: result.state === "accepted" ? "allowed-once" : "rejected" });
10952
+ return true;
10953
+ }
10954
+ const outboundCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.outboundRequest);
10955
+ if (outboundCard?.card?.outboundRequest) {
10956
+ const outboundBot = outboundCard.from?.botId ? store.bot(outboundCard.from.botId) : store.botByThread(threadId);
10957
+ const result = outboundRequests.resolve({ threadId, requestId, behavior });
10958
+ if (!result.claimed) {
10959
+ // The hold is memory-only: after a restart the card outlives the
10960
+ // relay that was waiting on it, so close it rather than leave an
10961
+ // approval nobody can deliver owning the composer.
10962
+ if (!outboundCard.card.answered) {
10963
+ store.patchMessage(threadId, outboundCard.id, { card: { ...outboundCard.card, answered: "unavailable", dismissed: true } });
10964
+ }
10965
+ json(res, 200, { ok: true, outcome: "unavailable" });
10966
+ return true;
10967
+ }
10968
+ if (result.state === "already_settled") {
10969
+ json(res, 200, { ok: true, outcome: result.behavior === "allow" ? "allowed-once" : "rejected", alreadySettled: true });
10970
+ return true;
10971
+ }
10972
+ store.patchMessage(threadId, outboundCard.id, {
10973
+ card: { ...outboundCard.card, answered: result.state === "allowed" ? "allow" : "deny" },
10974
+ });
10975
+ appendDecision(DATA_DIR, {
10976
+ threadId, requestId, botId: outboundBot?.id, botName: outboundBot?.name,
10977
+ tool: outboundCard.card.tool, summary: outboundCard.card.subtitle,
10978
+ decision: result.state === "allowed" ? "user-approved" : "user-denied", source: "user",
10979
+ });
10980
+ json(res, 200, { ok: true, outcome: result.state === "allowed" ? "allowed-once" : "rejected" });
10981
+ return true;
10982
+ }
10983
+ return false;
10984
+ }
10326
10985
  const profileRequests = new ProfileRequestService({
10327
10986
  store,
10328
10987
  autoApply: fullAccessForSource,
@@ -10359,8 +11018,24 @@ const tighteningRequests = new TighteningRequestService({
10359
11018
  // The card judges the effective mounts, so the snapshot resolves the same
10360
11019
  // way the engine does: through the config-aware custom-server filter.
10361
11020
  mountedMcpServers: (bot) => Object.keys(engineMcpServers(bot)),
10362
- enabledSkills: (botId) => listSkills(botId).filter((skill) => skill.enabled).map((skill) => skill.name),
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
+ },
10363
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
+ }
10364
11039
  const disabled = setSkillEnabled(botId, name, false);
10365
11040
  return typeof disabled === "object" && "error" in disabled ? { ok: false, error: disabled.error } : { ok: true };
10366
11041
  },
@@ -10923,7 +11598,7 @@ setupRetry = 0) {
10923
11598
  const preparedApprovalMode = roomTurnApprovalMode(bot, threadId, orchestration);
10924
11599
  const preparedSelection = { ...bot.modelSelection };
10925
11600
  const preparedComposio = bot.composio;
10926
- const instance = turnInstance(bot);
11601
+ const instance = registry.get(bot.modelSelection.instanceId);
10927
11602
  const userName = cfg.profile?.name?.trim() || "User";
10928
11603
  if (providerInstancesChanging.has(bot.modelSelection.instanceId)) {
10929
11604
  onDispatchError?.(`${bot.name}'s provider account is being updated — try again shortly`);
@@ -11096,13 +11771,14 @@ setupRetry = 0) {
11096
11771
  const readyBot = store.bot(bot.id);
11097
11772
  if (!readyBot)
11098
11773
  return false;
11774
+ toolScopeForTurn(readyBot.id);
11099
11775
  const readyGroup = store.group(group.id);
11100
11776
  const stillOwnsThread = readyGroup?.dm
11101
11777
  ? readyGroup.threadId === threadId
11102
11778
  : Boolean(readyGroup && store.groupTaskByThread(readyGroup.id, threadId));
11103
11779
  if (!readyGroup || !stillOwnsThread || !readyGroup.memberIds.includes(readyBot.id))
11104
11780
  return false;
11105
- const setupChanged = turnInstance(readyBot) !== instance ||
11781
+ const setupChanged = registry.get(readyBot.modelSelection.instanceId) !== instance ||
11106
11782
  roomTurnApprovalMode(readyBot, threadId, orchestration) !== preparedApprovalMode ||
11107
11783
  readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
11108
11784
  readyBot.modelSelection.model !== preparedSelection.model ||
@@ -11227,6 +11903,13 @@ setupRetry = 0) {
11227
11903
  if (roomPlan.computer === "local" && instance.adapter.capabilities.remoteAgent === true) {
11228
11904
  throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
11229
11905
  }
11906
+ // The same one rule as a bot thread, before anything is provisioned: an
11907
+ // engine that cannot use the cloud computer is refused, never swapped.
11908
+ if (!roomTeamComputer && roomPlan.computer === "cloud") {
11909
+ const unsupported = cloudPlaceDriverError(instance.adapter.capabilities, turnProvider(readyBot) === "vps" ? "vps" : "box");
11910
+ if (unsupported)
11911
+ throw new PlaceUnavailableError("cloud", "works-on", unsupported);
11912
+ }
11230
11913
  // One place per room turn as well: a team computer reached on Auto means
11231
11914
  // no separate built-in browser.
11232
11915
  if (roomPlan.browser && !(roomPlan.computer === undefined && roomTeamComputer)) {
@@ -11245,11 +11928,11 @@ setupRetry = 0) {
11245
11928
  return false;
11246
11929
  }
11247
11930
  if (roomTeamComputer) {
11248
- const attached = await attachTeamBoat(roomTeamComputer, readyBot.id, resourceOwner, instance.adapter.capabilities.usesCloudComputer === true, instance.adapter.capabilities.remoteAgent === true);
11931
+ const attached = await attachTeamBoat(roomTeamComputer, readyBot.id, resourceOwner, usesCloudComputer(instance.adapter.capabilities), instance.adapter.capabilities.remoteAgent === true);
11249
11932
  if (isCancelled?.() || groupSpeakers.get(threadId) !== roomSpeaker ||
11250
11933
  activeInternalGenerationByThread.get(threadId) !== internalGeneration)
11251
11934
  return false;
11252
- integrations.computer = attached.integration;
11935
+ Object.assign(integrations, attached.mount);
11253
11936
  startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: instance.adapter.capabilities.remoteAgent === true });
11254
11937
  }
11255
11938
  // The speaker's own explicit place, mounted exactly as its bot thread
@@ -11258,89 +11941,99 @@ setupRetry = 0) {
11258
11941
  const roomSetupIsCurrent = () => !isCancelled?.() &&
11259
11942
  groupSpeakers.get(threadId) === roomSpeaker &&
11260
11943
  activeInternalGenerationByThread.get(threadId) === internalGeneration;
11261
- if (!roomTeamComputer && roomPlan.computer === "local") {
11262
- integrations.localComputer = await mountHostComputer(resourceOwner, readyBot.id, instance.adapter.capabilities.localComputerMcp === true);
11263
- if (!roomSetupIsCurrent())
11264
- return false;
11265
- roomComputerKind = "local";
11266
- }
11267
- if (!roomTeamComputer && roomPlan.computer === "cloud") {
11268
- if (turnProvider(readyBot) === "vps") {
11269
- const unsupported = vps.vpsDriverError(instance.driverKind, instance.adapter.capabilities.computerMcp === true);
11270
- if (unsupported)
11271
- throw new Error(unsupported);
11272
- vpsThreadStarted(readyBot.id, threadId);
11273
- roomVpsBotId = readyBot.id;
11274
- const mounted = await mountBotVps(readyBot, resourceOwner, {
11275
- start: true,
11276
- onClaimed: (capture) => {
11277
- if (roomSetupIsCurrent() && store.group(readyGroup.id)?.busyBotId === readyBot.id) {
11278
- roomScreenBotId = readyBot.id;
11279
- startScreenPoller(readyBot.id, threadId, { computer: capture });
11280
- }
11281
- },
11282
- });
11944
+ // An explicit place that cannot be attached fails the member's turn with
11945
+ // its cause and the one setting that changes it.
11946
+ try {
11947
+ if (!roomTeamComputer && roomPlan.computer === "local") {
11948
+ integrations.localComputer = await mountHostComputer(resourceOwner, readyBot.id, instance.adapter.capabilities.localComputerMcp === true);
11283
11949
  if (!roomSetupIsCurrent())
11284
11950
  return false;
11285
- if (!("integration" in mounted))
11286
- throw new Error(mounted.problem ?? "the VPS computer could not be created or reached");
11287
- integrations.localComputer = mounted.integration;
11288
- roomComputerKind = "vps";
11951
+ roomComputerKind = "local";
11952
+ }
11953
+ if (!roomTeamComputer && roomPlan.computer === "cloud") {
11954
+ if (turnProvider(readyBot) === "vps") {
11955
+ vpsThreadStarted(readyBot.id, threadId);
11956
+ roomVpsBotId = readyBot.id;
11957
+ const mounted = await mountBotVps(readyBot, resourceOwner, {
11958
+ start: true,
11959
+ onClaimed: (capture) => {
11960
+ if (roomSetupIsCurrent() && store.group(readyGroup.id)?.busyBotId === readyBot.id) {
11961
+ roomScreenBotId = readyBot.id;
11962
+ startScreenPoller(readyBot.id, threadId, { computer: capture });
11963
+ }
11964
+ },
11965
+ });
11966
+ if (!roomSetupIsCurrent())
11967
+ return false;
11968
+ if (!("integration" in mounted))
11969
+ throw new Error(mounted.problem ?? "the VPS computer could not be created or reached");
11970
+ integrations.localComputer = mounted.integration;
11971
+ roomComputerKind = "vps";
11972
+ }
11973
+ else {
11974
+ if (!boat.boatConfigured(cfg))
11975
+ throw new Error(BOAT_NOT_CONFIGURED);
11976
+ const remoteAgent = instance.adapter.capabilities.remoteAgent === true;
11977
+ const attached = await attachBotBoat(readyBot, resourceOwner, { explicitCloud: true,
11978
+ canMount: usesCloudComputer(instance.adapter.capabilities), remoteAgent });
11979
+ if (!roomSetupIsCurrent())
11980
+ return false;
11981
+ if (!attached?.mount)
11982
+ throw new Error("the cloud computer could not be created or reached");
11983
+ Object.assign(integrations, attached.mount);
11984
+ roomScreenBotId = readyBot.id;
11985
+ startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: remoteAgent });
11986
+ roomComputerKind = "box";
11987
+ }
11289
11988
  }
11290
- else {
11291
- if (!boat.boatConfigured(cfg))
11292
- throw new Error(BOAT_NOT_CONFIGURED);
11293
- const remoteAgent = instance.adapter.capabilities.remoteAgent === true;
11294
- const attached = await attachBotBoat(readyBot, resourceOwner, { explicitCloud: true,
11295
- canMount: instance.adapter.capabilities.usesCloudComputer === true, remoteAgent });
11296
- if (!roomSetupIsCurrent())
11989
+ // Room and Goal turns use the speaker's desktop, never the coordinator's.
11990
+ // Claim the same lease as direct turns before asynchronous VM setup.
11991
+ if (readyBot.computer === "vm") {
11992
+ if (instance.adapter.capabilities.computerMcp !== true || instance.adapter.capabilities.remoteAgent === true) {
11993
+ throw new Error("this model cannot use the Local VM");
11994
+ }
11995
+ // A distinct identity fences cleanup even in shared mode on the same room thread.
11996
+ const target = { ...localVmTargetForThread(readyBot.id, threadId) };
11997
+ await bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
11998
+ if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(target.key)) {
11999
+ throw new Error("this Local VM is being started, stopped, or replaced");
12000
+ }
12001
+ if (!localVmLeaseFor(target).claim(threadId, readyBot.id, localVmOwnerBusy)) {
12002
+ throw new Error("this Local VM is already being used by another turn");
12003
+ }
12004
+ if (localVmMode(cfg) === "pool")
12005
+ localVmSeatPool.touch(threadId);
12006
+ roomVmTarget = target;
12007
+ localVmThreadTargets.set(threadId, target);
12008
+ localVmActiveThreads.set(target.key, threadId);
12009
+ localVmIdleFor(target).touch();
12010
+ const setupIsCurrent = () => !isCancelled?.() &&
12011
+ groupSpeakers.get(threadId) === roomSpeaker &&
12012
+ activeInternalGenerationByThread.get(threadId) === internalGeneration &&
12013
+ store.group(readyGroup.id)?.memberIds.includes(readyBot.id) === true &&
12014
+ store.bot(readyBot.id)?.busy === true &&
12015
+ store.group(readyGroup.id)?.busyBotId === readyBot.id;
12016
+ const vm = await readyLocalVmForTurn(readyBot.id, target, setupIsCurrent);
12017
+ if (!setupIsCurrent()) {
11297
12018
  return false;
11298
- if (!attached?.integration)
11299
- throw new Error("the cloud computer could not be created or reached");
11300
- integrations.computer = attached.integration;
11301
- roomScreenBotId = readyBot.id;
11302
- startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: remoteAgent });
11303
- roomComputerKind = "box";
12019
+ }
12020
+ if (!vm.ready || !vm.runtime)
12021
+ throw new Error(vm.problem ?? "the Local VM is not ready");
12022
+ const owner = localVmLeaseFor(target).current(localVmOwnerBusy);
12023
+ if (owner?.threadId !== threadId || owner.botId !== readyBot.id) {
12024
+ throw new Error("the Local VM lease expired while preparing the turn");
12025
+ }
12026
+ integrations.localComputer = containerComputerMcp(vm.runtime, controlIntegration(readyBot.id, threadId, internalGeneration, target), target);
11304
12027
  }
11305
12028
  }
11306
- // Room and Goal turns use the speaker's desktop, never the coordinator's.
11307
- // Claim the same lease as direct turns before asynchronous VM setup.
11308
- if (readyBot.computer === "vm") {
11309
- if (instance.adapter.capabilities.computerMcp !== true || instance.adapter.capabilities.remoteAgent === true) {
11310
- throw new Error("this model cannot use the Local VM");
11311
- }
11312
- // A distinct identity fences cleanup even in shared mode on the same room thread.
11313
- const target = { ...localVmTargetForThread(readyBot.id, threadId) };
11314
- await bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
11315
- if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(target.key)) {
11316
- throw new Error("this Local VM is being started, stopped, or replaced");
11317
- }
11318
- if (!localVmLeaseFor(target).claim(threadId, readyBot.id, localVmOwnerBusy)) {
11319
- throw new Error("this Local VM is already being used by another turn");
11320
- }
11321
- if (localVmMode(cfg) === "pool")
11322
- localVmSeatPool.touch(threadId);
11323
- roomVmTarget = target;
11324
- localVmThreadTargets.set(threadId, target);
11325
- localVmActiveThreads.set(target.key, threadId);
11326
- localVmIdleFor(target).touch();
11327
- const setupIsCurrent = () => !isCancelled?.() &&
11328
- groupSpeakers.get(threadId) === roomSpeaker &&
11329
- activeInternalGenerationByThread.get(threadId) === internalGeneration &&
11330
- store.group(readyGroup.id)?.memberIds.includes(readyBot.id) === true &&
11331
- store.bot(readyBot.id)?.busy === true &&
11332
- store.group(readyGroup.id)?.busyBotId === readyBot.id;
11333
- const vm = await readyLocalVmForTurn(readyBot.id, target, setupIsCurrent);
11334
- if (!setupIsCurrent()) {
11335
- return false;
11336
- }
11337
- if (!vm.ready || !vm.runtime)
11338
- throw new Error(vm.problem ?? "the Local VM is not ready");
11339
- const owner = localVmLeaseFor(target).current(localVmOwnerBusy);
11340
- if (owner?.threadId !== threadId || owner.botId !== readyBot.id) {
11341
- throw new Error("the Local VM lease expired while preparing the turn");
11342
- }
11343
- integrations.localComputer = containerComputerMcp(vm.runtime, controlIntegration(readyBot.id, threadId, internalGeneration, target), target);
12029
+ catch (error) {
12030
+ const place = roomPlan.computer;
12031
+ if (place !== "cloud" && place !== "vm" && place !== "local")
12032
+ throw error;
12033
+ if (error instanceof DirectTurnSetupCancelled || error instanceof ComputerWaitParked || error instanceof PlaceUnavailableError)
12034
+ throw error;
12035
+ // A room row is not cut, so the cause keeps all its words.
12036
+ throw placeUnavailable(place, "works-on", error instanceof Error ? error.message : String(error), Infinity);
11344
12037
  }
11345
12038
  const roster = readyGroup.memberIds
11346
12039
  .map((id) => store.bot(id))
@@ -11426,7 +12119,7 @@ setupRetry = 0) {
11426
12119
  const recentLines = recentWork(recentWorkSources(bot), bot, { userName, currentThreadId: threadId, ...recentWorkFilter() });
11427
12120
  const roomComputerPromptKind = resolveComputerPromptKind({
11428
12121
  kind: roomTeamComputer || roomComputerKind === "box" ? "box" : roomVmTarget ? "vm" : roomComputerKind,
11429
- driverKind: instance.driverKind, cloudComputerMcp: instance.adapter.capabilities.cloudComputerMcp,
12122
+ driverKind: instance.driverKind,
11430
12123
  vmPrivate: localVmMode(cfg) === "per-bot",
11431
12124
  });
11432
12125
  {
@@ -11452,13 +12145,16 @@ setupRetry = 0) {
11452
12145
  { id: "recall", label: "Recall", text: integrations.agents && bot.memoryEnabled !== false ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
11453
12146
  { id: "recent", label: "Recent work", text: recentWorkPrompt(recentLines) },
11454
12147
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
12148
+ { id: "team-memory", label: "Team memory", text: teamMemory.systemPrompt(bot.section) + (integrations.agents ? TEAM_MEMORY_PROMPT : "") },
11455
12149
  // the room path has always put a newline before memory and trimmed
11456
12150
  // the block's leading space; keep that so existing prompts are
11457
12151
  // byte-identical. The write guidance follows the tools actually
11458
12152
  // mounted, exactly as the 1:1 path decides it: memory_update is on the
11459
12153
  // agents server, so a room turn with it must be told to use it too.
11460
12154
  { id: "memory", label: "Memory", text: roomMemory ? `\n${roomMemory.trim()}` : "" },
11461
- { id: "skills", label: "Skills index", text: workspace ? skillsSystemPrompt(bot.id) : "" },
12155
+ // readyBot was captured before awaited setup work; an assignment PUT in
12156
+ // that window must still reach this turn's prompt.
12157
+ { id: "skills", label: "Skills index", text: workspace ? skillsSystemPrompt(bot.id, skillsLibraryEnabled(cfg) ? (store.bot(bot.id)?.assignedSkills ?? bot.assignedSkills) : undefined) : "" },
11462
12158
  { id: "skill-instructions", label: "Skill instructions", text: renderSkillInstructions(selectedSkills, { includeRoot: Boolean(workspace) }) },
11463
12159
  { id: "playbooks", label: "Playbooks", text: installedPlaybookInstructions(text, bot.playbooks) },
11464
12160
  ]);
@@ -11582,9 +12278,9 @@ setupRetry = 0) {
11582
12278
  threadId,
11583
12279
  botId: readyBot.id,
11584
12280
  text: withRecalled(roomRecalled, text),
11585
- refreshSystemPrompt: true,
11586
12281
  images: turnImages,
11587
12282
  approvalMode: roomTurnApprovalMode(readyBot, threadId, orchestration),
12283
+ toolScope: toolScopeForTurn(readyBot.id),
11588
12284
  ...(roomGuestConfined ? { guestConfined: true, confinedWhy: confinedWhy(threadId) } : {}),
11589
12285
  system: roomSystem.text,
11590
12286
  systemStable: roomSystem.stable,
@@ -11667,6 +12363,7 @@ setupRetry = 0) {
11667
12363
  watchdog.settle(threadId);
11668
12364
  drainQueuedSends();
11669
12365
  drainConnectorResumes();
12366
+ drainComputerResumes();
11670
12367
  drainSecretResumes();
11671
12368
  drainTeamSetupResumes();
11672
12369
  return false;
@@ -11697,6 +12394,7 @@ setupRetry = 0) {
11697
12394
  retryDelegationsWaitingOn(bot.id);
11698
12395
  drainQueuedSends();
11699
12396
  drainConnectorResumes();
12397
+ drainComputerResumes();
11700
12398
  drainSecretResumes();
11701
12399
  drainTeamSetupResumes();
11702
12400
  }
@@ -11725,6 +12423,7 @@ setupRetry = 0) {
11725
12423
  // queued while this bot briefly owned the room must be retried now.
11726
12424
  drainQueuedSends();
11727
12425
  drainConnectorResumes();
12426
+ drainComputerResumes();
11728
12427
  drainSecretResumes();
11729
12428
  drainTeamSetupResumes();
11730
12429
  }
@@ -11790,15 +12489,35 @@ setupRetry = 0) {
11790
12489
  return false;
11791
12490
  const isSpendCap = typeof error === "object" && error !== null && error.code === "spend_cap";
11792
12491
  const isVariantError = typeof error === "object" && error !== null && error.code === "unsupported_model_variant";
11793
- if (!roomSpeaker && !isSpendCap && !isVariantError)
12492
+ const isToolScopeError = typeof error === "object" && error !== null && error.code === "tool_scope";
12493
+ if (error instanceof ComputerWaitParked) {
12494
+ registerComputerResume({
12495
+ botId: bot.id,
12496
+ threadId,
12497
+ resource: error.resource,
12498
+ generation: error.generation,
12499
+ afterMessageId: store.activePath(threadId).findLast((entry) => entry.role === "user")?.id,
12500
+ });
12501
+ // The room settles with the parked chip the wait appended (#1651);
12502
+ // the registered resume re-runs the speaker turn through the group
12503
+ // queue once the seat frees. "parked" is its own outcome, not "busy":
12504
+ // the member never ran, an in-step retry would only wait the ceiling
12505
+ // out again and burn goal turns, and the goal loop routes around the
12506
+ // member while the registered resume owns the re-run.
12507
+ if (orchestration) {
12508
+ orchestration.result.outcome = "parked";
12509
+ orchestration.result.stopReason = `${bot.name} parked waiting for the computer`;
12510
+ }
12511
+ return false;
12512
+ }
12513
+ if (!roomSpeaker && !isSpendCap && !isVariantError && !isToolScopeError)
11794
12514
  throw error;
11795
12515
  const message = error instanceof Error ? error.message : "Local VM setup failed";
11796
- if (!(error instanceof ComputerWaitGaveUp))
11797
- store.appendMessage(threadId, {
11798
- role: "bot", kind: "activity",
11799
- from: { botId: bot.id, name: bot.name, color: bot.color },
11800
- tool: { name: `error: ${message}`, ok: false },
11801
- });
12516
+ store.appendMessage(threadId, {
12517
+ role: "bot", kind: "activity",
12518
+ from: { botId: bot.id, name: bot.name, color: bot.color },
12519
+ tool: { name: `error: ${message}`, ok: false },
12520
+ });
11802
12521
  onDispatchError?.(message);
11803
12522
  if (orchestration) {
11804
12523
  // A spend-cap refusal is deterministic: record it as its own
@@ -11808,7 +12527,7 @@ setupRetry = 0) {
11808
12527
  if (isSpendCap)
11809
12528
  orchestration.result.stopReason = message;
11810
12529
  }
11811
- return false;
12530
+ return isToolScopeError;
11812
12531
  }
11813
12532
  finally {
11814
12533
  // Covers connector/setup failures, cancellation before dispatch, and all
@@ -11829,6 +12548,7 @@ setupRetry = 0) {
11829
12548
  }
11830
12549
  drainQueuedSends();
11831
12550
  drainConnectorResumes();
12551
+ drainComputerResumes();
11832
12552
  drainSecretResumes();
11833
12553
  drainTeamSetupResumes();
11834
12554
  }
@@ -11887,6 +12607,12 @@ async function runGroupGoalStep(args) {
11887
12607
  coordinatorTurn.turnId = turnId;
11888
12608
  },
11889
12609
  });
12610
+ // Busy here is a lost claim race — a 1:1 re-claiming the bot between
12611
+ // the wait settling and this turn starting — so wait and retry
12612
+ // in-step. A parked member is deliberately absent from both this
12613
+ // retry and the transient one below: the computer seat is still held,
12614
+ // an immediate retry would wait the ceiling out again while the
12615
+ // registered resume already owns re-running the member.
11890
12616
  if (result.outcome === "busy")
11891
12617
  continue;
11892
12618
  // One retry for a transient provider failure: a 13-turn goal must not
@@ -11990,6 +12716,13 @@ async function runGroupGoalOperation(args) {
11990
12716
  finishGroupGoalRun(args.groupId, args.operation, "blocked", `${coordinatorResult.stopReason ?? `${args.coordinator.name} stayed busy`} — send the goal again when they are free.`);
11991
12717
  return;
11992
12718
  }
12719
+ if (coordinatorResult.outcome === "parked") {
12720
+ // The lead parked at the computer wait ceiling: like lead-busy, the
12721
+ // run cannot route around them, but the parked lead turn itself
12722
+ // resumes through the registered entry once the seat frees.
12723
+ finishGroupGoalRun(args.groupId, args.operation, "blocked", `${coordinatorResult.stopReason ?? `${args.coordinator.name} parked waiting for the computer`} — send the goal again when the computer is free.`);
12724
+ return;
12725
+ }
11993
12726
  if (!coordinatorResult.ran || coordinatorResult.outcome !== "settled") {
11994
12727
  const reason = coordinatorResult.stopReason?.trim().slice(0, 120);
11995
12728
  finishGroupGoalRun(args.groupId, args.operation, "failed", `${args.coordinator.name} could not complete the coordination step${reason ? ` — ${reason}` : ""}.`);
@@ -12047,9 +12780,10 @@ async function runGroupGoalOperation(args) {
12047
12780
  finishGroupGoalRun(args.groupId, args.operation, "blocked", workerResult.stopReason ?? `${workerBot.name} hit the workspace spend cap.`);
12048
12781
  return;
12049
12782
  }
12050
- if (workerResult.outcome === "busy") {
12051
- // bounded: a team that keeps landing on busy teammates is blocked, not
12052
- // looping — three exhausted waits per run, then stop and say so
12783
+ if (workerResult.outcome === "busy" || workerResult.outcome === "parked") {
12784
+ // bounded: a team that keeps landing on busy or computer-parked
12785
+ // teammates is blocked, not looping — three exhausted waits per run,
12786
+ // then stop and say so
12053
12787
  waitExhaustions += 1;
12054
12788
  if (waitExhaustions >= GROUP_GOAL_MAX_WAIT_EXHAUSTIONS) {
12055
12789
  finishGroupGoalRun(args.groupId, args.operation, "blocked", `Teammates stayed busy past the wait limit ${waitExhaustions} times — try again when the team is free.`);
@@ -12944,6 +13678,95 @@ function drainConnectorResumes() {
12944
13678
  dispatchConnectorResume(entry);
12945
13679
  }
12946
13680
  }
13681
+ const pendingComputerResumes = new Map();
13682
+ function registerComputerResume(entry) {
13683
+ // One per thread: the newest park replaces an older one.
13684
+ pendingComputerResumes.set(entry.threadId, entry);
13685
+ // The seat may already be free (it freed before this park registered):
13686
+ // an idle install would otherwise never drain. #1651.
13687
+ queueMicrotask(drainComputerResumes);
13688
+ }
13689
+ function markComputerResumeFailed(entry, message) {
13690
+ const owner = connectorThread(entry.botId, entry.threadId);
13691
+ if (!owner)
13692
+ return;
13693
+ store.appendMessage(entry.threadId, {
13694
+ role: "bot", kind: "activity",
13695
+ ...(owner.group ? { from: { botId: owner.bot.id, name: owner.bot.name, color: owner.bot.color } } : {}),
13696
+ tool: { name: `Parked turn could not continue: ${message.slice(0, 160)}`, ok: false },
13697
+ });
13698
+ }
13699
+ function dispatchComputerResume(entry) {
13700
+ const owner = connectorThread(entry.botId, entry.threadId);
13701
+ if (!owner)
13702
+ return;
13703
+ const prompt = "OpenMausBot computer update: the computer this conversation waited for is free again. Continue the task that parked waiting for it.";
13704
+ if (owner.group) {
13705
+ const groupId = owner.group.id;
13706
+ const operation = beginGroupTurnOperation(groupId, entry.threadId, [entry.botId]);
13707
+ const previous = groupQueues.get(groupId) ?? Promise.resolve();
13708
+ const next = previous.then(async () => {
13709
+ if (operation.cancelled)
13710
+ return;
13711
+ const current = connectorThread(entry.botId, entry.threadId);
13712
+ if (!current?.group)
13713
+ return;
13714
+ if (current.bot.busy) {
13715
+ pendingComputerResumes.set(entry.threadId, entry);
13716
+ return;
13717
+ }
13718
+ await runGroupMemberTurn(current.group.id, entry.threadId, entry.botId, 0, new Set(), prompt, (message) => markComputerResumeFailed(entry, message), () => operation.cancelled, () => groupProviderHandshakeStarted(operation), () => groupProviderHandshakeSettled(operation));
13719
+ });
13720
+ const tracked = next.finally(() => finishGroupTurnOperation(groupId, operation));
13721
+ groupQueues.set(groupId, tracked.catch((error) => {
13722
+ markComputerResumeFailed(entry, error instanceof Error ? error.message : String(error));
13723
+ }));
13724
+ return;
13725
+ }
13726
+ void startTurn(entry.botId, prompt, {
13727
+ threadId: entry.threadId,
13728
+ cardContinuation: true,
13729
+ onDispatchError: (message) => markComputerResumeFailed(entry, message),
13730
+ }).catch((error) => {
13731
+ const message = error instanceof Error ? error.message : String(error);
13732
+ if (isTurnAdmissionBlocked(error))
13733
+ pendingComputerResumes.set(entry.threadId, entry);
13734
+ else
13735
+ markComputerResumeFailed(entry, message);
13736
+ });
13737
+ }
13738
+ function drainComputerResumes() {
13739
+ for (const [key, entry] of pendingComputerResumes) {
13740
+ if (!connectorThread(entry.botId, entry.threadId)) {
13741
+ pendingComputerResumes.delete(key);
13742
+ continue;
13743
+ }
13744
+ // A newer ask replaced the parked work: the resume is stale, not blocked.
13745
+ const latestUser = store.activePath(entry.threadId).findLast((message) => message.role === "user")?.id;
13746
+ if (entry.afterMessageId !== undefined && latestUser !== entry.afterMessageId) {
13747
+ pendingComputerResumes.delete(key);
13748
+ continue;
13749
+ }
13750
+ // A newer generation owns the thread — a new turn superseded the parked
13751
+ // one — so the parked resume is stale too. The parked turn's OWN
13752
+ // generation is still current while it settles: the lazy-claim park
13753
+ // registers its resume before the interrupt lands, and a drain in that
13754
+ // window must keep the entry (#1651), not read busy as superseded.
13755
+ const activeGeneration = activeInternalGenerationByThread.get(entry.threadId);
13756
+ if (activeGeneration !== undefined && activeGeneration !== entry.generation) {
13757
+ pendingComputerResumes.delete(key);
13758
+ continue;
13759
+ }
13760
+ // Busy under the parked generation: the turn is still settling. Keep the
13761
+ // entry; the next drain (its completion, the next release) dispatches.
13762
+ if (threadBusy(entry.botId, entry.threadId))
13763
+ continue;
13764
+ if (!turnResources.free(entry.resource))
13765
+ continue;
13766
+ pendingComputerResumes.delete(key);
13767
+ dispatchComputerResume(entry);
13768
+ }
13769
+ }
12947
13770
  const pendingSecretResumes = new Map();
12948
13771
  const phoneSecretSubmissions = new PhoneSecretSubmissionRegistry();
12949
13772
  function claimPhoneSecretBotDeletion(botId) {
@@ -13175,6 +13998,7 @@ bus.subscribe((event) => {
13175
13998
  return;
13176
13999
  if (event.type === "turn.completed") {
13177
14000
  drainConnectorResumes();
14001
+ drainComputerResumes();
13178
14002
  drainSecretResumes();
13179
14003
  drainTeamSetupResumes();
13180
14004
  }
@@ -13382,6 +14206,8 @@ function configStatus() {
13382
14206
  // the decision model: switches and configured-or-not, never the key
13383
14207
  decider: describeDecider(cfg),
13384
14208
  imageGen: avatarImageStatus(cfg),
14209
+ // Live calls: configured-or-not only; the voice name is a setting
14210
+ live: liveSettingsFor(cfg),
13385
14211
  // not a secret — the sidebar shows it
13386
14212
  profile: { name: cfg.profile?.name ?? "", email: cfg.profile?.email ?? "", aboutMe: cfg.profile?.aboutMe ?? "" },
13387
14213
  // the enrolled organisation's read-only desktop policy; null when not enrolled
@@ -13407,6 +14233,7 @@ function configStatus() {
13407
14233
  },
13408
14234
  features: {
13409
14235
  skillAuthoring: skillAuthoringEnabled(cfg),
14236
+ skillsLibrary: skillsLibraryEnabled(cfg),
13410
14237
  showToolCalls: showToolCallsEnabled(cfg),
13411
14238
  routinesInConversation: routinesInConversationEnabled(cfg),
13412
14239
  browser: builtInBrowserEnabled(cfg),
@@ -13708,6 +14535,7 @@ async function reloadProviders() {
13708
14535
  // queued behind them drains now — onto the freshly loaded fleet
13709
14536
  drainQueuedSends();
13710
14537
  drainConnectorResumes();
14538
+ drainComputerResumes();
13711
14539
  drainSecretResumes();
13712
14540
  drainTeamSetupResumes();
13713
14541
  }
@@ -13864,6 +14692,102 @@ ROUTES.push(createAntigravityLeftoverRoutes({
13864
14692
  remove: () => removeAntigravityLeftovers(),
13865
14693
  }));
13866
14694
  ROUTES.push(desktopViewer.route);
14695
+ // Live calls (GPT-Live as the voice, the bot as the brain). A client holds
14696
+ // the WebRTC audio; the harness creates the session with the key (which
14697
+ // never leaves it) and runs the call in LiveCallController.
14698
+ /** Who the voice speaks for, for its instructions. */
14699
+ function liveBotFor(botId) {
14700
+ const bot = store.bot(botId);
14701
+ if (!bot)
14702
+ throw new LiveSessionError("That bot no longer exists.", 404);
14703
+ return { name: bot.name, title: bot.title, description: bot.description };
14704
+ }
14705
+ /** The chat's text so far, so the voice can follow "and the other one?". */
14706
+ function liveHistoryFor(threadId) {
14707
+ return store.activePath(threadId)
14708
+ .filter((message) => message.kind === "text" && typeof message.text === "string" && (message.role === "user" || message.role === "bot"))
14709
+ .map((message) => ({ role: message.role === "user" ? "user" : "assistant", text: message.text ?? "" }));
14710
+ }
14711
+ /** A call outlives the request that started it: a signed-out or removed
14712
+ * person's call must not keep reaching the bot (or spending the key). */
14713
+ function liveSignedIn(auth) {
14714
+ return auth.kind !== "session" || sessions.isLive(auth.session.id);
14715
+ }
14716
+ const liveCalls = new LiveCallController({
14717
+ store,
14718
+ send: async ({ auth, botId, threadId, text }) => {
14719
+ // the controller ends the call on this error
14720
+ if (!liveSignedIn(auth))
14721
+ throw new LiveCallSignedOutError();
14722
+ const receipt = await acceptDirectSend({
14723
+ botId, threadId, text,
14724
+ sendId: randomUUID().replaceAll("-", ""),
14725
+ sender: messageSender(auth),
14726
+ trigger: usageTriggerFor(auth),
14727
+ via: "call",
14728
+ // a person is on the call: these are their words
14729
+ personPresent: true,
14730
+ });
14731
+ if ("queued" in receipt)
14732
+ return { kind: "queued", queueId: receipt.queueId };
14733
+ return { kind: receipt.steered ? "steered" : "started", messageId: receipt.message.id };
14734
+ },
14735
+ respond: async (input) => {
14736
+ const result = await respondToCard(input);
14737
+ if (result.ok)
14738
+ return { ok: true };
14739
+ // respondToCard's 401: the session that started the call has ended
14740
+ if (result.status === 401)
14741
+ throw new LiveCallSignedOutError();
14742
+ return { ok: false, error: result.error };
14743
+ },
14744
+ // send() queues through the steer queue; an edit or cancel there removes a request undelivered
14745
+ queued: isSteeredMessageQueued,
14746
+ signedIn: liveSignedIn,
14747
+ // the caller's own typed lines carry this sender (none for the owner)
14748
+ personKey: (auth) => messageSender(auth)?.id,
14749
+ activity: (botId, threadId) => {
14750
+ const task = store.taskByThread(botId, threadId);
14751
+ if (!task?.busy)
14752
+ return "idle";
14753
+ return task.activity === "waiting-on-you" ? "waiting" : "working";
14754
+ },
14755
+ broadcast: (frame) => broadcast(frame, { adminOnly: true }),
14756
+ settings: () => ({ key: cfg.live?.key ?? "", ...liveSettingsFor(cfg) }),
14757
+ createSession: ({ key, sdp, botId, threadId, voice }) => createLiveSession({ key, sdp, voice, bot: liveBotFor(botId), history: liveHistoryFor(threadId) }),
14758
+ // Node's WebSocket (undici) accepts headers in its second argument.
14759
+ openSocket: (url, key) => new WebSocket(url, { headers: { authorization: `Bearer ${key}` } }),
14760
+ attachUrl: (sessionId) => liveAttachUrl(sessionId),
14761
+ speakable: (text) => toUtterances(text),
14762
+ log: (line) => console.log(line),
14763
+ });
14764
+ // A signed-out or revoked sign-in ends the call it started at once (the idle
14765
+ // check would only notice within 15 s). A paired phone's unpairing arrives
14766
+ // from the companion instead (POST /api/live/device-revoked).
14767
+ sessions.onSessionRevoked((sessionId) => liveCalls.sessionRevoked(sessionId));
14768
+ ROUTES.push(createLiveRoutes({
14769
+ calls: liveCalls,
14770
+ resolveTarget: (botId, threadId) => {
14771
+ const bot = store.bot(botId);
14772
+ if (!bot)
14773
+ return null;
14774
+ const target = threadId ?? bot.threadId;
14775
+ if (store.botByThread(target)?.id !== bot.id)
14776
+ return null;
14777
+ return { botId: bot.id, botName: bot.name, threadId: target };
14778
+ },
14779
+ settings: () => liveSettingsFor(cfg),
14780
+ // Non-secret settings only, written the way PUT /api/config writes a
14781
+ // section: saveConfig merges into `live`, so the key stays where it is.
14782
+ saveSettings: async (patch) => {
14783
+ if (providerConfigBusy)
14784
+ throw Object.assign(new Error("Settings are already being updated. Try again in a moment."), { status: 409 });
14785
+ saveConfig({ live: patch });
14786
+ Object.assign(cfg, loadConfig());
14787
+ broadcast({ kind: "config", ...configStatus() });
14788
+ return liveSettingsFor(cfg);
14789
+ },
14790
+ }));
13867
14791
  const toolResults = new ToolResults();
13868
14792
  const handleRequest = async (req, res) => {
13869
14793
  let url;
@@ -13920,7 +14844,7 @@ const handleRequest = async (req, res) => {
13920
14844
  if (method === "GET" && !path.startsWith("/api/") && !path.startsWith("/.well-known/") && serveStatic(res, path))
13921
14845
  return;
13922
14846
  if (method === "GET" && path === "/.well-known/openmausbot/environment") {
13923
- return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !HOSTED_WORKSPACE && !CLOUD_HOME && emailSignIn.enabled(), sharedComputers: lendingEnabled() }));
14847
+ return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !HOSTED_WORKSPACE && !CLOUD_HOME && emailSignIn.enabled(), sharedComputers: lendingEnabled(), cloudHome: Boolean(CLOUD_HOME) }));
13924
14848
  }
13925
14849
  const domainCheck = /^\/\.well-known\/openmausbot\/domain-check\/([a-f0-9]{64})$/.exec(path);
13926
14850
  if (method === "GET" && domainCheck) {
@@ -14019,7 +14943,7 @@ const handleRequest = async (req, res) => {
14019
14943
  console.warn(`pairing refused from ${requestSource(req)}: ${result.error}`);
14020
14944
  return json(res, result.status, { error: result.error });
14021
14945
  }
14022
- const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !CLOUD_HOME && emailSignIn.enabled(), sharedComputers: lendingEnabled() });
14946
+ const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !CLOUD_HOME && emailSignIn.enabled(), sharedComputers: lendingEnabled(), cloudHome: Boolean(CLOUD_HOME) });
14023
14947
  if (wantsCookie) {
14024
14948
  // A browser sign-in replaces this browser's own session here, if it had one, rather than leaving it behind.
14025
14949
  const previous = browser ? sessions.authenticate(parseCookies(req.headers.cookie).get(SESSION_COOKIE)) : null;
@@ -14428,7 +15352,7 @@ const handleRequest = async (req, res) => {
14428
15352
  ? "phone"
14429
15353
  : path.startsWith("/api/internal/connectors/")
14430
15354
  ? "connectors"
14431
- : path === "/api/internal/computer-control"
15355
+ : path === "/api/internal/computer-control" || path === "/api/internal/computer/mcp"
14432
15356
  ? "computer"
14433
15357
  : "agents";
14434
15358
  if (internalCapability.kind !== requiredCapabilityKind) {
@@ -14585,7 +15509,7 @@ const handleRequest = async (req, res) => {
14585
15509
  if (method === "POST" && path === "/api/internal/memory") {
14586
15510
  const body = await readInternalBody();
14587
15511
  const result = ownersWrite(() => updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText, ...(body.until !== undefined ? { until: body.until } : {}) }, { source: memorySource() }));
14588
- return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : result.code === "over-budget" ? 413 : 400, result);
15512
+ return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : 400, result);
14589
15513
  }
14590
15514
  if (method === "POST" && path === "/api/internal/memory/log") {
14591
15515
  const body = await readInternalBody();
@@ -14619,6 +15543,22 @@ const handleRequest = async (req, res) => {
14619
15543
  requireActiveInternalCapability();
14620
15544
  return json(res, 200, { result });
14621
15545
  }
15546
+ // The cloud computer's tools (harness-mcp-proxy computer): the turn's
15547
+ // capability names its Boat, and the Boat credential stays here.
15548
+ if (method === "POST" && path === "/api/internal/computer/mcp") {
15549
+ const body = await readInternalBody();
15550
+ const boxId = internalCapability.boxId;
15551
+ if (!boxId)
15552
+ return json(res, 403, { error: "this turn has no cloud computer" });
15553
+ const abort = new AbortController();
15554
+ res.once("close", () => { if (!res.writableEnded)
15555
+ abort.abort(); });
15556
+ return json(res, 200, { result: await cloudComputerRpc(body, {
15557
+ cfg, boxId, signal: abort.signal,
15558
+ gate: () => computerCallGate(internalCapability),
15559
+ assertActive: requireActiveInternalCapability,
15560
+ }) });
15561
+ }
14622
15562
  if (method === "POST" && path === "/api/internal/phone/claim") {
14623
15563
  // Lazy phone exclusivity (issue #1663): the turn holds computer:phone
14624
15564
  // from its first real tool call until it settles. The proxy answers a
@@ -15000,6 +15940,58 @@ const handleRequest = async (req, res) => {
15000
15940
  });
15001
15941
  return json(res, 201, proposed);
15002
15942
  }
15943
+ if (method === "POST" && path === "/api/internal/team-memory") {
15944
+ const parsed = z.object({
15945
+ fromBotId: z.string().min(1).max(128),
15946
+ fromThreadId: z.string().min(1).max(128),
15947
+ kind: z.string(),
15948
+ name: z.string(),
15949
+ detail: z.string(),
15950
+ aliases: z.array(z.string()).optional(),
15951
+ }).strict().safeParse(await readInternalBody());
15952
+ if (!parsed.success)
15953
+ return json(res, 400, { error: "invalid team memory proposal" });
15954
+ const body = parsed.data;
15955
+ const from = store.bot(body.fromBotId);
15956
+ if (!from)
15957
+ return json(res, 403, { error: "unknown sender" });
15958
+ const owner = connectorThread(from.id, body.fromThreadId);
15959
+ if (!owner)
15960
+ return json(res, 403, { error: "source conversation does not belong to sender" });
15961
+ if (!sameAudience(from.visibility, undefined) || (owner.group && !roomFeedsBot(owner.group, from))) {
15962
+ return json(res, 403, { error: "restricted conversations cannot publish section-wide memory; ask an admin to add it" });
15963
+ }
15964
+ if (!TEAM_MEMORY_KINDS.includes(body.kind)) {
15965
+ return json(res, 400, { error: `kind must be one of ${TEAM_MEMORY_KINDS.join(", ")}` });
15966
+ }
15967
+ let proposed;
15968
+ try {
15969
+ proposed = teamMemory.propose(from.section, { kind: body.kind, name: body.name, detail: body.detail, aliases: body.aliases }, { botId: from.id, botName: from.name, threadId: body.fromThreadId, at: Date.now() });
15970
+ }
15971
+ catch (error) {
15972
+ return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
15973
+ }
15974
+ const summary = `${proposed.entry.kind[0].toUpperCase()}${proposed.entry.kind.slice(1)}: ${proposed.entry.name}${proposed.entry.aliases.length ? ` (also ${proposed.entry.aliases.join(", ")})` : ""} — ${proposed.entry.detail}`;
15975
+ const section = sectionContextKey(from.section);
15976
+ const card = store.appendMessage(body.fromThreadId, {
15977
+ role: "bot",
15978
+ kind: "options",
15979
+ ...(owner.group ? { from: { botId: from.id, name: from.name, color: from.color } } : {}),
15980
+ card: {
15981
+ title: "Remember this for the team?",
15982
+ subtitle: summary,
15983
+ options: ["Remember", "Skip"],
15984
+ requestId: proposed.entry.id,
15985
+ tool: "propose_team_memory",
15986
+ teamMemoryRequest: { section, entryId: proposed.entry.id, kind: proposed.entry.kind },
15987
+ },
15988
+ });
15989
+ appendDecision(DATA_DIR, {
15990
+ threadId: body.fromThreadId, requestId: proposed.entry.id, botId: from.id, botName: from.name,
15991
+ tool: "propose_team_memory", summary, decision: "card-shown", source: "team-memory",
15992
+ });
15993
+ return json(res, 201, { status: "proposed", requestId: proposed.entry.id, messageId: card.id, summary });
15994
+ }
15003
15995
  if (method === "POST" && path === "/api/internal/profile-requests") {
15004
15996
  const parsed = z.object({
15005
15997
  fromBotId: z.string().min(1).max(128),
@@ -15290,7 +16282,7 @@ const handleRequest = async (req, res) => {
15290
16282
  return json(res, 403, { error: "source conversation does not belong to sender" });
15291
16283
  }
15292
16284
  return json(res, 200, {
15293
- skills: listSkills(from.id),
16285
+ skills: listBotSkills(from),
15294
16286
  staged: listStagedSkillWrites(from.id).map(stagedSkillListing),
15295
16287
  });
15296
16288
  }
@@ -15864,20 +16856,17 @@ const handleRequest = async (req, res) => {
15864
16856
  })).filter(g => g.members.length);
15865
16857
  return json(res, 200, { currentRoom: source ? { id: source.id, name: source.name, workingFolder: source.cwd || null } : null,
15866
16858
  bots: reachablePeers(store.bots, internalSender).map(bot => ({ id: bot.id, name: bot.name, title: bot.title, section: bot.section, busy: bot.busy })),
15867
- rooms, note: "Without group_id: use this room when in a room, otherwise each distinct assignment starts a fresh thread for that teammate. Give a self-contained brief. Each bot uses its own environment and permissions. Files are not transferred: pass absolute paths only when accessible to the recipient, otherwise pass the content." });
16859
+ rooms, note: "Each bot uses its own environment and permissions. Files are not transferred: pass absolute paths only when accessible to the recipient, otherwise pass the content." });
15868
16860
  }
15869
16861
  if (method === "POST" && path === "/api/internal/coordinate-bots") {
15870
16862
  const parsed = z.object({
15871
16863
  groupId: z.string().min(1).max(128).optional(),
15872
16864
  botIds: z.array(z.string().min(1).max(128)).min(1).max(4).refine(ids => new Set(ids).size === ids.length),
15873
- message: z.string().trim().min(1).max(4000), requestKey: z.string().regex(/^[\w-]{1,100}$/),
16865
+ message: z.string().trim().min(1).max(4000),
15874
16866
  rework: z.boolean().default(false),
15875
- // Only ever a name for a thread, so it travels under the same
15876
- // one-line rule as a peer thread title.
15877
- label: z.string().trim().min(1).max(60).refine(fitsOnOneLine).optional(),
15878
16867
  }).safeParse(await readInternalBody());
15879
16868
  if (!parsed.success)
15880
- return json(res, 400, { error: "Provide 1-4 distinct botIds, message (1-4000 characters), a short requestKey (letters, digits, underscores or hyphens) and an optional one-line label of at most 60 characters." });
16869
+ return json(res, 400, { error: "Provide 1-4 distinct botIds and a message of 1-4000 characters." });
15881
16870
  const groupId = parsed.data.groupId ?? source?.id;
15882
16871
  const destination = groupId ? store.group(groupId) : undefined;
15883
16872
  if (groupId && !destination)
@@ -15929,60 +16918,63 @@ const handleRequest = async (req, res) => {
15929
16918
  });
15930
16919
  approvalGranted = true;
15931
16920
  }
15932
- const accepted = [];
15933
- const errors = [];
16921
+ // A room shows one brief per call, addressed to the call's recipients.
16922
+ const requestBatchKey = destination ? randomUUID() : undefined;
15934
16923
  // One receipt per recipient, in the order the caller addressed
15935
16924
  // them. Dispatch is asynchronous (RoomHandoffs.tick), so a fresh
15936
- // enqueue is honestly "queued"; a duplicate inherits the state of
15937
- // the node that already carries this request_key.
16925
+ // enqueue is honestly "queued"; a repeat reports the request it
16926
+ // repeats.
15938
16927
  const receipts = [];
16928
+ // Why nothing was sent, in full: a receipt's detail is clipped.
16929
+ const refused = [];
15939
16930
  for (const target of targets) {
15940
16931
  let createdThread;
15941
16932
  try {
15942
16933
  requireActiveInternalCapability();
16934
+ // Outside a room this conversation has one thread with each
16935
+ // teammate: the first request opens it, everything after
16936
+ // continues it and runs once the work already there is done.
16937
+ const continued = destination ? undefined : store.workThread(target.botId, internalSender.id, address.threadId);
15943
16938
  if (!destination) {
15944
- const task = store.createTask(target.botId, `@${internalSender.name}${parsed.data.label ? ` · ${parsed.data.label}` : " · work"}`, false, undefined, { botId: internalSender.id, name: internalSender.name, kind: "work", at: Date.now() });
16939
+ const task = continued ?? store.createTask(target.botId, `@${internalSender.name} · work`, false, undefined, { botId: internalSender.id, name: internalSender.name, kind: "work", threadId: address.threadId, at: Date.now() });
15945
16940
  if (!task)
15946
16941
  throw new Error("The recipient no longer exists");
15947
- target.threadId = createdThread = task.threadId;
15948
- if (delegatedFullAccess(internalSender, internalCapability.threadId, store.bot(target.botId))) {
15949
- grantDelegatedFullAccess(internalSender, store.bot(target.botId), target.threadId);
15950
- }
16942
+ target.threadId = task.threadId;
16943
+ if (!continued)
16944
+ createdThread = task.threadId;
16945
+ applyDelegatedLevel(internalSender, internalCapability.threadId, store.bot(target.botId), target.threadId);
15951
16946
  }
15952
- const { node, duplicate } = roomHandoffs.enqueue(address, internalCapability.generation, internalCapability.roomHandoffId, target, parsed.data.requestKey + ":" + target.botId, parsed.data.message, approvalGranted, parsed.data.rework, [...store.messagesFor(address.threadId)].reverse().find(m => m.role === "user" && m.kind === "text")?.text ?? "", destination ? parsed.data.requestKey : undefined);
15953
- // A retry keeps its original task; discard the speculative row.
15954
- if (duplicate && createdThread && createdThread !== node.threadId) {
16947
+ const { node, duplicate } = roomHandoffs.enqueue(address, internalCapability.generation, internalCapability.roomHandoffId, target, target.botId, parsed.data.message, approvalGranted, parsed.data.rework, [...store.messagesFor(address.threadId)].reverse().find(m => m.role === "user" && m.kind === "text")?.text ?? "", requestBatchKey);
16948
+ // A repeat lands on the request already made, possibly in a
16949
+ // thread the person archived meanwhile; a thread opened for it
16950
+ // is never used.
16951
+ if (duplicate && createdThread)
15955
16952
  store.deleteTask(target.botId, createdThread);
15956
- }
15957
- if (!duplicate && createdThread)
16953
+ else if (createdThread)
15958
16954
  threadStarters.set(createdThread, openerFrom(address.threadId));
15959
16955
  createdThread = undefined; // The durable coordinator now owns this task.
15960
- accepted.push({ requestId: node.id, botId: node.botId, duplicate, status: node.status });
15961
- // A duplicate request_key lands on the node enqueue already
15962
- // made. Only a live node can still report: a failed or
15963
- // cancelled one already fired its report and tick() skips it,
15964
- // so promising "its result will resume you" would be the one
15965
- // lie a receipt must never tell.
16956
+ // Only a live request can still report: a finished one already
16957
+ // fired its report and tick() skips it, so a repeat of it sends
16958
+ // nothing and says how to run it again.
16959
+ const title = destination ? undefined : store.taskByThread(node.botId, node.threadId)?.title;
16960
+ const finished = {
16961
+ completed: "it already finished and its result stands; send it with rework=true to run it again",
16962
+ failed: "it failed; send it with rework=true to retry", cancelled: "it was cancelled; send it with rework=true to retry",
16963
+ };
16964
+ const notSent = duplicate && finished[node.status] ? `not sent again: ${finished[node.status]}` : undefined;
16965
+ if (notSent)
16966
+ refused.push(notSent);
15966
16967
  receipts.push(peerDeliveryReceipt({
15967
16968
  botId: node.botId,
15968
16969
  botName: store.bot(node.botId)?.name,
15969
- outcome: duplicate
15970
- ? node.status === "running" || node.status === "completed"
15971
- ? "injected"
15972
- : node.status === "failed" || node.status === "cancelled"
15973
- ? "failed"
15974
- : "queued"
15975
- : "queued",
15976
- detail: duplicate
15977
- ? node.status === "running"
15978
- ? "duplicate request_key — this teammate is already running that exact assignment"
15979
- : node.status === "completed"
15980
- ? "duplicate request_key — this teammate already completed that assignment; the earlier result stands"
15981
- : node.status === "failed" || node.status === "cancelled"
15982
- ? `duplicate request_key — the earlier assignment ${node.status === "cancelled" ? "was cancelled" : "failed"} and will not rerun or resume you; resend with a new request_key to retry`
15983
- : "duplicate request_key — already in flight; its result will resume you automatically"
15984
- : "handed to the coordinator; the teammate's turn has not started yet",
16970
+ outcome: notSent ? "failed" : !duplicate || node.status === "queued" ? "queued" : "injected",
16971
+ detail: notSent ?? (duplicate
16972
+ ? `already sent: it is ${node.status === "queued" ? "queued" : "under way"} and its result will resume you`
16973
+ : title
16974
+ ? `sent to "${title}"; it runs after anything still running there`
16975
+ : "handed to the coordinator; the teammate's turn has not started yet"),
15985
16976
  requestId: node.id,
16977
+ ...(destination ? {} : { threadId: node.threadId }),
15986
16978
  }));
15987
16979
  if (!duplicate) {
15988
16980
  const recipient = store.bot(target.botId);
@@ -15999,14 +16991,13 @@ const handleRequest = async (req, res) => {
15999
16991
  store.deleteTask(target.botId, createdThread);
16000
16992
  }
16001
16993
  const said = error instanceof Error ? error.message : String(error);
16002
- errors.push({ botId: target.botId, error: said });
16003
- receipts.push(peerDeliveryReceipt({
16004
- botId: target.botId, botName: store.bot(target.botId)?.name, outcome: "failed", detail: said,
16005
- }));
16994
+ refused.push(said);
16995
+ receipts.push(peerDeliveryReceipt({ botId: target.botId, botName: store.bot(target.botId)?.name, outcome: "failed", detail: said }));
16006
16996
  }
16007
16997
  }
16008
- return json(res, accepted.length ? 200 : 409, { accepted, errors, receipts,
16009
- ...(accepted.length ? { message: "End your turn after sending all work. These actual teammates will reply and resume you automatically. Do not poll or wait." } : { error: errors.map(e => e.error).join("; ") }),
16998
+ const sent = refused.length < receipts.length;
16999
+ return json(res, sent ? 200 : 409, { receipts,
17000
+ ...(sent ? { message: "End your turn after sending all work. These actual teammates will reply and resume you automatically. Do not poll or wait." } : { error: refused.join("; ") }),
16010
17001
  });
16011
17002
  }
16012
17003
  }
@@ -16218,11 +17209,15 @@ const handleRequest = async (req, res) => {
16218
17209
  const target = store.bot(toBotId);
16219
17210
  if (!target)
16220
17211
  return json(res, 404, { error: "no such bot" });
17212
+ const oneWay = body.oneWay === true;
17213
+ if (oneWay && target.id === from.id) {
17214
+ return json(res, 409, { error: "send_to_bot is cross-bot only; use start_thread to send independent work to yourself" });
17215
+ }
16221
17216
  if (internalCapability.roomCoordination) {
16222
- if (target.id !== from.id) {
17217
+ if (target.id !== from.id && !oneWay) {
16223
17218
  return json(res, 409, { error: "Use coordinate_bots for teamwork; it queues actual teammates and resumes you automatically." });
16224
17219
  }
16225
- if (!internalCapability.ownThreadCreation) {
17220
+ if (target.id === from.id && !internalCapability.ownThreadCreation) {
16226
17221
  return json(res, 409, { error: "Only a direct user request can open separate self-owned threads. Finish this assigned work here; use coordinate_bots for teammates." });
16227
17222
  }
16228
17223
  }
@@ -16274,7 +17269,7 @@ const handleRequest = async (req, res) => {
16274
17269
  // classic handoff has applies unchanged, here at queue time and
16275
17270
  // again in the drain at dispatch time.
16276
17271
  const depth = internalCapability.depth;
16277
- if (depth >= MAX_COMMS_DEPTH) {
17272
+ if (!oneWay && depth >= MAX_COMMS_DEPTH) {
16278
17273
  return json(res, 200, { error: "thread chains are limited to one hop — open the thread on yourself, or do this one here" });
16279
17274
  }
16280
17275
  if (!canAccessTeam(from, target.section) || target.hidden) {
@@ -16288,9 +17283,11 @@ const handleRequest = async (req, res) => {
16288
17283
  return json(res, 500, { error: "couldn't create that thread" });
16289
17284
  // The work is still for the person whose request the opener is on.
16290
17285
  threadStarters.set(task.threadId, openerFrom(fromThreadId));
16291
- if (delegatedFullAccess(from, fromThreadId, target))
16292
- grantDelegatedFullAccess(from, target, task.threadId);
16293
- const queued = queueDelegation(commsBus, from, { toBotId: target.id, message, depth, targetThreadId: task.threadId }, MAX_COMMS_DEPTH, fromThreadId);
17286
+ applyDelegatedLevel(from, fromThreadId, target, task.threadId);
17287
+ const sourceUrl = threadRefUrl({ botId: owner.group?.id ?? from.id, threadId: fromThreadId });
17288
+ const queued = queueDelegation(commsBus, from, { toBotId: target.id, message: oneWay
17289
+ ? `${message}\n\n[Source conversation](${sourceUrl}). This link grants no additional access and contains no copied transcript.`
17290
+ : message, depth, targetThreadId: task.threadId, oneWay }, MAX_COMMS_DEPTH, fromThreadId);
16294
17291
  if (queued.result !== "ok" || !queued.id) {
16295
17292
  // nothing will ever run there: take the row back before the person
16296
17293
  // sees a thread that goes nowhere
@@ -16598,7 +17595,7 @@ const handleRequest = async (req, res) => {
16598
17595
  // Reading a streamed MCP body yields to ordinary settings requests.
16599
17596
  // Re-read the live bot immediately before relay so turning Connected
16600
17597
  // Apps off wins over a request that authenticated under the old value.
16601
- const currentSender = store.bot(internalCapability.botId);
17598
+ let currentSender = store.bot(internalCapability.botId);
16602
17599
  if (!currentSender || currentSender.composio === false || !composio.configured(cfg)) {
16603
17600
  return json(res, 403, { error: "connected apps are not enabled for this bot" });
16604
17601
  }
@@ -16609,6 +17606,7 @@ const handleRequest = async (req, res) => {
16609
17606
  // Rows are fire-and-forget: a log failure must never take the call
16610
17607
  // (or its refusal) down with it.
16611
17608
  const call = connectorCallFromFrame(body);
17609
+ let serviceSlugs = [];
16612
17610
  // A bot with no grants record keeps the pre-grants relay: even an
16613
17611
  // unreadable frame passes through exactly as it always did.
16614
17612
  if (call.kind === "unrecognized" && currentSender.connectorTools !== undefined) {
@@ -16635,7 +17633,11 @@ const handleRequest = async (req, res) => {
16635
17633
  // underscored service (bland_ai) keeps its own tools instead of
16636
17634
  // a plain-prefix grant (bland) capturing them; an unreachable
16637
17635
  // catalog reads as empty and the plain split stands.
16638
- const serviceSlugs = await composio.connectedServiceSlugs(cfg);
17636
+ serviceSlugs = await composio.connectedServiceSlugs(cfg);
17637
+ requireActiveInternalCapability();
17638
+ currentSender = store.bot(internalCapability.botId);
17639
+ if (!currentSender || currentSender.composio === false || !composio.configured(cfg))
17640
+ return json(res, 403, { error: "connected apps are no longer enabled for this bot" });
16639
17641
  const verdict = evaluateConnectorTools(call.names, currentSender.connectorTools, serviceSlugs);
16640
17642
  if (!verdict.allowed) {
16641
17643
  for (const denial of verdict.denials) {
@@ -16675,9 +17677,174 @@ const handleRequest = async (req, res) => {
16675
17677
  rule: verdict.rule,
16676
17678
  });
16677
17679
  }
16678
- const upstream = await composio.relayMcp(cfg, body, Array.isArray(req.headers["mcp-session-id"])
16679
- ? req.headers["mcp-session-id"][0]
16680
- : req.headers["mcp-session-id"]);
17680
+ // ── the outbound gate ──
17681
+ // A tools/call that sends something (shared/outbound.ts) is its own
17682
+ // confirmation in every approval mode, Full included: it waits on a
17683
+ // card, or spends the bot's daily allowance. Everything else relays
17684
+ // untouched. A refusal is an ordinary tool error so the bot reads
17685
+ // it and carries on, rather than a transport failure it retries.
17686
+ const rpc = body && typeof body === "object" && !Array.isArray(body)
17687
+ ? body
17688
+ : null;
17689
+ const rpcParams = rpc?.params && typeof rpc.params === "object" && !Array.isArray(rpc.params)
17690
+ ? rpc.params
17691
+ : null;
17692
+ // The app tools this call would actually run, seen through Composio's
17693
+ // meta tools (a multi-execute list, workbench code), and the ones
17694
+ // among them that send.
17695
+ const outboundCalls = rpc?.method === "tools/call" && typeof rpcParams?.name === "string"
17696
+ ? outboundCallsIn(rpcParams.name, rpcParams.arguments)
17697
+ : [];
17698
+ const outboundTool = outboundCalls.length ? outboundCalls[0].slug : null;
17699
+ const refuseOutbound = (text) => {
17700
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
17701
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id: rpc?.id ?? null, result: { content: [{ type: "text", text }], isError: true } }));
17702
+ };
17703
+ // ── connection scopes ──
17704
+ // Checked first: an app this bot may not touch is refused outright,
17705
+ // never turned into a question for the person.
17706
+ const connectorCalls = rpc?.method === "tools/call" && typeof rpcParams?.name === "string"
17707
+ ? connectorCallsIn(rpcParams.name, rpcParams.arguments)
17708
+ : [];
17709
+ const access = connectorAccessDecision(currentSender.connectorScopes, connectorCalls, serviceSlugs);
17710
+ if (!access.ok) {
17711
+ const described = describeTool(access.slug);
17712
+ const appName = described.app ?? access.toolkit;
17713
+ appendDecision(DATA_DIR, {
17714
+ threadId: internalCapability.threadId, botId: currentSender.id, botName: currentSender.name,
17715
+ tool: access.slug, decision: "auto-denied", source: "connector-scope", rule: `scope:${access.toolkit}:${access.reason}`,
17716
+ });
17717
+ return refuseOutbound(access.reason === "app"
17718
+ ? `OpenMausBot did not run this: ${currentSender.name} has not been given access to ${appName}. Ask the user to allow it under the bot's Access settings, and do not retry.`
17719
+ : `OpenMausBot did not run this: ${appName} is read-only for ${currentSender.name}, and ${described.label} would write to it. Ask the user before trying again.`);
17720
+ }
17721
+ const opaque = connectorCalls.some(call => call.slug === "COMPOSIO_PROXY_EXECUTE");
17722
+ if (opaque && currentSender.connectorTools !== undefined)
17723
+ return refuseOutbound("OpenMausBot cannot verify this code against this bot's tool grants. Use a direct app tool.");
17724
+ let outboundApproved = false;
17725
+ if (outboundTool) {
17726
+ const policy = currentSender.outbound ?? DEFAULT_OUTBOUND_POLICY;
17727
+ const threadId = internalCapability.threadId;
17728
+ const { app } = describeTool(outboundTool);
17729
+ const summary = outboundCalls.map((call) => {
17730
+ const described = describeTool(call.slug);
17731
+ const argsText = call.arguments === undefined ? "" : JSON.stringify(call.arguments);
17732
+ return `${described.app ? `${described.app} · ` : ""}${described.label}${argsText ? `\n${argsText.slice(0, 400)}${argsText.length > 400 ? "… [arguments truncated]" : ""}` : ""}`;
17733
+ }).join("\n\n");
17734
+ const capNote = (count) => `OpenMausBot did not send this. ${currentSender.name} has reached its daily limit of ${count} outbound action${count === 1 ? "" : "s"}. Ask the user to raise the limit under the bot's Permissions before trying again.`;
17735
+ if (policy.policy === "allow" && !opaque) {
17736
+ const today = outboundCounts.today(currentSender.id);
17737
+ if (today + outboundCalls.length > policy.dailyCap) {
17738
+ appendDecision(DATA_DIR, {
17739
+ threadId, botId: currentSender.id, botName: currentSender.name,
17740
+ tool: outboundTool, summary, decision: "auto-denied", source: "outbound", rule: `daily-cap:${policy.dailyCap}`,
17741
+ });
17742
+ return refuseOutbound(capNote(policy.dailyCap));
17743
+ }
17744
+ }
17745
+ else {
17746
+ const owner = connectorThread(currentSender.id, threadId);
17747
+ const held = outboundRequests.open({ botId: currentSender.id, threadId, tool: outboundTool, timeoutMs: OUTBOUND_HOLD_MS });
17748
+ const card = store.appendMessage(threadId, {
17749
+ role: "bot",
17750
+ kind: "options",
17751
+ ...(owner?.group ? { from: { botId: currentSender.id, name: currentSender.name, color: currentSender.color } } : {}),
17752
+ card: {
17753
+ title: "Send on your behalf?",
17754
+ subtitle: summary,
17755
+ options: ["Allow", "Deny"],
17756
+ requestId: held.requestId,
17757
+ tool: outboundTool,
17758
+ held: HELD_NOTE["approval.held.outbound"],
17759
+ heldCode: "approval.held.outbound",
17760
+ outboundRequest: { tool: outboundTool, app },
17761
+ },
17762
+ });
17763
+ appendDecision(DATA_DIR, {
17764
+ threadId, requestId: held.requestId, botId: currentSender.id, botName: currentSender.name,
17765
+ tool: outboundTool, summary, decision: "card-shown", source: "outbound",
17766
+ });
17767
+ if (owner?.group) {
17768
+ if (activeGroupTurnForBot(currentSender.id)?.threadId === threadId)
17769
+ store.setActivity(currentSender.id, "waiting-on-you");
17770
+ }
17771
+ else if (store.taskByThread(currentSender.id, threadId)?.busy) {
17772
+ store.setTaskActivity(currentSender.id, threadId, "waiting-on-you");
17773
+ }
17774
+ notify(buildNotification("approval", currentSender, threadId, summary, { avatarUrl: currentSender.avatarUrl }));
17775
+ const answer = await held.answer;
17776
+ outboundRequests.forget(held.requestId);
17777
+ if (internalCapabilityIsActive(internalCapability)) {
17778
+ const waiting = store.bot(currentSender.id);
17779
+ if (owner?.group) {
17780
+ if (activeGroupTurnForBot(currentSender.id)?.threadId === threadId && waiting?.activity === "waiting-on-you")
17781
+ store.setActivity(waiting.id, "working");
17782
+ }
17783
+ else if (store.taskByThread(currentSender.id, threadId)?.activity === "waiting-on-you") {
17784
+ store.setTaskActivity(currentSender.id, threadId, "working");
17785
+ }
17786
+ }
17787
+ if (answer !== "allow") {
17788
+ if (answer === "timeout") {
17789
+ const stale = store.messagesFor(threadId).find((message) => message.id === card.id);
17790
+ if (stale?.card && !stale.card.answered) {
17791
+ store.patchMessage(threadId, stale.id, { card: { ...stale.card, answered: "unavailable", dismissed: true } });
17792
+ }
17793
+ }
17794
+ return refuseOutbound(answer === "timeout"
17795
+ ? "OpenMausBot did not send this: nobody answered the approval in time. Ask the user before trying again."
17796
+ : "OpenMausBot did not send this: the user declined. Do not retry it; ask the user what they would like instead.");
17797
+ }
17798
+ outboundApproved = true;
17799
+ requireActiveInternalCapability();
17800
+ }
17801
+ }
17802
+ let dispatchRefusal;
17803
+ const refuseDispatch = (text) => {
17804
+ dispatchRefusal = text;
17805
+ throw new Error(text);
17806
+ };
17807
+ let upstream;
17808
+ try {
17809
+ upstream = await composio.relayMcp(cfg, body, Array.isArray(req.headers["mcp-session-id"])
17810
+ ? req.headers["mcp-session-id"][0]
17811
+ : req.headers["mcp-session-id"], () => {
17812
+ requireActiveInternalCapability();
17813
+ const fresh = store.bot(internalCapability.botId);
17814
+ if (!fresh || fresh.composio === false || !composio.configured(cfg))
17815
+ refuseDispatch("Connected apps were disabled while this call waited.");
17816
+ const freshAccess = connectorAccessDecision(fresh.connectorScopes, connectorCalls, serviceSlugs);
17817
+ if (!freshAccess.ok || (opaque && fresh.connectorTools !== undefined) ||
17818
+ (call.kind === "tools" && !evaluateConnectorTools(call.names, fresh.connectorTools, serviceSlugs).allowed) ||
17819
+ (call.kind === "unrecognized" && fresh.connectorTools !== undefined)) {
17820
+ refuseDispatch("This bot's connected-app permissions changed. The call was not run.");
17821
+ }
17822
+ if (outboundTool) {
17823
+ const policy = fresh.outbound ?? DEFAULT_OUTBOUND_POLICY;
17824
+ if (policy.policy === "allow" && !opaque) {
17825
+ let reserved;
17826
+ try {
17827
+ reserved = outboundCounts.reserve(fresh.id, outboundCalls.length, policy.dailyCap);
17828
+ }
17829
+ catch {
17830
+ refuseDispatch("The daily allowance could not be saved. Nothing was sent.");
17831
+ }
17832
+ if (reserved === null)
17833
+ refuseDispatch("This bot has reached its daily outbound limit. Nothing was sent.");
17834
+ appendDecision(DATA_DIR, { threadId: internalCapability.threadId, botId: fresh.id, botName: fresh.name,
17835
+ tool: outboundTool, decision: "auto-approved", source: "outbound", rule: `daily-allowance:${reserved}/${policy.dailyCap}` });
17836
+ }
17837
+ else if (!outboundApproved) {
17838
+ refuseDispatch("The outbound policy changed. Ask for approval again before sending.");
17839
+ }
17840
+ }
17841
+ });
17842
+ }
17843
+ catch (error) {
17844
+ if (dispatchRefusal)
17845
+ return refuseOutbound(dispatchRefusal);
17846
+ throw error;
17847
+ }
16681
17848
  const headers = {
16682
17849
  "content-type": upstream.contentType,
16683
17850
  "cache-control": "no-store",
@@ -16755,6 +17922,16 @@ const handleRequest = async (req, res) => {
16755
17922
  if (!composio.configured(cfg) || owner.bot.composio === false) {
16756
17923
  return json(res, 409, { error: "connected apps are not enabled for this bot" });
16757
17924
  }
17925
+ if (owner.bot.connectorScopes) {
17926
+ const outside = slugs.find((slug) => !owner.bot.connectorScopes?.apps[slug]);
17927
+ if (outside) {
17928
+ appendDecision(DATA_DIR, {
17929
+ threadId, botId: owner.bot.id, botName: owner.bot.name,
17930
+ tool: "COMPOSIO_MANAGE_CONNECTIONS", summary: outside, decision: "auto-denied", source: "connector-scope", rule: `scope:${outside}:app`,
17931
+ });
17932
+ return json(res, 403, { error: `${owner.bot.name} has not been given access to ${outside}; allow it under the bot's Access settings first` });
17933
+ }
17934
+ }
16758
17935
  const connectionState = await composio.connectionStatus(cfg, slugs).catch(() => ({}));
16759
17936
  requireActiveInternalCapability();
16760
17937
  const messageIds = [];
@@ -18115,6 +19292,7 @@ const handleRequest = async (req, res) => {
18115
19292
  return true;
18116
19293
  const openApprovalThreads = store.groupTasks(group.id).flatMap((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
18117
19294
  message.card?.requestId &&
19295
+ (!isPersistentQuestionCard(message.card) || runningTurnEngines.has(task.threadId)) &&
18118
19296
  !message.card.answered &&
18119
19297
  !message.card.dismissed && !message.card.expired) ? [task.threadId] : []);
18120
19298
  return openApprovalThreads.length > 0 && !openApprovalThreads.includes(targetThreadId);
@@ -18866,10 +20044,11 @@ const handleRequest = async (req, res) => {
18866
20044
  if (created && !created.ok)
18867
20045
  return json(res, 400, { error: created.error });
18868
20046
  const bot = store.createBot({ ...profile.patch, soul: settings.soul, section, modelSelection: selection,
20047
+ toolScope: settings.toolScope ?? undefined,
18869
20048
  ...(created?.ok ? { visibility: created.visibility } : {}) });
18870
20049
  const createdRoutines = [];
18871
20050
  try {
18872
- const { chiefOfStaff: _chief, managedSections: _managed, ...ordinary } = settings;
20051
+ const { chiefOfStaff: _chief, managedSections: _managed, toolScope: _toolScope, ...ordinary } = settings;
18873
20052
  store.patchBot(bot.id, {
18874
20053
  // Store creation completes workspace defaults (including effort).
18875
20054
  // Applying the rest of the template must not undo that selection.
@@ -19255,6 +20434,43 @@ const handleRequest = async (req, res) => {
19255
20434
  patch.section = section ?? undefined;
19256
20435
  if (body.chiefOfStaff === false)
19257
20436
  patch.chiefOfStaff = false;
20437
+ // where a task carries on when this engine is unavailable, in order
20438
+ if (body.fallback !== undefined) {
20439
+ if (!Array.isArray(body.fallback) || body.fallback.length > 5) {
20440
+ return json(res, 400, { error: "fallback must be a list of up to five { instanceId, model } entries" });
20441
+ }
20442
+ const chain = [];
20443
+ for (const entry of body.fallback) {
20444
+ const row = entry && typeof entry === "object" ? entry : null;
20445
+ if (!row || typeof row.instanceId !== "string" || !/^[\w.-]+$/.test(row.instanceId) || typeof row.model !== "string" || !row.model.trim() || row.model.length > 500) {
20446
+ return json(res, 400, { error: "fallback must be a list of up to five { instanceId, model } entries" });
20447
+ }
20448
+ if (!registry.get(row.instanceId))
20449
+ return json(res, 400, { error: `fallback engine "${row.instanceId}" is not available` });
20450
+ if (chain.some((seen) => seen.instanceId === row.instanceId))
20451
+ continue;
20452
+ chain.push({ instanceId: row.instanceId, model: row.model.trim() });
20453
+ }
20454
+ patch.fallback = chain;
20455
+ }
20456
+ // which connected apps this bot may use; null clears back to all of them
20457
+ if (body.connectorScopes !== undefined) {
20458
+ if (body.connectorScopes === null)
20459
+ patch.connectorScopes = undefined;
20460
+ else {
20461
+ const scopes = normalizeConnectorScopes(body.connectorScopes);
20462
+ if (!scopes)
20463
+ return json(res, 400, { error: "connectorScopes must be { apps: { <toolkit>: read | write } } or null" });
20464
+ patch.connectorScopes = scopes;
20465
+ }
20466
+ }
20467
+ // sending on the person's behalf: ask every time, or a daily allowance
20468
+ if (body.outbound !== undefined) {
20469
+ const policy = normalizeOutboundPolicy(body.outbound);
20470
+ if (!policy)
20471
+ return json(res, 400, { error: "outbound must be { policy: ask | allow, dailyCap: 1..1000 }" });
20472
+ patch.outbound = policy;
20473
+ }
19258
20474
  // per-bot gate on the workspace's connected apps (Composio)
19259
20475
  if (body.composio !== undefined) {
19260
20476
  if (typeof body.composio !== "boolean")
@@ -19309,6 +20525,12 @@ const handleRequest = async (req, res) => {
19309
20525
  // Per-bot selection of app-wide MCP servers. Omitted keeps the current
19310
20526
  // selection; null restores all enabled servers; [] explicitly mounts none.
19311
20527
  let requestedMcpServers = existingBot?.mcpServers;
20528
+ if (Object.hasOwn(body, "toolScope")) {
20529
+ const parsed = parseToolScope(body.toolScope === null ? undefined : body.toolScope);
20530
+ if (!parsed.ok)
20531
+ return json(res, 400, { error: parsed.error });
20532
+ patch.toolScope = parsed.scope;
20533
+ }
19312
20534
  if (body.mcpServers !== undefined) {
19313
20535
  if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
19314
20536
  return json(res, 401, { error: "unauthorized: this session has expired or was revoked" });
@@ -19560,6 +20782,16 @@ const handleRequest = async (req, res) => {
19560
20782
  // desktop capability or a paired session, which is what the refusal
19561
20783
  // points at.
19562
20784
  const loosened = [];
20785
+ // These three fields were validated above before joining the broad patch.
20786
+ const trustPatch = patch;
20787
+ if (body.outbound !== undefined && trustPatch.outbound?.policy === "allow" &&
20788
+ (existingBot?.outbound?.policy !== "allow" || trustPatch.outbound.dailyCap > existingBot.outbound.dailyCap))
20789
+ loosened.push("outbound");
20790
+ if (body.connectorScopes !== undefined && existingBot?.connectorScopes &&
20791
+ (!trustPatch.connectorScopes || Object.entries(trustPatch.connectorScopes.apps).some(([slug, scope]) => !existingBot.connectorScopes.apps[slug] || (scope === "write" && existingBot.connectorScopes.apps[slug] === "read"))))
20792
+ loosened.push("connectorScopes");
20793
+ if (body.fallback !== undefined && trustPatch.fallback?.some(candidate => !existingBot?.fallback?.some(previous => previous.instanceId === candidate.instanceId && previous.model === candidate.model)))
20794
+ loosened.push("fallback");
19563
20795
  if (Array.isArray(patch.managedSections) && patch.managedSections.some(section => !(existingBot?.managedSections ?? []).includes(section))) {
19564
20796
  loosened.push("managedSections");
19565
20797
  }
@@ -19586,6 +20818,8 @@ const handleRequest = async (req, res) => {
19586
20818
  (requestedMcpServers === undefined || requestedMcpServers.some((name) => !existingBot.mcpServers.includes(name)))) {
19587
20819
  loosened.push("mcpServers");
19588
20820
  }
20821
+ if (Object.hasOwn(body, "toolScope") && toolScopeWidens(existingBot?.toolScope, patch.toolScope))
20822
+ loosened.push("toolScope");
19589
20823
  const browserOrigin = typeof req.headers.origin === "string" && req.headers.origin.trim() !== "";
19590
20824
  if (loosened.length && auth.kind === "loopback" && !DESKTOP_MANAGED && !browserOrigin) {
19591
20825
  if (store.bots.some((candidate) => candidate.busy)) {
@@ -19621,6 +20855,18 @@ const handleRequest = async (req, res) => {
19621
20855
  (freshBrowserBot.busy || activeGroupTurnForBot(freshBrowserBot.id))) {
19622
20856
  return json(res, 409, { error: "stop this bot's turn before changing its memory setting" });
19623
20857
  }
20858
+ if (Object.hasOwn(body, "toolScope")) {
20859
+ if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
20860
+ return json(res, 401, { error: "unauthorized: this session has expired or was revoked" });
20861
+ }
20862
+ if (toolScopeWidens(freshBrowserBot?.toolScope, patch.toolScope) && auth.kind === "loopback" && !DESKTOP_MANAGED && !browserOrigin && store.bots.some((candidate) => candidate.busy)) {
20863
+ return json(res, 409, { error: "A bot is working right now, so this change has to come from the desktop app or a paired device. Try again once every bot is idle." });
20864
+ }
20865
+ if (freshBrowserBot && JSON.stringify(freshBrowserBot.toolScope) !== JSON.stringify(patch.toolScope)
20866
+ && (freshBrowserBot.busy || activeGroupTurnForBot(freshBrowserBot.id))) {
20867
+ return json(res, 409, { error: "Stop this bot's turns before changing its tool selection." });
20868
+ }
20869
+ }
19624
20870
  // Custom servers are process configuration: a running turn cannot
19625
20871
  // unmount them. Recheck after any awaited runtime revocation above.
19626
20872
  if (body.mcpServers !== undefined) {
@@ -19772,8 +21018,19 @@ const handleRequest = async (req, res) => {
19772
21018
  if (m && method === "GET") {
19773
21019
  if (!store.bot(m[1]))
19774
21020
  return json(res, 404, { error: "no such bot" });
21021
+ if (skillsLibraryEnabled(cfg)) {
21022
+ const { skills, library } = listBotSkillsWithOrigin(m[1], store.bot(m[1]).assignedSkills);
21023
+ return json(res, 200, {
21024
+ skills,
21025
+ library,
21026
+ // the authoritative assignment list: merged rows hide
21027
+ // assignments a same-name private skill shadows
21028
+ assignedSkills: store.bot(m[1]).assignedSkills,
21029
+ staged: listStagedSkillWrites(m[1]).map(stagedSkillListing),
21030
+ });
21031
+ }
19775
21032
  return json(res, 200, {
19776
- skills: listSkills(m[1]),
21033
+ skills: listBotSkills(store.bot(m[1])),
19777
21034
  staged: listStagedSkillWrites(m[1]).map(stagedSkillListing),
19778
21035
  });
19779
21036
  }
@@ -19795,7 +21052,8 @@ const handleRequest = async (req, res) => {
19795
21052
  }
19796
21053
  m = path.match(/^\/api\/bots\/([\w-]+)\/skills\/([a-z0-9-]+)$/);
19797
21054
  if (m && method === "GET") {
19798
- const text = readSkillFile(m[1], m[2]);
21055
+ const bot = store.bot(m[1]);
21056
+ const text = bot ? readBotSkillFile(bot, m[2]) : null;
19799
21057
  if (text === null)
19800
21058
  return json(res, 404, { error: "no such skill" });
19801
21059
  return json(res, 200, { text });
@@ -19815,11 +21073,165 @@ const handleRequest = async (req, res) => {
19815
21073
  return json(res, 404, { error: result.error });
19816
21074
  return json(res, 200, { ok: true });
19817
21075
  }
21076
+ // ── skills library (features.skillsLibrary): local browse surface ────
21077
+ // Everything here is local: the library on disk, assignments on bot
21078
+ // records, imports from pasted SKILL.md text. No registry, no network.
21079
+ m = path.match(/^\/api\/skills-library$/);
21080
+ if (m && method === "GET") {
21081
+ if (!skillsLibraryEnabled(cfg))
21082
+ return json(res, 404, { error: "skills library is not enabled" });
21083
+ const index = readSkillLibraryIndex();
21084
+ const skills = listLibrarySkills()
21085
+ .map((listing) => ({
21086
+ name: listing.name,
21087
+ description: listing.description,
21088
+ source: listing.source,
21089
+ enabled: listing.enabled,
21090
+ tags: listing.tags ?? [],
21091
+ version: index[listing.name]?.package?.release ?? null,
21092
+ importedAt: listing.importedAt,
21093
+ ...(listing.license ? { license: listing.license } : {}),
21094
+ ...(listing.compatibility ? { compatibility: listing.compatibility } : {}),
21095
+ warnings: listing.warnings,
21096
+ assignedBots: store.bots
21097
+ .filter((bot) => bot.assignedSkills?.includes(listing.name))
21098
+ .map((bot) => ({ id: bot.id, name: bot.name })),
21099
+ }))
21100
+ .sort((a, b) => a.name.localeCompare(b.name));
21101
+ return json(res, 200, { skills });
21102
+ }
21103
+ if (m && method === "POST") {
21104
+ if (!skillsLibraryEnabled(cfg))
21105
+ return json(res, 404, { error: "skills library is not enabled" });
21106
+ const parsed = z.object({ text: z.string().min(1) }).safeParse(await readBody(req));
21107
+ if (!parsed.success)
21108
+ return json(res, 400, { error: "text must be the SKILL.md contents to import" });
21109
+ const skill = parseSkillMd(parsed.data.text);
21110
+ if ("error" in skill)
21111
+ return json(res, 400, { error: skill.error });
21112
+ // Imports land disabled with their scan warnings attached: enabling is
21113
+ // a separate, explicit review step on the row.
21114
+ const installed = installLibrarySkill({
21115
+ name: skill.name,
21116
+ instructions: parsed.data.text,
21117
+ source: "local-import",
21118
+ license: skill.license,
21119
+ compatibility: skill.compatibility,
21120
+ tags: skill.tags,
21121
+ warnings: scanSkillText(parsed.data.text),
21122
+ reviewState: "disabled",
21123
+ });
21124
+ if ("error" in installed)
21125
+ return json(res, 422, { error: installed.error });
21126
+ return json(res, 201, { skill: installed });
21127
+ }
21128
+ m = path.match(/^\/api\/skills-library\/([a-z0-9-]+)$/);
21129
+ if (m && method === "GET") {
21130
+ if (!skillsLibraryEnabled(cfg))
21131
+ return json(res, 404, { error: "skills library is not enabled" });
21132
+ const text = readLibrarySkillFile(m[1]);
21133
+ if (text === null)
21134
+ return json(res, 404, { error: "no such skill" });
21135
+ return json(res, 200, { text });
21136
+ }
21137
+ if (m && method === "PATCH") {
21138
+ if (!skillsLibraryEnabled(cfg))
21139
+ return json(res, 404, { error: "skills library is not enabled" });
21140
+ const parsed = z.object({ enabled: z.boolean() }).safeParse(await readBody(req));
21141
+ if (!parsed.success)
21142
+ return json(res, 400, { error: "enabled must be true or false" });
21143
+ const result = setLibrarySkillReviewState(m[1], parsed.data.enabled ? "approved" : "disabled");
21144
+ if ("error" in result)
21145
+ return json(res, 404, { error: result.error });
21146
+ return json(res, 200, { skill: result });
21147
+ }
21148
+ m = path.match(/^\/api\/bots\/([\w-]+)\/skills-library$/);
21149
+ if (m && method === "PUT") {
21150
+ if (!skillsLibraryEnabled(cfg))
21151
+ return json(res, 404, { error: "skills library is not enabled" });
21152
+ if (!store.bot(m[1]))
21153
+ return json(res, 404, { error: "no such bot" });
21154
+ const parsed = z.object({ skills: z.array(z.string().min(1).max(64)).max(200) }).safeParse(await readBody(req));
21155
+ if (!parsed.success)
21156
+ return json(res, 400, { error: "skills must be a list of skill names" });
21157
+ const index = readSkillLibraryIndex();
21158
+ const names = [...new Set(parsed.data.skills)];
21159
+ const unknown = names.filter((name) => !index[name]);
21160
+ if (unknown.length)
21161
+ return json(res, 422, { error: "not in the library: " + unknown.join(", ") });
21162
+ store.patchBot(m[1], { assignedSkills: names });
21163
+ return json(res, 200, { assignedSkills: names });
21164
+ }
19818
21165
  // ── section context: a user-owned team brief ────────────────────────
19819
21166
  // Bots receive this in their system context, but no agent tool can write
19820
21167
  // it. That keeps one bot from silently changing every teammate's future
19821
21168
  // turns. The section query parameter is required even for General (""),
19822
21169
  // so a malformed client cannot accidentally read or replace that brief.
21170
+ // ── team memory: the section's shared people, places, decisions, terms ──
21171
+ // The person's view and their edits. Same section rule as the brief:
21172
+ // the query parameter is required even for General.
21173
+ if (path === "/api/team-memory" && (method === "GET" || method === "POST")) {
21174
+ if (!url.searchParams.has("section"))
21175
+ return json(res, 400, { error: "section is required" });
21176
+ const section = sectionContextKey(url.searchParams.get("section") ?? "");
21177
+ if (section.length > 60)
21178
+ return json(res, 400, { error: "section must be at most 60 characters" });
21179
+ if (method === "GET") {
21180
+ return json(res, 200, { section, label: sectionContextLabel(section), entries: teamMemory.list(section) });
21181
+ }
21182
+ const body = await readBody(req);
21183
+ if (!body || typeof body !== "object" || !TEAM_MEMORY_KINDS.includes(String(body.kind))) {
21184
+ return json(res, 400, { error: `kind must be one of ${TEAM_MEMORY_KINDS.join(", ")}` });
21185
+ }
21186
+ try {
21187
+ const proposed = teamMemory.propose(section, { kind: body.kind, name: String(body.name ?? ""), detail: String(body.detail ?? ""), aliases: Array.isArray(body.aliases) ? body.aliases.map(String) : undefined }, { botId: "", botName: "you", threadId: "", at: Date.now() });
21188
+ // the person's own entry never waits on the person
21189
+ if (proposed.status === "proposed")
21190
+ teamMemory.resolve(section, proposed.entry.id, "accept");
21191
+ return json(res, 201, { entries: teamMemory.list(section) });
21192
+ }
21193
+ catch (error) {
21194
+ return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
21195
+ }
21196
+ }
21197
+ m = path.match(/^\/api\/team-memory\/([\w-]+)$/);
21198
+ if (m && (method === "PATCH" || method === "DELETE")) {
21199
+ if (!url.searchParams.has("section"))
21200
+ return json(res, 400, { error: "section is required" });
21201
+ const section = sectionContextKey(url.searchParams.get("section") ?? "");
21202
+ if (method === "DELETE") {
21203
+ if (!teamMemory.remove(section, m[1]))
21204
+ return json(res, 404, { error: "no such entry" });
21205
+ return json(res, 200, { entries: teamMemory.list(section) });
21206
+ }
21207
+ const body = await readBody(req);
21208
+ if (!body || typeof body !== "object")
21209
+ return json(res, 400, { error: "body must be a JSON object" });
21210
+ if (body.accept === true) {
21211
+ const result = teamMemory.resolve(section, m[1], "accept");
21212
+ if (!result.claimed)
21213
+ return json(res, 404, { error: "no such entry" });
21214
+ return json(res, 200, { entries: teamMemory.list(section) });
21215
+ }
21216
+ const patch = {};
21217
+ if (typeof body.name === "string")
21218
+ patch.name = body.name;
21219
+ if (typeof body.detail === "string")
21220
+ patch.detail = body.detail;
21221
+ if (Array.isArray(body.aliases))
21222
+ patch.aliases = body.aliases.map(String);
21223
+ if (typeof body.kind === "string" && TEAM_MEMORY_KINDS.includes(body.kind))
21224
+ patch.kind = body.kind;
21225
+ try {
21226
+ const updated = teamMemory.update(section, m[1], patch);
21227
+ if (!updated)
21228
+ return json(res, 404, { error: "no such entry" });
21229
+ return json(res, 200, { entry: updated, entries: teamMemory.list(section) });
21230
+ }
21231
+ catch (error) {
21232
+ return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
21233
+ }
21234
+ }
19823
21235
  if (path === "/api/section-context" && (method === "GET" || method === "PUT")) {
19824
21236
  if (!url.searchParams.has("section"))
19825
21237
  return json(res, 400, { error: "section is required" });
@@ -20159,53 +21571,17 @@ const handleRequest = async (req, res) => {
20159
21571
  const trigger = onBehalfOf
20160
21572
  ? { kind: "user", ...(onBehalfOf.email ? { email: onBehalfOf.email } : {}), ...(onBehalfOf.name ? { label: onBehalfOf.name } : {}) }
20161
21573
  : usageTriggerFor(auth);
20162
- // The send is acknowledged before the turn starts, so a workspace at its
20163
- // spend limit is refused here, where the person can see it.
20164
- try {
20165
- assertWithinBudget(cfg, DATA_DIR);
20166
- }
20167
- catch (error) {
20168
- return json(res, 409, { error: error instanceof Error ? error.message : String(error), code: "spend_cap" });
20169
- }
20170
- if (!store.taskByThread(bot.id, threadId)) {
20171
- return json(res, 409, { error: "the bot switched tasks before it could receive the message" });
20172
- }
21574
+ // The spend cap and a task that is gone answer before a malformed
21575
+ // sendId or reply target does. acceptDirectSend checks both again for
21576
+ // callers that do not come through this route; nothing awaits between.
21577
+ const refused = directSendRefusal(bot.id, threadId);
21578
+ if (refused)
21579
+ return json(res, refused.status, refused.body);
20173
21580
  const sendId = parseSendId(body.sendId);
20174
21581
  const replyTo = resolveReplyTarget(threadId, body.replyToId);
20175
- const receipt = await sendSequencer.run(sendId ? `bot:${bot.id}:${threadId}:${sendId}` : undefined, sendFingerprint(text, replyTo?.id), async () => {
20176
- if (sendId) {
20177
- if (cancelledChatFollowup("bot", bot.id, threadId, sendId)) {
20178
- throw Object.assign(new Error("this queued sendId was cancelled; send a new message to try again"), { status: 409 });
20179
- }
20180
- const accepted = acceptedSendMatch(store.messagesFor(threadId), sendId, text, replyTo?.id);
20181
- if (accepted.kind === "conflict") {
20182
- throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
20183
- }
20184
- if (accepted.kind === "match") {
20185
- const canonical = {
20186
- ok: true,
20187
- threadId,
20188
- message: accepted.message,
20189
- };
20190
- return accepted.message.steered
20191
- ? { ...canonical, steered: true }
20192
- : canonical;
20193
- }
20194
- const queued = queuedSteeredMessage(bot.id, threadId, sendId);
20195
- if (queued) {
20196
- if (queued.text !== text || queued.replyToId !== replyTo?.id) {
20197
- throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
20198
- }
20199
- return { ok: true, queued: true, queueId: queued.id, threadId, reason: queued.reason };
20200
- }
20201
- }
20202
- const currentAtStart = store.projectBotForTask(bot.id, threadId);
20203
- if (!currentAtStart)
20204
- throw Object.assign(new Error("no such bot"), { status: 404 });
20205
- if (!store.taskByThread(currentAtStart.id, threadId)) {
20206
- throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
20207
- }
20208
- if (guarded) {
21582
+ const sender = messageSender(auth);
21583
+ const guardedStart = guarded
21584
+ ? async (currentAtStart) => {
20209
21585
  // There is no await between these checks and startTurn's
20210
21586
  // synchronous transcript append / runtime reservation. In
20211
21587
  // particular, never steer or enqueue under stale permissions.
@@ -20227,91 +21603,32 @@ const handleRequest = async (req, res) => {
20227
21603
  if (guardedAdmission.action === "refuse") {
20228
21604
  throw Object.assign(new Error("wait for a free thread slot before retrying this message"), { status: 409, code: "guarded_busy" });
20229
21605
  }
20230
- const message = await startTurn(bot.id, text, { threadId, replyTo, sendId, sender: messageSender(auth), trigger });
21606
+ // Stored as relayed: a worker's line for someone else, which a
21607
+ // Live call on this thread must not read back as typed there.
21608
+ const message = await startTurn(bot.id, text, { threadId, replyTo, sendId, sender, trigger, relayed: true });
20231
21609
  return { ok: true, threadId, message };
20232
21610
  }
20233
- // Claude can accept the message inside its live turn. If the write
20234
- // loses a race with turn settlement, or the engine cannot steer, the
20235
- // existing server-side queue records it atomically for the next turn.
20236
- if (currentAtStart.busy) {
20237
- const instance = runningTurnInstance(currentAtStart, threadId);
20238
- let steered = "refused";
20239
- // A live text steer has no image side channel. Keep an attachment
20240
- // message intact for the next ordinary turn, where central image
20241
- // admission can hand it to the provider natively.
20242
- const carriesImages = extractTurnImages(text).images.length > 0;
20243
- const steerTarget = handoffs.current(threadId);
20244
- const busyAdmission = admit("direct-busy", {
20245
- carriesImages,
20246
- pendingComputerSelection: Boolean(computerSelectionTurns.get(threadId)?.selected),
20247
- engineCanSteer: Boolean(instance?.adapter.capabilities.queueing && instance.adapter.steer),
20248
- });
20249
- // steer was offered only when a live instance could take it;
20250
- // the second check carries that fact to the type system.
20251
- if (busyAdmission.action === "steer" && instance?.adapter.steer) {
20252
- steered = await instance.adapter
20253
- .steer(threadId, promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"))
20254
- .catch(() => "indeterminate");
20255
- }
20256
- // steer() is awaited adapter work. The turn can settle, the task can
20257
- // switch, or the whole bot can be deleted before its acknowledgement
20258
- // arrives. Re-read every ownership invariant before appending even a
20259
- // successful steer; otherwise that late acknowledgement writes a user
20260
- // message into a task the bot no longer owns. A conflict leaves the
20261
- // text in the client's composer/outbox to resend deliberately.
20262
- const current = store.projectBotForTask(bot.id, threadId);
20263
- if (!current)
20264
- throw Object.assign(new Error("no such bot"), { status: 404 });
20265
- if (!store.taskByThread(bot.id, threadId)) {
20266
- throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
20267
- }
20268
- const delivered = steered !== "refused";
20269
- if (delivered) {
20270
- if (steered === "steered" && !current.busy) {
20271
- throw Object.assign(new Error("the running turn ended before the steered message could be recorded"), { status: 409 });
20272
- }
20273
- // "indeterminate" falls through to the same record: the words
20274
- // may already be folded into a turn whose acknowledgement was
20275
- // lost, and handing them back for a resend could run them
20276
- // twice. Recording them once is the honest outcome.
20277
- // A person steering a webhook turn is present, and auto mode may
20278
- // follow them again. But this route is also reachable from the
20279
- // bot's own shell on a headless server (loopback is the owner
20280
- // there), and "continue" typed by the turn itself must not be
20281
- // the thing that lifts the block written against it — so only
20282
- // a request that proves a person (a paired session, or the
20283
- // desktop's owner capability, which every mutation there has
20284
- // already shown) clears the mark.
20285
- if (auth.kind === "session" || DESKTOP_MANAGED)
20286
- clearUnattended(threadId);
20287
- const message = store.appendMessage(threadId, {
20288
- role: "user",
20289
- kind: "text",
20290
- text,
20291
- replyToId: replyTo?.id,
20292
- sendId,
20293
- steered: true,
20294
- sender: messageSender(auth),
20295
- });
20296
- // Offered to the next turn again unless the person stops this one.
20297
- handoffs.steered(threadId, steerTarget, instance?.instanceId, message.id);
20298
- return { ok: true, steered: true, threadId, message };
20299
- }
20300
- if (!current.busy) {
20301
- return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId, messageSender(auth), trigger);
20302
- }
20303
- const queued = queueSteeredMessage(current.id, threadId, text, {
20304
- replyToId: replyTo?.id,
20305
- sendId,
20306
- prompt: promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"),
20307
- sender: messageSender(auth),
20308
- trigger,
20309
- });
20310
- return { ok: true, queued: true, queueId: queued.id, threadId };
20311
- }
20312
- return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId, messageSender(auth), trigger);
20313
- });
20314
- return json(res, 202, receipt);
21611
+ : undefined;
21612
+ try {
21613
+ const receipt = await acceptDirectSend({
21614
+ botId: bot.id, threadId, text, sendId, replyTo, sender, trigger,
21615
+ // A person steering a webhook turn is present, and auto mode may
21616
+ // follow them again. But this route is also reachable from the
21617
+ // bot's own shell on a headless server (loopback is the owner
21618
+ // there), and "continue" typed by the turn itself must not be the
21619
+ // thing that lifts the block written against it — so only a
21620
+ // request that proves a person (a paired session, or the desktop's
21621
+ // owner capability, which every mutation there has already shown)
21622
+ // clears the mark.
21623
+ personPresent: auth.kind === "session" || DESKTOP_MANAGED,
21624
+ }, guardedStart);
21625
+ return json(res, 202, receipt);
21626
+ }
21627
+ catch (error) {
21628
+ if (error instanceof DirectSendRefused)
21629
+ return json(res, error.status, error.body);
21630
+ throw error;
21631
+ }
20315
21632
  }
20316
21633
  m = path.match(/^\/api\/bots\/([\w-]+)\/queue\/([\w-]+)$/);
20317
21634
  if (m && method === "DELETE") {
@@ -20378,6 +21695,7 @@ const handleRequest = async (req, res) => {
20378
21695
  peerAsk: item.peerAsk,
20379
21696
  steered: true,
20380
21697
  sender: item.sender,
21698
+ ...(item.via ? { via: item.via } : {}),
20381
21699
  }));
20382
21700
  // Offered to the next turn again unless the person stops this one.
20383
21701
  for (const message of messages)
@@ -20515,6 +21833,8 @@ const handleRequest = async (req, res) => {
20515
21833
  return json(res, 403, { error: refusal });
20516
21834
  }
20517
21835
  return await answeringCardAs(auth, bot.threadId, String(body.requestId), async () => {
21836
+ if (resolveAndSendTrust(res, bot.threadId, String(body.requestId), behavior))
21837
+ return;
20518
21838
  if (await resolveAndSendTeamSetup(res, {
20519
21839
  botId: bot.id, threadId: bot.threadId, requestId: String(body.requestId), behavior,
20520
21840
  }, auth.kind === "loopback" ? DESKTOP_MANAGED || Boolean(req.headers.origin) && !store.bots.some((bot) => bot.busy || activeGroupTurnForBot(bot.id)) : auth.scopes.includes("admin")))
@@ -20567,7 +21887,32 @@ const handleRequest = async (req, res) => {
20567
21887
  resolvePeerComms(approvalBus, String(body.requestId), behavior)) {
20568
21888
  return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
20569
21889
  }
20570
- const outcome = await answerRequest(bot.threadId, bot.modelSelection.instanceId, String(body.requestId), behavior, body.message, { id: bot.id, name: bot.name }, body.always === true, body.rememberCommand === true);
21890
+ const requestId = String(body.requestId);
21891
+ const pending = store.messagesFor(bot.threadId).find((message) => message.card?.requestId === requestId);
21892
+ if (isPersistentQuestionCard(pending?.card) && (body.dismiss === true || body.message === QUESTION_DISMISS_MESSAGE)) {
21893
+ if (!pending?.card)
21894
+ return json(res, 404, { error: "this question is no longer available" });
21895
+ if (pending.card.answered !== "answer" || pending.card.dismissed) {
21896
+ return json(res, 409, { error: "answer this question before dismissing it" });
21897
+ }
21898
+ store.patchMessage(bot.threadId, pending.id, { card: { ...pending.card, dismissed: true } });
21899
+ return json(res, 200, { ok: true, dismissed: true });
21900
+ }
21901
+ const outcome = await answerRequest(bot.threadId, bot.modelSelection.instanceId, requestId, behavior, body.message, { id: bot.id, name: bot.name }, body.always === true, body.rememberCommand === true);
21902
+ if (outcome === "unavailable" && isPersistentQuestionCard(pending?.card) && behavior === "answer") {
21903
+ if (!pending)
21904
+ return json(res, 404, { error: "this question is no longer available" });
21905
+ const answer = typeof body.message === "string" ? body.message.trim() : "";
21906
+ if (!answer)
21907
+ return json(res, 400, { error: "a question answer is required" });
21908
+ try {
21909
+ const reply = await deliverLateQuestionAnswer(auth, bot.threadId, requestId, answer, pending, bot);
21910
+ return json(res, 200, { ok: true, outcome: "answered", late: true, ...(reply.queued ? { queued: true } : {}) });
21911
+ }
21912
+ catch (error) {
21913
+ return json(res, 409, { error: error instanceof Error ? error.message : "the late answer could not be queued" });
21914
+ }
21915
+ }
20571
21916
  return json(res, 200, { ok: true, outcome });
20572
21917
  });
20573
21918
  }
@@ -20592,6 +21937,8 @@ const handleRequest = async (req, res) => {
20592
21937
  return json(res, 403, { error: refusal });
20593
21938
  }
20594
21939
  return await answeringCardAs(auth, threadId, requestId, async () => {
21940
+ if (resolveAndSendTrust(res, threadId, requestId, behavior))
21941
+ return;
20595
21942
  const skillCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.skillRequest);
20596
21943
  if (skillCard?.card?.skillRequest) {
20597
21944
  const skillBotId = skillCard.from?.botId ?? store.botByThread(threadId)?.id;
@@ -20693,14 +22040,46 @@ const handleRequest = async (req, res) => {
20693
22040
  // answerRequest closes an unreachable card, and a pending approval owns
20694
22041
  // the composer, so a dead end here locks the room for good.
20695
22042
  const pending = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId);
22043
+ if (isPersistentQuestionCard(pending?.card) && (body.dismiss === true || body.message === QUESTION_DISMISS_MESSAGE)) {
22044
+ if (!pending?.card)
22045
+ return json(res, 404, { error: "this question is no longer available" });
22046
+ if (pending.card.answered !== "answer" || pending.card.dismissed) {
22047
+ return json(res, 409, { error: "answer this question before dismissing it" });
22048
+ }
22049
+ store.patchMessage(threadId, pending.id, { card: { ...pending.card, dismissed: true } });
22050
+ return json(res, 200, { ok: true, dismissed: true });
22051
+ }
22052
+ const persistentQuestion = isPersistentQuestionCard(pending?.card);
20696
22053
  const owner = group
20697
- ? (group.busyBotId ? store.bot(group.busyBotId) : undefined) ??
20698
- (pending?.from ? store.bot(pending.from.botId) : undefined)
22054
+ ? persistentQuestion
22055
+ ? (pending?.from ? store.bot(pending.from.botId) : undefined)
22056
+ : (group.busyBotId ? store.bot(group.busyBotId) : undefined) ??
22057
+ (pending?.from ? store.bot(pending.from.botId) : undefined)
20699
22058
  : store.botByThread(threadId);
22059
+ // A surviving question belongs to its original asker, never a later speaker.
22060
+ if (group && persistentQuestion && !owner) {
22061
+ return json(res, 409, { error: "the bot that asked this question is no longer available" });
22062
+ }
20700
22063
  if (!owner && !pending)
20701
22064
  return json(res, 404, { error: "nothing is waiting on an answer in this conversation" });
20702
22065
  const requestOwner = owner ? botForThread(owner.id, threadId) : null;
20703
22066
  const outcome = await answerRequest(threadId, requestOwner?.modelSelection.instanceId ?? "", requestId, behavior, body.message, owner ? { id: owner.id, name: owner.name } : undefined, body.always === true, body.rememberCommand === true);
22067
+ if (outcome === "unavailable" && isPersistentQuestionCard(pending?.card) && behavior === "answer") {
22068
+ if (!pending?.card)
22069
+ return json(res, 404, { error: "this question is no longer available" });
22070
+ const answer = typeof body.message === "string" ? body.message.trim() : "";
22071
+ if (!answer)
22072
+ return json(res, 400, { error: "a question answer is required" });
22073
+ if (!owner)
22074
+ return json(res, 409, { error: "the bot that asked this question is no longer available" });
22075
+ try {
22076
+ const reply = await deliverLateQuestionAnswer(auth, threadId, requestId, answer, pending, owner);
22077
+ return json(res, 200, { ok: true, outcome: "answered", late: true, ...(reply.queued ? { queued: true } : {}) });
22078
+ }
22079
+ catch (error) {
22080
+ return json(res, 409, { error: error instanceof Error ? error.message : "the late answer could not be queued" });
22081
+ }
22082
+ }
20704
22083
  return json(res, 200, { ok: true, outcome });
20705
22084
  });
20706
22085
  }
@@ -21697,6 +23076,16 @@ const handleRequest = async (req, res) => {
21697
23076
  billing: prices ? { currency: cfg.billing?.currency ?? "USD" } : null,
21698
23077
  });
21699
23078
  }
23079
+ // Subscription windows (5-hour and weekly), not the token ledger above.
23080
+ // Same admin gate as /api/usage: this route is unlisted, so it stays admin.
23081
+ if (method === "GET" && path === "/api/plan-usage") {
23082
+ res.setHeader("cache-control", "no-store");
23083
+ const report = await loadPlanUsage({
23084
+ accounts: planAccountsFromInstances(instanceConfigs(cfg)),
23085
+ refresh: url.searchParams.get("refresh") === "1",
23086
+ });
23087
+ return json(res, 200, report);
23088
+ }
21700
23089
  // ── provider key check: does a pasted or saved key open the provider's door ──
21701
23090
  // One read-only request from this server; the verdict never carries the
21702
23091
  // key. Admin scope by default, like every route not opened to clients.
@@ -21779,6 +23168,34 @@ const handleRequest = async (req, res) => {
21779
23168
  res.end(decisionsCsv(readDecisionRange(DATA_DIR, range)));
21780
23169
  return;
21781
23170
  }
23171
+ // ── a bot's activity: what it did, with the outcome ──
23172
+ // Read-only over the same two logs as the inspector and the decision
23173
+ // route above. The bot's own threads (its DM and every task) carry the
23174
+ // tool runs; the decision log carries the bot's requests wherever they
23175
+ // happened, since those rows name the bot.
23176
+ m = path.match(/^\/api\/bots\/([\w-]+)\/activity$/);
23177
+ if (m && method === "GET") {
23178
+ const bot = store.bot(m[1]);
23179
+ if (!bot)
23180
+ return json(res, 404, { error: "no such bot" });
23181
+ const rawLimit = url.searchParams.get("limit");
23182
+ const parsedLimit = rawLimit === null ? undefined : Number(rawLimit);
23183
+ if (parsedLimit !== undefined && (!Number.isInteger(parsedLimit) || parsedLimit <= 0)) {
23184
+ return json(res, 400, { error: "limit must be a positive whole number" });
23185
+ }
23186
+ const threadIds = [...new Set([bot.threadId, ...store.tasks(bot.id).map((task) => task.threadId)])];
23187
+ return json(res, 200, {
23188
+ rows: readBotActivity({ dataDir: DATA_DIR, eventsDir: EVENTS_DIR, botId: bot.id, threadIds, limit: parsedLimit ?? 300 }),
23189
+ });
23190
+ }
23191
+ // ── a bot's outbound allowance: the policy, and how much of it is spent ──
23192
+ m = path.match(/^\/api\/bots\/([\w-]+)\/outbound$/);
23193
+ if (m && method === "GET") {
23194
+ const bot = store.bot(m[1]);
23195
+ if (!bot)
23196
+ return json(res, 404, { error: "no such bot" });
23197
+ return json(res, 200, { policy: bot.outbound ?? DEFAULT_OUTBOUND_POLICY, today: outboundCounts.today(bot.id) });
23198
+ }
21782
23199
  // ── provider instances (model picker) ──
21783
23200
  if (method === "GET" && path === "/api/instances") {
21784
23201
  // Rescan PATH first: this endpoint is how the app answers "what can I
@@ -22063,7 +23480,7 @@ const handleRequest = async (req, res) => {
22063
23480
  catch { /* Allow repairing an unused malformed account. */ }
22064
23481
  entry.config = { ...entry.config, configDir: body.configDir };
22065
23482
  if (previousDir !== configuredAccountDirectory(entry)) {
22066
- const used = store.bots.some((bot) => bot.modelSelection.instanceId === instanceId || store.tasks(bot.id).some((task) => task.modelSelection?.instanceId === instanceId || task.resumeCursors[instanceId] || task.lastInstanceId === instanceId));
23483
+ const used = store.bots.some((bot) => bot.modelSelection.instanceId === instanceId || bot.fallback?.some(candidate => candidate.instanceId === instanceId) || store.tasks(bot.id).some((task) => task.modelSelection?.instanceId === instanceId || task.resumeCursors[instanceId] || task.lastInstanceId === instanceId));
22067
23484
  if (used)
22068
23485
  return json(res, 409, { error: "This account is used by bots or conversation history. Add another account and select it for the bot instead." });
22069
23486
  assertSeparateClaudeAccount(instances, instanceId, entry);
@@ -22087,7 +23504,7 @@ const handleRequest = async (req, res) => {
22087
23504
  if (instances[instanceId].driver !== "claudeAgent" || instanceId === "claude") {
22088
23505
  return json(res, 400, { error: "Only added Claude accounts can be removed here." });
22089
23506
  }
22090
- if (cfg.defaultModelSelection?.instanceId === instanceId || store.bots.some((bot) => bot.modelSelection.instanceId === instanceId || store.tasks(bot.id).some((task) => task.modelSelection?.instanceId === instanceId)) ||
23507
+ if (cfg.defaultModelSelection?.instanceId === instanceId || store.bots.some((bot) => bot.modelSelection.instanceId === instanceId || bot.fallback?.some(candidate => candidate.instanceId === instanceId) || store.tasks(bot.id).some((task) => task.modelSelection?.instanceId === instanceId)) ||
22091
23508
  busyProviderSelections().some((selection) => selection.instanceId === instanceId)) {
22092
23509
  return json(res, 409, { error: "Choose another account for the bots and default model using this account before removing it." });
22093
23510
  }
@@ -22746,6 +24163,8 @@ const handleRequest = async (req, res) => {
22746
24163
  persisted.imageGen.key = "";
22747
24164
  if (persisted.imageGen?.customApiKey !== undefined)
22748
24165
  persisted.imageGen.customApiKey = "";
24166
+ if (persisted.live?.key !== undefined)
24167
+ persisted.live.key = "";
22749
24168
  saveConfig(persisted);
22750
24169
  configWriteCommitted = true;
22751
24170
  syncCredentialEnv(patch);
@@ -22856,6 +24275,7 @@ const handleRequest = async (req, res) => {
22856
24275
  drainQueuedSends();
22857
24276
  drainDelegationWakes();
22858
24277
  drainConnectorResumes();
24278
+ drainComputerResumes();
22859
24279
  drainSecretResumes();
22860
24280
  drainTeamSetupResumes();
22861
24281
  }
@@ -23502,6 +24922,7 @@ for (const row of chatFollowups()) {
23502
24922
  role: "user", kind: "text", text: row.kind === "aside" ? row.payload.prompt ?? row.payload.text : row.payload.text, replyToId: row.payload.replyToId,
23503
24923
  sendId: row.payload.sendId, queueId: row.id, sender: row.payload.sender,
23504
24924
  ...(row.kind === "channel" ? { channelMode: row.payload.mode, via: row.payload.via } : {}),
24925
+ ...(row.kind === "bot" && row.payload.via === "call" ? { via: "call" } : {}),
23505
24926
  ...(row.kind === "aside" ? {
23506
24927
  aside: true,
23507
24928
  peerAsk: row.payload.aside
@@ -23617,6 +25038,7 @@ const gracefulShutdown = createGracefulShutdown({
23617
25038
  await Promise.all([...temporaryBrowserSessions.keys()].map((botId) => forgetTemporaryBrowser(botId)));
23618
25039
  await browserRuntime.closeAll();
23619
25040
  },
25041
+ () => liveCalls.shutdown(),
23620
25042
  () => flushAllProfileHistory(),
23621
25043
  () => flushAllMemoryJournals(),
23622
25044
  () => flushUsageLedger(DATA_DIR),