openmausbot 0.1.85 → 0.1.86

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 (193) hide show
  1. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  2. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  3. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  4. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  5. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  6. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  7. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  10. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  11. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  12. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  13. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  16. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  17. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  18. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  19. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  20. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  21. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  22. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  23. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  24. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  25. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  26. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  27. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  28. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  29. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  30. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  31. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  32. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  33. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  34. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  35. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  36. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  37. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  43. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  44. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  45. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  46. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  47. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  48. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  49. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  50. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  51. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  52. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  53. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  54. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  55. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  56. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  57. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  58. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  59. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  60. package/dist/assets/abnfDiagram-O67JEVCF-DpUZNnFD.js +1 -0
  61. package/dist/assets/arc-DW7mrI4p.js +1 -0
  62. package/dist/assets/architectureDiagram-NJMV4G6O-CyeZ5WLZ.js +36 -0
  63. package/dist/assets/blockDiagram-BEXU5L5S-CBVaNjO7.js +140 -0
  64. package/dist/assets/c4Diagram-YGBWAQC7-sf8d41uT.js +38 -0
  65. package/dist/assets/channel-BXFkgus9.js +1 -0
  66. package/dist/assets/chunk-2Q5K7J3B-BOGvy7Y_.js +1 -0
  67. package/dist/assets/chunk-3FUC2YCW-BiF9zyiz.js +2 -0
  68. package/dist/assets/chunk-5VM5RSS4-EFuZ1SBZ.js +15 -0
  69. package/dist/assets/chunk-GTNCS2PH-pJLNSAT7.js +88 -0
  70. package/dist/assets/chunk-GWA4HPMP-Cb-3_3IT.js +1 -0
  71. package/dist/assets/chunk-JWPE2WC7-DAf5aogn.js +1 -0
  72. package/dist/assets/chunk-NETBCI7D-DR3kiwZz.js +1 -0
  73. package/dist/assets/chunk-WEXAMYUT-BmU8PeyM.js +1 -0
  74. package/dist/assets/chunk-XXDRQBXY-Buh8g8OT.js +1 -0
  75. package/dist/assets/classDiagram-v2-NBCMYWYE-BurfmMWM.js +217 -0
  76. package/dist/assets/cose-bilkent-JH36ORCC-KkGZUEkU.js +1 -0
  77. package/dist/assets/cynefin-OW5HDTMX-C3PpV96d.js +166 -0
  78. package/dist/assets/cynefinDiagram-VND7K2PF-Dt8JwJaI.js +62 -0
  79. package/dist/assets/cytoscape.esm-BB4DxJjf.js +321 -0
  80. package/dist/assets/dagre-6A5THRUB-DkjN8266.js +4 -0
  81. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  82. package/dist/assets/diagram-22UHCM2B-DUAJS0JV.js +200 -0
  83. package/dist/assets/diagram-3UASUU5V-Dw4KhF1a.js +24 -0
  84. package/dist/assets/diagram-ATOU4E4O-Dd1gmQqf.js +3 -0
  85. package/dist/assets/diagram-CDSNMT55-CjwGs0Yw.js +30 -0
  86. package/dist/assets/diagram-MLGK6HIB-BL-69Zrq.js +24 -0
  87. package/dist/assets/diagram-MPIPVDR6-5Zi2N7wo.js +41 -0
  88. package/dist/assets/ebnfDiagram-ZINNZB2B-D9NekWUX.js +1 -0
  89. package/dist/assets/elk-276RUBZZ-CkaSQjxw.js +27 -0
  90. package/dist/assets/erDiagram-OPXOYQCR-CdAVBV8-.js +99 -0
  91. package/dist/assets/flowDiagram-KWPJA3E3-Bm_lWWH7.js +213 -0
  92. package/dist/assets/ganttDiagram-FUAMR5RP-FZ8ejYWJ.js +292 -0
  93. package/dist/assets/gitGraphDiagram-X574FWY7-Dlv9rizZ.js +106 -0
  94. package/dist/assets/graph-D8UmR1vK.js +1 -0
  95. package/dist/assets/index-BGgqlWPJ.css +1 -0
  96. package/dist/assets/index-BtHo8ZKl.js +577 -0
  97. package/dist/assets/{index-CTNNoqSJ.js → index-h9FFNwTu.js} +1 -1
  98. package/dist/assets/infoDiagram-VRGFBTTK-BdQ_gW5B.js +2 -0
  99. package/dist/assets/init-Gi6I4Gst.js +1 -0
  100. package/dist/assets/ishikawaDiagram-OU5B5YK6-EwssEp2X.js +70 -0
  101. package/dist/assets/journeyDiagram-ZHPQQLJL-BX1G1vOd.js +139 -0
  102. package/dist/assets/kanban-definition-PNTS6WVX-BB4kAsyC.js +89 -0
  103. package/dist/assets/linear-3su2TG93.js +1 -0
  104. package/dist/assets/mermaid.core-CDD1E0s9.js +311 -0
  105. package/dist/assets/mindmap-definition-NLK3R4M7-Ba50MsNK.js +96 -0
  106. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  107. package/dist/assets/pegDiagram-GJSIUBJH-DZLkkZlM.js +1 -0
  108. package/dist/assets/pieDiagram-5QR66LMP-DaTz77Uq.js +39 -0
  109. package/dist/assets/quadrantDiagram-O4NWA36T-CpaiidpR.js +7 -0
  110. package/dist/assets/railroadDiagram-XR7U4H2S-mN4JrMeI.js +1 -0
  111. package/dist/assets/requirementDiagram-PLB6GJNP-BSNDRpg7.js +84 -0
  112. package/dist/assets/sankeyDiagram-IPEJSGJF-FngzGNug.js +40 -0
  113. package/dist/assets/sequenceDiagram-PO4LG4MO-CM4pCC3L.js +169 -0
  114. package/dist/assets/sizeCapture-INFHLROL-CRICHXkw.js +1 -0
  115. package/dist/assets/stateDiagram-v2-GCMORJYK-oyTWpxH2.js +291 -0
  116. package/dist/assets/swimlanes-2SLR337P-D9h9mPBq.js +1 -0
  117. package/dist/assets/swimlanesDiagram-TC7HE7FX-BWrvB2ak.js +8 -0
  118. package/dist/assets/timeline-definition-EJHVYXUP-BJUlN9bW.js +120 -0
  119. package/dist/assets/usecaseDiagram-POWQR4AR-3uBNkbAb.js +370 -0
  120. package/dist/assets/vennDiagram-UO4OBE2U-D2odalzT.js +34 -0
  121. package/dist/assets/wardleyDiagram-VNRHLVJA-DHgFaB9S.js +78 -0
  122. package/dist/assets/xychartDiagram-PMCCYNJV-w5GYM2h0.js +7 -0
  123. package/dist/index.html +2 -2
  124. package/dist-server/container-mcp.js +19 -10
  125. package/dist-server/drivers/agents-proxy.js +16159 -1596
  126. package/dist-server/enterprise/LICENSE +32 -0
  127. package/dist-server/enterprise/server/index.js +5 -5
  128. package/dist-server/index.js +4779 -1667
  129. package/dist-server/mcp-server.js +3 -1
  130. package/dist-server/openmausbot.js +4679 -4266
  131. package/dist-server/pair-cli.js +4679 -4266
  132. package/dist-server/server/admin-activity.js +410 -0
  133. package/dist-server/server/bot-visibility.js +462 -0
  134. package/dist-server/server/box.js +2 -1
  135. package/dist-server/server/browser-runtime.js +1 -1
  136. package/dist-server/server/channel-queue.js +2 -0
  137. package/dist-server/server/cli-prompts.js +24 -2
  138. package/dist-server/server/cli.js +79 -10
  139. package/dist-server/server/composio.js +49 -11
  140. package/dist-server/server/config.js +79 -13
  141. package/dist-server/server/decision-log.js +209 -48
  142. package/dist-server/server/default-model-selection.js +20 -3
  143. package/dist-server/server/delegations.js +11 -9
  144. package/dist-server/server/drivers/acp/core.js +110 -22
  145. package/dist-server/server/drivers/acp/grok.js +4 -3
  146. package/dist-server/server/drivers/agents-call.js +1123 -0
  147. package/dist-server/server/drivers/agents-catalog.js +659 -0
  148. package/dist-server/server/drivers/agents-client.js +28 -0
  149. package/dist-server/server/drivers/agents-proxy.js +18 -1722
  150. package/dist-server/server/drivers/chat-mcp-tools.js +17 -2
  151. package/dist-server/server/drivers/claude.js +21 -6
  152. package/dist-server/server/drivers/codex-mcp-names.js +44 -0
  153. package/dist-server/server/drivers/codex.js +26 -4
  154. package/dist-server/server/drivers/grok.js +2 -1
  155. package/dist-server/server/drivers/openai-compat.js +15 -8
  156. package/dist-server/server/enterprise.js +97 -16
  157. package/dist-server/server/external-runtime.js +83 -0
  158. package/dist-server/server/guarded-requests.js +60 -0
  159. package/dist-server/server/harness/http.js +61 -0
  160. package/dist-server/server/hosted-models.js +151 -0
  161. package/dist-server/server/hosted-slack.js +11 -0
  162. package/dist-server/server/index.js +1718 -348
  163. package/dist-server/server/managed-desktop.js +79 -14
  164. package/dist-server/server/managed-policy.js +153 -0
  165. package/dist-server/server/message-db.js +28 -0
  166. package/dist-server/server/model-context-window.js +7 -0
  167. package/dist-server/server/model-prices.js +121 -0
  168. package/dist-server/server/notify.js +10 -1
  169. package/dist-server/server/peer-roster.js +13 -2
  170. package/dist-server/server/recent-work.js +15 -0
  171. package/dist-server/server/request-auth.js +96 -0
  172. package/dist-server/server/room-handoffs.js +25 -11
  173. package/dist-server/server/routes/hosted-slack.js +20 -0
  174. package/dist-server/server/routes/table.js +14 -0
  175. package/dist-server/server/routines.js +22 -0
  176. package/dist-server/server/spend.js +111 -16
  177. package/dist-server/server/steer-queue.js +6 -1
  178. package/dist-server/server/store.js +292 -21
  179. package/dist-server/server/team-backup.js +10 -1
  180. package/dist-server/server/thread-starters.js +53 -0
  181. package/dist-server/server/usage-ledger.js +31 -8
  182. package/dist-server/server/workspace-backup-policy.js +9 -2
  183. package/dist-server/server/workspace-backup.js +7 -4
  184. package/dist-server/shared/mention-boundary.js +15 -0
  185. package/dist-server/shared/routines.js +7 -1
  186. package/dist-server/shared/team-backup.js +2 -0
  187. package/dist-server/src/lib/composer-attachments.js +16 -0
  188. package/dist-server/vps-container-mcp.js +19 -10
  189. package/enterprise/README.md +33 -12
  190. package/enterprise/server/index.js +5 -5
  191. package/package.json +1 -1
  192. package/dist/assets/index-Ba9G44HI.js +0 -310
  193. package/dist/assets/index-CkRNp7wX.css +0 -1
@@ -1,6 +1,7 @@
1
1
  // OpenMausBot server — the harness host. Clients hold no transports
2
2
  // (upstream rule): the React app dispatches typed commands over HTTP and
3
3
  // folds one SSE event stream; every provider process runs here.
4
+ import { AsyncLocalStorage } from "node:async_hooks";
4
5
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
5
6
  import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs";
6
7
  import { writeFileAtomic } from "./atomic.js";
@@ -8,6 +9,7 @@ import { rm as removeDirectory } from "node:fs/promises";
8
9
  import { createServer } from "node:http";
9
10
  import { ToolResults, TOOL_RESULT_MAX_CHARS } from "./tool-results.js";
10
11
  import { extname, join } from "node:path";
12
+ import { authorizeExternalRuntime, externalRuntimeIsActive } from "./external-runtime.js";
11
13
  import { z } from "zod";
12
14
  import { selectReplay, DEFAULT_REBUILD_BYTES, MAX_SUMMARY_BYTES } from "./context-rebuild.js";
13
15
  import { draftSummary, foldPoint } from "./compaction-summary.js";
@@ -16,6 +18,7 @@ import { autoCompactWindow } from "./drivers/claude.js";
16
18
  import { SharedComputers, sharedComputerOperation, sharedComputerRegistration } from "./shared-computers.js";
17
19
  import { SharedComputerControl } from "./shared-computer-control.js";
18
20
  import { RoomHandoffs } from "./room-handoffs.js";
21
+ import { assertRequestTarget, guardedRequestPath, requestConflict, requestNeedsInput, requestSourceForCard } from "./guarded-requests.js";
19
22
  import { botAvatarUrlFromStoredPath } from "../shared/bot-avatar.js";
20
23
  import { BOT_PROFILE_LIMITS } from "../shared/bot-profile.js";
21
24
  import { CLOUD_COMPUTER_BUSY_ERROR } from "../shared/computer-contention.js";
@@ -31,7 +34,7 @@ import { BrowserCleanupCoordinator, finalizeBrowserCleanupMutation, requireBrows
31
34
  import * as checkpoints from "./checkpoints.js";
32
35
  import { commandReceipt } from "./commands.js";
33
36
  import { buildTurnDigest, coverageForDriver, digestPromptLine, renderDigest, toolEvidence } from "./digest.js";
34
- import { appendDecision, readDecisions, flushDecisionLog } from "./decision-log.js";
37
+ import { appendDecision, bindDecisionRetention, boundRetentionDays, decisionRetentionDays, decisionsCsv, flushDecisionLog, pruneDecisions, readDecisionRange, readDecisions, withDecisionActor } from "./decision-log.js";
35
38
  import { validateBotCwd } from "./bot-cwd.js";
36
39
  import { ATTACHMENTS_DIR, attachmentExists, cleanupStaleAttachmentPartials, deleteAttachment, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, readAttachment, saveFile, saveImage, saveImageUpload, validateAttachmentUploadId, } from "./attachments.js";
37
40
  import { messageFileDisposition, messageFileDownloadName, messageAttachmentName, messageFileRoots, messageImageTargetAt, messageReferencesFile, openMessageFile, } from "./message-file.js";
@@ -50,19 +53,20 @@ import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
50
53
  import { canAccessTeam, canReachPeer, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
51
54
  import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
52
55
  import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerRuntimeStatus, localVmRecreatableOnDemand, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, } from "./container-computer.js";
53
- import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, } from "./config.js";
56
+ import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, } from "./config.js";
54
57
  import { sweepThreadEventLogs } from "./thread-retention.js";
55
58
  import { ComputerControl } from "./computer-control.js";
56
59
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
57
60
  import { registerEnginesBinDir } from "./engine-install.js";
58
61
  import { appendUsage, parseUsageRange, readUsage, summarizeUsage, usageCsv, USAGE_GROUPINGS, flushUsageLedger } from "./usage-ledger.js";
62
+ import { ledgerCost } from "./model-prices.js";
59
63
  import { checkProviderKey, PROVIDER_KEY_KINDS } from "./provider-key-check.js";
60
- import { assertWithinBudget, noteSpend, spendState } from "./spend.js";
64
+ import { assertWithinBudget, noteSpend, spendAlertText, spendState, takeSpendAlert } from "./spend.js";
61
65
  import { fleetAvailable, fleetRequest, fleetSocketPath } from "./fleet-client.js";
62
66
  import { entitled } from "./enterprise.js";
63
67
  import { HOSTED_CONTRACT_HEADER, HOSTED_CONTRACT_METADATA, HOSTED_CONTRACT_VERSION } from "./hosted-contract.js";
64
68
  import { describeSpawnFailure, execCli } from "./procs.js";
65
- import { blockedTarget, buildNotification } from "./notify.js";
69
+ import { blockedTarget, buildNotification, buildSpendNotification } from "./notify.js";
66
70
  import { isModelVariant, newId, } from "./contracts.js";
67
71
  import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.js";
68
72
  import { decodeGeneratedImage } from "./generated-image.js";
@@ -71,9 +75,9 @@ import { probeMcpServer } from "./mcp-probe.js";
71
75
  import { GROUP_GOAL_MAX_TURNS, groupGoalAssignmentKey, groupGoalCompletionTurnId, groupGoalCoordinatorInstructions, groupGoalWorkerInstructions, parseGroupGoalDecision, resolveGroupGoalMember, selectGroupGoalCoordinator, } from "./group-goal-run.js";
72
76
  import { BUILT_IN_DRIVERS } from "./drivers/builtIn.js";
73
77
  import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from "./comms-visibility.js";
74
- import { readMessageText, recallMessages, recentMessages, searchMessages, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups } from "./message-db.js";
78
+ import { readMessageText, recallMessages, recentMessages, searchMessages, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups, threadsReferencing } from "./message-db.js";
75
79
  import { briefCrossingLabel, claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
76
- import { parseSince, recentWork, recentWorkPrompt, turnOutcomeLine } from "./recent-work.js";
80
+ import { parseSince, parseUntil, recentWork, recentWorkPrompt, turnOutcomeLine } from "./recent-work.js";
77
81
  import { chiefForBot, INCIDENTS_THREAD_TITLE, IncidentLedger, incidentChip, incidentText } from "./incidents.js";
78
82
  /** A session_read answer competes with the transcript for the context
79
83
  * window; a computer-use turn's output can run to hundreds of KB. */
@@ -86,6 +90,8 @@ import { acceptedSendMatch, parseSendId, sendFingerprint, SendSequencer, } from
86
90
  import { EventBus } from "./harness/bus.js";
87
91
  import { ProviderRegistry } from "./harness/registry.js";
88
92
  import { ManagedDesktopProviders } from "./managed-desktop.js";
93
+ import { computerKindForResource, ManagedDesktopPolicy } from "./managed-policy.js";
94
+ import { hostedModelPolicy, HOSTED_MODEL_POLICY_HEADER, HOSTED_PROVIDER_SETTINGS_ERROR } from "./hosted-models.js";
89
95
  import { selectDefaultModelSelection } from "./default-model-selection.js";
90
96
  import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
91
97
  import { peerProvenanceNote, withPeerProvenance } from "./peer-provenance.js";
@@ -156,7 +162,7 @@ import { parseSurface, resolveSurface, surfaceLabel, surfaceOfComputerKind, surf
156
162
  import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
157
163
  import { createGracefulShutdown } from "./graceful-shutdown.js";
158
164
  import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
159
- import { createWorkspaceAccess, describeEdition, editionStatus, hostedWorkspaceConfiguration, hostedWorkspaceConfigured, loadEnterpriseLayer } from "./enterprise.js";
165
+ import { createWorkspaceAccess, describeEdition, editionStatus, hostedWorkspaceConfiguration, hostedWorkspaceConfigured, editionForMembers, LICENSE_WARN_DAYS, licenseWarning, sharedWorkspaceFullAccessConfigured, loadEnterpriseLayer, workspaceMembership } from "./enterprise.js";
160
166
  import { environmentDescriptor, loadEnvironmentId, serverVersion } from "./environment.js";
161
167
  import { WorkspaceBackupMaintenance } from "./workspace-backup-maintenance.js";
162
168
  import { createWorkspaceBackupRoutes, isWorkspaceBackupSessionControl } from "./workspace-backup-http.js";
@@ -164,11 +170,19 @@ import { applyPendingWorkspaceRestore, readLastWorkspaceRestore } from "./worksp
164
170
  import { createCustomDomainVerifier, customDomainIpv4, normalizeCustomDomain } from "./custom-domain.js";
165
171
  import { allowedScopes, createEmailSignIn, parseAllowList } from "./account-signin.js";
166
172
  import { ProviderAuthSessions } from "./provider-auth-sessions.js";
167
- import { clearSessionCookie, clientBotPatchViolation, clientGroupPatchViolation, isLoopbackHost, isProxied, labelFromUserAgent, requestOrigin, requestSource, resolveRequestAuth, parseCookies, serializeSessionCookie, sessionCookieName, } from "./request-auth.js";
173
+ import { clearSessionCookie, clientBotPatchViolation, clientGroupPatchViolation, isLoopbackHost, isProxied, labelFromUserAgent, requestOrigin, requestSource, requiredScope, resolveLoopbackTrust, resolveRequestAuth, parseCookies, serializeSessionCookie, sessionCookieName, } from "./request-auth.js";
168
174
  import { cookieMaxAgeSeconds, formatPairingCode, SessionRegistry } from "./sessions.js";
175
+ import { ThreadStarters } from "./thread-starters.js";
176
+ import { activityCsv, activityEntries, appendAdminAction, botAuditSnapshot, botChangeRows, configChangeRows, parseActivityWhat, flushAdminActivity, pruneAdminActivity, readAdminActivityRange, sharedSignIn, signInListsOf, } from "./admin-activity.js";
177
+ import { frameForMember, memberBody, memberBot, noteSeen, notFoundFor, audienceEquals, memberGroup, narrowestAudience, parseVisibility, pathSubject, roomFeeds, routineVisible, sameAudience, SEES_EVERYTHING, VisibleSet, } from "./bot-visibility.js";
169
178
  import { describeBrand, loadBrand } from "./brand.js";
170
179
  import { deliverSseFrame } from "./sse-fanout.js";
171
180
  import { PHONE_SECRET_PROTOCOL_VERSION, PhoneSecretBridge, PhoneSecretError, PhoneSecretSubmissionRegistry, assertPhoneSecretRequestMatches, phoneSecretOperationId, } from "./phone-secret.js";
181
+ // Keep these two last: a route module may import any server module, and
182
+ // loading the table after everything above leaves module start-up order as is.
183
+ import { json, onJsonBody, parsedBodyOf, readBody } from "./harness/http.js";
184
+ import { ROUTES, dispatchRoutes } from "./routes/table.js";
185
+ import { createHostedSlackRoutes } from "./routes/hosted-slack.js";
172
186
  const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799);
173
187
  const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1);
174
188
  // Behind a proxy or tunnel, the base URL senders should use (docs/self-hosting.md).
@@ -232,10 +246,37 @@ const sessions = new SessionRegistry({
232
246
  portalMembership: hostedWorkspaceConfiguration()?.portalMembership === true,
233
247
  });
234
248
  const sharedComputers = new SharedComputers(id => sessions.isLive(id));
249
+ // Who each thread is for, when a signed-in person can be named (server-private).
250
+ const threadStarters = new ThreadStarters(join(DATA_DIR, "thread-starters.json"));
235
251
  const SESSION_COOKIE = sessionCookieName(PORT, ENVIRONMENT_ID);
236
252
  const HOSTED_WORKSPACE = hostedWorkspaceConfigured();
237
253
  let workspaceAccess = null;
238
254
  const DESKTOP_MANAGED = process.env.OMB_DESKTOP_PARENT === "1";
255
+ const SHARED_WORKSPACE_FULL_ACCESS = sharedWorkspaceFullAccessConfigured();
256
+ const sharedWorkspaceFullAccessEnabled = () => SHARED_WORKSPACE_FULL_ACCESS && Boolean(workspaceAccess) && entitled("admin");
257
+ // Who a loopback request without a session is (server/request-auth.ts
258
+ // LoopbackTrust): the owner on a desktop or a one-person server; a service on
259
+ // a shared workspace, where every bot's shell is a loopback caller too.
260
+ const LOOPBACK = resolveLoopbackTrust({ desktopManaged: DESKTOP_MANAGED, hostedWorkspace: HOSTED_WORKSPACE });
261
+ // `openmausbot serve` on a service-trust server hands the server it starts a
262
+ // per-launch secret on stdin, then closes it (server/cli.ts). It opens only
263
+ // the pairing route, for that CLI. Never an environment variable: every
264
+ // engine this server starts inherits its environment.
265
+ let cliOwnerToken;
266
+ if (process.env.OMB_CLI_OWNER_STDIN === "1" && LOOPBACK.trust === "service" && process.stdin) {
267
+ let received = "";
268
+ process.stdin.setEncoding("utf8");
269
+ process.stdin.on("error", () => { });
270
+ process.stdin.on("data", (chunk) => {
271
+ if (cliOwnerToken !== undefined || received.length > 256)
272
+ return;
273
+ received += chunk;
274
+ const line = received.split("\n", 1)[0];
275
+ if (received.includes("\n") && /^[A-Za-z0-9_-]{43}$/.test(line))
276
+ cliOwnerToken = line;
277
+ });
278
+ }
279
+ delete process.env.OMB_CLI_OWNER_STDIN;
239
280
  // Empty is deliberately a deny-all bootstrap state. Only Electron's private
240
281
  // utility-process port can replace it with the per-launch owner capability.
241
282
  let desktopMutationToken = DESKTOP_MANAGED ? "" : undefined;
@@ -243,10 +284,22 @@ let companionMutationToken = DESKTOP_MANAGED ? "" : undefined;
243
284
  // Where remote clients reach this server (a proxy's public address); pairing URLs use it.
244
285
  const FALLBACK_PUBLIC_URL = process.env.OMB_PUBLIC_URL?.trim().replace(/\/+$/, "") || null;
245
286
  const cfg = loadConfig();
287
+ const hostedModels = hostedModelPolicy(DATA_DIR);
288
+ const providerConfigs = () => hostedModels ? hostedModels.configs() : instanceConfigs(cfg);
289
+ const decorateHostedProvider = hostedModels ? (instance) => hostedModels.decorate(instance) : undefined;
290
+ if (hostedModels) {
291
+ const selection = hostedModels.select(cfg.defaultModelSelection);
292
+ if (selection.instanceId && JSON.stringify(selection) !== JSON.stringify(cfg.defaultModelSelection))
293
+ saveConfig({ defaultModelSelection: selection });
294
+ cfg.defaultModelSelection = selection;
295
+ }
246
296
  // The per-thread event log cap is checked after every NDJSON append.
247
297
  // config.json is read once per process (a change restarts the server, like
248
298
  // every other hand-edited knob), so a binding made here never goes stale.
249
299
  bindThreadLogCapProvider(() => threadEventLogMaxBytes(cfg));
300
+ // The decision log keeps month files for at least this many days. Read per
301
+ // prune, so a Settings change applies at the next one without a restart.
302
+ bindDecisionRetention(() => decisionRetentionDays(cfg.decisions?.retentionDays));
250
303
  const customDomainVerifier = createCustomDomainVerifier({ environmentId: ENVIRONMENT_ID });
251
304
  // "Sign in with your email" on /pair: the allow-list is read per call so a
252
305
  // Settings change or an env bootstrap applies without a restart.
@@ -276,11 +329,15 @@ const registry = new ProviderRegistry(BUILT_IN_DRIVERS);
276
329
  // Engines installed from Settings live under the data directory and win over
277
330
  // any other copy on PATH.
278
331
  registerEnginesBinDir();
279
- // Who asked for the next turn on a thread, noted where a message comes in
280
- // and read by the usage ledger when the turn settles. The last note stands
281
- // until the next message: a resumed connector or a drained queued send
282
- // still belongs to the person who wrote, and routine or peer turns are
283
- // told apart before this is consulted.
332
+ // Who asked for the turn running (or last run) on each thread, read by the
333
+ // usage ledger when it settles. It is set when a turn is ADMITTED, from the
334
+ // message that started it — never when a message merely queues behind a
335
+ // running turn or is steered into one, so a second person's words cannot
336
+ // re-book the first person's turn. A queued message carries its sender's
337
+ // trigger until it drains and starts its own turn. Continuations (a resumed
338
+ // connector, a delegation wake) set nothing and stay with the person whose
339
+ // ask they continue; routine and peer turns are told apart before this is
340
+ // consulted.
284
341
  const turnTriggers = new Map();
285
342
  /** Who a user message is from, when that is someone other than the desktop
286
343
  * owner. Loopback is the owner by design, so it stays unstamped and reads as
@@ -291,15 +348,509 @@ function messageSender(auth) {
291
348
  if (auth.kind !== "session")
292
349
  return undefined;
293
350
  const name = (auth.session.email ?? auth.session.label ?? "").trim();
294
- return name ? { name } : undefined;
351
+ return name ? { name, id: personKey(auth.session) } : undefined;
352
+ }
353
+ /** An opaque, stable key for the person behind a session: their account
354
+ * email when they signed in with one (a new device is still them), else the
355
+ * paired session itself. Hashed, so a message or a thread can carry it
356
+ * without handing other members a session id. */
357
+ function personKey(session) {
358
+ const basis = session.email ? `email:${session.email.trim().toLowerCase()}` : `session:${session.id}`;
359
+ return `p_${createHash("sha256").update(basis).digest("base64url").slice(0, 22)}`;
360
+ }
361
+ /** More than one person uses this workspace: portal membership, or an email
362
+ * sign-in list that names members. Only then does who-may-answer narrow. */
363
+ function sharedMembership() {
364
+ return hostedWorkspaceConfiguration()?.portalMembership === true || signInAllowList().members.length > 0;
365
+ }
366
+ /** The person a user line came from, when a session sent it. A bot's line
367
+ * (peerAsk), the owner, a service, a routine or a webhook names nobody. */
368
+ function linePersonKey(message) {
369
+ return message?.role === "user" && !message.peerAsk ? message.sender?.id : undefined;
370
+ }
371
+ /** The opaque key of whoever sent the request a card belongs to: the
372
+ * message the harness proved started the turn, else the last user line
373
+ * before the card. */
374
+ function cardRequesterKey(threadId, requestId) {
375
+ const thread = store.messagesFor(threadId);
376
+ const index = thread.findIndex((message) => message.card?.requestId === requestId);
377
+ if (index < 0)
378
+ return undefined;
379
+ const card = thread[index];
380
+ return linePersonKey(card.requestMessageId
381
+ ? thread.find((message) => message.id === card.requestMessageId)
382
+ : thread.slice(0, index).findLast((message) => message.role === "user"));
383
+ }
384
+ /** The signed-in person a thread's current work is for: whoever sent its
385
+ * current (else latest) request from a session, else whoever the thread was
386
+ * opened for. A bot opening a thread while working records this for the new
387
+ * thread, so delegated work leads back to the person who asked. */
388
+ function threadPersonKey(threadId) {
389
+ const thread = store.messagesFor(threadId);
390
+ const owner = directRequestOwners.get(threadId);
391
+ const request = owner?.messageId
392
+ ? thread.find((message) => message.id === owner.messageId)
393
+ : thread.findLast((message) => message.role === "user");
394
+ return linePersonKey(request) ?? threadStarters.get(threadId);
395
+ }
396
+ /** Whose session may answer a card. The provider CLI's own approval modes and
397
+ * the harness's proposals stay exactly as they are; this adds no card, gate
398
+ * or prompt, it only decides whose answer to an existing card counts.
399
+ * - the owner on this machine, and admins: any card;
400
+ * - a session-less caller on a shared server (service trust): decline only;
401
+ * - where several people share the workspace and the card can be traced to
402
+ * a person (who sent its request, who the thread was opened for, followed
403
+ * back through threads bots opened for them): those people;
404
+ * - otherwise (one person; or a routine, webhook, Slack, owner-sent or older
405
+ * thread that names nobody): anyone who may chat, as before. */
406
+ function cardAnswerRefusal(auth, threadId, requestId, behavior) {
407
+ if (auth.kind === "loopback") {
408
+ return auth.trust === "service" && behavior !== "deny"
409
+ ? "A local service can only decline this request. Approve or answer it in OpenMausBot while signed in."
410
+ : null;
411
+ }
412
+ if (auth.scopes.includes("admin") || !sharedMembership())
413
+ return null;
414
+ const known = [threadStarters.get(threadId), cardRequesterKey(threadId, requestId)].filter((person) => Boolean(person));
415
+ if (!known.length || known.includes(personKey(auth.session)))
416
+ return null;
417
+ return "Only the person who started this conversation or sent this request, or a workspace admin, can answer this card.";
418
+ }
419
+ function decisionActorFor(auth) {
420
+ if (auth.kind === "loopback")
421
+ return auth.trust === "service" ? { kind: "worker" } : { kind: "loopback" };
422
+ const session = auth.session;
423
+ return {
424
+ kind: "session", sessionId: session.id, label: session.label,
425
+ ...(session.email ? { email: session.email } : {}),
426
+ // A portal session's id is its membership grant: never written down.
427
+ ...(session.userId && !session.userId.startsWith("portal:") ? { userId: session.userId } : {}),
428
+ };
429
+ }
430
+ function cardAnswererFor(auth) {
431
+ if (auth.kind === "loopback")
432
+ return auth.trust === "service" ? { kind: "worker" } : { kind: "loopback" };
433
+ return { kind: "session", name: (auth.session.email ?? auth.session.label ?? "").trim() || "Signed-in user" };
434
+ }
435
+ /** Answer a card as `auth`: the decision rows written meanwhile name the
436
+ * answerer, and a card this answer settled records who settled it. A card
437
+ * that was already settled keeps whatever it said. */
438
+ async function answeringCardAs(auth, threadId, requestId, work) {
439
+ const open = (() => {
440
+ const card = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId)?.card;
441
+ return Boolean(card && !card.answered && !card.dismissed);
442
+ })();
443
+ try {
444
+ await withDecisionActor(decisionActorFor(auth), work);
445
+ }
446
+ finally {
447
+ const message = open ? store.messagesFor(threadId).find((candidate) => candidate.card?.requestId === requestId) : undefined;
448
+ const card = message?.card;
449
+ if (message && card && !card.answeredBy && card.answered !== "unavailable" && (card.answered || card.dismissed)) {
450
+ store.patchMessage(threadId, message.id, { card: { ...card, answeredBy: cardAnswererFor(auth) } });
451
+ }
452
+ }
453
+ }
454
+ // ── bot visibility (server/bot-visibility.ts) ──────────────────────────
455
+ /** Who is looking. The owner, a session-less local service (the Slack
456
+ * worker) and admin sessions see every bot; any other session is a member,
457
+ * known by the email it signed in with. */
458
+ function viewerFor(auth) {
459
+ if (auth.kind === "loopback" || auth.scopes.includes("admin"))
460
+ return SEES_EVERYTHING;
461
+ return { kind: "member", ...(auth.session.email ? { email: auth.session.email } : {}) };
462
+ }
463
+ /** Thread → the bot or room that owns it, and one VisibleSet per viewer,
464
+ * both kept until the next bot, room or thread change (store.onChange
465
+ * below), so a stream of live frames costs a map lookup each. */
466
+ const threadOwners = new Map();
467
+ const visibleSets = new Map();
468
+ function threadOwner(threadId) {
469
+ let owner = threadOwners.get(threadId);
470
+ if (owner === undefined) {
471
+ const bot = store.botByThread(threadId);
472
+ const group = bot ? undefined : store.groupByThread(threadId);
473
+ owner = bot ? { bot: bot.id } : group ? { group: group.id } : null;
474
+ if (threadOwners.size >= 20_000)
475
+ threadOwners.clear();
476
+ threadOwners.set(threadId, owner);
477
+ }
478
+ return owner ?? undefined;
479
+ }
480
+ function visibleTo(viewer) {
481
+ const key = viewer.kind === "all" ? "*" : `member:${viewer.email?.trim().toLowerCase() ?? ""}`;
482
+ let set = visibleSets.get(key);
483
+ if (!set) {
484
+ set = new VisibleSet(store.bots, store.groups, viewer, threadOwner);
485
+ if (visibleSets.size >= 1_000)
486
+ visibleSets.clear();
487
+ visibleSets.set(key, set);
488
+ }
489
+ return set;
490
+ }
491
+ function forgetVisibility() {
492
+ threadOwners.clear();
493
+ visibleSets.clear();
494
+ }
495
+ /** Which threads mention an attachment, for a member's file request while a
496
+ * bot is restricted: a scan of the message store, so kept a minute. */
497
+ const attachmentThreads = new Map();
498
+ function threadsUsingAttachment(name) {
499
+ const cached = attachmentThreads.get(name);
500
+ if (cached && Date.now() - cached.at < 60_000)
501
+ return cached.threads;
502
+ const threads = threadsReferencing(name);
503
+ if (attachmentThreads.size >= 2_000)
504
+ attachmentThreads.clear();
505
+ attachmentThreads.set(name, { at: Date.now(), threads });
506
+ return threads;
507
+ }
508
+ /** The one check every request naming a bot, thread, room, routine, run or
509
+ * attachment passes through for a member. An id nobody knows goes on to its
510
+ * handler, which answers "not found" itself. */
511
+ function subjectVisible(subject, visible) {
512
+ switch (subject.kind) {
513
+ case "bot":
514
+ return !store.bot(subject.id) || visible.bot(subject.id);
515
+ case "thread":
516
+ return !threadOwner(subject.id) || visible.thread(subject.id);
517
+ case "group":
518
+ return !store.group(subject.id) || visible.group(subject.id);
519
+ case "routine": {
520
+ const routine = routines?.listRoutines().find((candidate) => candidate.id === subject.id);
521
+ return !routine || routineVisible(routine, visible);
522
+ }
523
+ case "routine-run": {
524
+ const run = routines?.listRuns().find((candidate) => candidate.id === subject.id);
525
+ return !run || routineVisible(run, visible);
526
+ }
527
+ case "attachment":
528
+ return visible.attachment(subject.name, threadsUsingAttachment);
529
+ }
530
+ }
531
+ /** A member may schedule a routine only for a bot or room they can see. */
532
+ function hiddenRoutineTarget(body, visible) {
533
+ if (visible.everything || !body || typeof body !== "object")
534
+ return null;
535
+ const { botId, groupId } = body;
536
+ if (typeof botId === "string" && store.bot(botId) && !visible.bot(botId))
537
+ return "no such bot";
538
+ if (typeof groupId === "string" && store.group(groupId) && !visible.group(groupId))
539
+ return "no such channel";
540
+ return null;
541
+ }
542
+ /** Whether a room may feed this bot's recall and recent-work brief: only
543
+ * when everyone who can see the bot can see every bot in the room. */
544
+ function roomFeedsBot(group, bot) {
545
+ return roomFeeds([...roomMemberAudiences(group), group.audienceFloor], bot.visibility);
546
+ }
547
+ function roomMemberAudiences(group) {
548
+ return group.memberIds.flatMap((id) => {
549
+ const member = store.bot(id);
550
+ return member ? [member.visibility] : [];
551
+ });
552
+ }
553
+ /** Keep a room's floor at the narrowest audience it has ever had. Called
554
+ * whenever a member bot or the room changes (and for every room at start),
555
+ * so the floor is set before a restricted bot can leave: its departure, or
556
+ * its deletion, then never opens the transcript to more people. Only an
557
+ * admin's explicit reset (PATCH /api/groups/:id {resetAudience: true})
558
+ * widens it again, to what its current bots allow. */
559
+ function settleRoomFloor(group) {
560
+ if (group.dm)
561
+ return;
562
+ const next = narrowestAudience([group.audienceFloor, ...roomMemberAudiences(group)]);
563
+ if (audienceEquals(next, group.audienceFloor))
564
+ return;
565
+ store.patchGroup(group.id, { audienceFloor: next === "everyone" ? undefined : next });
566
+ }
567
+ /** The conversations a bot's recent-work brief may draw on: its own, and the
568
+ * rooms roomFeedsBot allows. */
569
+ function recentWorkSources(bot) {
570
+ return {
571
+ groups: store.groups.filter((group) => !group.memberIds.includes(bot.id) || roomFeedsBot(group, bot)),
572
+ taskByThread: (botId, threadId) => store.taskByThread(botId, threadId),
573
+ };
574
+ }
575
+ /** Bots in one room must be visible to the same people: a room is one
576
+ * transcript, so otherwise a restricted bot's words would feed a teammate
577
+ * everyone can see. Refuse a roster that adds such a bot (existing members
578
+ * may stay while an admin sorts it out), naming the pair in plain words. */
579
+ function roomAudienceConflict(memberIds, existing = []) {
580
+ const members = memberIds.flatMap((id) => {
581
+ const bot = store.bot(id);
582
+ return bot ? [bot] : [];
583
+ });
584
+ for (const added of members.filter((bot) => !existing.includes(bot.id))) {
585
+ const other = members.find((bot) => bot.id !== added.id && !sameAudience(bot.visibility, added.visibility));
586
+ if (other) {
587
+ return `Bots in one room must be visible to the same people. ${added.name} and ${other.name} are not: give them the same visibility first, or use separate rooms.`;
588
+ }
589
+ }
590
+ return null;
295
591
  }
296
- function noteTurnTrigger(threadId, auth) {
297
- turnTriggers.set(threadId, auth.kind === "session"
592
+ /** A scheduled call with several bots opens a room, so its bots keep one audience too. */
593
+ function calendarAudienceConflict(body) {
594
+ const botIds = body && typeof body === "object" ? body.botIds : undefined;
595
+ if (!Array.isArray(botIds) || botIds.length < 2 || botIds.some((id) => typeof id !== "string"))
596
+ return null;
597
+ return roomAudienceConflict(botIds);
598
+ }
599
+ /** What a member's live stream needs to narrow a frame. */
600
+ function memberFrameContext(visible) {
601
+ return {
602
+ visible,
603
+ webhookBot: (webhookId) => webhooks.list().find((webhook) => webhook.id === webhookId)?.botId,
604
+ freshBot: (botId) => {
605
+ const bot = store.bot(botId);
606
+ return bot ? { ...wireBot(bot), tasks: store.tasks(bot.id).map(wireTask), ...messagePage(bot.threadId, DEFAULT_PAGE) } : undefined;
607
+ },
608
+ freshGroup: (groupId) => {
609
+ const group = store.group(groupId);
610
+ return group ? { ...publicGroupState(group), ...messagePage(group.threadId, DEFAULT_PAGE) } : undefined;
611
+ },
612
+ };
613
+ }
614
+ // ── admin activity (server/admin-activity.ts) ──────────────────────────
615
+ /** Recorded only where several people share the workspace: a hosted
616
+ * workspace, an email sign-in list naming more than one person (or a whole
617
+ * domain), or a device paired with chat-only access. The desktop app and a
618
+ * one-person server keep no admin activity log, exactly as before. */
619
+ function adminActivityRecording() {
620
+ if (DESKTOP_MANAGED)
621
+ return false;
622
+ if (HOSTED_WORKSPACE)
623
+ return true;
624
+ const chatOnly = (scopes) => !scopes.includes("admin");
625
+ return sharedSignIn(signInAllowList()) || sessions.list().some((session) => chatOnly(session.scopes)) ||
626
+ sessions.openPairings().some((pairing) => chatOnly(pairing.scopes));
627
+ }
628
+ /** Who made an admin change: as the decision log names who answered, plus
629
+ * the command line (it marks its requests; on loopback that is the owner). */
630
+ function adminActorFor(auth, req) {
631
+ if (auth.kind === "loopback" && auth.trust !== "service" &&
632
+ (req.headers["x-openmausbot-cli"] === "1" || req.headers["x-openmausbot-cli-owner"] !== undefined))
633
+ return { kind: "cli" };
634
+ return decisionActorFor(auth);
635
+ }
636
+ /** The admin whose request is running, for config saves made while it runs.
637
+ * Each save records exactly what it wrote, so a slow request never takes
638
+ * the blame (or the credit) for another admin's change made meanwhile. */
639
+ const adminActorScope = new AsyncLocalStorage();
640
+ onConfigSaved((before, after) => {
641
+ const scope = adminActorScope.getStore();
642
+ if (!scope || DESKTOP_MANAGED)
643
+ return;
644
+ // Shared when the request began, or now: the change that makes a server
645
+ // shared, or stops it being, is kept.
646
+ if (!scope.sharedAtStart && !adminActivityRecording() && !sharedSignIn(signInListsOf(before)) && !sharedSignIn(signInListsOf(after)))
647
+ return;
648
+ for (const row of configChangeRows(before, after))
649
+ appendAdminAction(DATA_DIR, { ...row, actor: scope.actor });
650
+ });
651
+ function adminAuditPlan(method, path) {
652
+ if (method === "GET" || method === "HEAD" || method === "OPTIONS")
653
+ return null;
654
+ if (path.startsWith("/api/internal/") || path.startsWith("/api/testing/"))
655
+ return null;
656
+ const plan = {
657
+ config: requiredScope(method, path, { sharedComputers: sharedComputersEnabled(cfg) }) === "admin",
658
+ createsBots: method === "POST" && (path === "/api/bots" || path === "/api/teams/import"),
659
+ createsWebhook: method === "POST" && path === "/api/webhooks",
660
+ pairing: method === "POST" && path === "/api/auth/pairing",
661
+ };
662
+ let m = /^\/api\/bots\/([\w-]+)(?:\/(always-allow|model))?$/.exec(path);
663
+ if (m && ((!m[2] && method === "PATCH") || (m[2] === "model" && method === "PATCH") || (m[2] === "always-allow" && method === "POST")))
664
+ plan.botId = m[1];
665
+ if (m && !m[2] && method === "DELETE")
666
+ plan.deletesBot = m[1];
667
+ m = /^\/api\/webhooks\/([\w-]+)(\/rotate)?$/.exec(path);
668
+ if (m && ((m[2] && method === "POST") || (!m[2] && (method === "PATCH" || method === "DELETE"))))
669
+ plan.webhookId = m[1];
670
+ m = /^\/api\/auth\/sessions\/([\w-]+)$/.exec(path);
671
+ if (m && method === "DELETE")
672
+ plan.sessionId = m[1];
673
+ m = /^\/api\/groups\/([\w-]+)$/.exec(path);
674
+ if (m && method === "PATCH")
675
+ plan.roomId = m[1];
676
+ m = /^\/api\/instances\/([\w.-]+)\/(install|auth\/complete|auth\/sign-out|claude-update)$/.exec(path);
677
+ if (m && method === "POST")
678
+ plan.engineAction = { id: m[1], action: m[2].replace("/", "-") };
679
+ if (method === "POST" && path === "/api/instances/claude-accounts")
680
+ plan.engineAction = { id: "claude", action: "account-add" };
681
+ return plan.config || plan.botId || plan.createsBots || plan.deletesBot || plan.createsWebhook || plan.webhookId || plan.sessionId || plan.pairing || plan.engineAction || plan.roomId ? plan : null;
682
+ }
683
+ const WEBHOOK_AUDIT_FIELDS = ["name", "prompt", "botId", "runOn", "enabled", "eventTypes"];
684
+ const auditBot = (id) => (id ? botAuditSnapshot(store.bot(id)) : null);
685
+ const auditHook = (id) => (id ? webhooks.list().find((hook) => hook.id === id) : undefined);
686
+ const hookFields = (hook, fields = WEBHOOK_AUDIT_FIELDS) => Object.fromEntries(fields.map((field) => [field, hook[field] ?? null]));
687
+ /** The bot fields a PATCH named: only those are compared, so a concurrent
688
+ * change to another field is not put down to this request. */
689
+ function requestedBotFields(req, path) {
690
+ if (path.endsWith("/always-allow"))
691
+ return ["alwaysAllow"];
692
+ if (path.endsWith("/model"))
693
+ return ["modelSelection"];
694
+ const body = parsedBodyOf(req);
695
+ if (!body || typeof body !== "object" || Array.isArray(body))
696
+ return [];
697
+ const fields = new Set(Object.keys(body));
698
+ if (fields.has("autoApprove") || fields.has("approvalMode"))
699
+ fields.add("autoApprove").add("approvalMode");
700
+ if (fields.has("chiefOfStaff"))
701
+ fields.add("managedSections");
702
+ return [...fields];
703
+ }
704
+ /** Record this request's admin changes once it has answered successfully.
705
+ * Each row compares only what this request named or created, never a whole
706
+ * snapshot, so another admin's change made meanwhile is not attributed here. */
707
+ function beginAdminAudit(req, res, method, path, auth) {
708
+ const plan = DESKTOP_MANAGED ? null : adminAuditPlan(method, path);
709
+ const actor = plan ? adminActorFor(auth, req) : null;
710
+ // Whether several people share the workspace as the request begins: a
711
+ // change that ends sharing (the last chat-only phone signed out) is kept.
712
+ const sharedAtStart = plan ? adminActivityRecording() : false;
713
+ // Set for every request, so a kept-alive connection never inherits one.
714
+ adminActorScope.enterWith(plan?.config && actor ? { actor, sharedAtStart } : null);
715
+ if (!plan || !actor)
716
+ return;
717
+ const before = {
718
+ bot: auditBot(plan.botId ?? plan.deletesBot),
719
+ botName: store.bot(plan.botId ?? plan.deletesBot ?? "")?.name,
720
+ hook: auditHook(plan.webhookId),
721
+ session: plan.sessionId ? sessions.list().find((session) => session.id === plan.sessionId) : undefined,
722
+ roomFloor: plan.roomId ? store.group(plan.roomId)?.audienceFloor ?? "everyone" : undefined,
723
+ };
724
+ let answered;
725
+ if (plan.createsBots || plan.createsWebhook || plan.pairing)
726
+ onJsonBody(res, (body) => (answered = body));
727
+ res.once("finish", () => {
728
+ if (res.statusCode >= 400 || (!sharedAtStart && !adminActivityRecording()))
729
+ return;
730
+ try {
731
+ for (const row of adminAuditRows(plan, req, path, before, answered))
732
+ appendAdminAction(DATA_DIR, { ...row, actor });
733
+ }
734
+ catch (error) {
735
+ console.warn(`admin activity: could not record ${method} ${path}: ${error instanceof Error ? error.message : String(error)}`);
736
+ }
737
+ });
738
+ }
739
+ function adminAuditRows(plan, req, path, before, answered) {
740
+ const rows = [];
741
+ const reply = answered && typeof answered === "object" ? answered : {};
742
+ const idOf = (value) => (value && typeof value === "object" && typeof value.id === "string" ? value.id : undefined);
743
+ if (plan.botId) {
744
+ rows.push(...botChangeRows({ id: plan.botId, name: store.bot(plan.botId)?.name }, before.bot, auditBot(plan.botId), requestedBotFields(req, path)));
745
+ }
746
+ if (plan.createsBots) {
747
+ const created = [idOf(reply.bot), ...(Array.isArray(reply.bots) ? reply.bots.map(idOf) : [])].filter((id) => Boolean(id));
748
+ for (const id of new Set(created)) {
749
+ const fields = auditBot(id);
750
+ if (fields)
751
+ rows.push({ category: "bot", action: "bot.create", target: { kind: "bot", id, name: store.bot(id).name }, after: fields });
752
+ }
753
+ }
754
+ if (plan.deletesBot && before.bot && !store.bot(plan.deletesBot)) {
755
+ rows.push({ category: "bot", action: "bot.delete", target: { kind: "bot", id: plan.deletesBot, ...(before.botName ? { name: before.botName } : {}) }, before: before.bot });
756
+ }
757
+ const hookTarget = (hook) => ({ kind: "webhook", id: hook.id, name: hook.name });
758
+ if (plan.createsWebhook) {
759
+ const hook = auditHook(idOf(reply.webhook));
760
+ if (hook)
761
+ rows.push({ category: "webhook", action: "webhook.create", target: hookTarget(hook), after: hookFields(hook) });
762
+ }
763
+ if (plan.webhookId && before.hook) {
764
+ const after = auditHook(plan.webhookId);
765
+ if (!after)
766
+ rows.push({ category: "webhook", action: "webhook.delete", target: hookTarget(before.hook), before: hookFields(before.hook) });
767
+ else if (path.endsWith("/rotate"))
768
+ rows.push({ category: "webhook", action: "webhook.rotate-secret", target: hookTarget(after) });
769
+ else {
770
+ const body = parsedBodyOf(req);
771
+ const named = body && typeof body === "object" ? Object.keys(body) : [];
772
+ const changed = WEBHOOK_AUDIT_FIELDS.filter((field) => named.includes(field) &&
773
+ JSON.stringify(hookFields(before.hook, [field])) !== JSON.stringify(hookFields(after, [field])));
774
+ if (changed.length) {
775
+ rows.push({ category: "webhook", action: "webhook.update", target: hookTarget(after), changed: [...changed], before: hookFields(before.hook, changed), after: hookFields(after, changed) });
776
+ }
777
+ }
778
+ }
779
+ if (before.session && !sessions.list().some((session) => session.id === before.session.id)) {
780
+ const session = before.session;
781
+ rows.push({ category: "session", action: "session.revoke", target: { kind: "session", id: session.id, name: session.email ?? session.label },
782
+ before: { label: session.label, ...(session.email ? { email: session.email } : {}), scopes: session.scopes } });
783
+ }
784
+ if (plan.pairing) {
785
+ const pairing = sessions.openPairings().find((candidate) => candidate.id === reply.id);
786
+ if (pairing)
787
+ rows.push({ category: "session", action: "pairing.create", target: { kind: "pairing", name: pairing.label }, after: { label: pairing.label, scopes: pairing.scopes } });
788
+ }
789
+ if (plan.engineAction)
790
+ rows.push({ category: "engine", action: `engine.${plan.engineAction.action}`, target: { kind: "engine", id: plan.engineAction.id } });
791
+ const roomBody = plan.roomId ? parsedBodyOf(req) : undefined;
792
+ const room = plan.roomId ? store.group(plan.roomId) : undefined;
793
+ if (room && roomBody && typeof roomBody === "object" && roomBody.resetAudience === true) {
794
+ rows.push({ category: "visibility", action: "room.audience-reset", target: { kind: "room", id: room.id, name: room.name },
795
+ changed: ["audienceFloor"], before: { audienceFloor: before.roomFloor ?? "everyone" }, after: { audienceFloor: room.audienceFloor ?? "everyone" } });
796
+ }
797
+ return rows;
798
+ }
799
+ /** The guarded send contract external interfaces (the Slack worker) opt into.
800
+ * `onBehalfOf` is additive and advertised by /api/health
801
+ * (capabilities.guardedOnBehalfOf): it names the person a relay acts for, so
802
+ * the turn is booked to them rather than to this machine. Only this
803
+ * admin-scoped route reads it; every other send is booked to its own
804
+ * session. */
805
+ const guardedSendSchema = z.object({
806
+ threadId: z.string().regex(/^[\w-]+$/),
807
+ sendId: z.string().regex(/^[A-Za-z0-9_-]{16,80}$/),
808
+ text: z.string().min(1),
809
+ expectedActiveLeafId: z.string().regex(/^[\w-]+$/).nullable(),
810
+ expectedApprovalMode: z.enum(["ask", "full"]).optional(),
811
+ replyToId: z.string().optional(),
812
+ onBehalfOf: z.object({
813
+ email: z.string().trim().toLowerCase().max(254).pipe(z.email()).optional(),
814
+ name: z.string().trim().min(1).max(120).optional(),
815
+ }).strict().refine((who) => Boolean(who.email || who.name)).optional(),
816
+ }).strict();
817
+ /** Who a request is, for the usage ledger: a paired or signed-in person, or
818
+ * this machine. Captured when the message is accepted and carried with it. */
819
+ function usageTriggerFor(auth) {
820
+ return auth.kind === "session"
298
821
  ? { kind: "user", ...(auth.session.email ? { email: auth.session.email } : {}), label: auth.session.label }
299
- : { kind: "owner" });
822
+ : { kind: "owner" };
823
+ }
824
+ /** The operator's price list, when the server may read it (`billing`). */
825
+ function operatorPrices() {
826
+ return entitled("billing") && cfg.billing?.prices && Object.keys(cfg.billing.prices).length ? cfg.billing.prices : null;
827
+ }
828
+ /** Write one settled turn to the ledger, estimating its cost when the
829
+ * engine reported tokens but no price, and let the spend cap see it at once.
830
+ * The first booking that takes the month past the warning threshold, or to
831
+ * the cap, notifies admins (once per month each; server/spend.ts). */
832
+ function bookTurnUsage(row) {
833
+ const booked = { ...row, ...ledgerCost(row, operatorPrices()), at: new Date().toISOString() };
834
+ // The append lands asynchronously; the cap counts the row from memory
835
+ // until it does, so the check below (and the next turn start) sees it.
836
+ noteSpend(DATA_DIR, booked, appendUsage(DATA_DIR, booked));
837
+ const state = spendState(cfg, DATA_DIR);
838
+ const alert = takeSpendAlert(DATA_DIR, state);
839
+ if (alert && state) {
840
+ broadcast({ kind: "notify", notification: buildSpendNotification({ id: row.botId, name: row.botName }, row.threadId, spendAlertText(alert, state)) }, { adminOnly: true });
841
+ }
842
+ }
843
+ /** A queued line from before triggers were stored with it: its sender, else
844
+ * the owner; a bot's own queued words set nothing. */
845
+ function queuedTurnTrigger(item) {
846
+ if (item.trigger)
847
+ return item.trigger;
848
+ if (item.peerAsk)
849
+ return undefined;
850
+ return item.sender ? { kind: "user", label: item.sender.name } : { kind: "owner" };
300
851
  }
301
852
  const providerAuthSessions = new ProviderAuthSessions();
302
- await registry.load(instanceConfigs(cfg));
853
+ await registry.load(providerConfigs(), decorateHostedProvider);
303
854
  const bundledSkills = loadBundledSkills();
304
855
  const availableSkills = () => mergeSkills(bundledSkills, loadUserSkills(join(DATA_DIR, "skills")));
305
856
  // SAFETY: Electron's utility-process runtime is the only environment that
@@ -395,10 +946,21 @@ const bus = new EventBus();
395
946
  bus.attach(registry.instances());
396
947
  let companyRuntimeReady;
397
948
  const companyRuntimeStarted = new Promise(resolve => { companyRuntimeReady = resolve; });
949
+ /** The enrolled organisation's desktop policy: in memory only, read-only, and
950
+ * inert (null) unless Electron's enrolled parent sends one. */
951
+ const managedPolicy = new ManagedDesktopPolicy({ onChange: () => broadcast({ kind: "config", ...configStatus() }) });
952
+ /** Why the organisation refuses this instance for bots, or undefined. */
953
+ function policyModelRefusal(instance) {
954
+ const engine = BUILT_IN_DRIVERS.find(driver => driver.driverKind === instance.driverKind)?.metadata.displayName;
955
+ return managedPolicy.modelRefusal({ driverKind: instance.driverKind, displayName: engine }, managedDesktop.owns(instance.instanceId));
956
+ }
398
957
  const managedDesktop = new ManagedDesktopProviders({
399
958
  registry,
400
959
  dataDirectory: DATA_DIR,
401
960
  beforeReplace: stopCompanyInstances,
961
+ // Ids became stable across re-enrolment; carry old references forward once.
962
+ migrate: aliases => { store.renameInstances(new Map(aliases.map(({ from, to }) => [from, to]))); },
963
+ onAvailability: () => broadcast({ kind: "config", ...configStatus() }),
402
964
  afterReplace: ids => {
403
965
  for (const id of providerInstancesChanging)
404
966
  if (managedDesktop.owns(id))
@@ -409,6 +971,29 @@ const managedDesktop = new ManagedDesktopProviders({
409
971
  broadcast({ kind: "config", ...configStatus() });
410
972
  },
411
973
  });
974
+ utilityParentPort?.on("message", event => {
975
+ const message = event.data;
976
+ if (message?.type !== "openmausbot:managed-desktop-identity")
977
+ return;
978
+ const requestId = typeof message.requestId === "string" && message.requestId.length <= 100 ? message.requestId : undefined;
979
+ void companyRuntimeStarted.then(() => managedDesktop.migrateIdentity(message.identity)).then(() => true, () => false).then(ok => {
980
+ utilityParentPort.postMessage({ type: "openmausbot:managed-desktop-result", requestId, ok });
981
+ });
982
+ });
983
+ utilityParentPort?.on("message", event => {
984
+ const message = event.data;
985
+ if (message?.type !== "openmausbot:managed-desktop-policy")
986
+ return;
987
+ const requestId = typeof message.requestId === "string" && message.requestId.length <= 100 ? message.requestId : undefined;
988
+ let ok = true;
989
+ try {
990
+ managedPolicy.apply(message.policy);
991
+ }
992
+ catch {
993
+ ok = false;
994
+ }
995
+ utilityParentPort.postMessage({ type: "openmausbot:managed-desktop-result", requestId, ok });
996
+ });
412
997
  // Only Electron owns this port. There is deliberately no HTTP equivalent or
413
998
  // config patch for its organization identity, endpoint, or model capability.
414
999
  utilityParentPort?.on("message", event => {
@@ -431,6 +1016,46 @@ const internalCapabilities = new Map();
431
1016
  const activeInternalGenerationByThread = new Map();
432
1017
  const internalGenerationByProviderTurn = new ProviderTurnGenerationRegistry();
433
1018
  const computerSelectionTurns = new Map();
1019
+ // A bot can also run outside this server — a Telegram gateway, a Slack bot,
1020
+ // any long-lived engine process the harness did not spawn. That process never
1021
+ // receives a turn-scoped capability, so it cannot ask or delegate to its peers.
1022
+ // `<data dir>/external-runtimes.json` maps bot ids to standing bearer tokens
1023
+ // and an explicit threadId. Missing bindings fail closed, even for one task.
1024
+ // A matching bearer resolves to an agents capability bound to that thread only —
1025
+ // depth 0, no skill authoring, no room coordination, no bot/room/thread
1026
+ // creation. Every other bearer still goes through the per-turn map above. The
1027
+ // file is read on demand, so adding or rotating a token needs no restart, and
1028
+ // it must not be readable by other users (mode 600).
1029
+ const EXTERNAL_RUNTIME_GENERATION = "external-runtime";
1030
+ const EXTERNAL_RUNTIMES_FILE = join(DATA_DIR, "external-runtimes.json");
1031
+ /** The standing capability is for peer comms only. Everything that creates or
1032
+ * changes state on this server (threads, bots, rooms, skills, memory, …) needs
1033
+ * a real turn, which an external runtime never has here, so the routes are an
1034
+ * allow-list rather than flags on the capability. */
1035
+ function externalRuntimeMayCall(method, path) {
1036
+ if (method === "GET")
1037
+ return path === "/api/internal/agents" || /^\/api\/internal\/delegations\/[\w-]+$/.test(path);
1038
+ return method === "POST" && (path === "/api/internal/ask-bot" || path === "/api/internal/delegate-bot");
1039
+ }
1040
+ function externalRuntimeCapability(header) {
1041
+ const grant = authorizeExternalRuntime(EXTERNAL_RUNTIMES_FILE, header, id => store.bot(id));
1042
+ if (!grant)
1043
+ return null;
1044
+ return {
1045
+ botId: grant.botId,
1046
+ threadId: grant.threadId,
1047
+ externalRuntime: grant,
1048
+ generation: EXTERNAL_RUNTIME_GENERATION,
1049
+ depth: 0,
1050
+ kind: "agents",
1051
+ skillAuthoring: false,
1052
+ createdBots: 0,
1053
+ openedThreads: 0,
1054
+ orphanExpiresAt: Number.MAX_SAFE_INTEGER,
1055
+ roomCoordination: false,
1056
+ ownThreadCreation: false,
1057
+ };
1058
+ }
434
1059
  function beginInternalCapabilityGeneration(threadId, generation = randomUUID()) {
435
1060
  const previous = activeInternalGenerationByThread.get(threadId);
436
1061
  if (previous)
@@ -506,9 +1131,14 @@ function authorizedInternalCapability(header) {
506
1131
  if (got.length === expected.length && timingSafeEqual(got, expected))
507
1132
  return capability;
508
1133
  }
509
- return null;
1134
+ return externalRuntimeCapability(header);
510
1135
  }
511
1136
  function internalCapabilityIsActive(capability) {
1137
+ if (capability.generation === EXTERNAL_RUNTIME_GENERATION) {
1138
+ return Boolean(capability.externalRuntime && capability.externalRuntime.botId === capability.botId &&
1139
+ capability.externalRuntime.threadId === capability.threadId &&
1140
+ externalRuntimeIsActive(EXTERNAL_RUNTIMES_FILE, capability.externalRuntime, id => store.bot(id)));
1141
+ }
512
1142
  const switching = computerSelectionTurns.get(capability.threadId);
513
1143
  if ((capability.kind === "computer" || capability.kind === "browser") &&
514
1144
  switching?.generation === capability.generation && switching.selected)
@@ -608,6 +1238,9 @@ class DirectTurnSetupCancelled extends Error {
608
1238
  }
609
1239
  const directTurnDispatchClaims = new Map();
610
1240
  const directTurnGenerationByThread = new Map();
1241
+ // Only the latest direct request per thread is retained. A fresh user turn
1242
+ // replaces it; unknown control-plane continuations deliberately lose proof.
1243
+ const directRequestOwners = new Map();
611
1244
  const directFollowupTurns = new ProviderTurnGenerationRegistry();
612
1245
  const directFollowupSettlers = new Map();
613
1246
  const directCoordinationSettlers = new Map();
@@ -668,6 +1301,11 @@ function releaseTurnResources(owner) {
668
1301
  }
669
1302
  }
670
1303
  async function bindTurnComputer(owner, resource, exclusive = false) {
1304
+ // Every computer claim passes here: a kind the organisation disallows is
1305
+ // refused at claim time, whichever path selected it.
1306
+ const kind = computerKindForResource(resource), refusal = kind && managedPolicy.computerRefusal(kind);
1307
+ if (refusal)
1308
+ throw Object.assign(new Error(refusal), { code: "managed_policy" });
671
1309
  const active = () => activeInternalGenerationByThread.get(owner.threadId) === owner.generation &&
672
1310
  turnResourceOwners.get(owner.threadId)?.generation === owner.generation;
673
1311
  let waitingMessage;
@@ -729,9 +1367,23 @@ function botAtThreadCapacity(botId) {
729
1367
  // completion event has already cleared the stored busy flag.
730
1368
  return store.tasks(botId).filter((task) => threadBusy(botId, task.threadId)).length >= maxConcurrentBotThreads(cfg);
731
1369
  }
1370
+ /** A card waiting on the person still owns fresh coordinated work, even
1371
+ * when another thread slot is free. A sibling that is only working does not. */
1372
+ function recipientAwaitingPerson(botId, exceptThreadId) {
1373
+ return store.tasks(botId).some((task) => task.threadId !== exceptThreadId && task.activity === "waiting-on-you");
1374
+ }
732
1375
  function hasDirectDispatch(botId) {
733
1376
  return [...directTurnDispatchClaims.values()].some((claim) => claim.botId === botId);
734
1377
  }
1378
+ /** Exactly what startTurn admits for a direct thread: the landing thread
1379
+ * itself free, a free capacity slot, and no live group turn. Never the
1380
+ * whole-bot busy flag — that is true whenever ANY thread is working,
1381
+ * waiting on a person, or has no signal. Delegations and the resume drains
1382
+ * share this test so peer and room work waits for a real slot, not total
1383
+ * bot idleness. */
1384
+ function canAdmitDirectTurn(botId, threadId) {
1385
+ return !threadBusy(botId, threadId) && !botAtThreadCapacity(botId) && !activeGroupTurnForBot(botId);
1386
+ }
735
1387
  /** Routine and webhook dispatch shares startTurn's admission preconditions
736
1388
  * instead of waiting for whole-bot idleness: a free thread slot and no
737
1389
  * active group turn. A group turn blocks scheduled starts the same way it
@@ -754,6 +1406,12 @@ function requestedTaskBot(botId, rawThreadId) {
754
1406
  return task;
755
1407
  }
756
1408
  async function interruptDirectThread(botId, threadId) {
1409
+ const requestOwner = directRequestOwners.get(threadId);
1410
+ if (requestOwner) {
1411
+ requestOwner.stopped = true;
1412
+ if (requestOwner.messageId)
1413
+ store.patchMessage(threadId, requestOwner.messageId, { requestCancelled: true });
1414
+ }
757
1415
  // Stop belongs to the conversation it was pressed in. This bot's turn ends
758
1416
  // and this conversation stops awaiting its teammates, so nothing resumes
759
1417
  // into a stopped chat; assignments that never started are dropped. A
@@ -861,8 +1519,12 @@ function markDirectTurnDispatching(botId, claimId, threadId) {
861
1519
  return true;
862
1520
  }
863
1521
  function clearDirectTurnDispatch(threadId, claimId) {
864
- if (directTurnDispatchClaims.get(threadId)?.id === claimId)
1522
+ if (directTurnDispatchClaims.get(threadId)?.id === claimId) {
865
1523
  directTurnDispatchClaims.delete(threadId);
1524
+ // A fast provider can settle before its dispatch ACK. Its first cleanup
1525
+ // still saw this reservation, so releasing it must retry the final fence.
1526
+ settleTrackedRequest(threadId);
1527
+ }
866
1528
  }
867
1529
  const compactionControllers = new Map();
868
1530
  function cancelDirectTurnDispatch(botId, expectedThreadId) {
@@ -1112,8 +1774,15 @@ function askBotAndWait(targetBotId, message, depth, fromBotId, fromThreadId, tar
1112
1774
  });
1113
1775
  }
1114
1776
  // New bots honor setup's saved choice; unconfigured workspaces prefer Claude.
1777
+ // While enrolled, a Company model that can run beats a signed-out personal
1778
+ // engine, and the organisation's policy is respected; otherwise inert.
1115
1779
  async function defaultSelection() {
1116
- return selectDefaultModelSelection(await registry.describe(), cfg.defaultModelSelection);
1780
+ if (hostedModels)
1781
+ return hostedModels.select(cfg.defaultModelSelection);
1782
+ return selectDefaultModelSelection(await registry.describe(), cfg.defaultModelSelection, {
1783
+ company: (instanceId) => managedDesktop.owns(instanceId),
1784
+ refusal: (instance) => policyModelRefusal(instance),
1785
+ });
1117
1786
  }
1118
1787
  function checkedModelSelection(raw, current, requireAvailableModel = false) {
1119
1788
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
@@ -1130,6 +1799,8 @@ function checkedModelSelection(raw, current, requireAvailableModel = false) {
1130
1799
  instanceId: value.instanceId.trim(),
1131
1800
  model: value.model.trim(),
1132
1801
  };
1802
+ if (hostedModels && !hostedModels.allows(selection))
1803
+ return { ok: false, status: 400, error: hostedModels.error() };
1133
1804
  if (value.effort !== undefined) {
1134
1805
  if (!isEffortLevel(value.effort)) {
1135
1806
  return { ok: false, status: 400, error: `effort "${String(value.effort)}" is not recognized` };
@@ -1305,6 +1976,7 @@ let followupsReady = false;
1305
1976
  const sendSequencer = new SendSequencer();
1306
1977
  bootSelection = await defaultSelection();
1307
1978
  store.seedIfEmpty();
1979
+ hostedModels?.reconcile(store);
1308
1980
  // A committed profile cleanup means both its config deletion and bot-reference
1309
1981
  // cleanup were intended to be durable. Reconcile stale secondary references
1310
1982
  // before Electron can ACK and remove the journal: a crash between those writes
@@ -1336,9 +2008,7 @@ if (browserCleanupReferencesReconciled)
1336
2008
  * than the desktop window did. Stripped here rather than at each call site
1337
2009
  * so a new broadcast cannot forget. */
1338
2010
  let activeCoordinationForThread = (_threadId) => false;
1339
- const wireTask = (task) => activeCoordinationForThread(task.threadId) && !task.busy
1340
- ? { ...toWireTask(task), busy: true, activity: "working" }
1341
- : toWireTask(task);
2011
+ const wireTask = (task) => ({ ...toWireTask(task), waitingForTeammates: activeCoordinationForThread(task.threadId) && !task.busy });
1342
2012
  const wireBot = (bot) => {
1343
2013
  const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, ...rest } = bot;
1344
2014
  // An elevated selection is inert until the desktop confirms its exact
@@ -1347,7 +2017,7 @@ const wireBot = (bot) => {
1347
2017
  const visible = approvalGrant && !approvalGrant.threadOnly
1348
2018
  ? { ...rest, approvalMode: "ask", autoApprove: false }
1349
2019
  : rest;
1350
- return { ...visible, ...(activeCoordinationForThread(bot.threadId) && !visible.busy ? { busy: true, activity: "working" } : {}),
2020
+ return { ...visible, waitingForTeammates: activeCoordinationForThread(bot.threadId) && !threadBusy(bot.id, bot.threadId),
1351
2021
  avatarUrl: visible.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
1352
2022
  };
1353
2023
  /** The correlated private response carries the requested value so Electron
@@ -1417,7 +2087,7 @@ function previewSystemPrompt(bot) {
1417
2087
  browser: previewPlan.computer === undefined ? false : previewPlan.browser,
1418
2088
  }, { note: previewPlan.note }) },
1419
2089
  { id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? COMPOSIO_PROMPT : "" },
1420
- { id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(customMcpServers(cfg, bot.mcpServers))) : "" },
2090
+ { id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(engineMcpServers(bot))) : "" },
1421
2091
  { id: "browser", label: "Browser", text: previewPlan.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
1422
2092
  { id: "coordination", label: "Team", text: agentsMounted && coordination ? ` ${coordination}` : "" },
1423
2093
  { id: "credential", label: "Credentials", text: agentsMounted ? CREDENTIAL_PROMPT : "" },
@@ -1583,8 +2253,10 @@ function handleDesktopTrustedApprovalMessage(raw) {
1583
2253
  const grantTarget = (bot) => bot.approvalGrant?.threadOnly
1584
2254
  ? store.projectBotForTask(bot.id, bot.approvalGrant.threadId) : bot;
1585
2255
  const grantBusy = (bot) => bot.approvalGrant?.threadOnly
1586
- ? threadBusy(bot.id, bot.approvalGrant.threadId) : bot.busy;
1587
- const grantSupported = (bot, mode) => supportsApprovalMode(registry.cliTarget(grantTarget(bot)?.modelSelection.instanceId ?? "")?.driverKind, mode);
2256
+ ? threadBusy(bot.id, bot.approvalGrant.threadId) : bot.busy ||
2257
+ Boolean(bot.approvalGrant?.allThreads && store.tasks(bot.id).some(task => threadBusy(bot.id, task.threadId)));
2258
+ const grantSupported = (bot, mode) => supportsApprovalMode(registry.cliTarget(grantTarget(bot)?.modelSelection.instanceId ?? "")?.driverKind, mode) &&
2259
+ (!bot.approvalGrant?.allThreads || store.tasks(bot.id).every(task => supportsApprovalMode(registry.cliTarget((task.modelSelection ?? bot.modelSelection).instanceId)?.driverKind, mode)));
1588
2260
  const clearGrant = (bot) => store.patchBot(bot.id, {
1589
2261
  ...(!bot.approvalGrant?.threadOnly ? { approvalMode: "ask", autoApprove: false } : {}),
1590
2262
  approvalGrant: undefined,
@@ -1617,10 +2289,14 @@ function handleDesktopTrustedApprovalMessage(raw) {
1617
2289
  !grantBusy(bot) &&
1618
2290
  threadCanReceiveGrant(bot) &&
1619
2291
  grantSupported(bot, mode)) {
1620
- if (bot.approvalGrant.threadId) {
2292
+ if (bot.approvalGrant.allThreads && mode === "full") {
2293
+ store.setAllThreadApprovalMode(botId, mode);
2294
+ }
2295
+ else if (bot.approvalGrant.threadId) {
1621
2296
  store.patchTask(botId, bot.approvalGrant.threadId, { approvalMode: mode, autoApprove: false });
1622
2297
  }
1623
- store.patchBot(botId, { approvalGrant: undefined });
2298
+ if (bot.approvalGrant)
2299
+ store.patchBot(botId, { approvalGrant: undefined });
1624
2300
  postDesktopPrivateMessage({ type: "approval-trusted-mode-commit-result", requestId, ok: true, bot: wireBot(store.bot(botId)) });
1625
2301
  }
1626
2302
  else if (bot?.approvalGrant?.requestId === requestId) {
@@ -1801,6 +2477,22 @@ function handleDesktopTrustedApprovalMessage(raw) {
1801
2477
  }
1802
2478
  const currentMode = approvalModeFor(existing);
1803
2479
  const threadId = message.threadId;
2480
+ if (message.allThreads !== undefined && (typeof message.allThreads !== "boolean" ||
2481
+ (message.allThreads && (threadId !== undefined || message.threadOnly === true ||
2482
+ message.modelSelection !== undefined || message.updateBotDefault !== undefined ||
2483
+ (mode !== "full" && mode !== "ask"))))) {
2484
+ respond({ ok: false, error: "Invalid all-threads approval scope" });
2485
+ return true;
2486
+ }
2487
+ if (message.allThreads === true && mode === "ask") {
2488
+ const updated = store.setAllThreadApprovalMode(botId, "ask");
2489
+ void stopBotForEmergencyApprovalDowngrade(botId).then(() => respond({ ok: true, bot: wireBot(store.bot(botId) ?? updated) }), () => respond({ ok: false, error: "Approval was reset to Ask, but an active turn could not be stopped" }));
2490
+ return true;
2491
+ }
2492
+ if (message.allThreads === true && (existing.approvalGrant || store.tasks(botId).some(task => threadBusy(botId, task.threadId) || !supportsApprovalMode(registry.cliTarget((task.modelSelection ?? existing.modelSelection).instanceId)?.driverKind, mode)))) {
2493
+ respond({ ok: false, error: "Finish this bot's active turns and approval changes first. Every thread's provider must support Full access." });
2494
+ return true;
2495
+ }
1804
2496
  if (message.threadOnly !== undefined && typeof message.threadOnly !== "boolean") {
1805
2497
  respond({ ok: false, error: "Invalid thread approval scope" });
1806
2498
  return true;
@@ -1848,7 +2540,7 @@ function handleDesktopTrustedApprovalMessage(raw) {
1848
2540
  return true;
1849
2541
  }
1850
2542
  try {
1851
- store.switchTaskModel(botId, threadId, checked.selection, message.updateBotDefault, true);
2543
+ store.switchTaskModel(botId, threadId, checked.selection, message.updateBotDefault, true, hostedModels?.resetTask(target.modelSelection, checked.selection));
1852
2544
  const fresh = { ...wireBot(store.bot(botId)), approvalMode: approvalModeFor(store.bot(botId)) };
1853
2545
  broadcast({ kind: "bot", bot: fresh });
1854
2546
  respond({ ok: true, bot: fresh });
@@ -1897,7 +2589,7 @@ function handleDesktopTrustedApprovalMessage(raw) {
1897
2589
  approvalMode: mode,
1898
2590
  autoApprove: mode === "auto",
1899
2591
  approvalGrant: mode === "full" || mode === "custom"
1900
- ? { requestId, mode, phase: "prepared", ...(typeof threadId === "string" ? { threadId } : {}) }
2592
+ ? { requestId, mode, phase: "prepared", ...(typeof threadId === "string" ? { threadId } : {}), ...(message.allThreads === true ? { allThreads: true } : {}) }
1901
2593
  : undefined,
1902
2594
  });
1903
2595
  if (!updated) {
@@ -2013,6 +2705,9 @@ function createChannel(value) {
2013
2705
  if (!roster.ok)
2014
2706
  throw Object.assign(new Error(roster.error), { status: 400 });
2015
2707
  const { memberIds } = roster;
2708
+ const audienceConflict = roomAudienceConflict(memberIds);
2709
+ if (audienceConflict)
2710
+ throw Object.assign(new Error(audienceConflict), { status: 400 });
2016
2711
  if (body.name !== undefined && typeof body.name !== "string") {
2017
2712
  throw Object.assign(new Error("channel name must be a string"), { status: 400 });
2018
2713
  }
@@ -2093,6 +2788,9 @@ function updateChannel(groupId, value) {
2093
2788
  const roster = checkedMemberIds(body.memberIds);
2094
2789
  if (!roster.ok)
2095
2790
  throw Object.assign(new Error(roster.error.replace("channel", "room")), { status: 400 });
2791
+ const audienceConflict = roomAudienceConflict(roster.memberIds, existing.memberIds);
2792
+ if (audienceConflict)
2793
+ throw Object.assign(new Error(audienceConflict), { status: 400 });
2096
2794
  const removedGoalLead = routines.listRoutines().some((routine) => routine.enabled &&
2097
2795
  routine.target === "room-goal" &&
2098
2796
  routine.groupId === existing.id &&
@@ -2151,7 +2849,18 @@ function updateChannel(groupId, value) {
2151
2849
  patch.section = trimmed;
2152
2850
  }
2153
2851
  }
2852
+ // An admin's explicit "show this room to everyone its current bots allow":
2853
+ // the only way a room's floor widens (settleRoomFloor). Admin-only: a
2854
+ // member's PATCH is limited to display fields, and a Chief's never has it.
2855
+ const resetAudience = body.resetAudience !== undefined;
2856
+ if (resetAudience) {
2857
+ if (body.resetAudience !== true)
2858
+ throw Object.assign(new Error("resetAudience must be true"), { status: 400 });
2859
+ patch.audienceFloor = undefined;
2860
+ }
2154
2861
  const group = store.patchGroup(groupId, patch);
2862
+ if (resetAudience)
2863
+ audienceChanged();
2155
2864
  if (!group)
2156
2865
  throw Object.assign(new Error("no such room"), { status: 404 });
2157
2866
  return group;
@@ -2195,7 +2904,7 @@ function roomHandoffProblem(node, parent) {
2195
2904
  return "Room work cannot cross the sender's section boundary";
2196
2905
  if (source && group && source.id === group.id && parent.threadId !== node.threadId)
2197
2906
  return "Same-room work must stay in the originating conversation";
2198
- if (!peerAllowed(from, bot.id))
2907
+ if (!peerAllowed(from, bot))
2199
2908
  return "The recipient is not an allowed peer of the sender";
2200
2909
  }
2201
2910
  }
@@ -2233,12 +2942,17 @@ function outstandingAssignmentsPrompt(threadId) {
2233
2942
  const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
2234
2943
  validate: (node, parent) => roomHandoffProblem(node, parent) ??
2235
2944
  (parent && store.bot(parent.botId)?.approvePeerComms && !fullAccessForSource(parent.botId, parent.threadId) && !node.approvalGranted ? "Sender now requires peer approval; submit a new approved request" : undefined),
2236
- // Direct assignments and follow-ups use independent threads. Match direct
2237
- // turn admission: unrelated work need not block a free thread slot, but
2238
- // never overlap the addressed thread, exceed capacity, or race a group turn.
2239
- busy: n => !n.groupId
2240
- ? threadBusy(n.botId, n.threadId) || botAtThreadCapacity(n.botId) || Boolean(activeGroupTurnForBot(n.botId))
2241
- : Boolean(store.bot(n.botId)?.busy || (n.groupId && store.group(n.groupId) && groupIsWorking(store.group(n.groupId)))),
2945
+ // A free slot admits fresh work beside a sibling that is actually running
2946
+ // (#1589). A card waiting on the person still holds fresh work (#1128).
2947
+ // An owed resume is not fresh work, so a sibling card must not starve it
2948
+ // (#1278). Never overlap the addressed thread, exceed capacity, or race a
2949
+ // group turn.
2950
+ busy: n => {
2951
+ if (n.groupId)
2952
+ return Boolean(store.bot(n.botId)?.busy || (store.group(n.groupId) && groupIsWorking(store.group(n.groupId))));
2953
+ const slot = threadBusy(n.botId, n.threadId) || botAtThreadCapacity(n.botId) || Boolean(activeGroupTurnForBot(n.botId));
2954
+ return n.status === "resume" ? slot : slot || recipientAwaitingPerson(n.botId, n.threadId);
2955
+ },
2242
2956
  changed: (groupIds, directThreadIds) => {
2243
2957
  for (const id of groupIds) {
2244
2958
  const group = store.group(id);
@@ -2246,6 +2960,7 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
2246
2960
  broadcast({ kind: "group", group: publicGroupState(group) });
2247
2961
  }
2248
2962
  for (const threadId of directThreadIds) {
2963
+ settleTrackedRequest(threadId);
2249
2964
  const bot = store.botByThread(threadId);
2250
2965
  if (bot)
2251
2966
  broadcast({ kind: "bot", bot: wireBot(bot) });
@@ -2320,6 +3035,21 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
2320
3035
  });
2321
3036
  }
2322
3037
  markInternalTurn(node.threadId);
3038
+ // The settle itself is quiet by design: both the delegated turn and
3039
+ // this resume are internal, so turn.completed raises neither the unread
3040
+ // flag nor a done frame. One chip plus one notification per settle
3041
+ // covers the gap; steers land inside the running turn and never add
3042
+ // more. Group settles already speak through the room's own flow.
3043
+ if (resumed && !group) {
3044
+ const settledFrom = [...new Set(roomHandoffs.children(node.id)
3045
+ .map(child => store.bot(child.botId)?.name ?? "Teammate"))];
3046
+ const who = settledFrom.length ? settledFrom.join(", ") : "delegated work";
3047
+ store.appendMessage(node.threadId, {
3048
+ role: "bot", kind: "activity",
3049
+ tool: { name: `Resumed with ${who} results, reviewing`, ok: true },
3050
+ });
3051
+ notify(buildNotification("delegation-settled", bot, node.threadId, `Results in from ${who}`, { avatarUrl: bot.avatarUrl }));
3052
+ }
2323
3053
  if (sender && parent && isUnattended(sender.id, parent.threadId))
2324
3054
  markUnattended(bot.id, node.threadId);
2325
3055
  if (!group)
@@ -2343,6 +3073,8 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
2343
3073
  }
2344
3074
  void startTurn(bot.id, turnText, {
2345
3075
  threadId: node.threadId, cardContinuation: true, commsDepth: MAX_COMMS_DEPTH,
3076
+ requestMessageId: directRequestOwners.get(node.threadId)?.generations.has(node.rootId)
3077
+ ? directRequestOwners.get(node.threadId)?.messageId : undefined,
2346
3078
  unattended: isUnattended(bot.id, node.threadId),
2347
3079
  coordination: { id: node.id, resumed, settle: finish },
2348
3080
  onDispatchError: error => finish({ ok: false, text: error }),
@@ -2677,6 +3409,9 @@ const groupWithThread = (group) => ({
2677
3409
  // endpoints whose callers need the transcript (task create/switch, imports)
2678
3410
  // still send their richer payload on top.
2679
3411
  store.onChange((change) => {
3412
+ // Who owns which thread, and what each member may see, follow the fleet.
3413
+ if (change.type !== "message" && change.type !== "message.patch" && change.type !== "thread" && change.type !== "sections")
3414
+ forgetVisibility();
2680
3415
  switch (change.type) {
2681
3416
  case "sections":
2682
3417
  broadcast({ kind: "sections", sections: store.sections });
@@ -2691,6 +3426,7 @@ store.onChange((change) => {
2691
3426
  broadcast({ kind: "thread", threadId: change.threadId, activeLeafId: change.activeLeafId });
2692
3427
  break;
2693
3428
  case "thread.deleted":
3429
+ directRequestOwners.delete(change.threadId);
2694
3430
  routines?.forgetRoutineRequestReceiptsForThread(change.threadId);
2695
3431
  // A deleted destination must not strand an approval in an internal
2696
3432
  // task. Keep each run's snapshot and expose its execution as fallback.
@@ -2705,6 +3441,10 @@ store.onChange((change) => {
2705
3441
  const bot = store.bot(change.botId);
2706
3442
  if (bot)
2707
3443
  broadcast({ kind: "bot", bot: wireBot(bot) });
3444
+ // A new audience narrows the rooms this bot is in, for good.
3445
+ if (bot)
3446
+ for (const group of store.groups.filter((candidate) => candidate.memberIds.includes(bot.id)))
3447
+ settleRoomFloor(group);
2708
3448
  break;
2709
3449
  }
2710
3450
  case "bot.deleted":
@@ -2714,6 +3454,8 @@ store.onChange((change) => {
2714
3454
  const group = store.group(change.groupId);
2715
3455
  if (group)
2716
3456
  broadcast({ kind: "group", group: publicGroupState(group) });
3457
+ if (group)
3458
+ settleRoomFloor(group);
2717
3459
  break;
2718
3460
  }
2719
3461
  case "group.deleted":
@@ -2773,6 +3515,62 @@ function messagePage(threadId, limit, before) {
2773
3515
  activeLeafId: store.activeLeaf(threadId),
2774
3516
  };
2775
3517
  }
3518
+ function guardedRequestSnapshot(botId, threadId, sendId) {
3519
+ if (!store.taskByThread(botId, threadId))
3520
+ throw requestConflict("No such conversation for this bot");
3521
+ const activeLeafId = store.activeLeaf(threadId);
3522
+ const messages = guardedRequestPath(store.messagesFor(threadId), activeLeafId, sendId);
3523
+ const messageId = messages[0].id;
3524
+ const owner = directRequestOwners.get(threadId);
3525
+ const busy = threadBusy(botId, threadId);
3526
+ const waiting = messages.some(requestNeedsInput) || roomHandoffs.activeDirect(threadId) ||
3527
+ [...pendingTeamSetupResumes.values()].some(entry => entry.request.threadId === threadId);
3528
+ const latestTerminal = messages.findLast(message => message.role === "bot" && message.kind === "text" && message.turnTerminal);
3529
+ const latestTurn = messages.findLast(message => message.turnId);
3530
+ const latestOutcome = messages.findLast(message => message.turnSucceeded !== undefined);
3531
+ // A restart retains proven transcript annotations, not an execution lease.
3532
+ // Unproven output or a different live owner always requires the workspace.
3533
+ const untracked = messages[0].requestCancelled || (owner && (owner.messageId !== messageId || owner.stopped)) ||
3534
+ (busy && !owner) || messages.slice(1).some(message => (message.turnId || (message.role === "bot" && message.kind === "text")) && message.requestMessageId !== messageId) ||
3535
+ (!busy && !waiting && (messages[0].requestPending !== false || !latestTerminal?.turnSucceeded || latestTurn?.turnId !== latestTerminal.turnId ||
3536
+ latestOutcome?.turnSucceeded !== true || (owner && owner.turnId !== latestTerminal.turnId)));
3537
+ const snapshot = {
3538
+ messageId, activeLeafId,
3539
+ phase: untracked ? "untracked" : waiting ? "waiting" : busy ? "working" : "settled",
3540
+ activeTurnId: busy ? owner?.turnId ?? null : null,
3541
+ executionId: owner?.generation ?? null,
3542
+ messages: messages.map(slimMessage),
3543
+ };
3544
+ if (Buffer.byteLength(JSON.stringify(snapshot), "utf8") > 1024 * 1024) {
3545
+ throw Object.assign(new Error("Open this request in the workspace; its transcript exceeds the response limit"), { status: 413 });
3546
+ }
3547
+ return snapshot;
3548
+ }
3549
+ /** A durable completion fence, not a replay queue. If the process dies while
3550
+ * a Chief is awaiting results, its earlier handoff must not become a final. */
3551
+ function settleTrackedRequest(threadId) {
3552
+ const owner = directRequestOwners.get(threadId);
3553
+ const bot = store.botByThread(threadId);
3554
+ if (!owner?.messageId || owner.stopped || !bot || threadBusy(bot.id, threadId) || roomHandoffs.activeDirect(threadId) ||
3555
+ [...pendingTeamSetupResumes.values()].some(entry => entry.request.threadId === threadId))
3556
+ return;
3557
+ // A failed coordination root never supplies the promised return, even if
3558
+ // its original provider successfully said it had assigned the work.
3559
+ if ([...roomHandoffs.nodes.values()].some(node => !node.parentId && node.threadId === threadId &&
3560
+ owner.generations.has(node.id) && node.status !== "completed"))
3561
+ return;
3562
+ const path = store.activePath(threadId);
3563
+ const source = path.findLast(message => message.role === "user");
3564
+ if (source?.id !== owner.messageId || source.requestCancelled || source.requestPending !== true)
3565
+ return;
3566
+ const messages = path.slice(path.indexOf(source) + 1);
3567
+ const terminal = messages.findLast(message => message.turnTerminal && message.role === "bot" && message.kind === "text");
3568
+ if (messages.some(requestNeedsInput) || terminal?.requestMessageId !== source.id || !terminal.turnSucceeded ||
3569
+ terminal.turnId !== owner.turnId || messages.findLast(message => message.turnId)?.turnId !== terminal.turnId ||
3570
+ messages.findLast(message => message.turnSucceeded !== undefined)?.turnSucceeded !== true)
3571
+ return;
3572
+ store.patchMessage(threadId, source.id, { requestPending: false });
3573
+ }
2776
3574
  /** A bounded page centred on a known message, used when a search result is
2777
3575
  * opened on a client that only hydrated the newest part of the transcript. */
2778
3576
  function messageWindow(threadId, messageId, limit) {
@@ -2786,6 +3584,26 @@ function messageWindow(threadId, messageId, limit) {
2786
3584
  return { messages: all.slice(start, stop).map(slimMessage), hasMore: start > 0 };
2787
3585
  }
2788
3586
  const sseClients = new Set();
3587
+ /** The frame at which a bot's audience last changed (see the SSE route). */
3588
+ let audienceChangedAt = 0;
3589
+ /** A bot's audience changed: end every member stream. Each reconnects at
3590
+ * once with its cursor, which now predates the change, so it is answered
3591
+ * with a fresh snapshot of exactly what that person may see. Admin and
3592
+ * owner streams are untouched. */
3593
+ function audienceChanged() {
3594
+ audienceChangedAt = lastSeq;
3595
+ for (const client of sseClients) {
3596
+ if (client.viewer.kind !== "member")
3597
+ continue;
3598
+ sseClients.delete(client);
3599
+ try {
3600
+ client.res.end();
3601
+ }
3602
+ catch {
3603
+ /* already gone */
3604
+ }
3605
+ }
3606
+ }
2789
3607
  function closeSessionStreams(sessionId) {
2790
3608
  browserLive.closeForOwner(sessionId);
2791
3609
  for (const client of sseClients) {
@@ -2836,7 +3654,9 @@ function cursorSeq(raw) {
2836
3654
  const parsed = Number(seq);
2837
3655
  return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
2838
3656
  }
2839
- function broadcast(payload) {
3657
+ /** `adminOnly` frames (a workspace spend notice) reach admin streams and
3658
+ * are withheld from client sessions, live and on replay. */
3659
+ function broadcast(payload, options = {}) {
2840
3660
  // Membership may also change through fleet/CLI config writes. Close stale
2841
3661
  // email streams before any further workspace data is delivered.
2842
3662
  sessions.revalidateEmailSessions();
@@ -2845,25 +3665,50 @@ function broadcast(payload) {
2845
3665
  const frame = `id: ${STREAM_ID}:${seq}\ndata: ${JSON.stringify({ ...payload, seq })}\n\n`;
2846
3666
  // Store both projections as immutable frames: live and reconnecting clients
2847
3667
  // must receive the same filtered config without changing the admin event.
2848
- const clientFrame = kind === "config"
2849
- ? `id: ${STREAM_ID}:${seq}\ndata: ${JSON.stringify({ ...configForAccess(payload, false), seq })}\n\n`
2850
- : frame;
3668
+ const clientFrame = options.adminOnly
3669
+ ? null
3670
+ : kind === "config"
3671
+ ? `id: ${STREAM_ID}:${seq}\ndata: ${JSON.stringify({ ...configForAccess(payload, false), seq })}\n\n`
3672
+ : frame;
2851
3673
  // Live desktop captures can each be hundreds of kilobytes and become stale
2852
3674
  // as soon as the next one arrives. Keep their sequence slots so resume-gap
2853
3675
  // detection stays honest, but never retain their base64 payloads.
2854
- replayBuffer.push({ seq, kind, frame: kind === "screen" ? null : frame, clientFrame: kind === "screen" ? null : clientFrame });
3676
+ replayBuffer.push({ seq, kind, frame: kind === "screen" ? null : frame, clientFrame: kind === "screen" ? null : clientFrame, payload: kind === "screen" ? null : payload });
2855
3677
  if (replayBuffer.length > REPLAY_MAX)
2856
3678
  replayBuffer.shift();
2857
3679
  for (const client of Array.from(sseClients)) {
2858
3680
  if (!wants(client, kind))
2859
3681
  continue;
3682
+ // Admin-only frames (clientFrame null) never reach members, and a member never falls back to the admin frame.
3683
+ const out = client.admin ? frame : clientFrame === null ? null : memberFrame(client, seq, payload, clientFrame);
3684
+ if (out === null)
3685
+ continue;
2860
3686
  // Screen frames are replaceable and durable events are not: see
2861
3687
  // ./sse-fanout.ts for the backpressure/bound decision this makes.
2862
- if (deliverSseFrame(client, kind, client.admin ? frame : clientFrame) === "disconnected") {
3688
+ if (deliverSseFrame(client, kind, out) === "disconnected") {
2863
3689
  sseClients.delete(client);
2864
3690
  }
2865
3691
  }
2866
3692
  }
3693
+ /** The frame a non-admin stream gets: the shared client frame, unless a bot
3694
+ * is restricted and this member may not see all of what the frame carries —
3695
+ * then narrowed (bot-visibility.ts), withdrawn, or nothing at all (null). */
3696
+ function memberFrame(client, seq, payload, clientFrame) {
3697
+ // A frame kept from clients altogether stays kept: never the admin copy.
3698
+ if (clientFrame === null)
3699
+ return null;
3700
+ if (client.viewer.kind === "all")
3701
+ return clientFrame;
3702
+ const visible = visibleTo(client.viewer);
3703
+ if (visible.everything) {
3704
+ noteSeen(payload, client.seen);
3705
+ return clientFrame;
3706
+ }
3707
+ const narrowed = frameForMember(payload, memberFrameContext(visible), client.seen);
3708
+ if (!narrowed)
3709
+ return null;
3710
+ return narrowed === payload ? clientFrame : `id: ${STREAM_ID}:${seq}\ndata: ${JSON.stringify({ ...narrowed, seq })}\n\n`;
3711
+ }
2867
3712
  onSteeredQueueChange(() => broadcast({ kind: "bot.queued", queues: publicBotQueuedMessages() }));
2868
3713
  // ── server-side event folding (upstream's ingestion worker, miniature) ──
2869
3714
  // The canonical stream is the source of truth; the persisted transcript
@@ -3186,9 +4031,14 @@ bus.subscribe((event) => {
3186
4031
  const line = turnOutcomeLine({ ok: event.ok, reply: reply?.text, stopReason: event.stopReason, tools });
3187
4032
  if (!line)
3188
4033
  return;
4034
+ // A room fewer people can see than this bot leaves no line in its log:
4035
+ // session_search would hand it back in a chat anyone who sees the bot has.
4036
+ const room = store.groupByThread(event.threadId);
4037
+ if (room && !roomFeedsBot(room, bot))
4038
+ return;
3189
4039
  appendMemoryLog(bot.id, line, {
3190
4040
  source: memorySourceLabel({
3191
- room: store.groupByThread(event.threadId),
4041
+ room,
3192
4042
  task: store.taskByThread(bot.id, event.threadId),
3193
4043
  threadId: event.threadId,
3194
4044
  }),
@@ -3311,6 +4161,8 @@ async function scheduleTurnDigest(input) {
3311
4161
  text: renderDigest(digest),
3312
4162
  digest,
3313
4163
  turnId: input.turnId,
4164
+ requestMessageId: activities.find(message => message.turnId === input.turnId && message.requestMessageId)?.requestMessageId,
4165
+ turnSucceeded: input.succeeded,
3314
4166
  ...(input.from ? { from: input.from } : {}),
3315
4167
  }, { kind: "digest.append", key: `${input.threadId}:${input.turnId}` });
3316
4168
  // This is derived from the native session's own work, not an unseen
@@ -3854,6 +4706,11 @@ async function selectableComputers(bot) {
3854
4706
  catch (error) {
3855
4707
  reason = error instanceof Error ? error.message : String(error);
3856
4708
  }
4709
+ // Not offered at all when the organisation disallows it.
4710
+ const kind = surface === "local" ? "thisComputer" : surface === "vm" ? "localVm" : surface === "cloud" ? (bot.cloudBackend === "vps" ? "vps" : "box") : undefined;
4711
+ const managed = kind && managedPolicy.computerRefusal(kind);
4712
+ if (managed)
4713
+ return { surface, label: surfaceLabel(surface), available: false, ready: false, canStart: false, canCreate: false, reason: managed };
3857
4714
  const available = ready || canStart || canCreate;
3858
4715
  return { surface, label: surfaceLabel(surface), available, ready, canStart, canCreate, ...(!available ? { reason } : {}) };
3859
4716
  }));
@@ -4118,7 +4975,11 @@ bus.subscribe((event) => {
4118
4975
  return;
4119
4976
  const speaker = group ? groupSpeakers.get(event.threadId) : undefined;
4120
4977
  const pushMessage = (m) => {
4121
- const message = store.appendMessage(event.threadId, group && m.role === "bot" ? { ...m, from: speaker } : m);
4978
+ const owner = bot ? directRequestOwners.get(event.threadId) : undefined;
4979
+ const proven = owner?.messageId && !owner.stopped && owner.generation === directTurnGenerationByThread.get(event.threadId) &&
4980
+ owner.turnId && (!event.turnId || event.turnId === owner.turnId);
4981
+ const message = store.appendMessage(event.threadId, group && m.role === "bot" ? { ...m, from: speaker }
4982
+ : proven ? { ...m, requestMessageId: owner.messageId } : m);
4122
4983
  return message;
4123
4984
  };
4124
4985
  if (coordinatorVisibleText) {
@@ -4129,6 +4990,10 @@ bus.subscribe((event) => {
4129
4990
  handoffs.onEvent(event);
4130
4991
  if (event.turnId)
4131
4992
  liveTurnByThread.set(event.threadId, event.turnId);
4993
+ const requestOwner = bot ? directRequestOwners.get(event.threadId) : undefined;
4994
+ if (requestOwner && event.turnId && !requestOwner.turnId && event.type === "turn.started" &&
4995
+ directTurnDispatchClaims.get(event.threadId)?.id === requestOwner.generation)
4996
+ requestOwner.turnId = event.turnId;
4132
4997
  const liveTurnId = event.turnId ?? liveTurnByThread.get(event.threadId);
4133
4998
  switch (event.type) {
4134
4999
  case "turn.started":
@@ -4455,8 +5320,11 @@ bus.subscribe((event) => {
4455
5320
  });
4456
5321
  }
4457
5322
  }
4458
- if (completedTurnId)
4459
- store.markTerminalAssistantMessage(event.threadId, completedTurnId);
5323
+ if (completedTurnId) {
5324
+ const terminal = store.markTerminalAssistantMessage(event.threadId, completedTurnId);
5325
+ if (terminal)
5326
+ store.patchMessage(event.threadId, terminal.id, { turnSucceeded: event.ok });
5327
+ }
4460
5328
  const reply = lastReply.get(event.threadId) ?? "";
4461
5329
  lastReply.delete(event.threadId);
4462
5330
  // A run that broke — not one the person stopped, and not a routine's,
@@ -4506,6 +5374,7 @@ bus.subscribe((event) => {
4506
5374
  drainTeamSetupResumes();
4507
5375
  drainDelegationWakes();
4508
5376
  }
5377
+ settleTrackedRequest(event.threadId);
4509
5378
  };
4510
5379
  // bank what this turn spent before the bot broadcast carries the
4511
5380
  // task list to every window. The driver's own per-turn figure
@@ -4531,7 +5400,7 @@ bus.subscribe((event) => {
4531
5400
  const measuredSelection = directTurnBots.get(event.threadId)?.modelSelection ?? selection;
4532
5401
  store.patchTask(bot.id, event.threadId, { lastContextModel: Number.isFinite(lastContext?.tokens) && (lastContext?.tokens ?? 0) > 0
4533
5402
  ? `${measuredSelection.instanceId}:${measuredSelection.model}` : undefined });
4534
- appendUsage(DATA_DIR, {
5403
+ bookTurnUsage({
4535
5404
  botId: bot.id,
4536
5405
  botName: bot.name,
4537
5406
  threadId: event.threadId,
@@ -4548,12 +5417,12 @@ bus.subscribe((event) => {
4548
5417
  ? { kind: "bot", ...(settledTask?.openedBy?.botId ? { botId: settledTask.openedBy.botId } : {}) }
4549
5418
  : turnTriggers.get(event.threadId) ?? { kind: "owner" },
4550
5419
  });
4551
- noteSpend(DATA_DIR, event.cost ?? null);
4552
5420
  const digestSettled = completedTurnId ? scheduleTurnDigest({
4553
5421
  botId: bot.id,
4554
5422
  botName: bot.name,
4555
5423
  threadId: event.threadId,
4556
5424
  turnId: completedTurnId,
5425
+ succeeded: event.ok,
4557
5426
  driverKind: registry.get(selection.instanceId)?.driverKind,
4558
5427
  instanceId: selection.instanceId,
4559
5428
  reply,
@@ -4616,7 +5485,7 @@ bus.subscribe((event) => {
4616
5485
  const tokens = event.usage ?? lastReported;
4617
5486
  const speakingBot = store.bot(speaker.botId);
4618
5487
  const selection = speakingBot?.modelSelection;
4619
- appendUsage(DATA_DIR, {
5488
+ bookTurnUsage({
4620
5489
  botId: speaker.botId,
4621
5490
  botName: speaker.name,
4622
5491
  threadId: event.threadId,
@@ -4631,7 +5500,6 @@ bus.subscribe((event) => {
4631
5500
  ? { kind: "routine", routineId: routineRun.routineId, label: routineRun.routineName }
4632
5501
  : turnTriggers.get(event.threadId) ?? { kind: "owner" },
4633
5502
  });
4634
- noteSpend(DATA_DIR, event.cost ?? null);
4635
5503
  if (completedTurnId) {
4636
5504
  void scheduleTurnDigest({
4637
5505
  botId: speaker.botId, botName: speaker.name,
@@ -5252,15 +6120,13 @@ function retryDelegationsWaitingOn(botId) {
5252
6120
  // Explicit idle releases (room/setup/reload/watchdog fallbacks) may not
5253
6121
  // publish turn.completed. They free a waiting source continuation too.
5254
6122
  drainDelegationWakes();
5255
- // A bot still busy in one thread has nevertheless freed a slot: the
5256
- // fresh-thread handoffs waiting on it can move, while the active-thread
5257
- // ones keep waiting for it to go idle, as they always have.
5258
- const stillBusy = store.bot(botId)?.busy === true;
5259
- if (stillBusy && botAtThreadCapacity(botId))
6123
+ // A bot still busy in one thread has nevertheless freed a slot: every
6124
+ // handoff waiting on it re-tests its own admission — a fresh-thread
6125
+ // handoff asks for any free slot, a classic one the standing thread's
6126
+ // startTurn admission — so neither waits for whole-bot idleness.
6127
+ if (store.bot(botId)?.busy === true && botAtThreadCapacity(botId))
5260
6128
  return;
5261
- const released = stillBusy
5262
- ? releaseDelegationsWaitingOn(botId, (item) => item.targetThreadId !== undefined)
5263
- : releaseDelegationsWaitingOn(botId);
6129
+ const released = releaseDelegationsWaitingOn(botId);
5264
6130
  for (const waitingThread of released) {
5265
6131
  drainThreadDelegations(waitingThread);
5266
6132
  }
@@ -5308,7 +6174,7 @@ bus.subscribe((event) => {
5308
6174
  function drainQueuedSends() {
5309
6175
  if (!followupsReady)
5310
6176
  return;
5311
- drainSteeredMessages(store, (botId, threadId, prompt, userMessage, excludeIds, unattended) =>
6177
+ drainSteeredMessages(store, (botId, threadId, prompt, userMessage, excludeIds, unattended, head) =>
5312
6178
  // A plain attended turn — no automationSource, no comms depth: exactly
5313
6179
  // what typing the same words into an idle bot would run. Self-opened
5314
6180
  // work retains `unattended` and the message's bot-origin provenance
@@ -5317,8 +6183,10 @@ function drainQueuedSends() {
5317
6183
  // from duplicating the last one, and excludeIds drops every drained
5318
6184
  // line from the transcript-replay so they are not also in `prompt`.
5319
6185
  new Promise((resolve, reject) => {
6186
+ // The drained turn is booked to whoever sent the first waiting line.
5320
6187
  void startTurn(botId, prompt, {
5321
6188
  threadId, userMessage, excludeMessageIds: excludeIds, unattended, onTurnSettled: resolve,
6189
+ trigger: queuedTurnTrigger(head),
5322
6190
  }).catch((err) => {
5323
6191
  store.appendMessage(threadId, {
5324
6192
  role: "bot", kind: "activity",
@@ -5337,7 +6205,7 @@ function drainQueuedSends() {
5337
6205
  }
5338
6206
  /** Keep a person's words off the transcript until a direct-thread slot is
5339
6207
  * available. Reuse the existing cancellable, idempotent composer queue. */
5340
- async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender) {
6208
+ async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender, trigger) {
5341
6209
  const capacity = botAtThreadCapacity(botId);
5342
6210
  if (capacity || threadBusy(botId, threadId) || parksBehindCoordination(botId, threadId)) {
5343
6211
  const reason = capacity ? "capacity" : undefined;
@@ -5346,10 +6214,12 @@ async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId,
5346
6214
  sendId,
5347
6215
  reason,
5348
6216
  prompt: promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"),
6217
+ sender,
6218
+ trigger,
5349
6219
  });
5350
6220
  return { ok: true, queued: true, queueId: queued.id, threadId, reason };
5351
6221
  }
5352
- const message = await startTurn(botId, text, { threadId, replyTo, sendId, sender });
6222
+ const message = await startTurn(botId, text, { threadId, replyTo, sendId, sender, trigger });
5353
6223
  return { ok: true, threadId, message };
5354
6224
  }
5355
6225
  /** How many start_thread calls one turn may make. Same spirit as the
@@ -5619,6 +6489,8 @@ async function startTurn(botId, text, opts) {
5619
6489
  const task = store.taskByThread(bot.id, threadId);
5620
6490
  if (!task)
5621
6491
  throw Object.assign(new Error("no such task"), { status: 404 });
6492
+ if (hostedModels && !hostedModels.allows(bot.modelSelection))
6493
+ throw Object.assign(new Error(hostedModels.error()), { status: 409 });
5622
6494
  const plan = turnSurfacePlan(bot, opts?.runOn, threadId);
5623
6495
  const instance = turnInstance(bot, opts?.runOn, threadId);
5624
6496
  if (!instance) {
@@ -5626,6 +6498,9 @@ async function startTurn(botId, text, opts) {
5626
6498
  ? "the Cloud VM runner is unavailable — configure Box in App Settings"
5627
6499
  : `provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`), { status: 409 });
5628
6500
  }
6501
+ const policyRefusal = policyModelRefusal(instance);
6502
+ if (policyRefusal)
6503
+ throw Object.assign(new Error(policyRefusal), { status: 409, code: "managed_policy" });
5629
6504
  // Resolve only transport tags from this newly submitted text. The original
5630
6505
  // string remains the durable message. Native-image providers get a
5631
6506
  // path-free prompt and bounded inputs instead of needing a Read tool;
@@ -5700,6 +6575,10 @@ async function startTurn(botId, text, opts) {
5700
6575
  sender: opts?.sender,
5701
6576
  });
5702
6577
  }
6578
+ // Admitted: every refusal above has passed and no other turn runs on this
6579
+ // thread, so this is the one moment the ledger's "who asked" may change.
6580
+ if (opts?.trigger)
6581
+ turnTriggers.set(threadId, opts.trigger);
5703
6582
  // A card continuation neither starts nor ends the person's ask: it
5704
6583
  // resumes the turn their last message began, so that record stands.
5705
6584
  if (!opts?.cardContinuation) {
@@ -5722,6 +6601,22 @@ async function startTurn(botId, text, opts) {
5722
6601
  const resourceOwner = { threadId, generation: dispatchClaimId };
5723
6602
  turnResourceOwners.set(threadId, resourceOwner);
5724
6603
  directTurnGenerationByThread.set(threadId, dispatchClaimId);
6604
+ let requestMessageId = opts?.cardContinuation ? opts.requestMessageId : userMessage.id;
6605
+ const previousRequest = directRequestOwners.get(threadId);
6606
+ const requestGenerations = requestMessageId && previousRequest?.messageId === requestMessageId
6607
+ ? previousRequest.generations : new Set();
6608
+ if (requestGenerations.size >= 500) {
6609
+ requestGenerations.clear();
6610
+ requestMessageId = undefined;
6611
+ }
6612
+ requestGenerations.add(dispatchClaimId);
6613
+ directRequestOwners.set(threadId, { generation: dispatchClaimId, messageId: requestMessageId,
6614
+ generations: requestGenerations, turnId: null });
6615
+ // An unknown control-plane wake may invalidate a prior final, but it must
6616
+ // never acquire authority by guessing the latest user as its origin.
6617
+ const pendingSource = requestMessageId ?? store.activePath(threadId).findLast(message => message.role === "user")?.id;
6618
+ if (pendingSource)
6619
+ store.patchMessage(threadId, pendingSource, { requestPending: true });
5725
6620
  if (opts?.coordination)
5726
6621
  directCoordinationSettlers.set(dispatchClaimId, opts.coordination.settle);
5727
6622
  // Ordinary sources need the same exact completion ownership as queued
@@ -5930,7 +6825,7 @@ async function startTurn(botId, text, opts) {
5930
6825
  // composio — only to a driver that can mount them. Their tools are
5931
6826
  // never pre-allowed, so every call rides the normal permission flow.
5932
6827
  if (instance.adapter.capabilities.customMcp === true) {
5933
- const custom = customMcpServers(cfg, bot.mcpServers);
6828
+ const custom = engineMcpServers(bot);
5934
6829
  if (Object.keys(custom).length)
5935
6830
  integrations.custom = custom;
5936
6831
  }
@@ -6000,6 +6895,13 @@ async function startTurn(botId, text, opts) {
6000
6895
  throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
6001
6896
  }
6002
6897
  const wants = plan.computer;
6898
+ // A place the organisation disallows is refused before anything is
6899
+ // prepared; Auto below simply skips disallowed places.
6900
+ const wantedKind = teamComputer ? "box" : wants === "local" ? "thisComputer" : wants === "vm" ? "localVm"
6901
+ : wants === "cloud" ? (cloudBackend === "vps" ? "vps" : "box") : undefined;
6902
+ const placeRefusal = wantedKind && managedPolicy.computerRefusal(wantedKind);
6903
+ if (placeRefusal)
6904
+ throw Object.assign(new Error(placeRefusal), { status: 409, code: "managed_policy" });
6003
6905
  let previewCapture = null;
6004
6906
  let browserCapture = null;
6005
6907
  let computerKind = null;
@@ -6177,7 +7079,7 @@ async function startTurn(botId, text, opts) {
6177
7079
  // A VPS is a local-agent computer mount, never a remote agent runner.
6178
7080
  // Explicit Cloud may prepare/start it. Auto remains read-only unless
6179
7081
  // the person explicitly opted this bot into remote lifecycle actions.
6180
- if ((wants === "cloud" || wants === undefined) && cloudBackend === "vps") {
7082
+ if ((wants === "cloud" || (wants === undefined && managedPolicy.computerAllowed("vps"))) && cloudBackend === "vps") {
6181
7083
  const unsupported = vps.vpsDriverError(instance.driverKind, mountsComputerMcp);
6182
7084
  if (unsupported && wants === "cloud")
6183
7085
  throw new Error(unsupported);
@@ -6253,7 +7155,7 @@ async function startTurn(botId, text, opts) {
6253
7155
  // Auto reaches a Local VM this bot already has before it ever touches the
6254
7156
  // host's own desktop: on a headless server that VM is the only desktop
6255
7157
  // there is, and a person who prepared one meant it to be used.
6256
- if (wants === undefined && !integrations.computer && !integrations.localComputer && await attachLocalVm(false))
7158
+ if (wants === undefined && !integrations.computer && !integrations.localComputer && managedPolicy.computerAllowed("localVm") && await attachLocalVm(false))
6257
7159
  computerKind = "vm";
6258
7160
  // An unattended run on a bot with a VPS configured never lands on the
6259
7161
  // host's own desktop instead: a scheduled job clicking on someone's
@@ -6263,6 +7165,7 @@ async function startTurn(botId, text, opts) {
6263
7165
  !integrations.localComputer &&
6264
7166
  wants === undefined &&
6265
7167
  !unattendedVps &&
7168
+ managedPolicy.computerAllowed("thisComputer") &&
6266
7169
  shouldMountLocalComputer({
6267
7170
  requested: undefined,
6268
7171
  hostPlatform: process.platform,
@@ -6457,7 +7360,7 @@ async function startTurn(botId, text, opts) {
6457
7360
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
6458
7361
  // what the bot said lately in its other conversations, so a task
6459
7362
  // never redoes — or forgets — what another one already did
6460
- { id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(store, bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId })) },
7363
+ { id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(recentWorkSources(bot), bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId })) },
6461
7364
  { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace }) },
6462
7365
  { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id) : "" },
6463
7366
  { id: "skill-instructions", label: "Skill instructions", text: skillInstructions },
@@ -6507,7 +7410,7 @@ async function startTurn(botId, text, opts) {
6507
7410
  systemStable: prompt.stable,
6508
7411
  systemVolatile: prompt.volatile,
6509
7412
  integrations,
6510
- mcpFromUserConfig: claudeUserMcpEnabled(cfg),
7413
+ mcpFromUserConfig: claudeUserMcpEnabled(cfg) && !managedPolicy.restrictsMcp(),
6511
7414
  cwd,
6512
7415
  }), () => !directTurnClaimExists(bot.id, dispatchClaimId, threadId), async () => {
6513
7416
  await instance.adapter.interruptTurn(threadId).catch(() => { });
@@ -6516,6 +7419,12 @@ async function startTurn(botId, text, opts) {
6516
7419
  retireProviderTurn(dispatch.value.turnId);
6517
7420
  throw new DirectTurnSetupCancelled("turn stopped during provider setup");
6518
7421
  }
7422
+ const requestOwner = directRequestOwners.get(threadId);
7423
+ if (requestOwner?.generation === dispatchClaimId && dispatch.value.turnId) {
7424
+ if (requestOwner.turnId && requestOwner.turnId !== dispatch.value.turnId)
7425
+ requestOwner.messageId = undefined;
7426
+ requestOwner.turnId = dispatch.value.turnId;
7427
+ }
6519
7428
  bindInternalCapabilityToProviderTurn(threadId, dispatchClaimId, dispatch.value.turnId);
6520
7429
  handoffs.bindTurn(threadId, dispatchClaimId, dispatch.value.turnId);
6521
7430
  if (directFollowupSettlers.has(dispatchClaimId) && dispatch.value.turnId &&
@@ -6601,6 +7510,7 @@ async function startTurn(botId, text, opts) {
6601
7510
  store.appendMessage(threadId, {
6602
7511
  role: "bot",
6603
7512
  kind: "activity",
7513
+ turnSucceeded: false,
6604
7514
  tool: { name: `error: ${message.slice(0, 160)}`, ok: false },
6605
7515
  });
6606
7516
  // Worth a buzz for the same reason a routine failure is, and the rule
@@ -6827,7 +7737,12 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
6827
7737
  // Load queued handoffs before scheduler recovery can fail an interrupted
6828
7738
  // run. Its failure callback can then durably drop that work immediately;
6829
7739
  // nothing dispatches until the listener is ready below.
6830
- const commsBus = { store, broadcast, threadSlotFree: (botId) => !botAtThreadCapacity(botId) };
7740
+ const commsBus = {
7741
+ store,
7742
+ broadcast,
7743
+ threadSlotFree: (botId) => !botAtThreadCapacity(botId),
7744
+ canAdmitDirectTurn,
7745
+ };
6831
7746
  _loadPending();
6832
7747
  routines = new RoutineManager({
6833
7748
  emit: broadcast,
@@ -7301,7 +8216,7 @@ function dispatchTeamSetupResume(entry) {
7301
8216
  const message = store.messagesFor(request.threadId).find((item) => item.id === messageId);
7302
8217
  if (!owner || !message?.card?.teamSetupRequest?.result)
7303
8218
  return;
7304
- if (owner.group ? owner.bot.busy : threadBusy(request.botId, request.threadId) || activeGroupTurnForBot(request.botId)) {
8219
+ if (!canAdmitDirectTurn(request.botId, request.threadId)) {
7305
8220
  pendingTeamSetupResumes.set(request.requestId, entry);
7306
8221
  return;
7307
8222
  }
@@ -7332,7 +8247,8 @@ function dispatchTeamSetupResume(entry) {
7332
8247
  groupQueues.set(groupId, next.finally(() => finishGroupTurnOperation(groupId, operation)).catch((error) => failed(error instanceof Error ? error.message : String(error))));
7333
8248
  return;
7334
8249
  }
7335
- void startTurn(request.botId, prompt, { threadId: request.threadId, cardContinuation: true, onDispatchError: failed }).catch((error) => {
8250
+ void startTurn(request.botId, prompt, { threadId: request.threadId, cardContinuation: true,
8251
+ requestMessageId: requestSourceForCard(store.messagesFor(request.threadId), messageId), onDispatchError: failed }).catch((error) => {
7336
8252
  if (cancelled())
7337
8253
  return;
7338
8254
  if (isTurnAdmissionBlocked(error))
@@ -7343,8 +8259,7 @@ function dispatchTeamSetupResume(entry) {
7343
8259
  }
7344
8260
  function drainTeamSetupResumes() {
7345
8261
  for (const [key, entry] of pendingTeamSetupResumes) {
7346
- const owner = connectorThread(entry.request.botId, entry.request.threadId);
7347
- if (owner?.group ? owner.bot.busy : threadBusy(entry.request.botId, entry.request.threadId) || activeGroupTurnForBot(entry.request.botId))
8262
+ if (!canAdmitDirectTurn(entry.request.botId, entry.request.threadId))
7348
8263
  continue;
7349
8264
  pendingTeamSetupResumes.delete(key);
7350
8265
  dispatchTeamSetupResume(entry);
@@ -7689,8 +8604,9 @@ setupRetry = 0) {
7689
8604
  onDispatchError?.(`${bot.name}'s provider account is being updated — try again shortly`);
7690
8605
  return true;
7691
8606
  }
7692
- if (!instance) {
7693
- const message = `${bot.name}'s model is unavailable`;
8607
+ const roomPolicyRefusal = instance ? policyModelRefusal(instance) : undefined;
8608
+ if (!instance || roomPolicyRefusal) {
8609
+ const message = roomPolicyRefusal ?? `${bot.name}'s model is unavailable`;
7694
8610
  store.appendMessage(threadId, {
7695
8611
  role: "bot",
7696
8612
  kind: "activity",
@@ -7804,7 +8720,7 @@ setupRetry = 0) {
7804
8720
  }
7805
8721
  // user-configured MCP servers: same gating as the 1:1 site above.
7806
8722
  if (instance.adapter.capabilities.customMcp === true) {
7807
- const custom = customMcpServers(cfg, bot.mcpServers);
8723
+ const custom = engineMcpServers(bot);
7808
8724
  if (Object.keys(custom).length)
7809
8725
  integrations.custom = custom;
7810
8726
  }
@@ -7921,6 +8837,13 @@ setupRetry = 0) {
7921
8837
  readyBot.browser !== false &&
7922
8838
  instance.adapter.capabilities.browserMcp === true,
7923
8839
  });
8840
+ // A place the organisation disallows is refused before anything is
8841
+ // provisioned or started, exactly as a bot thread refuses it.
8842
+ const roomKind = roomTeamComputer ? "box" : roomPlan.computer === "local" ? "thisComputer" : readyBot.computer === "vm" ? "localVm"
8843
+ : roomPlan.computer === "cloud" ? (turnProvider(readyBot) === "vps" ? "vps" : "box") : undefined;
8844
+ const roomPlaceRefusal = roomKind && managedPolicy.computerRefusal(roomKind);
8845
+ if (roomPlaceRefusal)
8846
+ throw Object.assign(new Error(roomPlaceRefusal), { code: "managed_policy" });
7924
8847
  // The Computer engine runs on the Box; this computer has no tools it can
7925
8848
  // reach, exactly as a bot thread refuses it.
7926
8849
  if (roomPlan.computer === "local" && instance.driverKind === "boxAgent") {
@@ -8112,7 +9035,7 @@ setupRetry = 0) {
8112
9035
  // brief can carry a private chat into the room; as with session_search
8113
9036
  // (#754) the room is told, once per source thread, rather than the
8114
9037
  // crossing being blocked.
8115
- const recentLines = recentWork(store, bot, { userName, currentThreadId: threadId });
9038
+ const recentLines = recentWork(recentWorkSources(bot), bot, { userName, currentThreadId: threadId });
8116
9039
  {
8117
9040
  const crossing = claimRecallCrossings(threadId, recentLines.filter((line) => line.private).map((line) => line.threadId));
8118
9041
  if (crossing.count) {
@@ -8254,7 +9177,7 @@ setupRetry = 0) {
8254
9177
  systemVolatile: roomSystem.volatile,
8255
9178
  cwd,
8256
9179
  integrations,
8257
- mcpFromUserConfig: claudeUserMcpEnabled(cfg),
9180
+ mcpFromUserConfig: claudeUserMcpEnabled(cfg) && !managedPolicy.restrictsMcp(),
8258
9181
  ...(instance.instanceId === readyBot.modelSelection.instanceId
8259
9182
  ? memberTurnSelection(readyBot.modelSelection)
8260
9183
  : { model: instance.models.default }),
@@ -8778,6 +9701,9 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
8778
9701
  via: options.via,
8779
9702
  sender: options.sender,
8780
9703
  });
9704
+ // Admitted (see startTurn): the room's speakers are booked to this sender.
9705
+ if (options.trigger)
9706
+ turnTriggers.set(threadId, options.trigger);
8781
9707
  const titled = group.dm ? null : store.titleGroupTaskFromFirstMessage(group.id, text, threadId);
8782
9708
  const snippet = titled?.title;
8783
9709
  const archived = members.filter((member) => member.hidden);
@@ -8833,7 +9759,8 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
8833
9759
  const titleBot = goalCoordinator ?? responders[0];
8834
9760
  const titleInstance = registry.get(titleBot.modelSelection.instanceId);
8835
9761
  const titleText = extractTurnImages(text).text;
8836
- if (titled && snippet && titleText.trim() && llmThreadTitlesEnabled(cfg) && titleInstance?.generateText) {
9762
+ // An engine the organisation disallows never receives the text, even for a title.
9763
+ if (titled && snippet && titleText.trim() && llmThreadTitlesEnabled(cfg) && titleInstance?.generateText && !policyModelRefusal(titleInstance)) {
8837
9764
  void generateThreadTitle(titleInstance, titleText)
8838
9765
  .then((title) => {
8839
9766
  if (title)
@@ -8926,7 +9853,7 @@ function drainQueuedChannelSends() {
8926
9853
  drainChannelMessages((groupId) => {
8927
9854
  const group = store.group(groupId);
8928
9855
  return group ? groupIsWorking(group) : false;
8929
- }, ({ groupId, threadId, text, replyToId, sendId, mode, id, via }) => {
9856
+ }, ({ groupId, threadId, text, replyToId, sendId, mode, id, via, sender, trigger }) => {
8930
9857
  const group = store.group(groupId);
8931
9858
  const ownsThread = group?.dm
8932
9859
  ? group.threadId === threadId
@@ -8934,11 +9861,11 @@ function drainQueuedChannelSends() {
8934
9861
  if (!group || !ownsThread)
8935
9862
  return;
8936
9863
  try {
8937
- startGroupTurn(groupId, text, resolveReplyTarget(threadId, replyToId), sendId, mode, id, { via, threadId });
9864
+ startGroupTurn(groupId, text, resolveReplyTarget(threadId, replyToId), sendId, mode, id, { via, threadId, sender, trigger: queuedTurnTrigger({ trigger, sender }) });
8938
9865
  }
8939
9866
  catch (error) {
8940
9867
  if (!store.messagesFor(threadId).some((message) => message.queueId === id && message.role === "user")) {
8941
- store.appendMessage(threadId, { role: "user", kind: "text", text, replyToId, sendId, channelMode: mode, queueId: id, via });
9868
+ store.appendMessage(threadId, { role: "user", kind: "text", text, replyToId, sendId, channelMode: mode, queueId: id, via, sender });
8942
9869
  }
8943
9870
  store.appendMessage(threadId, {
8944
9871
  role: "bot",
@@ -8966,6 +9893,10 @@ function ensureCalendarCallRoom(call) {
8966
9893
  let group = linked && sameCalendarRoster(linked, call.botIds) && !roomSetupPending(linked)
8967
9894
  ? linked
8968
9895
  : undefined;
9896
+ // A call's room is a room like any other: one audience.
9897
+ const conflict = group ? null : roomAudienceConflict(call.botIds);
9898
+ if (conflict)
9899
+ throw Object.assign(new Error(conflict), { status: 400 });
8969
9900
  group ??= store.createGroup(call.name, call.botIds, false, undefined, {
8970
9901
  bulletin: "",
8971
9902
  defaultResponder: { kind: "everyone" },
@@ -8980,7 +9911,14 @@ function deliverCalendarCall(call, scheduledFor) {
8980
9911
  // Multi-bot entries are rooms and begin with the shared event prompt.
8981
9912
  if (call.botIds.length < 2)
8982
9913
  return;
8983
- const group = ensureCalendarCallRoom(call);
9914
+ let group;
9915
+ try {
9916
+ group = ensureCalendarCallRoom(call);
9917
+ }
9918
+ catch (error) {
9919
+ console.warn(`calendar call "${call.name}" did not start: ${error instanceof Error ? error.message : String(error)}`);
9920
+ return;
9921
+ }
8984
9922
  const text = [
8985
9923
  `@everyone ${call.description.trim() || call.name}`,
8986
9924
  ...call.attachments.map((attachment) => `<${attachment.kind === "image" ? "attached-image" : "attached-file"} path="${escapeAttribute(attachment.path)}" name="${escapeAttribute(attachment.name)}" />`),
@@ -9018,6 +9956,8 @@ function connectorThread(botId, threadId) {
9018
9956
  const bot = store.bot(botId);
9019
9957
  if (!bot)
9020
9958
  return null;
9959
+ if (bot.threadId === threadId)
9960
+ return { bot, group: undefined }; // a bot's own main chat
9021
9961
  if (store.taskByThread(botId, threadId))
9022
9962
  return { bot, group: undefined };
9023
9963
  const group = store.groupByThread(threadId);
@@ -9386,7 +10326,7 @@ function dispatchConnectorResume(entry) {
9386
10326
  return;
9387
10327
  const names = entry.labels.join(", ");
9388
10328
  const prompt = `OpenMausBot connection update: the user securely connected ${names}. Continue the task that paused for this connection. Do not ask them to connect it again.`;
9389
- if (owner.group ? owner.bot.busy : threadBusy(entry.botId, entry.threadId) || activeGroupTurnForBot(entry.botId)) {
10329
+ if (!canAdmitDirectTurn(entry.botId, entry.threadId)) {
9390
10330
  pendingConnectorResumes.set(`${entry.threadId}:${entry.resumeKey}`, entry);
9391
10331
  return;
9392
10332
  }
@@ -9439,8 +10379,7 @@ function maybeResumeConnectors(botId, threadId, resumeKey) {
9439
10379
  }
9440
10380
  function drainConnectorResumes() {
9441
10381
  for (const [key, entry] of pendingConnectorResumes) {
9442
- const owner = connectorThread(entry.botId, entry.threadId);
9443
- if (owner?.group ? owner.bot.busy : threadBusy(entry.botId, entry.threadId) || activeGroupTurnForBot(entry.botId))
10382
+ if (!canAdmitDirectTurn(entry.botId, entry.threadId))
9444
10383
  continue;
9445
10384
  pendingConnectorResumes.delete(key);
9446
10385
  dispatchConnectorResume(entry);
@@ -9513,7 +10452,7 @@ function dispatchSecretResume(entry) {
9513
10452
  const prompt = entry.outcome === "provided"
9514
10453
  ? `OpenMausBot credential update: the user securely provided ${entry.label}. Continue the task that paused for it. You do not receive the secret and must not ask them to paste it into chat.`
9515
10454
  : `OpenMausBot credential update: the user declined to provide ${entry.label}. Continue without it if possible, or briefly explain the limitation. Do not ask them to paste it into chat.`;
9516
- if (owner.group ? owner.bot.busy : threadBusy(entry.botId, entry.threadId) || activeGroupTurnForBot(entry.botId)) {
10455
+ if (!canAdmitDirectTurn(entry.botId, entry.threadId)) {
9517
10456
  pendingSecretResumes.set(`${entry.threadId}:${entry.messageId}`, entry);
9518
10457
  return;
9519
10458
  }
@@ -9658,8 +10597,7 @@ async function provideSecretFromPhone(context, authenticatedDeviceId) {
9658
10597
  }
9659
10598
  function drainSecretResumes() {
9660
10599
  for (const [key, entry] of pendingSecretResumes) {
9661
- const owner = connectorThread(entry.botId, entry.threadId);
9662
- if (owner?.group ? owner.bot.busy : threadBusy(entry.botId, entry.threadId) || activeGroupTurnForBot(entry.botId))
10600
+ if (!canAdmitDirectTurn(entry.botId, entry.threadId))
9663
10601
  continue;
9664
10602
  pendingSecretResumes.delete(key);
9665
10603
  dispatchSecretResume(entry);
@@ -9847,20 +10785,38 @@ async function perBotLocalVmCountForModeChange() {
9847
10785
  }
9848
10786
  return existingPerBotLocalVmCount(runtime.runtime);
9849
10787
  }
10788
+ /** What Settings needs about the edition: the features, and, once the key
10789
+ * is inside its warning window or grace period, when it lapses. The dates
10790
+ * reach admins only (configForAccess strips them for client sessions). */
10791
+ function editionForSettings(status) {
10792
+ const expiring = status.edition === "enterprise" && status.expiresAt && status.expiresInDays !== undefined &&
10793
+ (status.graceEndsAt !== undefined || status.expiresInDays <= LICENSE_WARN_DAYS);
10794
+ return {
10795
+ edition: status.edition,
10796
+ features: status.features,
10797
+ ...(expiring ? { license: { expiresAt: status.expiresAt, expiresInDays: status.expiresInDays, ...(status.graceEndsAt ? { graceEndsAt: status.graceEndsAt } : {}) } } : {}),
10798
+ };
10799
+ }
9850
10800
  function configStatus() {
10801
+ // off-by-default thread cleanup knobs stay absent so clients can tell
10802
+ // "unset" apart from any in-range value
10803
+ const eventLogMaxBytes = threadEventLogMaxBytes(cfg);
10804
+ const eventLogRetentionDays = threadEventLogRetentionDays(cfg);
9851
10805
  return {
9852
10806
  xai: { configured: Boolean(cfg.xai?.key) },
9853
10807
  anthropic: { configured: Boolean(cfg.anthropic?.key) },
9854
10808
  // a fleet agent on this server means Settings → Workspaces has something to drive
9855
10809
  fleet: { available: fleetAvailable(fleetSocketPath()) },
9856
10810
  // what this build is entitled to, so Settings shows only what works here
9857
- edition: (({ edition, features }) => ({ edition, features }))(editionStatus()),
10811
+ edition: editionForSettings(editionStatus()),
9858
10812
  // settings, not secrets: the cap and the operator's own price list
9859
10813
  budgets: {
9860
10814
  ...(cfg.budgets?.monthlyUsd !== undefined ? { monthlyUsd: cfg.budgets.monthlyUsd } : {}),
9861
10815
  ...(cfg.budgets?.warnAtPercent !== undefined ? { warnAtPercent: cfg.budgets.warnAtPercent } : {}),
9862
10816
  },
9863
10817
  billing: { currency: cfg.billing?.currency ?? "USD", prices: cfg.billing?.prices ?? {} },
10818
+ // how long the approval decision log is kept, after env and default
10819
+ decisions: { retentionDays: decisionRetentionDays(cfg.decisions?.retentionDays) },
9864
10820
  // the base URL is a setting, not a secret; the key stays write-only
9865
10821
  openaiCompat: { configured: Boolean(cfg.openaiCompat?.key), url: cfg.openaiCompat?.url ?? "" },
9866
10822
  composio: {
@@ -9876,10 +10832,16 @@ function configStatus() {
9876
10832
  imageGen: avatarImageStatus(cfg),
9877
10833
  // not a secret — the sidebar shows it
9878
10834
  profile: { name: cfg.profile?.name ?? "", email: cfg.profile?.email ?? "" },
10835
+ // the enrolled organisation's read-only desktop policy; null when not enrolled
10836
+ managedPolicy: managedPolicy.summary(),
9879
10837
  // not a secret — the settings picker shows it; "" = follow the system
9880
10838
  language: cfg.language ?? "",
9881
10839
  rooms: { turnTimeoutMinutes: roomTurnTimeoutMinutes(cfg) },
9882
- threads: { maxConcurrentPerBot: maxConcurrentBotThreads(cfg) },
10840
+ threads: {
10841
+ maxConcurrentPerBot: maxConcurrentBotThreads(cfg),
10842
+ ...(eventLogMaxBytes !== null ? { eventLogMaxBytes } : {}),
10843
+ ...(eventLogRetentionDays !== null ? { eventLogRetentionDays } : {}),
10844
+ },
9883
10845
  localVm: {
9884
10846
  mode: localVmMode(cfg),
9885
10847
  maxInstances: localVmMaxInstances(cfg),
@@ -9913,6 +10875,8 @@ function configStatus() {
9913
10875
  browserProfiles: cfg.browserProfiles ?? [],
9914
10876
  // who may sign in with an emailed code (server/account-signin.ts)
9915
10877
  signIn: { admins: cfg.signIn?.admins ?? [], members: cfg.signIn?.members ?? [] },
10878
+ // whether that list decides anything here, or the organisation's Admin does
10879
+ membership: workspaceMembership(),
9916
10880
  };
9917
10881
  }
9918
10882
  function configForAccess(status, admin) {
@@ -9923,6 +10887,7 @@ function configForAccess(status, admin) {
9923
10887
  // source objects.
9924
10888
  return {
9925
10889
  ...status,
10890
+ edition: { edition: status.edition.edition, features: status.edition.features },
9926
10891
  signIn: { admins: [], members: [] },
9927
10892
  vps: { configured: status.vps.configured, sshAlias: "" },
9928
10893
  profile: { name: status.profile.name, email: "" },
@@ -9930,7 +10895,15 @@ function configForAccess(status, admin) {
9930
10895
  };
9931
10896
  }
9932
10897
  function mcpServerResponse() {
9933
- return { servers: listMcpServers(cfg.mcpServers) };
10898
+ // While enrolled with custom servers off, say which servers stay configured
10899
+ // but never reach bots, and why. Nothing here is written to config.json.
10900
+ const policy = managedPolicy.current();
10901
+ const servers = listMcpServers(cfg.mcpServers).map(server => managedPolicy.mcpAllowed(server.name, "url" in server ? server.url : undefined) ? server : { ...server, managedBy: policy.organizationName });
10902
+ return { servers, ...(policy && !policy.mcp.allowCustom ? { managed: { organizationName: policy.organizationName, allowlist: policy.mcp.allowlist } } : {}) };
10903
+ }
10904
+ /** Adding or editing a server the organisation has not approved is refused. */
10905
+ function mcpPolicyRefusal(name, server) {
10906
+ return managedPolicy.mcpRefusal(name, "url" in server && typeof server.url === "string" ? server.url : undefined);
9934
10907
  }
9935
10908
  /** The fields a new server may set, in whichever shape the form sent — a
9936
10909
  * command to run or a URL to reach. Absent keys stay absent, so the strict
@@ -9946,6 +10919,11 @@ function mcpServerBody(body) {
9946
10919
  }
9947
10920
  return out;
9948
10921
  }
10922
+ /** Configured MCP servers that may reach this bot's engine. While enrolled,
10923
+ * the organisation's allow-list filters them; config.json is never changed. */
10924
+ function engineMcpServers(bot) {
10925
+ return managedPolicy.filterMcp(customMcpServers(cfg, bot.mcpServers));
10926
+ }
9949
10927
  function persistMcpServers(next) {
9950
10928
  saveConfig({ mcpServers: next });
9951
10929
  // Do not reload the provider fleet: integrations are assembled from cfg at
@@ -9955,25 +10933,31 @@ function persistMcpServers(next) {
9955
10933
  cfg.mcpServers = next;
9956
10934
  }
9957
10935
  async function describeInstances() {
9958
- const configs = instanceConfigs(cfg);
10936
+ const configs = providerConfigs();
9959
10937
  return (await registry.describe()).map((instance) => {
9960
10938
  const entry = configs[instance.instanceId];
9961
10939
  const described = entry?.icon ? { ...instance, icon: entry.icon } : instance;
10940
+ if (hostedModels)
10941
+ return { ...described, readOnly: true,
10942
+ install: undefined, authentication: undefined, cli: undefined, cliCandidates: [],
10943
+ };
10944
+ const policyReason = policyModelRefusal(instance);
10945
+ const policy = policyReason ? { policy: { organizationName: managedPolicy.current().organizationName, reason: policyReason } } : {};
9962
10946
  if (managedDesktop.owns(instance.instanceId))
9963
10947
  return {
9964
- ...described, readOnly: true, managed: managedDesktop.info(instance.instanceId),
10948
+ ...described, ...policy, readOnly: true, managed: managedDesktop.info(instance.instanceId),
9965
10949
  install: undefined, authentication: undefined, cli: undefined, cliCandidates: [],
9966
10950
  };
9967
10951
  if (entry?.driver !== "claudeAgent")
9968
- return described;
10952
+ return { ...described, ...policy };
9969
10953
  try {
9970
10954
  const claudeAccount = claudeAccountInfo(instance.instanceId, entry, instance.cli ?? instance.cliDefault ?? "claude");
9971
- return { ...described, claudeAccount, install: { ...instance.install, signInCommand: claudeAccount.signInCommand } };
10955
+ return { ...described, ...policy, claudeAccount, install: { ...instance.install, signInCommand: claudeAccount.signInCommand } };
9972
10956
  }
9973
10957
  catch {
9974
10958
  // A malformed saved config remains a repairable shadow, never takes
9975
10959
  // the model picker down or offers a login for the wrong directory.
9976
- return { ...described, install: { ...instance.install, signInCommand: undefined } };
10960
+ return { ...described, ...policy, install: { ...instance.install, signInCommand: undefined } };
9977
10961
  }
9978
10962
  });
9979
10963
  }
@@ -10090,7 +11074,7 @@ async function reloadProviders() {
10090
11074
  bus.detachAll();
10091
11075
  try {
10092
11076
  await registry.disposeAll();
10093
- await registry.load(instanceConfigs(cfg));
11077
+ await registry.load(providerConfigs(), decorateHostedProvider);
10094
11078
  // Personal providers are usable independently of the optional Company
10095
11079
  // overlay. Subscribe them before restoring that overlay so a broken or
10096
11080
  // expired Company runtime cannot leave the rebuilt personal fleet mute.
@@ -10183,11 +11167,6 @@ function serveStatic(res, path) {
10183
11167
  }
10184
11168
  }
10185
11169
  }
10186
- function json(res, status, body) {
10187
- const data = JSON.stringify(body);
10188
- res.writeHead(status, { "content-type": "application/json" });
10189
- res.end(data);
10190
- }
10191
11170
  /** A store refusal is a client error with a status of its own (400 path,
10192
11171
  * 409 conflict, 413 too large); a 409 also carries what is on disk now so
10193
11172
  * the editor can show the bot's version instead of guessing. Anything else
@@ -10208,46 +11187,6 @@ function journalEntryForClient(botId, entry) {
10208
11187
  const threadTitle = entry.threadId ? store.taskByThread(botId, entry.threadId)?.title : undefined;
10209
11188
  return threadTitle ? { ...visible, threadTitle } : visible;
10210
11189
  }
10211
- function readBody(req, limit = 1_000_000) {
10212
- return new Promise((resolve, reject) => {
10213
- let data = "";
10214
- let bytes = 0;
10215
- let done = false;
10216
- const fail = (status, msg) => {
10217
- if (done)
10218
- return;
10219
- done = true;
10220
- const err = Object.assign(new Error(msg), { status });
10221
- reject(err);
10222
- };
10223
- req.on("data", (c) => {
10224
- if (done)
10225
- return;
10226
- bytes += typeof c === "string" ? Buffer.byteLength(c) : c.length;
10227
- if (bytes > limit) {
10228
- // Keep draining the socket, but stop retaining attacker-controlled
10229
- // bytes. Destroying the request here prevents the caller from
10230
- // receiving the useful 413 response.
10231
- return fail(413, "body too large");
10232
- }
10233
- data += c;
10234
- });
10235
- req.on("end", () => {
10236
- if (done)
10237
- return;
10238
- let body;
10239
- try {
10240
- body = data ? JSON.parse(data) : {};
10241
- }
10242
- catch {
10243
- return fail(400, "invalid JSON body");
10244
- }
10245
- done = true;
10246
- resolve(body);
10247
- });
10248
- req.on("error", (e) => fail(400, e instanceof Error ? e.message : String(e)));
10249
- });
10250
- }
10251
11190
  // Loopback-only enforcement: the harness runs on 127.0.0.1 but accepts
10252
11191
  // requests from any loopback connection and any web page that DNS-rebinds
10253
11192
  // onto it. Reject non-loopback Hosts outright (defeats rebinding) and
@@ -10262,7 +11201,7 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
10262
11201
  const current = resolveRequestAuth(req, {
10263
11202
  sessions, cookieName: SESSION_COOKIE, streamPath: "/api/events",
10264
11203
  url: new URL(req.url ?? "/", `http://localhost:${PORT}`),
10265
- loopbackMutationToken: desktopMutationToken, companionMutationToken,
11204
+ loopbackMutationToken: desktopMutationToken, companionMutationToken, loopbackTrust: LOOPBACK.trust, cliOwnerToken,
10266
11205
  }).auth;
10267
11206
  return Boolean(current?.scopes.includes("admin") && current.kind === original.kind &&
10268
11207
  (current.kind !== "session" || (original.kind === "session" && current.session.id === original.session.id)));
@@ -10286,13 +11225,16 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
10286
11225
  pause: () => { routines?.stop(); calendarCalls?.stop(); watchdog.stop(); },
10287
11226
  resume: () => { routines?.start(); calendarCalls?.start(); watchdog.start(); },
10288
11227
  flush: async () => {
10289
- await Promise.all([flushAllProfileHistory(), flushAllMemoryJournals(), flushUsageLedger(DATA_DIR), flushDecisionLog(DATA_DIR)]);
11228
+ await Promise.all([flushAllProfileHistory(), flushAllMemoryJournals(), flushUsageLedger(DATA_DIR), flushDecisionLog(DATA_DIR), flushAdminActivity(DATA_DIR)]);
10290
11229
  // With writers gated and work idle, release our WAL connection for the
10291
11230
  // consistent snapshot. Store reopens it lazily after maintenance.
10292
11231
  closeMessageDb();
10293
11232
  },
10294
11233
  }, keepLocked),
10295
11234
  });
11235
+ // Route modules (server/routes/README.md). `workspaceAccess` is assigned at
11236
+ // boot, after this line, so the dependency reads it per request.
11237
+ ROUTES.push(createHostedSlackRoutes({ bot: (id) => store.bot(id), hostedReady: () => Boolean(workspaceAccess) && entitled("admin") }));
10296
11238
  const toolResults = new ToolResults();
10297
11239
  const handleRequest = async (req, res) => {
10298
11240
  let url;
@@ -10317,6 +11259,8 @@ const handleRequest = async (req, res) => {
10317
11259
  return json(res, 503, { error: "Hosted workspace readiness is unavailable." });
10318
11260
  }
10319
11261
  res.setHeader(HOSTED_CONTRACT_HEADER, String(HOSTED_CONTRACT_VERSION));
11262
+ if (hostedModels)
11263
+ res.setHeader(HOSTED_MODEL_POLICY_HEADER, "1");
10320
11264
  return json(res, 200, { ok: true, service: "openmausbot", membershipAuthority: "portal", workspace: hosted.workspace, ...HOSTED_CONTRACT_METADATA });
10321
11265
  }
10322
11266
  // Hosted workspaces have one sign-in authority. A missing optional layer
@@ -10333,6 +11277,13 @@ const handleRequest = async (req, res) => {
10333
11277
  return json(res, 503, { error: "Workspace sign-in is unavailable." });
10334
11278
  }
10335
11279
  }
11280
+ // An enrolled organisation that turns remote access off refuses new pairing
11281
+ // codes and new remote sessions. Existing sessions and this app are unchanged.
11282
+ if (method === "POST" && ["/api/auth/pair", "/api/pair", "/api/auth/pairing", "/api/auth/email/start", "/api/auth/email/verify"].includes(path)) {
11283
+ const refusal = managedPolicy.remoteAccessRefusal();
11284
+ if (refusal)
11285
+ return json(res, 403, { error: refusal, code: "managed_policy" });
11286
+ }
10336
11287
  // ── who is asking (server/request-auth.ts) ──────────────────────────
10337
11288
  // Two public routes come first: what this server is, and turning a pairing
10338
11289
  // code into a session. Everything else needs the loopback owner or a
@@ -10458,6 +11409,8 @@ const handleRequest = async (req, res) => {
10458
11409
  loopbackMutationToken: desktopMutationToken,
10459
11410
  companionMutationToken,
10460
11411
  features: { sharedComputers: sharedComputersEnabled(cfg) },
11412
+ loopbackTrust: LOOPBACK.trust,
11413
+ cliOwnerToken,
10461
11414
  });
10462
11415
  // The browser's cookie carries the term it was set with, and the
10463
11416
  // session's term slides on use (sessions.ts `renew`), so re-issue the
@@ -10494,6 +11447,20 @@ const handleRequest = async (req, res) => {
10494
11447
  if (failure)
10495
11448
  return json(res, failure.status, { error: failure.error });
10496
11449
  }
11450
+ // Bot visibility: a member reaches a restricted bot, its threads, rooms,
11451
+ // routines and files exactly as they reach an id that does not exist.
11452
+ // Lists and live frames are narrowed where they are built, below.
11453
+ const visible = visibleTo(viewerFor(auth));
11454
+ if (!visible.everything) {
11455
+ const subject = pathSubject(path);
11456
+ if (subject && !subjectVisible(subject, visible))
11457
+ return json(res, 404, { error: notFoundFor(subject) });
11458
+ // Every bot a member is sent (a read receipt, a profile or display
11459
+ // edit, a new or switched task) comes without its audience list or
11460
+ // the teammates they cannot see, whichever route answers.
11461
+ onJsonBody(res, (body) => memberBody(body, visible));
11462
+ }
11463
+ beginAdminAudit(req, res, method, path, auth);
10497
11464
  if (method === "POST" && path === "/api/workspace-backup/restore" && teamComputers.list().some(computer => computer.section !== null)) {
10498
11465
  return json(res, 409, { error: "Unassign team computers before restoring a workspace; restored team names must not gain access to existing desktops" });
10499
11466
  }
@@ -10504,10 +11471,14 @@ const handleRequest = async (req, res) => {
10504
11471
  if (path.startsWith("/api/") && path !== "/api/events" && path !== "/api/health" && !path.startsWith("/api/shared-computers/") && !isWorkspaceBackupSessionControl(method, path)) {
10505
11472
  releaseWorkspaceRequest = workspaceMaintenance.request();
10506
11473
  }
11474
+ // New routes live in modules registered in server/routes/table.ts and
11475
+ // are tried here, behind the gate above; do not add route `if`s below.
11476
+ if (await dispatchRoutes(ROUTES, { req, res, url, path, method, auth, json, readBody }))
11477
+ return;
10507
11478
  // ── sessions: who am I, tickets, pairing and revocation ─────────────
10508
11479
  if (method === "GET" && path === "/api/auth/session") {
10509
11480
  return json(res, 200, auth.kind === "loopback"
10510
- ? { kind: "loopback", scopes: auth.scopes, environmentId: ENVIRONMENT_ID }
11481
+ ? { kind: "loopback", scopes: auth.scopes, environmentId: ENVIRONMENT_ID, ...(auth.trust ? { trust: auth.trust } : {}) }
10511
11482
  : {
10512
11483
  kind: "session",
10513
11484
  id: auth.session.id,
@@ -10518,6 +11489,9 @@ const handleRequest = async (req, res) => {
10518
11489
  environmentId: ENVIRONMENT_ID,
10519
11490
  // the account behind the session, when it came from a sign-in
10520
11491
  ...(auth.session.email ? { email: auth.session.email } : {}),
11492
+ // a hosted team workspace: the web UI's first run skips the
11493
+ // desktop-only beats there. Absent everywhere else.
11494
+ ...(HOSTED_WORKSPACE ? { hosted: true } : {}),
10521
11495
  });
10522
11496
  }
10523
11497
  if (method === "POST" && path === "/api/auth/stream-ticket") {
@@ -10620,8 +11594,14 @@ const handleRequest = async (req, res) => {
10620
11594
  sharedComputerControl.release(body.id);
10621
11595
  else if (body.action === "renew")
10622
11596
  sharedComputerControl.renew(body.id);
10623
- else
11597
+ else {
11598
+ // The desktop's own screen, lent to another computer: the organisation's
11599
+ // "this computer" setting applies here as it does to bot turns.
11600
+ const refusal = managedPolicy.computerRefusal("thisComputer");
11601
+ if (refusal)
11602
+ return json(res, 403, { error: refusal, code: "managed_policy" });
10624
11603
  sharedComputerControl.acquire(body.id);
11604
+ }
10625
11605
  return json(res, 200, { ok: true });
10626
11606
  }
10627
11607
  // A paired desktop registers only its own outbound connector. A second,
@@ -10721,6 +11701,9 @@ const handleRequest = async (req, res) => {
10721
11701
  if (!internalSender) {
10722
11702
  return json(res, 401, { error: "unauthorized" });
10723
11703
  }
11704
+ if (internalCapability.generation === EXTERNAL_RUNTIME_GENERATION && !externalRuntimeMayCall(method, path)) {
11705
+ return json(res, 403, { error: "an external runtime can only list, ask and delegate to its peers and read its delegations" });
11706
+ }
10724
11707
  const requiredCapabilityKind = path === "/api/internal/hook"
10725
11708
  ? "hooks"
10726
11709
  : path === "/api/internal/browser/mcp"
@@ -10842,6 +11825,14 @@ const handleRequest = async (req, res) => {
10842
11825
  const result = toolResults.read(internalCapability, id, offset);
10843
11826
  return result ? json(res, 200, result) : json(res, 404, { error: "Saved result unavailable in this bot's conversation, expired, or offset out of range. Do not repeat an action to retrieve its output." });
10844
11827
  }
11828
+ // Notes written from a room fewer people can see than this bot would
11829
+ // carry that room's words to everyone who can see the bot.
11830
+ if ((path === "/api/internal/memory" || path === "/api/internal/memory/log") && method === "POST") {
11831
+ const room = store.groupByThread(internalCapability.threadId);
11832
+ if (room && !roomFeedsBot(room, internalSender)) {
11833
+ return json(res, 403, { error: "This room is visible to fewer people than you are, so notes from it can't go into your memory. Keep them in the room." });
11834
+ }
11835
+ }
10845
11836
  if (method === "POST" && path === "/api/internal/memory") {
10846
11837
  const body = await readInternalBody();
10847
11838
  const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText }, { source: memorySource() });
@@ -11165,7 +12156,7 @@ const handleRequest = async (req, res) => {
11165
12156
  return json(res, 403, { error: "Only an active Chief may plan team setup" });
11166
12157
  return json(res, 200, {
11167
12158
  teams: teamSetupTeams().filter((name) => canAccessTeam(chief, name)),
11168
- bots: store.bots.filter((bot) => !bot.hidden && canAccessTeam(chief, bot.section) && (bot.id === chief.id || peerAllowed(chief, bot.id)))
12159
+ bots: store.bots.filter((bot) => !bot.hidden && canAccessTeam(chief, bot.section) && (bot.id === chief.id || peerAllowed(chief, bot)))
11169
12160
  .map((bot) => ({ id: bot.id, name: bot.name, title: bot.title, section: bot.section ?? "", modelSelection: bot.modelSelection })),
11170
12161
  instances: instances.map((instance) => ({ instanceId: instance.instanceId, driverKind: instance.driverKind, displayName: instance.displayName,
11171
12162
  state: instance.snapshot.state, models: instance.models, effortLevels: instance.capabilities?.effortLevels ?? [] })),
@@ -11240,7 +12231,7 @@ const handleRequest = async (req, res) => {
11240
12231
  const sinceRaw = url.searchParams.get("since");
11241
12232
  const untilRaw = url.searchParams.get("until");
11242
12233
  const since = sinceRaw ? parseSince(sinceRaw, now) : null;
11243
- const until = untilRaw ? parseSince(untilRaw, now) : null;
12234
+ const until = untilRaw ? parseUntil(untilRaw, now) : null;
11244
12235
  if (sinceRaw && since === null)
11245
12236
  return json(res, 400, { error: "since must be a date, or a span like 24h, 3d, today, yesterday" });
11246
12237
  if (untilRaw && until === null)
@@ -11268,7 +12259,9 @@ const handleRequest = async (req, res) => {
11268
12259
  // Still own-bot: another bot's threads never enter this list.
11269
12260
  const roomByThread = new Map();
11270
12261
  for (const group of store.groups) {
11271
- if (!group.memberIds.includes(from.id))
12262
+ // A room fewer people can see than this bot stays out of its recall
12263
+ // (bot-visibility.ts roomFeeds): its lines would otherwise reach them.
12264
+ if (!group.memberIds.includes(from.id) || !roomFeedsBot(group, from))
11272
12265
  continue;
11273
12266
  roomByThread.set(group.threadId, group);
11274
12267
  for (const task of group.tasks ?? [])
@@ -11461,7 +12454,7 @@ const handleRequest = async (req, res) => {
11461
12454
  // The sender's allow-list, when it has one. Checked here rather than
11462
12455
  // trusted from the roster: the tool call carries a bot id, and an id
11463
12456
  // the model held from an earlier turn must not outlive the grant.
11464
- if (!peerAllowed(from, target.id)) {
12457
+ if (!peerAllowed(from, target)) {
11465
12458
  return json(res, 403, { error: `that bot is not on this bot's allowed peers. ${PEER_ACCESS_HELP}` });
11466
12459
  }
11467
12460
  const fromThreadId = internalCapability.threadId;
@@ -11484,6 +12477,12 @@ const handleRequest = async (req, res) => {
11484
12477
  const queued = queueDelegation(commsBus, from, { toBotId, message, reason: "asked while busy", depth, approvalAlreadyGranted }, MAX_COMMS_DEPTH, fromThreadId);
11485
12478
  if (queued.result !== "ok" || !queued.id)
11486
12479
  return json(res, 200, { busy: true });
12480
+ // An external caller has no source turn whose completion can
12481
+ // begin the busy wait. Drain now so the target's idle release
12482
+ // retries this handoff, retaining any approval already granted.
12483
+ if (internalCapability.generation === EXTERNAL_RUNTIME_GENERATION && !threadBusy(from.id, fromThreadId)) {
12484
+ drainThreadDelegations(fromThreadId);
12485
+ }
11487
12486
  return json(res, 200, { busy: true, taskId: queued.id, toBotName: target.name });
11488
12487
  };
11489
12488
  if (target.busy)
@@ -11515,7 +12514,7 @@ const handleRequest = async (req, res) => {
11515
12514
  if (!canAccessTeam(freshFrom, freshTarget.section) || freshTarget.hidden) {
11516
12515
  return json(res, 200, { error: "that bot moved to a different section" });
11517
12516
  }
11518
- if (!peerAllowed(freshFrom, freshTarget.id)) {
12517
+ if (!peerAllowed(freshFrom, freshTarget)) {
11519
12518
  return json(res, 200, { error: "that bot is no longer an allowed peer" });
11520
12519
  }
11521
12520
  // Membership can be revoked while the card is open: re-check the
@@ -11604,11 +12603,17 @@ const handleRequest = async (req, res) => {
11604
12603
  // Bounded long-poll: the delegating bot parks ONE cheap HTTP request
11605
12604
  // here instead of burning a model inference per status check.
11606
12605
  for (;;) {
12606
+ requireActiveInternalCapability();
11607
12607
  const receipt = findDelegationReceipt(taskId);
11608
12608
  if (receipt) {
11609
12609
  if (receipt.sourceThreadId !== fromThreadId) {
11610
12610
  return json(res, 403, { error: "that task belongs to a different conversation" });
11611
12611
  }
12612
+ const sender = store.bot(from.id);
12613
+ const target = store.bot(receipt.toBotId);
12614
+ if (!sender || (target && !canReachPeer(sender, target))) {
12615
+ return json(res, 403, { error: "Result withheld: team access changed while the teammate was working" });
12616
+ }
11612
12617
  return json(res, 200, { status: receipt.status, toBotName: receipt.toBotName, result: receipt.result ?? "" });
11613
12618
  }
11614
12619
  const stillQueued = pendingDelegationInfo(taskId);
@@ -11619,8 +12624,13 @@ const handleRequest = async (req, res) => {
11619
12624
  return json(res, 404, { error: "unknown task id — delegation receipts are kept for about 48 hours" });
11620
12625
  if (owner !== fromThreadId)
11621
12626
  return json(res, 403, { error: "that task belongs to a different conversation" });
12627
+ const toBotId = stillQueued?.toBotId ?? running?.toBotId ?? "";
12628
+ const sender = store.bot(from.id);
12629
+ const target = store.bot(toBotId);
12630
+ if (!sender || (target && !canReachPeer(sender, target))) {
12631
+ return json(res, 403, { error: "Result withheld: team access changed while the teammate was working" });
12632
+ }
11622
12633
  if (Date.now() >= deadline) {
11623
- const toBotId = stillQueued?.toBotId ?? running?.toBotId ?? "";
11624
12634
  if (running && runningEntry) {
11625
12635
  const recent = summarizeDelegatedActivity(store.messagesFor(runningEntry[0]), running.startedAtMs ?? Date.now());
11626
12636
  return json(res, 200, {
@@ -11665,7 +12675,7 @@ const handleRequest = async (req, res) => {
11665
12675
  const target = store.bot(botId);
11666
12676
  if (!target || target.id === from.id)
11667
12677
  return json(res, 404, { error: "no such teammate" });
11668
- if (target.hidden || !canAccessTeam(from, target.section) || !peerAllowed(from, target.id)) {
12678
+ if (target.hidden || !canAccessTeam(from, target.section) || !peerAllowed(from, target)) {
11669
12679
  return json(res, 403, { error: "that bot is not on this Chief's team — call list_bots for the ones you can reach" });
11670
12680
  }
11671
12681
  if (store.groupByThread(threadId))
@@ -11716,7 +12726,7 @@ const handleRequest = async (req, res) => {
11716
12726
  if (!canAccessTeam(from, target.section) || target.hidden) {
11717
12727
  return json(res, 403, { error: `that bot belongs to a different section or is unavailable. ${PEER_ACCESS_HELP}` });
11718
12728
  }
11719
- if (!peerAllowed(from, target.id)) {
12729
+ if (!peerAllowed(from, target)) {
11720
12730
  return json(res, 403, { error: `that bot is not on this bot's allowed peers. ${PEER_ACCESS_HELP}` });
11721
12731
  }
11722
12732
  const fromThreadId = internalCapability.threadId;
@@ -11736,6 +12746,21 @@ const handleRequest = async (req, res) => {
11736
12746
  return json(res, 200, { error: said[queued.result === "ok" ? "no_target" : queued.result] });
11737
12747
  }
11738
12748
  const targetName = store.bot(toBotId)?.name ?? toBotId;
12749
+ // The queue normally drains when the source thread's turn completes. A
12750
+ // caller this server did not spawn (an external runtime) has no live
12751
+ // turn here, so its handoff would wait for an unrelated turn on that
12752
+ // thread to settle. Nothing in flight on the source thread means there
12753
+ // is nothing to wait for: drain now.
12754
+ if (internalCapability.generation === EXTERNAL_RUNTIME_GENERATION && !threadBusy(from.id, fromThreadId)) {
12755
+ drainThreadDelegations(fromThreadId);
12756
+ return json(res, 200, {
12757
+ queued: true,
12758
+ taskId: queued.id,
12759
+ message: peerReviewRequired(from, fromThreadId)
12760
+ ? `Queued for review — @${targetName} will pick it up once the user approves.`
12761
+ : `Delegated — @${targetName} is picking it up now.`,
12762
+ });
12763
+ }
11739
12764
  return json(res, 200, {
11740
12765
  queued: true,
11741
12766
  taskId: queued.id,
@@ -11844,6 +12869,11 @@ const handleRequest = async (req, res) => {
11844
12869
  target.threadId = resolved.task.threadId;
11845
12870
  if (resolved.created)
11846
12871
  createdThread = resolved.task.threadId;
12872
+ // A work thread carries one assignment: it is for the person
12873
+ // this coordination serves. The durable pair conversation is
12874
+ // shared by every assignment between two bots, so it names nobody.
12875
+ if (resolved.created && resolved.task.openedBy?.kind === "work")
12876
+ threadStarters.set(resolved.task.threadId, threadPersonKey(address.threadId));
11847
12877
  if (delegatedFullAccess(internalSender, internalCapability.threadId, store.bot(target.botId))) {
11848
12878
  grantDelegatedFullAccess(internalSender, store.bot(target.botId), target.threadId);
11849
12879
  }
@@ -12074,6 +13104,7 @@ const handleRequest = async (req, res) => {
12074
13104
  const task = store.createTask(from.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() });
12075
13105
  if (!task)
12076
13106
  return json(res, 500, { error: "couldn't create that thread" });
13107
+ threadStarters.set(task.threadId, threadPersonKey(fromThreadId));
12077
13108
  internalCapability.openedThreads += 1;
12078
13109
  const chip = {
12079
13110
  role: "bot",
@@ -12109,12 +13140,14 @@ const handleRequest = async (req, res) => {
12109
13140
  if (!canAccessTeam(from, target.section) || target.hidden) {
12110
13141
  return json(res, 403, { error: `that bot belongs to a different section or is unavailable. ${PEER_ACCESS_HELP}` });
12111
13142
  }
12112
- if (!peerAllowed(from, target.id)) {
13143
+ if (!peerAllowed(from, target)) {
12113
13144
  return json(res, 403, { error: `that bot is not on this bot's allowed peers. ${PEER_ACCESS_HELP}` });
12114
13145
  }
12115
13146
  const task = store.createTask(target.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() });
12116
13147
  if (!task)
12117
13148
  return json(res, 500, { error: "couldn't create that thread" });
13149
+ // The work is still for the person whose request the opener is on.
13150
+ threadStarters.set(task.threadId, threadPersonKey(fromThreadId));
12118
13151
  if (delegatedFullAccess(from, fromThreadId, target))
12119
13152
  grantDelegatedFullAccess(from, target, task.threadId);
12120
13153
  const queued = queueDelegation(commsBus, from, { toBotId: target.id, message, depth, targetThreadId: task.threadId }, MAX_COMMS_DEPTH, fromThreadId);
@@ -12198,6 +13231,8 @@ const handleRequest = async (req, res) => {
12198
13231
  description: instructions,
12199
13232
  modelSelection: { ...chief.modelSelection },
12200
13233
  section: chief.section,
13234
+ // exactly the Chief's audience: a restricted Chief never makes a bot everyone sees
13235
+ ...(chief.visibility ? { visibility: chief.visibility } : {}),
12201
13236
  }, { seedMessages: false });
12202
13237
  const safeBot = store.patchBot(created.id, {
12203
13238
  composio: false,
@@ -12515,11 +13550,11 @@ const handleRequest = async (req, res) => {
12515
13550
  // transcripts: this projection carries only ids, status relationships,
12516
13551
  // optional delegation labels, and timestamps.
12517
13552
  if (method === "GET" && path === "/api/team-map") {
12518
- const visible = new Set(store.bots.filter((bot) => !bot.hidden).map((bot) => bot.id));
13553
+ const mapped = new Set(store.bots.filter((bot) => !bot.hidden && visible.bot(bot.id)).map((bot) => bot.id));
12519
13554
  const collaborations = store.groups
12520
13555
  .filter((group) => group.dm === true &&
12521
13556
  group.memberIds.length === 2 &&
12522
- group.memberIds.every((botId) => visible.has(botId)))
13557
+ group.memberIds.every((botId) => mapped.has(botId)))
12523
13558
  .map((group) => ({
12524
13559
  groupId: group.id,
12525
13560
  botIds: [group.memberIds[0], group.memberIds[1]],
@@ -12527,17 +13562,17 @@ const handleRequest = async (req, res) => {
12527
13562
  }))
12528
13563
  .sort((a, b) => b.lastAt - a.lastAt);
12529
13564
  const queued = pendingDelegationSnapshot().flatMap((item) => {
12530
- if (!visible.has(item.sourceBotId) || !visible.has(item.toBotId))
13565
+ if (!mapped.has(item.sourceBotId) || !mapped.has(item.toBotId))
12531
13566
  return [];
12532
13567
  return [{ sourceBotId: item.sourceBotId, targetBotId: item.toBotId, reason: item.reason }];
12533
13568
  });
12534
13569
  const running = [...delegationWatch.entries()].flatMap(([threadId, watch]) => {
12535
- if (!visible.has(watch.toBotId))
13570
+ if (!mapped.has(watch.toBotId))
12536
13571
  return [];
12537
13572
  const channel = watch.channelId ? store.group(watch.channelId) : undefined;
12538
13573
  const sourceBotId = watch.sourceBotId ??
12539
13574
  channel?.memberIds.find((botId) => botId !== watch.toBotId);
12540
- if (!sourceBotId || !visible.has(sourceBotId))
13575
+ if (!sourceBotId || !mapped.has(sourceBotId))
12541
13576
  return [];
12542
13577
  return [{ sourceBotId, targetBotId: watch.toBotId, threadId, groupId: channel?.id }];
12543
13578
  });
@@ -12550,12 +13585,17 @@ const handleRequest = async (req, res) => {
12550
13585
  const from = fromParam == null ? undefined : Number(fromParam);
12551
13586
  const to = toParam == null ? undefined : Number(toParam);
12552
13587
  return json(res, 200, {
12553
- routines: routines.listRoutines(),
12554
- runs: routines.listRuns(from != null && Number.isFinite(from) ? from : undefined, to != null && Number.isFinite(to) ? to : undefined),
13588
+ routines: routines.listRoutines().filter((routine) => routineVisible(routine, visible)),
13589
+ runs: routines.listRuns(from != null && Number.isFinite(from) ? from : undefined, to != null && Number.isFinite(to) ? to : undefined)
13590
+ .filter((run) => routineVisible(run, visible)),
12555
13591
  });
12556
13592
  }
12557
13593
  if (path === "/api/routines" && method === "POST") {
12558
- return json(res, 201, { routine: routines.create(await readBody(req)) });
13594
+ const body = await readBody(req);
13595
+ const hidden = hiddenRoutineTarget(body, visible);
13596
+ if (hidden)
13597
+ return json(res, 404, { error: hidden });
13598
+ return json(res, 201, { routine: routines.create(body) });
12559
13599
  }
12560
13600
  // The desktop shell polls this to decide whether to hold the computer
12561
13601
  // awake: a run in flight, or a routine due within the hour.
@@ -12569,7 +13609,11 @@ const handleRequest = async (req, res) => {
12569
13609
  }
12570
13610
  routineMatch = path.match(/^\/api\/routines\/([\w-]+)$/);
12571
13611
  if (routineMatch && method === "PATCH") {
12572
- const routine = routines.update(routineMatch[1], await readBody(req));
13612
+ const body = await readBody(req);
13613
+ const hidden = hiddenRoutineTarget(body, visible);
13614
+ if (hidden)
13615
+ return json(res, 404, { error: hidden });
13616
+ const routine = routines.update(routineMatch[1], body);
12573
13617
  return routine ? json(res, 200, { routine }) : json(res, 404, { error: "no such routine" });
12574
13618
  }
12575
13619
  if (routineMatch && method === "DELETE") {
@@ -12577,6 +13621,9 @@ const handleRequest = async (req, res) => {
12577
13621
  ? json(res, 200, { ok: true })
12578
13622
  : json(res, 404, { error: "no such routine" });
12579
13623
  }
13624
+ if (path === "/api/routine-runs/seen-all" && method === "POST") {
13625
+ return json(res, 200, { runs: routines.markAllSeen() });
13626
+ }
12580
13627
  const runMatch = path.match(/^\/api\/routine-runs\/([\w-]+)\/(cancel|seen)$/);
12581
13628
  if (runMatch && method === "POST") {
12582
13629
  const run = runMatch[2] === "cancel"
@@ -12590,7 +13637,11 @@ const handleRequest = async (req, res) => {
12590
13637
  }
12591
13638
  if (path === "/api/calendar-calls" && method === "POST") {
12592
13639
  try {
12593
- return json(res, 201, { call: calendarCalls.create(await readBody(req)) });
13640
+ const body = await readBody(req);
13641
+ const conflict = calendarAudienceConflict(body);
13642
+ if (conflict)
13643
+ return json(res, 400, { error: conflict });
13644
+ return json(res, 201, { call: calendarCalls.create(body) });
12594
13645
  }
12595
13646
  catch (error) {
12596
13647
  throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 400 });
@@ -12612,7 +13663,11 @@ const handleRequest = async (req, res) => {
12612
13663
  if (!calendarCalls.get(calendarCallMatch[1]))
12613
13664
  return json(res, 404, { error: "no such scheduled call" });
12614
13665
  try {
12615
- return json(res, 200, { call: calendarCalls.update(calendarCallMatch[1], await readBody(req)) });
13666
+ const body = await readBody(req);
13667
+ const conflict = calendarAudienceConflict(body);
13668
+ if (conflict)
13669
+ return json(res, 400, { error: conflict });
13670
+ return json(res, 200, { call: calendarCalls.update(calendarCallMatch[1], body) });
12616
13671
  }
12617
13672
  catch (error) {
12618
13673
  throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 400 });
@@ -12628,7 +13683,9 @@ const handleRequest = async (req, res) => {
12628
13683
  // second, webhook-only loopback listener so Funnel or a future hosted
12629
13684
  // relay never has to expose the rest of OpenMausBot's control surface.
12630
13685
  if (path === "/api/webhooks" && method === "GET") {
12631
- return json(res, 200, { webhooks: webhooks.list(), attempts: webhooks.listAttempts(), ingress: webhookIngressStatus() });
13686
+ const shownHooks = webhooks.list().filter((webhook) => visible.bot(webhook.botId));
13687
+ const shownIds = new Set(shownHooks.map((webhook) => webhook.id));
13688
+ return json(res, 200, { webhooks: shownHooks, attempts: webhooks.listAttempts().filter((attempt) => visible.everything || shownIds.has(attempt.webhookId)), ingress: webhookIngressStatus() });
12632
13689
  }
12633
13690
  if (path === "/api/webhooks" && method === "POST") {
12634
13691
  const created = webhooks.create(await readBody(req));
@@ -12716,11 +13773,19 @@ const handleRequest = async (req, res) => {
12716
13773
  return json(res, 405, { error: "method not allowed" });
12717
13774
  }
12718
13775
  if (method === "GET" && path === "/api/events") {
13776
+ const viewer = viewerFor(auth);
13777
+ const visibleNow = visibleTo(viewer);
12719
13778
  const client = {
12720
13779
  res,
12721
13780
  admin: auth.scopes.includes("admin"),
12722
13781
  screens: url.searchParams.get("screens") !== "off",
12723
13782
  backpressured: false,
13783
+ viewer,
13784
+ // What this person's hydration (GET /api/bots, narrowed the same way) showed them.
13785
+ seen: viewer.kind === "all" ? { bots: new Set(), groups: new Set() } : {
13786
+ bots: new Set(store.bots.filter((bot) => visibleNow.bot(bot.id)).map((bot) => bot.id)),
13787
+ groups: new Set(store.groups.filter((group) => visibleNow.group(group.id)).map((group) => group.id)),
13788
+ },
12724
13789
  };
12725
13790
  if (auth.kind === "session")
12726
13791
  client.sessionId = auth.session.id;
@@ -12746,7 +13811,11 @@ const handleRequest = async (req, res) => {
12746
13811
  // would leave a permanent hole in its state.
12747
13812
  const resumed = since !== null &&
12748
13813
  since <= lastSeq &&
12749
- (replayBuffer.length === 0 ? since === lastSeq : replayBuffer[0].seq <= since + 1);
13814
+ (replayBuffer.length === 0 ? since === lastSeq : replayBuffer[0].seq <= since + 1) &&
13815
+ // A member whose cursor predates a bot's audience change gets a fresh
13816
+ // snapshot: a replay is judged by today's audience and could neither
13817
+ // withdraw what the change hid nor bring back whole what it showed.
13818
+ !(viewer.kind === "member" && audienceChangedAt > 0 && since <= audienceChangedAt);
12750
13819
  res.write(`data: ${JSON.stringify({
12751
13820
  kind: "hello",
12752
13821
  cursor: `${STREAM_ID}:${lastSeq}`,
@@ -12757,8 +13826,14 @@ const handleRequest = async (req, res) => {
12757
13826
  })}\n\n`);
12758
13827
  if (resumed) {
12759
13828
  for (const buffered of replayBuffer) {
12760
- const frame = client.admin ? buffered.frame : buffered.clientFrame;
12761
- if (buffered.seq > since && frame && wants(client, buffered.kind))
13829
+ if (buffered.seq <= since || !wants(client, buffered.kind))
13830
+ continue;
13831
+ const frame = client.admin
13832
+ ? buffered.frame
13833
+ : buffered.payload
13834
+ ? memberFrame(client, buffered.seq, buffered.payload, buffered.clientFrame)
13835
+ : buffered.clientFrame;
13836
+ if (frame)
12762
13837
  res.write(frame);
12763
13838
  }
12764
13839
  }
@@ -12807,16 +13882,23 @@ const handleRequest = async (req, res) => {
12807
13882
  // tasks stays explicit, because that is the one field publicBot() adds
12808
13883
  // that messagePage() does not: wireBot() omits the key entirely for a
12809
13884
  // bot record carrying no tasks, where publicBot() always sent [].
13885
+ // A member on a workspace with a restricted bot gets only what they may
13886
+ // see (bot-visibility.ts); for everyone else these filters keep all.
13887
+ const shownBots = store.bots.filter((bot) => visible.bot(bot.id));
13888
+ const queued = publicBotQueuedMessages();
12810
13889
  return json(res, 200, {
12811
- bots: store.bots.map((bot) => ({
13890
+ bots: shownBots.map((bot) => memberBot({
12812
13891
  ...wireBot(bot),
12813
13892
  tasks: store.tasks(bot.id).map(wireTask),
12814
13893
  ...messagePage(bot.threadId, limit),
12815
- })),
12816
- botQueuedMessages: publicBotQueuedMessages(),
12817
- sections: store.sections,
12818
- groups: store.groups.map((g) => ({ ...publicGroupState(g), ...messagePage(g.threadId, limit) })),
12819
- computerControl: Object.fromEntries(store.bots.map((bot) => {
13894
+ }, visible)),
13895
+ botQueuedMessages: visible.everything ? queued : Object.fromEntries(Object.entries(queued).filter(([threadId]) => visible.thread(threadId))),
13896
+ sections: visible.sections(store.sections),
13897
+ groups: store.groups.filter((g) => visible.group(g.id)).map((g) => {
13898
+ const room = { ...publicGroupState(g), ...messagePage(g.threadId, limit) };
13899
+ return visible.everything ? room : memberGroup(room);
13900
+ }),
13901
+ computerControl: Object.fromEntries(shownBots.map((bot) => {
12820
13902
  const snapshot = botComputerControlSnapshot(bot.id);
12821
13903
  return [bot.id, { held: snapshot.held, helpReason: snapshot.helpReason }];
12822
13904
  })),
@@ -13124,7 +14206,7 @@ const handleRequest = async (req, res) => {
13124
14206
  const rawLimit = url.searchParams.get("limit");
13125
14207
  const limit = rawLimit ? Math.min(Math.max(Number(rawLimit) || 0, 1), 100) : 40;
13126
14208
  const threadId = url.searchParams.get("threadId")?.trim() || undefined;
13127
- if (threadId && !store.botByThread(threadId) && !store.groupByThread(threadId)) {
14209
+ if (threadId && ((!store.botByThread(threadId) && !store.groupByThread(threadId)) || !visible.thread(threadId))) {
13128
14210
  return json(res, 404, { error: "no such conversation" });
13129
14211
  }
13130
14212
  // whether each hit sits on its thread's visible branch — a click on
@@ -13136,7 +14218,11 @@ const handleRequest = async (req, res) => {
13136
14218
  activePaths.set(threadId, (ids = new Set(store.activePath(threadId).map((m) => m.id))));
13137
14219
  return ids.has(messageId);
13138
14220
  };
13139
- const hits = searchMessages(q, limit, threadId)
14221
+ // A member who may not see every bot: look further, then keep only
14222
+ // hits in conversations they can open.
14223
+ const hits = searchMessages(q, visible.everything ? limit : Math.min(limit * 10, 1_000), threadId)
14224
+ .filter((hit) => visible.thread(hit.threadId))
14225
+ .slice(0, limit)
13140
14226
  .map((hit) => {
13141
14227
  const bot = store.botByThread(hit.threadId);
13142
14228
  const group = bot ? undefined : store.groupByThread(hit.threadId);
@@ -13208,7 +14294,13 @@ const handleRequest = async (req, res) => {
13208
14294
  }
13209
14295
  // ── channels (persisted internally as groups) ───────────────────────
13210
14296
  if (method === "POST" && path === "/api/groups") {
13211
- const group = createChannel(await readBody(req));
14297
+ const body = await readBody(req);
14298
+ // A member may only put bots they can see in a room: the room would
14299
+ // otherwise show them a restricted bot (bot-visibility.ts).
14300
+ const hiddenMember = Array.isArray(body?.memberIds) ? body.memberIds.find((id) => typeof id === "string" && store.bot(id) && !visible.bot(id)) : undefined;
14301
+ if (hiddenMember)
14302
+ return json(res, 400, { error: `unknown channel member: ${String(hiddenMember)}` });
14303
+ const group = createChannel(body);
13212
14304
  return json(res, 201, { group: { ...publicGroupState(group), messages: [] } });
13213
14305
  }
13214
14306
  if (method === "POST" && path === "/api/teams/export") {
@@ -13350,12 +14442,31 @@ const handleRequest = async (req, res) => {
13350
14442
  projectCwd = validated.cwd;
13351
14443
  }
13352
14444
  }
14445
+ // Who may see the imported bots, from their first moment: the admin's
14446
+ // choice (?visibility=admins, or a JSON value), never the file's.
14447
+ const rawVisibility = url.searchParams.get("visibility");
14448
+ let importVisibility;
14449
+ if (rawVisibility !== null) {
14450
+ let value = rawVisibility;
14451
+ if (rawVisibility.trim().startsWith("{")) {
14452
+ try {
14453
+ value = JSON.parse(rawVisibility);
14454
+ }
14455
+ catch {
14456
+ /* parseVisibility refuses the raw text with a plain sentence */
14457
+ }
14458
+ }
14459
+ const parsed = parseVisibility(value);
14460
+ if (!parsed.ok)
14461
+ return json(res, 400, { error: parsed.error });
14462
+ importVisibility = parsed.visibility;
14463
+ }
13353
14464
  const body = await readBody(req, MAX_TEAM_BACKUP_BYTES);
13354
14465
  if (body?.format === "openmaus.backup") {
13355
14466
  if (importMode !== "add")
13356
14467
  return json(res, 400, { error: "Import backups alongside your existing bots; project mode is only for templates" });
13357
14468
  try {
13358
- const imported = importTeamBackup(store, routines, body, await defaultSelection());
14469
+ const imported = importTeamBackup(store, routines, body, await defaultSelection(), { visibility: importVisibility });
13359
14470
  const bots = imported.bots.map((bot) => publicBot(bot));
13360
14471
  const groups = imported.groups.map((group) => ({ ...publicGroupState(group), ...messagePage(group.threadId, undefined) }));
13361
14472
  for (const bot of bots)
@@ -13418,6 +14529,7 @@ const handleRequest = async (req, res) => {
13418
14529
  ...importedMemberProfile(member, takenNames),
13419
14530
  modelSelection: selection,
13420
14531
  section: importSection,
14532
+ ...(importVisibility ? { visibility: importVisibility } : {}),
13421
14533
  }, { seedMessages: false });
13422
14534
  importedBots.push(created);
13423
14535
  const installedPlaybooks = source.playbookKeys.flatMap((key) => {
@@ -13614,6 +14726,9 @@ const handleRequest = async (req, res) => {
13614
14726
  const task = store.createGroupTask(group.id, request.data.title);
13615
14727
  if (!task)
13616
14728
  return json(res, 500, { error: "couldn't create that task" });
14729
+ // Who opened it decides who may answer its cards on a shared workspace.
14730
+ if (auth.kind === "session")
14731
+ threadStarters.set(task.threadId, personKey(auth.session));
13617
14732
  const fresh = groupWithThread(store.group(group.id));
13618
14733
  broadcast({ kind: "group", group: fresh });
13619
14734
  return json(res, 201, { group: fresh, task });
@@ -13669,15 +14784,43 @@ const handleRequest = async (req, res) => {
13669
14784
  return json(res, 404, { error: "no such channel" });
13670
14785
  if (group.dm)
13671
14786
  return json(res, 400, { error: "bot-to-bot channels keep one canonical conversation" });
13672
- if (channelTaskBlocked(group)) {
13673
- return json(res, 409, { error: "this channel is working or waiting on you — finish that turn first" });
13674
- }
13675
14787
  if (!body || typeof body !== "object" || Array.isArray(body)) {
13676
14788
  return json(res, 400, { error: "body must be a JSON object" });
13677
14789
  }
13678
- const task = store.renameGroupTask(m[1], m[2], String(body.title ?? ""));
13679
- if (!task)
14790
+ const allowed = new Set(["title", "pinned"]);
14791
+ if (Object.keys(body).some((key) => !allowed.has(key)))
14792
+ return json(res, 400, { error: "unsupported channel thread setting" });
14793
+ const existing = store.groupTaskByThread(group.id, m[2]);
14794
+ if (!existing)
13680
14795
  return json(res, 404, { error: "no such channel task" });
14796
+ const pinning = body.pinned !== undefined;
14797
+ const renaming = body.title !== undefined;
14798
+ if (!pinning && !renaming)
14799
+ return json(res, 400, { error: "unsupported channel thread setting" });
14800
+ if (pinning && typeof body.pinned !== "boolean")
14801
+ return json(res, 400, { error: "pinned must be a boolean" });
14802
+ if (renaming && typeof body.title !== "string")
14803
+ return json(res, 400, { error: "title must be a string" });
14804
+ // Echoing the current title lets a newer client pin on an older server
14805
+ // without the old handler turning a missing title into "Untitled".
14806
+ // That echo is not a rename. A real rename stays blocked while working.
14807
+ const titleChange = renaming && body.title !== existing.title;
14808
+ if (channelTaskBlocked(group) && !(pinning && !titleChange)) {
14809
+ return json(res, 409, { error: "this channel is working or waiting on you — finish that turn first" });
14810
+ }
14811
+ let task = existing;
14812
+ if (pinning) {
14813
+ const pinned = store.setGroupTaskPinned(group.id, m[2], body.pinned === true);
14814
+ if (!pinned)
14815
+ return json(res, 404, { error: "no such channel task" });
14816
+ task = pinned;
14817
+ }
14818
+ if (titleChange) {
14819
+ const renamed = store.renameGroupTask(group.id, m[2], body.title);
14820
+ if (!renamed)
14821
+ return json(res, 404, { error: "no such channel task" });
14822
+ task = renamed;
14823
+ }
13681
14824
  return json(res, 200, { task });
13682
14825
  }
13683
14826
  if (m && method === "DELETE") {
@@ -13772,7 +14915,7 @@ const handleRequest = async (req, res) => {
13772
14915
  return json(res, 400, { error: "threadId must be a task id" });
13773
14916
  }
13774
14917
  const threadId = body.threadId ?? group.threadId;
13775
- noteTurnTrigger(threadId, auth);
14918
+ const trigger = usageTriggerFor(auth);
13776
14919
  try {
13777
14920
  assertWithinBudget(cfg, DATA_DIR);
13778
14921
  }
@@ -13836,10 +14979,12 @@ const handleRequest = async (req, res) => {
13836
14979
  sendId,
13837
14980
  mode: channelMode,
13838
14981
  via,
14982
+ sender: messageSender(auth),
14983
+ trigger,
13839
14984
  });
13840
14985
  return { ok: true, queued: true, queueId: queued.id, threadId };
13841
14986
  }
13842
- const message = startGroupTurn(current.id, text, replyTo, sendId, channelMode, undefined, { via, sender: messageSender(auth) });
14987
+ const message = startGroupTurn(current.id, text, replyTo, sendId, channelMode, undefined, { via, sender: messageSender(auth), trigger });
13843
14988
  return { ok: true, threadId, message };
13844
14989
  });
13845
14990
  return json(res, 202, receipt);
@@ -13879,7 +15024,8 @@ const handleRequest = async (req, res) => {
13879
15024
  if (!ownsThread) {
13880
15025
  return json(res, 409, { error: "the channel switched tasks before it could receive the message" });
13881
15026
  }
13882
- noteTurnTrigger(targetThreadId, auth);
15027
+ // Steering folds words into the running room turn; who pressed Steer
15028
+ // does not re-book that turn (its sender was captured when it started).
13883
15029
  const current = store.group(group.id);
13884
15030
  if (!current)
13885
15031
  return json(res, 404, { error: "no such room" });
@@ -13937,6 +15083,7 @@ const handleRequest = async (req, res) => {
13937
15083
  queueId: head.id,
13938
15084
  via: head.via,
13939
15085
  steered: true,
15086
+ sender: head.sender,
13940
15087
  });
13941
15088
  settleHeldChannelQueueHead(held);
13942
15089
  return json(res, 200, {
@@ -14118,12 +15265,20 @@ const handleRequest = async (req, res) => {
14118
15265
  return json(res, checked.status, { error: checked.error });
14119
15266
  selection = checked.selection;
14120
15267
  }
15268
+ if (hostedModels && !hostedModels.allows(selection))
15269
+ return json(res, 400, { error: hostedModels.error() });
14121
15270
  // Keep the capacity check immediately beside the synchronous write.
14122
15271
  // Awaiting provider discovery before this point cannot race the cap.
14123
15272
  if (store.bots.length >= MAX_WORKSPACE_BOTS) {
14124
15273
  return json(res, 409, { error: `this workspace is limited to ${MAX_WORKSPACE_BOTS} bots` });
14125
15274
  }
14126
- const bot = store.createBot({ ...profile.patch, section, modelSelection: selection });
15275
+ // Who may see it, from the first moment (admin route: members cannot
15276
+ // create bots). A bot named for a sensitive job must never be
15277
+ // announced to everyone before it is restricted.
15278
+ const created = body.visibility === undefined ? null : parseVisibility(body.visibility);
15279
+ if (created && !created.ok)
15280
+ return json(res, 400, { error: created.error });
15281
+ const bot = store.createBot({ ...profile.patch, section, modelSelection: selection, ...(created?.ok ? { visibility: created.visibility } : {}) });
14127
15282
  return json(res, 201, {
14128
15283
  bot: {
14129
15284
  ...wireBot(bot),
@@ -14228,7 +15383,8 @@ const handleRequest = async (req, res) => {
14228
15383
  const bot = store.patchBot(existing.id, { modelSelection: checked.selection });
14229
15384
  if (!bot)
14230
15385
  return json(res, 404, { error: "no such bot" });
14231
- store.patchTask(bot.id, selected.threadId, { modelSelection: checked.selection });
15386
+ store.patchTask(bot.id, selected.threadId, { modelSelection: checked.selection,
15387
+ ...hostedModels?.resetTask(selected.modelSelection, checked.selection) });
14232
15388
  return json(res, 200, { bot: wireBot(bot) });
14233
15389
  }
14234
15390
  m = path.match(/^\/api\/bots\/([\w-]+)\/read$/);
@@ -14250,13 +15406,18 @@ const handleRequest = async (req, res) => {
14250
15406
  const bot = requestedTaskBot(m[1], body.threadId);
14251
15407
  if (!allowKey)
14252
15408
  return json(res, 400, { error: "allowKey required" });
14253
- const pending = store.messagesFor(bot.threadId).some((message) => message.card?.requestId &&
15409
+ const pending = store.messagesFor(bot.threadId).find((message) => message.card?.requestId &&
14254
15410
  !message.card.answered &&
14255
15411
  message.card.dismissed !== true &&
14256
15412
  message.card.allowKey === allowKey);
14257
15413
  if (!pending) {
14258
15414
  return json(res, 409, { error: "that grant is not on a pending approval for this bot" });
14259
15415
  }
15416
+ // A standing grant is an approval: the same people who may approve
15417
+ // the card it came from may remember it.
15418
+ const refusal = cardAnswerRefusal(auth, bot.threadId, pending.card.requestId, "allow");
15419
+ if (refusal)
15420
+ return json(res, 403, { error: refusal });
14260
15421
  store.patchTask(bot.id, bot.threadId, {
14261
15422
  alwaysAllow: [...new Set([...(bot.alwaysAllow ?? []), allowKey])].slice(0, 200),
14262
15423
  });
@@ -14282,6 +15443,7 @@ const handleRequest = async (req, res) => {
14282
15443
  const existingBot = store.bot(m[1]);
14283
15444
  const selectedTask = existingBot ? requestedTaskBot(existingBot.id, undefined) : null;
14284
15445
  const beforeProfile = existingBot ? profileSnapshot(existingBot) : undefined;
15446
+ const beforeVisibility = existingBot?.visibility;
14285
15447
  if (body.requireAvailableModel !== undefined && typeof body.requireAvailableModel !== "boolean") {
14286
15448
  return json(res, 400, { error: "requireAvailableModel must be true or false" });
14287
15449
  }
@@ -14616,6 +15778,16 @@ const handleRequest = async (req, res) => {
14616
15778
  // authority to return if this bot is elected Chief again later.
14617
15779
  if (body.chiefOfStaff === false)
14618
15780
  patch.managedSections = [];
15781
+ // Who may see this bot on a workspace several people share
15782
+ // (bot-visibility.ts): "everyone" (the default), "admins", or
15783
+ // { people: [...] }. Access control, applied at once; members' PATCHes
15784
+ // never get here (clientBotPatchViolation).
15785
+ if (Object.hasOwn(body, "visibility")) {
15786
+ const parsed = parseVisibility(body.visibility);
15787
+ if (!parsed.ok)
15788
+ return json(res, 400, { error: parsed.error });
15789
+ patch.visibility = parsed.visibility;
15790
+ }
14619
15791
  if (body.alwaysAllow !== undefined) {
14620
15792
  if (!Array.isArray(body.alwaysAllow) || body.alwaysAllow.some((t) => typeof t !== "string")) {
14621
15793
  return json(res, 400, { error: "alwaysAllow must be a list of tool keys" });
@@ -14749,7 +15921,8 @@ const handleRequest = async (req, res) => {
14749
15921
  if (!bot)
14750
15922
  return json(res, 404, { error: "no such bot" });
14751
15923
  if (normalizedSelection && selectedTask)
14752
- store.patchTask(bot.id, selectedTask.threadId, { modelSelection: normalizedSelection });
15924
+ store.patchTask(bot.id, selectedTask.threadId, { modelSelection: normalizedSelection,
15925
+ ...hostedModels?.resetTask(selectedTask.modelSelection, normalizedSelection) });
14753
15926
  if (existingBot && (bot.browserProfile !== beforeBrowserProfile || bot.browser !== beforeBrowserEnabled)) {
14754
15927
  browserLive.closeForBot(bot.id);
14755
15928
  if (beforeBrowserProfile === "guest" && (bot.browserProfile !== "guest" || bot.browser === false)) {
@@ -14765,6 +15938,15 @@ const handleRequest = async (req, res) => {
14765
15938
  const now = store.bot(bot.id);
14766
15939
  recordProfileChange(bot.id, "user", "api", beforeProfile, profileSnapshot(now));
14767
15940
  }
15941
+ // A new audience changes who may see this bot's rooms and teams too:
15942
+ // re-announce them so every member's stream gains or withdraws them.
15943
+ if (Object.hasOwn(patch, "visibility") && JSON.stringify(beforeVisibility ?? "everyone") !== JSON.stringify(bot.visibility ?? "everyone")) {
15944
+ for (const group of store.groups.filter((candidate) => candidate.memberIds.includes(bot.id))) {
15945
+ broadcast({ kind: "group", group: publicGroupState(group) });
15946
+ }
15947
+ broadcast({ kind: "sections", sections: store.sections });
15948
+ audienceChanged();
15949
+ }
14768
15950
  return json(res, 200, { bot: wireBot(store.bot(bot.id)) });
14769
15951
  }
14770
15952
  if (method === "POST" && path === "/api/local-computer/interrupt") {
@@ -15261,6 +16443,35 @@ const handleRequest = async (req, res) => {
15261
16443
  });
15262
16444
  return json(res, 200, { message: patched });
15263
16445
  }
16446
+ m = path.match(/^\/api\/bots\/([\w-]+)\/requests\/([A-Za-z0-9_-]{16,80})(\/interrupt)?$/);
16447
+ if (m && ((method === "GET" && !m[3]) || (method === "POST" && m[3]))) {
16448
+ const body = method === "GET" ? { threadId: url.searchParams.get("threadId") } : await readBody(req);
16449
+ const id = z.string().regex(/^[\w-]+$/);
16450
+ const parsed = (method === "GET" ? z.object({ threadId: id }).strict() : z.object({
16451
+ threadId: id, messageId: id, expectedActiveLeafId: id.nullable(),
16452
+ expectedTurnId: z.string().min(1).max(200).nullable(), expectedExecutionId: id.nullable(),
16453
+ }).strict()).safeParse(body);
16454
+ if (!parsed.success)
16455
+ return json(res, 400, { error: "A pinned thread and exact request snapshot are required" });
16456
+ const threadId = parsed.data.threadId;
16457
+ const snapshot = guardedRequestSnapshot(m[1], threadId, m[2]);
16458
+ res.setHeader("cache-control", "private, no-store");
16459
+ if (method === "GET")
16460
+ return json(res, 200, snapshot);
16461
+ assertRequestTarget(snapshot, body);
16462
+ const owner = directRequestOwners.get(threadId);
16463
+ if (snapshot.phase === "untracked" || !owner || owner.messageId !== snapshot.messageId) {
16464
+ return json(res, 409, { error: "This continuation cannot be safely stopped from this interface", code: "guarded_request_untracked" });
16465
+ }
16466
+ if (snapshot.phase === "settled")
16467
+ throw requestConflict("This request has already settled");
16468
+ // No await between comparing the exact lease and revoking it. Retrying
16469
+ // this body cannot bind to a later setup claim with a null provider id.
16470
+ owner.stopped = true;
16471
+ handoffs.stoppedByPerson(threadId);
16472
+ await interruptDirectThread(m[1], threadId);
16473
+ return json(res, 200, { ok: true, outcome: "stopped" });
16474
+ }
15264
16475
  m = path.match(/^\/api\/bots\/([\w-]+)\/messages(?:\/(guarded))?$/);
15265
16476
  if (m && method === "POST") {
15266
16477
  const guarded = m[2] === "guarded";
@@ -15270,14 +16481,12 @@ const handleRequest = async (req, res) => {
15270
16481
  }
15271
16482
  // External interfaces must opt into a distinct route: an older server
15272
16483
  // returns 404 rather than silently ignoring safety preconditions.
15273
- if (guarded && !z.object({
15274
- threadId: z.string().regex(/^[\w-]+$/),
15275
- sendId: z.string().regex(/^[A-Za-z0-9_-]{16,80}$/),
15276
- text: z.string().min(1),
15277
- expectedActiveLeafId: z.string().regex(/^[\w-]+$/).nullable(),
15278
- replyToId: z.string().optional(),
15279
- }).strict().safeParse(body).success) {
15280
- return json(res, 400, { error: "guarded sends require threadId, sendId, text and expectedActiveLeafId" });
16484
+ const guardedBody = guarded ? guardedSendSchema.safeParse(body) : null;
16485
+ if (guardedBody && !guardedBody.success) {
16486
+ const onBehalfOfInvalid = guardedBody.error.issues.some((issue) => issue.path[0] === "onBehalfOf");
16487
+ return json(res, 400, { error: onBehalfOfInvalid
16488
+ ? "onBehalfOf must name the person with an email address, a name, or both"
16489
+ : "guarded sends require threadId, sendId, text and expectedActiveLeafId" });
15281
16490
  }
15282
16491
  const text = String(body.text ?? "").trim();
15283
16492
  if (!text)
@@ -15293,8 +16502,16 @@ const handleRequest = async (req, res) => {
15293
16502
  // receipt after a task switch, while a genuinely new send still has to
15294
16503
  // target the task that is active now.
15295
16504
  const threadId = body.threadId ?? bot.threadId;
15296
- if (!guarded)
15297
- noteTurnTrigger(threadId, auth);
16505
+ // Who this message is from, for the ledger. It is captured here and
16506
+ // travels with the message: into the turn it starts, or into the queue
16507
+ // until it drains. A message steered into someone else's running turn
16508
+ // books nothing — that turn stays with whoever started it. A guarded
16509
+ // relay (the Slack worker, which already holds admin scope to reach
16510
+ // this route) names the person it acts for.
16511
+ const onBehalfOf = guardedBody?.data?.onBehalfOf;
16512
+ const trigger = onBehalfOf
16513
+ ? { kind: "user", ...(onBehalfOf.email ? { email: onBehalfOf.email } : {}), ...(onBehalfOf.name ? { label: onBehalfOf.name } : {}) }
16514
+ : usageTriggerFor(auth);
15298
16515
  // The send is acknowledged before the turn starts, so a workspace at its
15299
16516
  // spend limit is refused here, where the person can see it.
15300
16517
  try {
@@ -15345,8 +16562,10 @@ const handleRequest = async (req, res) => {
15345
16562
  // There is no await between these checks and startTurn's
15346
16563
  // synchronous transcript append / runtime reservation. In
15347
16564
  // particular, never steer or enqueue under stale permissions.
15348
- if (approvalModeFor(currentAtStart) !== "ask" || currentAtStart.autoApprove === true || currentAtStart.alwaysAllow?.length) {
15349
- throw Object.assign(new Error("guarded sends require Ask mode without remembered permissions"), { status: 409, code: "guarded_permissions" });
16565
+ const expectedMode = body.expectedApprovalMode ?? "ask";
16566
+ if (approvalModeFor(currentAtStart) !== expectedMode ||
16567
+ (expectedMode === "ask" && (currentAtStart.autoApprove === true || currentAtStart.alwaysAllow?.length))) {
16568
+ throw Object.assign(new Error("guarded sends require the exact expected approval mode; Ask cannot have remembered permissions"), { status: 409, code: "guarded_permissions" });
15350
16569
  }
15351
16570
  if (store.activeLeaf(threadId) !== body.expectedActiveLeafId) {
15352
16571
  throw Object.assign(new Error("the conversation changed before this message could start"), { status: 409, code: "guarded_branch" });
@@ -15354,8 +16573,7 @@ const handleRequest = async (req, res) => {
15354
16573
  if (currentAtStart.busy || threadBusy(bot.id, threadId) || botAtThreadCapacity(bot.id) || parksBehindCoordination(bot.id, threadId) || activeGroupTurnForBot(bot.id)) {
15355
16574
  throw Object.assign(new Error("wait for a free thread slot before retrying this message"), { status: 409, code: "guarded_busy" });
15356
16575
  }
15357
- noteTurnTrigger(threadId, auth);
15358
- const message = await startTurn(bot.id, text, { threadId, replyTo, sendId, sender: messageSender(auth) });
16576
+ const message = await startTurn(bot.id, text, { threadId, replyTo, sendId, sender: messageSender(auth), trigger });
15359
16577
  return { ok: true, threadId, message };
15360
16578
  }
15361
16579
  // Claude can accept the message inside its live turn. If the write
@@ -15419,16 +16637,18 @@ const handleRequest = async (req, res) => {
15419
16637
  return { ok: true, steered: true, threadId, message };
15420
16638
  }
15421
16639
  if (!current.busy) {
15422
- return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId, messageSender(auth));
16640
+ return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId, messageSender(auth), trigger);
15423
16641
  }
15424
16642
  const queued = queueSteeredMessage(current.id, threadId, text, {
15425
16643
  replyToId: replyTo?.id,
15426
16644
  sendId,
15427
16645
  prompt: promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"),
16646
+ sender: messageSender(auth),
16647
+ trigger,
15428
16648
  });
15429
16649
  return { ok: true, queued: true, queueId: queued.id, threadId };
15430
16650
  }
15431
- return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId, messageSender(auth));
16651
+ return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId, messageSender(auth), trigger);
15432
16652
  });
15433
16653
  return json(res, 202, receipt);
15434
16654
  }
@@ -15450,7 +16670,9 @@ const handleRequest = async (req, res) => {
15450
16670
  const body = await readBody(req);
15451
16671
  requirePinnedClientThread(m[1], body?.threadId);
15452
16672
  const bot = requestedTaskBot(m[1], body?.threadId);
15453
- noteTurnTrigger(bot.threadId, auth);
16673
+ // Pressing Steer folds the queued words into the running turn. It does
16674
+ // not re-book that turn to whoever pressed it, and the words keep the
16675
+ // sender they were queued with.
15454
16676
  // Lift the whole queue atomically: a settle racing this request can
15455
16677
  // drain it as a follow-up, or this request can steer it into the live
15456
16678
  // turn — never both for the same words.
@@ -15494,6 +16716,7 @@ const handleRequest = async (req, res) => {
15494
16716
  queueId: item.messageId,
15495
16717
  peerAsk: item.peerAsk,
15496
16718
  steered: true,
16719
+ sender: item.sender,
15497
16720
  }));
15498
16721
  // Offered to the next turn again unless the person stops this one.
15499
16722
  for (const message of messages)
@@ -15525,9 +16748,8 @@ const handleRequest = async (req, res) => {
15525
16748
  const body = await readBody(req);
15526
16749
  requirePinnedClientThread(m[1], body?.threadId);
15527
16750
  const bot = requestedTaskBot(m[1], body?.threadId);
15528
- noteTurnTrigger(bot.threadId, auth);
15529
16751
  await startTurn(bot.id, "Summarize conversation context", {
15530
- threadId: bot.threadId, compactOnly: true, cardContinuation: true,
16752
+ threadId: bot.threadId, compactOnly: true, cardContinuation: true, trigger: usageTriggerFor(auth),
15531
16753
  });
15532
16754
  return json(res, 202, { ok: true, threadId: bot.threadId });
15533
16755
  }
@@ -15561,10 +16783,14 @@ const handleRequest = async (req, res) => {
15561
16783
  error: `provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`,
15562
16784
  });
15563
16785
  }
16786
+ const rerunInstance = registry.get(bot.modelSelection.instanceId);
16787
+ const rerunRefusal = policyModelRefusal(rerunInstance);
16788
+ if (rerunRefusal)
16789
+ return json(res, 409, { error: rerunRefusal });
15564
16790
  // startTurn admits the rerun before branching. A shared-resource or
15565
16791
  // concurrency-limit refusal must leave the original transcript intact.
15566
16792
  const replyTo = source.replyToId ? resolveReplyTarget(bot.threadId, source.replyToId) : undefined;
15567
- const message = await startTurn(bot.id, text, { threadId: bot.threadId, editedMessageId: messageId, replyTo });
16793
+ const message = await startTurn(bot.id, text, { threadId: bot.threadId, editedMessageId: messageId, replyTo, trigger: usageTriggerFor(auth) });
15568
16794
  return json(res, 202, { ok: true, message });
15569
16795
  }
15570
16796
  // switch which fork of the conversation is visible (no new turn)
@@ -15594,44 +16820,49 @@ const handleRequest = async (req, res) => {
15594
16820
  const reviewedSha256 = typeof body.reviewedSha256 === "string" ? body.reviewedSha256 : undefined;
15595
16821
  if (!behavior)
15596
16822
  return json(res, 400, { error: "behavior must be allow, deny, or answer" });
15597
- if (await resolveAndSendTeamSetup(res, {
15598
- botId: bot.id, threadId: bot.threadId, requestId: String(body.requestId), behavior,
15599
- }, auth.kind === "loopback" ? DESKTOP_MANAGED || Boolean(req.headers.origin) && !store.bots.some((bot) => bot.busy || activeGroupTurnForBot(bot.id)) : auth.scopes.includes("admin")))
15600
- return;
15601
- if (resolveAndSendRoutine(res, {
15602
- botId: bot.id,
15603
- botName: bot.name,
15604
- threadId: bot.threadId,
15605
- requestId: String(body.requestId),
15606
- behavior,
15607
- }))
15608
- return;
15609
- if (resolveAndSendProfile(res, {
15610
- botId: bot.id,
15611
- botName: bot.name,
15612
- threadId: bot.threadId,
15613
- requestId: String(body.requestId),
15614
- behavior,
15615
- }))
15616
- return;
15617
- if (sendSkillResolution(res, resolveSkillRequest({
15618
- botId: bot.id,
15619
- botName: bot.name,
15620
- threadId: bot.threadId,
15621
- requestId: String(body.requestId),
15622
- behavior,
15623
- reviewedSha256,
15624
- })))
15625
- return;
15626
- // peer-approval intercept: harness-native cards carry a requestId
15627
- // that lives in peer-approval's pending map. Resolve them here so
15628
- // the provider adapter never sees a request it didn't raise.
15629
- if (store.messagesFor(bot.threadId).some((message) => message.card?.requestId === String(body.requestId)) &&
15630
- resolvePeerComms(approvalBus, String(body.requestId), behavior)) {
15631
- return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
15632
- }
15633
- const outcome = await answerRequest(bot.threadId, bot.modelSelection.instanceId, String(body.requestId), behavior, body.message, { id: bot.id, name: bot.name }, body.always === true);
15634
- return json(res, 200, { ok: true, outcome });
16823
+ const refusal = cardAnswerRefusal(auth, bot.threadId, String(body.requestId), behavior);
16824
+ if (refusal)
16825
+ return json(res, 403, { error: refusal });
16826
+ return await answeringCardAs(auth, bot.threadId, String(body.requestId), async () => {
16827
+ if (await resolveAndSendTeamSetup(res, {
16828
+ botId: bot.id, threadId: bot.threadId, requestId: String(body.requestId), behavior,
16829
+ }, auth.kind === "loopback" ? DESKTOP_MANAGED || Boolean(req.headers.origin) && !store.bots.some((bot) => bot.busy || activeGroupTurnForBot(bot.id)) : auth.scopes.includes("admin")))
16830
+ return;
16831
+ if (resolveAndSendRoutine(res, {
16832
+ botId: bot.id,
16833
+ botName: bot.name,
16834
+ threadId: bot.threadId,
16835
+ requestId: String(body.requestId),
16836
+ behavior,
16837
+ }))
16838
+ return;
16839
+ if (resolveAndSendProfile(res, {
16840
+ botId: bot.id,
16841
+ botName: bot.name,
16842
+ threadId: bot.threadId,
16843
+ requestId: String(body.requestId),
16844
+ behavior,
16845
+ }))
16846
+ return;
16847
+ if (sendSkillResolution(res, resolveSkillRequest({
16848
+ botId: bot.id,
16849
+ botName: bot.name,
16850
+ threadId: bot.threadId,
16851
+ requestId: String(body.requestId),
16852
+ behavior,
16853
+ reviewedSha256,
16854
+ })))
16855
+ return;
16856
+ // peer-approval intercept: harness-native cards carry a requestId
16857
+ // that lives in peer-approval's pending map. Resolve them here so
16858
+ // the provider adapter never sees a request it didn't raise.
16859
+ if (store.messagesFor(bot.threadId).some((message) => message.card?.requestId === String(body.requestId)) &&
16860
+ resolvePeerComms(approvalBus, String(body.requestId), behavior)) {
16861
+ return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
16862
+ }
16863
+ const outcome = await answerRequest(bot.threadId, bot.modelSelection.instanceId, String(body.requestId), behavior, body.message, { id: bot.id, name: bot.name }, body.always === true);
16864
+ return json(res, 200, { ok: true, outcome });
16865
+ });
15635
16866
  }
15636
16867
  // Answer by THREAD, so a request raised inside a room can be answered
15637
16868
  // too: a member's turn runs on the room's thread, and the bot that
@@ -15645,86 +16876,91 @@ const handleRequest = async (req, res) => {
15645
16876
  if (!behavior)
15646
16877
  return json(res, 400, { error: "behavior must be allow, deny, or answer" });
15647
16878
  const requestId = String(body.requestId);
15648
- const skillCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.skillRequest);
15649
- if (skillCard?.card?.skillRequest) {
15650
- const skillBotId = skillCard.from?.botId ?? store.botByThread(threadId)?.id;
15651
- if (!skillBotId)
15652
- return json(res, 400, { error: "this skill request has no valid owner" });
15653
- const skillOwner = store.bot(skillBotId);
15654
- if (sendSkillResolution(res, resolveSkillRequest({
15655
- botId: skillBotId,
15656
- botName: skillOwner?.name,
15657
- threadId,
15658
- requestId,
15659
- behavior,
15660
- reviewedSha256,
15661
- })))
15662
- return;
15663
- }
15664
- const routineCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.routineRequest);
15665
- if (routineCard?.card?.routineRequest) {
15666
- // Derive the owner from the conversation, not from the executable
15667
- // payload being authorized. Room cards carry their trusted sender;
15668
- // one-to-one tasks resolve through the store's thread ownership.
15669
- const routineBotId = routineCard.from?.botId ?? store.botByThread(threadId)?.id;
15670
- if (!routineBotId)
15671
- return json(res, 400, { error: "this routine request has no valid owner" });
15672
- const routineOwner = store.bot(routineBotId);
15673
- if (resolveAndSendRoutine(res, {
15674
- botId: routineBotId,
15675
- botName: routineOwner?.name,
15676
- threadId,
15677
- requestId,
15678
- behavior,
15679
- }))
15680
- return;
15681
- }
15682
- const setupCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.teamSetupRequest);
15683
- if (setupCard) {
15684
- const setupBotId = setupCard.from?.botId ?? store.botByThread(threadId)?.id;
15685
- if (!setupBotId)
15686
- return json(res, 400, { error: "This team setup has no valid owner" });
15687
- if (await resolveAndSendTeamSetup(res, { botId: setupBotId, threadId, requestId, behavior }, auth.kind === "loopback" ? DESKTOP_MANAGED || Boolean(req.headers.origin) && !store.bots.some((bot) => bot.busy || activeGroupTurnForBot(bot.id)) : auth.scopes.includes("admin")))
15688
- return;
15689
- }
15690
- const profileCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.profileRequest);
15691
- if (profileCard?.card?.profileRequest) {
15692
- const profileBotId = profileCard.from?.botId ?? store.botByThread(threadId)?.id;
15693
- if (!profileBotId)
15694
- return json(res, 400, { error: "this profile request has no valid owner" });
15695
- const profileOwner = store.bot(profileBotId);
15696
- if (resolveAndSendProfile(res, {
15697
- botId: profileBotId,
15698
- botName: profileOwner?.name,
15699
- threadId,
15700
- requestId,
15701
- behavior,
15702
- }))
15703
- return;
15704
- }
15705
- // peer-approval intercept (see /api/bots/:id/respond above). A peer card
15706
- // belongs to the bus rather than to a speaker, so resolve it before we go
15707
- // looking for one — a room between turns has no speaker to find.
15708
- if (store.messagesFor(threadId).some((message) => message.card?.requestId === requestId) &&
15709
- resolvePeerComms(approvalBus, requestId, behavior)) {
15710
- return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
15711
- }
15712
- const group = store.groupByThread(threadId);
15713
- // busyBotId is in-memory only, so an approval that outlives its turn — or
15714
- // the process — leaves a durable card with no speaker behind it. Fall back
15715
- // to the member that raised it, and answer even when that member is gone:
15716
- // answerRequest closes an unreachable card, and a pending approval owns
15717
- // the composer, so a dead end here locks the room for good.
15718
- const pending = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId);
15719
- const owner = group
15720
- ? (group.busyBotId ? store.bot(group.busyBotId) : undefined) ??
15721
- (pending?.from ? store.bot(pending.from.botId) : undefined)
15722
- : store.botByThread(threadId);
15723
- if (!owner && !pending)
15724
- return json(res, 404, { error: "nothing is waiting on an answer in this conversation" });
15725
- const requestOwner = owner ? botForThread(owner.id, threadId) : null;
15726
- const outcome = await answerRequest(threadId, requestOwner?.modelSelection.instanceId ?? "", requestId, behavior, body.message, owner ? { id: owner.id, name: owner.name } : undefined, body.always === true);
15727
- return json(res, 200, { ok: true, outcome });
16879
+ const refusal = cardAnswerRefusal(auth, threadId, requestId, behavior);
16880
+ if (refusal)
16881
+ return json(res, 403, { error: refusal });
16882
+ return await answeringCardAs(auth, threadId, requestId, async () => {
16883
+ const skillCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.skillRequest);
16884
+ if (skillCard?.card?.skillRequest) {
16885
+ const skillBotId = skillCard.from?.botId ?? store.botByThread(threadId)?.id;
16886
+ if (!skillBotId)
16887
+ return json(res, 400, { error: "this skill request has no valid owner" });
16888
+ const skillOwner = store.bot(skillBotId);
16889
+ if (sendSkillResolution(res, resolveSkillRequest({
16890
+ botId: skillBotId,
16891
+ botName: skillOwner?.name,
16892
+ threadId,
16893
+ requestId,
16894
+ behavior,
16895
+ reviewedSha256,
16896
+ })))
16897
+ return;
16898
+ }
16899
+ const routineCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.routineRequest);
16900
+ if (routineCard?.card?.routineRequest) {
16901
+ // Derive the owner from the conversation, not from the executable
16902
+ // payload being authorized. Room cards carry their trusted sender;
16903
+ // one-to-one tasks resolve through the store's thread ownership.
16904
+ const routineBotId = routineCard.from?.botId ?? store.botByThread(threadId)?.id;
16905
+ if (!routineBotId)
16906
+ return json(res, 400, { error: "this routine request has no valid owner" });
16907
+ const routineOwner = store.bot(routineBotId);
16908
+ if (resolveAndSendRoutine(res, {
16909
+ botId: routineBotId,
16910
+ botName: routineOwner?.name,
16911
+ threadId,
16912
+ requestId,
16913
+ behavior,
16914
+ }))
16915
+ return;
16916
+ }
16917
+ const setupCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.teamSetupRequest);
16918
+ if (setupCard) {
16919
+ const setupBotId = setupCard.from?.botId ?? store.botByThread(threadId)?.id;
16920
+ if (!setupBotId)
16921
+ return json(res, 400, { error: "This team setup has no valid owner" });
16922
+ if (await resolveAndSendTeamSetup(res, { botId: setupBotId, threadId, requestId, behavior }, auth.kind === "loopback" ? DESKTOP_MANAGED || Boolean(req.headers.origin) && !store.bots.some((bot) => bot.busy || activeGroupTurnForBot(bot.id)) : auth.scopes.includes("admin")))
16923
+ return;
16924
+ }
16925
+ const profileCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.profileRequest);
16926
+ if (profileCard?.card?.profileRequest) {
16927
+ const profileBotId = profileCard.from?.botId ?? store.botByThread(threadId)?.id;
16928
+ if (!profileBotId)
16929
+ return json(res, 400, { error: "this profile request has no valid owner" });
16930
+ const profileOwner = store.bot(profileBotId);
16931
+ if (resolveAndSendProfile(res, {
16932
+ botId: profileBotId,
16933
+ botName: profileOwner?.name,
16934
+ threadId,
16935
+ requestId,
16936
+ behavior,
16937
+ }))
16938
+ return;
16939
+ }
16940
+ // peer-approval intercept (see /api/bots/:id/respond above). A peer card
16941
+ // belongs to the bus rather than to a speaker, so resolve it before we go
16942
+ // looking for one — a room between turns has no speaker to find.
16943
+ if (store.messagesFor(threadId).some((message) => message.card?.requestId === requestId) &&
16944
+ resolvePeerComms(approvalBus, requestId, behavior)) {
16945
+ return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
16946
+ }
16947
+ const group = store.groupByThread(threadId);
16948
+ // busyBotId is in-memory only, so an approval that outlives its turn — or
16949
+ // the process — leaves a durable card with no speaker behind it. Fall back
16950
+ // to the member that raised it, and answer even when that member is gone:
16951
+ // answerRequest closes an unreachable card, and a pending approval owns
16952
+ // the composer, so a dead end here locks the room for good.
16953
+ const pending = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId);
16954
+ const owner = group
16955
+ ? (group.busyBotId ? store.bot(group.busyBotId) : undefined) ??
16956
+ (pending?.from ? store.bot(pending.from.botId) : undefined)
16957
+ : store.botByThread(threadId);
16958
+ if (!owner && !pending)
16959
+ return json(res, 404, { error: "nothing is waiting on an answer in this conversation" });
16960
+ const requestOwner = owner ? botForThread(owner.id, threadId) : null;
16961
+ const outcome = await answerRequest(threadId, requestOwner?.modelSelection.instanceId ?? "", requestId, behavior, body.message, owner ? { id: owner.id, name: owner.name } : undefined, body.always === true);
16962
+ return json(res, 200, { ok: true, outcome });
16963
+ });
15728
16964
  }
15729
16965
  m = path.match(/^\/api\/bots\/([\w-]+)\/interrupt$/);
15730
16966
  if (m && method === "POST") {
@@ -15855,18 +17091,36 @@ const handleRequest = async (req, res) => {
15855
17091
  m = path.match(/^\/api\/bots\/([\w-]+)\/tasks$/);
15856
17092
  if (m && method === "POST") {
15857
17093
  const body = await readBody(req);
17094
+ if (!body || typeof body !== "object" || Array.isArray(body))
17095
+ return json(res, 400, { error: "body must be a JSON object" });
15858
17096
  const bot = store.bot(m[1]);
15859
17097
  if (!bot)
15860
17098
  return json(res, 404, { error: "no such bot" });
17099
+ if (body.approvalMode !== undefined && body.approvalMode !== "ask" && body.approvalMode !== "full") {
17100
+ return json(res, 400, { error: "new task approvalMode must be ask or full" });
17101
+ }
17102
+ if (body.approvalMode === "full") {
17103
+ if (auth.kind !== "loopback" || !sharedWorkspaceFullAccessEnabled()) {
17104
+ return json(res, 403, { error: "New Full tasks require the operator's dedicated shared-workspace policy" });
17105
+ }
17106
+ if (bot.approvalGrant)
17107
+ return json(res, 409, { error: "the bot's approval mode is still being confirmed" });
17108
+ if (!supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, "full")) {
17109
+ return json(res, 400, { error: "This provider does not support Full access" });
17110
+ }
17111
+ }
15861
17112
  if (phoneSecretSubmissions.hasBot(bot.id)) {
15862
17113
  return json(res, 409, { error: "this bot is securely saving a credential — try again when it finishes" });
15863
17114
  }
15864
17115
  if (body.projectId !== undefined && (typeof body.projectId !== "string" || !store.project(bot.id, body.projectId))) {
15865
17116
  return json(res, 400, { error: "projectId must belong to this bot" });
15866
17117
  }
15867
- const task = store.createTask(bot.id, typeof body.title === "string" ? body.title : undefined, true, body.projectId);
17118
+ const task = store.createTask(bot.id, typeof body.title === "string" ? body.title : undefined, true, body.projectId, undefined, body.approvalMode);
15868
17119
  if (!task)
15869
17120
  return json(res, 500, { error: "couldn't create that task" });
17121
+ // Who opened it decides who may answer its cards on a shared workspace.
17122
+ if (auth.kind === "session")
17123
+ threadStarters.set(task.threadId, personKey(auth.session));
15870
17124
  const fresh = botWithThread(store.bot(bot.id));
15871
17125
  broadcast({ kind: "bot", bot: fresh });
15872
17126
  return json(res, 201, { bot: fresh, task: wireTask(task) });
@@ -15909,7 +17163,7 @@ const handleRequest = async (req, res) => {
15909
17163
  const current = store.projectBotForTask(m[1], m[2]);
15910
17164
  if (!current)
15911
17165
  return json(res, 404, { error: "no such task" });
15912
- const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt", "surface"]);
17166
+ const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt", "pinned", "surface"]);
15913
17167
  if (Object.keys(body).some((key) => !allowed.has(key)))
15914
17168
  return json(res, 400, { error: "unsupported thread setting" });
15915
17169
  for (const key of ["requireAvailableModel", "acknowledgeLocalAuto", "updateBotDefault", "resetApprovalToAsk"]) {
@@ -15946,6 +17200,11 @@ const handleRequest = async (req, res) => {
15946
17200
  else
15947
17201
  return json(res, 400, { error: "archivedAt must be a timestamp, or null to unarchive" });
15948
17202
  }
17203
+ if (body.pinned !== undefined) {
17204
+ if (typeof body.pinned !== "boolean")
17205
+ return json(res, 400, { error: "pinned must be a boolean" });
17206
+ patch.pinned = body.pinned ? true : undefined;
17207
+ }
15949
17208
  if (body.surface !== undefined) {
15950
17209
  if (threadBusy(current.id, current.threadId))
15951
17210
  return json(res, 409, { error: "Stop this thread before changing its computer destination." });
@@ -16008,7 +17267,7 @@ const handleRequest = async (req, res) => {
16008
17267
  return json(res, checked.status, { error: checked.error });
16009
17268
  }
16010
17269
  const task = patch.modelSelection
16011
- ? store.switchTaskModel(m[1], m[2], patch.modelSelection, body.updateBotDefault === true, body.resetApprovalToAsk === true, patch)
17270
+ ? store.switchTaskModel(m[1], m[2], patch.modelSelection, body.updateBotDefault === true, body.resetApprovalToAsk === true, { ...patch, ...hostedModels?.resetTask(current.modelSelection, patch.modelSelection) })
16012
17271
  : store.patchTask(m[1], m[2], patch);
16013
17272
  const fresh = botWithThread(store.bot(m[1]));
16014
17273
  broadcast({ kind: "bot", bot: fresh });
@@ -16389,7 +17648,10 @@ const handleRequest = async (req, res) => {
16389
17648
  // child proves it is OURS by echoing its pid (a stray dev server has
16390
17649
  // the same API shape but a different pid)
16391
17650
  if (method === "GET" && path === "/api/health") {
16392
- return json(res, 200, { app: "openmausbot", pid: process.pid, static: Boolean(STATIC_DIR), capabilities: { guardedMessages: 1 } });
17651
+ return json(res, 200, { app: "openmausbot", pid: process.pid, static: Boolean(STATIC_DIR), capabilities: {
17652
+ guardedMessages: 1, guardedRequests: 1, guardedFullAccess: 1, guardedOnBehalfOf: 1,
17653
+ ...(sharedWorkspaceFullAccessEnabled() ? { sharedWorkspaceFullAccess: 1 } : {}),
17654
+ } });
16393
17655
  }
16394
17656
  // The bots' browser engine: install it on this machine (agent-browser +
16395
17657
  // a Chrome for Testing, a one-time download), or ask how that is going.
@@ -16446,7 +17708,9 @@ const handleRequest = async (req, res) => {
16446
17708
  }
16447
17709
  // Which edition this server runs and why (see server/enterprise.ts). Read-only.
16448
17710
  if (method === "GET" && path === "/api/edition") {
16449
- return json(res, 200, editionStatus());
17711
+ // A member learns what is entitled; when the key lapses is for admins.
17712
+ const status = editionStatus();
17713
+ return json(res, 200, auth.scopes.includes("admin") ? status : editionForMembers(status));
16450
17714
  }
16451
17715
  // The brand for this deployment (server/brand.ts): read per request so edits show on reload.
16452
17716
  if (method === "GET" && path === "/api/brand") {
@@ -16483,7 +17747,7 @@ const handleRequest = async (req, res) => {
16483
17747
  return json(res, 400, { error: "from and to must be YYYY-MM-DD, from no later than to, at most a year apart" });
16484
17748
  const rows = readUsage(DATA_DIR, range);
16485
17749
  // The operator's price list is applied only with the billing entitlement.
16486
- const prices = entitled("billing") && cfg.billing?.prices && Object.keys(cfg.billing.prices).length ? cfg.billing.prices : null;
17750
+ const prices = operatorPrices();
16487
17751
  if (path === "/api/usage.csv") {
16488
17752
  const stamp = (date) => date.toISOString().slice(0, 10);
16489
17753
  res.writeHead(200, {
@@ -16540,6 +17804,57 @@ const handleRequest = async (req, res) => {
16540
17804
  }
16541
17805
  return json(res, 200, { decisions: readDecisions(DATA_DIR, parsedLimit ?? 200) });
16542
17806
  }
17807
+ // Settings → Activity: admin actions and approvals together, filtered by
17808
+ // who, what and when; the same list as CSV. Admin scope by default.
17809
+ if (method === "GET" && (path === "/api/admin-activity" || path === "/api/admin-activity.csv")) {
17810
+ const fromParam = url.searchParams.get("from") || new Date(Date.now() - 29 * 24 * 60 * 60_000).toISOString().slice(0, 10);
17811
+ const range = parseUsageRange(fromParam, url.searchParams.get("to"));
17812
+ if (!range)
17813
+ return json(res, 400, { error: "from and to must be dates (YYYY-MM-DD), from no later than to, at most a year apart" });
17814
+ const what = parseActivityWhat(url.searchParams.get("what"));
17815
+ if (!what)
17816
+ return json(res, 400, { error: "what must be all, approvals, decisions, config, people, session, webhook, mcp, engine, bot, budget or visibility" });
17817
+ const entries = activityEntries(readDecisionRange(DATA_DIR, range), readAdminActivityRange(DATA_DIR, range), {
17818
+ what,
17819
+ who: (url.searchParams.get("who") ?? "").slice(0, 200),
17820
+ });
17821
+ if (path.endsWith(".csv")) {
17822
+ const stamp = (date) => date.toISOString().slice(0, 10);
17823
+ res.writeHead(200, {
17824
+ "content-type": "text/csv; charset=utf-8",
17825
+ "content-disposition": `attachment; filename="activity-${stamp(range.from)}-${stamp(range.to)}.csv"`,
17826
+ "cache-control": "no-store",
17827
+ });
17828
+ res.end(activityCsv(entries));
17829
+ return;
17830
+ }
17831
+ const rawLimit = Number(url.searchParams.get("limit") ?? 500);
17832
+ const limit = Number.isInteger(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 5_000) : 500;
17833
+ res.setHeader("cache-control", "no-store");
17834
+ return json(res, 200, {
17835
+ entries: entries.slice(0, limit),
17836
+ total: entries.length,
17837
+ from: range.from.toISOString(),
17838
+ to: range.to.toISOString(),
17839
+ retentionDays: boundRetentionDays(),
17840
+ recording: adminActivityRecording(),
17841
+ });
17842
+ }
17843
+ // The same rows for an auditor's spreadsheet, over a date range. Admin
17844
+ // scope by default (not in CLIENT_ALLOW, not a service route).
17845
+ if (method === "GET" && path === "/api/decisions.csv") {
17846
+ const range = parseUsageRange(url.searchParams.get("from"), url.searchParams.get("to"));
17847
+ if (!range)
17848
+ return json(res, 400, { error: "from and to must be YYYY-MM-DD, from no later than to, at most a year apart" });
17849
+ const stamp = (date) => date.toISOString().slice(0, 10);
17850
+ res.writeHead(200, {
17851
+ "content-type": "text/csv; charset=utf-8",
17852
+ "content-disposition": `attachment; filename="decisions-${stamp(range.from)}-${stamp(range.to)}.csv"`,
17853
+ "cache-control": "no-store",
17854
+ });
17855
+ res.end(decisionsCsv(readDecisionRange(DATA_DIR, range)));
17856
+ return;
17857
+ }
16543
17858
  // ── provider instances (model picker) ──
16544
17859
  if (method === "GET" && path === "/api/instances") {
16545
17860
  // Rescan PATH first: this endpoint is how the app answers "what can I
@@ -16550,6 +17865,8 @@ const handleRequest = async (req, res) => {
16550
17865
  return json(res, 200, { instances: await describeInstances() });
16551
17866
  }
16552
17867
  const companyMutation = /^\/api\/instances\/(company\.[\w.-]+)(?:\/|$)/.exec(path);
17868
+ if (hostedModels && path.startsWith("/api/instances/") && method !== "GET")
17869
+ return json(res, 403, { error: HOSTED_PROVIDER_SETTINGS_ERROR });
16553
17870
  if (companyMutation && method !== "GET")
16554
17871
  return json(res, 403, { error: "Company accounts are read-only here. Manage this connection in desktop Settings." });
16555
17872
  const instanceIconPatch = /^\/api\/instances\/([\w.-]+)\/icon$/.exec(path);
@@ -16882,6 +18199,9 @@ const handleRequest = async (req, res) => {
16882
18199
  const parsed = parseMcpServerMutation(name, mcpServerBody(body));
16883
18200
  if (!parsed.ok)
16884
18201
  return json(res, 400, { error: parsed.error });
18202
+ const refusal = mcpPolicyRefusal(name, parsed.server);
18203
+ if (refusal)
18204
+ return json(res, 403, { error: refusal, code: "managed_policy" });
16885
18205
  persistMcpServers({ ...current, [name]: parsed.server });
16886
18206
  return json(res, 201, mcpServerResponse());
16887
18207
  }
@@ -16918,6 +18238,9 @@ const handleRequest = async (req, res) => {
16918
18238
  if (Object.keys(current).length + names.length > MAX_MCP_SERVERS) {
16919
18239
  return json(res, 400, { error: `You can add at most ${MAX_MCP_SERVERS} MCP servers.` });
16920
18240
  }
18241
+ const refused = names.filter(name => mcpPolicyRefusal(name, parsed.servers[name]));
18242
+ if (refused.length)
18243
+ return json(res, 403, { error: `${mcpPolicyRefusal(refused[0], parsed.servers[refused[0]])} Not approved: ${refused.join(", ")}.`, code: "managed_policy" });
16921
18244
  persistMcpServers({ ...current, ...parsed.servers });
16922
18245
  return json(res, 201, { ...mcpServerResponse(), added: names });
16923
18246
  }
@@ -16959,6 +18282,9 @@ const handleRequest = async (req, res) => {
16959
18282
  const parsed = parseMcpServerMutation(name, body, existing.server);
16960
18283
  if (!parsed.ok)
16961
18284
  return json(res, 400, { error: parsed.error });
18285
+ const refusal = mcpPolicyRefusal(name, parsed.server);
18286
+ if (refusal)
18287
+ return json(res, 403, { error: refusal, code: "managed_policy" });
16962
18288
  persistMcpServers({ ...current, [name]: parsed.server });
16963
18289
  return json(res, 200, mcpServerResponse());
16964
18290
  }
@@ -16972,7 +18298,14 @@ const handleRequest = async (req, res) => {
16972
18298
  }
16973
18299
  if ((method === "PUT" || method === "PATCH") && path === "/api/config") {
16974
18300
  const body = await readBody(req);
18301
+ if (hostedModels && ["instances", "anthropic", "openaiCompat", "xai", "opencodeGo"].some(key => Object.hasOwn(body, key)))
18302
+ return json(res, 403, { error: HOSTED_PROVIDER_SETTINGS_ERROR });
16975
18303
  const patch = parseConfigPatch(body);
18304
+ if (hostedModels && patch.defaultModelSelection) {
18305
+ const checked = checkedModelSelection(patch.defaultModelSelection);
18306
+ if (!checked.ok)
18307
+ return json(res, checked.status, { error: checked.error });
18308
+ }
16976
18309
  if (!Object.keys(patch).length)
16977
18310
  return json(res, 400, { error: "nothing to save" });
16978
18311
  const changingVoiceProvider = patch.tts?.provider !== undefined
@@ -17092,21 +18425,22 @@ const handleRequest = async (req, res) => {
17092
18425
  }
17093
18426
  }
17094
18427
  let currentBoxInventory = null;
18428
+ let replacingRejectedBoxToken = false;
17095
18429
  let currentBoxResources = null;
17096
18430
  const journalBoxResources = [];
17097
18431
  const deletingBoxIds = new Set(boxDeletions.map((entry) => entry.boxId));
17098
18432
  if (changingBoxToken && currentBoxToken) {
17099
18433
  currentBoxInventory = await box.listManagedBoxes({ box: { token: currentBoxToken } }, managedBoxOwners());
17100
18434
  if (!currentBoxInventory.available) {
17101
- if (!replacementProvedByDeletion) {
18435
+ replacingRejectedBoxToken = currentBoxInventory.credentialRejected === true && Boolean(nextBoxToken);
18436
+ if (!replacementProvedByDeletion && !replacingRejectedBoxToken) {
17102
18437
  return json(res, 503, {
17103
18438
  error: `${currentBoxInventory.problem ?? "cloud computer inventory is unavailable"}. Keep the current Box account and retry`,
17104
18439
  });
17105
18440
  }
17106
- // The old token may be the reason this deletion is stuck. A
17107
- // target-bound operation/identity proved the replacement belongs
17108
- // to the same account, so do not deadlock credential recovery on
17109
- // an inventory request made with the expired token.
18441
+ // A rejected old key cannot authorize its own rotation. Below,
18442
+ // validate the replacement and prove access to remembered Boxes;
18443
+ // pending deletions retain their target-bound verification.
17110
18444
  currentBoxInventory = null;
17111
18445
  }
17112
18446
  if (currentBoxInventory) {
@@ -17189,7 +18523,14 @@ const handleRequest = async (req, res) => {
17189
18523
  if (!check.ok)
17190
18524
  return json(res, 400, { error: check.message });
17191
18525
  }
17192
- if (changingBoxToken && (!currentBoxToken || replacementProvedByDeletion) && boxRecovery.length > 0) {
18526
+ if (replacingRejectedBoxToken) {
18527
+ // An expired credential cannot authorize its own replacement. Probe
18528
+ // the new account without adopting or retiring any local identities.
18529
+ const replacement = await box.listManagedBoxes({ box: { token: nextBoxToken } }, managedBoxOwners(), { adoptLegacy: false });
18530
+ if (!replacement.available)
18531
+ return json(res, 503, { error: replacement.problem ?? "Could not verify the replacement Box key. Your saved key and computers are unchanged." });
18532
+ }
18533
+ if (changingBoxToken && (!currentBoxToken || replacementProvedByDeletion || replacingRejectedBoxToken) && boxRecovery.length > 0) {
17193
18534
  if (!nextBoxToken) {
17194
18535
  return json(res, 409, { error: "restore the Box account that owns the remembered cloud computers before clearing it" });
17195
18536
  }
@@ -17502,8 +18843,14 @@ const handleRequest = async (req, res) => {
17502
18843
  }
17503
18844
  // ── connectors (Composio) ──
17504
18845
  if (method === "GET" && path === "/api/connectors/catalog") {
17505
- const { cards, source } = await composio.listToolkits(cfg);
17506
- return json(res, 200, { configured: composio.configured(cfg), mode: composio.connectionMode(cfg), source, cards });
18846
+ const { cards, source, pagination } = await composio.listToolkits(cfg);
18847
+ return json(res, 200, {
18848
+ configured: composio.configured(cfg),
18849
+ mode: composio.connectionMode(cfg),
18850
+ source,
18851
+ cards,
18852
+ ...(pagination ? { pagination } : {}),
18853
+ });
17507
18854
  }
17508
18855
  if (method === "GET" && path === "/api/connectors/connected") {
17509
18856
  const availability = composio.connectorAvailability(cfg);
@@ -17907,7 +19254,7 @@ const handleRequest = async (req, res) => {
17907
19254
  catch (e) {
17908
19255
  const status = e?.status ?? 500;
17909
19256
  const candidateCode = e?.code;
17910
- const code = typeof candidateCode === "string" && ["guarded_busy", "guarded_branch", "guarded_permissions"].includes(candidateCode)
19257
+ const code = typeof candidateCode === "string" && ["guarded_busy", "guarded_branch", "guarded_permissions", "guarded_request_changed", "guarded_request_untracked"].includes(candidateCode)
17911
19258
  ? candidateCode : undefined;
17912
19259
  return json(res, status, { error: e instanceof Error ? e.message : String(e), ...(code ? { code } : {}) });
17913
19260
  }
@@ -17919,6 +19266,28 @@ const server = createServer(handleRequest);
17919
19266
  calendarCalls.start();
17920
19267
  // Resolve the edition before accepting requests so /api/edition is never a guess.
17921
19268
  console.log(describeEdition(await loadEnterpriseLayer()));
19269
+ // A key inside its last 30 days, or already in its grace period, says so
19270
+ // where an operator reads first.
19271
+ const startupLicenseWarning = licenseWarning(editionStatus());
19272
+ if (startupLicenseWarning)
19273
+ console.warn(startupLicenseWarning);
19274
+ console.log(LOOPBACK.trust === "service"
19275
+ ? `local requests: service trust (${LOOPBACK.reason}); without a session, loopback may use only health, the Slack worker's guarded routes and bot capability routes`
19276
+ : `local requests: owner trust (${LOOPBACK.reason})`);
19277
+ if (LOOPBACK.warning)
19278
+ console.warn(`local requests: ${LOOPBACK.warning}`);
19279
+ // The decision log also prunes as it writes; this covers a quiet server.
19280
+ // Every room's floor, once, before any request: a room that was already
19281
+ // mixed when this server started keeps its narrowest audience from here on.
19282
+ for (const group of store.groups)
19283
+ settleRoomFloor(group);
19284
+ const pruneDecisionLog = () => {
19285
+ void pruneDecisions(DATA_DIR, decisionRetentionDays(cfg.decisions?.retentionDays));
19286
+ // The admin activity log keeps its months for the same window.
19287
+ void pruneAdminActivity(DATA_DIR, decisionRetentionDays(cfg.decisions?.retentionDays));
19288
+ };
19289
+ pruneDecisionLog();
19290
+ setInterval(pruneDecisionLog, 6 * 60 * 60_000).unref();
17922
19291
  workspaceAccess = createWorkspaceAccess({ sessions, cookieName: SESSION_COOKIE, closeSessionStreams });
17923
19292
  // Ten-second cadence plus the bridge's five-second backchannel deadline bounds
17924
19293
  // stale portal access on quiet event/browser streams to fifteen seconds.
@@ -17990,7 +19359,7 @@ for (const row of chatFollowups()) {
17990
19359
  const messages = store.messagesFor(row.threadId);
17991
19360
  const recovered = messages.find((message) => message.queueId === row.id && message.role === "user") ?? store.appendMessage(row.threadId, {
17992
19361
  role: "user", kind: "text", text: row.payload.text, replyToId: row.payload.replyToId,
17993
- sendId: row.payload.sendId, queueId: row.id,
19362
+ sendId: row.payload.sendId, queueId: row.id, sender: row.payload.sender,
17994
19363
  ...(row.kind === "channel" ? { channelMode: row.payload.mode, via: row.payload.via } : {}),
17995
19364
  });
17996
19365
  // Nor as a message a resumed session has not seen: count it as handed.
@@ -18088,6 +19457,7 @@ const gracefulShutdown = createGracefulShutdown({
18088
19457
  () => flushAllMemoryJournals(),
18089
19458
  () => flushUsageLedger(DATA_DIR),
18090
19459
  () => flushDecisionLog(DATA_DIR),
19460
+ () => flushAdminActivity(DATA_DIR),
18091
19461
  ],
18092
19462
  // Cleanup jobs run concurrently. Release only after they settle (or reach
18093
19463
  // the shutdown deadline), immediately before the process exits, so no new