openmausbot 0.1.94 → 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 (145) hide show
  1. package/dist/assets/{DesktopViewer-bxM7NV3N.js → DesktopViewer-BwWpc4RK.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-BhMdJhRJ.js → abnfDiagram-O67JEVCF-jsQkIqTn.js} +1 -1
  3. package/dist/assets/{arc-B5itKDcx.js → arc-7kei4Hsu.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-C5d7UY6U.js → architectureDiagram-NJMV4G6O-CRWDFDMq.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-BrZIhMmu.js → blockDiagram-BEXU5L5S-CTKW_IKs.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-7I0ArfbD.js → c4Diagram-YGBWAQC7-BBTW7-6M.js} +1 -1
  7. package/dist/assets/channel-DS2NbT5x.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-BnEsyN36.js → chunk-2Q5K7J3B-M_Ox-MkD.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BXATQ52s.js → chunk-3FUC2YCW-D2YiN-Ud.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-B0BpTkEx.js → chunk-5VM5RSS4-BmVYJ0f8.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-BtLTz6FR.js → chunk-GTNCS2PH-BtiLVVnZ.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-D6gcZOYt.js → chunk-GWA4HPMP-DFYSb6J6.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-DCLrosvf.js → chunk-JWPE2WC7-mknKBzgV.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-B9JsknV9.js → chunk-NETBCI7D-DgSYGiOu.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CsoTxw7z.js → chunk-WEXAMYUT-C7ZvuguH.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-0F_frB0c.js → chunk-XXDRQBXY-CPqxINpA.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-DkrpnflX.js → classDiagram-v2-NBCMYWYE-Cd6xfJaC.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-BNDwYBt9.js → cose-bilkent-JH36ORCC-BSXY5qRh.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-JUkNr1Mk.js → cynefin-OW5HDTMX-D_lhEMj1.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-DKeh9N4M.js → cynefinDiagram-VND7K2PF-Da9FrFwk.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-ve-6T_wX.js → dagre-6A5THRUB-T77jMOXe.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-BuJ2d8rT.js → diagram-22UHCM2B-Dkj96X5E.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-BgOs_Ni-.js → diagram-3UASUU5V-CMi9zlD_.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-DMK6VmcP.js → diagram-ATOU4E4O-CXK6YUfS.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-DksASlcY.js → diagram-CDSNMT55-BE7B1u9Q.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-Bgn8I5k6.js → diagram-MLGK6HIB-CCMQphKr.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-zK0jqO8N.js → diagram-MPIPVDR6-iHJ_-tGH.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-vKrbzTwr.js → ebnfDiagram-ZINNZB2B-DONsCWQ7.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-Bzv2mPNf.js → elk-276RUBZZ-CU_WOaXk.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-CvOyYuxu.js → erDiagram-OPXOYQCR-BeYwM04G.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-CtbnDbc0.js → flowDiagram-KWPJA3E3-Bdw9DqEl.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-c4dMRYyG.js → ganttDiagram-FUAMR5RP-C9iwq3qm.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-B34uaQho.js → gitGraphDiagram-X574FWY7-Dx-VWzUg.js} +1 -1
  34. package/dist/assets/{index-CPiqc_DN.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-o64ma4CI.js → infoDiagram-VRGFBTTK-BG7uaEd4.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-DV4C9BAm.js → ishikawaDiagram-OU5B5YK6-D72WAXnM.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-Bf0AHk1H.js → journeyDiagram-ZHPQQLJL-DKH1gjYT.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-B1UMSKmE.js → kanban-definition-PNTS6WVX-3upm9JUT.js} +1 -1
  41. package/dist/assets/{linear-BpKhuyLm.js → linear-CcR4pc0-.js} +1 -1
  42. package/dist/assets/{mermaid.core-B_BKNCw5.js → mermaid.core-C6yU5FSS.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-DeARruT1.js → mindmap-definition-NLK3R4M7-DOI2xEuv.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-BOQEk_1D.js → pegDiagram-GJSIUBJH-BVWJSgLd.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-EIfoiTZo.js → pieDiagram-5QR66LMP-jtbYezhq.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-GgZwT-fi.js → quadrantDiagram-O4NWA36T-g0u8mtPX.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-D9rDQomj.js → railroadDiagram-XR7U4H2S-k8689Wny.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-DAh1MIeo.js → requirementDiagram-PLB6GJNP-DsJ22PDt.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-CPr6Uz64.js → sankeyDiagram-IPEJSGJF-CneWBRgG.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-BU6VIdcm.js → sequenceDiagram-PO4LG4MO-DWsFCWap.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-qF2HO41r.js → sizeCapture-INFHLROL-Bvf0YE3S.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-DlJ8G4MD.js → stateDiagram-v2-GCMORJYK-Bxb3Iei8.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DzYE8I54.js → swimlanes-2SLR337P-DLkk7W4A.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-C6bZtBXk.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-B7wzEQ0A.js → timeline-definition-EJHVYXUP-KR9bIHYC.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-BvGgrxIP.js → usecaseDiagram-POWQR4AR-DfsOrj8t.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-CEECdgQg.js → vennDiagram-UO4OBE2U-DRSTzG3h.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-XQHA0P_P.js → wardleyDiagram-VNRHLVJA-DtEdhPgT.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-B5mLcZI2.js → xychartDiagram-PMCCYNJV-B-XhTxjG.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/cloud-home-start.js +2 -1
  62. package/dist-server/companion/src/routes.js +39 -0
  63. package/dist-server/container-mcp.js +2 -2
  64. package/dist-server/drivers/agents-proxy.js +58 -38
  65. package/dist-server/enterprise/server/index.js +2 -1
  66. package/dist-server/{browser-proxy.js → harness-mcp-proxy.js} +35 -19
  67. package/dist-server/index.js +3159 -2313
  68. package/dist-server/local-computer.js +2 -1
  69. package/dist-server/openmausbot.js +314 -1336
  70. package/dist-server/pair-cli.js +314 -1336
  71. package/dist-server/proxy-paths.js +2 -1
  72. package/dist-server/server/account-signin.js +0 -2
  73. package/dist-server/server/activity.js +201 -0
  74. package/dist-server/server/auto-approve.js +6 -0
  75. package/dist-server/server/automatic-recovery.js +1 -1
  76. package/dist-server/server/boat-create-idempotency.js +0 -7
  77. package/dist-server/server/boat-delete-journal.js +0 -4
  78. package/dist-server/server/bot-package.js +1 -19
  79. package/dist-server/server/browser-runtime.js +1 -2
  80. package/dist-server/server/chief-of-staff.js +2 -3
  81. package/dist-server/server/cloud-backend.js +0 -4
  82. package/dist-server/server/cloud-computer-tools.js +110 -0
  83. package/dist-server/server/cloud-home.js +0 -1
  84. package/dist-server/server/cloud-lending.js +0 -3
  85. package/dist-server/server/composio.js +31 -2
  86. package/dist-server/server/computer-control.js +0 -15
  87. package/dist-server/server/computer-observation.js +0 -74
  88. package/dist-server/server/config.js +29 -1
  89. package/dist-server/server/container-computer.js +7 -19
  90. package/dist-server/server/contracts.js +9 -0
  91. package/dist-server/server/control-client.js +4 -10
  92. package/dist-server/server/drivers/acp/core.js +7 -5
  93. package/dist-server/server/drivers/agents-call.js +42 -29
  94. package/dist-server/server/drivers/agents-catalog.js +24 -11
  95. package/dist-server/server/drivers/agents-proxy.js +8 -30
  96. package/dist-server/server/drivers/antigravity.js +1 -4
  97. package/dist-server/server/drivers/boatagent.js +23 -4
  98. package/dist-server/server/drivers/cerebras.js +3 -3
  99. package/dist-server/server/drivers/chat-mcp-tools.js +4 -7
  100. package/dist-server/server/drivers/claude.js +16 -14
  101. package/dist-server/server/drivers/openai-chat.js +3 -6
  102. package/dist-server/server/drivers/retry.js +8 -4
  103. package/dist-server/server/engine-install.js +1 -5
  104. package/dist-server/server/harness/registry.js +0 -1
  105. package/dist-server/server/{browser-proxy.js → harness-mcp-proxy.js} +40 -20
  106. package/dist-server/server/index.js +789 -256
  107. package/dist-server/server/lending-memory.js +12 -8
  108. package/dist-server/server/memory-entries.js +38 -8
  109. package/dist-server/server/memory-organize.js +1 -8
  110. package/dist-server/server/memory-topics.js +4 -4
  111. package/dist-server/server/memory-upkeep.js +13 -15
  112. package/dist-server/server/message-file.js +5 -9
  113. package/dist-server/server/outbound-counts.js +79 -0
  114. package/dist-server/server/outbound-requests.js +60 -0
  115. package/dist-server/server/package-import.js +1 -1
  116. package/dist-server/server/peer-delivery.js +1 -0
  117. package/dist-server/server/proxy-paths.js +2 -1
  118. package/dist-server/server/room-handoffs.js +5 -13
  119. package/dist-server/server/routine-requests.js +1 -1
  120. package/dist-server/server/routines.js +5 -1
  121. package/dist-server/server/setup-mode.js +0 -2
  122. package/dist-server/server/skill-learn.js +0 -1
  123. package/dist-server/server/skill-library.js +0 -15
  124. package/dist-server/server/store.js +9 -1
  125. package/dist-server/server/surface.js +65 -20
  126. package/dist-server/server/system-prompt.js +4 -4
  127. package/dist-server/server/team-memory.js +209 -0
  128. package/dist-server/server/thread-events.js +2 -2
  129. package/dist-server/server/vps-computer.js +4 -14
  130. package/dist-server/server/workspace.js +85 -47
  131. package/dist-server/shared/activity.js +3 -0
  132. package/dist-server/shared/bot-avatar.js +8 -0
  133. package/dist-server/shared/connector-scopes.js +78 -0
  134. package/dist-server/shared/outbound.js +128 -0
  135. package/dist-server/shared/tool-surface.js +3 -2
  136. package/dist-server/vps-container-mcp.js +2 -2
  137. package/enterprise/server/index.js +2 -1
  138. package/package.json +1 -1
  139. package/dist/assets/channel-CdJX6i8k.js +0 -1
  140. package/dist/assets/index-CPYrGsv1.js +0 -610
  141. package/dist/assets/index-CzT42GR8.css +0 -1
  142. package/dist/assets/swimlanesDiagram-TC7HE7FX-D-U4GtLQ.js +0 -8
  143. package/dist-server/server/drivers/chat-boat-tools.js +0 -90
  144. package/dist-server/server/drivers/grokagent.js +0 -4
  145. package/dist-server/server/openmaus-status-capsule.js +0 -329
@@ -32,7 +32,7 @@ import { createApprovalModeSupport } from "./harness-capabilities.js";
32
32
  import { escapeAttribute } from "../src/lib/composer-attachments.js";
33
33
  import { threadRefUrl } from "../src/lib/thread-refs.js";
34
34
  import { CREDENTIAL_TARGETS, credentialResumeOutcome, credentialIsConfigured, isPendingCredentialRequest, isCredentialTargetId, } from "../shared/credential-request.js";
35
- import { approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval, delegatedApprovalMode } from "./auto-approve.js";
35
+ import { HELD_NOTE, approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval, delegatedApprovalMode } from "./auto-approve.js";
36
36
  import { CommandAllowlistStore, commandAllowlistCandidate } from "./command-allowlist.js";
37
37
  import { updateClaudeCli } from "./claude-update.js";
38
38
  import { configuredAccountDirectory, assertSeparateClaudeAccount, claudeAccountInfo, createClaudeAccountSchema, instanceSettingsSchema, newClaudeAccount } from "./claude-accounts.js";
@@ -51,6 +51,7 @@ import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
51
51
  import { groupTurnCwd } from "./room-cwd.js";
52
52
  import { RoomTurnDeadline, RoomTurnStallRegistry, roomTurnTimeoutMessage } from "./room-turn-timeout.js";
53
53
  import * as boat from "./boat.js";
54
+ import { cloudComputerRpc } from "./cloud-computer-tools.js";
54
55
  import { TeamComputers, teamComputerAssignment, teamComputerCreate, teamComputerOwner } from "./team-computers.js";
55
56
  import { isEffortLevel } from "../shared/wire.js";
56
57
  import { isPersistentQuestionCard, QUESTION_DISMISS_MESSAGE, shouldSettleRequestCard } from "../shared/ask-question.js";
@@ -66,10 +67,9 @@ import { buildRecall } from "./recall.js";
66
67
  import { createMemoryUpkeep, upkeepEnabled } from "./memory-upkeep.js";
67
68
  import { appendAboutMe, commitLearned, planLearned } from "./profile-learned.js";
68
69
  import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, livePeerRosterBlock, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
69
- import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
70
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";
71
71
  import { attachForTurn, saveBotAttachment } from "./bot-attachment.js";
72
- import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmIdleTimeoutMinutes, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, computerClaimIdleReleaseEnabled, cloudOverflowAllowlistedThreads, cloudOverflowEnabled, cloudOverflowIdleStopMs, cloudOverflowPerSecondCostUsd, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, stripWorkspaceCredentialEnv, PROVIDER_CREDENTIAL_ENV, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, skillsLibraryEnabled, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, CLAUDE_API_INSTANCE, liveSettingsFor, } from "./config.js";
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";
73
73
  import { sweepThreadEventLogs } from "./thread-retention.js";
74
74
  import { ComputerControl } from "./computer-control.js";
75
75
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
@@ -85,7 +85,7 @@ import { entitled } from "./enterprise.js";
85
85
  import { HOSTED_CONTRACT_HEADER, HOSTED_CONTRACT_METADATA, HOSTED_CONTRACT_VERSION } from "./hosted-contract.js";
86
86
  import { describeSpawnFailure, execCli } from "./procs.js";
87
87
  import { blockedTarget, buildNotification, buildSpendNotification } from "./notify.js";
88
- import { isModelVariant, TurnNotStartedError, newId, } from "./contracts.js";
88
+ import { isModelVariant, TurnNotStartedError, newId, usesCloudComputer, } from "./contracts.js";
89
89
  import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.js";
90
90
  import { recoveryCapabilityError } from "./automatic-recovery.js";
91
91
  import { decodeGeneratedImage } from "./generated-image.js";
@@ -150,7 +150,7 @@ import { botSkillTemplateSchema, newBotDefaultsSchema, resolveBotCreationDefault
150
150
  import { expandLearnTurnText, learnSource } from "./skill-learn.js";
151
151
  import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
152
152
  import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-folder.js";
153
- import { buildSystemPrompt, userProfileSystemPrompt, cloudHomePrompt, computerPrompt, composioSystemPrompt, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, resolveComputerPromptKind, } from "./system-prompt.js";
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";
154
154
  import { readCuaConnection, readCuaUnavailableReason, gatedLocalComputer } from "./local-computer.js";
155
155
  import { discoverExistingPerBotLocalVms, discoverExistingPoolLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
156
156
  import { localVmStopReason, recordLocalVmIdleStop } from "./local-vm-stop-reason.js";
@@ -183,6 +183,12 @@ import { fetchGithubTeam, fetchLibraryTeam, fetchTeamCatalog } from "./team-libr
183
183
  import { BOT_PACKAGE_MAX_SKILLS, isBotPackage, packageSummary, parsePackageDocument, renderBotPackageMarkdown } from "./bot-package.js";
184
184
  import { createTeamManifest, parseTeamManifest } from "./team-manifest.js";
185
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";
186
192
  import { bindThreadLogCapProvider } from "./thread-log-rotation.js";
187
193
  import { listenWebhookIngress, webhookCredential } from "./webhook-ingress.js";
188
194
  import { assertModelVariantSupported, memberTurnSelection } from "./member-turn.js";
@@ -204,7 +210,7 @@ import { startAutoVmClaim } from "./auto-vm-claims.js";
204
210
  import { computerFreeAfterText, computerParkedText, computerStoppedWaitingText, computerWaitingText } from "./computer-wait.js";
205
211
  import { CloudOverflowConsent, CloudSeatLease, cloudOverflowAction, cloudOverflowConsentText, cloudOverflowOfferText, cloudSeatStartedText, cloudSeatStoppedText } from "./cloud-overflow.js";
206
212
  import { modelContextWindow } from "./model-context-window.js";
207
- import { parseSurface, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
213
+ import { cloudPlaceDriverError, parseSurface, PlaceUnavailableError, placeUnavailable, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
208
214
  import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
209
215
  import { createGracefulShutdown } from "./graceful-shutdown.js";
210
216
  import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
@@ -292,10 +298,12 @@ const workspaceMaintenance = new WorkspaceBackupMaintenance();
292
298
  // Remote clients (server/request-auth.ts, server/sessions.ts): a stable identity
293
299
  // for this server, the paired sessions, and the cookie the served UI uses.
294
300
  const ENVIRONMENT_ID = loadEnvironmentId(DATA_DIR);
295
- function signInAllowList() {
296
- const current = loadConfig().signIn;
297
- return { admins: parseAllowList(current?.admins?.join(",")), members: parseAllowList(current?.members?.join(",")) };
298
- }
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
+ }));
299
307
  const sessions = new SessionRegistry({
300
308
  file: join(DATA_DIR, "sessions.json"),
301
309
  emailScopesSnapshot: () => {
@@ -940,6 +948,9 @@ function cardAnswerRefusal(auth, threadId, requestId, behavior) {
940
948
  ? "A local service can only decline this request. Approve or answer it in OpenMausBot while signed in."
941
949
  : null;
942
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
+ }
943
954
  // A Cloud home is one person's: a device paired with chat-only access (a
944
955
  // guest) may read along but never answer, or its words would reach the
945
956
  // owner's turn, and through it a lent Mac (docs/cloud-pro.md).
@@ -2724,8 +2735,8 @@ async function browserIntegration(botId, profile, turn) {
2724
2735
  const token = mintInternalCapability({ botId, ...turn, browserSession: session,
2725
2736
  kind: "browser", depth: 0, skillAuthoring: false, createdBots: 0, openedThreads: 0 });
2726
2737
  return { profile: partitionId, session, spec, integration: {
2727
- command: process.execPath, args: [SPAWNED_PROXIES.browser], env: {
2728
- ...AGENTS_NODE_FLAG, OMB_BROWSER_TOKEN: token, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`,
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}`,
2729
2740
  },
2730
2741
  } };
2731
2742
  }
@@ -2826,8 +2837,10 @@ const routineRequestEnvelopeSchema = z.discriminatedUnion("action", [
2826
2837
  }).strict(),
2827
2838
  ...["pause", "resume", "run_now", "delete"].map((action) => z.object({ ...routineRequestSourceSchema, action: z.literal(action), routineId: z.unknown(), forBotId: z.unknown().optional() }).strict()),
2828
2839
  ]);
2829
- /** The loopback endpoint a bot's computer proxy polls before acting. */
2830
- 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) {
2831
2844
  return {
2832
2845
  url: `http://127.0.0.1:${PORT}/api/internal/computer-control?botId=${encodeURIComponent(botId)}`,
2833
2846
  token: mintInternalCapability({
@@ -2837,6 +2850,7 @@ function controlIntegration(botId, threadId, generation, localVmTarget) {
2837
2850
  depth: 0,
2838
2851
  kind: "computer",
2839
2852
  ...(localVmTarget ? { localVmTarget } : {}),
2853
+ ...(boxId ? { boxId } : {}),
2840
2854
  ...(teamComputerTurns.get(threadId) ? { teamComputerId: teamComputerTurns.get(threadId).computerId } : {}),
2841
2855
  skillAuthoring: false,
2842
2856
  createdBots: 0,
@@ -3318,7 +3332,7 @@ function previewSystemPrompt(bot) {
3318
3332
  ]
3319
3333
  .filter(Boolean)
3320
3334
  .join(" ");
3321
- const instance = turnInstance(bot);
3335
+ const instance = registry.get(bot.modelSelection.instanceId);
3322
3336
  const caps = instance?.adapter.capabilities;
3323
3337
  const teamComputer = inheritedTeamComputer(bot);
3324
3338
  const previewComputer = teamComputer ? "cloud" : bot.computer;
@@ -3326,12 +3340,11 @@ function previewSystemPrompt(bot) {
3326
3340
  kind: previewComputer === "vm" ? "vm" : previewComputer === "cloud"
3327
3341
  ? bot.cloudBackend === "vps" ? "vps" : "box" : previewComputer === "local" ? "local" : null,
3328
3342
  driverKind: instance?.driverKind,
3329
- cloudComputerMcp: caps?.cloudComputerMcp,
3330
3343
  vmPrivate: localVmMode(cfg) === "per-bot",
3331
3344
  });
3332
3345
  const peers = reachablePeers(store.bots, bot);
3333
3346
  const coordination = bot.chiefOfStaff
3334
- ? chiefOfStaffSystemPrompt(bot.id, store.bots, true, openMausStatusSystemPrompt())
3347
+ ? chiefOfStaffSystemPrompt(bot.id, store.bots, true)
3335
3348
  : peers.length > 0
3336
3349
  ? peerRosterSystemPrompt(peers)
3337
3350
  : "";
@@ -3367,7 +3380,7 @@ function previewSystemPrompt(bot) {
3367
3380
  browser: previewPlan.computer === undefined ? false : previewPlan.browser,
3368
3381
  }, { note: previewPlan.note, cloudHome: Boolean(CLOUD_HOME) }) },
3369
3382
  { id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(agentsMounted && lendingEnabled()) : "" },
3370
- { id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? composioSystemPrompt(bot.connectorTools) : "" },
3383
+ { id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? composioSystemPrompt(bot.connectorTools) + describeConnectorScopes(bot.connectorScopes) : "" },
3371
3384
  { id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(engineMcpServers(bot))) : "" },
3372
3385
  { id: "browser", label: "Browser", text: previewPlan.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
3373
3386
  { id: "coordination", label: "Team", text: agentsMounted && coordination ? ` ${coordination}` : "" },
@@ -3375,6 +3388,7 @@ function previewSystemPrompt(bot) {
3375
3388
  { id: "routine", label: "Routines", text: agentsMounted ? ROUTINE_PROMPT : "" },
3376
3389
  { id: "profile", label: "Profile changes", text: agentsMounted ? PROFILE_PROMPT : "" },
3377
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 : "") },
3378
3392
  { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: agentsMounted, fileTools: Boolean(privateWorkspace), enabled: bot.memoryEnabled !== false }) },
3379
3393
  { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id, skillsLibraryEnabled(cfg) ? bot.assignedSkills : undefined) : "" },
3380
3394
  ]);
@@ -4279,7 +4293,7 @@ function outstandingAssignmentsPrompt(threadId) {
4279
4293
  assignment: node.text.slice(0, 1_000),
4280
4294
  status: node.status === "queued" ? "waiting for that teammate to be free" : "working on it now",
4281
4295
  }));
4282
- return ` Assignments you already sent are still outstanding, and nothing in this conversation cancelled them: ${JSON.stringify(listed)}. Do not send them again, do not poll or wait for them, and do not tell the user they were lost. Each result returns to this conversation on its own and resumes you then. Answer the message above with that work still in flight.`;
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.`;
4283
4297
  }
4284
4298
  const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
4285
4299
  validate: (node, parent) => roomHandoffProblem(node, parent) ??
@@ -4353,9 +4367,10 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
4353
4367
  }
4354
4368
  // A successful direct work thread closes after its result is reported:
4355
4369
  // folded out of the sidebar, never deleted, and reopened if addressed.
4356
- // 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.
4357
4372
  const childTask = store.taskByThread(child.botId, child.threadId);
4358
- if (!child.groupId && child.status === "completed" && !problem && !childTask?.closedBy
4373
+ if (!child.groupId && child.status === "completed" && !problem && !childTask?.closedBy && !roomHandoffs.activeDirect(child.threadId)
4359
4374
  && childTask?.openedBy?.kind === "work" && childTask.openedBy.botId === parent.botId) {
4360
4375
  store.setTaskClosedBy(child.botId, child.threadId, { botId: parent.botId, name: store.bot(parent.botId)?.name ?? childTask.openedBy.name, at: Date.now() });
4361
4376
  }
@@ -5280,7 +5295,10 @@ always, rememberCommand) {
5280
5295
  }
5281
5296
  if (messageId)
5282
5297
  askMessageByRequest.delete(`${threadId}:${requestId}`);
5283
- if (!persistentQuestion)
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)
5284
5302
  store.appendMessage(threadId, {
5285
5303
  role: "bot",
5286
5304
  kind: "activity",
@@ -5451,8 +5469,8 @@ const watchdog = new TurnWatchdog({
5451
5469
  const bot = botForThread(turn.botId, turn.threadId);
5452
5470
  const routineRun = activeRoutineRunForThread(turn.threadId);
5453
5471
  const instance = bot
5454
- ? runningTurnInstance(bot, turn.threadId, routineRun?.runOn)
5455
- : routineRun?.runOn === "cloud" ? registry.instances().find((candidate) => candidate.driverKind === "boxAgent") : null;
5472
+ ? runningTurnInstance(bot, turn.threadId)
5473
+ : runningTurnEngines.get(turn.threadId) ?? null;
5456
5474
  void instance?.adapter.interruptTurn(turn.threadId).catch(() => { });
5457
5475
  const minutes = Math.round(TURN_STALL_MS / 60_000);
5458
5476
  if (routineRun?.target === "bot") {
@@ -6186,10 +6204,29 @@ async function attachTeamBoat(computer, botId, owner, canMount, remoteAgent) {
6186
6204
  !turnResources.owns(`computer:box:${machine.id}`, owner))
6187
6205
  throw new Error("This computer turn ended while its machine was starting");
6188
6206
  return {
6189
- 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),
6190
6208
  capture: () => boat.screenshotBoat(cfg, ownerId, machine.id),
6191
6209
  };
6192
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
+ }
6193
6230
  /** "Works on: This computer", strict. One mount for a bot thread and a group
6194
6231
  * chat, so the two cannot drift on what they check or how they fail. */
6195
6232
  async function mountHostComputer(owner, botId, providerSupportsLocal) {
@@ -6291,9 +6328,7 @@ async function attachBotBoat(bot, owner, opts) {
6291
6328
  await bindTurnComputer(owner, `computer:box:${machine.id}`, opts.remoteAgent);
6292
6329
  return {
6293
6330
  capture: () => boat.screenshotBoat(cfg, bot.id, machine.id),
6294
- integration: opts.canMount
6295
- ? { kind: "box", boxId: machine.id, token: boat.boatAccount(cfg)?.token ?? "", control: controlIntegration(bot.id, owner.threadId, owner.generation) }
6296
- : null,
6331
+ mount: opts.canMount ? cloudComputerMount(bot.id, owner, machine.id, opts.remoteAgent) : null,
6297
6332
  };
6298
6333
  }
6299
6334
  function managedBoatOwners() {
@@ -6370,17 +6405,6 @@ function turnProvider(bot, runOn, threadId) {
6370
6405
  return "box";
6371
6406
  return bot.cloudBackend === "vps" ? "vps" : wants === "cloud" ? "box" : null;
6372
6407
  }
6373
- /** A driver with a Boat bridge keeps the selected model. Other engines use
6374
- * Boat's native runner. Start and interrupt must resolve the same owner. */
6375
- function turnInstance(bot, runOn, threadId) {
6376
- const selected = registry.get(bot.modelSelection.instanceId);
6377
- if (selected?.adapter.capabilities.cloudComputerMcp)
6378
- return selected;
6379
- const onBoat = turnProvider(bot, runOn, threadId) === "box";
6380
- return onBoat
6381
- ? registry.instances().find((candidate) => candidate.driverKind === "boxAgent") ?? null
6382
- : registry.get(bot.modelSelection.instanceId);
6383
- }
6384
6408
  /** Preview requests carry the selected conversation, not whichever thread
6385
6409
  * happens to be the bot's default. A query never grants lifecycle authority. */
6386
6410
  function computerPreviewBot(botId, url) {
@@ -6448,7 +6472,7 @@ async function selectableComputers(bot) {
6448
6472
  canCreate = Boolean(status?.configured && status.daemonUp && status.container === "missing");
6449
6473
  reason = status?.problem ?? reason;
6450
6474
  }
6451
- 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)) {
6452
6476
  const status = await boat.boatStatus(cfg, bot.id);
6453
6477
  const lifecycle = boat.boatTurnLifecycleAction({ explicitCloud: true, canMount: true, state: status.box?.state ?? null });
6454
6478
  ready = lifecycle === "attach";
@@ -6527,8 +6551,9 @@ function continueComputerSelection(threadId, generation, succeeded) {
6527
6551
  /** The engine that dispatched each live turn. A bot's settings may change
6528
6552
  * mid-turn; an interrupt or steer must reach the engine actually running. */
6529
6553
  const runningTurnEngines = new Map();
6530
- function runningTurnInstance(bot, threadId, runOn) {
6531
- 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);
6532
6557
  }
6533
6558
  function providerTransitionForTurn(bot, runOn, threadId) {
6534
6559
  const provider = turnProvider(bot, runOn, threadId);
@@ -7738,7 +7763,7 @@ function markTaskContextExternallyUpdated(bot, threadId) {
7738
7763
  const owner = task.lastInstanceId;
7739
7764
  const record = owner ? task.handedMessages?.[owner] : undefined;
7740
7765
  if (owner && record?.session !== undefined && record.session === task.resumeCursors[owner] &&
7741
- turnInstance(botForThread(bot.id, threadId) ?? bot, undefined, threadId)?.instanceId === owner &&
7766
+ (botForThread(bot.id, threadId) ?? bot).modelSelection.instanceId === owner &&
7742
7767
  registry.get(owner)?.adapter.capabilities.strictResume) {
7743
7768
  store.patchTask(bot.id, threadId, { unread: true });
7744
7769
  return;
@@ -8796,11 +8821,9 @@ async function startTurn(botId, text, opts) {
8796
8821
  if (hostedModels && !hostedModels.allows(bot.modelSelection))
8797
8822
  throw Object.assign(new Error(hostedModels.error()), { status: 409 });
8798
8823
  const plan = turnSurfacePlan(bot, opts?.runOn, threadId);
8799
- const instance = turnInstance(bot, opts?.runOn, threadId);
8824
+ const instance = registry.get(bot.modelSelection.instanceId);
8800
8825
  if (!instance) {
8801
- throw Object.assign(new Error(turnProvider(bot, opts?.runOn, threadId) === "box"
8802
- ? "the Cloud VM runner is unavailable — configure Boat in App Settings"
8803
- : `provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`), { status: 409 });
8826
+ throw Object.assign(new Error(`provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`), { status: 409 });
8804
8827
  }
8805
8828
  const policyRefusal = policyModelRefusal(instance);
8806
8829
  if (policyRefusal)
@@ -8860,13 +8883,12 @@ async function startTurn(botId, text, opts) {
8860
8883
  if (providerInstancesChanging.has(instanceId)) {
8861
8884
  throw Object.assign(new Error("this provider account is being updated — try again shortly"), { status: 409 });
8862
8885
  }
8863
- const switchedEngine = instance.instanceId !== bot.modelSelection.instanceId;
8864
- const useInstanceDefaults = switchedEngine || (opts?.runOn === "cloud" && !instance.adapter.capabilities.cloudComputerMcp);
8865
- const model = useInstanceDefaults ? instance.models.default : bot.modelSelection.model;
8866
- // A native cloud runner borrows its instance defaults; a Boat bridge keeps
8867
- // the bot's selected model, effort and variant.
8868
- const effort = useInstanceDefaults ? undefined : bot.modelSelection.effort;
8869
- const variant = useInstanceDefaults ? undefined : bot.modelSelection.variant;
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;
8870
8892
  assertModelVariantSupported({ variant, effort }, instance.adapter.capabilities);
8871
8893
  // A selection can be persisted while its engine is offline. Re-check when
8872
8894
  // the engine returns so an old or unsupported value never reaches a CLI.
@@ -9190,24 +9212,23 @@ async function startTurn(botId, text, opts) {
9190
9212
  }
9191
9213
  const privateWorkspace = worksInWorkspace ? ensureTaskWorkspace(bot.id, threadId) : undefined;
9192
9214
  const skillInstructions = renderSkillInstructions(selectedSkills, {
9193
- includeRoot: worksInWorkspace && opts?.runOn !== "cloud",
9215
+ includeRoot: worksInWorkspace,
9194
9216
  });
9195
9217
  const packagePlaybooks = installedPlaybookInstructions(providerText, bot.playbooks);
9196
9218
  // An explicit working folder wins for new tasks; otherwise they use
9197
9219
  // the private bot workspace. A legacy task with an existing provider
9198
9220
  // session deliberately pins to null (the old home-folder behavior),
9199
9221
  // because moving a live session would break resume.
9200
- // A cloud run happens on the boat, where a host folder means nothing:
9201
- // pin the task to the default so the header chip never shows the
9202
- // bot's folder for a task that runs elsewhere.
9203
- if (opts?.runOn === "cloud")
9204
- store.pinTaskCwd(bot.id, threadId, undefined, { none: true });
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).
9205
9226
  // On a Cloud home a conversation a guest opened is pinned to its own
9206
9227
  // folder when it first runs, not the bot's project folder the owner's
9207
9228
  // conversations share (what it leaves there reaches none of them). One
9208
9229
  // that ran before keeps its pin, unless the session that opened it was
9209
9230
  // revoked: then it was unpinned at boot (server/cloud-owner.ts).
9210
- const pinnedCwd = privateWorkspace && opts?.runOn !== "cloud"
9231
+ const pinnedCwd = privateWorkspace
9211
9232
  ? store.pinTaskCwd(bot.id, threadId, privateWorkspace, { privateOnly: cloudGuestOpened(threadId) })
9212
9233
  : null;
9213
9234
  const cwd = pinnedCwd ?? undefined;
@@ -9224,8 +9245,8 @@ async function startTurn(botId, text, opts) {
9224
9245
  const dwebUrl = process.env.DWEB_URL?.trim();
9225
9246
  if (dwebUrl)
9226
9247
  integrations.dweb = { url: dwebUrl };
9227
- // Cloud routines always use Boat/BoatAgent. The per-bot backend applies
9228
- // only to ordinary turns that mount a computer into the local agent.
9248
+ // Cloud routines always use the bot's Boat. The per-bot backend applies
9249
+ // only to ordinary turns.
9229
9250
  const teamComputer = inheritedTeamComputer(bot);
9230
9251
  const cloudBackend = teamComputer || opts?.runOn === "cloud" || bot.cloudBackend !== "vps" ? "box" : "vps";
9231
9252
  const mountsComputerMcp = instance.adapter.capabilities.computerMcp === true;
@@ -9253,6 +9274,20 @@ async function startTurn(botId, text, opts) {
9253
9274
  const placeRefusal = wantedKind && computerPlaceRefusal(wantedKind);
9254
9275
  if (placeRefusal)
9255
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
+ }
9256
9291
  let previewCapture = null;
9257
9292
  let browserCapture = null;
9258
9293
  let computerKind = null;
@@ -9506,24 +9541,23 @@ async function startTurn(botId, text, opts) {
9506
9541
  return false;
9507
9542
  }
9508
9543
  };
9509
- if (wants === "vm") {
9510
- if (await attachLocalVm(true))
9511
- computerKind = "vm";
9512
- }
9513
- else if (wants === "local") {
9514
- integrations.localComputer = await mountHostComputer(resourceOwner, bot.id, mountsLocalComputer);
9515
- computerKind = "local";
9516
- }
9517
- // A VPS is a local-agent computer mount, never a remote agent runner.
9518
- // Explicit Cloud may prepare/start it. Auto remains read-only unless
9519
- // the person explicitly opted this bot into remote lifecycle actions.
9520
- if ((wants === "cloud" || (wants === undefined && managedPolicy.computerAllowed("vps"))) && cloudBackend === "vps") {
9521
- const unsupported = vps.vpsDriverError(instance.driverKind, mountsComputerMcp);
9522
- if (unsupported && wants === "cloud")
9523
- throw new Error(unsupported);
9524
- if (unsupported && wants === undefined)
9525
- autoVpsProblem = unsupported;
9526
- if (!unsupported) {
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")) {
9527
9561
  // The VPS "computer" is the desktop inside this bot's managed
9528
9562
  // container, and only screen work needs that desktop to itself.
9529
9563
  // So the lease is claimed on the first computer call, through the
@@ -9561,34 +9595,40 @@ async function startTurn(botId, text, opts) {
9561
9595
  autoVpsProblem = mounted.problem ?? "the VPS computer could not be reached";
9562
9596
  }
9563
9597
  }
9564
- }
9565
- // Cloud is strict when selected. Native Boat and drivers with a Boat
9566
- // bridge may also reuse an already-ready Boat on Auto.
9567
- if (teamComputer) {
9568
- const attached = await attachTeamBoat(teamComputer, bot.id, resourceOwner, instance.adapter.capabilities.usesCloudComputer === true, instance.adapter.capabilities.remoteAgent === true);
9569
- integrations.computer = attached.integration;
9570
- previewCapture = attached.capture;
9571
- computerKind = "box";
9572
- }
9573
- if (!teamComputer && instance.adapter.capabilities.usesCloudComputer === true && (wants === "cloud" || wants === undefined) && cloudBackend === "box" && boat.boatConfigured(cfg)) {
9574
- const attached = await attachBotBoat(bot, resourceOwner, {
9575
- explicitCloud: wants === "cloud",
9576
- canMount: instance.adapter.capabilities.usesCloudComputer === true,
9577
- remoteAgent: instance.adapter.capabilities.remoteAgent === true,
9578
- });
9579
- if (attached) {
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);
9580
9603
  previewCapture = attached.capture;
9581
- if (attached.integration) {
9582
- integrations.computer = attached.integration;
9583
- 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
+ }
9584
9618
  }
9585
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
+ }
9586
9626
  }
9587
- if (wants === "cloud" && cloudBackend === "box" && !boat.boatConfigured(cfg)) {
9588
- throw new Error(BOAT_NOT_CONFIGURED);
9589
- }
9590
- if (wants === "cloud" && cloudBackend === "box" && !integrations.computer) {
9591
- throw new Error("the cloud computer could not be created or reached");
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));
9592
9632
  }
9593
9633
  // Auto-only host fallback. Electron owns cua-driver/TCC attribution;
9594
9634
  // the harness only reads its already-running connection descriptor.
@@ -9679,7 +9719,7 @@ async function startTurn(botId, text, opts) {
9679
9719
  ? mentionedBots(providerText, sectionPeers)
9680
9720
  : [];
9681
9721
  const coordinationPrompt = bot.chiefOfStaff
9682
- ? chiefOfStaffSystemPrompt(bot.id, store.bots, Boolean(integrations.agents), openMausStatusSystemPrompt(), boundedCoordination)
9722
+ ? chiefOfStaffSystemPrompt(bot.id, store.bots, Boolean(integrations.agents), boundedCoordination)
9683
9723
  : integrations.agents && sectionPeers.length > 0
9684
9724
  // Ordinary bots could always CALL the peer tools; until now the
9685
9725
  // one generic sentence they got never named a teammate, so the
@@ -9788,7 +9828,6 @@ async function startTurn(botId, text, opts) {
9788
9828
  const computerPromptKind = resolveComputerPromptKind({
9789
9829
  kind: computerKind,
9790
9830
  driverKind: instance.driverKind,
9791
- cloudComputerMcp: instance.adapter.capabilities.cloudComputerMcp,
9792
9831
  vmPrivate: localVmMode(cfg) === "per-bot",
9793
9832
  });
9794
9833
  const coordinationNode = opts?.coordination ? roomHandoffs.nodes.get(opts.coordination.id) : undefined;
@@ -9801,14 +9840,14 @@ async function startTurn(botId, text, opts) {
9801
9840
  // to a turn whose engine actually mounted them (setupMode is already
9802
9841
  // false when they are not — see agentsMounted above)
9803
9842
  { id: "setup", label: "Setup", text: setupSystemPrompt(setupMode, { skills: skillAuthoring, cwd: liveBot?.cwd ?? bot.cwd }) },
9804
- { id: "files", label: "File locations", text: worksInWorkspace && opts?.runOn !== "cloud" ? workspaceLocationsPrompt(bot.id, cwd, liveBot?.cwd ?? bot.cwd) : "" },
9843
+ { id: "files", label: "File locations", text: worksInWorkspace ? workspaceLocationsPrompt(bot.id, cwd, liveBot?.cwd ?? bot.cwd) : "" },
9805
9844
  { id: "computer", label: "Computer", text: computerPrompt(computerPromptKind) },
9806
9845
  { id: "team-computer", label: "Team computer", text: teamComputerPrompt(teamComputer) },
9807
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) }) },
9808
9847
  { id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
9809
9848
  // gated on the integration, not the key: the hint only goes to a
9810
9849
  // bot whose driver actually mounted the tools
9811
- { 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) : "" },
9812
9851
  { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
9813
9852
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
9814
9853
  { id: "coordination", label: "Team", text: coordinationPrompt ? ` ${coordinationPrompt}` : "" },
@@ -9821,6 +9860,7 @@ async function startTurn(botId, text, opts) {
9821
9860
  { id: "profile", label: "Profile changes", text: profilePrompt },
9822
9861
  { id: "learn", label: "Skill authoring", text: learnPrompt },
9823
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 : "") },
9824
9864
  // what the bot said lately in its other conversations, so a task
9825
9865
  // never redoes — or forgets — what another one already did
9826
9866
  { id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(recentWorkSources(bot), bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId, ...recentWorkFilter() })) },
@@ -9866,9 +9906,9 @@ async function startTurn(botId, text, opts) {
9866
9906
  const dispatch = await guardTurnDispatch(instance.adapter.sendTurn({
9867
9907
  threadId,
9868
9908
  botId: bot.id,
9869
- 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)),
9870
9911
  text: withRecalled(recalled, dispatchContext.turnText),
9871
- refreshSystemPrompt: true,
9872
9912
  images: turnImages,
9873
9913
  approvalMode: approvalModeForTurn(bot, commsDepth > 0, threadId),
9874
9914
  toolScope,
@@ -10028,10 +10068,26 @@ async function startTurn(botId, text, opts) {
10028
10068
  drainDelegationWakes();
10029
10069
  return;
10030
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
+ }
10031
10081
  let message = e instanceof Error ? e.message : String(e);
10032
- const backup = cfg.automaticRecovery?.enabled ? cfg.automaticRecovery.backup : undefined;
10033
10082
  const current = store.projectBotForTask(bot.id, threadId);
10034
- 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) &&
10035
10091
  !directRequestOwners.get(threadId)?.stopped && !hasQueuedSteeredMessages(bot.id, threadId) &&
10036
10092
  JSON.stringify(current.modelSelection) === JSON.stringify(bot.modelSelection) &&
10037
10093
  (backup.instanceId !== instanceId || backup.model !== model) &&
@@ -10055,7 +10111,7 @@ async function startTurn(botId, text, opts) {
10055
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,
10056
10112
  } });
10057
10113
  await startTurn(bot.id, text, { ...opts, threadId, userMessage, editedMessageId: undefined,
10058
- automaticRecoveryAttempted: true });
10114
+ automaticRecoveryAttempted: true, automaticRecoveryIndex: nextIndex + 1 });
10059
10115
  // The backup now owns these callbacks; don't publish a failed
10060
10116
  // routine/peer result or release a queued receipt in between.
10061
10117
  directFollowupSettlers.delete(dispatchClaimId);
@@ -10395,15 +10451,15 @@ routines = new RoutineManager({
10395
10451
  goalRunId: runId,
10396
10452
  });
10397
10453
  },
10398
- interruptTurn: async (botId, threadId, runOn) => {
10454
+ interruptTurn: async (botId, threadId) => {
10399
10455
  const bot = botForThread(botId, threadId);
10400
10456
  pendingDelegationWakes.delete(threadId);
10401
10457
  discardDelegations(commsBus, threadId);
10402
10458
  cancelDirectTurnDispatch(botId, threadId);
10403
10459
  revokeInternalCapabilitiesForThread(threadId);
10404
10460
  const instance = bot
10405
- ? runningTurnInstance(bot, threadId, runOn)
10406
- : runOn === "cloud" ? registry.instances().find((candidate) => candidate.driverKind === "boxAgent") ?? null : null;
10461
+ ? runningTurnInstance(bot, threadId)
10462
+ : runningTurnEngines.get(threadId) ?? null;
10407
10463
  try {
10408
10464
  await instance?.adapter.interruptTurn(threadId);
10409
10465
  }
@@ -10564,9 +10620,13 @@ async function cloudRoutineReadiness(botId, threadId) {
10564
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.',
10565
10621
  };
10566
10622
  }
10567
- const instance = turnInstance(bot, "cloud", threadId);
10623
+ const instance = registry.get(bot.modelSelection.instanceId);
10568
10624
  if (!instance)
10569
- 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." };
10570
10630
  try {
10571
10631
  if ((await instance.snapshot()).state !== "available") {
10572
10632
  return { ready: false, reason: "The target bot's model is not ready to use the Boat cloud computer." };
@@ -10859,6 +10919,69 @@ const validateModelProposal = (selection, current) => {
10859
10919
  }
10860
10920
  return null;
10861
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
+ }
10862
10985
  const profileRequests = new ProfileRequestService({
10863
10986
  store,
10864
10987
  autoApply: fullAccessForSource,
@@ -11475,7 +11598,7 @@ setupRetry = 0) {
11475
11598
  const preparedApprovalMode = roomTurnApprovalMode(bot, threadId, orchestration);
11476
11599
  const preparedSelection = { ...bot.modelSelection };
11477
11600
  const preparedComposio = bot.composio;
11478
- const instance = turnInstance(bot);
11601
+ const instance = registry.get(bot.modelSelection.instanceId);
11479
11602
  const userName = cfg.profile?.name?.trim() || "User";
11480
11603
  if (providerInstancesChanging.has(bot.modelSelection.instanceId)) {
11481
11604
  onDispatchError?.(`${bot.name}'s provider account is being updated — try again shortly`);
@@ -11655,7 +11778,7 @@ setupRetry = 0) {
11655
11778
  : Boolean(readyGroup && store.groupTaskByThread(readyGroup.id, threadId));
11656
11779
  if (!readyGroup || !stillOwnsThread || !readyGroup.memberIds.includes(readyBot.id))
11657
11780
  return false;
11658
- const setupChanged = turnInstance(readyBot) !== instance ||
11781
+ const setupChanged = registry.get(readyBot.modelSelection.instanceId) !== instance ||
11659
11782
  roomTurnApprovalMode(readyBot, threadId, orchestration) !== preparedApprovalMode ||
11660
11783
  readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
11661
11784
  readyBot.modelSelection.model !== preparedSelection.model ||
@@ -11780,6 +11903,13 @@ setupRetry = 0) {
11780
11903
  if (roomPlan.computer === "local" && instance.adapter.capabilities.remoteAgent === true) {
11781
11904
  throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
11782
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
+ }
11783
11913
  // One place per room turn as well: a team computer reached on Auto means
11784
11914
  // no separate built-in browser.
11785
11915
  if (roomPlan.browser && !(roomPlan.computer === undefined && roomTeamComputer)) {
@@ -11798,11 +11928,11 @@ setupRetry = 0) {
11798
11928
  return false;
11799
11929
  }
11800
11930
  if (roomTeamComputer) {
11801
- 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);
11802
11932
  if (isCancelled?.() || groupSpeakers.get(threadId) !== roomSpeaker ||
11803
11933
  activeInternalGenerationByThread.get(threadId) !== internalGeneration)
11804
11934
  return false;
11805
- integrations.computer = attached.integration;
11935
+ Object.assign(integrations, attached.mount);
11806
11936
  startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: instance.adapter.capabilities.remoteAgent === true });
11807
11937
  }
11808
11938
  // The speaker's own explicit place, mounted exactly as its bot thread
@@ -11811,89 +11941,99 @@ setupRetry = 0) {
11811
11941
  const roomSetupIsCurrent = () => !isCancelled?.() &&
11812
11942
  groupSpeakers.get(threadId) === roomSpeaker &&
11813
11943
  activeInternalGenerationByThread.get(threadId) === internalGeneration;
11814
- if (!roomTeamComputer && roomPlan.computer === "local") {
11815
- integrations.localComputer = await mountHostComputer(resourceOwner, readyBot.id, instance.adapter.capabilities.localComputerMcp === true);
11816
- if (!roomSetupIsCurrent())
11817
- return false;
11818
- roomComputerKind = "local";
11819
- }
11820
- if (!roomTeamComputer && roomPlan.computer === "cloud") {
11821
- if (turnProvider(readyBot) === "vps") {
11822
- const unsupported = vps.vpsDriverError(instance.driverKind, instance.adapter.capabilities.computerMcp === true);
11823
- if (unsupported)
11824
- throw new Error(unsupported);
11825
- vpsThreadStarted(readyBot.id, threadId);
11826
- roomVpsBotId = readyBot.id;
11827
- const mounted = await mountBotVps(readyBot, resourceOwner, {
11828
- start: true,
11829
- onClaimed: (capture) => {
11830
- if (roomSetupIsCurrent() && store.group(readyGroup.id)?.busyBotId === readyBot.id) {
11831
- roomScreenBotId = readyBot.id;
11832
- startScreenPoller(readyBot.id, threadId, { computer: capture });
11833
- }
11834
- },
11835
- });
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);
11836
11949
  if (!roomSetupIsCurrent())
11837
11950
  return false;
11838
- if (!("integration" in mounted))
11839
- throw new Error(mounted.problem ?? "the VPS computer could not be created or reached");
11840
- integrations.localComputer = mounted.integration;
11841
- roomComputerKind = "vps";
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
+ }
11842
11988
  }
11843
- else {
11844
- if (!boat.boatConfigured(cfg))
11845
- throw new Error(BOAT_NOT_CONFIGURED);
11846
- const remoteAgent = instance.adapter.capabilities.remoteAgent === true;
11847
- const attached = await attachBotBoat(readyBot, resourceOwner, { explicitCloud: true,
11848
- canMount: instance.adapter.capabilities.usesCloudComputer === true, remoteAgent });
11849
- if (!roomSetupIsCurrent())
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()) {
11850
12018
  return false;
11851
- if (!attached?.integration)
11852
- throw new Error("the cloud computer could not be created or reached");
11853
- integrations.computer = attached.integration;
11854
- roomScreenBotId = readyBot.id;
11855
- startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: remoteAgent });
11856
- roomComputerKind = "box";
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);
11857
12027
  }
11858
12028
  }
11859
- // Room and Goal turns use the speaker's desktop, never the coordinator's.
11860
- // Claim the same lease as direct turns before asynchronous VM setup.
11861
- if (readyBot.computer === "vm") {
11862
- if (instance.adapter.capabilities.computerMcp !== true || instance.adapter.capabilities.remoteAgent === true) {
11863
- throw new Error("this model cannot use the Local VM");
11864
- }
11865
- // A distinct identity fences cleanup even in shared mode on the same room thread.
11866
- const target = { ...localVmTargetForThread(readyBot.id, threadId) };
11867
- await bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
11868
- if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(target.key)) {
11869
- throw new Error("this Local VM is being started, stopped, or replaced");
11870
- }
11871
- if (!localVmLeaseFor(target).claim(threadId, readyBot.id, localVmOwnerBusy)) {
11872
- throw new Error("this Local VM is already being used by another turn");
11873
- }
11874
- if (localVmMode(cfg) === "pool")
11875
- localVmSeatPool.touch(threadId);
11876
- roomVmTarget = target;
11877
- localVmThreadTargets.set(threadId, target);
11878
- localVmActiveThreads.set(target.key, threadId);
11879
- localVmIdleFor(target).touch();
11880
- const setupIsCurrent = () => !isCancelled?.() &&
11881
- groupSpeakers.get(threadId) === roomSpeaker &&
11882
- activeInternalGenerationByThread.get(threadId) === internalGeneration &&
11883
- store.group(readyGroup.id)?.memberIds.includes(readyBot.id) === true &&
11884
- store.bot(readyBot.id)?.busy === true &&
11885
- store.group(readyGroup.id)?.busyBotId === readyBot.id;
11886
- const vm = await readyLocalVmForTurn(readyBot.id, target, setupIsCurrent);
11887
- if (!setupIsCurrent()) {
11888
- return false;
11889
- }
11890
- if (!vm.ready || !vm.runtime)
11891
- throw new Error(vm.problem ?? "the Local VM is not ready");
11892
- const owner = localVmLeaseFor(target).current(localVmOwnerBusy);
11893
- if (owner?.threadId !== threadId || owner.botId !== readyBot.id) {
11894
- throw new Error("the Local VM lease expired while preparing the turn");
11895
- }
11896
- integrations.localComputer = containerComputerMcp(vm.runtime, controlIntegration(readyBot.id, threadId, internalGeneration, target), target);
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);
11897
12037
  }
11898
12038
  const roster = readyGroup.memberIds
11899
12039
  .map((id) => store.bot(id))
@@ -11979,7 +12119,7 @@ setupRetry = 0) {
11979
12119
  const recentLines = recentWork(recentWorkSources(bot), bot, { userName, currentThreadId: threadId, ...recentWorkFilter() });
11980
12120
  const roomComputerPromptKind = resolveComputerPromptKind({
11981
12121
  kind: roomTeamComputer || roomComputerKind === "box" ? "box" : roomVmTarget ? "vm" : roomComputerKind,
11982
- driverKind: instance.driverKind, cloudComputerMcp: instance.adapter.capabilities.cloudComputerMcp,
12122
+ driverKind: instance.driverKind,
11983
12123
  vmPrivate: localVmMode(cfg) === "per-bot",
11984
12124
  });
11985
12125
  {
@@ -12005,6 +12145,7 @@ setupRetry = 0) {
12005
12145
  { id: "recall", label: "Recall", text: integrations.agents && bot.memoryEnabled !== false ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
12006
12146
  { id: "recent", label: "Recent work", text: recentWorkPrompt(recentLines) },
12007
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 : "") },
12008
12149
  // the room path has always put a newline before memory and trimmed
12009
12150
  // the block's leading space; keep that so existing prompts are
12010
12151
  // byte-identical. The write guidance follows the tools actually
@@ -12137,7 +12278,6 @@ setupRetry = 0) {
12137
12278
  threadId,
12138
12279
  botId: readyBot.id,
12139
12280
  text: withRecalled(roomRecalled, text),
12140
- refreshSystemPrompt: true,
12141
12281
  images: turnImages,
12142
12282
  approvalMode: roomTurnApprovalMode(readyBot, threadId, orchestration),
12143
12283
  toolScope: toolScopeForTurn(readyBot.id),
@@ -15212,7 +15352,7 @@ const handleRequest = async (req, res) => {
15212
15352
  ? "phone"
15213
15353
  : path.startsWith("/api/internal/connectors/")
15214
15354
  ? "connectors"
15215
- : path === "/api/internal/computer-control"
15355
+ : path === "/api/internal/computer-control" || path === "/api/internal/computer/mcp"
15216
15356
  ? "computer"
15217
15357
  : "agents";
15218
15358
  if (internalCapability.kind !== requiredCapabilityKind) {
@@ -15369,7 +15509,7 @@ const handleRequest = async (req, res) => {
15369
15509
  if (method === "POST" && path === "/api/internal/memory") {
15370
15510
  const body = await readInternalBody();
15371
15511
  const result = ownersWrite(() => updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText, ...(body.until !== undefined ? { until: body.until } : {}) }, { source: memorySource() }));
15372
- return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : result.code === "over-budget" ? 413 : 400, result);
15512
+ return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : 400, result);
15373
15513
  }
15374
15514
  if (method === "POST" && path === "/api/internal/memory/log") {
15375
15515
  const body = await readInternalBody();
@@ -15403,6 +15543,22 @@ const handleRequest = async (req, res) => {
15403
15543
  requireActiveInternalCapability();
15404
15544
  return json(res, 200, { result });
15405
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
+ }
15406
15562
  if (method === "POST" && path === "/api/internal/phone/claim") {
15407
15563
  // Lazy phone exclusivity (issue #1663): the turn holds computer:phone
15408
15564
  // from its first real tool call until it settles. The proxy answers a
@@ -15784,6 +15940,58 @@ const handleRequest = async (req, res) => {
15784
15940
  });
15785
15941
  return json(res, 201, proposed);
15786
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
+ }
15787
15995
  if (method === "POST" && path === "/api/internal/profile-requests") {
15788
15996
  const parsed = z.object({
15789
15997
  fromBotId: z.string().min(1).max(128),
@@ -16648,20 +16856,17 @@ const handleRequest = async (req, res) => {
16648
16856
  })).filter(g => g.members.length);
16649
16857
  return json(res, 200, { currentRoom: source ? { id: source.id, name: source.name, workingFolder: source.cwd || null } : null,
16650
16858
  bots: reachablePeers(store.bots, internalSender).map(bot => ({ id: bot.id, name: bot.name, title: bot.title, section: bot.section, busy: bot.busy })),
16651
- rooms, note: "Without group_id: use this room when in a room, otherwise each distinct assignment starts a fresh thread for that teammate. Give a self-contained brief. Each bot uses its own environment and permissions. Files are not transferred: pass absolute paths only when accessible to the recipient, otherwise pass the content." });
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." });
16652
16860
  }
16653
16861
  if (method === "POST" && path === "/api/internal/coordinate-bots") {
16654
16862
  const parsed = z.object({
16655
16863
  groupId: z.string().min(1).max(128).optional(),
16656
16864
  botIds: z.array(z.string().min(1).max(128)).min(1).max(4).refine(ids => new Set(ids).size === ids.length),
16657
- message: z.string().trim().min(1).max(4000), requestKey: z.string().regex(/^[\w-]{1,100}$/),
16865
+ message: z.string().trim().min(1).max(4000),
16658
16866
  rework: z.boolean().default(false),
16659
- // Only ever a name for a thread, so it travels under the same
16660
- // one-line rule as a peer thread title.
16661
- label: z.string().trim().min(1).max(60).refine(fitsOnOneLine).optional(),
16662
16867
  }).safeParse(await readInternalBody());
16663
16868
  if (!parsed.success)
16664
- return json(res, 400, { error: "Provide 1-4 distinct botIds, message (1-4000 characters), a short requestKey (letters, digits, underscores or hyphens) and an optional one-line label of at most 60 characters." });
16869
+ return json(res, 400, { error: "Provide 1-4 distinct botIds and a message of 1-4000 characters." });
16665
16870
  const groupId = parsed.data.groupId ?? source?.id;
16666
16871
  const destination = groupId ? store.group(groupId) : undefined;
16667
16872
  if (groupId && !destination)
@@ -16713,58 +16918,63 @@ const handleRequest = async (req, res) => {
16713
16918
  });
16714
16919
  approvalGranted = true;
16715
16920
  }
16716
- const accepted = [];
16717
- const errors = [];
16921
+ // A room shows one brief per call, addressed to the call's recipients.
16922
+ const requestBatchKey = destination ? randomUUID() : undefined;
16718
16923
  // One receipt per recipient, in the order the caller addressed
16719
16924
  // them. Dispatch is asynchronous (RoomHandoffs.tick), so a fresh
16720
- // enqueue is honestly "queued"; a duplicate inherits the state of
16721
- // the node that already carries this request_key.
16925
+ // enqueue is honestly "queued"; a repeat reports the request it
16926
+ // repeats.
16722
16927
  const receipts = [];
16928
+ // Why nothing was sent, in full: a receipt's detail is clipped.
16929
+ const refused = [];
16723
16930
  for (const target of targets) {
16724
16931
  let createdThread;
16725
16932
  try {
16726
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);
16727
16938
  if (!destination) {
16728
- const task = store.createTask(target.botId, `@${internalSender.name}${parsed.data.label ? ` · ${parsed.data.label}` : " · work"}`, false, undefined, { botId: internalSender.id, name: internalSender.name, kind: "work", at: Date.now() });
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() });
16729
16940
  if (!task)
16730
16941
  throw new Error("The recipient no longer exists");
16731
- target.threadId = createdThread = task.threadId;
16942
+ target.threadId = task.threadId;
16943
+ if (!continued)
16944
+ createdThread = task.threadId;
16732
16945
  applyDelegatedLevel(internalSender, internalCapability.threadId, store.bot(target.botId), target.threadId);
16733
16946
  }
16734
- const { node, duplicate } = roomHandoffs.enqueue(address, internalCapability.generation, internalCapability.roomHandoffId, target, parsed.data.requestKey + ":" + target.botId, parsed.data.message, approvalGranted, parsed.data.rework, [...store.messagesFor(address.threadId)].reverse().find(m => m.role === "user" && m.kind === "text")?.text ?? "", destination ? parsed.data.requestKey : undefined);
16735
- // A retry keeps its original task; discard the speculative row.
16736
- if (duplicate && createdThread && createdThread !== node.threadId) {
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)
16737
16952
  store.deleteTask(target.botId, createdThread);
16738
- }
16739
- if (!duplicate && createdThread)
16953
+ else if (createdThread)
16740
16954
  threadStarters.set(createdThread, openerFrom(address.threadId));
16741
16955
  createdThread = undefined; // The durable coordinator now owns this task.
16742
- accepted.push({ requestId: node.id, botId: node.botId, duplicate, status: node.status });
16743
- // A duplicate request_key lands on the node enqueue already
16744
- // made. Only a live node can still report: a failed or
16745
- // cancelled one already fired its report and tick() skips it,
16746
- // so promising "its result will resume you" would be the one
16747
- // lie a receipt must never tell.
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);
16748
16967
  receipts.push(peerDeliveryReceipt({
16749
16968
  botId: node.botId,
16750
16969
  botName: store.bot(node.botId)?.name,
16751
- outcome: duplicate
16752
- ? node.status === "running" || node.status === "completed"
16753
- ? "injected"
16754
- : node.status === "failed" || node.status === "cancelled"
16755
- ? "failed"
16756
- : "queued"
16757
- : "queued",
16758
- detail: duplicate
16759
- ? node.status === "running"
16760
- ? "duplicate request_key — this teammate is already running that exact assignment"
16761
- : node.status === "completed"
16762
- ? "duplicate request_key — this teammate already completed that assignment; the earlier result stands"
16763
- : node.status === "failed" || node.status === "cancelled"
16764
- ? `duplicate request_key — the earlier assignment ${node.status === "cancelled" ? "was cancelled" : "failed"} and will not rerun or resume you; resend with a new request_key to retry`
16765
- : "duplicate request_key — already in flight; its result will resume you automatically"
16766
- : "handed to the coordinator; the teammate's turn has not started yet",
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"),
16767
16976
  requestId: node.id,
16977
+ ...(destination ? {} : { threadId: node.threadId }),
16768
16978
  }));
16769
16979
  if (!duplicate) {
16770
16980
  const recipient = store.bot(target.botId);
@@ -16781,14 +16991,13 @@ const handleRequest = async (req, res) => {
16781
16991
  store.deleteTask(target.botId, createdThread);
16782
16992
  }
16783
16993
  const said = error instanceof Error ? error.message : String(error);
16784
- errors.push({ botId: target.botId, error: said });
16785
- receipts.push(peerDeliveryReceipt({
16786
- botId: target.botId, botName: store.bot(target.botId)?.name, outcome: "failed", detail: said,
16787
- }));
16994
+ refused.push(said);
16995
+ receipts.push(peerDeliveryReceipt({ botId: target.botId, botName: store.bot(target.botId)?.name, outcome: "failed", detail: said }));
16788
16996
  }
16789
16997
  }
16790
- return json(res, accepted.length ? 200 : 409, { accepted, errors, receipts,
16791
- ...(accepted.length ? { message: "End your turn after sending all work. These actual teammates will reply and resume you automatically. Do not poll or wait." } : { error: errors.map(e => e.error).join("; ") }),
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("; ") }),
16792
17001
  });
16793
17002
  }
16794
17003
  }
@@ -17386,7 +17595,7 @@ const handleRequest = async (req, res) => {
17386
17595
  // Reading a streamed MCP body yields to ordinary settings requests.
17387
17596
  // Re-read the live bot immediately before relay so turning Connected
17388
17597
  // Apps off wins over a request that authenticated under the old value.
17389
- const currentSender = store.bot(internalCapability.botId);
17598
+ let currentSender = store.bot(internalCapability.botId);
17390
17599
  if (!currentSender || currentSender.composio === false || !composio.configured(cfg)) {
17391
17600
  return json(res, 403, { error: "connected apps are not enabled for this bot" });
17392
17601
  }
@@ -17397,6 +17606,7 @@ const handleRequest = async (req, res) => {
17397
17606
  // Rows are fire-and-forget: a log failure must never take the call
17398
17607
  // (or its refusal) down with it.
17399
17608
  const call = connectorCallFromFrame(body);
17609
+ let serviceSlugs = [];
17400
17610
  // A bot with no grants record keeps the pre-grants relay: even an
17401
17611
  // unreadable frame passes through exactly as it always did.
17402
17612
  if (call.kind === "unrecognized" && currentSender.connectorTools !== undefined) {
@@ -17423,7 +17633,11 @@ const handleRequest = async (req, res) => {
17423
17633
  // underscored service (bland_ai) keeps its own tools instead of
17424
17634
  // a plain-prefix grant (bland) capturing them; an unreachable
17425
17635
  // catalog reads as empty and the plain split stands.
17426
- 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" });
17427
17641
  const verdict = evaluateConnectorTools(call.names, currentSender.connectorTools, serviceSlugs);
17428
17642
  if (!verdict.allowed) {
17429
17643
  for (const denial of verdict.denials) {
@@ -17463,9 +17677,174 @@ const handleRequest = async (req, res) => {
17463
17677
  rule: verdict.rule,
17464
17678
  });
17465
17679
  }
17466
- const upstream = await composio.relayMcp(cfg, body, Array.isArray(req.headers["mcp-session-id"])
17467
- ? req.headers["mcp-session-id"][0]
17468
- : req.headers["mcp-session-id"]);
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
+ }
17469
17848
  const headers = {
17470
17849
  "content-type": upstream.contentType,
17471
17850
  "cache-control": "no-store",
@@ -17543,6 +17922,16 @@ const handleRequest = async (req, res) => {
17543
17922
  if (!composio.configured(cfg) || owner.bot.composio === false) {
17544
17923
  return json(res, 409, { error: "connected apps are not enabled for this bot" });
17545
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
+ }
17546
17935
  const connectionState = await composio.connectionStatus(cfg, slugs).catch(() => ({}));
17547
17936
  requireActiveInternalCapability();
17548
17937
  const messageIds = [];
@@ -20045,6 +20434,43 @@ const handleRequest = async (req, res) => {
20045
20434
  patch.section = section ?? undefined;
20046
20435
  if (body.chiefOfStaff === false)
20047
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
+ }
20048
20474
  // per-bot gate on the workspace's connected apps (Composio)
20049
20475
  if (body.composio !== undefined) {
20050
20476
  if (typeof body.composio !== "boolean")
@@ -20356,6 +20782,16 @@ const handleRequest = async (req, res) => {
20356
20782
  // desktop capability or a paired session, which is what the refusal
20357
20783
  // points at.
20358
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");
20359
20795
  if (Array.isArray(patch.managedSections) && patch.managedSections.some(section => !(existingBot?.managedSections ?? []).includes(section))) {
20360
20796
  loosened.push("managedSections");
20361
20797
  }
@@ -20731,6 +21167,71 @@ const handleRequest = async (req, res) => {
20731
21167
  // it. That keeps one bot from silently changing every teammate's future
20732
21168
  // turns. The section query parameter is required even for General (""),
20733
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
+ }
20734
21235
  if (path === "/api/section-context" && (method === "GET" || method === "PUT")) {
20735
21236
  if (!url.searchParams.has("section"))
20736
21237
  return json(res, 400, { error: "section is required" });
@@ -21332,6 +21833,8 @@ const handleRequest = async (req, res) => {
21332
21833
  return json(res, 403, { error: refusal });
21333
21834
  }
21334
21835
  return await answeringCardAs(auth, bot.threadId, String(body.requestId), async () => {
21836
+ if (resolveAndSendTrust(res, bot.threadId, String(body.requestId), behavior))
21837
+ return;
21335
21838
  if (await resolveAndSendTeamSetup(res, {
21336
21839
  botId: bot.id, threadId: bot.threadId, requestId: String(body.requestId), behavior,
21337
21840
  }, auth.kind === "loopback" ? DESKTOP_MANAGED || Boolean(req.headers.origin) && !store.bots.some((bot) => bot.busy || activeGroupTurnForBot(bot.id)) : auth.scopes.includes("admin")))
@@ -21434,6 +21937,8 @@ const handleRequest = async (req, res) => {
21434
21937
  return json(res, 403, { error: refusal });
21435
21938
  }
21436
21939
  return await answeringCardAs(auth, threadId, requestId, async () => {
21940
+ if (resolveAndSendTrust(res, threadId, requestId, behavior))
21941
+ return;
21437
21942
  const skillCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.skillRequest);
21438
21943
  if (skillCard?.card?.skillRequest) {
21439
21944
  const skillBotId = skillCard.from?.botId ?? store.botByThread(threadId)?.id;
@@ -22663,6 +23168,34 @@ const handleRequest = async (req, res) => {
22663
23168
  res.end(decisionsCsv(readDecisionRange(DATA_DIR, range)));
22664
23169
  return;
22665
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
+ }
22666
23199
  // ── provider instances (model picker) ──
22667
23200
  if (method === "GET" && path === "/api/instances") {
22668
23201
  // Rescan PATH first: this endpoint is how the app answers "what can I
@@ -22947,7 +23480,7 @@ const handleRequest = async (req, res) => {
22947
23480
  catch { /* Allow repairing an unused malformed account. */ }
22948
23481
  entry.config = { ...entry.config, configDir: body.configDir };
22949
23482
  if (previousDir !== configuredAccountDirectory(entry)) {
22950
- const used = store.bots.some((bot) => bot.modelSelection.instanceId === instanceId || store.tasks(bot.id).some((task) => task.modelSelection?.instanceId === instanceId || task.resumeCursors[instanceId] || task.lastInstanceId === instanceId));
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));
22951
23484
  if (used)
22952
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." });
22953
23486
  assertSeparateClaudeAccount(instances, instanceId, entry);
@@ -22971,7 +23504,7 @@ const handleRequest = async (req, res) => {
22971
23504
  if (instances[instanceId].driver !== "claudeAgent" || instanceId === "claude") {
22972
23505
  return json(res, 400, { error: "Only added Claude accounts can be removed here." });
22973
23506
  }
22974
- 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)) ||
22975
23508
  busyProviderSelections().some((selection) => selection.instanceId === instanceId)) {
22976
23509
  return json(res, 409, { error: "Choose another account for the bots and default model using this account before removing it." });
22977
23510
  }