openmausbot 0.1.86 → 0.1.88

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 (216) hide show
  1. package/README.md +4 -3
  2. package/dist/assets/{abnfDiagram-O67JEVCF-DpUZNnFD.js → abnfDiagram-O67JEVCF-qd9VD3GV.js} +1 -1
  3. package/dist/assets/{arc-DW7mrI4p.js → arc-3RwkUjRe.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CyeZ5WLZ.js → architectureDiagram-NJMV4G6O-CFKSJS7a.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CBVaNjO7.js → blockDiagram-BEXU5L5S-CG1rEfWD.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-sf8d41uT.js → c4Diagram-YGBWAQC7-CBW9N664.js} +1 -1
  7. package/dist/assets/channel-CnRnO3tF.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-BOGvy7Y_.js → chunk-2Q5K7J3B-2mRfTksB.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BiF9zyiz.js → chunk-3FUC2YCW-Cv1dpErj.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-EFuZ1SBZ.js → chunk-5VM5RSS4-B7IACJp-.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-pJLNSAT7.js → chunk-GTNCS2PH-An0hZtYa.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-Cb-3_3IT.js → chunk-GWA4HPMP-BcJB2nEm.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-DAf5aogn.js → chunk-JWPE2WC7-CV_W7TN7.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-DR3kiwZz.js → chunk-NETBCI7D-Bcs_kwnA.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-BmU8PeyM.js → chunk-WEXAMYUT-lrx_DxFP.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-Buh8g8OT.js → chunk-XXDRQBXY-DabHlLGM.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-BurfmMWM.js → classDiagram-v2-NBCMYWYE-BnHj6P7d.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-KkGZUEkU.js → cose-bilkent-JH36ORCC-CfLp6UDi.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-C3PpV96d.js → cynefin-OW5HDTMX-Dm4dbVA1.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-Dt8JwJaI.js → cynefinDiagram-VND7K2PF-B9mH-PID.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-DkjN8266.js → dagre-6A5THRUB-DDc_NVQJ.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-DUAJS0JV.js → diagram-22UHCM2B-lwr8Ip8-.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-Dw4KhF1a.js → diagram-3UASUU5V-BLLe6dmI.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-Dd1gmQqf.js → diagram-ATOU4E4O-6eUIq4lz.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-CjwGs0Yw.js → diagram-CDSNMT55-O-pRN7VQ.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-BL-69Zrq.js → diagram-MLGK6HIB-BietvGAI.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-5Zi2N7wo.js → diagram-MPIPVDR6-Bd2Cl62X.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-D9NekWUX.js → ebnfDiagram-ZINNZB2B-B6iDy69W.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-CkaSQjxw.js → elk-276RUBZZ-DQVHfKe0.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-CdAVBV8-.js → erDiagram-OPXOYQCR-BWdVp2tQ.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-Bm_lWWH7.js → flowDiagram-KWPJA3E3-WLE6rKB4.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-FZ8ejYWJ.js → ganttDiagram-FUAMR5RP-BbjS-4gn.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-Dlv9rizZ.js → gitGraphDiagram-X574FWY7-Dpy2QSGq.js} +1 -1
  34. package/dist/assets/index-BIMdIbaw.css +1 -0
  35. package/dist/assets/index-CHnODCr2.js +583 -0
  36. package/dist/assets/{index-h9FFNwTu.js → index-CIaHn5NS.js} +1 -1
  37. package/dist/assets/{infoDiagram-VRGFBTTK-BdQ_gW5B.js → infoDiagram-VRGFBTTK-D_jKWlKe.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-EwssEp2X.js → ishikawaDiagram-OU5B5YK6-DLJohSuK.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-BX1G1vOd.js → journeyDiagram-ZHPQQLJL-DPOFj0qN.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-BB4kAsyC.js → kanban-definition-PNTS6WVX-BI7v-xpK.js} +1 -1
  41. package/dist/assets/{linear-3su2TG93.js → linear-C2pWPSxO.js} +1 -1
  42. package/dist/assets/{mermaid.core-CDD1E0s9.js → mermaid.core-CR-j5WQU.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-Ba50MsNK.js → mindmap-definition-NLK3R4M7-lz9GvXRh.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-DZLkkZlM.js → pegDiagram-GJSIUBJH-Dl4_ia61.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-DaTz77Uq.js → pieDiagram-5QR66LMP-DWuT3n88.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-CpaiidpR.js → quadrantDiagram-O4NWA36T-DXWe9Jx7.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-mN4JrMeI.js → railroadDiagram-XR7U4H2S-COY1IBVY.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-BSNDRpg7.js → requirementDiagram-PLB6GJNP-DnyVuhsQ.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-FngzGNug.js → sankeyDiagram-IPEJSGJF-BSc4NdGK.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-CM4pCC3L.js → sequenceDiagram-PO4LG4MO-kqEU2EGq.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-CRICHXkw.js → sizeCapture-INFHLROL-BvB4bkWD.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-oyTWpxH2.js → stateDiagram-v2-GCMORJYK-BIrqNv7y.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-D9h9mPBq.js → swimlanes-2SLR337P-CFnalQUE.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-C4a8qNGj.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BJUlN9bW.js → timeline-definition-EJHVYXUP-CsSWJnSQ.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-3uBNkbAb.js → usecaseDiagram-POWQR4AR-DkAmJ1EE.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-D2odalzT.js → vennDiagram-UO4OBE2U-C3vd9drL.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DHgFaB9S.js → wardleyDiagram-VNRHLVJA-j26CM-pi.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-w5GYM2h0.js → xychartDiagram-PMCCYNJV-BYu1Q_at.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/browser-proxy.js +2 -1
  62. package/dist-server/companion/src/routes.js +8 -1
  63. package/dist-server/connector-proxy.js +105 -1
  64. package/dist-server/container-mcp.js +1032 -165
  65. package/dist-server/drivers/agents-proxy.js +338 -29
  66. package/dist-server/drivers/phone-proxy.js +33 -2
  67. package/dist-server/index.js +40590 -31811
  68. package/dist-server/local-computer-proxy.js +5 -3
  69. package/dist-server/local-computer.js +53 -17
  70. package/dist-server/mcp-gate.js +2 -0
  71. package/dist-server/mcp-server.js +5 -3
  72. package/dist-server/message-search.worker.js +52 -0
  73. package/dist-server/openmausbot.js +4457 -1399
  74. package/dist-server/pair-cli.js +4457 -1399
  75. package/dist-server/permission-proxy.js +29 -9
  76. package/dist-server/server/admission.js +81 -0
  77. package/dist-server/server/aside-queue.js +294 -0
  78. package/dist-server/server/attachments.js +98 -1
  79. package/dist-server/server/auto-approve.js +4 -3
  80. package/dist-server/server/auto-vm-claims.js +1 -0
  81. package/dist-server/server/bot-attachment.js +107 -0
  82. package/dist-server/server/bot-overview.js +19 -0
  83. package/dist-server/server/bot-package.js +18 -292
  84. package/dist-server/server/bot-profile.js +5 -17
  85. package/dist-server/server/box-create-idempotency.js +3 -3
  86. package/dist-server/server/box.js +57 -55
  87. package/dist-server/server/browser-proxy.js +3 -2
  88. package/dist-server/server/browser-runtime.js +37 -1
  89. package/dist-server/server/cli-api-setup.js +1 -0
  90. package/dist-server/server/cli.js +2 -2
  91. package/dist-server/server/command-allowlist.js +154 -0
  92. package/dist-server/server/composio.js +297 -1
  93. package/dist-server/server/computer-wait.js +22 -9
  94. package/dist-server/server/config.js +84 -12
  95. package/dist-server/server/connector-advertisement.js +165 -0
  96. package/dist-server/server/connector-proxy.js +14 -2
  97. package/dist-server/server/connector-verdict.js +168 -0
  98. package/dist-server/server/container-computer.js +61 -1
  99. package/dist-server/server/contracts.js +22 -0
  100. package/dist-server/server/default-model-selection.js +9 -0
  101. package/dist-server/server/delegations.js +85 -34
  102. package/dist-server/server/drivers/acp/core.js +80 -20
  103. package/dist-server/server/drivers/acp/cursor.js +3 -7
  104. package/dist-server/server/drivers/acp/droid.js +4 -7
  105. package/dist-server/server/drivers/acp/gemini.js +4 -4
  106. package/dist-server/server/drivers/acp/grok.js +4 -9
  107. package/dist-server/server/drivers/acp/hermes.js +3 -5
  108. package/dist-server/server/drivers/acp/opencode-go.js +2 -5
  109. package/dist-server/server/drivers/acp/qwen.js +9 -15
  110. package/dist-server/server/drivers/agents-call.js +179 -13
  111. package/dist-server/server/drivers/agents-catalog.js +134 -10
  112. package/dist-server/server/drivers/boxagent.js +160 -36
  113. package/dist-server/server/drivers/builtIn.js +2 -0
  114. package/dist-server/server/drivers/chat-box-tools.js +90 -0
  115. package/dist-server/server/drivers/chat-images.js +54 -0
  116. package/dist-server/server/drivers/chat-mcp-tools.js +38 -8
  117. package/dist-server/server/drivers/chat-tool-approval.js +70 -18
  118. package/dist-server/server/drivers/claude.js +38 -19
  119. package/dist-server/server/drivers/codex-catalog.js +14 -6
  120. package/dist-server/server/drivers/codex-identity.js +2 -1
  121. package/dist-server/server/drivers/codex-instructions.js +48 -6
  122. package/dist-server/server/drivers/codex-release.js +48 -0
  123. package/dist-server/server/drivers/codex-tool-surface.js +20 -0
  124. package/dist-server/server/drivers/codex.js +102 -55
  125. package/dist-server/server/drivers/mistral.js +115 -0
  126. package/dist-server/server/drivers/openai-chat.js +180 -33
  127. package/dist-server/server/drivers/openai-compat.js +1 -0
  128. package/dist-server/server/drivers/permission-command.js +35 -0
  129. package/dist-server/server/drivers/phone-proxy.js +47 -2
  130. package/dist-server/server/drivers/pi.js +306 -28
  131. package/dist-server/server/drivers/prompt-split.js +110 -0
  132. package/dist-server/server/drivers/session-idle.js +29 -0
  133. package/dist-server/server/env-path.js +15 -0
  134. package/dist-server/server/fleet.js +1 -1
  135. package/dist-server/server/group-thread-usage.js +103 -0
  136. package/dist-server/server/guarded-requests.js +2 -2
  137. package/dist-server/server/harness/registry.js +1 -0
  138. package/dist-server/server/harness-capabilities.js +46 -0
  139. package/dist-server/server/hosted-models.js +1 -1
  140. package/dist-server/server/index.js +2396 -505
  141. package/dist-server/server/local-computer.js +72 -23
  142. package/dist-server/server/managed-desktop.js +1 -1
  143. package/dist-server/server/mcp-bridge.js +6 -6
  144. package/dist-server/server/message-db.js +22 -42
  145. package/dist-server/server/message-file.js +9 -1
  146. package/dist-server/server/message-search-query.js +43 -0
  147. package/dist-server/server/message-search-worker.js +80 -0
  148. package/dist-server/server/message-search.worker.js +21 -0
  149. package/dist-server/server/model-requests.js +248 -0
  150. package/dist-server/server/new-bot-defaults.js +115 -0
  151. package/dist-server/server/options-card.js +21 -0
  152. package/dist-server/server/org-library.js +1065 -0
  153. package/dist-server/server/package-export.js +627 -39
  154. package/dist-server/server/package-import.js +458 -0
  155. package/dist-server/server/package-parts.js +87 -0
  156. package/dist-server/server/peer-approval.js +16 -7
  157. package/dist-server/server/peer-delivery.js +60 -0
  158. package/dist-server/server/peer-roster.js +67 -0
  159. package/dist-server/server/presets.js +492 -0
  160. package/dist-server/server/profile-requests.js +66 -39
  161. package/dist-server/server/profile-revision.js +9 -1
  162. package/dist-server/server/profile-versions.js +6 -3
  163. package/dist-server/server/provider-key-check.js +3 -2
  164. package/dist-server/server/published-teams.js +80 -0
  165. package/dist-server/server/room-handoffs.js +19 -3
  166. package/dist-server/server/routes/bot-presets.js +25 -0
  167. package/dist-server/server/routine-requests.js +35 -10
  168. package/dist-server/server/routines.js +41 -1
  169. package/dist-server/server/section-context.js +10 -0
  170. package/dist-server/server/skill-fetch.js +72 -14
  171. package/dist-server/server/skills.js +35 -61
  172. package/dist-server/server/steer-queue.js +3 -2
  173. package/dist-server/server/store.js +291 -17
  174. package/dist-server/server/surface.js +10 -4
  175. package/dist-server/server/system-prompt.js +38 -6
  176. package/dist-server/server/team-backup.js +6 -2
  177. package/dist-server/server/team-computers.js +10 -0
  178. package/dist-server/server/team-library.js +6 -2
  179. package/dist-server/server/team-manifest.js +4 -3
  180. package/dist-server/server/team-setup-requests.js +139 -27
  181. package/dist-server/server/thread-events.js +20 -2
  182. package/dist-server/server/tightening-requests.js +405 -0
  183. package/dist-server/server/tts/chatterbox.js +27 -5
  184. package/dist-server/server/turn-context.js +2 -0
  185. package/dist-server/server/turn-watchdog.js +12 -3
  186. package/dist-server/server/usage-ledger.js +19 -2
  187. package/dist-server/server/vps-computer.js +139 -51
  188. package/dist-server/server/vps-container-mcp.js +7 -0
  189. package/dist-server/server/vps-ssh.js +36 -7
  190. package/dist-server/server/workspace-backup-policy.js +12 -0
  191. package/dist-server/server/workspace-backup.js +42 -5
  192. package/dist-server/server/workspace-backup.worker.js +10 -0
  193. package/dist-server/server/workspace.js +1 -1
  194. package/dist-server/shared/approval-mode.js +1 -1
  195. package/dist-server/shared/ask-question.js +207 -12
  196. package/dist-server/shared/bot-profile.js +15 -0
  197. package/dist-server/shared/command-allowlist.js +1 -0
  198. package/dist-server/shared/credential-request.js +6 -2
  199. package/dist-server/shared/line-diff.js +23 -0
  200. package/dist-server/shared/model-request.js +1 -0
  201. package/dist-server/shared/options-card.js +51 -0
  202. package/dist-server/shared/package-format.js +967 -0
  203. package/dist-server/shared/profile-request.js +5 -4
  204. package/dist-server/shared/skill-md.js +64 -0
  205. package/dist-server/shared/team-backup.js +7 -0
  206. package/dist-server/shared/tightening-request.js +87 -0
  207. package/dist-server/shared/wire.js +4 -0
  208. package/dist-server/src/lib/composer-attachments.js +25 -3
  209. package/dist-server/vps-container-mcp.js +1132 -171
  210. package/dist-server/workspace-backup.worker.js +19474 -0
  211. package/enterprise/README.md +1 -1
  212. package/package.json +1 -1
  213. package/dist/assets/channel-BXFkgus9.js +0 -1
  214. package/dist/assets/index-BGgqlWPJ.css +0 -1
  215. package/dist/assets/index-BtHo8ZKl.js +0 -577
  216. package/dist/assets/swimlanesDiagram-TC7HE7FX-BWrvB2ak.js +0 -8
@@ -3,7 +3,7 @@
3
3
  // folds one SSE event stream; every provider process runs here.
4
4
  import { AsyncLocalStorage } from "node:async_hooks";
5
5
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
6
- import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs";
6
+ import { existsSync, readFileSync, realpathSync, rmSync, mkdirSync } from "node:fs";
7
7
  import { writeFileAtomic } from "./atomic.js";
8
8
  import { rm as removeDirectory } from "node:fs/promises";
9
9
  import { createServer } from "node:http";
@@ -24,8 +24,9 @@ import { BOT_PROFILE_LIMITS } from "../shared/bot-profile.js";
24
24
  import { CLOUD_COMPUTER_BUSY_ERROR } from "../shared/computer-contention.js";
25
25
  import { approvalModeFor, supportsApprovalMode, modelSwitchNeedsAsk, isEmergencyApprovalDowngrade, isApprovalMode, } from "../shared/approval-mode.js";
26
26
  import { escapeAttribute } from "../src/lib/composer-attachments.js";
27
- import { CREDENTIAL_TARGETS, credentialResumeOutcome, credentialIsConfigured, isReusableCredentialRequest, isCredentialTargetId, } from "../shared/credential-request.js";
27
+ import { CREDENTIAL_TARGETS, credentialResumeOutcome, credentialIsConfigured, isPendingCredentialRequest, isCredentialTargetId, } from "../shared/credential-request.js";
28
28
  import { approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval, delegationInheritsFullAccess } from "./auto-approve.js";
29
+ import { CommandAllowlistStore, commandAllowlistCandidate } from "./command-allowlist.js";
29
30
  import { updateClaudeCli } from "./claude-update.js";
30
31
  import { configuredAccountDirectory, assertSeparateClaudeAccount, claudeAccountInfo, createClaudeAccountSchema, instanceSettingsSchema, newClaudeAccount } from "./claude-accounts.js";
31
32
  import { providerIconPatchSchema, withInstanceIcon } from "./provider-icon.js";
@@ -36,7 +37,7 @@ import { commandReceipt } from "./commands.js";
36
37
  import { buildTurnDigest, coverageForDriver, digestPromptLine, renderDigest, toolEvidence } from "./digest.js";
37
38
  import { appendDecision, bindDecisionRetention, boundRetentionDays, decisionRetentionDays, decisionsCsv, flushDecisionLog, pruneDecisions, readDecisionRange, readDecisions, withDecisionActor } from "./decision-log.js";
38
39
  import { validateBotCwd } from "./bot-cwd.js";
39
- import { ATTACHMENTS_DIR, attachmentExists, cleanupStaleAttachmentPartials, deleteAttachment, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, readAttachment, saveFile, saveImage, saveImageUpload, validateAttachmentUploadId, } from "./attachments.js";
40
+ import { ATTACHMENTS_DIR, attachmentExists, cleanupStaleAttachmentPartials, deleteAttachment, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, parseAudioRange, readAttachment, saveAudio, saveFile, saveImage, saveImageUpload, validateAttachmentUploadId, } from "./attachments.js";
40
41
  import { messageFileDisposition, messageFileDownloadName, messageAttachmentName, messageFileRoots, messageImageTargetAt, messageReferencesFile, openMessageFile, } from "./message-file.js";
41
42
  import { avatarGenerationRequestSchema, avatarGenerationStateMatches, generateAvatarImage, avatarImageStatus, snapshotAvatarGenerationState, } from "./avatar-image.js";
42
43
  import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
@@ -49,16 +50,19 @@ import { boxCreateRecoverySnapshot, retireDeletedBoxCreate } from "./box-create-
49
50
  import { boxDeletionSnapshot } from "./box-delete-journal.js";
50
51
  import { boxAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourceChangeError, } from "./cloud-backend.js";
51
52
  import * as composio from "./composio.js";
53
+ import { connectorCallFromFrame, connectorRefusalText, connectorUnrecognizedText, evaluateConnectorTools } from "./connector-verdict.js";
52
54
  import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
53
- import { canAccessTeam, canReachPeer, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
55
+ import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, livePeerRosterBlock, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
54
56
  import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
55
- import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerRuntimeStatus, localVmRecreatableOnDemand, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, } from "./container-computer.js";
57
+ import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerExec, containerRuntimeStatus, localVmRecreatableOnDemand, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, VM_WORKSPACE_GUEST, } from "./container-computer.js";
58
+ import { attachForTurn, saveBotAttachment } from "./bot-attachment.js";
56
59
  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";
57
60
  import { sweepThreadEventLogs } from "./thread-retention.js";
58
61
  import { ComputerControl } from "./computer-control.js";
59
62
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
60
63
  import { registerEnginesBinDir } from "./engine-install.js";
61
64
  import { appendUsage, parseUsageRange, readUsage, summarizeUsage, usageCsv, USAGE_GROUPINGS, flushUsageLedger } from "./usage-ledger.js";
65
+ import { GroupUsageReader } from "./group-thread-usage.js";
62
66
  import { ledgerCost } from "./model-prices.js";
63
67
  import { checkProviderKey, PROVIDER_KEY_KINDS } from "./provider-key-check.js";
64
68
  import { assertWithinBudget, noteSpend, spendAlertText, spendState, takeSpendAlert } from "./spend.js";
@@ -75,14 +79,16 @@ import { probeMcpServer } from "./mcp-probe.js";
75
79
  import { GROUP_GOAL_MAX_TURNS, groupGoalAssignmentKey, groupGoalCompletionTurnId, groupGoalCoordinatorInstructions, groupGoalWorkerInstructions, parseGroupGoalDecision, resolveGroupGoalMember, selectGroupGoalCoordinator, } from "./group-goal-run.js";
76
80
  import { BUILT_IN_DRIVERS } from "./drivers/builtIn.js";
77
81
  import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from "./comms-visibility.js";
78
- import { readMessageText, recallMessages, recentMessages, searchMessages, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups, threadsReferencing } from "./message-db.js";
82
+ import { readMessageText, recallMessages, recentMessages, searchMessagesAsync, closeMessageSearch, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups, threadsReferencing } from "./message-db.js";
79
83
  import { briefCrossingLabel, claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
80
84
  import { parseSince, parseUntil, recentWork, recentWorkPrompt, turnOutcomeLine } from "./recent-work.js";
81
85
  import { chiefForBot, INCIDENTS_THREAD_TITLE, IncidentLedger, incidentChip, incidentText } from "./incidents.js";
82
86
  /** A session_read answer competes with the transcript for the context
83
87
  * window; a computer-use turn's output can run to hundreds of KB. */
84
88
  const SESSION_READ_MAX_CHARS = 8_000;
89
+ import { attemptAsideInjection, drainAsideMessages, queueAsideMessage, restoreAsideMessages, } from "./aside-queue.js";
85
90
  import { promptWithReply, transcriptText } from "./replies.js";
91
+ import { admit } from "./admission.js";
86
92
  import { _loadPending, buildDelegationFailurePrompt, buildDelegationRevivalPrompt, DELEGATION_TTL_MS, DelegationWakeBudget, discardDelegations, drainDelegations, expireStaleDelegations, findDelegationReceipt, pendingDelegationInfo, pendingDelegationSnapshot, pendingThreads, queueDelegation, recordDelegationReceipt, releaseDelegationsWaitingOn, summarizeDelegatedActivity } from "./delegations.js";
87
93
  import { cancelSteeredMessage, drainSteeredMessages, hasQueuedSteeredMessages, holdSteeredQueue, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, restoreHeldSteeredQueue, restoreSteeredMessages, settleHeldSteeredQueue, } from "./steer-queue.js";
88
94
  import { cancelChannelMessage, drainChannelMessages, holdChannelQueue, queuedChannelMessage, queueChannelMessage, restoreChannelMessages, restoreHeldChannelQueue, resolveHeldReplyTarget, settleHeldChannelQueueHead, } from "./channel-queue.js";
@@ -92,30 +98,33 @@ import { ProviderRegistry } from "./harness/registry.js";
92
98
  import { ManagedDesktopProviders } from "./managed-desktop.js";
93
99
  import { computerKindForResource, ManagedDesktopPolicy } from "./managed-policy.js";
94
100
  import { hostedModelPolicy, HOSTED_MODEL_POLICY_HEADER, HOSTED_PROVIDER_SETTINGS_ERROR } from "./hosted-models.js";
95
- import { selectDefaultModelSelection } from "./default-model-selection.js";
96
- import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
101
+ import { selectDefaultModelSelection, withNewBotEffort } from "./default-model-selection.js";
102
+ import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, peerApprovalFailure, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
103
+ import { peerDeliveryReceipt } from "./peer-delivery.js";
97
104
  import { peerProvenanceNote, withPeerProvenance } from "./peer-provenance.js";
98
105
  import { decideRoomPost, emptyRoomPostBudget } from "./room-post-budget.js";
99
- import { isProjectEmoji, mentionedBots, roomResponders, sectionKey, Store, titleFromLlm, toWireTask, } from "./store.js";
106
+ import { isProjectEmoji, mentionedBots, parseConnectorTools, roomResponders, sectionKey, Store, titleFromLlm, toWireTask, } from "./store.js";
100
107
  import * as tts from "./tts/index.js";
101
108
  import { narrateTool, toUtterances } from "./tts/speech-text.js";
102
- import { buildRecoveryText, buildTurnContext, engineIsFresh, peerMessageText } from "./turn-context.js";
109
+ import { buildRecoveryText, buildTurnContext, engineIsFresh, NATIVELY_REPLAYING_DRIVER_KINDS, peerMessageText } from "./turn-context.js";
103
110
  import { Handoffs, handedStateUsable, recordHanded, renderUnseen, sessionStart, unseenMessages, withUnseenMessages } from "./delta-context.js";
104
111
  import { extractTurnImages } from "./turn-images.js";
105
112
  import { TurnWatchdog } from "./turn-watchdog.js";
106
113
  import { TurnResources, workspaceResource } from "./turn-resources.js";
107
114
  import { ensureWorkspace, ensureTaskWorkspace, workspaceLocationsPrompt, supportsWorkspaceFiles, updateMemory, appendMemoryLog, isMemoryTopicName, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, workspaceDir, } from "./workspace.js";
108
- import { readMemoryTopic } from "./workspace.js";
115
+ import { listMemoryTopics, readMemoryFile, readMemoryTopic, writeMemoryFile, writeMemoryTopic } from "./workspace.js";
109
116
  import { MEMORY_INDEX, MemoryStoreError, memoryCapacity, memoryOverview, openMemoryLocation, readMemoryDoc, } from "./memory-store.js";
110
117
  import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, flushMemoryJournal, journalMemoryDelete, journalMemoryWrite, readMemoryJournal, revertMemoryChange, } from "./memory-journal.js";
111
118
  import { readSectionContext, readSections, sectionContextKey, sectionContextLabel, sectionContextSystemPrompt, writeSectionContext, SECTION_CONTEXT_MAX_BYTES, } from "./section-context.js";
112
- import { applyStagedSkillWrite, applySkillWriteWithReceipt, getStagedSkillWrite, installSkill, listSkills, listStagedSkillWrites, isSkillName, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
119
+ import { applyStagedSkillWrite, applySkillWriteWithReceipt, getStagedSkillWrite, installOrgSkill, installSkill, parseSkillMd, scanSkillText, listSkills, listStagedSkillWrites, isSkillName, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillPackageStamps, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
113
120
  import { fetchSkillFromSource } from "./skill-fetch.js";
121
+ import { pickBotName } from "./names.js";
122
+ import { botSkillTemplateSchema, newBotDefaultsSchema, resolveBotCreationDefaults } from "./new-bot-defaults.js";
114
123
  import { expandLearnTurnText, learnSource } from "./skill-learn.js";
115
124
  import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
116
125
  import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-folder.js";
117
- import { buildSystemPrompt, computerPrompt, COMPOSIO_PROMPT, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, } from "./system-prompt.js";
118
- import { readCuaConnection, gatedLocalComputer } from "./local-computer.js";
126
+ import { buildSystemPrompt, userProfileSystemPrompt, computerPrompt, composioSystemPrompt, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, } from "./system-prompt.js";
127
+ import { readCuaConnection, readCuaUnavailableReason, gatedLocalComputer } from "./local-computer.js";
119
128
  import { discoverExistingPerBotLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
120
129
  import { LocalVmIdleTimer } from "./local-vm-idle.js";
121
130
  import { LocalVmLeasePool } from "./local-vm-lease.js";
@@ -131,17 +140,19 @@ import { agentBrowserFrame, agentBrowserIntegration, browserEngineEncryptionKey,
131
140
  import { createScreenFrameSource } from "./screen-frame-source.js";
132
141
  import { screenFrameHash, screenSurfaceForTool, screenTouchingTool, settledFrameIsNews } from "./screen-frame-gate.js";
133
142
  import { RoutineRequestService } from "./routine-requests.js";
143
+ import { createOptionsCard } from "./options-card.js";
134
144
  import { buildBotOverview, connectedAppsFacts } from "./bot-overview.js";
135
145
  import { ProfileRequestService } from "./profile-requests.js";
146
+ import { ModelRequestService } from "./model-requests.js";
147
+ import { TighteningRequestService } from "./tightening-requests.js";
136
148
  import { TeamSetupError, TeamSetupRequestService } from "./team-setup-requests.js";
137
149
  import { profileRevision, profileSnapshot } from "./profile-revision.js";
138
150
  import { flushAllProfileHistory, flushProfileHistory, readHistory, recordProfileChange } from "./profile-versions.js";
139
151
  import { fetchBotDirectory, matchDirectoryBots } from "./bot-directory.js";
140
152
  import { scoutProject, suggestTeam } from "./project-scout.js";
141
153
  import { fetchGithubTeam, fetchLibraryTeam, fetchTeamCatalog } from "./team-library.js";
142
- import { BOT_PACKAGE_MAX_SKILLS, isBotPackage, packageAgentAsMember, parseBotPackage, renderBotPackageMarkdown } from "./bot-package.js";
143
- import { createTeamManifest, importedMemberProfile, parseTeamManifest } from "./team-manifest.js";
144
- import { takeImportName } from "../shared/import-name.js";
154
+ import { BOT_PACKAGE_MAX_SKILLS, isBotPackage, packageSummary, parsePackageDocument, renderBotPackageMarkdown } from "./bot-package.js";
155
+ import { createTeamManifest, parseTeamManifest } from "./team-manifest.js";
145
156
  import { readThreadEvents } from "./thread-events.js";
146
157
  import { bindThreadLogCapProvider } from "./thread-log-rotation.js";
147
158
  import { listenWebhookIngress, webhookCredential } from "./webhook-ingress.js";
@@ -150,13 +161,17 @@ import { WebhookManager } from "./webhooks.js";
150
161
  import { SPAWNED_PROXIES } from "./proxy-paths.js";
151
162
  import { loadBundledSkills, loadUserSkills, mergeSkills, renderSkillInstructions, selectBundledSkills } from "./skill-library.js";
152
163
  import { installedPlaybookInstructions } from "./installed-playbooks.js";
153
- import { createBotPackageExport } from "./package-export.js";
164
+ import { createBotPackageExport, createLibraryPackageExport, createTeamPackageExport, picture as sharedPicture, TeamExportError } from "./package-export.js";
165
+ import { importPackageDocument, importTeamManifest, PackageImportError } from "./package-import.js";
166
+ import { applyPresetToBot, createPresetStore, DEFAULTS_PRESET_KEY, PRESET_UNAVAILABLE_MESSAGE, presetFromDefaults, presetOffered, readPublishedLibrary, writePublishedLibrary, } from "./presets.js";
167
+ import { readPublishedTeam, writePublishedTeam } from "./published-teams.js";
168
+ import { OrgLibrary } from "./org-library.js";
154
169
  import { createTeamBackup, importTeamBackup } from "./team-backup.js";
155
170
  import { MAX_TEAM_BACKUP_BYTES } from "../shared/team-backup.js";
156
171
  import { shouldMountLocalComputer } from "./local-routing.js";
157
172
  import { autoLocalVmAttachable } from "./container-computer.js";
158
173
  import { startAutoVmClaim } from "./auto-vm-claims.js";
159
- import { computerFreeText, computerStillBusyText, computerWaitEndedText, computerWaitingText } from "./computer-wait.js";
174
+ import { computerFreeAfterText, computerStillBusyText, computerStoppedWaitingText, computerWaitingText } from "./computer-wait.js";
160
175
  import { modelContextWindow } from "./model-context-window.js";
161
176
  import { parseSurface, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
162
177
  import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
@@ -183,6 +198,7 @@ import { PHONE_SECRET_PROTOCOL_VERSION, PhoneSecretBridge, PhoneSecretError, Pho
183
198
  import { json, onJsonBody, parsedBodyOf, readBody } from "./harness/http.js";
184
199
  import { ROUTES, dispatchRoutes } from "./routes/table.js";
185
200
  import { createHostedSlackRoutes } from "./routes/hosted-slack.js";
201
+ import { createBotPresetRoutes } from "./routes/bot-presets.js";
186
202
  const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799);
187
203
  const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1);
188
204
  // Behind a proxy or tunnel, the base URL senders should use (docs/self-hosting.md).
@@ -248,6 +264,7 @@ const sessions = new SessionRegistry({
248
264
  const sharedComputers = new SharedComputers(id => sessions.isLive(id));
249
265
  // Who each thread is for, when a signed-in person can be named (server-private).
250
266
  const threadStarters = new ThreadStarters(join(DATA_DIR, "thread-starters.json"));
267
+ const commandAllowlist = new CommandAllowlistStore(join(DATA_DIR, "command-allowlist.json"));
251
268
  const SESSION_COOKIE = sessionCookieName(PORT, ENVIRONMENT_ID);
252
269
  const HOSTED_WORKSPACE = hostedWorkspaceConfigured();
253
270
  let workspaceAccess = null;
@@ -416,6 +433,28 @@ function cardAnswerRefusal(auth, threadId, requestId, behavior) {
416
433
  return null;
417
434
  return "Only the person who started this conversation or sent this request, or a workspace admin, can answer this card.";
418
435
  }
436
+ function canManageCommandAllowlist(auth) {
437
+ return auth.kind === "loopback" ? auth.trust !== "service" : auth.scopes.includes("admin");
438
+ }
439
+ const COMMAND_ALLOWLIST_DRIVERS = new Set([
440
+ "claudeAgent", "codex", "antigravityAgent", "grokAgent", "opencodeGo", "kimiAgent",
441
+ "droidAgent", "cursorAgent", "qwenAgent", "geminiAgent", "hermesAgent", "customAcp",
442
+ ]);
443
+ function commandAllowlistResponse(bot) {
444
+ const instance = registry.get(bot.modelSelection.instanceId);
445
+ const task = store.taskByThread(bot.id, bot.threadId);
446
+ let cwd = task?.cwd !== undefined ? task.cwd : bot.cwd ?? join(realpathSync(DATA_DIR), "task-workspaces", bot.id, bot.threadId);
447
+ if (cwd && existsSync(cwd))
448
+ cwd = realpathSync(cwd);
449
+ return {
450
+ rules: commandAllowlist.list(bot.id),
451
+ supported: COMMAND_ALLOWLIST_DRIVERS.has(instance?.driverKind ?? ""),
452
+ context: {
453
+ providerInstanceId: bot.modelSelection.instanceId,
454
+ cwd,
455
+ },
456
+ };
457
+ }
419
458
  function decisionActorFor(auth) {
420
459
  if (auth.kind === "loopback")
421
460
  return auth.trust === "service" ? { kind: "worker" } : { kind: "loopback" };
@@ -438,7 +477,7 @@ function cardAnswererFor(auth) {
438
477
  async function answeringCardAs(auth, threadId, requestId, work) {
439
478
  const open = (() => {
440
479
  const card = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId)?.card;
441
- return Boolean(card && !card.answered && !card.dismissed);
480
+ return Boolean(card && !card.answered && !card.dismissed && !card.expired);
442
481
  })();
443
482
  try {
444
483
  await withDecisionActor(decisionActorFor(auth), work);
@@ -655,7 +694,7 @@ function adminAuditPlan(method, path) {
655
694
  return null;
656
695
  const plan = {
657
696
  config: requiredScope(method, path, { sharedComputers: sharedComputersEnabled(cfg) }) === "admin",
658
- createsBots: method === "POST" && (path === "/api/bots" || path === "/api/teams/import"),
697
+ createsBots: method === "POST" && (path === "/api/bots" || path === "/api/teams/import" || path === "/api/org-library/add"),
659
698
  createsWebhook: method === "POST" && path === "/api/webhooks",
660
699
  pairing: method === "POST" && path === "/api/auth/pairing",
661
700
  };
@@ -994,6 +1033,24 @@ utilityParentPort?.on("message", event => {
994
1033
  }
995
1034
  utilityParentPort.postMessage({ type: "openmausbot:managed-desktop-result", requestId, ok });
996
1035
  });
1036
+ // The organization library (server/org-library.ts): Electron relays the
1037
+ // verified catalog; the runtime swaps it and acknowledges at once, then
1038
+ // reconciles and reports on its own. A relay that arrives before the library
1039
+ // exists waits for it (the acknowledgement does not).
1040
+ let orgLibrary = null;
1041
+ let pendingOrgLibraryRelay = null;
1042
+ utilityParentPort?.on("message", event => {
1043
+ const message = event.data;
1044
+ if (message?.type !== "openmausbot:managed-library")
1045
+ return;
1046
+ const requestId = typeof message.requestId === "string" && message.requestId.length <= 100 ? message.requestId : undefined;
1047
+ let ok = true;
1048
+ if (orgLibrary)
1049
+ ok = orgLibrary.applyRelay(message.library ?? null).ok;
1050
+ else
1051
+ pendingOrgLibraryRelay = { library: message.library ?? null };
1052
+ utilityParentPort.postMessage({ type: "openmausbot:managed-desktop-result", requestId, ok });
1053
+ });
997
1054
  // Only Electron owns this port. There is deliberately no HTTP equivalent or
998
1055
  // config patch for its organization identity, endpoint, or model capability.
999
1056
  utilityParentPort?.on("message", event => {
@@ -1007,6 +1064,7 @@ utilityParentPort?.on("message", event => {
1007
1064
  utilityParentPort.postMessage({ type: "openmausbot:managed-desktop-result", requestId, ok: false, error: "Company connection could not be applied. Reconnect from desktop Settings." });
1008
1065
  });
1009
1066
  });
1067
+ const MAX_ATTACHED_FILES_PER_TURN = 10;
1010
1068
  // A capability lives for the exact provider-turn generation, including while
1011
1069
  // that turn is parked on a human approval. The long ceiling is only an orphan
1012
1070
  // backstop for an impossible-to-settle adapter; normal terminal paths revoke
@@ -1215,6 +1273,12 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation, r
1215
1273
  // The shared-computer tools are advertised only while the workspace
1216
1274
  // gate is on; the routes behind them refuse regardless.
1217
1275
  OMB_SHARED_COMPUTERS_ENABLED: sharedComputersEnabled(cfg) ? "1" : "0",
1276
+ // Same capability rule for voice: the tool is offered only when this
1277
+ // bot can actually speak, and the route re-checks on every call.
1278
+ OMB_VOICE_NOTES: (() => {
1279
+ const speaking = botForThread(botId, threadId) ?? store.bot(botId);
1280
+ return tts.voiceReady(cfg, speaking?.voice) && speaking?.voiceNotes !== false ? "1" : "0";
1281
+ })(),
1218
1282
  },
1219
1283
  };
1220
1284
  }
@@ -1236,6 +1300,8 @@ function hooksIntegration(botId, threadId, generation) {
1236
1300
  }
1237
1301
  class DirectTurnSetupCancelled extends Error {
1238
1302
  }
1303
+ class ComputerWaitGaveUp extends Error {
1304
+ }
1239
1305
  const directTurnDispatchClaims = new Map();
1240
1306
  const directTurnGenerationByThread = new Map();
1241
1307
  // Only the latest direct request per thread is retained. A fresh user turn
@@ -1252,10 +1318,10 @@ function settleDirectCoordination(generation, outcome) {
1252
1318
  directCoordinationSettlers.delete(generation);
1253
1319
  settle?.(outcome);
1254
1320
  }
1255
- function settleDirectFollowup(generation) {
1321
+ function settleDirectFollowup(generation, outcome = { ok: false, text: "The coordinated turn was interrupted" }) {
1256
1322
  if (!generation)
1257
1323
  return;
1258
- settleDirectCoordination(generation, { ok: false, text: "The coordinated turn was interrupted" });
1324
+ settleDirectCoordination(generation, outcome);
1259
1325
  const pending = directFollowupSettlers.get(generation);
1260
1326
  if (!pending)
1261
1327
  return;
@@ -1269,6 +1335,10 @@ function settleDirectFollowup(generation) {
1269
1335
  // Keep the exact provider/profile settings that own a running conversation.
1270
1336
  // Selecting another thread or changing a default must not retarget its tools.
1271
1337
  const directTurnBots = new Map();
1338
+ /** Prompt bytes for the turn in flight, by thread, until the settled turn
1339
+ * books them next to its token usage: stable bytes ride the cacheable
1340
+ * prefix, volatile bytes are the part that legitimately changes. */
1341
+ const turnPromptBytes = new Map();
1272
1342
  let providerFleetReloading = false;
1273
1343
  const turnResources = new TurnResources();
1274
1344
  const sharedComputerControl = new SharedComputerControl(turnResources, () => store.bots.some(bot => botComputerControlSnapshot(bot.id).held));
@@ -1312,7 +1382,49 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
1312
1382
  // Who holds the desktop, as the chip and the give-up error name them: a
1313
1383
  // bot running a titled thread, or a room. Read once, when the wait begins.
1314
1384
  let holder;
1385
+ let holderThreadRef;
1315
1386
  const deadline = Date.now() + GROUP_GOAL_WAIT_MAX_MS;
1387
+ // The wait is history the transcript keeps (#1647): the waiting chip stays
1388
+ // exactly as written, a resolution line is appended beside it, and the
1389
+ // turn.wait_* events carry the same facts for the inspector log.
1390
+ let waitedSince;
1391
+ let waitEnded = false;
1392
+ const waitEventBase = () => {
1393
+ const selection = store.botByThread(owner.threadId)?.modelSelection;
1394
+ const instance = selection ? registry.get(selection.instanceId) : null;
1395
+ return {
1396
+ eventId: newId(),
1397
+ provider: instance?.driverKind ?? "harness",
1398
+ ...(instance && selection ? { providerInstanceId: selection.instanceId } : {}),
1399
+ threadId: owner.threadId,
1400
+ createdAt: new Date().toISOString(),
1401
+ };
1402
+ };
1403
+ const endWait = (outcome) => {
1404
+ if (!waitingMessage || waitEnded)
1405
+ return;
1406
+ waitEnded = true;
1407
+ const waitedMs = waitedSince === undefined ? 0 : Date.now() - waitedSince;
1408
+ const text = outcome === "acquired"
1409
+ ? computerFreeAfterText(holder, waitedMs)
1410
+ : outcome === "gave_up"
1411
+ ? computerStillBusyText(holder, waitedMs)
1412
+ : computerStoppedWaitingText(holder, waitedMs);
1413
+ store.appendMessage(owner.threadId, {
1414
+ role: "bot", kind: "activity",
1415
+ ...(outcome === "gave_up" ? { turnSucceeded: false } : {}),
1416
+ tool: { name: text, ok: outcome !== "gave_up" },
1417
+ ...(holderThreadRef ? { threadRef: holderThreadRef } : {}),
1418
+ });
1419
+ bus.publish({
1420
+ ...waitEventBase(),
1421
+ type: "turn.wait_ended",
1422
+ resource,
1423
+ ...(holder ? { holder } : {}),
1424
+ waitedMs,
1425
+ outcome,
1426
+ });
1427
+ };
1316
1428
  try {
1317
1429
  while (true) {
1318
1430
  if (!active())
@@ -1327,22 +1439,33 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
1327
1439
  holder = holderBot
1328
1440
  ? { name: holderBot.name, ...(holderTask?.title ? { task: holderTask.title } : {}) }
1329
1441
  : holderRoom ? { name: holderRoom.name } : undefined;
1442
+ holderThreadRef = holderBot && holderTask?.title
1443
+ ? { botId: holderBot.id, threadId: holderTask.threadId, title: holderTask.title }
1444
+ : undefined;
1330
1445
  waitingMessage = store.appendMessage(owner.threadId, {
1331
1446
  role: "bot", kind: "activity",
1332
1447
  tool: { name: computerWaitingText(holder) },
1333
- ...(holderBot && holderTask ? { threadRef: { botId: holderBot.id, threadId: holderTask.threadId, title: holderTask.title } } : {}),
1448
+ ...(holderThreadRef ? { threadRef: holderThreadRef } : {}),
1449
+ });
1450
+ waitedSince = Date.now();
1451
+ bus.publish({
1452
+ ...waitEventBase(),
1453
+ type: "turn.wait_started",
1454
+ resource,
1455
+ ...(holder ? { holder } : {}),
1334
1456
  });
1335
1457
  }
1336
- if (Date.now() >= deadline)
1337
- throw new Error(computerStillBusyText(holder, GROUP_GOAL_WAIT_MAX_MS));
1458
+ if (Date.now() >= deadline) {
1459
+ endWait("gave_up");
1460
+ throw new ComputerWaitGaveUp(computerStillBusyText(holder, GROUP_GOAL_WAIT_MAX_MS));
1461
+ }
1338
1462
  await new Promise(resolve => setTimeout(resolve, 100));
1339
1463
  }
1340
1464
  }
1341
1465
  finally {
1342
- if (waitingMessage)
1343
- store.patchMessage(owner.threadId, waitingMessage.id, {
1344
- tool: { name: active() && turnResources.owns(resource, owner) ? computerFreeText() : computerWaitEndedText(), ok: true },
1345
- });
1466
+ // Never patch the waiting chip over: the queue position this turn held
1467
+ // stays visible beside what the wait came to.
1468
+ endWait(active() && turnResources.owns(resource, owner) ? "acquired" : "stopped");
1346
1469
  }
1347
1470
  turnResourceOwners.set(owner.threadId, owner);
1348
1471
  turnComputerResources.set(owner.threadId, { owner, resource });
@@ -1382,7 +1505,11 @@ function hasDirectDispatch(botId) {
1382
1505
  * share this test so peer and room work waits for a real slot, not total
1383
1506
  * bot idleness. */
1384
1507
  function canAdmitDirectTurn(botId, threadId) {
1385
- return !threadBusy(botId, threadId) && !botAtThreadCapacity(botId) && !activeGroupTurnForBot(botId);
1508
+ return admit("peer", {}, {
1509
+ threadBusy: threadBusy(botId, threadId),
1510
+ atCapacity: botAtThreadCapacity(botId),
1511
+ groupTurn: Boolean(activeGroupTurnForBot(botId)),
1512
+ }).action === "start";
1386
1513
  }
1387
1514
  /** Routine and webhook dispatch shares startTurn's admission preconditions
1388
1515
  * instead of waiting for whole-bot idleness: a free thread slot and no
@@ -1390,7 +1517,12 @@ function canAdmitDirectTurn(botId, threadId) {
1390
1517
  * blocks every other turn kind; it does not consume a capacity slot. */
1391
1518
  function unattendedDispatchState(botId) {
1392
1519
  const bot = store.bot(botId);
1393
- return !bot ? "missing" : botAtThreadCapacity(botId) || activeGroupTurnForBot(botId) ? "busy" : "ready";
1520
+ const decision = admit("unattended", {}, {
1521
+ botPresent: Boolean(bot),
1522
+ atCapacity: Boolean(bot) && botAtThreadCapacity(botId),
1523
+ groupTurn: Boolean(bot) && Boolean(activeGroupTurnForBot(botId)),
1524
+ });
1525
+ return decision.action !== "refuse" ? "ready" : decision.code === "missing" ? "missing" : "busy";
1394
1526
  }
1395
1527
  function requestedTaskBot(botId, rawThreadId) {
1396
1528
  const profile = store.bot(botId);
@@ -1457,15 +1589,15 @@ async function interruptAllDirectThreads(botId) {
1457
1589
  }
1458
1590
  const retiredProviderTurns = new RetiredTurnRegistry();
1459
1591
  const pendingCancelledProviderHandshakes = new PendingTurnCancellations();
1460
- const generatedImagesByTurn = new Map();
1461
- function generatedImageTurnKey(threadId, turnId) {
1592
+ const turnAttachmentsByTurn = new Map();
1593
+ function turnAttachmentKey(threadId, turnId) {
1462
1594
  return `${threadId}:${turnId ?? "active"}`;
1463
1595
  }
1464
- function purgeGeneratedImagesForThread(threadId) {
1465
- for (const [key, attachments] of generatedImagesByTurn) {
1596
+ function purgeTurnAttachmentsForThread(threadId) {
1597
+ for (const [key, attachments] of turnAttachmentsByTurn) {
1466
1598
  if (!key.startsWith(`${threadId}:`))
1467
1599
  continue;
1468
- generatedImagesByTurn.delete(key);
1600
+ turnAttachmentsByTurn.delete(key);
1469
1601
  for (const attachment of attachments) {
1470
1602
  deleteAttachment(attachment.path);
1471
1603
  }
@@ -1479,13 +1611,13 @@ function clearCancelledProviderHandshake(threadId, ownerId) {
1479
1611
  }
1480
1612
  function retireProviderTurn(turnId) {
1481
1613
  retiredProviderTurns.retire(turnId);
1482
- // A stopped/replaced turn is never folded again. Delete only image files
1483
- // that were staged for that exact provider turn so unattached output does
1484
- // not accumulate invisibly on disk.
1485
- for (const [key, attachments] of generatedImagesByTurn) {
1614
+ // A stopped/replaced turn is never folded again. Delete only staged files
1615
+ // (images, voice notes) that were parked for that exact provider turn so
1616
+ // unattached output does not accumulate invisibly on disk.
1617
+ for (const [key, attachments] of turnAttachmentsByTurn) {
1486
1618
  if (!key.endsWith(`:${turnId}`))
1487
1619
  continue;
1488
- generatedImagesByTurn.delete(key);
1620
+ turnAttachmentsByTurn.delete(key);
1489
1621
  for (const attachment of attachments) {
1490
1622
  deleteAttachment(attachment.path);
1491
1623
  }
@@ -1636,8 +1768,26 @@ export function browserEngineSummary() {
1636
1768
  ? { kind: "engine", version: status.version, ...progress }
1637
1769
  : { kind: "unavailable", reason: status.reason, installable: status.installable, ...progress };
1638
1770
  }
1639
- function phoneIntegration() {
1640
- const env = { ...AGENTS_NODE_FLAG };
1771
+ function phoneIntegration(botId, threadId, generation) {
1772
+ // The phone is claimed lazily, at the first tools/call the engine makes
1773
+ // through this proxy (/api/internal/phone/claim): trigger-term matching
1774
+ // only decides whether the tools are mounted, so a conversation that
1775
+ // merely mentions "android" holds nothing while it runs.
1776
+ const token = mintInternalCapability({
1777
+ botId,
1778
+ threadId,
1779
+ generation,
1780
+ depth: 0,
1781
+ kind: "phone",
1782
+ skillAuthoring: false,
1783
+ createdBots: 0,
1784
+ openedThreads: 0,
1785
+ });
1786
+ const env = {
1787
+ ...AGENTS_NODE_FLAG,
1788
+ OMB_PHONE_TOKEN: token,
1789
+ OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`,
1790
+ };
1641
1791
  if (process.env.OMB_ADB_PATH)
1642
1792
  env.OMB_ADB_PATH = process.env.OMB_ADB_PATH;
1643
1793
  if (process.env.OMB_RESOURCES_PATH)
@@ -1646,9 +1796,9 @@ function phoneIntegration() {
1646
1796
  env.PH_ANDROID_SERIAL = process.env.PH_ANDROID_SERIAL;
1647
1797
  return { command: process.execPath, args: [phoneProxyPath], env };
1648
1798
  }
1649
- function connectedAppsIntegration(botId, threadId, generation) {
1799
+ function connectedAppsIntegration(bot, threadId, generation) {
1650
1800
  const token = mintInternalCapability({
1651
- botId,
1801
+ botId: bot.id,
1652
1802
  threadId,
1653
1803
  generation,
1654
1804
  depth: 0,
@@ -1660,8 +1810,12 @@ function connectedAppsIntegration(botId, threadId, generation) {
1660
1810
  return composio.mcpIntegration(cfg, {
1661
1811
  harnessUrl: `http://127.0.0.1:${PORT}`,
1662
1812
  commsToken: token,
1663
- botId,
1813
+ botId: bot.id,
1664
1814
  threadId,
1815
+ // Connector grants 3/5: the record (or its absence) rides along so
1816
+ // the bridge can filter tools/list; the verdict on the relay endpoint
1817
+ // stays the authority for every call.
1818
+ connectorTools: bot.connectorTools,
1665
1819
  });
1666
1820
  }
1667
1821
  // ── computer control (who is driving) ──────────────────────────────────
@@ -1933,6 +2087,113 @@ function collectExportSkills(bots, names) {
1933
2087
  }
1934
2088
  return byBot;
1935
2089
  }
2090
+ /** Skills on one team's bots for a whole-team share. "all" skips a skill
2091
+ * whose stored SKILL.md changed (listed to the person); an explicit list
2092
+ * refuses instead, because the person asked for that exact skill. */
2093
+ /** The team's skills for a whole-team export. `available` (every skill name
2094
+ * on the team's bots) comes back on refusals too: the Share dialog draws its
2095
+ * skill choices from it, so a refused choice can always be changed. */
2096
+ function collectTeamSkills(bots, selection) {
2097
+ const all = selection === undefined || selection === "all";
2098
+ const available = [...new Set(bots.flatMap((bot) => listSkills(bot.id).map((skill) => skill.name)))].sort();
2099
+ if (!all && (!Array.isArray(selection) || selection.some((name) => typeof name !== "string" || !isSkillName(name)))) {
2100
+ return { ok: false, error: "skills must be \"all\" or a list of skill names", available };
2101
+ }
2102
+ const chosen = new Set(all ? available : selection);
2103
+ const unknown = [...chosen].find((name) => !available.includes(name));
2104
+ if (unknown)
2105
+ return { ok: false, error: `This team has no skill named "${unknown}"`, available };
2106
+ const skillsByBot = new Map();
2107
+ const skipped = [];
2108
+ for (const bot of bots) {
2109
+ const assigned = [];
2110
+ for (const listing of listSkills(bot.id)) {
2111
+ if (!chosen.has(listing.name))
2112
+ continue;
2113
+ const instructions = readSkillFile(bot.id, listing.name);
2114
+ if (instructions === null) {
2115
+ if (!all)
2116
+ return { ok: false, error: `Skill "${listing.name}" changed or is unavailable and cannot be shared safely`, available };
2117
+ const part = `skills[${listing.name}]`;
2118
+ if (!skipped.some((skip) => skip.part === part))
2119
+ skipped.push({ part, reason: "skill_changed" });
2120
+ continue;
2121
+ }
2122
+ assigned.push({
2123
+ name: listing.name,
2124
+ description: listing.description,
2125
+ ...(listing.source ? { source: listing.source } : {}),
2126
+ ...(listing.license ? { license: listing.license } : {}),
2127
+ ...(listing.compatibility ? { compatibility: listing.compatibility } : {}),
2128
+ instructions,
2129
+ enabled: listing.enabled,
2130
+ });
2131
+ }
2132
+ if (assigned.length)
2133
+ skillsByBot.set(bot.id, assigned);
2134
+ }
2135
+ return { ok: true, all, skillsByBot, skipped, available };
2136
+ }
2137
+ /** A bot's starter notes: MEMORY.md and its topic files. Daily logs never
2138
+ * travel (they are closer to chat history than to notes). */
2139
+ function starterNotes(botId) {
2140
+ const notes = [];
2141
+ const index = readMemoryFile(botId).text;
2142
+ if (index.trim())
2143
+ notes.push({ path: "MEMORY.md", text: index });
2144
+ for (const topic of listMemoryTopics(botId).sort((a, b) => a.name.localeCompare(b.name))) {
2145
+ const text = readMemoryTopic(botId, topic.name);
2146
+ if (text?.trim())
2147
+ notes.push({ path: `memory/${topic.name}`, text });
2148
+ }
2149
+ return notes;
2150
+ }
2151
+ /** The importer's view of this installation. Pictures become ordinary local
2152
+ * avatars, connection slots ordinary (switched-off) MCP servers, starter
2153
+ * notes ordinary memory writes. */
2154
+ function packageImportDeps(selection) {
2155
+ return {
2156
+ store,
2157
+ routines: routines,
2158
+ skills: { install: installSkill, setEnabled: setSkillEnabled, installOrg: installOrgSkill },
2159
+ memory: { writeIndex: writeMemoryFile, writeTopic: writeMemoryTopic },
2160
+ mcp: { servers: () => cfg.mcpServers ?? {}, refusal: mcpPolicyRefusal, persist: persistMcpServers },
2161
+ sections: { writeBrief: (section, text) => void writeSectionContext(section, text) },
2162
+ images: {
2163
+ save: (bytes, mime) => {
2164
+ const avatarUrl = botAvatarUrlFromStoredPath(saveImage(Buffer.from(bytes), mime).path);
2165
+ if (!avatarUrl)
2166
+ throw new Error("A picture in this package could not be stored");
2167
+ return avatarUrl;
2168
+ },
2169
+ },
2170
+ broadcast: (event) => event.kind === "bot"
2171
+ ? broadcast({ kind: "bot", bot: publicBot(event.bot) })
2172
+ : broadcast({ kind: "group", group: publicGroupState(event.group) }),
2173
+ defaultSelection: () => selection,
2174
+ presets: presetStore,
2175
+ };
2176
+ }
2177
+ /** "Include my New bot defaults as a preset" from an export body: the saved
2178
+ * defaults through the allowlist, with the picture the dialog prepared. */
2179
+ function defaultsPresetFor(body, includeNotes) {
2180
+ const skipped = [];
2181
+ let avatar = null;
2182
+ if (body.presetAvatar !== undefined && body.presetAvatar !== null && body.presetAvatar !== "") {
2183
+ const offered = sharedPicture(body.presetAvatar);
2184
+ if (typeof offered === "string")
2185
+ skipped.push({ part: `presets[${DEFAULTS_PRESET_KEY}].bot.appearance.avatar`, reason: offered });
2186
+ else
2187
+ avatar = offered;
2188
+ }
2189
+ const built = presetFromDefaults(cfg.newBotDefaults, {
2190
+ name: typeof body.presetName === "string" ? body.presetName : undefined,
2191
+ description: typeof body.presetDescription === "string" ? body.presetDescription : undefined,
2192
+ includeNotes,
2193
+ avatar,
2194
+ });
2195
+ return { preset: built.value, skipped: [...skipped, ...built.skipped] };
2196
+ }
1936
2197
  function checkedGroupResponder(value, memberIds) {
1937
2198
  if (!value || typeof value !== "object" || Array.isArray(value))
1938
2199
  return null;
@@ -1970,7 +2231,9 @@ function checkedMemberIds(value) {
1970
2231
  return { ok: true, memberIds };
1971
2232
  }
1972
2233
  let bootSelection = { instanceId: "", model: "" };
1973
- const store = new Store(() => bootSelection);
2234
+ /** Preset bots for New bot, from shared files and the organization (presets.ts). */
2235
+ const presetStore = createPresetStore();
2236
+ const store = new Store(() => bootSelection, (selection) => withNewBotEffort(selection, cfg.newBots?.effort, registry.get(selection.instanceId)?.adapter.capabilities.effortLevels));
1974
2237
  const teamComputers = new TeamComputers(join(DATA_DIR, "team-computers.json"), ENVIRONMENT_ID);
1975
2238
  let followupsReady = false;
1976
2239
  const sendSequencer = new SendSequencer();
@@ -2008,9 +2271,17 @@ if (browserCleanupReferencesReconciled)
2008
2271
  * than the desktop window did. Stripped here rather than at each call site
2009
2272
  * so a new broadcast cannot forget. */
2010
2273
  let activeCoordinationForThread = (_threadId) => false;
2011
- const wireTask = (task) => ({ ...toWireTask(task), waitingForTeammates: activeCoordinationForThread(task.threadId) && !task.busy });
2274
+ const wireTask = (task) => {
2275
+ // Time-based snoozes heal on read against the server clock — no client
2276
+ // timer, no device skew. The 0 sentinel ("until new activity") is not a
2277
+ // time and survives reads; only a wake event in the store clears it.
2278
+ const { snoozedUntil, ...base } = toWireTask(task);
2279
+ const coordinated = { ...base, waitingForTeammates: activeCoordinationForThread(task.threadId) && !task.busy };
2280
+ const asleep = snoozedUntil === 0 || (snoozedUntil !== undefined && snoozedUntil > Date.now());
2281
+ return asleep ? { ...coordinated, snoozedUntil } : coordinated;
2282
+ };
2012
2283
  const wireBot = (bot) => {
2013
- const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, ...rest } = bot;
2284
+ const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, ...rest } = bot;
2014
2285
  // An elevated selection is inert until the desktop confirms its exact
2015
2286
  // private reply. Every ordinary client sees the effective Ask state during
2016
2287
  // that two-phase window, never a grant that may still roll back.
@@ -2023,7 +2294,7 @@ const wireBot = (bot) => {
2023
2294
  /** The correlated private response carries the requested value so Electron
2024
2295
  * can validate it before sending the confirmation that makes it effective. */
2025
2296
  const wireTrustedApprovalBot = (bot) => {
2026
- const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, ...rest } = bot;
2297
+ const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, ...rest } = bot;
2027
2298
  return { ...rest, approvalMode: approvalModeFor(rest), avatarUrl: rest.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
2028
2299
  };
2029
2300
  /** A settings-based preview, not a receipt of a dispatched turn. No
@@ -2046,7 +2317,7 @@ function previewSystemPrompt(bot) {
2046
2317
  const computerPromptKind = previewComputer === "vm"
2047
2318
  ? caps?.computerMcp ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared" : null
2048
2319
  : previewComputer === "cloud"
2049
- ? instance?.driverKind === "boxAgent" ? "box-agent" : caps?.computerMcp ? bot.cloudBackend === "vps" ? "vps" : "box" : null
2320
+ ? instance?.driverKind === "boxAgent" ? "box-agent" : caps?.computerMcp ? bot.cloudBackend === "vps" ? "vps" : caps.cloudComputerMcp ? "box-chat" : "box" : null
2050
2321
  : previewComputer === "local"
2051
2322
  ? caps?.localComputerMcp ? "local" : null
2052
2323
  : null;
@@ -2070,6 +2341,7 @@ function previewSystemPrompt(bot) {
2070
2341
  });
2071
2342
  const privateWorkspace = instance && supportsWorkspaceFiles(instance.driverKind);
2072
2343
  const built = buildSystemPrompt(persona, bot.soul ?? "", [
2344
+ { id: "user-profile", label: "About the user", text: userProfileSystemPrompt(cfg.profile) },
2073
2345
  {
2074
2346
  id: "setup",
2075
2347
  label: "Setup",
@@ -2086,7 +2358,7 @@ function previewSystemPrompt(bot) {
2086
2358
  computer: previewPlan.computer && previewPlan.computer !== "off" && computerPromptKind ? previewPlan.computer : null,
2087
2359
  browser: previewPlan.computer === undefined ? false : previewPlan.browser,
2088
2360
  }, { note: previewPlan.note }) },
2089
- { id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? COMPOSIO_PROMPT : "" },
2361
+ { id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? composioSystemPrompt(bot.connectorTools) : "" },
2090
2362
  { id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(engineMcpServers(bot))) : "" },
2091
2363
  { id: "browser", label: "Browser", text: previewPlan.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
2092
2364
  { id: "coordination", label: "Team", text: agentsMounted && coordination ? ` ${coordination}` : "" },
@@ -2134,6 +2406,7 @@ async function botOverview(bot) {
2134
2406
  approvePeerComms: bot.approvePeerComms,
2135
2407
  peers: bot.peers,
2136
2408
  composio: bot.composio,
2409
+ connectorTools: bot.connectorTools,
2137
2410
  browser: bot.browser,
2138
2411
  chiefOfStaff: bot.chiefOfStaff,
2139
2412
  managedSections: bot.managedSections,
@@ -2869,7 +3142,7 @@ const channelTaskBlocked = (group) => groupIsWorking(group) ||
2869
3142
  store.groupTasks(group.id).some((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
2870
3143
  message.card?.requestId &&
2871
3144
  !message.card.answered &&
2872
- !message.card.dismissed));
3145
+ !message.card.dismissed && !message.card.expired));
2873
3146
  // Recovery can synchronously emit room changes. Load coordination state
2874
3147
  // before registering store listeners or recovering interrupted routines.
2875
3148
  const groupQueues = new Map();
@@ -2889,7 +3162,7 @@ function roomHandoffProblem(node, parent) {
2889
3162
  // cannot silently give a specialist access to another team's transcript.
2890
3163
  const outsideSection = (room, speaker) => room.memberIds.some(id => {
2891
3164
  const member = store.bot(id);
2892
- return member && !canAccessTeam(speaker, member.section);
3165
+ return member && !canAccessTeam(speaker, member.section) && !coordinatorSupervises(member, speaker);
2893
3166
  });
2894
3167
  if (group && outsideSection(group, bot))
2895
3168
  return "Destination room includes a member outside the agent's section";
@@ -2900,7 +3173,10 @@ function roomHandoffProblem(node, parent) {
2900
3173
  const source = parent.groupId ? store.group(parent.groupId) : undefined;
2901
3174
  if (!from || from.hidden || (parent.groupId ? !source || !source.memberIds.includes(from.id) || !store.groupTaskByThread(source.id, parent.threadId) : !store.taskByThread(from.id, parent.threadId)))
2902
3175
  return "Source membership or task was removed";
2903
- if (!canAccessTeam(from, bot.section) || (source && outsideSection(source, from)))
3176
+ // A specialist may address its supervisor in their shared conversation;
3177
+ // supervision alone never grants a direct chat or another room's access.
3178
+ const sharedSupervisor = source && group && source.id === group.id && parent.threadId === node.threadId && coordinatorSupervises(bot, from);
3179
+ if ((!canAccessTeam(from, bot.section) && !sharedSupervisor) || (source && outsideSection(source, from)))
2904
3180
  return "Room work cannot cross the sender's section boundary";
2905
3181
  if (source && group && source.id === group.id && parent.threadId !== node.threadId)
2906
3182
  return "Same-room work must stay in the originating conversation";
@@ -3025,13 +3301,16 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
3025
3301
  const parent = node.parentId ? roomHandoffs.nodes.get(node.parentId) : undefined;
3026
3302
  const sender = parent ? store.bot(parent.botId) : undefined;
3027
3303
  const result = {};
3028
- const turnText = coordinationTurnText(node, resumed);
3304
+ const brief = coordinationTurnText(node, resumed);
3305
+ const liveRoster = resumed ? undefined : coordinationLiveRosterBlock(bot);
3306
+ const turnText = liveRoster ? `${brief}\n\n${liveRoster}` : brief;
3029
3307
  const systemInstructions = coordinationSystemInstructions();
3030
- if (!resumed && !store.messagesFor(node.threadId).some(m => m.roomRequest?.id === node.id && m.roomRequest.phase === "request")) {
3308
+ const request = roomHandoffs.sharedRequest(node);
3309
+ if (!resumed && !store.messagesFor(node.threadId).some(m => m.roomRequest?.id === request.id && m.roomRequest.phase === "request")) {
3031
3310
  store.appendMessage(node.threadId, { role: "bot", kind: "text",
3032
- roomRequest: { id: node.id, phase: "request" },
3311
+ roomRequest: { id: request.id, phase: "request" },
3033
3312
  from: sender ? { botId: sender.id, name: sender.name, color: sender.color } : undefined,
3034
- text: `@${bot.name} ${node.text}`,
3313
+ text: `${request.botIds.map(id => `@${store.bot(id)?.name ?? "Teammate"}`).join(" ")} ${node.text}`,
3035
3314
  });
3036
3315
  }
3037
3316
  markInternalTurn(node.threadId);
@@ -3096,7 +3375,7 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
3096
3375
  result.outcome = "cancelled";
3097
3376
  return;
3098
3377
  }
3099
- await runGroupMemberTurn(group.id, node.threadId, bot.id, MAX_COMMS_DEPTH, new Set(), undefined, error => { result.stopReason = error; }, () => operation.cancelled, () => groupProviderHandshakeStarted(operation), () => groupProviderHandshakeSettled(operation), { claimed: true }, { roomHandoffId: node.id, resumed, systemInstructions, turnInstructions: turnText, followMentions: false, result }, operation);
3378
+ await runGroupMemberTurn(group.id, node.threadId, bot.id, MAX_COMMS_DEPTH, new Set(), undefined, error => { result.stopReason = error; }, () => operation.cancelled, () => groupProviderHandshakeStarted(operation), () => groupProviderHandshakeSettled(operation), { claimed: true }, { roomHandoffId: node.id, resumed, systemInstructions, turnInstructions: brief, liveRoster, followMentions: false, result }, operation);
3100
3379
  });
3101
3380
  const tracked = run.finally(() => {
3102
3381
  signal.removeEventListener("abort", abort);
@@ -3108,8 +3387,20 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
3108
3387
  },
3109
3388
  }, Date.now, roomHandoffLimits(cfg));
3110
3389
  activeCoordinationForThread = threadId => roomHandoffs.activeDirect(threadId);
3111
- function publicGroupState(group) {
3112
- return { ...group, working: groupIsWorking(group) || [...roomHandoffs.nodes.values()].some(n => n.groupId === group.id && !["completed", "failed", "cancelled"].includes(n.status)) };
3390
+ const groupUsageReader = new GroupUsageReader(DATA_DIR);
3391
+ try {
3392
+ groupUsageReader.refresh();
3393
+ }
3394
+ catch { /* accounting must not block startup */ }
3395
+ function publicGroupState(record) {
3396
+ // The organization library's part hashes stay server-side.
3397
+ const { installedPackage: _installedPackage, ...group } = record;
3398
+ let usage;
3399
+ try {
3400
+ usage = groupUsageReader.forThread(group.threadId);
3401
+ }
3402
+ catch { /* accounting must not block chat */ }
3403
+ return { ...group, usage: usage ?? null, working: groupIsWorking(group) || [...roomHandoffs.nodes.values()].some(n => n.groupId === group.id && !["completed", "failed", "cancelled"].includes(n.status)) };
3113
3404
  }
3114
3405
  function beginGroupTurnOperation(groupId, threadId, botIds = []) {
3115
3406
  const operation = {
@@ -3140,6 +3431,13 @@ function finishGroupTurnOperation(groupId, operation) {
3140
3431
  const group = store.group(groupId);
3141
3432
  if (group)
3142
3433
  broadcast({ kind: "group", group: publicGroupState(group) });
3434
+ // The operation can outlive the last member's turn.completed: the fold
3435
+ // releases the speaker, but this map entry is what holds a 1:1 message
3436
+ // queued behind the room (reason "group-turn"), and no later event fires
3437
+ // once the run settles. Drain here, at the true end — the drain's own
3438
+ // block check re-reads activeGroupTurnForBot, so a replacement operation
3439
+ // begun in the interim still keeps its ground.
3440
+ drainQueuedSends();
3143
3441
  // A follow-up sent while this operation was running belongs to the
3144
3442
  // harness, not whichever composer happened to be mounted. Hand the next
3145
3443
  // one to the ordinary channel runner as soon as the channel is truly idle.
@@ -3347,6 +3645,35 @@ async function waitForChatRoomMember(operation, threadId, bot) {
3347
3645
  }
3348
3646
  }
3349
3647
  }
3648
+ /** The peer's newest stored message, across every task it owns: the recency
3649
+ * the live roster breaks ties with. A bot with no messages yet falls back
3650
+ * to its newest task's creation, which keeps a freshly created teammate
3651
+ * ahead of a silent veteran only when it really is the newer arrival. */
3652
+ function newestPeerActivityAt(botId) {
3653
+ let newest = 0;
3654
+ for (const task of store.tasks(botId)) {
3655
+ const at = store.messagesTail(task.threadId, 1).messages.at(-1)?.at ?? task.createdAt;
3656
+ if (at > newest)
3657
+ newest = at;
3658
+ }
3659
+ return newest;
3660
+ }
3661
+ /** The live-peer roster for a fresh coordination brief. Claude snapshots
3662
+ * system prompts, so dispatch-time data has to ride the user turn; and only
3663
+ * a recipient whose driver mounts the agents tools can act on the names, so
3664
+ * any other recipient gets nothing — the same gate the system-prompt roster
3665
+ * applies. The roster names the RECIPIENT's own reachable peers, which is
3666
+ * exactly the set its coordinate_bots calls will be allowed to reach. */
3667
+ function coordinationLiveRosterBlock(recipient) {
3668
+ const instance = registry.get(recipient.modelSelection.instanceId);
3669
+ if (instance?.adapter.capabilities.agentsMcp !== true)
3670
+ return "";
3671
+ const team = reachablePeers(store.bots, recipient).map(peer => ({
3672
+ ...peer,
3673
+ lastActivityAt: newestPeerActivityAt(peer.id),
3674
+ }));
3675
+ return livePeerRosterBlock(livePeerRoster(team));
3676
+ }
3350
3677
  function cancelGroupTurnOperations(groupId, threadId, outcome = {
3351
3678
  status: "stopped",
3352
3679
  detail: "Stopped by you.",
@@ -3448,6 +3775,12 @@ store.onChange((change) => {
3448
3775
  break;
3449
3776
  }
3450
3777
  case "bot.deleted":
3778
+ try {
3779
+ commandAllowlist.clear(change.botId);
3780
+ }
3781
+ catch {
3782
+ console.error("[command-allowlist] Could not remove deleted bot's saved rules.");
3783
+ }
3451
3784
  broadcast({ kind: "bot.deleted", botId: change.botId });
3452
3785
  break;
3453
3786
  case "group": {
@@ -3718,6 +4051,17 @@ onSteeredQueueChange(() => broadcast({ kind: "bot.queued", queues: publicBotQueu
3718
4051
  // once can collide on a bare id and patch each other's messages.
3719
4052
  const toolMessageByItem = new Map(); // threadId:itemId -> messageId
3720
4053
  const askMessageByRequest = new Map(); // threadId:requestId -> messageId
4054
+ // Only native, live permission metadata can become a remembered grant. Never
4055
+ // rebuild executable authority from an imported transcript or display summary.
4056
+ const pendingCommandRules = new Map();
4057
+ function commandRememberRefusal(auth, threadId, requestId, behavior) {
4058
+ if (!canManageCommandAllowlist(auth))
4059
+ return "Only the workspace owner or an admin can save bot-wide command permissions.";
4060
+ if (behavior !== "allow" || !pendingCommandRules.has(`${threadId}:${requestId}`)) {
4061
+ return "This live request does not contain a command that can be saved. Allow it once instead.";
4062
+ }
4063
+ return null;
4064
+ }
3721
4065
  /** Deliver a person's answer to the engine that asked, and tell the truth
3722
4066
  * about what happened. `unavailable` — the turn ended, the ask timed out,
3723
4067
  * the engine has no asks — is fail-closed: the action was never run. The
@@ -3725,7 +4069,7 @@ const askMessageByRequest = new Map(); // threadId:requestId -> messageId
3725
4069
  * a 500 while the card sits open forever. */
3726
4070
  async function answerRequest(threadId, instanceId, requestId, behavior, message, decidedFor,
3727
4071
  /** "Always allow this session": the provider keeps the allow, not the app */
3728
- always) {
4072
+ always, rememberCommand) {
3729
4073
  // Snapshot the card BEFORE delivering the answer: a delivered answer
3730
4074
  // resolves the request synchronously through the fold, which consumes
3731
4075
  // the askMessageByRequest entry — by the time the await returns, nobody
@@ -3738,7 +4082,10 @@ always) {
3738
4082
  ? thread.find((m) => m.id === cardMessageId)
3739
4083
  : thread.find((m) => m.card?.requestId === requestId);
3740
4084
  const card = cardMessage?.card;
3741
- const instance = registry.get(instanceId);
4085
+ const remembered = rememberCommand ? pendingCommandRules.get(`${threadId}:${requestId}`) : undefined;
4086
+ // Cloud routing and a model change can differ from the bot's current
4087
+ // settings. The live turn owns this question, just as it owns Stop/steer.
4088
+ const instance = runningTurnEngines.get(threadId) ?? registry.get(instanceId);
3742
4089
  let outcome = "unavailable";
3743
4090
  if (instance) {
3744
4091
  try {
@@ -3748,6 +4095,18 @@ always) {
3748
4095
  outcome = "unavailable";
3749
4096
  }
3750
4097
  }
4098
+ if (remembered && outcome === "allowed-once" && behavior === "allow" && store.bot(remembered.botId)) {
4099
+ // Persist only after the provider accepted this exact live request. A dead
4100
+ // card, denied request, failed delivery or process restart saves no grant.
4101
+ try {
4102
+ commandAllowlist.add(remembered.botId, remembered.candidate);
4103
+ }
4104
+ catch {
4105
+ store.appendMessage(threadId, { role: "bot", kind: "activity", tool: {
4106
+ name: "Command allowed once, but its allowlist rule could not be saved. Try again from the permissions menu.", ok: false,
4107
+ } });
4108
+ }
4109
+ }
3751
4110
  // An answered question keeps its words. `request.resolved` only records
3752
4111
  // the behavior, so without this the card reads "answer" forever and the
3753
4112
  // person can no longer see what they told the bot.
@@ -3801,6 +4160,9 @@ always) {
3801
4160
  * be answered. Routine proposals are harness-owned and durable, so they stay
3802
4161
  * actionable even after the proposing turn has stopped. */
3803
4162
  function closeOpenApprovals(threadId) {
4163
+ for (const key of pendingCommandRules.keys())
4164
+ if (key.startsWith(`${threadId}:`))
4165
+ pendingCommandRules.delete(key);
3804
4166
  // Peer approvals also hold an in-memory promise. Resolve those first; merely
3805
4167
  // patching their cards would leave the delegation queue waiting 15 minutes.
3806
4168
  cancelPeerApprovalsForThread(threadId);
@@ -3970,6 +4332,10 @@ bus.subscribe((event) => {
3970
4332
  watchdog.setWaitingOnHuman(event.threadId, true);
3971
4333
  else if (event.type === "request.resolved")
3972
4334
  watchdog.setWaitingOnHuman(event.threadId, false);
4335
+ else if (event.type === "turn.wait_started")
4336
+ watchdog.setWaitingOnComputer(event.threadId, true);
4337
+ else if (event.type === "turn.wait_ended")
4338
+ watchdog.setWaitingOnComputer(event.threadId, false);
3973
4339
  else if (event.type === "turn.completed") {
3974
4340
  watchdog.settle(event.threadId);
3975
4341
  revokeInternalCapabilityForProviderEvent(event);
@@ -4349,6 +4715,61 @@ function botComputerControlKey(bot) {
4349
4715
  const computer = inheritedTeamComputer(bot);
4350
4716
  return computer ? teamComputerOwner(computer.id) : bot.id;
4351
4717
  }
4718
+ async function computerCallGate(internalCapability) {
4719
+ const botId = internalCapability.botId;
4720
+ const snapshot = botComputerControlSnapshot(botId, internalCapability.teamComputerId);
4721
+ const slot = autoVmClaims.get(internalCapability.threadId);
4722
+ const lazyClaim = slot && slot.owner.generation === internalCapability.generation ? slot : undefined;
4723
+ if (!snapshot.held && lazyClaim?.lazy && !lazyClaim.begin) {
4724
+ // First screen tools/call on a lazily-attached Auto VM (issue
4725
+ // #1361): fire the exclusive claim — once — and give it a moment
4726
+ // to land. A free, ready VM claims in the time of one container
4727
+ // inspect, so this call then proceeds with an honest answer;
4728
+ // only a claim still queued behind another holder answers held
4729
+ // below, and then the contention text is true. Keyed on the
4730
+ // slot, never on the thread's turn-computer entry: a bind this
4731
+ // turn abandoned earlier (a VPS that turned out to be asleep)
4732
+ // must not hide the unclaimed VM and let the call through.
4733
+ startAutoVmClaim(autoVmClaims, internalCapability.threadId, internalCapability.generation);
4734
+ await Promise.race([
4735
+ lazyClaim.begin ?? Promise.resolve(),
4736
+ new Promise((resolve) => setTimeout(resolve, LAZY_VM_CLAIM_GRACE_MS)),
4737
+ ]);
4738
+ }
4739
+ if (!snapshot.held && lazyClaim?.failed === true) {
4740
+ // A rejected lazy claim (gate finding F1, issue #1361): the
4741
+ // computer MCP mounted at dispatch is still live, and the claim
4742
+ // may even have left a turn-computer entry behind (it can reject
4743
+ // after bindTurnComputer succeeded — lease lost to a person,
4744
+ // lifecycle busy, boot failure). Either way this turn owns no
4745
+ // usable VM, so keep refusing every screen call for the rest of
4746
+ // the generation; the bridge must never forward one onto a VM
4747
+ // this turn never claimed. Turn settle GC clears the slot. Say
4748
+ // why, and say not to retry: the contention text would send the
4749
+ // model into a screenshot loop against a claim that cannot land.
4750
+ return {
4751
+ held: true, helpOpen: false,
4752
+ blockedReason: `This turn could not claim ${lazyClaim.label ?? "this computer"}${lazyClaim.failure ? ` (${lazyClaim.failure})` : ""}. This call was not performed. Do not retry computer work in this turn; tell the person what you could not do.`,
4753
+ };
4754
+ }
4755
+ if (!snapshot.held && lazyClaim?.lazy && lazyClaim.begin && !lazyClaim.claimed) {
4756
+ // The claim fired and is still waiting on the exclusive bind:
4757
+ // another turn genuinely holds this desktop right now.
4758
+ return {
4759
+ held: true, helpOpen: false,
4760
+ blockedReason: "Another thread is using this computer. This call was not performed. Pause computer work until that thread finishes, then take a fresh screenshot before acting.",
4761
+ };
4762
+ }
4763
+ const computer = turnComputerResources.get(internalCapability.threadId);
4764
+ if (!snapshot.held && computer && computer.owner.generation === internalCapability.generation &&
4765
+ !claimTurnResource(computer.owner, computer.resource)) {
4766
+ return {
4767
+ held: true, helpOpen: false,
4768
+ blockedReason: "Another thread is using this computer. This call was not performed. Pause computer work until that thread finishes, then take a fresh screenshot before acting.",
4769
+ };
4770
+ }
4771
+ return { held: snapshot.held, helpOpen: snapshot.helpReason !== null };
4772
+ }
4352
4773
  function botComputerControlSnapshot(botId, pinnedComputerId) {
4353
4774
  const own = computerControl.snapshot(botId);
4354
4775
  const bot = store.bot(botId);
@@ -4444,8 +4865,12 @@ async function mountHostComputer(owner, botId, providerSupportsLocal) {
4444
4865
  : "this model engine cannot control this computer — choose Claude or an ACP engine, or select another destination");
4445
4866
  }
4446
4867
  const cua = readCuaConnection();
4447
- if (!cua)
4448
- throw new Error("CUA Driver is not ready for this computer — check permissions and restart OpenMausBot");
4868
+ if (!cua) {
4869
+ const reason = readCuaUnavailableReason();
4870
+ throw new Error(reason
4871
+ ? `CUA Driver is not ready for this computer — ${reason}${process.platform === "darwin" && /(?:Screen Recording|Accessibility).*required/i.test(reason) ? ". Relaunch OpenMausBot after granting the missing macOS permission." : ""}`
4872
+ : "CUA Driver is not ready for this computer — check permissions and restart OpenMausBot");
4873
+ }
4449
4874
  await bindTurnComputer(owner, "computer:host");
4450
4875
  return gatedLocalComputer(cua, controlIntegration(botId, owner.threadId, owner.generation));
4451
4876
  }
@@ -4472,6 +4897,7 @@ async function mountBotVps(bot, owner, opts) {
4472
4897
  owner,
4473
4898
  lazy: true,
4474
4899
  label: "the VPS computer",
4900
+ onRejected: opts.onRejected,
4475
4901
  claim: async () => {
4476
4902
  await bindTurnComputer(owner, vpsResource, true);
4477
4903
  opts.onClaimed(vpsCapture);
@@ -4608,11 +5034,12 @@ function turnProvider(bot, runOn, threadId) {
4608
5034
  return "box";
4609
5035
  return bot.cloudBackend === "vps" ? "vps" : wants === "cloud" ? "box" : null;
4610
5036
  }
4611
- /** A turn on the cloud computer runs ON the cloud computer: the Box runs the
4612
- * bot's own harness there with the computer tools built in, so nothing on this
4613
- * machine relays clicks and screenshots. Every start/interrupt of a turn asks
4614
- * here which engine owns it. */
5037
+ /** A driver with a Box bridge keeps the selected model. Other engines use
5038
+ * Box's native runner. Start and interrupt must resolve the same owner. */
4615
5039
  function turnInstance(bot, runOn, threadId) {
5040
+ const selected = registry.get(bot.modelSelection.instanceId);
5041
+ if (selected?.adapter.capabilities.cloudComputerMcp)
5042
+ return selected;
4616
5043
  const onBox = turnProvider(bot, runOn, threadId) === "box";
4617
5044
  return onBox
4618
5045
  ? registry.instances().find((candidate) => candidate.driverKind === "boxAgent") ?? null
@@ -4677,7 +5104,7 @@ async function selectableComputers(bot) {
4677
5104
  canCreate = Boolean(status?.configured && status.daemonUp && status.container === "missing");
4678
5105
  reason = status?.problem ?? reason;
4679
5106
  }
4680
- else if (box.boxConfigured(cfg) && registry.instances().some(instance => instance.driverKind === "boxAgent")) {
5107
+ else if (box.boxConfigured(cfg) && (caps?.cloudComputerMcp || registry.instances().some(instance => instance.driverKind === "boxAgent"))) {
4681
5108
  const status = await box.boxStatus(cfg, bot.id);
4682
5109
  const lifecycle = box.boxTurnLifecycleAction({ explicitCloud: true, canMount: true, state: status.box?.state ?? null });
4683
5110
  ready = lifecycle === "attach";
@@ -4740,7 +5167,10 @@ function continueComputerSelection(threadId, generation, succeeded) {
4740
5167
  }
4741
5168
  if (store.activePath(threadId).findLast(message => message.role === "user" && message.kind === "text")?.id !== selection.source.id)
4742
5169
  return;
4743
- store.patchTask(bot.id, threadId, { surface });
5170
+ // The model picked this surface through select_computer; the person
5171
+ // never chose it in the composer or thread settings. It is the
5172
+ // machine's record, so a later Works on change may sweep it.
5173
+ store.patchTask(bot.id, threadId, { surface, surfaceSource: "auto" });
4744
5174
  const text = `The computer selection is now ${surfaceLabel(surface)}. Continue the user's original request using the tools mounted for this turn; verify the result before claiming success.\n\n${selection.text}`;
4745
5175
  void startTurn(bot.id, text, { threadId, userMessage: selection.source, computerSelectionContinuation: true }).catch(error => {
4746
5176
  if (store.taskByThread(bot.id, threadId))
@@ -4965,8 +5395,12 @@ bus.subscribe((event) => {
4965
5395
  const privateImageEvent = event.type === "item.completed" && event.itemType === "assistant_image";
4966
5396
  // The durable message patch below is the public frame. Sending raw base64
4967
5397
  // through runtime SSE would multiply large bytes across every app window.
4968
- if (!privateImageEvent)
4969
- broadcast({ kind: "runtime", event });
5398
+ if (!privateImageEvent) {
5399
+ // Exact executable input is internal matching metadata, not inspector UI.
5400
+ // The event bus's persistence redactor does not scrub its live event.
5401
+ const publicEvent = event.type === "request.opened" ? { ...event, command: undefined } : event;
5402
+ broadcast({ kind: "runtime", event: publicEvent });
5403
+ }
4970
5404
  const routineRun = privateImageEvent ? null : (routines?.handleRuntimeEvent(event) ?? null);
4971
5405
  const ownerBot = store.botByThread(event.threadId);
4972
5406
  const bot = ownerBot ? botForThread(ownerBot.id, event.threadId) ?? undefined : undefined;
@@ -5018,10 +5452,10 @@ bus.subscribe((event) => {
5018
5452
  try {
5019
5453
  const decoded = decodeGeneratedImage(event.data);
5020
5454
  const saved = saveImage(decoded.bytes, decoded.mime);
5021
- const key = generatedImageTurnKey(event.threadId, event.turnId);
5022
- const current = generatedImagesByTurn.get(key) ?? [];
5455
+ const key = turnAttachmentKey(event.threadId, event.turnId);
5456
+ const current = turnAttachmentsByTurn.get(key) ?? [];
5023
5457
  current.push({ kind: "image", path: saved.path, mime: saved.mime });
5024
- generatedImagesByTurn.set(key, current);
5458
+ turnAttachmentsByTurn.set(key, current);
5025
5459
  }
5026
5460
  catch (error) {
5027
5461
  pushMessage({
@@ -5092,14 +5526,22 @@ bus.subscribe((event) => {
5092
5526
  const permission = event.requestType === "permission" && !event.questions?.length;
5093
5527
  // A permission request here is one the provider left for a person: its
5094
5528
  // own mode already ran (Ask, Edits, Auto's reviewer, Custom's config).
5095
- // OpenMausBot decides nothing about the action itself. Only Full access
5096
- // answers, because that is exactly what the person granted. A QUESTION
5529
+ // Only the person's explicit Full access or exact saved command answers.
5530
+ // The app does not guess whether the action is safe. A QUESTION
5097
5531
  // always reaches the human — even Full access never invents an answer.
5098
5532
  const asker = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
5099
5533
  const unattended = permission && asker && event.requestId ? isUnattended(asker.id, event.threadId) : false;
5100
5534
  const effectiveApprovalMode = asker ? approvalModeForTurn(asker, isInternalTurn(event.threadId)) : "ask";
5535
+ // Native shell descriptors are distinct from computer/MCP permissions;
5536
+ // choosing a local desktop must not disable an exact shell grant.
5537
+ const command = permission && asker && event.requestId && !event.requiresExplicitApproval && event.command
5538
+ ? commandAllowlistCandidate({ ...event.command, providerInstanceId: event.providerInstanceId ?? asker.modelSelection.instanceId })
5539
+ : null;
5101
5540
  const verdict = permission && asker && event.requestId
5102
- ? autoVerdict(effectiveApprovalMode, event.tool, { requiresExplicitApproval: event.requiresExplicitApproval })
5541
+ ? autoVerdict(effectiveApprovalMode, event.tool, {
5542
+ requiresExplicitApproval: event.requiresExplicitApproval,
5543
+ commandAllowed: Boolean(command && commandAllowlist.matches(asker.id, command)),
5544
+ })
5103
5545
  : null;
5104
5546
  // Auto's reviewer is the engine's own. Claude accepts `--permission-mode
5105
5547
  // auto` for any model and starts in Manual without a word when auto is
@@ -5146,8 +5588,8 @@ bus.subscribe((event) => {
5146
5588
  pushMessage({
5147
5589
  role: "bot", kind: "activity",
5148
5590
  tool: { name: outcome === "rejected"
5149
- ? "The provider rejected this action despite Full access."
5150
- : "error: could not deliver Full access to the provider; retry the task after reconnecting.", ok: false },
5591
+ ? `The provider rejected this action despite ${verdict.source === "command-allowlist" ? "the saved command permission" : "Full access"}.`
5592
+ : "error: could not deliver approval to the provider; retry the task after reconnecting.", ok: false },
5151
5593
  });
5152
5594
  return;
5153
5595
  }
@@ -5170,7 +5612,7 @@ bus.subscribe((event) => {
5170
5612
  })().catch(() => {
5171
5613
  // A receipt failure must neither crash the server nor manufacture
5172
5614
  // a new permission request after the provider took our answer.
5173
- console.error("[full-access] Could not record the provider approval result.");
5615
+ console.error("[approval] Could not record the provider approval result.");
5174
5616
  });
5175
5617
  break;
5176
5618
  }
@@ -5192,9 +5634,11 @@ bus.subscribe((event) => {
5192
5634
  options: event.choices?.length ? event.choices : permission ? ["Allow", "Deny"] : [],
5193
5635
  requestId: event.requestId,
5194
5636
  tool: permission ? event.tool : undefined,
5195
- questionRequest: questions ? { version: 1, questions } : undefined,
5196
- // the provider can keep an allow for its session; the app keeps
5197
- // no grant of its own for a provider's tool
5637
+ questionRequest: questions
5638
+ ? { version: 1, questions, ...(event.origin === "output" ? { origin: "output" } : {}) }
5639
+ : undefined,
5640
+ commandAllowlist: command ?? undefined,
5641
+ // Provider-owned session grants remain separate from exact commands.
5198
5642
  allowSession: permission && event.allowSession && !event.requiresExplicitApproval ? true : undefined,
5199
5643
  // The text stays for cards saved before heldCode existed, and for
5200
5644
  // clients that do not know the key yet.
@@ -5205,6 +5649,8 @@ bus.subscribe((event) => {
5205
5649
  });
5206
5650
  if (event.requestId)
5207
5651
  askMessageByRequest.set(`${event.threadId}:${event.requestId}`, message.id);
5652
+ if (command && asker && event.requestId)
5653
+ pendingCommandRules.set(`${event.threadId}:${event.requestId}`, { botId: asker.id, candidate: command });
5208
5654
  // Every card that reaches a human is a decision too: "the provider
5209
5655
  // left this for you, in this mode". `question` marks the cards no
5210
5656
  // rule may ever answer; a permission card without a verdict (no known
@@ -5219,6 +5665,7 @@ bus.subscribe((event) => {
5219
5665
  summary: event.summary,
5220
5666
  decision: "card-shown",
5221
5667
  source: !permission ? "question" : verdict ? verdict.source : "no-grant",
5668
+ origin: !permission && event.origin === "output" ? "output" : undefined,
5222
5669
  unattended: unattended || undefined,
5223
5670
  });
5224
5671
  // Notify from HERE, not from a separate subscriber on request.opened:
@@ -5239,6 +5686,8 @@ bus.subscribe((event) => {
5239
5686
  break;
5240
5687
  }
5241
5688
  case "request.resolved": {
5689
+ if (event.requestId)
5690
+ pendingCommandRules.delete(`${event.threadId}:${event.requestId}`);
5242
5691
  // answered (by whoever): the turn is working again, unless it settled
5243
5692
  const waiting = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
5244
5693
  if (bot && store.taskByThread(bot.id, event.threadId)?.activity === "waiting-on-you") {
@@ -5272,7 +5721,13 @@ bus.subscribe((event) => {
5272
5721
  pushMessage({
5273
5722
  role: "bot",
5274
5723
  kind: "activity",
5275
- tool: { name: `error: ${event.message.slice(0, 160)}`, ok: false, setup: event.setup, ...(event.terminal ? { terminal: true } : {}) },
5724
+ tool: {
5725
+ name: `error: ${event.message.slice(0, 160)}`,
5726
+ ok: false,
5727
+ setup: event.setup,
5728
+ ...(event.terminal ? { terminal: true } : {}),
5729
+ ...(event.claudeUpdate ? { claudeUpdate: true } : {}),
5730
+ },
5276
5731
  });
5277
5732
  // a setup error means the engine could not even start: the bot is
5278
5733
  // dead until something changes, not merely idle. The next successful
@@ -5296,26 +5751,60 @@ bus.subscribe((event) => {
5296
5751
  // thread nobody types in again (a deleted bot's) is not held forever.
5297
5752
  const internal = isInternalTurn(event.threadId);
5298
5753
  clearInternalTurn(event.threadId);
5299
- const generatedKey = generatedImageTurnKey(event.threadId, event.turnId);
5300
- const generated = generatedImagesByTurn.get(generatedKey) ?? [];
5301
- generatedImagesByTurn.delete(generatedKey);
5302
- if (generated.length) {
5754
+ const generatedKey = turnAttachmentKey(event.threadId, event.turnId);
5755
+ const generated = turnAttachmentsByTurn.get(generatedKey) ?? [];
5756
+ turnAttachmentsByTurn.delete(generatedKey);
5757
+ // A turn that never completed never lands its half-said note: parked
5758
+ // audio is deleted when the settle is not ok (person stop, provider
5759
+ // failure) rather than posted as if the turn finished (#1742).
5760
+ // Generated images keep their long-standing settle-anyway behavior.
5761
+ for (const attachment of generated) {
5762
+ if (event.ok || attachment.kind !== "audio")
5763
+ continue;
5764
+ deleteAttachment(attachment.path);
5765
+ }
5766
+ const settled = event.ok || !generated.length
5767
+ ? generated
5768
+ : generated.filter((attachment) => attachment.kind !== "audio");
5769
+ if (settled.length) {
5303
5770
  const response = [...store.messagesFor(event.threadId)].reverse().find((message) => message.role === "bot" &&
5304
5771
  message.kind === "text" &&
5305
5772
  message.turnId === completedTurnId);
5773
+ // A voice note's text is its transcript: the visible caption search,
5774
+ // compaction and notifications read (#1740 decision 3).
5775
+ const transcript = settled
5776
+ .filter((attachment) => attachment.kind === "audio" && attachment.text)
5777
+ .map((attachment) => attachment.text.trim())
5778
+ .filter(Boolean)
5779
+ .join("\n\n");
5780
+ const staged = settled.map((attachment) => attachment.kind === "audio"
5781
+ ? { kind: "audio", path: attachment.path, mime: attachment.mime }
5782
+ : attachment);
5306
5783
  if (response) {
5307
5784
  store.patchMessage(event.threadId, response.id, {
5308
- attachments: [...(response.attachments ?? []), ...generated],
5785
+ attachments: [...(response.attachments ?? []), ...staged],
5786
+ // The note's text is its transcript and stays visible beside a
5787
+ // written epilogue: the wire audio carries no text, so letting
5788
+ // the epilogue replace it would erase what was said (#1740
5789
+ // decision 3).
5790
+ ...(transcript
5791
+ ? {
5792
+ text: [response.text?.trim(), redactSecretsInText(transcript)]
5793
+ .filter(Boolean)
5794
+ .join("\n\n"),
5795
+ }
5796
+ : {}),
5309
5797
  });
5310
5798
  }
5311
5799
  else {
5312
- // Some image turns have no textual epilogue. Keep the image as the
5313
- // terminal assistant response instead of inventing model words.
5800
+ // Some turns have no textual epilogue. Keep the attachment as the
5801
+ // terminal assistant response instead of inventing model words; a
5802
+ // voice note's own text is the visible transcript there.
5314
5803
  pushMessage({
5315
5804
  role: "bot",
5316
5805
  kind: "text",
5317
- text: "",
5318
- attachments: generated,
5806
+ text: transcript,
5807
+ attachments: staged,
5319
5808
  turnId: completedTurnId,
5320
5809
  });
5321
5810
  }
@@ -5329,7 +5818,13 @@ bus.subscribe((event) => {
5329
5818
  lastReply.delete(event.threadId);
5330
5819
  // A run that broke — not one the person stopped, and not a routine's,
5331
5820
  // which reports through its own failure path — is the Chief's to see.
5332
- if (!event.ok && event.stopReason !== "interrupted" && !routines?.runForThread(event.threadId)) {
5821
+ // A lazy computer-claim rejection already reported its failure and
5822
+ // interrupted the turn; Claude settles that interrupt as
5823
+ // exit_before_result, not "interrupted", so this generation's marker
5824
+ // keeps the same failure from reporting a second incident.
5825
+ const lazyClaimAlreadyReported = event.stopReason === "exit_before_result" &&
5826
+ turnResourceOwners.get(event.threadId)?.lazyClaimFailureReported === true;
5827
+ if (!event.ok && event.stopReason !== "interrupted" && !lazyClaimAlreadyReported && !routines?.runForThread(event.threadId)) {
5333
5828
  const broken = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
5334
5829
  if (broken)
5335
5830
  reportIncident({ kind: "failed", bot: broken, threadId: event.threadId, detail: event.stopReason?.trim() || "the run ended without a result" });
@@ -5400,6 +5895,8 @@ bus.subscribe((event) => {
5400
5895
  const measuredSelection = directTurnBots.get(event.threadId)?.modelSelection ?? selection;
5401
5896
  store.patchTask(bot.id, event.threadId, { lastContextModel: Number.isFinite(lastContext?.tokens) && (lastContext?.tokens ?? 0) > 0
5402
5897
  ? `${measuredSelection.instanceId}:${measuredSelection.model}` : undefined });
5898
+ const promptBytes = turnPromptBytes.get(event.threadId);
5899
+ turnPromptBytes.delete(event.threadId);
5403
5900
  bookTurnUsage({
5404
5901
  botId: bot.id,
5405
5902
  botName: bot.name,
@@ -5410,6 +5907,7 @@ bus.subscribe((event) => {
5410
5907
  input: tokens?.input ?? 0,
5411
5908
  output: tokens?.output ?? 0,
5412
5909
  ...(typeof tokens?.cachedInput === "number" ? { cachedInput: tokens.cachedInput } : {}),
5910
+ ...(promptBytes ? { promptBytes } : {}),
5413
5911
  costUsd: event.cost ?? null,
5414
5912
  trigger: routineRun
5415
5913
  ? { kind: "routine", routineId: routineRun.routineId, label: routineRun.routineName }
@@ -5485,6 +5983,8 @@ bus.subscribe((event) => {
5485
5983
  const tokens = event.usage ?? lastReported;
5486
5984
  const speakingBot = store.bot(speaker.botId);
5487
5985
  const selection = speakingBot?.modelSelection;
5986
+ const promptBytes = turnPromptBytes.get(event.threadId);
5987
+ turnPromptBytes.delete(event.threadId);
5488
5988
  bookTurnUsage({
5489
5989
  botId: speaker.botId,
5490
5990
  botName: speaker.name,
@@ -5495,11 +5995,20 @@ bus.subscribe((event) => {
5495
5995
  input: tokens?.input ?? 0,
5496
5996
  output: tokens?.output ?? 0,
5497
5997
  ...(typeof tokens?.cachedInput === "number" ? { cachedInput: tokens.cachedInput } : {}),
5998
+ ...(promptBytes ? { promptBytes } : {}),
5498
5999
  costUsd: event.cost ?? null,
5499
6000
  trigger: routineRun
5500
6001
  ? { kind: "routine", routineId: routineRun.routineId, label: routineRun.routineName }
5501
6002
  : turnTriggers.get(event.threadId) ?? { kind: "owner" },
5502
6003
  });
6004
+ // Appends are asynchronous. Refresh after persistence so the settled
6005
+ // turn appears immediately, without waiting for another message.
6006
+ void flushUsageLedger(DATA_DIR).then(() => {
6007
+ groupUsageReader.refresh();
6008
+ const current = store.group(group.id);
6009
+ if (current)
6010
+ broadcast({ kind: "group", group: publicGroupState(current) });
6011
+ }).catch(() => { });
5503
6012
  if (completedTurnId) {
5504
6013
  void scheduleTurnDigest({
5505
6014
  botId: speaker.botId, botName: speaker.name,
@@ -6202,22 +6711,85 @@ function drainQueuedSends() {
6202
6711
  // intact until that exact handshake releases its runtime-only claim.
6203
6712
  (botId, threadId) => threadBusy(botId, threadId) || botAtThreadCapacity(botId) || Boolean(activeGroupTurnForBot(botId))
6204
6713
  || parksBehindCoordination(botId, threadId));
6714
+ // Asides always drain after person follow-ups (see drainAsideLane): the
6715
+ // steer drain above can make a thread busy again, deferring its asides
6716
+ // to the next boundary. Every path that clears a thread's send queue
6717
+ // reaches this point, so the aside lane drains on all of them — not
6718
+ // just turn.completed.
6719
+ drainAsideLane();
6720
+ }
6721
+ /** The aside lane's boundary pass. Invoked at the end of drainQueuedSends, i.e. AFTER the steer drain on purpose:
6722
+ * a thread with both waiting starts its person follow-up first, and that
6723
+ * turn makes the thread busy again, so its asides defer to the next
6724
+ * boundary — the person's correction always lands ahead of peer context.
6725
+ * A thread already running a seam-capable turn gets its asides folded in
6726
+ * mid-turn instead (one batched call); an idle thread degrades them to a
6727
+ * single enveloped follow-up turn, exactly the shape the steer-queue
6728
+ * gives queued person messages. */
6729
+ function drainAsideLane() {
6730
+ if (!followupsReady)
6731
+ return;
6732
+ void drainAsideMessages({
6733
+ store,
6734
+ inject: async (botId, threadId, prompt) => {
6735
+ const bot = store.projectBotForTask(botId, threadId) ?? store.bot(botId);
6736
+ if (!bot?.busy)
6737
+ return "refused";
6738
+ const instance = runningTurnInstance(bot, threadId);
6739
+ if (!instance?.adapter.capabilities.queueing || !instance.adapter.steer)
6740
+ return "refused";
6741
+ return instance.adapter.steer(threadId, prompt).catch(() => "indeterminate");
6742
+ },
6743
+ run: (botId, threadId, prompt, userMessage, excludeIds, head) => new Promise((resolve, reject) => {
6744
+ void startTurn(botId, prompt, {
6745
+ threadId,
6746
+ userMessage,
6747
+ excludeMessageIds: excludeIds,
6748
+ commsDepth: head.aside.commsDepth + 1,
6749
+ unattended: head.aside.unattended === true,
6750
+ peerAsk: {
6751
+ botId: head.aside.fromBotId,
6752
+ name: head.aside.fromBotName,
6753
+ ...(head.aside.unattended ? { unattended: true } : {}),
6754
+ },
6755
+ onTurnSettled: resolve,
6756
+ }).catch((err) => {
6757
+ store.appendMessage(threadId, {
6758
+ role: "bot", kind: "activity",
6759
+ tool: {
6760
+ name: `error: queued aside could not start — ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`,
6761
+ ok: false,
6762
+ },
6763
+ });
6764
+ resolve();
6765
+ }).catch(reject);
6766
+ }),
6767
+ isBlocked: (botId, threadId) => threadBusy(botId, threadId) || botAtThreadCapacity(botId) || Boolean(activeGroupTurnForBot(botId))
6768
+ || parksBehindCoordination(botId, threadId),
6769
+ }).catch((error) => console.warn("aside-queue: drain failed", error));
6205
6770
  }
6206
6771
  /** Keep a person's words off the transcript until a direct-thread slot is
6207
6772
  * available. Reuse the existing cancellable, idempotent composer queue. */
6208
6773
  async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender, trigger) {
6209
- const capacity = botAtThreadCapacity(botId);
6210
- if (capacity || threadBusy(botId, threadId) || parksBehindCoordination(botId, threadId)) {
6211
- const reason = capacity ? "capacity" : undefined;
6774
+ const decision = admit("direct", {}, {
6775
+ // A room turn holds the bot exactly like the sibling opened-thread queue
6776
+ // below: the drain's own block check waits it out, so the words queue
6777
+ // here rather than bounce off startTurn's 409.
6778
+ atCapacity: botAtThreadCapacity(botId),
6779
+ threadBusy: threadBusy(botId, threadId),
6780
+ groupTurn: Boolean(activeGroupTurnForBot(botId)),
6781
+ parksBehindCoordination: parksBehindCoordination(botId, threadId),
6782
+ });
6783
+ if (decision.action === "queue") {
6212
6784
  const queued = queueSteeredMessage(botId, threadId, text, {
6213
6785
  replyToId: replyTo?.id,
6214
6786
  sendId,
6215
- reason,
6787
+ reason: decision.reason,
6216
6788
  prompt: promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"),
6217
6789
  sender,
6218
6790
  trigger,
6219
6791
  });
6220
- return { ok: true, queued: true, queueId: queued.id, threadId, reason };
6792
+ return { ok: true, queued: true, queueId: queued.id, threadId, reason: decision.reason };
6221
6793
  }
6222
6794
  const message = await startTurn(botId, text, { threadId, replyTo, sendId, sender, trigger });
6223
6795
  return { ok: true, threadId, message };
@@ -6232,11 +6804,19 @@ const MAX_THREADS_OPENED_PER_TURN = 5;
6232
6804
  * bot's own request, not a new human request authorizing recursive fan-out. */
6233
6805
  async function startOrQueueOpenedThread(botId, threadId, text, unattended) {
6234
6806
  const peerAsk = { botId, name: store.bot(botId).name, unattended: unattended || undefined };
6235
- // A room turn holds the bot too (startTurn refuses a direct turn during
6236
- // one); the drain's own block check already waits for it, so the words
6237
- // queue here rather than bounce.
6238
- if (botAtThreadCapacity(botId) || activeGroupTurnForBot(botId)) {
6239
- queueSteeredMessage(botId, threadId, text, { reason: "capacity", unattended, peerAsk });
6807
+ const decision = admit("opened-thread", {}, {
6808
+ // A room turn holds the bot too (startTurn refuses a direct turn during
6809
+ // one); the drain's own block check already waits for it, so the words
6810
+ // queue here rather than bounce.
6811
+ atCapacity: botAtThreadCapacity(botId),
6812
+ groupTurn: Boolean(activeGroupTurnForBot(botId)),
6813
+ });
6814
+ if (decision.action === "queue") {
6815
+ queueSteeredMessage(botId, threadId, text, {
6816
+ reason: decision.reason,
6817
+ unattended,
6818
+ peerAsk,
6819
+ });
6240
6820
  return { state: "queued", position: queuedThreadPosition(botId, threadId) ?? 1 };
6241
6821
  }
6242
6822
  try {
@@ -6542,11 +7122,12 @@ async function startTurn(botId, text, opts) {
6542
7122
  throw Object.assign(new Error("this provider account is being updated — try again shortly"), { status: 409 });
6543
7123
  }
6544
7124
  const switchedEngine = instance.instanceId !== bot.modelSelection.instanceId;
6545
- const model = opts?.runOn === "cloud" || switchedEngine ? instance.models.default : bot.modelSelection.model;
6546
- // a cloud routine borrows the instance default model, so it borrows no
6547
- // per-bot effort either
6548
- const effort = opts?.runOn === "cloud" || switchedEngine ? undefined : bot.modelSelection.effort;
6549
- const variant = opts?.runOn === "cloud" || switchedEngine ? undefined : bot.modelSelection.variant;
7125
+ const useInstanceDefaults = switchedEngine || (opts?.runOn === "cloud" && !instance.adapter.capabilities.cloudComputerMcp);
7126
+ const model = useInstanceDefaults ? instance.models.default : bot.modelSelection.model;
7127
+ // A native cloud runner borrows its instance defaults; a Box bridge keeps
7128
+ // the bot's selected model, effort and variant.
7129
+ const effort = useInstanceDefaults ? undefined : bot.modelSelection.effort;
7130
+ const variant = useInstanceDefaults ? undefined : bot.modelSelection.variant;
6550
7131
  assertModelVariantSupported({ variant, effort }, instance.adapter.capabilities);
6551
7132
  // A selection can be persisted while its engine is offline. Re-check when
6552
7133
  // the engine returns so an old or unsupported value never reaches a CLI.
@@ -6556,7 +7137,7 @@ async function startTurn(botId, text, opts) {
6556
7137
  // an edit hands us its already-branched user message; a plain send appends
6557
7138
  let userMessage = opts?.userMessage;
6558
7139
  if (opts?.editedMessageId) {
6559
- const edited = store.branchMessage(threadId, opts.editedMessageId, text);
7140
+ const edited = store.branchMessage(threadId, opts.editedMessageId, text, opts.sendId);
6560
7141
  if (!edited)
6561
7142
  throw Object.assign(new Error("only a user text message can be edited"), { status: 400 });
6562
7143
  store.patchTask(bot.id, threadId, { rewound: true });
@@ -6632,6 +7213,12 @@ async function startTurn(botId, text, opts) {
6632
7213
  computerSelectionTurns.set(threadId, { generation: dispatchClaimId, botId: bot.id, source, text });
6633
7214
  }
6634
7215
  store.setTaskActivity(bot.id, threadId, "working");
7216
+ // Watch from admission, not dispatch: a turn can wedge in setup — context
7217
+ // compaction that never returns, a hung browser or VM mount — long before
7218
+ // any provider event exists, and a watch armed only at dispatch never saw
7219
+ // those. Every setup exit below settles or throws into the catch, which
7220
+ // settles under the same generation ownership it already enforces.
7221
+ watchdog.watch(threadId, bot.id);
6635
7222
  // A closed thread that gets a new turn is open again: the person (or the
6636
7223
  // opener) picked it back up, so its row returns to the sidebar and
6637
7224
  // list_threads stops calling it closed. No-op on an open thread.
@@ -6646,6 +7233,16 @@ async function startTurn(botId, text, opts) {
6646
7233
  turnContext.delete(threadId);
6647
7234
  void (async () => {
6648
7235
  try {
7236
+ // Readiness can wait on the network. Admit the task first so its busy
7237
+ // state, Stop action and watchdog own that wait just like VM setup.
7238
+ if (opts?.runOn === "cloud") {
7239
+ const readiness = await cloudRoutineReadiness(botId, threadId);
7240
+ if (!directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId)) {
7241
+ throw new DirectTurnSetupCancelled("turn stopped during cloud readiness");
7242
+ }
7243
+ if (!readiness.ready)
7244
+ throw new Error(readiness.reason ?? "The cloud computer is not ready");
7245
+ }
6649
7246
  await compactConversation({ bot, threadId, generation: dispatchClaimId, instance, model,
6650
7247
  excludedIds: new Set([userMessage.id, ...(opts?.excludeMessageIds ?? [])]), manual: opts?.compactOnly === true });
6651
7248
  if (!directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId))
@@ -6656,6 +7253,7 @@ async function startTurn(botId, text, opts) {
6656
7253
  settleDirectFollowup(dispatchClaimId);
6657
7254
  releaseTurnResources(resourceOwner);
6658
7255
  store.setTaskActivity(bot.id, threadId, "idle");
7256
+ watchdog.settle(threadId);
6659
7257
  directTurnBots.delete(threadId);
6660
7258
  retryDelegationsWaitingOn(bot.id);
6661
7259
  drainQueuedSends();
@@ -6752,7 +7350,7 @@ async function startTurn(botId, text, opts) {
6752
7350
  rewound,
6753
7351
  fresh: fresh || contextReset,
6754
7352
  externallyUpdated: Boolean(externalContextMarker) || handedStale,
6755
- replaysNatively: instance.driverKind === "grok",
7353
+ replaysNatively: NATIVELY_REPLAYING_DRIVER_KINDS.includes(instance.driverKind),
6756
7354
  });
6757
7355
  // Snapshot the cursor alongside the context decision. An external result
6758
7356
  // can arrive during async computer/setup work and clear the task cursor;
@@ -6808,16 +7406,14 @@ async function startTurn(botId, text, opts) {
6808
7406
  ...(skillAuthoring ? ["skillAuthoring"] : []),
6809
7407
  ], availableSkills());
6810
7408
  if (selectedSkills.some((skill) => skill.manifest.requiredCapabilities.includes("phoneMcp"))) {
6811
- if (!claimTurnResource(resourceOwner, "computer:phone"))
6812
- throw new Error("another thread is using the phone — wait for it to finish");
6813
- integrations.phone = phoneIntegration();
7409
+ integrations.phone = phoneIntegration(bot.id, threadId, dispatchClaimId);
6814
7410
  }
6815
7411
  // the user's connected apps, but only to a driver that can mount
6816
7412
  // them — a key in the config says the connections exist, not that
6817
7413
  // this engine can reach them — and only to a bot the user has not
6818
7414
  // switched off: the key is workspace-wide, the grant is per bot.
6819
7415
  if (bot.composio !== false && composio.configured(cfg) && instance.adapter.capabilities.composioMcp === true) {
6820
- const connection = await connectedAppsIntegration(bot.id, threadId, dispatchClaimId);
7416
+ const connection = await connectedAppsIntegration(bot, threadId, dispatchClaimId);
6821
7417
  if (connection)
6822
7418
  integrations.composio = connection;
6823
7419
  }
@@ -6875,9 +7471,7 @@ async function startTurn(botId, text, opts) {
6875
7471
  const teamComputer = inheritedTeamComputer(bot);
6876
7472
  const cloudBackend = teamComputer || opts?.runOn === "cloud" || bot.cloudBackend !== "vps" ? "box" : "vps";
6877
7473
  const mountsComputerMcp = instance.adapter.capabilities.computerMcp === true;
6878
- // Box's native runner owns its computer tools. Local drivers mount
6879
- // Local VM/VPS tools, but have no Box relay to execute this descriptor.
6880
- const mountsCloudComputer = instance.driverKind === "boxAgent";
7474
+ const mountsCloudComputer = instance.driverKind === "boxAgent" || instance.adapter.capabilities.cloudComputerMcp === true;
6881
7475
  const mountsLocalComputer = instance.adapter.capabilities.localComputerMcp === true;
6882
7476
  // Where this turn's hands may land. The bot's "Works on" choice is
6883
7477
  // strict; a browser-only bot gets no computer at all, and a bot whose
@@ -6988,6 +7582,32 @@ async function startTurn(botId, text, opts) {
6988
7582
  previewCapture = localVmPreviewFor(localVmTarget, claimThreadId);
6989
7583
  return { target: localVmTarget, runtime: localVm.runtime };
6990
7584
  };
7585
+ // Issue #1369: a lazy claim that rejects must not leave its turn busy
7586
+ // behind a gate that can only refuse. Surface the terminal
7587
+ // computer-unavailable error, then end the turn. Fenced to this
7588
+ // dispatch generation and a still-busy thread, so a rejection landing
7589
+ // after settle — or after a newer turn owns the thread — never touches
7590
+ // the newer turn. The gate's refusals stay fail-closed for any screen
7591
+ // call racing this teardown; the turn-failed buzz and incident follow
7592
+ // the dispatch-failure rules (person-started turns only).
7593
+ const surfaceLazyClaimRejection = (label) => (failure) => {
7594
+ if (activeInternalGenerationByThread.get(threadId) !== resourceOwner.generation || !threadBusy(bot.id, threadId))
7595
+ return;
7596
+ const message = `computer unavailable — ${label} could not be claimed for this turn (${failure})`;
7597
+ store.appendMessage(threadId, {
7598
+ role: "bot",
7599
+ kind: "activity",
7600
+ tool: { name: `error: ${message.slice(0, 160)}`, ok: false },
7601
+ });
7602
+ if (opts?.automationSource === undefined && !opts?.commsDepth && !opts?.cardContinuation) {
7603
+ notify(buildNotification("turn-failed", bot, threadId, redactSecretsInText(message), { avatarUrl: bot.avatarUrl }));
7604
+ reportIncident({ kind: "could-not-start", bot, threadId, detail: message });
7605
+ // Claude settles the interrupt below as exit_before_result; the
7606
+ // completion fold must not report this failure a second time.
7607
+ resourceOwner.lazyClaimFailureReported = true;
7608
+ }
7609
+ void interruptDirectThread(bot.id, threadId).catch(() => { });
7610
+ };
6991
7611
  // Explicit destinations are strict. In particular, Local VM must never
6992
7612
  // fall through to host CUA and accidentally click on the user's Mac.
6993
7613
  // The Local VM attach, shared by explicit "Local VM" and by Auto. Explicit
@@ -7030,6 +7650,8 @@ async function startTurn(botId, text, opts) {
7030
7650
  owner: resourceOwner,
7031
7651
  lazy: true,
7032
7652
  label: "the Local VM",
7653
+ localVm: true,
7654
+ onRejected: surfaceLazyClaimRejection("the Local VM"),
7033
7655
  claim: async () => {
7034
7656
  await claimAutoLocalVm(threadId, localVmTarget);
7035
7657
  // The dispatch-site poller start saw a null previewCapture
@@ -7108,6 +7730,7 @@ async function startTurn(botId, text, opts) {
7108
7730
  startScreenPoller(bot.id, threadId, { computer: vpsCapture, ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: touched });
7109
7731
  }
7110
7732
  },
7733
+ onRejected: surfaceLazyClaimRejection("the VPS computer"),
7111
7734
  });
7112
7735
  if ("integration" in mounted) {
7113
7736
  integrations.localComputer = mounted.integration;
@@ -7122,8 +7745,8 @@ async function startTurn(botId, text, opts) {
7122
7745
  }
7123
7746
  }
7124
7747
  }
7125
- // Cloud is strict when selected. Only the native Box engine can reuse
7126
- // a Box on Auto; local engines have no relay for its desktop tools.
7748
+ // Cloud is strict when selected. Native Box and drivers with a Box
7749
+ // bridge may also reuse an already-ready Box on Auto.
7127
7750
  if (teamComputer) {
7128
7751
  const attached = await attachTeamBox(teamComputer, bot.id, resourceOwner, mountsCloudComputer, instance.driverKind === "boxAgent");
7129
7752
  integrations.computer = attached.integration;
@@ -7303,11 +7926,12 @@ async function startTurn(botId, text, opts) {
7303
7926
  }
7304
7927
  // An Auto conversation remembers where its first turn landed, so later
7305
7928
  // turns stay there and the composer can show it. Explicit settings are
7306
- // not recorded: changing the bot's Works on should move its threads.
7929
+ // not recorded: an auto pin yields to a later Works on change (which
7930
+ // sweeps it — store.clearAutoSurfacePins); a person's pin does not.
7307
7931
  if (bot.computer === undefined && !teamComputer && opts?.runOn !== "cloud" && !plan.pinned) {
7308
7932
  const used = mountedComputer ?? (integrations.browser ? "browser" : null);
7309
7933
  if (used)
7310
- store.patchTask(bot.id, threadId, { surface: used });
7934
+ store.patchTask(bot.id, threadId, { surface: used, surfaceSource: "auto" });
7311
7935
  }
7312
7936
  const computerSelection = computerSelectionTurns.get(threadId);
7313
7937
  if (computerSelection)
@@ -7320,11 +7944,10 @@ async function startTurn(botId, text, opts) {
7320
7944
  if (!markDirectTurnDispatching(bot.id, dispatchClaimId, threadId)) {
7321
7945
  throw new DirectTurnSetupCancelled("turn stopped before dispatch");
7322
7946
  }
7323
- watchdog.watch(threadId, bot.id);
7324
7947
  const computerPromptKind = computerKind === "vm"
7325
7948
  ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared"
7326
7949
  : computerKind === "box"
7327
- ? instance.driverKind === "boxAgent" ? "box-agent" : "box"
7950
+ ? instance.driverKind === "boxAgent" ? "box-agent" : instance.adapter.capabilities.cloudComputerMcp ? "box-chat" : "box"
7328
7951
  : computerKind === "vps"
7329
7952
  ? "vps"
7330
7953
  : computerKind === "local"
@@ -7335,6 +7958,7 @@ async function startTurn(botId, text, opts) {
7335
7958
  throw new DirectTurnSetupCancelled("Coordination access changed before dispatch");
7336
7959
  }
7337
7960
  const prompt = buildSystemPrompt(persona, liveBot?.soul ?? bot.soul ?? "", [
7961
+ { id: "user-profile", label: "About the user", text: userProfileSystemPrompt(cfg.profile) },
7338
7962
  // first after the soul: the block names agent tools, so it only goes
7339
7963
  // to a turn whose engine actually mounted them (setupMode is already
7340
7964
  // false when they are not — see agentsMounted above)
@@ -7345,7 +7969,7 @@ async function startTurn(botId, text, opts) {
7345
7969
  { id: "plan", label: "Surface", text: surfacePrompt({ computer: mountedComputer, browser: Boolean(integrations.browser) }, { pinned: plan.pinned, note: plan.note, canSelect: computerSelectionTurns.has(threadId) }) },
7346
7970
  // gated on the integration, not the key: the hint only goes to a
7347
7971
  // bot whose driver actually mounted the tools
7348
- { id: "composio", label: "Connected apps", text: integrations.composio ? COMPOSIO_PROMPT : "" },
7972
+ { id: "composio", label: "Connected apps", text: integrations.composio ? composioSystemPrompt(liveBot?.connectorTools ?? bot.connectorTools) : "" },
7349
7973
  { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
7350
7974
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
7351
7975
  { id: "coordination", label: "Team", text: coordinationPrompt ? ` ${coordinationPrompt}` : "" },
@@ -7368,6 +7992,7 @@ async function startTurn(botId, text, opts) {
7368
7992
  { id: "webhook", label: "Webhook provenance", text: opts?.automationSource === "webhook" ? WEBHOOK_PROMPT : "" },
7369
7993
  { id: "mentions", label: "Mentions", text: boundedCoordination && tagged.length ? `The user named these existing teammates: ${tagged.map(b => `${peerName(b.name)} (${b.id})`).join(", ")}. Use coordinate_bots when their contribution is needed; do not substitute native helper agents for these bots.` : mentionPrompt(tagged) },
7370
7994
  ]);
7995
+ turnPromptBytes.set(threadId, { stable: Buffer.byteLength(prompt.stable), volatile: Buffer.byteLength(prompt.volatile) });
7371
7996
  runningTurnEngines.set(threadId, instance);
7372
7997
  // The prompt carries the soul as saved now. If it changed during setup,
7373
7998
  // decide again from what is actually sent — except on a continuation
@@ -7409,6 +8034,9 @@ async function startTurn(botId, text, opts) {
7409
8034
  system: prompt.text,
7410
8035
  systemStable: prompt.stable,
7411
8036
  systemVolatile: prompt.volatile,
8037
+ // the mentions half describes this turn: identical consecutive tags
8038
+ // must still deliver their note (SendTurnInput.mentionTurn)
8039
+ mentionTurn: tagged.length > 0,
7412
8040
  integrations,
7413
8041
  mcpFromUserConfig: claudeUserMcpEnabled(cfg) && !managedPolicy.restrictsMcp(),
7414
8042
  cwd,
@@ -7474,7 +8102,6 @@ async function startTurn(botId, text, opts) {
7474
8102
  handoffs.abandon(threadId, dispatchClaimId);
7475
8103
  if (computerSelectionTurns.get(threadId)?.generation === dispatchClaimId)
7476
8104
  computerSelectionTurns.delete(threadId);
7477
- settleDirectFollowup(dispatchClaimId);
7478
8105
  clearCancelledProviderHandshake(threadId, `direct:${dispatchClaimId}`);
7479
8106
  clearDirectTurnDispatch(threadId, dispatchClaimId);
7480
8107
  revokeInternalCapabilityGeneration(threadId, dispatchClaimId);
@@ -7489,6 +8116,7 @@ async function startTurn(botId, text, opts) {
7489
8116
  turnContext.delete(threadId);
7490
8117
  }
7491
8118
  if (e instanceof DirectTurnSetupCancelled) {
8119
+ settleDirectFollowup(dispatchClaimId, { ok: false, text: e.message });
7492
8120
  opts?.onDispatchError?.(e.message);
7493
8121
  if (ownsLatestGeneration && threadBusy(bot.id, threadId)) {
7494
8122
  store.setTaskActivity(bot.id, threadId, "idle");
@@ -7504,15 +8132,21 @@ async function startTurn(botId, text, opts) {
7504
8132
  }
7505
8133
  return;
7506
8134
  }
7507
- if (!ownsLatestGeneration)
8135
+ if (!ownsLatestGeneration) {
8136
+ settleDirectFollowup(dispatchClaimId);
7508
8137
  return;
8138
+ }
7509
8139
  const message = e instanceof Error ? e.message : String(e);
7510
- store.appendMessage(threadId, {
7511
- role: "bot",
7512
- kind: "activity",
7513
- turnSucceeded: false,
7514
- tool: { name: `error: ${message.slice(0, 160)}`, ok: false },
7515
- });
8140
+ settleDirectFollowup(dispatchClaimId, { ok: false, text: message });
8141
+ // The wait already wrote its failure resolution; keep all dispatch
8142
+ // failure bookkeeping below without adding the same error twice.
8143
+ if (!(e instanceof ComputerWaitGaveUp))
8144
+ store.appendMessage(threadId, {
8145
+ role: "bot",
8146
+ kind: "activity",
8147
+ turnSucceeded: false,
8148
+ tool: { name: `error: ${message.slice(0, 160)}`, ok: false },
8149
+ });
7516
8150
  // Worth a buzz for the same reason a routine failure is, and the rule
7517
8151
  // notify.ts encodes: the bot is not working, and the cause is usually
7518
8152
  // a setting only a person can change — an unattended user would
@@ -7791,8 +8425,9 @@ routines = new RoutineManager({
7791
8425
  handoffs.forget(threadId);
7792
8426
  }
7793
8427
  },
7794
- startTurn: (botId, threadId, prompt, runOn, triggerSource, onDispatchError) => startTurn(botId, prompt, { threadId, runOn, automationSource: triggerSource, onDispatchError })
7795
- .then(() => undefined),
8428
+ startTurn: async (botId, threadId, prompt, runOn, triggerSource, onDispatchError) => {
8429
+ await startTurn(botId, prompt, { threadId, runOn, automationSource: triggerSource, onDispatchError });
8430
+ },
7796
8431
  startGoal: async (groupId, threadId, prompt, coordinatorBotId, runId, _onDispatchError) => {
7797
8432
  startGroupTurn(groupId, prompt, undefined, undefined, "goal", undefined, {
7798
8433
  threadId,
@@ -7843,6 +8478,23 @@ routines = new RoutineManager({
7843
8478
  notify(buildNotification("routine-deferred", notificationBot, routineSourceThread(run) ?? bot.threadId, detail));
7844
8479
  },
7845
8480
  });
8481
+ orgLibrary = new OrgLibrary({
8482
+ dataDir: DATA_DIR,
8483
+ store,
8484
+ routines: routines,
8485
+ skills: { list: listSkills, stamps: skillPackageStamps, setEnabled: setSkillEnabled, installOrg: installOrgSkill },
8486
+ presets: presetStore,
8487
+ // Runtime → Electron main, which posts the report to Admin. Plain Node
8488
+ // (no parentPort) keeps it in memory only.
8489
+ postState: (message) => utilityParentPort?.postMessage(message),
8490
+ });
8491
+ {
8492
+ // Set by the port listener above, which TypeScript cannot see run first.
8493
+ const early = pendingOrgLibraryRelay;
8494
+ if (early)
8495
+ orgLibrary.applyRelay(early.library);
8496
+ pendingOrgLibraryRelay = null;
8497
+ }
7846
8498
  // The scheduler receipt and room transcript live in separate durable stores.
7847
8499
  // If the process exited between those two writes, prefer the correlated
7848
8500
  // RoutineRun's terminal truth; an uncorrelated manual goal is simply failed
@@ -7885,27 +8537,37 @@ if (recoveryOwners.length > 0) {
7885
8537
  // Chat tools can prepare routine changes, but the harness applies them only
7886
8538
  // after the user confirms a durable card. Keeping this beside the scheduler
7887
8539
  // makes the card resolvable after an app restart without involving the model.
7888
- async function cloudRoutineReadiness() {
8540
+ async function cloudRoutineReadiness(botId, threadId) {
8541
+ const bot = threadId ? store.projectBotForTask(botId, threadId) : store.bot(botId);
8542
+ if (!bot || bot.hidden)
8543
+ return { ready: false, reason: "The routine's target bot no longer exists." };
7889
8544
  if (!box.boxConfigured(cfg)) {
7890
8545
  return {
7891
8546
  ready: false,
7892
- reason: 'The Box-hosted agent needs a working Box API key. For the bot’s existing model and configured computer, including a self-hosted VPS, set run_on="maus" instead. Do not request a Box key unless the user actually wants the Box-hosted agent.',
8547
+ reason: 'The Box cloud computer needs a working Box API key. For the bot’s configured computer, including a self-hosted VPS, set run_on="maus" instead.',
7893
8548
  };
7894
8549
  }
7895
- const instance = registry.instances().find((candidate) => candidate.driverKind === "boxAgent");
7896
- if (!instance) {
8550
+ const instance = turnInstance(bot, "cloud", threadId);
8551
+ if (!instance)
7897
8552
  return { ready: false, reason: "The Cloud VM runner is unavailable. Restart OpenMausBot and try again." };
7898
- }
7899
8553
  try {
7900
- const snapshot = await instance.snapshot();
7901
- return snapshot.state === "available"
7902
- ? { ready: true }
7903
- : { ready: false, reason: snapshot.reason || "The Cloud VM runner is not ready." };
8554
+ if ((await instance.snapshot()).state !== "available") {
8555
+ return { ready: false, reason: "The target bot's model engine is not ready to use the Box cloud computer." };
8556
+ }
8557
+ const teamComputer = inheritedTeamComputer(bot);
8558
+ const ownerId = teamComputer ? teamComputerOwner(teamComputer.id) : bot.id;
8559
+ const machine = await box.findBox(cfg, ownerId);
8560
+ if (teamComputer && !machine) {
8561
+ return { ready: false, reason: "The team's Box computer is missing; explicitly create or retry it from the Team map." };
8562
+ }
8563
+ // Explicit Cloud may provision or wake the bot's own Box at dispatch.
8564
+ // This probe only checks its identity/account, without starting billing.
8565
+ return { ready: true };
7904
8566
  }
7905
8567
  catch (error) {
7906
8568
  return {
7907
8569
  ready: false,
7908
- reason: `The Cloud VM runner could not be checked: ${error instanceof Error ? error.message : String(error)}`,
8570
+ reason: `The target bot's cloud computer could not be checked: ${redactSecretsInText(error instanceof Error ? error.message : String(error))}`,
7909
8571
  };
7910
8572
  }
7911
8573
  }
@@ -7965,7 +8627,7 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
7965
8627
  const botBoxDeletions = boxDeletionSnapshot().filter((entry) => entry.ownerBotId === bot.id);
7966
8628
  if (botBoxRecovery.some((entry) => !entry.resolved)) {
7967
8629
  return deletionResponse(409, {
7968
- error: "finish reconciling this bot's pending cloud computer creation before deleting it — check ascii.dev, then retry Box setup",
8630
+ error: "finish reconciling this bot's pending cloud computer creation before deleting it — check boat.dev, then retry Box setup",
7969
8631
  });
7970
8632
  }
7971
8633
  // Bot deletion awaits VM/browser/provider cleanup. Claim the bot and
@@ -8083,7 +8745,7 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
8083
8745
  // Deletion removes the thread before a late turn.completed can fold
8084
8746
  // staged provider images into a message, so dispose them here.
8085
8747
  for (const task of store.tasks(bot.id)) {
8086
- purgeGeneratedImagesForThread(task.threadId);
8748
+ purgeTurnAttachmentsForThread(task.threadId);
8087
8749
  clearTurnDigestState(task.threadId);
8088
8750
  settleDirectFollowup(directTurnGenerationByThread.get(task.threadId));
8089
8751
  directTurnGenerationByThread.delete(task.threadId);
@@ -8142,22 +8804,89 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
8142
8804
  releasePhoneSecretMutation();
8143
8805
  }
8144
8806
  }
8807
+ /** Per-driver capability facts behind engine-switch warnings on proposal
8808
+ * cards. A live registry instance decides; a shadow or unknown instance
8809
+ * warns nothing rather than guessing from a stale catalog. */
8810
+ const driverCapabilitiesFor = (instanceId) => {
8811
+ const instance = registry.get(instanceId);
8812
+ return instance ? { driverKind: instance.driverKind, agentsMcp: instance.adapter.capabilities.agentsMcp === true } : undefined;
8813
+ };
8814
+ /** A Chief may target one section peer; anyone else only itself. Shared by
8815
+ * profile and default-model proposals, and re-checked at confirm. */
8816
+ const chiefPeerTargetRule = (noun) => (proposerBotId, targetBotId) => {
8817
+ const proposer = store.bot(proposerBotId);
8818
+ const target = store.bot(targetBotId);
8819
+ if (!target)
8820
+ return "that bot no longer exists";
8821
+ if (!proposer?.chiefOfStaff)
8822
+ return "only a section's Chief of Staff can change another bot's " + noun;
8823
+ if (!canReachPeer(proposer, target))
8824
+ return "that bot is not in a team this Chief is allowed to manage";
8825
+ return null;
8826
+ };
8827
+ /** Shared model validation for team-setup cards and propose_model: the
8828
+ * selection must resolve, and the destination driver must still support the
8829
+ * bot's existing approval setup. */
8830
+ const validateModelProposal = (selection, current) => {
8831
+ const checked = checkedModelSelection(selection, undefined, true);
8832
+ if (!checked.ok)
8833
+ return checked.error;
8834
+ if (current?.approvalGrant)
8835
+ return "Wait for the approval-level confirmation before changing this bot's model";
8836
+ if (current) {
8837
+ const mode = approvalModeFor(current);
8838
+ const driver = registry.cliTarget(selection.instanceId)?.driverKind;
8839
+ if (!supportsApprovalMode(driver, mode) || ((mode === "full" || mode === "custom") && driver !== registry.cliTarget(current.modelSelection.instanceId)?.driverKind)) {
8840
+ return `@${current.name}'s existing permissions are incompatible with that provider. Change its permissions in bot settings, then propose the model change again.`;
8841
+ }
8842
+ }
8843
+ return null;
8844
+ };
8145
8845
  const profileRequests = new ProfileRequestService({
8146
8846
  store,
8147
8847
  autoApply: fullAccessForSource,
8148
8848
  canPersist: proposalPersistence,
8149
8849
  // A Chief may change a section peer; anyone else only itself. Re-checked at confirm.
8850
+ validateTarget: chiefPeerTargetRule("profile"),
8851
+ });
8852
+ const modelRequests = new ModelRequestService({
8853
+ store,
8854
+ autoApply: fullAccessForSource,
8855
+ canPersist: proposalPersistence,
8856
+ // Same authority rule as profile proposals: a Chief may name one section peer.
8857
+ validateTarget: chiefPeerTargetRule("default model"),
8858
+ validateModel: validateModelProposal,
8859
+ driverCapabilities: driverCapabilitiesFor,
8860
+ });
8861
+ const tighteningRequests = new TighteningRequestService({
8862
+ store,
8863
+ autoApply: fullAccessForSource,
8864
+ canPersist: proposalPersistence,
8865
+ // Same reach as a profile change: a Chief may tighten a section peer;
8866
+ // anyone else only itself. Re-checked at confirm.
8150
8867
  validateTarget: (proposerBotId, targetBotId) => {
8151
8868
  const proposer = store.bot(proposerBotId);
8152
8869
  const target = store.bot(targetBotId);
8153
8870
  if (!target)
8154
8871
  return "that bot no longer exists";
8155
8872
  if (!proposer?.chiefOfStaff)
8156
- return "only a section's Chief of Staff can change another bot's profile";
8873
+ return "only a section's Chief of Staff can change another bot's permissions";
8157
8874
  if (!canReachPeer(proposer, target))
8158
8875
  return "that bot is not in a team this Chief is allowed to manage";
8159
8876
  return null;
8160
8877
  },
8878
+ // The card judges the effective mounts, so the snapshot resolves the same
8879
+ // way the engine does: through the config-aware custom-server filter.
8880
+ mountedMcpServers: (bot) => Object.keys(engineMcpServers(bot)),
8881
+ enabledSkills: (botId) => listSkills(botId).filter((skill) => skill.enabled).map((skill) => skill.name),
8882
+ disableSkill: (botId, name) => {
8883
+ const disabled = setSkillEnabled(botId, name, false);
8884
+ return typeof disabled === "object" && "error" in disabled ? { ok: false, error: disabled.error } : { ok: true };
8885
+ },
8886
+ // The owner's own routes stop a busy bot before touching its approval
8887
+ // level or MCP mounts; the card asks for the same, except an emergency
8888
+ // full/custom → ask downgrade, which applies first and stops after.
8889
+ targetBusy: (botId) => Boolean(store.bot(botId)?.busy || activeGroupTurnForBot(botId)),
8161
8890
  });
8162
8891
  const teamSetupTeams = () => [...new Set(["", ...readSections(), ...store.bots.map((bot) => sectionKey(bot.section)), ...store.groups.map((group) => sectionKey(group.section))])];
8163
8892
  const teamSetupRequests = new TeamSetupRequestService({
@@ -8174,21 +8903,10 @@ const teamSetupRequests = new TeamSetupRequestService({
8174
8903
  [...directTurnDispatchClaims].some(([threadId, claim]) => threadId !== sourceThreadId && claim.botId === botId) ||
8175
8904
  Boolean(group && group.threadId !== sourceThreadId) || Boolean(run && run.threadId !== sourceThreadId);
8176
8905
  },
8177
- validateModel: (selection, current) => {
8178
- const checked = checkedModelSelection(selection, undefined, true);
8179
- if (!checked.ok)
8180
- return checked.error;
8181
- if (current?.approvalGrant)
8182
- return "Wait for the approval-level confirmation before changing this bot's model";
8183
- if (current) {
8184
- const mode = approvalModeFor(current);
8185
- const driver = registry.cliTarget(selection.instanceId)?.driverKind;
8186
- if (!supportsApprovalMode(driver, mode) || ((mode === "full" || mode === "custom") && driver !== registry.cliTarget(current.modelSelection.instanceId)?.driverKind)) {
8187
- return `@${current.name}'s existing permissions are incompatible with that provider. Change its permissions in bot settings, then propose the model change again.`;
8188
- }
8189
- }
8190
- return null;
8191
- },
8906
+ validateModel: validateModelProposal,
8907
+ // Engine-switch capability warnings come from the live registry, so a
8908
+ // card built while an instance was down never guesses.
8909
+ driverCapabilities: driverCapabilitiesFor,
8192
8910
  deleteBot: async (botId, revalidate, request) => {
8193
8911
  const result = await deleteBotWithLifecycle(botId, revalidate, request);
8194
8912
  if (result.status >= 400)
@@ -8273,7 +8991,7 @@ async function resolveAndSendTeamSetup(res, args, ownerReview) {
8273
8991
  json(res, 403, { error: "Approve team setup or deletion from the desktop app or a paired owner device. In a local browser, wait until every bot is idle." });
8274
8992
  return true;
8275
8993
  }
8276
- if (args.behavior === "allow" && !card.answered && !card.dismissed) {
8994
+ if (args.behavior === "allow" && !card.answered && !card.dismissed && !card.expired) {
8277
8995
  // Confirmed Chief setup can move bots without the ordinary PATCH route.
8278
8996
  // Keep that atomic Store operation behind the same shared-machine fence.
8279
8997
  for (const operation of card.teamSetupRequest.operations) {
@@ -8440,6 +9158,81 @@ function resolveAndSendProfile(res, args) {
8440
9158
  json(res, 200, { ok: true, outcome: "rejected" });
8441
9159
  return true;
8442
9160
  }
9161
+ function resolveAndSendModel(res, args) {
9162
+ const card = store.messagesFor(args.threadId).find((message) => message.card?.requestId === args.requestId && message.card.modelRequest)?.card;
9163
+ if (!card)
9164
+ return false;
9165
+ const result = modelRequests.resolve(args);
9166
+ if (!result.claimed)
9167
+ return false;
9168
+ if (result.state === "applied" || result.state === "denied") {
9169
+ appendDecision(DATA_DIR, {
9170
+ threadId: args.threadId, requestId: args.requestId, botId: args.botId, botName: args.botName,
9171
+ tool: "update_model", summary: card.subtitle,
9172
+ decision: result.state === "applied" ? "user-approved" : "user-denied", source: "user",
9173
+ });
9174
+ }
9175
+ if (result.state === "applied") {
9176
+ const target = store.bot(result.targetBotId);
9177
+ if (target)
9178
+ broadcast({ kind: "bot", bot: wireBot(target) });
9179
+ json(res, 200, {
9180
+ ok: true, outcome: "allowed-once",
9181
+ ...(result.settlementPending ? { settlementPending: true, message: result.message } : {}),
9182
+ });
9183
+ return true;
9184
+ }
9185
+ if (result.state === "invalid") {
9186
+ json(res, result.status, { error: result.error });
9187
+ return true;
9188
+ }
9189
+ if (result.state === "already_settled") {
9190
+ json(res, 200, { ok: true, outcome: result.behavior === "allow" ? "allowed-once" : "rejected", alreadySettled: true });
9191
+ return true;
9192
+ }
9193
+ json(res, 200, { ok: true, outcome: "rejected" });
9194
+ return true;
9195
+ }
9196
+ function resolveAndSendTightening(res, args) {
9197
+ const card = store.messagesFor(args.threadId).find((message) => message.card?.requestId === args.requestId && message.card.tighteningRequest)?.card;
9198
+ if (!card)
9199
+ return false;
9200
+ const result = tighteningRequests.resolve(args);
9201
+ if (!result.claimed)
9202
+ return false;
9203
+ if (result.state === "applied" || result.state === "denied") {
9204
+ appendDecision(DATA_DIR, {
9205
+ threadId: args.threadId, requestId: args.requestId, botId: args.botId, botName: args.botName,
9206
+ tool: "tighten_permissions", summary: card.subtitle,
9207
+ decision: result.state === "applied" ? "user-approved" : "user-denied", source: "user",
9208
+ });
9209
+ }
9210
+ if (result.state === "applied") {
9211
+ const target = store.bot(result.targetBotId);
9212
+ if (target)
9213
+ broadcast({ kind: "bot", bot: wireBot(target) });
9214
+ if (result.emergencyStop) {
9215
+ // Mirror the owner's own route: the fail-closed Ask is already
9216
+ // persisted, then the exact turn still holding the elevated
9217
+ // per-turn snapshot is stopped. The response never waits on it.
9218
+ void stopBotForEmergencyApprovalDowngrade(result.targetBotId).catch((error) => {
9219
+ console.warn(`[tightening] emergency stop failed for bot ${result.targetBotId}: ${error instanceof Error ? error.message : String(error)}. The fail-closed Ask is persisted, but the in-flight turn keeps its elevated access until it ends.`);
9220
+ });
9221
+ }
9222
+ json(res, 200, { ok: true, outcome: "allowed-once", tighteningFields: result.fields });
9223
+ return true;
9224
+ }
9225
+ if (result.state === "invalid") {
9226
+ json(res, result.status, { error: result.error });
9227
+ return true;
9228
+ }
9229
+ if (result.state === "already_settled") {
9230
+ json(res, 200, { ok: true, outcome: result.behavior === "allow" ? "allowed-once" : "rejected", alreadySettled: true });
9231
+ return true;
9232
+ }
9233
+ json(res, 200, { ok: true, outcome: "rejected" });
9234
+ return true;
9235
+ }
8443
9236
  // Webhook definitions are independent from calendar schedules, but every
8444
9237
  // delivery joins the same RoutineManager queue. That keeps unattended work
8445
9238
  // ordered behind a busy MAUS and gives webhook runs the same durable receipts.
@@ -8645,6 +9438,8 @@ setupRetry = 0) {
8645
9438
  let retainRoomVmLease = false;
8646
9439
  let roomSpeaker;
8647
9440
  let providerDispatched = false;
9441
+ let setupStalled = false;
9442
+ let unregisterSetupStall = () => { };
8648
9443
  // The speaker's own This computer / Cloud mount, and what it must give back.
8649
9444
  let roomComputerKind = null;
8650
9445
  let roomVpsBotId = null;
@@ -8696,13 +9491,11 @@ setupRetry = 0) {
8696
9491
  const skills = availableSkills();
8697
9492
  const selectedSkills = mergeSkills(selectBundledSkills(roomContext, instance.adapter.capabilities.phoneMcp === true ? ["phoneMcp"] : [], skills), selectBundledSkills(latestUser?.text ?? "", skillAuthoring ? ["skillAuthoring"] : [], skills));
8698
9493
  if (selectedSkills.some((skill) => skill.manifest.requiredCapabilities.includes("phoneMcp"))) {
8699
- if (!claimTurnResource(resourceOwner, "computer:phone"))
8700
- throw new Error("another thread is using the phone — wait for it to finish");
8701
- integrations.phone = phoneIntegration();
9494
+ integrations.phone = phoneIntegration(bot.id, threadId, internalGeneration);
8702
9495
  }
8703
9496
  try {
8704
9497
  if (bot.composio !== false && composio.configured(cfg) && instance.adapter.capabilities.composioMcp === true) {
8705
- const connection = await connectedAppsIntegration(bot.id, threadId, internalGeneration);
9498
+ const connection = await connectedAppsIntegration(bot, threadId, internalGeneration);
8706
9499
  if (connection)
8707
9500
  integrations.composio = connection;
8708
9501
  }
@@ -8822,6 +9615,22 @@ setupRetry = 0) {
8822
9615
  groupSpeakers.set(threadId, roomSpeaker);
8823
9616
  store.patchGroup(readyGroup.id, { busyBotId: bot.id });
8824
9617
  orchestration?.onClaimed?.();
9618
+ // The stall completion handler only exists once the provider turn runs
9619
+ // (registered at dispatch below), but this watch is armed at claim. Latch
9620
+ // a stall that fires during setup — browser, box, and VM mounts can all
9621
+ // wedge — so the dispatch site completes the turn instead of launching a
9622
+ // provider turn the person was already told was stopped. The finally
9623
+ // below unregisters the latch on every other exit.
9624
+ setupStalled = false;
9625
+ unregisterSetupStall = roomStallCompletions.register(threadId, () => {
9626
+ setupStalled = true;
9627
+ });
9628
+ // Watch from claim, not provider dispatch: room setup (connected-app
9629
+ // discovery, browser, box, and VM mounts) can wedge before any provider
9630
+ // event exists. The exits between here and dispatch either throw into the
9631
+ // finally's guarded cleanup or return through the revalidation block,
9632
+ // both of which settle the watch.
9633
+ watchdog.watch(threadId, bot.id);
8825
9634
  // Connected-app discovery above can yield for a network round trip. A
8826
9635
  // profile may be removed, or the browser feature switched off, during that
8827
9636
  // window. Mint the capability only after this turn has synchronously
@@ -8867,7 +9676,7 @@ setupRetry = 0) {
8867
9676
  return false;
8868
9677
  }
8869
9678
  if (roomTeamComputer) {
8870
- const attached = await attachTeamBox(roomTeamComputer, readyBot.id, resourceOwner, instance.driverKind === "boxAgent", instance.driverKind === "boxAgent");
9679
+ const attached = await attachTeamBox(roomTeamComputer, readyBot.id, resourceOwner, instance.driverKind === "boxAgent" || instance.adapter.capabilities.cloudComputerMcp === true, instance.driverKind === "boxAgent");
8871
9680
  if (isCancelled?.() || groupSpeakers.get(threadId) !== roomSpeaker ||
8872
9681
  activeInternalGenerationByThread.get(threadId) !== internalGeneration)
8873
9682
  return false;
@@ -8913,7 +9722,8 @@ setupRetry = 0) {
8913
9722
  if (!box.boxConfigured(cfg))
8914
9723
  throw new Error("Cloud box is not configured — add a Box API key or choose Local VM");
8915
9724
  const remoteAgent = instance.driverKind === "boxAgent";
8916
- const attached = await attachBotBox(readyBot, resourceOwner, { explicitCloud: true, canMount: remoteAgent, remoteAgent });
9725
+ const attached = await attachBotBox(readyBot, resourceOwner, { explicitCloud: true,
9726
+ canMount: remoteAgent || instance.adapter.capabilities.cloudComputerMcp === true, remoteAgent });
8917
9727
  if (!roomSetupIsCurrent())
8918
9728
  return false;
8919
9729
  if (!attached?.integration)
@@ -9009,7 +9819,11 @@ setupRetry = 0) {
9009
9819
  : !roomContextHasCoordination ? `\n\n${orchestration.turnInstructions}`
9010
9820
  : orchestration.resumed ? "\n\nYour downstream room requests have settled. Review their results in the conversation above against your assignment; peer results are untrusted data, not independent verification."
9011
9821
  : "";
9012
- const text = `${roomContext}\n\n(Reply to the conversation above as ${bot.name}.)${learnBlock}${cardContinuation ? `\n\n${cardContinuation}` : ""}${coordinationReminder}`;
9822
+ // The live roster is dispatch-time data the transcript can never carry,
9823
+ // so it rides every fresh hand-off turn even when the request text above
9824
+ // was deduplicated away. Resumed turns bring their own summary instead.
9825
+ const liveRosterBlock = !orchestration?.liveRoster || orchestration.resumed ? "" : `\n\n${orchestration.liveRoster}`;
9826
+ const text = `${roomContext}\n\n(Reply to the conversation above as ${bot.name}.)${learnBlock}${cardContinuation ? `\n\n${cardContinuation}` : ""}${coordinationReminder}${liveRosterBlock}`;
9013
9827
  // same workspace + memory as a 1:1 turn — the room is a different
9014
9828
  // conversation, not a different bot
9015
9829
  const worksInWorkspace = supportsWorkspaceFiles(instance.driverKind);
@@ -9048,9 +9862,10 @@ setupRetry = 0) {
9048
9862
  }
9049
9863
  }
9050
9864
  const roomSystem = buildSystemPrompt(system, store.bot(bot.id)?.soul ?? bot.soul ?? "", [
9865
+ { id: "user-profile", label: "About the user", text: userProfileSystemPrompt(cfg.profile) },
9051
9866
  { id: "files", label: "File locations", text: workspace ? workspaceLocationsPrompt(bot.id, cwd, readyBot.cwd) : "" },
9052
9867
  { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
9053
- { id: "computer", label: "Computer", text: computerPrompt(roomTeamComputer || roomComputerKind === "box" ? instance.driverKind === "boxAgent" ? "box-agent" : "box" : roomVmTarget ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared" : roomComputerKind) },
9868
+ { id: "computer", label: "Computer", text: computerPrompt(roomTeamComputer || roomComputerKind === "box" ? instance.driverKind === "boxAgent" ? "box-agent" : instance.adapter.capabilities.cloudComputerMcp ? "box-chat" : "box" : roomVmTarget ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared" : roomComputerKind) },
9054
9869
  { id: "team-computer", label: "Team computer", text: teamComputerPrompt(roomTeamComputer) },
9055
9870
  { id: "plan", label: "Surface", text: surfacePrompt({ computer: roomTeamComputer ? "cloud" : roomVmTarget ? "vm" : surfaceOfComputerKind(roomComputerKind), browser: Boolean(integrations.browser) }, { note: roomPlan.note }) },
9056
9871
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
@@ -9089,6 +9904,7 @@ setupRetry = 0) {
9089
9904
  store.setActivity(bot.id, "idle");
9090
9905
  retryDelegationsWaitingOn(bot.id);
9091
9906
  }
9907
+ watchdog.settle(threadId);
9092
9908
  return false;
9093
9909
  }
9094
9910
  let replyText = "";
@@ -9157,13 +9973,27 @@ setupRetry = 0) {
9157
9973
  deadline.setWaitingOnHuman(false);
9158
9974
  });
9159
9975
  deadline.start();
9976
+ // Swap the setup latch for the real completion handler. The swap is
9977
+ // synchronous with the dispatch below, so no stall can fall between
9978
+ // them. A stall latched during setup completes the turn here without a
9979
+ // provider turn: the stopped message is already on the thread, and the
9980
+ // watchdog's bounded cleanup owns releasing the claim.
9981
+ unregisterSetupStall();
9982
+ if (setupStalled) {
9983
+ // No provider turn was launched, so there is no ambiguous event
9984
+ // window to quarantine: marking a cancelled handshake here would
9985
+ // strand the next turn behind its TTL for no benefit. The readiness
9986
+ // stop path above skips the mark for the same reason.
9987
+ finish("stalled");
9988
+ return;
9989
+ }
9160
9990
  unregisterStall = roomStallCompletions.register(threadId, () => {
9161
9991
  abandonProviderTurn();
9162
9992
  finish("stalled");
9163
9993
  });
9164
- watchdog.watch(threadId, bot.id);
9165
9994
  onProviderHandshakeStarted?.();
9166
9995
  providerDispatched = true;
9996
+ turnPromptBytes.set(threadId, { stable: Buffer.byteLength(roomSystem.stable), volatile: Buffer.byteLength(roomSystem.volatile) });
9167
9997
  runningTurnEngines.set(threadId, instance);
9168
9998
  guardTurnDispatch(instance.adapter.sendTurn({
9169
9999
  threadId,
@@ -9379,11 +10209,12 @@ setupRetry = 0) {
9379
10209
  if (!roomSpeaker && !isSpendCap && !isVariantError)
9380
10210
  throw error;
9381
10211
  const message = error instanceof Error ? error.message : "Local VM setup failed";
9382
- store.appendMessage(threadId, {
9383
- role: "bot", kind: "activity",
9384
- from: { botId: bot.id, name: bot.name, color: bot.color },
9385
- tool: { name: `error: ${message}`, ok: false },
9386
- });
10212
+ if (!(error instanceof ComputerWaitGaveUp))
10213
+ store.appendMessage(threadId, {
10214
+ role: "bot", kind: "activity",
10215
+ from: { botId: bot.id, name: bot.name, color: bot.color },
10216
+ tool: { name: `error: ${message}`, ok: false },
10217
+ });
9387
10218
  onDispatchError?.(message);
9388
10219
  if (orchestration) {
9389
10220
  // A spend-cap refusal is deterministic: record it as its own
@@ -9398,12 +10229,14 @@ setupRetry = 0) {
9398
10229
  finally {
9399
10230
  // Covers connector/setup failures, cancellation before dispatch, and all
9400
10231
  // other early returns that never produce a provider terminal event.
10232
+ unregisterSetupStall();
9401
10233
  revokeInternalCapabilityGeneration(threadId, internalGeneration);
9402
10234
  roomHandoffs.sourceSettled(internalGeneration, roomHandoffSourceSucceeded);
9403
10235
  if (!retainRoomVmLease)
9404
10236
  releaseRoomVmLease();
9405
10237
  if (!providerDispatched && roomSpeaker && groupSpeakers.get(threadId) === roomSpeaker) {
9406
10238
  groupSpeakers.delete(threadId);
10239
+ watchdog.settle(threadId);
9407
10240
  if (store.group(group.id)?.busyBotId === bot.id)
9408
10241
  store.patchGroup(group.id, { busyBotId: null });
9409
10242
  if (store.bot(bot.id)?.busy) {
@@ -9952,6 +10785,44 @@ function resolveReplyTarget(threadId, value) {
9952
10785
  }
9953
10786
  const CONNECTOR_SLUG = /^[a-z0-9][a-z0-9_-]{0,80}$/;
9954
10787
  const pendingConnectorResumes = new Map();
10788
+ /** The host folders a bot's own files may be read from in one conversation:
10789
+ * the conversation's pinned or configured working folder, the bot's own
10790
+ * workspace, and the private attachment store. */
10791
+ function messageFileRootsForThread(senderId, threadId) {
10792
+ const directBot = store.botByThread(threadId);
10793
+ const group = directBot ? undefined : store.groupByThread(threadId);
10794
+ let pinnedCwd;
10795
+ let configuredCwd;
10796
+ if (directBot) {
10797
+ pinnedCwd = store.taskByThread(directBot.id, threadId)?.cwd;
10798
+ configuredCwd = directBot.cwd;
10799
+ }
10800
+ else if (group) {
10801
+ const task = store.groupTaskByThread(group.id, threadId);
10802
+ pinnedCwd = task ? task.pinnedCwd : group.threadId === threadId ? group.pinnedCwd : undefined;
10803
+ configuredCwd = group.cwd;
10804
+ }
10805
+ return messageFileRoots({
10806
+ senderWorkspace: workspaceDir(senderId),
10807
+ attachments: ATTACHMENTS_DIR,
10808
+ pinnedCwd,
10809
+ configuredCwd,
10810
+ });
10811
+ }
10812
+ async function attachmentVmForTurn(capability) {
10813
+ const slot = autoVmClaims.get(capability.threadId);
10814
+ if (!localVmThreadTargets.has(capability.threadId) &&
10815
+ !(slot?.localVm && slot.owner.generation === capability.generation))
10816
+ return undefined;
10817
+ const gate = await computerCallGate(capability);
10818
+ if (gate.held)
10819
+ throw Object.assign(new Error(gate.blockedReason ?? "The person has control of this computer. Wait for them to release it."), { status: 409 });
10820
+ const vm = localVmThreadTargets.get(capability.threadId);
10821
+ if (!vm || !internalCapabilityIsActive({ ...capability, localVmTarget: vm })) {
10822
+ throw Object.assign(new Error("This turn no longer owns the Local VM."), { status: 409 });
10823
+ }
10824
+ return vm;
10825
+ }
9955
10826
  function connectorThread(botId, threadId) {
9956
10827
  const bot = store.bot(botId);
9957
10828
  if (!bot)
@@ -10015,7 +10886,7 @@ function roomPostEligibility(bot, group) {
10015
10886
  }
10016
10887
  const outsider = group.memberIds
10017
10888
  .map((id) => store.bot(id))
10018
- .find((member) => member && !canAccessTeam(bot, member.section));
10889
+ .find((member) => member && !canAccessTeam(bot, member.section) && !coordinatorSupervises(member, bot));
10019
10890
  if (outsider) {
10020
10891
  return {
10021
10892
  ok: false,
@@ -10042,11 +10913,11 @@ function proposalPersistence(botId, threadId) {
10042
10913
  return { ok: true };
10043
10914
  // Only cards on the visible branch can be acted on from the composer.
10044
10915
  // Abandoned branches must not permanently consume the proposal quota.
10045
- // Routine and profile proposals share one budget per bot per thread, so
10046
- // one thread cannot pile up 8 of each.
10047
- const openRequests = store.activePath(threadId).filter((message) => (message.card?.routineRequest?.botId === botId || message.card?.profileRequest?.botId === botId || message.card?.teamSetupRequest?.botId === botId) &&
10916
+ // Routine, profile, default-model, tightening and team-setup proposals
10917
+ // share one budget per bot per thread, so one thread cannot pile up 8 of each.
10918
+ const openRequests = store.activePath(threadId).filter((message) => (message.card?.routineRequest?.botId === botId || message.card?.profileRequest?.botId === botId || message.card?.modelRequest?.botId === botId || message.card?.tighteningRequest?.botId === botId || message.card?.teamSetupRequest?.botId === botId) &&
10048
10919
  !message.card.answered &&
10049
- !message.card.dismissed).length;
10920
+ !message.card.dismissed && !message.card.expired).length;
10050
10921
  return openRequests >= 8
10051
10922
  ? { ok: false, status: 429, error: "confirm or cancel an existing proposal first" }
10052
10923
  : { ok: true };
@@ -10517,6 +11388,8 @@ async function provideSecretFromPhone(context, authenticatedDeviceId) {
10517
11388
  throw new PhoneSecretError("No such credential request", 404);
10518
11389
  if (message.secret.dismissed)
10519
11390
  throw new PhoneSecretError("This credential request was dismissed", 409);
11391
+ if (message.secret.superseded)
11392
+ throw new PhoneSecretError("This credential request was superseded by a newer one", 409);
10520
11393
  assertPhoneSecretRequestMatches(context, authenticatedDeviceId, {
10521
11394
  target: message.secret.target,
10522
11395
  requestKey: message.secret.requestKey,
@@ -10574,6 +11447,12 @@ async function provideSecretFromPhone(context, authenticatedDeviceId) {
10574
11447
  if (current.secret.dismissed) {
10575
11448
  throw new PhoneSecretError("This credential request was dismissed", 409);
10576
11449
  }
11450
+ // The request route may have superseded this card while the encrypted
11451
+ // save was in flight. Completing the superseded card would mark it
11452
+ // provided and dispatch its continuation, leaving two cards resolved.
11453
+ if (current.secret.superseded) {
11454
+ throw new PhoneSecretError("This credential request was superseded by a newer one", 409);
11455
+ }
10577
11456
  // Electron acknowledges only after credentials.bin and the server's
10578
11457
  // external-secret config update both commit. Keep this assertion at the
10579
11458
  // boundary so a future parent handler cannot accidentally resume first.
@@ -10804,6 +11683,7 @@ function configStatus() {
10804
11683
  const eventLogRetentionDays = threadEventLogRetentionDays(cfg);
10805
11684
  return {
10806
11685
  xai: { configured: Boolean(cfg.xai?.key) },
11686
+ mistral: { configured: Boolean(cfg.mistral?.key) },
10807
11687
  anthropic: { configured: Boolean(cfg.anthropic?.key) },
10808
11688
  // a fleet agent on this server means Settings → Workspaces has something to drive
10809
11689
  fleet: { available: fleetAvailable(fleetSocketPath()) },
@@ -10831,12 +11711,14 @@ function configStatus() {
10831
11711
  tts: tts.describeVoice(cfg),
10832
11712
  imageGen: avatarImageStatus(cfg),
10833
11713
  // not a secret — the sidebar shows it
10834
- profile: { name: cfg.profile?.name ?? "", email: cfg.profile?.email ?? "" },
11714
+ profile: { name: cfg.profile?.name ?? "", email: cfg.profile?.email ?? "", aboutMe: cfg.profile?.aboutMe ?? "" },
10835
11715
  // the enrolled organisation's read-only desktop policy; null when not enrolled
10836
11716
  managedPolicy: managedPolicy.summary(),
10837
11717
  // not a secret — the settings picker shows it; "" = follow the system
10838
11718
  language: cfg.language ?? "",
10839
11719
  rooms: { turnTimeoutMinutes: roomTurnTimeoutMinutes(cfg) },
11720
+ // absent effort = no level is sent, so clients can tell it from any level
11721
+ newBots: cfg.newBots?.effort ? { effort: cfg.newBots.effort } : {},
10840
11722
  threads: {
10841
11723
  maxConcurrentPerBot: maxConcurrentBotThreads(cfg),
10842
11724
  ...(eventLogMaxBytes !== null ? { eventLogMaxBytes } : {}),
@@ -11228,6 +12110,7 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
11228
12110
  await Promise.all([flushAllProfileHistory(), flushAllMemoryJournals(), flushUsageLedger(DATA_DIR), flushDecisionLog(DATA_DIR), flushAdminActivity(DATA_DIR)]);
11229
12111
  // With writers gated and work idle, release our WAL connection for the
11230
12112
  // consistent snapshot. Store reopens it lazily after maintenance.
12113
+ await closeMessageSearch();
11231
12114
  closeMessageDb();
11232
12115
  },
11233
12116
  }, keepLocked),
@@ -11235,6 +12118,10 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
11235
12118
  // Route modules (server/routes/README.md). `workspaceAccess` is assigned at
11236
12119
  // boot, after this line, so the dependency reads it per request.
11237
12120
  ROUTES.push(createHostedSlackRoutes({ bot: (id) => store.bot(id), hostedReady: () => Boolean(workspaceAccess) && entitled("admin") }));
12121
+ // Install statuses come from the organization library's own state, never
12122
+ // its file, so New bot cannot disagree with it. No organization: none.
12123
+ const orgInstallStatuses = () => orgLibrary?.installStatuses() ?? new Map();
12124
+ ROUTES.push(createBotPresetRoutes({ presets: presetStore, orgStatuses: orgInstallStatuses }));
11238
12125
  const toolResults = new ToolResults();
11239
12126
  const handleRequest = async (req, res) => {
11240
12127
  let url;
@@ -11659,6 +12546,21 @@ const handleRequest = async (req, res) => {
11659
12546
  // through a per-process high-entropy test key. The route does not exist
11660
12547
  // unless the launcher explicitly sets that key; production builds never
11661
12548
  // set it.
12549
+ // Isolated fixtures have no Electron parent to relay an organization's
12550
+ // library, so a test relays one here instead. Like the capability route
12551
+ // below, it exists only when the launcher sets its high-entropy key.
12552
+ if (method === "POST" && path === "/api/testing/org-library") {
12553
+ const expected = Buffer.from(process.env.OMB_TEST_ORG_LIBRARY_KEY ?? "");
12554
+ const header = req.headers["x-openmausbot-test-org-library"];
12555
+ const actual = Buffer.from(Array.isArray(header) ? "" : String(header ?? ""));
12556
+ if (expected.length < 32 || actual.length !== expected.length || !timingSafeEqual(actual, expected) || !orgLibrary) {
12557
+ return json(res, 404, { error: "not found" });
12558
+ }
12559
+ const body = await readBody(req);
12560
+ const applied = orgLibrary.applyRelay(body?.library ?? null);
12561
+ await orgLibrary.settled();
12562
+ return json(res, applied.ok ? 200 : 400, { ...applied, report: orgLibrary.lastReport() });
12563
+ }
11662
12564
  if (method === "POST" && path === "/api/testing/internal-capability") {
11663
12565
  const expected = process.env.OMB_TEST_INTERNAL_CAPABILITY_KEY ?? "";
11664
12566
  const actual = Array.isArray(req.headers["x-openmausbot-test-capability"])
@@ -11708,11 +12610,13 @@ const handleRequest = async (req, res) => {
11708
12610
  ? "hooks"
11709
12611
  : path === "/api/internal/browser/mcp"
11710
12612
  ? "browser"
11711
- : path.startsWith("/api/internal/connectors/")
11712
- ? "connectors"
11713
- : path === "/api/internal/computer-control"
11714
- ? "computer"
11715
- : "agents";
12613
+ : path === "/api/internal/phone/claim"
12614
+ ? "phone"
12615
+ : path.startsWith("/api/internal/connectors/")
12616
+ ? "connectors"
12617
+ : path === "/api/internal/computer-control"
12618
+ ? "computer"
12619
+ : "agents";
11716
12620
  if (internalCapability.kind !== requiredCapabilityKind) {
11717
12621
  return json(res, 403, { error: "this internal capability cannot access that service" });
11718
12622
  }
@@ -11825,6 +12729,22 @@ const handleRequest = async (req, res) => {
11825
12729
  const result = toolResults.read(internalCapability, id, offset);
11826
12730
  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." });
11827
12731
  }
12732
+ if (method === "POST" && path === "/api/internal/options-card") {
12733
+ if (!connectorThread(internalSender.id, internalCapability.threadId)) {
12734
+ return json(res, 403, { error: "source conversation does not belong to sender" });
12735
+ }
12736
+ const body = await readInternalBody();
12737
+ requireActiveInternalCapability();
12738
+ const result = createOptionsCard({
12739
+ store,
12740
+ bot: internalSender,
12741
+ threadId: internalCapability.threadId,
12742
+ input: body,
12743
+ });
12744
+ return result.ok
12745
+ ? json(res, 201, { messageId: result.messageId })
12746
+ : json(res, result.status, { error: result.error });
12747
+ }
11828
12748
  // Notes written from a room fewer people can see than this bot would
11829
12749
  // carry that room's words to everyone who can see the bot.
11830
12750
  if ((path === "/api/internal/memory" || path === "/api/internal/memory/log") && method === "POST") {
@@ -11870,6 +12790,17 @@ const handleRequest = async (req, res) => {
11870
12790
  requireActiveInternalCapability();
11871
12791
  return json(res, 200, { result });
11872
12792
  }
12793
+ if (method === "POST" && path === "/api/internal/phone/claim") {
12794
+ // Lazy phone exclusivity (issue #1663): the turn holds computer:phone
12795
+ // from its first real tool call until it settles. The proxy answers a
12796
+ // conflict as a blocked tool result, so a concurrent caller waits or
12797
+ // does other work instead of losing the whole turn.
12798
+ requireActiveInternalCapability();
12799
+ if (!claimTurnResource(internalCapability, "computer:phone")) {
12800
+ return json(res, 409, { error: "another thread is using the phone" });
12801
+ }
12802
+ return json(res, 200, { claimed: true });
12803
+ }
11873
12804
  // Off by default: both fall through to the same "unknown internal
11874
12805
  // endpoint" 404 a never-implemented route returns.
11875
12806
  if (method === "GET" && path === "/api/internal/shared-computers" && sharedComputersEnabled(cfg))
@@ -12000,6 +12931,94 @@ const handleRequest = async (req, res) => {
12000
12931
  store.setTaskClosedBy(owner.id, threadId, { botId: from.id, name: from.name, at: Date.now() });
12001
12932
  return json(res, 200, { closed: true, threadId, title: task.title, botName: owner.name });
12002
12933
  }
12934
+ if (method === "POST" && path === "/api/internal/attach-file") {
12935
+ const from = internalSender;
12936
+ const threadId = internalCapability.threadId;
12937
+ const source = connectorThread(from.id, threadId);
12938
+ if (!source)
12939
+ return json(res, 403, { error: "source conversation does not belong to sender" });
12940
+ const body = await readInternalBody();
12941
+ const requestedPath = typeof body.path === "string" ? body.path : "";
12942
+ const requestedName = typeof body.name === "string" ? body.name : undefined;
12943
+ try {
12944
+ const vm = await attachmentVmForTurn(internalCapability);
12945
+ const outcome = await attachForTurn(internalCapability, MAX_ATTACHED_FILES_PER_TURN, {
12946
+ save: () => {
12947
+ // The agents capability carries no VM; the thread's claimed desktop does.
12948
+ return saveBotAttachment({
12949
+ path: requestedPath,
12950
+ name: requestedName,
12951
+ // The attachment store is not a source: only the bot's own files are.
12952
+ roots: messageFileRootsForThread(from.id, threadId).filter((root) => root !== ATTACHMENTS_DIR),
12953
+ ...(vm ? { guest: { root: VM_WORKSPACE_GUEST, host: vm.workspaceDir } } : {}),
12954
+ });
12955
+ },
12956
+ // The copy is awaited: the turn may have been stopped, replaced or
12957
+ // deleted, or the bot removed from the room, before it finished.
12958
+ stillLive: () => internalCapabilityIsActive({ ...internalCapability, ...(vm ? { localVmTarget: vm } : {}) }) &&
12959
+ connectorThread(from.id, threadId) !== null,
12960
+ publish: (saved) => {
12961
+ store.appendMessage(threadId, {
12962
+ role: "bot",
12963
+ kind: "text",
12964
+ text: "",
12965
+ attachments: [saved.attachment],
12966
+ ...(source.group ? { from: { botId: from.id, name: from.name, color: from.color } } : {}),
12967
+ });
12968
+ },
12969
+ // Each save gets its own stored file, so an unpublished one is ours to remove.
12970
+ discard: (saved) => deleteAttachment(saved.attachment.path),
12971
+ });
12972
+ if (outcome.status === "limit") {
12973
+ return json(res, 429, { error: `You already attached ${MAX_ATTACHED_FILES_PER_TURN} files this turn. Put the rest in one document or archive summary instead.` });
12974
+ }
12975
+ if (outcome.status === "ended") {
12976
+ return json(res, 409, { error: "This turn ended before the file could be attached, so nothing was posted." });
12977
+ }
12978
+ const { saved } = outcome;
12979
+ const label = saved.attachment.kind === "file"
12980
+ ? saved.attachment.name
12981
+ : (requestedName?.trim() || requestedPath.split(/[\\/]/).at(-1) || "image");
12982
+ return json(res, 200, { ok: true, name: label, bytes: saved.bytes });
12983
+ }
12984
+ catch (error) {
12985
+ const status = typeof error.status === "number" ? error.status : 500;
12986
+ const message = status === 404
12987
+ ? `No file was found at ${requestedPath}. Save it under /home/cua/workspace (or your working folder) and pass its exact path.`
12988
+ : error instanceof Error ? error.message : "could not attach that file";
12989
+ return json(res, status, { error: message });
12990
+ }
12991
+ }
12992
+ if (method === "POST" && path === "/api/internal/vm-exec") {
12993
+ const threadId = internalCapability.threadId;
12994
+ if (!connectorThread(internalSender.id, threadId)) {
12995
+ return json(res, 403, { error: "source conversation does not belong to sender" });
12996
+ }
12997
+ const body = await readInternalBody();
12998
+ // Only the desktop this thread has claimed for the running turn; a bot
12999
+ // without one has nothing to run a command in.
13000
+ try {
13001
+ const vm = await attachmentVmForTurn(internalCapability);
13002
+ if (!vm)
13003
+ return json(res, 409, { error: "You have no Local VM desktop in this turn, so there is nowhere to run a command." });
13004
+ const runtime = (await containerRuntimeStatus()).runtime;
13005
+ requireActiveInternalCapability();
13006
+ if (!runtime || !internalCapabilityIsActive({ ...internalCapability, localVmTarget: vm }) ||
13007
+ botComputerControlSnapshot(internalSender.id).held) {
13008
+ return json(res, 409, { error: "The Local VM is no longer available to this turn." });
13009
+ }
13010
+ const result = await containerExec(vm, typeof body.command === "string" ? body.command : "", {
13011
+ runtime,
13012
+ timeoutSeconds: typeof body.timeout_seconds === "number" ? body.timeout_seconds : undefined,
13013
+ });
13014
+ localVmIdleFor(vm).touch();
13015
+ return json(res, 200, result);
13016
+ }
13017
+ catch (error) {
13018
+ const status = typeof error.status === "number" ? error.status : 500;
13019
+ return json(res, status, { error: error instanceof Error ? error.message : "could not run the command" });
13020
+ }
13021
+ }
12003
13022
  if (method === "GET" && path === "/api/internal/rooms") {
12004
13023
  const from = internalSender;
12005
13024
  const fromThreadId = internalCapability.threadId;
@@ -12145,6 +13164,71 @@ const handleRequest = async (req, res) => {
12145
13164
  });
12146
13165
  return json(res, 201, proposed);
12147
13166
  }
13167
+ if (method === "POST" && path === "/api/internal/model-requests") {
13168
+ const parsed = z.object({
13169
+ fromBotId: z.string().min(1).max(128),
13170
+ fromThreadId: z.string().min(1).max(128),
13171
+ forBotId: z.string().max(128).optional(),
13172
+ modelSelection: z.unknown(),
13173
+ reason: z.unknown(),
13174
+ }).strict().safeParse(await readInternalBody());
13175
+ if (!parsed.success)
13176
+ return json(res, 400, { error: "invalid model proposal" });
13177
+ const body = parsed.data;
13178
+ const from = store.bot(body.fromBotId);
13179
+ if (!from)
13180
+ return json(res, 403, { error: "unknown sender" });
13181
+ const owner = connectorThread(from.id, body.fromThreadId);
13182
+ if (!owner)
13183
+ return json(res, 403, { error: "source conversation does not belong to sender" });
13184
+ const proposed = modelRequests.submit({
13185
+ botId: from.id,
13186
+ threadId: body.fromThreadId,
13187
+ targetBotId: body.forBotId?.trim() || from.id,
13188
+ selection: body.modelSelection,
13189
+ reason: body.reason,
13190
+ from: owner.group ? { botId: from.id, name: from.name, color: from.color } : undefined,
13191
+ });
13192
+ appendDecision(DATA_DIR, {
13193
+ threadId: body.fromThreadId, requestId: proposed.requestId, botId: from.id, botName: from.name,
13194
+ tool: "update_model", summary: proposed.detail, decision: proposed.state === "applied" ? "auto-approved" : "card-shown",
13195
+ source: proposed.state === "applied" ? "full-access" : "model",
13196
+ });
13197
+ return json(res, 201, proposed);
13198
+ }
13199
+ if (method === "POST" && path === "/api/internal/tightening-requests") {
13200
+ const parsed = z.object({
13201
+ fromBotId: z.string().min(1).max(128),
13202
+ fromThreadId: z.string().min(1).max(128),
13203
+ forBotId: z.string().max(128).optional(),
13204
+ intents: z.unknown(),
13205
+ reason: z.unknown(),
13206
+ }).strict().safeParse(await readInternalBody());
13207
+ if (!parsed.success)
13208
+ return json(res, 400, { error: "invalid tightening proposal" });
13209
+ const body = parsed.data;
13210
+ const from = store.bot(body.fromBotId);
13211
+ if (!from)
13212
+ return json(res, 403, { error: "unknown sender" });
13213
+ const owner = connectorThread(from.id, body.fromThreadId);
13214
+ if (!owner)
13215
+ return json(res, 403, { error: "source conversation does not belong to sender" });
13216
+ const targetBotId = body.forBotId?.trim() || from.id;
13217
+ const proposed = tighteningRequests.submit({
13218
+ botId: from.id,
13219
+ threadId: body.fromThreadId,
13220
+ targetBotId,
13221
+ intents: body.intents,
13222
+ reason: body.reason,
13223
+ from: owner.group ? { botId: from.id, name: from.name, color: from.color } : undefined,
13224
+ });
13225
+ appendDecision(DATA_DIR, {
13226
+ threadId: body.fromThreadId, requestId: proposed.requestId, botId: from.id, botName: from.name,
13227
+ tool: "tighten_permissions", summary: proposed.detail, decision: proposed.state === "applied" ? "auto-approved" : "card-shown",
13228
+ source: proposed.state === "applied" ? "full-access" : "tightening",
13229
+ });
13230
+ return json(res, 201, proposed);
13231
+ }
12148
13232
  if (method === "GET" && path === "/api/internal/team-setup-catalog") {
12149
13233
  let chief = store.bot(internalCapability.botId);
12150
13234
  if (!chief.chiefOfStaff || chief.hidden)
@@ -12157,7 +13241,7 @@ const handleRequest = async (req, res) => {
12157
13241
  return json(res, 200, {
12158
13242
  teams: teamSetupTeams().filter((name) => canAccessTeam(chief, name)),
12159
13243
  bots: store.bots.filter((bot) => !bot.hidden && canAccessTeam(chief, bot.section) && (bot.id === chief.id || peerAllowed(chief, bot)))
12160
- .map((bot) => ({ id: bot.id, name: bot.name, title: bot.title, section: bot.section ?? "", modelSelection: bot.modelSelection })),
13244
+ .map((bot) => ({ id: bot.id, name: bot.name, title: bot.title, section: bot.section ?? "", modelSelection: bot.modelSelection, chiefOfStaff: Boolean(bot.chiefOfStaff) })),
12161
13245
  instances: instances.map((instance) => ({ instanceId: instance.instanceId, driverKind: instance.driverKind, displayName: instance.displayName,
12162
13246
  state: instance.snapshot.state, models: instance.models, effortLevels: instance.capabilities?.effortLevels ?? [] })),
12163
13247
  scope: "Bot model defaults apply to groups and new threads; existing threads retain their models. Full Access applies requested team setup immediately; other modes return a review card. Existing unauthorized teams remain outside this Chief's scope.",
@@ -12431,7 +13515,9 @@ const handleRequest = async (req, res) => {
12431
13515
  if (toBotRef === fromBotId)
12432
13516
  return json(res, 400, { error: "a bot cannot message itself" });
12433
13517
  if (depth >= MAX_COMMS_DEPTH)
12434
- return json(res, 200, { error: "message chains are limited to one hop" });
13518
+ return json(res, 200, { error: "message chains are limited to one hop", receipt: peerDeliveryReceipt({
13519
+ botId: toBotRef, outcome: "failed", detail: "message chains are limited to one hop — the message was not sent",
13520
+ }) });
12435
13521
  // A unique reachable teammate name is accepted where an id is
12436
13522
  // expected; see resolveTeammate for why.
12437
13523
  const resolvedTo = resolveTeammate(store.bots, internalSender, toBotRef);
@@ -12476,17 +13562,70 @@ const handleRequest = async (req, res) => {
12476
13562
  const queueBusyFallback = (approvalAlreadyGranted = false) => {
12477
13563
  const queued = queueDelegation(commsBus, from, { toBotId, message, reason: "asked while busy", depth, approvalAlreadyGranted }, MAX_COMMS_DEPTH, fromThreadId);
12478
13564
  if (queued.result !== "ok" || !queued.id)
12479
- return json(res, 200, { busy: true });
13565
+ return json(res, 200, { busy: true, receipt: peerDeliveryReceipt({
13566
+ botId: toBotId, botName: target.name, outcome: "failed",
13567
+ detail: "the teammate was busy and the delegation queue refused the fallback — the message was not delivered",
13568
+ }) });
12480
13569
  // An external caller has no source turn whose completion can
12481
13570
  // begin the busy wait. Drain now so the target's idle release
12482
13571
  // retries this handoff, retaining any approval already granted.
12483
13572
  if (internalCapability.generation === EXTERNAL_RUNTIME_GENERATION && !threadBusy(from.id, fromThreadId)) {
12484
13573
  drainThreadDelegations(fromThreadId);
12485
13574
  }
12486
- return json(res, 200, { busy: true, taskId: queued.id, toBotName: target.name });
13575
+ return json(res, 200, { busy: true, taskId: queued.id, toBotName: target.name, receipt: peerDeliveryReceipt({
13576
+ botId: toBotId, botName: target.name, outcome: "queued", taskId: queued.id,
13577
+ detail: "the teammate was busy; the ask was queued as a delegation instead",
13578
+ }) });
12487
13579
  };
12488
- if (target.busy)
13580
+ // The aside lane: this ask was addressed to the conversation
13581
+ // target.threadId holds, and that conversation is mid-turn. When
13582
+ // the engine running it exposes a mid-turn seam, the words fold
13583
+ // into the live turn as enveloped peer context — no interruption,
13584
+ // no new turn, and the sender learns exactly how the send landed.
13585
+ // A mid-turn injection is delivery, so it never routes through the
13586
+ // peer-review card: review gates turns a peer would start, and a
13587
+ // bot whose comms need approval keeps the delegation queue below.
13588
+ const tryAsideDelivery = async () => {
13589
+ if (peerReviewRequired(from, fromThreadId))
13590
+ return null;
13591
+ // Busy elsewhere (room turn, sibling thread): the thread this ask
13592
+ // targets is not the running one, so there is nothing to fold
13593
+ // into here — the queue lane keeps it.
13594
+ if (!threadBusy(toBotId, target.threadId))
13595
+ return null;
13596
+ const instance = runningTurnInstance(target, target.threadId);
13597
+ if (!instance?.adapter.capabilities.queueing || !instance.adapter.steer)
13598
+ return null;
13599
+ const steer = instance.adapter.steer;
13600
+ queueAsideMessage(toBotId, target.threadId, message, {
13601
+ fromBotId: from.id,
13602
+ fromBotName: from.name,
13603
+ unattended: isUnattended(from.id, fromThreadId),
13604
+ commsDepth: depth,
13605
+ });
13606
+ const attempt = await attemptAsideInjection(store, toBotId, target.threadId, (_botId, threadId, prompt) => steer(threadId, prompt).catch(() => "indeterminate"));
13607
+ if (!attempt)
13608
+ return null;
13609
+ return attempt.delivered
13610
+ ? {
13611
+ busy: true,
13612
+ aside: "injected",
13613
+ toBotName: target.name,
13614
+ message: `@${target.name} is mid-turn; your message was handed to them as an aside — peer context folded into their current work. It is not a request for a reply: continue your plan and treat their next output as possibly informed by it.`,
13615
+ }
13616
+ : {
13617
+ busy: true,
13618
+ aside: "queued",
13619
+ toBotName: target.name,
13620
+ message: `@${target.name} is mid-turn and the aside could not be folded in yet; it is queued and will reach them as context when the turn settles. No reply is expected.`,
13621
+ };
13622
+ };
13623
+ if (target.busy) {
13624
+ const asideReceipt = await tryAsideDelivery();
13625
+ if (asideReceipt)
13626
+ return json(res, 200, asideReceipt);
12489
13627
  return queueBusyFallback();
13628
+ }
12490
13629
  let currentFrom = from;
12491
13630
  let currentTarget = target;
12492
13631
  // the exchange is mirrored into a bot⇄bot channel: it shows up in
@@ -12497,14 +13636,19 @@ const handleRequest = async (req, res) => {
12497
13636
  //
12498
13637
  // per-bot approval gate: a chief-of-staff bot without this on is
12499
13638
  // free to coordinate; one with it on must wait for a human card
12500
- // (15-min timeout → deny) before its peer turn starts. The channel
13639
+ // (15-min timeout → expired) before its peer turn starts. The channel
12501
13640
  // and the chips are created only AFTER the verdict, so a denied
12502
13641
  // contact leaves no trace of an exchange that never happened.
12503
13642
  if (peerReviewRequired(from, fromThreadId)) {
12504
13643
  const verdict = await requestPeerApproval(approvalBus, from, target, message, "ask_bot", fromThreadId);
12505
13644
  requireActiveInternalCapability();
12506
- if (verdict !== "allow")
12507
- return json(res, 200, { error: "denied by user" });
13645
+ if (verdict !== "allow") {
13646
+ const failure = peerApprovalFailure(verdict);
13647
+ return json(res, 200, { ...failure, receipt: peerDeliveryReceipt({
13648
+ botId: toBotId, botName: target.name, outcome: "failed",
13649
+ detail: `peer review ${failure.error} — the message was not sent`,
13650
+ }) });
13651
+ }
12508
13652
  // The card may have been open for minutes. Re-read both records so
12509
13653
  // deleted bots cannot recreate transcripts through stale objects.
12510
13654
  const freshFrom = store.bot(fromBotId);
@@ -12512,10 +13656,17 @@ const handleRequest = async (req, res) => {
12512
13656
  if (!freshFrom || !freshTarget)
12513
13657
  return json(res, 404, { error: "no such bot" });
12514
13658
  if (!canAccessTeam(freshFrom, freshTarget.section) || freshTarget.hidden) {
12515
- return json(res, 200, { error: "that bot moved to a different section" });
13659
+ return json(res, 200, { error: "that bot moved to a different section", receipt: peerDeliveryReceipt({
13660
+ // Access no longer permits reading the peer's current profile.
13661
+ botId: toBotId, outcome: "failed",
13662
+ detail: "that bot moved to a different section — the message was not sent",
13663
+ }) });
12516
13664
  }
12517
13665
  if (!peerAllowed(freshFrom, freshTarget)) {
12518
- return json(res, 200, { error: "that bot is no longer an allowed peer" });
13666
+ return json(res, 200, { error: "that bot is no longer an allowed peer", receipt: peerDeliveryReceipt({
13667
+ botId: toBotId, outcome: "failed",
13668
+ detail: "that bot is no longer an allowed peer — the message was not sent",
13669
+ }) });
12519
13670
  }
12520
13671
  // Membership can be revoked while the card is open: re-check the
12521
13672
  // same way, so a bot removed from a room mid-approval cannot go on
@@ -12573,20 +13724,37 @@ const handleRequest = async (req, res) => {
12573
13724
  kind: "activity",
12574
13725
  tool: { name: `@${currentTarget.name} is still working — ask converted to a delegation` },
12575
13726
  });
12576
- return json(res, 200, { timeout: true, taskId, toBotName: currentTarget.name, waitedMs: ASK_BOT_TIMEOUT_MS });
13727
+ return json(res, 200, { timeout: true, taskId, toBotName: currentTarget.name, waitedMs: ASK_BOT_TIMEOUT_MS,
13728
+ receipt: peerDeliveryReceipt({ botId: toBotId, botName: currentTarget.name, outcome: "injected", taskId,
13729
+ detail: "the teammate's turn started and is still running; only the synchronous wait ended" }) });
12577
13730
  }
12578
13731
  if (outcome.status === "failed" && !outcome.text.trim()) {
12579
13732
  // No partial answer to hand back — mirror the failure where the
12580
13733
  // exchange lives, with the provider's reason instead of silence.
12581
13734
  const why = outcome.stopReason?.trim() ? ` — ${outcome.stopReason.trim().slice(0, 120)}` : "";
12582
13735
  mirrorActivity(commsBus, currentTarget, channel, `Turn failed${why}`, false);
12583
- return json(res, 200, { botName: currentTarget.name, text: `(the bot's turn failed${why})` });
13736
+ return json(res, 200, { botName: currentTarget.name, text: `(the bot's turn failed${why})`,
13737
+ receipt: peerDeliveryReceipt({ botId: toBotId, botName: currentTarget.name, outcome: "injected",
13738
+ detail: `the teammate's turn started and failed${why}` }) });
12584
13739
  }
12585
13740
  const reply = outcome.status === "timeout"
12586
13741
  ? outcome.text || "(timed out waiting for the bot to reply)"
12587
13742
  : outcome.text;
12588
13743
  mirrorReply(commsBus, currentTarget, reply, channel);
12589
- return json(res, 200, { botName: currentTarget.name, text: reply });
13744
+ // A dispatch error arrives as outcome "error" with the reason folded
13745
+ // into text — to the sender it reads like a reply from the peer. The
13746
+ // receipt is what keeps that honest: the turn never started, so the
13747
+ // message was never delivered.
13748
+ const dispatchFailed = outcome.status === "error";
13749
+ return json(res, 200, { botName: currentTarget.name, text: reply,
13750
+ receipt: peerDeliveryReceipt({ botId: toBotId, botName: currentTarget.name,
13751
+ outcome: dispatchFailed ? "failed" : "injected",
13752
+ detail: dispatchFailed
13753
+ ? "the teammate's turn could not start — the message was not delivered"
13754
+ : outcome.status === "timeout"
13755
+ ? "the teammate's turn started and is still running"
13756
+ : "the teammate's turn ran to completion",
13757
+ }) });
12590
13758
  }
12591
13759
  // Async handoff: the source bot queues a task for a peer and goes
12592
13760
  // back to the user; the peer turn runs after the source's
@@ -12614,7 +13782,12 @@ const handleRequest = async (req, res) => {
12614
13782
  if (!sender || (target && !canReachPeer(sender, target))) {
12615
13783
  return json(res, 403, { error: "Result withheld: team access changed while the teammate was working" });
12616
13784
  }
12617
- return json(res, 200, { status: receipt.status, toBotName: receipt.toBotName, result: receipt.result ?? "" });
13785
+ return json(res, 200, {
13786
+ status: receipt.status, toBotName: receipt.toBotName, result: receipt.result ?? "",
13787
+ ...(receipt.approvalOutcome ? {
13788
+ approvalOutcome: receipt.approvalOutcome, approvalSource: receipt.approvalSource,
13789
+ } : {}),
13790
+ });
12618
13791
  }
12619
13792
  const stillQueued = pendingDelegationInfo(taskId);
12620
13793
  const runningEntry = [...delegationWatch.entries()].find(([, watch]) => watch.taskId === taskId);
@@ -12743,7 +13916,11 @@ const handleRequest = async (req, res) => {
12743
13916
  no_target: "no such bot",
12744
13917
  too_many: "too many delegations queued on this turn — finish some first",
12745
13918
  };
12746
- return json(res, 200, { error: said[queued.result === "ok" ? "no_target" : queued.result] });
13919
+ const refusal = said[queued.result === "ok" ? "no_target" : queued.result];
13920
+ return json(res, 200, { error: refusal, receipt: peerDeliveryReceipt({
13921
+ botId: toBotId, botName: store.bot(toBotId)?.name, outcome: "failed",
13922
+ detail: `${refusal} — nothing was queued`,
13923
+ }) });
12747
13924
  }
12748
13925
  const targetName = store.bot(toBotId)?.name ?? toBotId;
12749
13926
  // The queue normally drains when the source thread's turn completes. A
@@ -12753,20 +13930,30 @@ const handleRequest = async (req, res) => {
12753
13930
  // is nothing to wait for: drain now.
12754
13931
  if (internalCapability.generation === EXTERNAL_RUNTIME_GENERATION && !threadBusy(from.id, fromThreadId)) {
12755
13932
  drainThreadDelegations(fromThreadId);
13933
+ const reviewed = peerReviewRequired(from, fromThreadId);
12756
13934
  return json(res, 200, {
12757
13935
  queued: true,
12758
13936
  taskId: queued.id,
12759
- message: peerReviewRequired(from, fromThreadId)
13937
+ message: reviewed
12760
13938
  ? `Queued for review — @${targetName} will pick it up once the user approves.`
12761
13939
  : `Delegated — @${targetName} is picking it up now.`,
13940
+ receipt: peerDeliveryReceipt({ botId: toBotId, botName: targetName, outcome: "queued", taskId: queued.id,
13941
+ detail: reviewed
13942
+ ? "the teammate's turn waits on the user's approval"
13943
+ : "the drain started; the teammate's turn has not started yet" }),
12762
13944
  });
12763
13945
  }
13946
+ const reviewed = peerReviewRequired(from, internalCapability.threadId);
12764
13947
  return json(res, 200, {
12765
13948
  queued: true,
12766
13949
  taskId: queued.id,
12767
- message: peerReviewRequired(from, internalCapability.threadId)
13950
+ message: reviewed
12768
13951
  ? `Queued for review — @${targetName} will only pick it up if the user approves after your turn finishes.`
12769
13952
  : `Delegation queued — @${targetName} will pick it up after your current turn finishes.`,
13953
+ receipt: peerDeliveryReceipt({ botId: toBotId, botName: targetName, outcome: "queued", taskId: queued.id,
13954
+ detail: reviewed
13955
+ ? "the teammate's turn waits on the user's approval after your turn finishes"
13956
+ : "the teammate's turn runs after your current turn finishes" }),
12770
13957
  });
12771
13958
  }
12772
13959
  if (path === "/api/internal/room-targets" || path === "/api/internal/coordinate-bots") {
@@ -12841,12 +14028,24 @@ const handleRequest = async (req, res) => {
12841
14028
  if (peerReviewRequired(internalSender, address.threadId)) {
12842
14029
  const verdicts = await Promise.all(targets.map(target => requestPeerApproval(approvalBus, internalSender, store.bot(target.botId), parsed.data.message, "delegate_bot", address.threadId)));
12843
14030
  requireActiveInternalCapability();
12844
- if (verdicts.some(verdict => verdict !== "allow"))
12845
- return json(res, 403, { error: "Denied by user; no work sent." });
14031
+ const blocked = verdicts.flatMap((verdict, index) => verdict === "allow" ? []
14032
+ : [{ botId: targets[index].botId, ...peerApprovalFailure(verdict) }]);
14033
+ if (blocked.length)
14034
+ return json(res, 403, {
14035
+ error: blocked.every(failure => failure.approvalOutcome === "deny")
14036
+ ? "Denied by user; no work sent."
14037
+ : `${blocked.map(failure => failure.error).join("; ")}; no work sent.`,
14038
+ approvals: blocked,
14039
+ });
12846
14040
  approvalGranted = true;
12847
14041
  }
12848
14042
  const accepted = [];
12849
14043
  const errors = [];
14044
+ // One receipt per recipient, in the order the caller addressed
14045
+ // them. Dispatch is asynchronous (RoomHandoffs.tick), so a fresh
14046
+ // enqueue is honestly "queued"; a duplicate inherits the state of
14047
+ // the node that already carries this request_key.
14048
+ const receipts = [];
12850
14049
  for (const target of targets) {
12851
14050
  let createdThread;
12852
14051
  try {
@@ -12878,7 +14077,7 @@ const handleRequest = async (req, res) => {
12878
14077
  grantDelegatedFullAccess(internalSender, store.bot(target.botId), target.threadId);
12879
14078
  }
12880
14079
  }
12881
- const { node, duplicate } = roomHandoffs.enqueue(address, internalCapability.generation, internalCapability.roomHandoffId, target, parsed.data.requestKey + ":" + target.botId, parsed.data.message, approvalGranted, parsed.data.rework, [...store.messagesFor(address.threadId)].reverse().find(m => m.role === "user" && m.kind === "text")?.text ?? "");
14080
+ const { node, duplicate } = roomHandoffs.enqueue(address, internalCapability.generation, internalCapability.roomHandoffId, target, parsed.data.requestKey + ":" + target.botId, parsed.data.message, approvalGranted, parsed.data.rework, [...store.messagesFor(address.threadId)].reverse().find(m => m.role === "user" && m.kind === "text")?.text ?? "", destination ? parsed.data.requestKey : undefined);
12882
14081
  // A re-dispatched request_key is answered by the request it
12883
14082
  // already made, so a thread resolved for the retry (the pair
12884
14083
  // conversation was busy with that very request) goes back
@@ -12887,6 +14086,32 @@ const handleRequest = async (req, res) => {
12887
14086
  store.deleteTask(target.botId, createdThread);
12888
14087
  createdThread = undefined; // The durable coordinator now owns this task.
12889
14088
  accepted.push({ requestId: node.id, botId: node.botId, duplicate, status: node.status });
14089
+ // A duplicate request_key lands on the node enqueue already
14090
+ // made. Only a live node can still report: a failed or
14091
+ // cancelled one already fired its report and tick() skips it,
14092
+ // so promising "its result will resume you" would be the one
14093
+ // lie a receipt must never tell.
14094
+ receipts.push(peerDeliveryReceipt({
14095
+ botId: node.botId,
14096
+ botName: store.bot(node.botId)?.name,
14097
+ outcome: duplicate
14098
+ ? node.status === "running" || node.status === "completed"
14099
+ ? "injected"
14100
+ : node.status === "failed" || node.status === "cancelled"
14101
+ ? "failed"
14102
+ : "queued"
14103
+ : "queued",
14104
+ detail: duplicate
14105
+ ? node.status === "running"
14106
+ ? "duplicate request_key — this teammate is already running that exact assignment"
14107
+ : node.status === "completed"
14108
+ ? "duplicate request_key — this teammate already completed that assignment; the earlier result stands"
14109
+ : node.status === "failed" || node.status === "cancelled"
14110
+ ? `duplicate request_key — the earlier assignment ${node.status === "cancelled" ? "was cancelled" : "failed"} and will not rerun or resume you; resend with a new request_key to retry`
14111
+ : "duplicate request_key — already in flight; its result will resume you automatically"
14112
+ : "handed to the coordinator; the teammate's turn has not started yet",
14113
+ requestId: node.id,
14114
+ }));
12890
14115
  if (!duplicate) {
12891
14116
  const recipient = store.bot(target.botId);
12892
14117
  store.appendMessage(address.threadId, { role: "bot", kind: "activity",
@@ -12900,10 +14125,14 @@ const handleRequest = async (req, res) => {
12900
14125
  catch (error) {
12901
14126
  if (createdThread)
12902
14127
  store.deleteTask(target.botId, createdThread);
12903
- errors.push({ botId: target.botId, error: error instanceof Error ? error.message : String(error) });
14128
+ const said = error instanceof Error ? error.message : String(error);
14129
+ errors.push({ botId: target.botId, error: said });
14130
+ receipts.push(peerDeliveryReceipt({
14131
+ botId: target.botId, botName: store.bot(target.botId)?.name, outcome: "failed", detail: said,
14132
+ }));
12904
14133
  }
12905
14134
  }
12906
- return json(res, accepted.length ? 200 : 409, { accepted, errors,
14135
+ return json(res, accepted.length ? 200 : 409, { accepted, errors, receipts,
12907
14136
  ...(accepted.length ? { message: "End your turn after sending all work. These actual teammates will reply and resume you automatically. Do not poll or wait." } : { error: errors.map(e => e.error).join("; ") }),
12908
14137
  });
12909
14138
  }
@@ -12932,6 +14161,10 @@ const handleRequest = async (req, res) => {
12932
14161
  return json(res, 403, { error: "source conversation does not belong to sender" });
12933
14162
  const groupId = String(body.groupId ?? "").trim();
12934
14163
  const message = String(body.message ?? "").trim();
14164
+ const attachVoiceNote = body.attachVoiceNote === true;
14165
+ if (body.attachVoiceNote !== undefined && typeof body.attachVoiceNote !== "boolean") {
14166
+ return json(res, 400, { error: "attachVoiceNote must be true or false" });
14167
+ }
12935
14168
  if (!groupId || !message) {
12936
14169
  return json(res, 400, { error: "post_to_room needs group_id (from list_rooms) and message" });
12937
14170
  }
@@ -12998,7 +14231,7 @@ const handleRequest = async (req, res) => {
12998
14231
  const verdict = await requestPeerApproval(approvalBus, from, { id: room.id, name: room.name }, message, "post_to_room", fromThreadId);
12999
14232
  requireActiveInternalCapability();
13000
14233
  if (verdict !== "allow")
13001
- return json(res, 200, { error: "denied by user" });
14234
+ return json(res, 200, peerApprovalFailure(verdict));
13002
14235
  // The card may have been open for minutes. Re-read both records so a
13003
14236
  // roster change, a section move, or a deletion during that window
13004
14237
  // cannot be posted through on a stale decision.
@@ -13024,13 +14257,46 @@ const handleRequest = async (req, res) => {
13024
14257
  // here is also what keeps its window alive through a turn that only
13025
14258
  // posts — an aged-out mark would hand the next hop to auto-approve.
13026
14259
  const unattended = isUnattended(poster.id, internalCapability.threadId);
14260
+ // A room post is the one send path that bypasses the settling reply,
14261
+ // so an explicit attach flag — never a silent default — moves this
14262
+ // turn's parked voice note onto the posted message. The removal
14263
+ // matters twice over: the clip is delivered exactly once (the
14264
+ // settling reply no longer finds it parked), and it survives a turn
14265
+ // that dies after the post, because it is no longer parked cleanup's
14266
+ // to delete. The post text stands as the caption.
14267
+ let attachedVoiceNote = false;
14268
+ let stagedAttachments = [];
14269
+ let detachParkedAudio;
14270
+ if (attachVoiceNote) {
14271
+ const parkedKey = turnAttachmentKey(fromThreadId, liveTurnByThread.get(fromThreadId));
14272
+ const parked = turnAttachmentsByTurn.get(parkedKey) ?? [];
14273
+ const audio = parked.filter((attachment) => attachment.kind === "audio");
14274
+ if (!audio.length) {
14275
+ return json(res, 400, { error: "no voice note to attach — call send_voice_note in this turn first" });
14276
+ }
14277
+ stagedAttachments = audio.map((attachment) => ({
14278
+ kind: "audio",
14279
+ path: attachment.path,
14280
+ mime: attachment.mime,
14281
+ }));
14282
+ attachedVoiceNote = true;
14283
+ // The parked set is mutated only after the append below lands.
14284
+ // Everything between is synchronous, so nothing can interleave,
14285
+ // and a persistence failure must leave the clip parked: the post
14286
+ // can then be retried, whereas a premature removal strands the
14287
+ // clip on disk attached to nothing — and the catalog already told
14288
+ // the model never to retry a refused post.
14289
+ detachParkedAudio = () => turnAttachmentsByTurn.set(parkedKey, parked.filter((attachment) => attachment.kind !== "audio"));
14290
+ }
13027
14291
  const posted = store.appendMessage(room.threadId, {
13028
14292
  role: "bot",
13029
14293
  kind: "text",
13030
14294
  text: message,
13031
14295
  from: { botId: poster.id, name: poster.name, color: poster.color },
13032
14296
  peerPost: unattended ? { unattended: true } : {},
14297
+ ...(attachedVoiceNote ? { attachments: stagedAttachments } : {}),
13033
14298
  });
14299
+ detachParkedAudio?.();
13034
14300
  store.patchGroup(room.id, { unread: true });
13035
14301
  // The same visibility contract the peer tools keep: whatever a bot
13036
14302
  // does elsewhere shows up in the conversation it is actually in.
@@ -13047,7 +14313,7 @@ const handleRequest = async (req, res) => {
13047
14313
  if (owner.group)
13048
14314
  chip.from = { botId: poster.id, name: poster.name, color: poster.color };
13049
14315
  store.appendMessage(fromThreadId, chip);
13050
- return json(res, 201, { ok: true, messageId: posted.id, roomName: room.name });
14316
+ return json(res, 201, { ok: true, messageId: posted.id, roomName: room.name, attachedVoiceNote });
13051
14317
  }
13052
14318
  // start_thread: a bot opens a real thread — on itself for separate
13053
14319
  // work, or on a teammate as a handoff that should run on its own.
@@ -13219,6 +14485,35 @@ const handleRequest = async (req, res) => {
13219
14485
  if (instructions.length > 1_000) {
13220
14486
  return json(res, 400, { error: "instructions must be at most 1000 characters" });
13221
14487
  }
14488
+ let selection;
14489
+ if (body.modelSelection === undefined) {
14490
+ selection = await defaultSelection();
14491
+ }
14492
+ else {
14493
+ const checked = checkedModelSelection(body.modelSelection, undefined, true);
14494
+ if (!checked.ok)
14495
+ return json(res, checked.status, { error: checked.error });
14496
+ selection = checked.selection;
14497
+ }
14498
+ if (hostedModels && !hostedModels.allows(selection))
14499
+ return json(res, 400, { error: hostedModels.error() });
14500
+ // The same check the profile path runs: absolute, exists, is a
14501
+ // folder. Creation names where the specialist works; nothing looser.
14502
+ let cwd;
14503
+ if (body.cwd !== undefined) {
14504
+ const checkedCwd = validateBotCwd(body.cwd);
14505
+ if (!checkedCwd.ok)
14506
+ return json(res, 400, { error: checkedCwd.error });
14507
+ cwd = checkedCwd.cwd ?? undefined;
14508
+ }
14509
+ // Discovery can yield; check current authority and capacity again before writing.
14510
+ if (store.bot(chief.id) !== chief || chief.hidden || !chief.chiefOfStaff || !connectorThread(chief.id, fromThreadId)) {
14511
+ return json(res, 403, { error: "only an active Chief of Staff can create operator bots" });
14512
+ }
14513
+ if (internalCapability.createdBots >= 4)
14514
+ return json(res, 429, { error: "you can create at most 4 bots in one turn" });
14515
+ if (store.bots.length >= MAX_WORKSPACE_BOTS)
14516
+ return json(res, 409, { error: `this workspace is limited to ${MAX_WORKSPACE_BOTS} bots` });
13222
14517
  const duplicate = store.bots.find((candidate) => !candidate.hidden &&
13223
14518
  sectionKey(candidate.section) === sectionKey(chief.section) &&
13224
14519
  candidate.name.trim().toLowerCase() === name.toLowerCase());
@@ -13229,13 +14524,15 @@ const handleRequest = async (req, res) => {
13229
14524
  name,
13230
14525
  title: role,
13231
14526
  description: instructions,
13232
- modelSelection: { ...chief.modelSelection },
14527
+ modelSelection: selection,
13233
14528
  section: chief.section,
14529
+ ...(cwd !== undefined ? { cwd } : {}),
13234
14530
  // exactly the Chief's audience: a restricted Chief never makes a bot everyone sees
13235
14531
  ...(chief.visibility ? { visibility: chief.visibility } : {}),
13236
14532
  }, { seedMessages: false });
13237
14533
  const safeBot = store.patchBot(created.id, {
13238
14534
  composio: false,
14535
+ connectorTools: {},
13239
14536
  autoApprove: false,
13240
14537
  approvePeerComms: false,
13241
14538
  });
@@ -13246,6 +14543,7 @@ const handleRequest = async (req, res) => {
13246
14543
  title: safeBot.title,
13247
14544
  section: safeBot.section || "General",
13248
14545
  model: safeBot.modelSelection.model,
14546
+ modelSelection: safeBot.modelSelection,
13249
14547
  });
13250
14548
  }
13251
14549
  if (method === "POST" && (path === "/api/internal/create-room" || path === "/api/internal/manage-room")) {
@@ -13343,15 +14641,12 @@ const handleRequest = async (req, res) => {
13343
14641
  if (credentialIsConfigured(cfg, credentialId)) {
13344
14642
  return json(res, 200, { alreadyConfigured: true, label: target.label });
13345
14643
  }
13346
- const existing = store.activePath(fromThreadId).find((message) => isReusableCredentialRequest(message, credentialId, from.id, Boolean(owner.group)));
13347
- if (existing) {
13348
- if (!existing.text?.trim()) {
13349
- store.patchMessage(fromThreadId, existing.id, {
13350
- text: credentialDesktopHandoff(target.label),
13351
- });
13352
- }
13353
- return json(res, 200, { messageId: existing.id, label: target.label });
13354
- }
14644
+ // A later request supersedes every still-pending card for this
14645
+ // credential instead of silently reusing it: the reason or task has
14646
+ // moved on, so the old card stops offering entry and the person gets
14647
+ // one fresh card they can act on. The fresh card is appended FIRST:
14648
+ // if persistence then fails, the prior cards stay actionable rather
14649
+ // than every pending request ending terminal with no card to act on.
13355
14650
  const reason = typeof body.reason === "string" ? body.reason.trim().slice(0, 240) : "";
13356
14651
  const message = store.appendMessage(fromThreadId, {
13357
14652
  role: "bot",
@@ -13367,8 +14662,59 @@ const handleRequest = async (req, res) => {
13367
14662
  requestKey: randomUUID(),
13368
14663
  },
13369
14664
  });
14665
+ for (const prior of store.activePath(fromThreadId)) {
14666
+ if (prior.id === message.id)
14667
+ continue;
14668
+ if (!prior.secret || !isPendingCredentialRequest(prior, credentialId, from.id, Boolean(owner.group)))
14669
+ continue;
14670
+ store.patchMessage(fromThreadId, prior.id, { secret: { ...prior.secret, superseded: true } });
14671
+ }
13370
14672
  return json(res, 201, { messageId: message.id, label: target.label });
13371
14673
  }
14674
+ if (method === "POST" && path === "/api/internal/voice-note") {
14675
+ const body = await readInternalBody();
14676
+ const text = typeof body.text === "string" ? body.text.trim() : "";
14677
+ if (!text)
14678
+ return json(res, 400, { error: "text required" });
14679
+ if (text.length > 1000)
14680
+ return json(res, 413, { error: "voice notes are limited to 1000 characters" });
14681
+ // Advertisement is presentation only; the capability is re-checked
14682
+ // here so a call that reaches an unconfigured workspace answers with
14683
+ // the setup guidance instead of a provider failure.
14684
+ const voiceBot = botForThread(internalSender.id, internalCapability.threadId) ?? internalSender;
14685
+ if (voiceBot.voiceNotes === false) {
14686
+ return json(res, 403, { error: "voice notes are turned off for this bot" });
14687
+ }
14688
+ try {
14689
+ const audio = await tts.speak(cfg, text, voiceBot.voice);
14690
+ // Synthesis can outlive its turn: a stop or settle during the await
14691
+ // revokes the capability and clears the live turn. Re-check both
14692
+ // before saving so the clip cannot park under the :active fallback
14693
+ // or onto a later turn's key (#1742).
14694
+ requireActiveInternalCapability();
14695
+ const turnId = liveTurnByThread.get(internalCapability.threadId);
14696
+ if (!turnId) {
14697
+ return json(res, 409, { error: "this turn has not started yet or already ended" });
14698
+ }
14699
+ const saved = saveAudio(Buffer.from(audio.bytes), audio.mime);
14700
+ const key = turnAttachmentKey(internalCapability.threadId, turnId);
14701
+ const current = turnAttachmentsByTurn.get(key) ?? [];
14702
+ current.push({ kind: "audio", path: saved.path, mime: saved.mime, text });
14703
+ turnAttachmentsByTurn.set(key, current);
14704
+ return json(res, 201, { attached: true });
14705
+ }
14706
+ catch (error) {
14707
+ // "no voice configured" is a setup state, not a provider failure —
14708
+ // 409 with the hint, exactly like /api/tts/speak.
14709
+ if (error instanceof tts.NoVoiceConfigured)
14710
+ return json(res, 409, { error: error.message });
14711
+ const status = error.status;
14712
+ if (typeof status === "number" && status >= 400 && status < 500) {
14713
+ return json(res, status, { error: error instanceof Error ? error.message : "voice note failed" });
14714
+ }
14715
+ throw error;
14716
+ }
14717
+ }
13372
14718
  if (method === "POST" && path === "/api/internal/connectors/mcp") {
13373
14719
  const body = await readInternalBody();
13374
14720
  // Reading a streamed MCP body yields to ordinary settings requests.
@@ -13378,6 +14724,79 @@ const handleRequest = async (req, res) => {
13378
14724
  if (!currentSender || currentSender.composio === false || !composio.configured(cfg)) {
13379
14725
  return json(res, 403, { error: "connected apps are not enabled for this bot" });
13380
14726
  }
14727
+ // Per-bot tool grants (issue #1736): verdict every tools/call frame
14728
+ // against the calling bot's connectorTools before it reaches
14729
+ // Composio. A bot with no grants record keeps the legacy all-tools
14730
+ // behavior; a grants record makes every unrecognized shape a deny.
14731
+ // Rows are fire-and-forget: a log failure must never take the call
14732
+ // (or its refusal) down with it.
14733
+ const call = connectorCallFromFrame(body);
14734
+ // A bot with no grants record keeps the pre-grants relay: even an
14735
+ // unreadable frame passes through exactly as it always did.
14736
+ if (call.kind === "unrecognized" && currentSender.connectorTools !== undefined) {
14737
+ appendDecision(DATA_DIR, {
14738
+ threadId: internalCapability.threadId,
14739
+ botId: currentSender.id,
14740
+ botName: currentSender.name,
14741
+ tool: call.invoked,
14742
+ summary: call.reason,
14743
+ decision: "auto-denied",
14744
+ source: "connector-scope",
14745
+ rule: "connectorTools",
14746
+ });
14747
+ const refusal = connectorUnrecognizedText(call.invoked, call.reason);
14748
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
14749
+ return res.end(JSON.stringify({
14750
+ jsonrpc: "2.0",
14751
+ id: body.id ?? null,
14752
+ result: { content: [{ type: "text", text: refusal }], isError: true },
14753
+ }));
14754
+ }
14755
+ if (call.kind === "tools") {
14756
+ // The resolver needs the real connected-service slugs, so an
14757
+ // underscored service (bland_ai) keeps its own tools instead of
14758
+ // a plain-prefix grant (bland) capturing them; an unreachable
14759
+ // catalog reads as empty and the plain split stands.
14760
+ const serviceSlugs = await composio.connectedServiceSlugs(cfg);
14761
+ const verdict = evaluateConnectorTools(call.names, currentSender.connectorTools, serviceSlugs);
14762
+ if (!verdict.allowed) {
14763
+ for (const denial of verdict.denials) {
14764
+ appendDecision(DATA_DIR, {
14765
+ threadId: internalCapability.threadId,
14766
+ botId: currentSender.id,
14767
+ botName: currentSender.name,
14768
+ tool: denial.tool,
14769
+ summary: denial.service === null
14770
+ ? "tool name does not name a service"
14771
+ : denial.onGrantedService
14772
+ ? "tool is not in this service's grant"
14773
+ : "service is not granted",
14774
+ decision: "auto-denied",
14775
+ source: "connector-scope",
14776
+ rule: denial.service ? "connectorTools." + denial.service : "connectorTools",
14777
+ });
14778
+ }
14779
+ const refusal = connectorRefusalText(verdict.denials);
14780
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
14781
+ return res.end(JSON.stringify({
14782
+ jsonrpc: "2.0",
14783
+ id: body.id ?? null,
14784
+ result: { content: [{ type: "text", text: refusal }], isError: true },
14785
+ }));
14786
+ }
14787
+ // One row per allowed call, naming the first target: the audit
14788
+ // trail reads "which bot ran what", not one row per tool.
14789
+ appendDecision(DATA_DIR, {
14790
+ threadId: internalCapability.threadId,
14791
+ botId: currentSender.id,
14792
+ botName: currentSender.name,
14793
+ tool: call.names[0],
14794
+ summary: ("allowed " + call.names.join(", ")).slice(0, 240),
14795
+ decision: "auto-approved",
14796
+ source: "connector-scope",
14797
+ rule: verdict.rule,
14798
+ });
14799
+ }
13381
14800
  const upstream = await composio.relayMcp(cfg, body, Array.isArray(req.headers["mcp-session-id"])
13382
14801
  ? req.headers["mcp-session-id"][0]
13383
14802
  : req.headers["mcp-session-id"]);
@@ -13397,58 +14816,7 @@ const handleRequest = async (req, res) => {
13397
14816
  if (!bot)
13398
14817
  return json(res, 404, { error: "no such bot" });
13399
14818
  if (method === "GET") {
13400
- const snapshot = botComputerControlSnapshot(botId, internalCapability.teamComputerId);
13401
- const slot = autoVmClaims.get(internalCapability.threadId);
13402
- const lazyClaim = slot && slot.owner.generation === internalCapability.generation ? slot : undefined;
13403
- if (!snapshot.held && lazyClaim?.lazy && !lazyClaim.begin) {
13404
- // First screen tools/call on a lazily-attached Auto VM (issue
13405
- // #1361): fire the exclusive claim — once — and give it a moment
13406
- // to land. A free, ready VM claims in the time of one container
13407
- // inspect, so this call then proceeds with an honest answer;
13408
- // only a claim still queued behind another holder answers held
13409
- // below, and then the contention text is true. Keyed on the
13410
- // slot, never on the thread's turn-computer entry: a bind this
13411
- // turn abandoned earlier (a VPS that turned out to be asleep)
13412
- // must not hide the unclaimed VM and let the call through.
13413
- startAutoVmClaim(autoVmClaims, internalCapability.threadId, internalCapability.generation);
13414
- await Promise.race([
13415
- lazyClaim.begin ?? Promise.resolve(),
13416
- new Promise((resolve) => setTimeout(resolve, LAZY_VM_CLAIM_GRACE_MS)),
13417
- ]);
13418
- }
13419
- if (!snapshot.held && lazyClaim?.failed === true) {
13420
- // A rejected lazy claim (gate finding F1, issue #1361): the
13421
- // computer MCP mounted at dispatch is still live, and the claim
13422
- // may even have left a turn-computer entry behind (it can reject
13423
- // after bindTurnComputer succeeded — lease lost to a person,
13424
- // lifecycle busy, boot failure). Either way this turn owns no
13425
- // usable VM, so keep refusing every screen call for the rest of
13426
- // the generation; the bridge must never forward one onto a VM
13427
- // this turn never claimed. Turn settle GC clears the slot. Say
13428
- // why, and say not to retry: the contention text would send the
13429
- // model into a screenshot loop against a claim that cannot land.
13430
- return json(res, 200, {
13431
- held: true, helpOpen: false,
13432
- blockedReason: `This turn could not claim ${lazyClaim.label ?? "this computer"}${lazyClaim.failure ? ` (${lazyClaim.failure})` : ""}. This call was not performed. Do not retry computer work in this turn; tell the person what you could not do.`,
13433
- });
13434
- }
13435
- if (!snapshot.held && lazyClaim?.lazy && lazyClaim.begin && !lazyClaim.claimed) {
13436
- // The claim fired and is still waiting on the exclusive bind:
13437
- // another turn genuinely holds this desktop right now.
13438
- return json(res, 200, {
13439
- held: true, helpOpen: false,
13440
- blockedReason: "Another thread is using this computer. This call was not performed. Pause computer work until that thread finishes, then take a fresh screenshot before acting.",
13441
- });
13442
- }
13443
- const computer = turnComputerResources.get(internalCapability.threadId);
13444
- if (!snapshot.held && computer && computer.owner.generation === internalCapability.generation &&
13445
- !claimTurnResource(computer.owner, computer.resource)) {
13446
- return json(res, 200, {
13447
- held: true, helpOpen: false,
13448
- blockedReason: "Another thread is using this computer. This call was not performed. Pause computer work until that thread finishes, then take a fresh screenshot before acting.",
13449
- });
13450
- }
13451
- return json(res, 200, { held: snapshot.held, helpOpen: snapshot.helpReason !== null });
14819
+ return json(res, 200, await computerCallGate(internalCapability));
13452
14820
  }
13453
14821
  if (method === "POST") {
13454
14822
  const body = await readInternalBody();
@@ -13873,6 +15241,10 @@ const handleRequest = async (req, res) => {
13873
15241
  }
13874
15242
  };
13875
15243
  if (method === "GET" && path === "/api/bots") {
15244
+ try {
15245
+ groupUsageReader.refresh();
15246
+ }
15247
+ catch { /* accounting must not block a snapshot */ }
13876
15248
  const limit = pageSize(url.searchParams.get("messages"));
13877
15249
  if (limit === null)
13878
15250
  return json(res, 400, { error: "messages must be a non-negative whole number" });
@@ -13974,9 +15346,10 @@ const handleRequest = async (req, res) => {
13974
15346
  const message = store.messagesFor(threadId).find((candidate) => candidate.id === m[2]);
13975
15347
  if (!message)
13976
15348
  return json(res, 404, { error: "no such message" });
13977
- if (message.kind !== "text") {
15349
+ if (message.kind !== "text" || (!message.text && !message.attachments?.length)) {
13978
15350
  return json(res, 403, { error: "that message does not share this file" });
13979
15351
  }
15352
+ const messageText = message.text ?? "";
13980
15353
  const body = method === "POST" ? await readBody(req) : null;
13981
15354
  const rawReference = streamsMessageImage ? url.searchParams.get("ref") : null;
13982
15355
  if (streamsMessageImage && (!rawReference || !/^\d+$/.test(rawReference))) {
@@ -13984,27 +15357,32 @@ const handleRequest = async (req, res) => {
13984
15357
  }
13985
15358
  const href = method === "POST"
13986
15359
  ? (typeof body.path === "string" ? body.path : "")
13987
- : messageImageTargetAt(message.text ?? "", Number(rawReference));
15360
+ : messageImageTargetAt(messageText, Number(rawReference));
13988
15361
  if (!href)
13989
15362
  return json(res, 400, { error: "path is required" });
13990
- // Generated image paths are durable capabilities on this exact message.
13991
- // They do not grant access to arbitrary workspace files or Markdown refs.
13992
- const generatedImage = method === "POST" && message.role === "bot" &&
13993
- message.attachments?.some((attachment) => attachment.kind === "image" && attachment.path === href) === true;
13994
15363
  let roots;
13995
15364
  let downloadName;
13996
- if (generatedImage) {
13997
- roots = [ATTACHMENTS_DIR];
13998
- }
13999
- else if (message.role === "user") {
14000
- downloadName = messageAttachmentName(message.text ?? "", href) ?? undefined;
15365
+ // A file the bot attached with attach_file lives in the private
15366
+ // attachment store; the message itself is the grant, exactly as a
15367
+ // user's attachment tag is for their own uploads.
15368
+ const botAttachment = message.role === "bot"
15369
+ ? message.attachments?.find((attachment) => attachment.path === href)
15370
+ : undefined;
15371
+ if (message.role === "user") {
15372
+ downloadName = messageAttachmentName(messageText, href) ?? undefined;
14001
15373
  if (!downloadName) {
14002
15374
  return json(res, 403, { error: "that message does not share this file" });
14003
15375
  }
14004
15376
  roots = [ATTACHMENTS_DIR];
14005
15377
  }
15378
+ else if (botAttachment) {
15379
+ // Files and images alike: the stored message is the grant, so the
15380
+ // mobile clients can fetch an attached image through this route too.
15381
+ downloadName = botAttachment.kind === "file" ? botAttachment.name : undefined;
15382
+ roots = [ATTACHMENTS_DIR];
15383
+ }
14006
15384
  else {
14007
- if (!messageReferencesFile(message.text ?? "", href)) {
15385
+ if (!messageReferencesFile(messageText, href)) {
14008
15386
  return json(res, 403, { error: "that bot message does not link to this file" });
14009
15387
  }
14010
15388
  const senderId = directBot?.id ?? message.from?.botId;
@@ -14014,26 +15392,10 @@ const handleRequest = async (req, res) => {
14014
15392
  if (!senderId) {
14015
15393
  return json(res, 403, { error: "the file's bot author could not be verified" });
14016
15394
  }
14017
- let pinnedCwd;
14018
- let configuredCwd;
14019
- if (directBot) {
14020
- pinnedCwd = store.taskByThread(directBot.id, threadId)?.cwd;
14021
- configuredCwd = directBot.cwd;
14022
- }
14023
- else if (group) {
14024
- const task = store.groupTaskByThread(group.id, threadId);
14025
- pinnedCwd = task ? task.pinnedCwd : group.threadId === threadId ? group.pinnedCwd : undefined;
14026
- configuredCwd = group.cwd;
14027
- }
14028
- roots = messageFileRoots({
14029
- senderWorkspace: workspaceDir(senderId),
14030
- attachments: ATTACHMENTS_DIR,
14031
- pinnedCwd,
14032
- configuredCwd,
14033
- });
15395
+ roots = messageFileRootsForThread(senderId, threadId);
14034
15396
  }
14035
15397
  const file = await openMessageFile(href, roots);
14036
- if ((streamsMessageImage || generatedImage) && !file.mime.startsWith("image/")) {
15398
+ if ((streamsMessageImage || botAttachment?.kind === "image") && !file.mime.startsWith("image/")) {
14037
15399
  await file.handle.close();
14038
15400
  return json(res, 415, { error: "only images can be previewed here" });
14039
15401
  }
@@ -14188,18 +15550,41 @@ const handleRequest = async (req, res) => {
14188
15550
  const attachment = readAttachment(m[1]);
14189
15551
  if (!attachment)
14190
15552
  return json(res, 404, { error: "no such attachment" });
14191
- res.writeHead(200, {
15553
+ const size = attachment.bytes.byteLength;
15554
+ // Who may see an attachment is decided per request — the dispatcher
15555
+ // 404s hidden ones above — so the browser must never reuse one
15556
+ // member's copy after a switch to another identity in the same profile.
15557
+ const headers = {
14192
15558
  "content-type": attachment.mime,
14193
- "content-length": String(attachment.bytes.byteLength),
14194
- "cache-control": "private, max-age=31536000, immutable",
15559
+ "cache-control": "private, no-store",
14195
15560
  "x-content-type-options": "nosniff",
14196
- });
15561
+ };
15562
+ // Range serving is audio-only (#1745): a player scrubbing a voice note
15563
+ // asks for byte windows, and images/documents keep the full response
15564
+ // the app has always relied on. A header we do not understand reads
15565
+ // as absent — the full 200 is always a correct answer to a GET.
15566
+ if (attachment.mime.startsWith("audio/")) {
15567
+ const rawRange = Array.isArray(req.headers.range) ? req.headers.range[0] : req.headers.range;
15568
+ const range = parseAudioRange(rawRange, size);
15569
+ if (range.kind === "unsatisfiable") {
15570
+ res.writeHead(416, { ...headers, "content-range": `bytes */${size}` });
15571
+ return res.end();
15572
+ }
15573
+ if (range.kind === "range") {
15574
+ res.writeHead(206, {
15575
+ ...headers,
15576
+ "content-length": String(range.end - range.start + 1),
15577
+ "content-range": `bytes ${range.start}-${range.end}/${size}`,
15578
+ });
15579
+ return res.end(attachment.bytes.subarray(range.start, range.end + 1));
15580
+ }
15581
+ }
15582
+ res.writeHead(200, { ...headers, "content-length": String(size) });
14197
15583
  return res.end(attachment.bytes);
14198
15584
  }
14199
15585
  // ── search across every transcript ──────────────────────────────────
14200
- // A LIKE scan over the SQLite message store: local transcripts are
14201
- // megabytes at most, so a scan answers in milliseconds and needs no
14202
- // index to maintain. Hits resolve to the bot/room that owns the thread;
15586
+ // Literal substring scans run in a bounded read-only worker so a large
15587
+ // history cannot stall unrelated turns. Hits resolve to the owning bot/room;
14203
15588
  // rows belonging to deleted conversations resolve to nothing and drop.
14204
15589
  if (method === "GET" && path === "/api/search") {
14205
15590
  const q = url.searchParams.get("q") ?? "";
@@ -14220,8 +15605,27 @@ const handleRequest = async (req, res) => {
14220
15605
  };
14221
15606
  // A member who may not see every bot: look further, then keep only
14222
15607
  // 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))
15608
+ const found = await searchMessagesAsync(q, visible.everything ? limit : Math.min(limit * 10, 1_000), threadId);
15609
+ // A scan may outlive a revocation or audience edit. Authorize again
15610
+ // after the await, and resolve visibility from current store state.
15611
+ if (HOSTED_WORKSPACE && auth.kind === "session") {
15612
+ const failure = workspaceAccess ? await workspaceAccess.authorize(req, auth)
15613
+ : { status: 503, error: "Workspace sign-in is unavailable." };
15614
+ if (failure)
15615
+ return json(res, failure.status, { error: failure.error });
15616
+ }
15617
+ const current = resolveRequestAuth(req, {
15618
+ sessions, cookieName: SESSION_COOKIE, streamPath: "/api/events", url,
15619
+ loopbackMutationToken: desktopMutationToken, companionMutationToken,
15620
+ features: { sharedComputers: sharedComputersEnabled(cfg) }, loopbackTrust: LOOPBACK.trust, cliOwnerToken,
15621
+ });
15622
+ if (!current.auth)
15623
+ return json(res, current.status, { error: current.error });
15624
+ const currentVisible = visibleTo(viewerFor(current.auth));
15625
+ if (threadId && !currentVisible.thread(threadId))
15626
+ return json(res, 404, { error: "no such conversation" });
15627
+ const hits = found
15628
+ .filter((hit) => currentVisible.thread(hit.threadId))
14225
15629
  .slice(0, limit)
14226
15630
  .map((hit) => {
14227
15631
  const bot = store.botByThread(hit.threadId);
@@ -14304,8 +15708,102 @@ const handleRequest = async (req, res) => {
14304
15708
  return json(res, 201, { group: { ...publicGroupState(group), messages: [] } });
14305
15709
  }
14306
15710
  if (method === "POST" && path === "/api/teams/export") {
14307
- const body = await readBody(req);
15711
+ // Pictures the Share dialog prepared ride this body, so it is larger
15712
+ // than the default JSON limit (the document itself is capped at 4 MB).
15713
+ const body = await readBody(req, 8 * 1024 * 1024);
14308
15714
  const profileName = cfg.profile?.name?.trim();
15715
+ if (body.format === "package" && body.version === 2 && body.kind === "library") {
15716
+ // Preset bots and their skills, no team: my New bot defaults as a
15717
+ // preset someone else picks in New bot (presets.ts has the allowlist).
15718
+ const optional = (value) => (typeof value === "string" ? value : undefined);
15719
+ const built = defaultsPresetFor(body, body.includeMemory === true);
15720
+ try {
15721
+ const exported = createLibraryPackageExport({
15722
+ name: optional(body.name),
15723
+ tagline: optional(body.tagline),
15724
+ summary: optional(body.summary),
15725
+ release: optional(body.release),
15726
+ notes: optional(body.notes),
15727
+ authorName: profileName,
15728
+ published: readPublishedLibrary(),
15729
+ preset: built.preset,
15730
+ skipped: built.skipped,
15731
+ });
15732
+ if (body.dryRun !== true)
15733
+ writePublishedLibrary(exported.published);
15734
+ return json(res, 200, {
15735
+ document: exported.document,
15736
+ filename: exported.filename,
15737
+ redacted: exported.redacted,
15738
+ skipped: exported.skipped,
15739
+ summary: packageSummary(exported.document),
15740
+ });
15741
+ }
15742
+ catch (error) {
15743
+ if (error instanceof TeamExportError)
15744
+ return json(res, error.status, { error: error.message });
15745
+ throw error;
15746
+ }
15747
+ }
15748
+ if (body.format === "package" && body.version === 2) {
15749
+ // One team, whole, except its chat history (package format v2).
15750
+ if (typeof body.team !== "string")
15751
+ return json(res, 400, { error: "Choose a team to share." });
15752
+ const team = body.team.trim();
15753
+ if (team && !store.sections.includes(team) && !store.bots.some((bot) => sectionKey(bot.section) === team)) {
15754
+ return json(res, 404, { error: "That team no longer exists." });
15755
+ }
15756
+ const optional = (value) => (typeof value === "string" ? value : undefined);
15757
+ const teamBots = store.bots.filter((bot) => !bot.hidden && sectionKey(bot.section) === team);
15758
+ const skills = collectTeamSkills(teamBots, body.skills);
15759
+ if (!skills.ok)
15760
+ return json(res, 400, { error: skills.error, choices: { skills: skills.available } });
15761
+ const avatars = body.avatars && typeof body.avatars === "object" && !Array.isArray(body.avatars)
15762
+ ? body.avatars
15763
+ : undefined;
15764
+ // Off unless asked: New bot defaults are personal until shared.
15765
+ const defaults = body.includeDefaultsPreset === true ? defaultsPresetFor(body, body.includeMemory === true) : null;
15766
+ try {
15767
+ const exported = createTeamPackageExport({
15768
+ team,
15769
+ name: optional(body.name),
15770
+ tagline: optional(body.tagline),
15771
+ summary: optional(body.summary),
15772
+ release: optional(body.release),
15773
+ notes: optional(body.notes),
15774
+ authorName: profileName,
15775
+ bots: store.bots,
15776
+ groups: store.groups,
15777
+ routines: routines.listRoutines(),
15778
+ brief: readSectionContext(team)?.text,
15779
+ published: readPublishedTeam(team),
15780
+ skillsByBot: skills.skillsByBot,
15781
+ skillSelection: skills.all ? "all" : "chosen",
15782
+ memoryByBot: body.includeMemory === true ? new Map(teamBots.map((bot) => [bot.id, starterNotes(bot.id)])) : undefined,
15783
+ avatars,
15784
+ mcpServers: cfg.mcpServers ?? {},
15785
+ skipped: [...skills.skipped, ...(defaults?.skipped ?? [])],
15786
+ preset: defaults?.preset ?? null,
15787
+ });
15788
+ // A preview (the dialog's live counts) never records keys; saving
15789
+ // the file does, so renaming anything later keeps its identity.
15790
+ if (body.dryRun !== true)
15791
+ writePublishedTeam(team, exported.published);
15792
+ return json(res, 200, {
15793
+ document: exported.document,
15794
+ filename: exported.filename,
15795
+ redacted: exported.redacted,
15796
+ skipped: exported.skipped,
15797
+ summary: packageSummary(exported.document),
15798
+ choices: { skills: skills.available },
15799
+ });
15800
+ }
15801
+ catch (error) {
15802
+ if (error instanceof TeamExportError)
15803
+ return json(res, error.status, { error: error.message, choices: { skills: skills.available } });
15804
+ throw error;
15805
+ }
15806
+ }
14309
15807
  const name = typeof body.name === "string" && body.name.trim()
14310
15808
  ? body.name.trim()
14311
15809
  : profileName
@@ -14414,6 +15912,64 @@ const handleRequest = async (req, res) => {
14414
15912
  }
14415
15913
  return json(res, 200, { directory });
14416
15914
  }
15915
+ // ── the organization library (server/org-library.ts) ─────────────────
15916
+ // Admin scope, as import is. With no organization every read answers
15917
+ // {organization: null} and nothing else changes.
15918
+ if (method === "GET" && path === "/api/org-library") {
15919
+ return json(res, 200, orgLibrary?.list() ?? { organization: null, packages: [] });
15920
+ }
15921
+ m = path.match(/^\/api\/org-library\/packages\/([a-f0-9-]{36})$/);
15922
+ if (m && method === "GET") {
15923
+ const outcome = orgLibrary?.preview(m[1]);
15924
+ if (!outcome)
15925
+ return json(res, 404, { error: "This package is not in your organization's library.", code: "not_listed" });
15926
+ return outcome.ok ? json(res, 200, outcome.value) : json(res, outcome.status, { error: outcome.error, code: outcome.code });
15927
+ }
15928
+ if (method === "POST" && path === "/api/org-library/add") {
15929
+ // One click, no confirmation: the person's click is the decision.
15930
+ const body = await readBody(req);
15931
+ const packageId = typeof body?.packageId === "string" && /^[a-f0-9-]{36}$/.test(body.packageId) ? body.packageId : null;
15932
+ if (!packageId)
15933
+ return json(res, 400, { error: "Choose a package from your organization's library." });
15934
+ if (!orgLibrary)
15935
+ return json(res, 404, { error: "This package is not in your organization's library.", code: "not_listed" });
15936
+ const outcome = orgLibrary.add(packageId, packageImportDeps(await defaultSelection()));
15937
+ if (!outcome.ok)
15938
+ return json(res, outcome.status, { error: outcome.error, code: outcome.code });
15939
+ if (outcome.value.alreadyAdded)
15940
+ return json(res, 200, outcome.value);
15941
+ const imported = outcome.value.result;
15942
+ return json(res, 201, {
15943
+ alreadyAdded: false,
15944
+ installId: imported.installId,
15945
+ name: imported.name,
15946
+ section: imported.section,
15947
+ bots: imported.bots.map((bot) => publicBot(store.bot(bot.id) ?? bot)),
15948
+ groups: imported.groups.map((created) => ({ ...publicGroupState(store.group(created.id) ?? created), messages: [] })),
15949
+ routines: imported.routines,
15950
+ offeredSkills: imported.offeredSkills,
15951
+ connections: imported.connections,
15952
+ skipped: imported.skipped,
15953
+ brief: imported.brief,
15954
+ notes: imported.notes,
15955
+ presets: imported.presets ?? [],
15956
+ });
15957
+ }
15958
+ if (method === "GET" && path === "/api/org-library/skills") {
15959
+ const botId = url.searchParams.get("botId") ?? undefined;
15960
+ return json(res, 200, orgLibrary?.offeredSkills(botId) ?? { organization: null, skills: [] });
15961
+ }
15962
+ if (method === "POST" && path === "/api/org-library/skills") {
15963
+ const body = await readBody(req);
15964
+ const parsed = z.object({ botId: z.string().min(1).max(128), installId: z.string().regex(/^[a-f0-9]{32}$/), name: z.string().min(1).max(64) })
15965
+ .safeParse(body);
15966
+ if (!parsed.success)
15967
+ return json(res, 400, { error: "Choose a bot and a skill from your organization's library." });
15968
+ if (!orgLibrary)
15969
+ return json(res, 404, { error: "This package is not in your organization's library.", code: "not_listed" });
15970
+ const outcome = orgLibrary.addOfferedSkill(parsed.data.botId, parsed.data.installId, parsed.data.name);
15971
+ return outcome.ok ? json(res, 201, { skill: outcome.value }) : json(res, outcome.status, { error: outcome.error, code: outcome.code });
15972
+ }
14417
15973
  if (method === "POST" && path === "/api/teams/import") {
14418
15974
  // Import is additive-only. A manifest is untrusted input (catalog,
14419
15975
  // GitHub, a shared file), so it must be structurally unable to reach
@@ -14479,163 +16035,59 @@ const handleRequest = async (req, res) => {
14479
16035
  return json(res, 400, { error: error instanceof Error ? error.message : "Backup could not be imported" });
14480
16036
  }
14481
16037
  }
16038
+ // Every package (v1 JSON, BotMRR Markdown, v2) is read by the one shared
16039
+ // parser as a FILE: it cannot claim a publisher, and its skills and
16040
+ // routines arrive off. A body field such as `trust` is just an unknown
16041
+ // field of the document and is stripped; only the organization library
16042
+ // module ever imports with trust "org".
14482
16043
  let packageDocument = null;
14483
16044
  let manifest = null;
14484
16045
  try {
14485
16046
  if (isBotPackage(body))
14486
- packageDocument = parseBotPackage(body);
16047
+ packageDocument = parsePackageDocument(body, { trust: "file" });
14487
16048
  else
14488
16049
  manifest = parseTeamManifest(body);
14489
16050
  }
14490
16051
  catch (error) {
14491
16052
  return json(res, 400, { error: error instanceof Error ? error.message : "Invalid bot package" });
14492
16053
  }
14493
- const pkg = packageDocument?.package;
14494
- const importName = pkg?.name ?? manifest.team.name;
14495
- const sourceMembers = pkg
14496
- ? pkg.agents.map((agent) => ({ member: packageAgentAsMember(agent), playbookKeys: agent.playbooks ?? [], skillNames: agent.skills ?? [] }))
14497
- : manifest.team.members.map((member) => ({ member, playbookKeys: [], skillNames: [] }));
14498
- const importedBots = [];
14499
- const createdGroups = [];
14500
- const createdRoutineIds = [];
14501
- // Names already in use, hidden bots included: an archived bot can be
14502
- // un-archived later, and a revived duplicate would be just as
14503
- // ambiguous then.
14504
- const takenNames = new Set(store.bots.map((bot) => bot.name.trim().toLowerCase()));
14505
- const memberIds = new Map();
14506
- let group;
14507
- let importSection;
16054
+ const deps = packageImportDeps(await defaultSelection());
16055
+ const options = {
16056
+ mode: importMode,
16057
+ cwd: projectCwd,
16058
+ room: url.searchParams.get("room") ?? undefined,
16059
+ ...(importVisibility ? { visibility: importVisibility } : {}),
16060
+ };
16061
+ let imported;
14508
16062
  try {
14509
- const selection = await defaultSelection();
14510
- const existingSections = new Set([...store.sections, ...store.bots.map((bot) => bot.section), ...store.groups.map((candidate) => candidate.section)]
14511
- .filter((section) => Boolean(section?.trim()))
14512
- .map((section) => section.trim().toLowerCase()));
14513
- // Every template gets its own section, including legacy teams and
14514
- // project imports. Never merge into an existing section (or replace
14515
- // its Chief). Keep the name editable through the 60-character API.
14516
- importSection = takeImportName(importName, existingSections, 60);
14517
- const playbookByKey = new Map((pkg?.playbooks ?? []).map((playbook) => [playbook.key, playbook]));
14518
- const packageSkillByName = new Map((pkg?.skills?.entries ?? []).map((skill) => [skill.name, skill]));
14519
- for (const source of sourceMembers) {
14520
- const member = source.member;
14521
- // importedMemberProfile is the authority boundary: persona fields
14522
- // only, colliding names numbered. seedMessages: false — an
14523
- // imported bot must not open by greeting the user as though it
14524
- // were new. composio: false — a shared persona never starts with
14525
- // reach into the user's connected apps (absence would mean
14526
- // allowed); the user can switch it on per bot after reading who
14527
- // they got.
14528
- const created = store.createBot({
14529
- ...importedMemberProfile(member, takenNames),
14530
- modelSelection: selection,
14531
- section: importSection,
14532
- ...(importVisibility ? { visibility: importVisibility } : {}),
14533
- }, { seedMessages: false });
14534
- importedBots.push(created);
14535
- const installedPlaybooks = source.playbookKeys.flatMap((key) => {
14536
- const playbook = playbookByKey.get(key);
14537
- return playbook ? [{ ...playbook }] : [];
14538
- });
14539
- store.patchBot(created.id, {
14540
- composio: false,
14541
- ...(installedPlaybooks.length ? { playbooks: installedPlaybooks } : {}),
14542
- ...(pkg
14543
- ? {
14544
- installedPackage: {
14545
- id: pkg.id,
14546
- name: pkg.name,
14547
- release: pkg.release,
14548
- requiredApps: pkg.requirements.apps.map((app) => ({ ...app })),
14549
- },
14550
- }
14551
- : {}),
14552
- });
14553
- for (const skillName of source.skillNames) {
14554
- const skill = packageSkillByName.get(skillName);
14555
- if (!skill)
14556
- throw new Error(`Package skill "${skillName}" is unavailable`);
14557
- const installed = installSkill(created.id, skill.source ?? `package:${pkg.id}`, [
14558
- { path: "SKILL.md", content: skill.instructions },
14559
- ]);
14560
- if ("error" in installed) {
14561
- throw new Error(`Package skill "${skillName}" could not be imported: ${installed.error}`);
14562
- }
14563
- }
14564
- memberIds.set(member.key, created.id);
14565
- }
14566
- // A package is an explicit structure import: its rooms are created
14567
- // from package-local keys only, then normalized to fresh bot ids.
14568
- for (const room of pkg?.rooms ?? []) {
14569
- const ids = room.members.map((key) => memberIds.get(key));
14570
- let created = store.createGroup(room.name, ids, false, importSection);
14571
- createdGroups.push(created);
14572
- const defaultResponder = room.defaultResponder.kind === "agent"
14573
- ? { kind: "member", botId: memberIds.get(room.defaultResponder.agent) }
14574
- : { kind: room.defaultResponder.kind };
14575
- created = store.patchGroup(created.id, {
14576
- bulletin: room.bulletin ?? "",
14577
- defaultResponder,
14578
- setupCompletedAt: Date.now(),
14579
- }) ?? created;
14580
- }
14581
- for (const routine of pkg?.routines ?? []) {
14582
- const created = routines.create({
14583
- name: routine.name,
14584
- prompt: routine.prompt,
14585
- botId: memberIds.get(routine.agent),
14586
- runOn: routine.runOn,
14587
- enabled: false,
14588
- schedule: routine.schedule,
14589
- durationMinutes: routine.durationMinutes,
14590
- ...(routine.timeoutMinutes === undefined ? {} : { timeoutMinutes: routine.timeoutMinutes }),
14591
- ...(routine.overlap ? { overlap: routine.overlap } : {}),
14592
- });
14593
- createdRoutineIds.push(created.id);
14594
- }
14595
- if (pkg?.chiefOfStaff) {
14596
- store.setChiefOfStaff(memberIds.get(pkg.chiefOfStaff));
14597
- }
14598
- // The room is created last, so a failure anywhere above leaves no
14599
- // half-built project behind — the catch below deletes the bots and
14600
- // there is no room pointing at them.
14601
- if (!pkg && importMode === "project" && importedBots.length > 0) {
14602
- const roomName = url.searchParams.get("room")?.trim() || manifest.team.name;
14603
- group = store.createGroup(roomName, importedBots.map((bot) => bot.id), false, importSection);
14604
- createdGroups.push(group);
14605
- if (projectCwd) {
14606
- // `cwd` is the folder the room WANTS; the store pins it on the
14607
- // first turn (pinGroupCwd). Setting the pin here would decide it
14608
- // before anyone has worked, which is the store's call, not ours.
14609
- group = store.patchGroup(group.id, { cwd: projectCwd }) ?? group;
14610
- }
14611
- broadcast({ kind: "group", group: publicGroupState(group) });
14612
- }
14613
- const publicBots = importedBots.map((bot) => publicBot(store.bot(bot.id)));
14614
- for (const bot of publicBots)
14615
- broadcast({ kind: "bot", bot });
14616
- return json(res, 201, {
14617
- name: importName,
14618
- bots: publicBots,
14619
- group,
14620
- groups: createdGroups.map((created) => ({ ...created, messages: [] })),
14621
- routines: createdRoutineIds.flatMap((id) => routines.listRoutines().filter((routine) => routine.id === id)),
14622
- });
16063
+ const result = packageDocument
16064
+ ? importPackageDocument(packageDocument, { ...options, trust: "file" }, deps)
16065
+ : importTeamManifest(manifest, options, deps);
16066
+ // A file import always gets a fresh install id, so it never reports
16067
+ // "already added"; that answer belongs to the organization library.
16068
+ if (result.alreadyAdded)
16069
+ return json(res, 200, result);
16070
+ imported = result;
14623
16071
  }
14624
16072
  catch (error) {
14625
- // A room of deleted members must not survive either — patchGroup can
14626
- // throw (disk) after createGroup already saved.
14627
- for (const routineId of createdRoutineIds)
14628
- routines.remove(routineId);
14629
- for (const created of createdGroups)
14630
- store.deleteGroup(created.id);
14631
- for (const bot of importedBots)
14632
- store.deleteBot(bot.id);
14633
- // Empty teams are now durable too. This import allocated a fresh
14634
- // identity, so its failed installation must retire that identity.
14635
- if (importSection && store.sections.includes(importSection))
14636
- store.changeEmptySection(importSection, null);
16073
+ if (error instanceof PackageImportError)
16074
+ return json(res, error.status, { error: error.message, code: error.code });
14637
16075
  throw error;
14638
16076
  }
16077
+ return json(res, 201, {
16078
+ name: imported.name,
16079
+ section: imported.section,
16080
+ bots: imported.bots.map((bot) => publicBot(store.bot(bot.id) ?? bot)),
16081
+ group: imported.group,
16082
+ groups: imported.groups.map((created) => ({ ...created, messages: [] })),
16083
+ routines: imported.routines,
16084
+ offeredSkills: imported.offeredSkills,
16085
+ connections: imported.connections,
16086
+ skipped: imported.skipped,
16087
+ brief: imported.brief,
16088
+ notes: imported.notes,
16089
+ presets: imported.presets ?? [],
16090
+ });
14639
16091
  }
14640
16092
  m = path.match(/^\/api\/groups\/([\w-]+)\/setup$/);
14641
16093
  if (m && method === "PATCH") {
@@ -14700,7 +16152,7 @@ const handleRequest = async (req, res) => {
14700
16152
  const openApprovalThreads = store.groupTasks(group.id).flatMap((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
14701
16153
  message.card?.requestId &&
14702
16154
  !message.card.answered &&
14703
- !message.card.dismissed) ? [task.threadId] : []);
16155
+ !message.card.dismissed && !message.card.expired) ? [task.threadId] : []);
14704
16156
  return openApprovalThreads.length > 0 && !openApprovalThreads.includes(targetThreadId);
14705
16157
  };
14706
16158
  m = path.match(/^\/api\/groups\/([\w-]+)\/tasks$/);
@@ -14973,7 +16425,8 @@ const handleRequest = async (req, res) => {
14973
16425
  status: 409,
14974
16426
  });
14975
16427
  }
14976
- if (groupIsWorking(current)) {
16428
+ const decision = admit("room", {}, { roomWorking: groupIsWorking(current) });
16429
+ if (decision.action === "queue") {
14977
16430
  const queued = queueChannelMessage(current.id, threadId, text, {
14978
16431
  replyToId: replyTo?.id,
14979
16432
  sendId,
@@ -15042,21 +16495,30 @@ const handleRequest = async (req, res) => {
15042
16495
  const held = holdChannelQueue(current.id, targetThreadId, m[2]);
15043
16496
  if (!held)
15044
16497
  return json(res, 404, { error: "no such queued message" });
15045
- if (!speaker || !instance?.adapter.capabilities.queueing || !instance.adapter.steer) {
15046
- restoreHeldChannelQueue(held);
15047
- return json(res, 200, { ok: true, queued: true, threadId: targetThreadId });
15048
- }
15049
16498
  const [head] = held.items;
15050
- if (!head || head.id !== m[2]) {
16499
+ const decision = admit("room-steer", {
16500
+ speakerPresent: Boolean(speaker),
16501
+ engineCanSteer: Boolean(instance?.adapter.capabilities.queueing && instance.adapter.steer),
16502
+ isQueueHead: Boolean(head && head.id === m[2]),
16503
+ });
16504
+ if (decision.action === "refuse") {
15051
16505
  restoreHeldChannelQueue(held);
15052
16506
  return json(res, 409, { error: "only the first queued message can steer" });
15053
16507
  }
16508
+ if (decision.action === "queue") {
16509
+ restoreHeldChannelQueue(held);
16510
+ return json(res, 200, { ok: true, queued: true, threadId: targetThreadId });
16511
+ }
15054
16512
  // A reply target that cannot be resolved restores the held queue
15055
16513
  // before the request fails — the room's normal drain keeps the head.
15056
16514
  const replyTo = resolveHeldReplyTarget(held, resolveReplyTarget);
15057
- const steered = await instance.adapter
15058
- .steer(targetThreadId, promptWithReply(head.text, replyTo, cfg.profile?.name?.trim() || "User"))
15059
- .catch(() => "indeterminate");
16515
+ // steer was offered only when a live speaker instance could take it;
16516
+ // the check carries that invariant to the type system.
16517
+ const steered = instance?.adapter.steer
16518
+ ? await instance.adapter
16519
+ .steer(targetThreadId, promptWithReply(head.text, replyTo, cfg.profile?.name?.trim() || "User"))
16520
+ .catch(() => "indeterminate")
16521
+ : "refused";
15060
16522
  // The steer was awaited adapter work: re-read every ownership
15061
16523
  // invariant before writing anything, exactly like the 1:1 path. A
15062
16524
  // speaker change, a channel switch, or a settled room restores the
@@ -15187,8 +16649,8 @@ const handleRequest = async (req, res) => {
15187
16649
  const section = url.searchParams.get("section")?.trim();
15188
16650
  if (!section)
15189
16651
  return json(res, 400, { error: "Choose a named team" });
15190
- if (teamComputers.forSection(section))
15191
- return json(res, 409, { error: "Unassign this team's computer before renaming or deleting the team" });
16652
+ if (method === "DELETE" && teamComputers.forSection(section))
16653
+ return json(res, 409, { error: "Unassign this team's computer before deleting the team" });
15192
16654
  let nextName = null;
15193
16655
  if (method === "PATCH") {
15194
16656
  const parsed = z.object({ name: z.string().trim().min(1).max(60) }).strict().safeParse(await readBody(req));
@@ -15196,11 +16658,33 @@ const handleRequest = async (req, res) => {
15196
16658
  return json(res, 400, { error: "Team name must be 1 to 60 characters" });
15197
16659
  nextName = parsed.data.name;
15198
16660
  }
15199
- const error = store.changeEmptySection(section, nextName);
16661
+ const error = nextName === null ? store.deleteSection(section) : store.renameSection(section, nextName, teamComputers);
15200
16662
  if (error)
15201
16663
  return json(res, error === "No such team" ? 404 : 409, { error });
15202
16664
  return json(res, 200, { sections: store.sections });
15203
16665
  }
16666
+ if (method === "PUT" && path === "/api/sidebar-sections") {
16667
+ const section = url.searchParams.get("section")?.trim();
16668
+ if (!section || !store.sections.includes(section))
16669
+ return json(res, 404, { error: "No such team" });
16670
+ const ids = z.array(z.string().regex(/^[\w-]+$/)).max(MAX_WORKSPACE_BOTS);
16671
+ const parsed = z.object({ addBotIds: ids, removeBotIds: ids }).strict().safeParse(await readBody(req));
16672
+ if (!parsed.success)
16673
+ return json(res, 400, { error: "Provide addBotIds and removeBotIds" });
16674
+ for (const [botIds, destination] of [[parsed.data.addBotIds, section], [parsed.data.removeBotIds, undefined]]) {
16675
+ for (const id of botIds) {
16676
+ const bot = store.bot(id);
16677
+ if (bot)
16678
+ assertTeamComputerChangeIdle(bot, { ...bot, section: destination });
16679
+ }
16680
+ }
16681
+ const result = store.updateTeamMembers(section, parsed.data.addBotIds, parsed.data.removeBotIds);
16682
+ if (!result.ok)
16683
+ return json(res, result.reason === "unavailable" ? 404 : 409, { error: result.reason === "chief-conflict" ? "A team can have only one Chief of Staff. Change the Chief before moving this bot."
16684
+ : result.reason === "membership-changed" ? "Team membership changed. Reopen the dialog and try again."
16685
+ : "One or more bots are unavailable" });
16686
+ return json(res, 200, { sections: store.sections, bots: result.bots.map(wireBot) });
16687
+ }
15204
16688
  if (method === "POST" && path === "/api/sidebar-sections") {
15205
16689
  const parsed = createSidebarSectionSchema.safeParse(await readBody(req));
15206
16690
  if (!parsed.success) {
@@ -15229,11 +16713,108 @@ const handleRequest = async (req, res) => {
15229
16713
  }
15230
16714
  return json(res, 200, { section: name, sections: store.sections, bots: result.bots.map(wireBot) });
15231
16715
  }
15232
- if (method === "POST" && path === "/api/bots") {
16716
+ if (method === "GET" && path === "/api/bot-defaults") {
16717
+ return json(res, 200, {
16718
+ defaults: structuredClone(cfg.newBotDefaults ?? newBotDefaultsSchema.parse({})),
16719
+ modelSelection: await defaultSelection(),
16720
+ suggestedName: pickBotName(store.bots.map(bot => bot.name)),
16721
+ });
16722
+ }
16723
+ if (method === "POST" && path === "/api/bot-defaults/skills/preview") {
16724
+ const checked = z.object({ source: z.string().min(1).max(2000) }).strict().safeParse(await readBody(req));
16725
+ if (!checked.success)
16726
+ return json(res, 400, { error: checked.error.message });
16727
+ const input = checked.data;
16728
+ const fetched = await fetchSkillFromSource(input.source);
16729
+ if ("error" in fetched)
16730
+ return json(res, 422, { error: fetched.error });
16731
+ const skills = [];
16732
+ for (const skill of fetched.skills) {
16733
+ const file = skill.files.find(file => file.path === "SKILL.md" || file.path.endsWith("/SKILL.md"));
16734
+ if (!file)
16735
+ continue;
16736
+ const parsed = parseSkillMd(file.content);
16737
+ if ("error" in parsed)
16738
+ return json(res, 422, { error: parsed.error });
16739
+ const skippedWarnings = skill.files.filter(candidate => candidate !== file)
16740
+ .map(candidate => `skipped supporting file "${candidate.path}" — v1 imports only SKILL.md`);
16741
+ const template = botSkillTemplateSchema.safeParse({ name: parsed.name, description: parsed.description, source: skill.source, text: file.content,
16742
+ enabled: false, warnings: [...scanSkillText(file.content), ...skippedWarnings] });
16743
+ if (!template.success)
16744
+ return json(res, 422, { error: template.error.message });
16745
+ skills.push(template.data);
16746
+ }
16747
+ return json(res, 200, { skills });
16748
+ }
16749
+ if (method === "POST" && path === "/api/bot-defaults/avatar") {
15233
16750
  const body = await readBody(req);
16751
+ if (!body || typeof body !== "object" || Array.isArray(body))
16752
+ return json(res, 400, { error: "avatar preview must be a JSON object" });
16753
+ const profile = parseBotProfilePatch(body.profile, true);
16754
+ if (!profile.ok)
16755
+ return json(res, 400, { error: profile.error });
16756
+ const checked = avatarGenerationRequestSchema.safeParse({ prompt: body.prompt });
16757
+ if (!checked.success)
16758
+ return json(res, 400, { error: checked.error.message });
16759
+ const { prompt } = checked.data;
16760
+ const generated = await generateAvatarImage(cfg, {
16761
+ name: profile.patch.name ?? "", title: profile.patch.title ?? "", description: profile.patch.description ?? "",
16762
+ }, prompt);
16763
+ // A preview belongs to the draft. Upload it through the existing image
16764
+ // endpoint only when the user commits the new bot or its defaults.
16765
+ return json(res, 200, { dataUrl: `data:${generated.mime};base64,${generated.bytes.toString("base64")}` });
16766
+ }
16767
+ if (method === "POST" && path === "/api/bots") {
16768
+ let body = await readBody(req);
15234
16769
  if (!body || typeof body !== "object" || Array.isArray(body)) {
15235
16770
  return json(res, 400, { error: "bot must be a JSON object" });
15236
16771
  }
16772
+ const template = resolveBotCreationDefaults(cfg.newBotDefaults, body);
16773
+ const settings = template.profile;
16774
+ if (auth.kind === "session" && !auth.scopes.includes("admin")) {
16775
+ const field = clientBotPatchViolation(settings);
16776
+ if (field || body.preset !== undefined || template.skills.length || template.routines.length || Object.keys(template.memory).length) {
16777
+ return json(res, 403, { error: "Creating a bot with these defaults needs the admin scope" });
16778
+ }
16779
+ }
16780
+ // A preset (presets.ts) supplies content: playbooks, skills and
16781
+ // starter notes. Name, look and instructions stay the request's own.
16782
+ if (body.preset !== undefined && (typeof body.preset !== "string" || !/^[\w-]{1,64}$/.test(body.preset))) {
16783
+ return json(res, 400, { error: "preset must be a preset id from New bot" });
16784
+ }
16785
+ const resolvedPreset = typeof body.preset === "string" ? presetStore.resolve(body.preset) : null;
16786
+ const preset = resolvedPreset && presetOffered(resolvedPreset.row, orgInstallStatuses()) ? resolvedPreset : null;
16787
+ if (body.preset !== undefined && !preset)
16788
+ return json(res, 404, { error: PRESET_UNAVAILABLE_MESSAGE });
16789
+ body = { ...body, ...settings, name: settings.name };
16790
+ if (settings.approvalMode === "full" || settings.approvalMode === "custom") {
16791
+ return json(res, 403, { error: "This approval level requires the desktop permission flow. Use the bot creation dialog or explicitly choose Ask/Auto for API creation." });
16792
+ }
16793
+ if (settings.computer === "local" && settings.approvalMode === "auto" && body.acknowledgeLocalAuto !== true) {
16794
+ return json(res, 400, { error: "Auto mode on this computer requires confirming the warning first (acknowledgeLocalAuto)" });
16795
+ }
16796
+ if (settings.avatarUrl && !storedAvatarExists(settings.avatarUrl)) {
16797
+ return json(res, 400, { error: "avatarUrl must reference an existing stored image" });
16798
+ }
16799
+ if (settings.browserProfile && settings.browserProfile !== "guest" && !(cfg.browserProfiles ?? []).some(profile => profile.id === settings.browserProfile)) {
16800
+ return json(res, 400, { error: "browserProfile must name an existing browser profile" });
16801
+ }
16802
+ if (settings.mcpServers?.some(name => mcpServerNameError(name) !== null) || (settings.mcpServers?.length ?? 0) > MAX_MCP_SERVERS) {
16803
+ return json(res, 400, { error: "Invalid MCP server selection" });
16804
+ }
16805
+ if (settings.managedSections?.length) {
16806
+ if (!settings.chiefOfStaff || settings.managedSections.some(name => name !== "" && !store.sections.includes(name))) {
16807
+ return json(res, 400, { error: "Only a Chief may manage existing additional teams" });
16808
+ }
16809
+ if (body.acknowledgePeerScope !== true)
16810
+ return json(res, 400, { error: "Confirm additional team access (acknowledgePeerScope)" });
16811
+ if (auth.kind === "loopback" && !DESKTOP_MANAGED && store.bots.some(bot => bot.busy)) {
16812
+ return json(res, 409, { error: "Stop running bots before granting access to another team" });
16813
+ }
16814
+ }
16815
+ const checkedCwd = validateBotCwd(settings.cwd ?? null);
16816
+ if (!checkedCwd.ok)
16817
+ return json(res, 400, { error: checkedCwd.error });
15237
16818
  if (body.requireAvailableModel !== undefined && typeof body.requireAvailableModel !== "boolean") {
15238
16819
  return json(res, 400, { error: "requireAvailableModel must be true or false" });
15239
16820
  }
@@ -15272,14 +16853,91 @@ const handleRequest = async (req, res) => {
15272
16853
  if (store.bots.length >= MAX_WORKSPACE_BOTS) {
15273
16854
  return json(res, 409, { error: `this workspace is limited to ${MAX_WORKSPACE_BOTS} bots` });
15274
16855
  }
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.
16856
+ // Apply the audience before creation can announce the bot to members.
15278
16857
  const created = body.visibility === undefined ? null : parseVisibility(body.visibility);
15279
16858
  if (created && !created.ok)
15280
16859
  return json(res, 400, { error: created.error });
15281
- const bot = store.createBot({ ...profile.patch, section, modelSelection: selection, ...(created?.ok ? { visibility: created.visibility } : {}) });
16860
+ const bot = store.createBot({ ...profile.patch, soul: settings.soul, section, modelSelection: selection,
16861
+ ...(created?.ok ? { visibility: created.visibility } : {}) });
16862
+ const createdRoutines = [];
16863
+ try {
16864
+ const { chiefOfStaff: _chief, managedSections: _managed, ...ordinary } = settings;
16865
+ store.patchBot(bot.id, {
16866
+ // Store creation completes workspace defaults (including effort).
16867
+ // Applying the rest of the template must not undo that selection.
16868
+ ...ordinary, name: bot.name, section, modelSelection: bot.modelSelection,
16869
+ mascotExpression: ordinary.mascotExpression ?? undefined,
16870
+ avatarUrl: ordinary.avatarUrl || undefined,
16871
+ computer: ordinary.computer ?? undefined, cwd: checkedCwd.cwd ?? undefined,
16872
+ peers: ordinary.peers ?? undefined, mcpServers: ordinary.mcpServers ?? undefined,
16873
+ browserProfile: ordinary.browserProfile || undefined,
16874
+ autoApprove: ordinary.approvalMode === "auto",
16875
+ });
16876
+ // What the preset brings wins over the saved defaults for the same
16877
+ // skill name or note file.
16878
+ const presetSkills = new Set(preset?.preset.skills ?? []);
16879
+ const presetNotes = new Set(Object.keys(preset?.preset.seed?.memory ?? {}));
16880
+ for (const [file, text] of Object.entries(template.memory)) {
16881
+ if (presetNotes.has(file))
16882
+ continue;
16883
+ journalMemoryWrite(bot.id, file, text, { actor: "person", via: "api" });
16884
+ }
16885
+ if (preset) {
16886
+ applyPresetToBot(bot.id, preset, {
16887
+ store,
16888
+ skills: { install: installSkill, setEnabled: setSkillEnabled, installOrg: installOrgSkill },
16889
+ memory: { writeIndex: writeMemoryFile, writeTopic: writeMemoryTopic },
16890
+ });
16891
+ }
16892
+ for (const skill of template.skills) {
16893
+ if (presetSkills.has(skill.name))
16894
+ continue;
16895
+ const parsed = parseSkillMd(skill.text);
16896
+ if ("error" in parsed || parsed.name !== skill.name)
16897
+ throw Object.assign(new Error("Skill name does not match its contents"), { status: 400 });
16898
+ const installed = installSkill(bot.id, skill.source, [{ path: "SKILL.md", content: skill.text }]);
16899
+ if ("error" in installed)
16900
+ throw Object.assign(new Error(installed.error), { status: 422 });
16901
+ if (skill.enabled) {
16902
+ const enabled = setSkillEnabled(bot.id, skill.name, true);
16903
+ if ("error" in enabled)
16904
+ throw Object.assign(new Error(enabled.error), { status: 422 });
16905
+ }
16906
+ }
16907
+ for (const routine of template.routines) {
16908
+ const created = routines.create({ ...routine, botId: bot.id, enabled: false });
16909
+ createdRoutines.push({ id: created.id, enabled: routine.enabled !== false });
16910
+ }
16911
+ }
16912
+ catch (error) {
16913
+ for (const routine of createdRoutines)
16914
+ routines.remove(routine.id);
16915
+ store.deleteBot(bot.id);
16916
+ throw error;
16917
+ }
16918
+ // Creation is complete. Activation errors must not invite a duplicate
16919
+ // creation retry or delete a bot whose routine may already have run.
16920
+ const warnings = [];
16921
+ if (settings.chiefOfStaff) {
16922
+ try {
16923
+ store.patchBot(bot.id, { managedSections: settings.managedSections ?? [] });
16924
+ store.setChiefOfStaff(bot.id);
16925
+ }
16926
+ catch {
16927
+ warnings.push("Bot created; review its Chief of Staff setting before delegating work.");
16928
+ }
16929
+ }
16930
+ for (const routine of createdRoutines)
16931
+ if (routine.enabled) {
16932
+ try {
16933
+ routines.update(routine.id, { enabled: true });
16934
+ }
16935
+ catch {
16936
+ warnings.push(`Bot created; routine ${routine.id} could not be activated.`);
16937
+ }
16938
+ }
15282
16939
  return json(res, 201, {
16940
+ ...(warnings.length ? { warnings } : {}),
15283
16941
  bot: {
15284
16942
  ...wireBot(bot),
15285
16943
  messages: store.messagesFor(bot.threadId),
@@ -15398,6 +17056,30 @@ const handleRequest = async (req, res) => {
15398
17056
  broadcast({ kind: "bot", bot: visible });
15399
17057
  return json(res, 200, { bot: visible });
15400
17058
  }
17059
+ m = path.match(/^\/api\/bots\/([\w-]+)\/command-allowlist(?:\/([\w-]+))?$/);
17060
+ if (m && ["GET", "POST", "DELETE"].includes(method)) {
17061
+ if (!canManageCommandAllowlist(auth))
17062
+ return json(res, 403, { error: "Only the workspace owner or an admin can manage command permissions." });
17063
+ const bot = requestedTaskBot(m[1], url.searchParams.get("threadId") ?? undefined);
17064
+ if (method === "GET" && !m[2])
17065
+ return json(res, 200, commandAllowlistResponse(bot));
17066
+ if (method === "POST" && !m[2]) {
17067
+ const candidate = commandAllowlistCandidate(await readBody(req));
17068
+ if (!candidate)
17069
+ return json(res, 400, { error: "Enter an exact command without secret values, an absolute working folder, and a provider." });
17070
+ const instance = registry.get(candidate.providerInstanceId);
17071
+ if (!instance || !COMMAND_ALLOWLIST_DRIVERS.has(instance.driverKind)) {
17072
+ return json(res, 400, { error: "This provider does not supply structured command approvals." });
17073
+ }
17074
+ commandAllowlist.add(bot.id, candidate);
17075
+ return json(res, 200, commandAllowlistResponse(bot));
17076
+ }
17077
+ if (method === "DELETE" && m[2]) {
17078
+ commandAllowlist.remove(bot.id, m[2]);
17079
+ return json(res, 200, commandAllowlistResponse(bot));
17080
+ }
17081
+ return json(res, 405, { error: "Unsupported command allowlist operation" });
17082
+ }
15401
17083
  m = path.match(/^\/api\/bots\/([\w-]+)\/always-allow$/);
15402
17084
  if (m && method === "POST") {
15403
17085
  const body = await readBody(req);
@@ -15555,6 +17237,35 @@ const handleRequest = async (req, res) => {
15555
17237
  return json(res, 400, { error: "composio must be true or false" });
15556
17238
  patch.composio = body.composio;
15557
17239
  }
17240
+ // per-bot gate on sending voice notes: the toggle is about what this
17241
+ // bot may do (an admin decision), not how it looks, so it lives here
17242
+ // rather than the client-writable profile surface.
17243
+ if (body.voiceNotes !== undefined) {
17244
+ if (typeof body.voiceNotes !== "boolean")
17245
+ return json(res, 400, { error: "voiceNotes must be true or false" });
17246
+ patch.voiceNotes = body.voiceNotes;
17247
+ }
17248
+ // which of those apps' tools this bot may call (connector grants 1/5:
17249
+ // data model only — enforcement lands with slice 2). null returns the
17250
+ // bot to boolean-only legacy behavior; {} means no tools.
17251
+ if (body.connectorTools !== undefined) {
17252
+ if (body.connectorTools === null) {
17253
+ patch.connectorTools = undefined;
17254
+ }
17255
+ else {
17256
+ const parsed = parseConnectorTools(body.connectorTools);
17257
+ if (!parsed.ok)
17258
+ return json(res, 400, { error: parsed.error });
17259
+ // Connector grants 3/5: past the shape checks, slugs must name
17260
+ // connected services and tool names must carry their service
17261
+ // prefix. Unreachable backends never block the patch — the
17262
+ // call-time verdict from slice 2 stays the authority.
17263
+ const semantic = await composio.validateConnectorGrants(cfg, parsed.grants);
17264
+ if (semantic)
17265
+ return json(res, 400, { error: semantic });
17266
+ patch.connectorTools = parsed.grants;
17267
+ }
17268
+ }
15558
17269
  // Queue this bot's direct messages behind outstanding delegated work
15559
17270
  // instead of steering the conversation immediately (#1194).
15560
17271
  if (body.parkDirectMessages !== undefined) {
@@ -15920,6 +17631,12 @@ const handleRequest = async (req, res) => {
15920
17631
  }
15921
17632
  if (!bot)
15922
17633
  return json(res, 404, { error: "no such bot" });
17634
+ // A defined Works on is the newest explicit choice: this bot's
17635
+ // auto-recorded pins that now point elsewhere give way immediately, so
17636
+ // the next turn on each thread follows the new setting. Person-set pins
17637
+ // and pins that already match stay; returning to Auto sweeps nothing.
17638
+ if (computerSpecified && requestedComputer !== undefined)
17639
+ store.clearAutoSurfacePins(m[1], requestedComputer);
15923
17640
  if (normalizedSelection && selectedTask)
15924
17641
  store.patchTask(bot.id, selectedTask.threadId, { modelSelection: normalizedSelection,
15925
17642
  ...hostedModels?.resetTask(selectedTask.modelSelection, normalizedSelection) });
@@ -15983,6 +17700,27 @@ const handleRequest = async (req, res) => {
15983
17700
  // ── bot skills: imported Agent Skills (SKILL.md) ────────────────────
15984
17701
  // Import lands DISABLED; the UI shows SKILL.md + scan warnings and a
15985
17702
  // person enables after reading. See server/skills.ts for the policy.
17703
+ m = path.match(/^\/api\/bots\/([\w-]+)\/skill-template$/);
17704
+ if (m && method === "POST") {
17705
+ if (!store.bot(m[1]))
17706
+ return json(res, 404, { error: "no such bot" });
17707
+ const checked = botSkillTemplateSchema.safeParse(await readBody(req));
17708
+ if (!checked.success)
17709
+ return json(res, 400, { error: checked.error.message });
17710
+ const input = checked.data;
17711
+ const parsed = parseSkillMd(input.text);
17712
+ if ("error" in parsed || parsed.name !== input.name)
17713
+ return json(res, 400, { error: "Skill name does not match its contents" });
17714
+ const installed = installSkill(m[1], input.source, [{ path: "SKILL.md", content: input.text }]);
17715
+ if ("error" in installed)
17716
+ return json(res, 422, { error: installed.error });
17717
+ if (input.enabled) {
17718
+ const enabled = setSkillEnabled(m[1], input.name, true);
17719
+ if ("error" in enabled)
17720
+ return json(res, 422, { error: enabled.error });
17721
+ }
17722
+ return json(res, 201, { installed });
17723
+ }
15986
17724
  m = path.match(/^\/api\/bots\/([\w-]+)\/skills$/);
15987
17725
  if (m && method === "GET") {
15988
17726
  if (!store.bot(m[1]))
@@ -15997,7 +17735,7 @@ const handleRequest = async (req, res) => {
15997
17735
  return json(res, 404, { error: "no such bot" });
15998
17736
  const parsed = z.object({ source: z.string().min(1).max(2000) }).safeParse(await readBody(req));
15999
17737
  if (!parsed.success)
16000
- return json(res, 400, { error: "source must be a GitHub URL or owner/repo" });
17738
+ return json(res, 400, { error: "source must be a GitHub or skills.sh URL, or owner/repo" });
16001
17739
  const fetched = await fetchSkillFromSource(parsed.data.source);
16002
17740
  if ("error" in fetched)
16003
17741
  return json(res, 422, { error: fetched.error });
@@ -16570,7 +18308,14 @@ const handleRequest = async (req, res) => {
16570
18308
  if (store.activeLeaf(threadId) !== body.expectedActiveLeafId) {
16571
18309
  throw Object.assign(new Error("the conversation changed before this message could start"), { status: 409, code: "guarded_branch" });
16572
18310
  }
16573
- if (currentAtStart.busy || threadBusy(bot.id, threadId) || botAtThreadCapacity(bot.id) || parksBehindCoordination(bot.id, threadId) || activeGroupTurnForBot(bot.id)) {
18311
+ const guardedAdmission = admit("guarded", {}, {
18312
+ botBusy: currentAtStart.busy,
18313
+ threadBusy: threadBusy(bot.id, threadId),
18314
+ atCapacity: botAtThreadCapacity(bot.id),
18315
+ parksBehindCoordination: parksBehindCoordination(bot.id, threadId),
18316
+ groupTurn: Boolean(activeGroupTurnForBot(bot.id)),
18317
+ });
18318
+ if (guardedAdmission.action === "refuse") {
16574
18319
  throw Object.assign(new Error("wait for a free thread slot before retrying this message"), { status: 409, code: "guarded_busy" });
16575
18320
  }
16576
18321
  const message = await startTurn(bot.id, text, { threadId, replyTo, sendId, sender: messageSender(auth), trigger });
@@ -16587,7 +18332,14 @@ const handleRequest = async (req, res) => {
16587
18332
  // admission can hand it to the provider natively.
16588
18333
  const carriesImages = extractTurnImages(text).images.length > 0;
16589
18334
  const steerTarget = handoffs.current(threadId);
16590
- if (!carriesImages && !computerSelectionTurns.get(threadId)?.selected && instance?.adapter.capabilities.queueing && instance.adapter.steer) {
18335
+ const busyAdmission = admit("direct-busy", {
18336
+ carriesImages,
18337
+ pendingComputerSelection: Boolean(computerSelectionTurns.get(threadId)?.selected),
18338
+ engineCanSteer: Boolean(instance?.adapter.capabilities.queueing && instance.adapter.steer),
18339
+ });
18340
+ // steer was offered only when a live instance could take it;
18341
+ // the second check carries that fact to the type system.
18342
+ if (busyAdmission.action === "steer" && instance?.adapter.steer) {
16591
18343
  steered = await instance.adapter
16592
18344
  .steer(threadId, promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"))
16593
18345
  .catch(() => "indeterminate");
@@ -16766,6 +18518,23 @@ const handleRequest = async (req, res) => {
16766
18518
  const text = String(body.text ?? "").trim();
16767
18519
  if (!text)
16768
18520
  return json(res, 400, { error: "text required" });
18521
+ const threadMessages = store.messagesFor(bot.threadId);
18522
+ const source = threadMessages.find((msg) => msg.id === messageId);
18523
+ if (!source || source.role !== "user" || source.kind !== "text") {
18524
+ return json(res, 404, { error: "only user messages can be edited" });
18525
+ }
18526
+ // The client's id for this edit. A retry of an edit the server already
18527
+ // forked answers with that fork instead of forking (and rerunning) again.
18528
+ // Checked before the busy guard: that fork's own turn is what is busy.
18529
+ const sendId = parseSendId(body.sendId);
18530
+ if (sendId) {
18531
+ const accepted = acceptedSendMatch(threadMessages, sendId, text, source.replyToId);
18532
+ if (accepted.kind === "conflict" || (accepted.kind === "match" && (accepted.message.parentId ?? null) !== (source.parentId ?? null))) {
18533
+ return json(res, 409, { error: "sendId already belongs to another message" });
18534
+ }
18535
+ if (accepted.kind === "match")
18536
+ return json(res, 202, { ok: true, message: accepted.message });
18537
+ }
16769
18538
  // everything from here down is synchronous, so two racing edits can
16770
18539
  // never both get past this check: startTurn flips busy before the
16771
18540
  // next request is handled
@@ -16774,10 +18543,6 @@ const handleRequest = async (req, res) => {
16774
18543
  if (phoneSecretSubmissions.hasThread(bot.threadId)) {
16775
18544
  return json(res, 409, { error: "this task is securely saving a credential — try again when it finishes" });
16776
18545
  }
16777
- const source = store.messagesFor(bot.threadId).find((msg) => msg.id === messageId);
16778
- if (!source || source.role !== "user" || source.kind !== "text") {
16779
- return json(res, 404, { error: "only user messages can be edited" });
16780
- }
16781
18546
  if (!registry.get(bot.modelSelection.instanceId)) {
16782
18547
  return json(res, 409, {
16783
18548
  error: `provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`,
@@ -16790,7 +18555,7 @@ const handleRequest = async (req, res) => {
16790
18555
  // startTurn admits the rerun before branching. A shared-resource or
16791
18556
  // concurrency-limit refusal must leave the original transcript intact.
16792
18557
  const replyTo = source.replyToId ? resolveReplyTarget(bot.threadId, source.replyToId) : undefined;
16793
- const message = await startTurn(bot.id, text, { threadId: bot.threadId, editedMessageId: messageId, replyTo, trigger: usageTriggerFor(auth) });
18558
+ const message = await startTurn(bot.id, text, { threadId: bot.threadId, editedMessageId: messageId, replyTo, sendId, trigger: usageTriggerFor(auth) });
16794
18559
  return json(res, 202, { ok: true, message });
16795
18560
  }
16796
18561
  // switch which fork of the conversation is visible (no new turn)
@@ -16823,6 +18588,11 @@ const handleRequest = async (req, res) => {
16823
18588
  const refusal = cardAnswerRefusal(auth, bot.threadId, String(body.requestId), behavior);
16824
18589
  if (refusal)
16825
18590
  return json(res, 403, { error: refusal });
18591
+ if (body.rememberCommand === true) {
18592
+ const refusal = commandRememberRefusal(auth, bot.threadId, String(body.requestId), behavior);
18593
+ if (refusal)
18594
+ return json(res, 403, { error: refusal });
18595
+ }
16826
18596
  return await answeringCardAs(auth, bot.threadId, String(body.requestId), async () => {
16827
18597
  if (await resolveAndSendTeamSetup(res, {
16828
18598
  botId: bot.id, threadId: bot.threadId, requestId: String(body.requestId), behavior,
@@ -16844,6 +18614,22 @@ const handleRequest = async (req, res) => {
16844
18614
  behavior,
16845
18615
  }))
16846
18616
  return;
18617
+ if (resolveAndSendModel(res, {
18618
+ botId: bot.id,
18619
+ botName: bot.name,
18620
+ threadId: bot.threadId,
18621
+ requestId: String(body.requestId),
18622
+ behavior,
18623
+ }))
18624
+ return;
18625
+ if (resolveAndSendTightening(res, {
18626
+ botId: bot.id,
18627
+ botName: bot.name,
18628
+ threadId: bot.threadId,
18629
+ requestId: String(body.requestId),
18630
+ behavior,
18631
+ }))
18632
+ return;
16847
18633
  if (sendSkillResolution(res, resolveSkillRequest({
16848
18634
  botId: bot.id,
16849
18635
  botName: bot.name,
@@ -16860,7 +18646,7 @@ const handleRequest = async (req, res) => {
16860
18646
  resolvePeerComms(approvalBus, String(body.requestId), behavior)) {
16861
18647
  return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
16862
18648
  }
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);
18649
+ const outcome = await answerRequest(bot.threadId, bot.modelSelection.instanceId, String(body.requestId), behavior, body.message, { id: bot.id, name: bot.name }, body.always === true, body.rememberCommand === true);
16864
18650
  return json(res, 200, { ok: true, outcome });
16865
18651
  });
16866
18652
  }
@@ -16879,6 +18665,11 @@ const handleRequest = async (req, res) => {
16879
18665
  const refusal = cardAnswerRefusal(auth, threadId, requestId, behavior);
16880
18666
  if (refusal)
16881
18667
  return json(res, 403, { error: refusal });
18668
+ if (body.rememberCommand === true) {
18669
+ const refusal = commandRememberRefusal(auth, threadId, requestId, behavior);
18670
+ if (refusal)
18671
+ return json(res, 403, { error: refusal });
18672
+ }
16882
18673
  return await answeringCardAs(auth, threadId, requestId, async () => {
16883
18674
  const skillCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.skillRequest);
16884
18675
  if (skillCard?.card?.skillRequest) {
@@ -16937,6 +18728,36 @@ const handleRequest = async (req, res) => {
16937
18728
  }))
16938
18729
  return;
16939
18730
  }
18731
+ const modelCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.modelRequest);
18732
+ if (modelCard?.card?.modelRequest) {
18733
+ const modelBotId = modelCard.from?.botId ?? store.botByThread(threadId)?.id;
18734
+ if (!modelBotId)
18735
+ return json(res, 400, { error: "this model request has no valid owner" });
18736
+ const modelOwner = store.bot(modelBotId);
18737
+ if (resolveAndSendModel(res, {
18738
+ botId: modelBotId,
18739
+ botName: modelOwner?.name,
18740
+ threadId,
18741
+ requestId,
18742
+ behavior,
18743
+ }))
18744
+ return;
18745
+ }
18746
+ const tighteningCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.tighteningRequest);
18747
+ if (tighteningCard?.card?.tighteningRequest) {
18748
+ const tighteningBotId = tighteningCard.from?.botId ?? store.botByThread(threadId)?.id;
18749
+ if (!tighteningBotId)
18750
+ return json(res, 400, { error: "this tightening request has no valid owner" });
18751
+ const tighteningOwner = store.bot(tighteningBotId);
18752
+ if (resolveAndSendTightening(res, {
18753
+ botId: tighteningBotId,
18754
+ botName: tighteningOwner?.name,
18755
+ threadId,
18756
+ requestId,
18757
+ behavior,
18758
+ }))
18759
+ return;
18760
+ }
16940
18761
  // peer-approval intercept (see /api/bots/:id/respond above). A peer card
16941
18762
  // belongs to the bus rather than to a speaker, so resolve it before we go
16942
18763
  // looking for one — a room between turns has no speaker to find.
@@ -16958,7 +18779,7 @@ const handleRequest = async (req, res) => {
16958
18779
  if (!owner && !pending)
16959
18780
  return json(res, 404, { error: "nothing is waiting on an answer in this conversation" });
16960
18781
  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);
18782
+ const outcome = await answerRequest(threadId, requestOwner?.modelSelection.instanceId ?? "", requestId, behavior, body.message, owner ? { id: owner.id, name: owner.name } : undefined, body.always === true, body.rememberCommand === true);
16962
18783
  return json(res, 200, { ok: true, outcome });
16963
18784
  });
16964
18785
  }
@@ -17163,7 +18984,7 @@ const handleRequest = async (req, res) => {
17163
18984
  const current = store.projectBotForTask(m[1], m[2]);
17164
18985
  if (!current)
17165
18986
  return json(res, 404, { error: "no such task" });
17166
- const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt", "pinned", "surface"]);
18987
+ const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt", "pinned", "snoozedUntil", "surface"]);
17167
18988
  if (Object.keys(body).some((key) => !allowed.has(key)))
17168
18989
  return json(res, 400, { error: "unsupported thread setting" });
17169
18990
  for (const key of ["requireAvailableModel", "acknowledgeLocalAuto", "updateBotDefault", "resetApprovalToAsk"]) {
@@ -17205,15 +19026,28 @@ const handleRequest = async (req, res) => {
17205
19026
  return json(res, 400, { error: "pinned must be a boolean" });
17206
19027
  patch.pinned = body.pinned ? true : undefined;
17207
19028
  }
19029
+ if (body.snoozedUntil !== undefined) {
19030
+ if (body.snoozedUntil === null)
19031
+ patch.snoozedUntil = undefined;
19032
+ else if (typeof body.snoozedUntil === "number" && Number.isFinite(body.snoozedUntil) && body.snoozedUntil >= 0)
19033
+ patch.snoozedUntil = body.snoozedUntil;
19034
+ else
19035
+ return json(res, 400, { error: "snoozedUntil must be a timestamp, 0 to snooze until activity, or null to wake now" });
19036
+ }
17208
19037
  if (body.surface !== undefined) {
17209
19038
  if (threadBusy(current.id, current.threadId))
17210
19039
  return json(res, 409, { error: "Stop this thread before changing its computer destination." });
17211
- // Where this conversation works, chosen from the composer. Null follows
17212
- // the bot's Works on again. Reachability is the turn's to judge.
17213
- if (body.surface === null)
19040
+ // Where this conversation works, chosen by the person and recorded as
19041
+ // theirs, so a later Works on change never sweeps it. Null follows the
19042
+ // bot's Works on again. Reachability is the turn's to judge.
19043
+ if (body.surface === null) {
17214
19044
  patch.surface = undefined;
17215
- else if (parseSurface(body.surface))
19045
+ patch.surfaceSource = undefined;
19046
+ }
19047
+ else if (parseSurface(body.surface)) {
17216
19048
  patch.surface = parseSurface(body.surface);
19049
+ patch.surfaceSource = "user";
19050
+ }
17217
19051
  else
17218
19052
  return json(res, 400, { error: "surface must be cloud, vm, local, browser, or null to follow the bot" });
17219
19053
  }
@@ -17783,7 +19617,7 @@ const handleRequest = async (req, res) => {
17783
19617
  return json(res, 400, { error: `provider must be one of ${PROVIDER_KEY_KINDS.join(", ")}` });
17784
19618
  }
17785
19619
  const kind = provider;
17786
- const saved = kind === "anthropic" ? cfg.anthropic : kind === "openaiCompat" ? cfg.openaiCompat : cfg.xai;
19620
+ const saved = kind === "anthropic" ? cfg.anthropic : kind === "openaiCompat" ? cfg.openaiCompat : kind === "mistral" ? cfg.mistral : cfg.xai;
17787
19621
  if (body?.key !== undefined && typeof body.key !== "string") {
17788
19622
  return json(res, 400, { error: "key must be a string" });
17789
19623
  }
@@ -17792,7 +19626,7 @@ const handleRequest = async (req, res) => {
17792
19626
  return json(res, 400, { error: "No key to test. Paste one or save one first." });
17793
19627
  if (key.length > 512)
17794
19628
  return json(res, 400, { error: "That does not look like an API key." });
17795
- const url = typeof body?.url === "string" && body.url.trim() ? body.url.trim() : saved?.url;
19629
+ const url = typeof body?.url === "string" && body.url.trim() ? body.url.trim() : (saved && "url" in saved ? saved.url : undefined);
17796
19630
  res.setHeader("cache-control", "no-store");
17797
19631
  return json(res, 200, await checkProviderKey({ provider: kind, key, url }));
17798
19632
  }
@@ -18095,8 +19929,8 @@ const handleRequest = async (req, res) => {
18095
19929
  return json(res, 400, { error: "Account settings are currently available for Claude only." });
18096
19930
  }
18097
19931
  if (body.tools !== undefined) {
18098
- if (!["openai-compat", "grok", "minimax"].includes(entry.driver)) {
18099
- return json(res, 400, { error: "The tools setting is available for OpenAI-compatible, Grok API and MiniMax API instances only." });
19932
+ if (!["openai-compat", "grok", "minimax", "mistral"].includes(entry.driver)) {
19933
+ return json(res, 400, { error: "The tools setting is available for OpenAI-compatible, Grok API, MiniMax API and Mistral API instances only." });
18100
19934
  }
18101
19935
  entry.config = { ...entry.config, tools: body.tools };
18102
19936
  }
@@ -18298,9 +20132,22 @@ const handleRequest = async (req, res) => {
18298
20132
  }
18299
20133
  if ((method === "PUT" || method === "PATCH") && path === "/api/config") {
18300
20134
  const body = await readBody(req);
18301
- if (hostedModels && ["instances", "anthropic", "openaiCompat", "xai", "opencodeGo"].some(key => Object.hasOwn(body, key)))
20135
+ if (hostedModels && ["instances", "anthropic", "openaiCompat", "xai", "mistral", "opencodeGo"].some(key => Object.hasOwn(body, key)))
18302
20136
  return json(res, 403, { error: HOSTED_PROVIDER_SETTINGS_ERROR });
18303
20137
  const patch = parseConfigPatch(body);
20138
+ if (patch.newBotDefaults) {
20139
+ if (patch.newBotDefaults.profile.modelSelection) {
20140
+ const checked = checkedModelSelection(patch.newBotDefaults.profile.modelSelection);
20141
+ if (!checked.ok)
20142
+ return json(res, checked.status, { error: checked.error });
20143
+ patch.newBotDefaults.profile.modelSelection = checked.selection;
20144
+ }
20145
+ for (const skill of patch.newBotDefaults.skills) {
20146
+ const parsed = parseSkillMd(skill.text);
20147
+ if ("error" in parsed || parsed.name !== skill.name)
20148
+ return json(res, 400, { error: "Skill name does not match its contents" });
20149
+ }
20150
+ }
18304
20151
  if (hostedModels && patch.defaultModelSelection) {
18305
20152
  const checked = checkedModelSelection(patch.defaultModelSelection);
18306
20153
  if (!checked.ok)
@@ -18470,7 +20317,7 @@ const handleRequest = async (req, res) => {
18470
20317
  }
18471
20318
  const listed = currentById.get(inspected.identity.boxId);
18472
20319
  if (listed && listed.name !== inspected.identity.name) {
18473
- return json(res, 503, { error: "ascii.dev returned conflicting cloud computer identities; keep the current Box account and retry" });
20320
+ return json(res, 503, { error: "boat.dev returned conflicting cloud computer identities; keep the current Box account and retry" });
18474
20321
  }
18475
20322
  currentById.set(inspected.identity.boxId, inspected.identity);
18476
20323
  journalBoxResources.push(inspected.identity);
@@ -18866,6 +20713,22 @@ const handleRequest = async (req, res) => {
18866
20713
  }
18867
20714
  return json(res, 200, { configured: true, credentialStore: "ok", services: await composio.connectedServices(cfg) });
18868
20715
  }
20716
+ if (method === "GET" && path === "/api/connectors/tools") {
20717
+ // The grant editor's inventory: every grantable tool name grouped by
20718
+ // service, read from the same MCP endpoint a mounted bot relays
20719
+ // through. Failure is a read-only editor, never a blocked save — the
20720
+ // exact-name model does not depend on the listing being reachable.
20721
+ const availability = composio.connectorAvailability(cfg);
20722
+ if (availability !== "configured") {
20723
+ return json(res, 200, { configured: false, services: {} });
20724
+ }
20725
+ try {
20726
+ return json(res, 200, { configured: true, services: await composio.listConnectorTools(cfg) });
20727
+ }
20728
+ catch (e) {
20729
+ return json(res, 502, { configured: true, services: {}, error: e instanceof Error ? e.message : String(e) });
20730
+ }
20731
+ }
18869
20732
  if (method === "GET" && path === "/api/connectors") {
18870
20733
  const services = (url.searchParams.get("services") ?? "").split(",").filter(Boolean);
18871
20734
  const availability = composio.connectorAvailability(cfg);
@@ -18935,6 +20798,8 @@ const handleRequest = async (req, res) => {
18935
20798
  if (m[3] === "provided") {
18936
20799
  if (message.secret.dismissed)
18937
20800
  return json(res, 409, { error: "this credential request was dismissed" });
20801
+ if (message.secret.superseded)
20802
+ return json(res, 409, { error: "this credential request was superseded by a newer one" });
18938
20803
  if (!credentialIsConfigured(cfg, message.secret.target)) {
18939
20804
  return json(res, 409, { error: `${message.secret.label} was not saved yet` });
18940
20805
  }
@@ -18947,6 +20812,8 @@ const handleRequest = async (req, res) => {
18947
20812
  return json(res, 200, state);
18948
20813
  }
18949
20814
  if (m[3] === "resume") {
20815
+ if (message.secret.superseded)
20816
+ return json(res, 409, { error: "this credential request was superseded by a newer one" });
18950
20817
  const outcome = credentialResumeOutcome(message.secret);
18951
20818
  if (!outcome) {
18952
20819
  return json(res, 409, { error: "this credential request is not ready to resume" });
@@ -18962,6 +20829,8 @@ const handleRequest = async (req, res) => {
18962
20829
  return json(res, 409, { error: "this credential request is no longer available" });
18963
20830
  return json(res, 200, { resumed: state.resumed });
18964
20831
  }
20832
+ if (message.secret.superseded)
20833
+ return json(res, 409, { error: "this credential request was superseded by a newer one" });
18965
20834
  if (!message.secret.provided && !resumeSecretCard(m[1], threadId, message.id, "dismissed")) {
18966
20835
  return json(res, 409, { error: "this credential request is no longer available" });
18967
20836
  }
@@ -19348,9 +21217,9 @@ setInterval(sweepThreadEventLogsNow, THREAD_LOG_RETENTION_SWEEP_MS).unref();
19348
21217
  for (const row of chatFollowups()) {
19349
21218
  if (row.status !== "dispatching" && row.status !== "interrupted")
19350
21219
  continue;
19351
- const owned = row.kind === "bot"
19352
- ? Boolean(store.taskByThread(row.ownerId, row.threadId))
19353
- : Boolean(store.groupByThread(row.threadId)?.id === row.ownerId);
21220
+ const owned = row.kind === "channel"
21221
+ ? Boolean(store.groupByThread(row.threadId)?.id === row.ownerId)
21222
+ : Boolean(store.taskByThread(row.ownerId, row.threadId));
19354
21223
  if (!owned) {
19355
21224
  settleChatFollowups([row.id], "cancelled");
19356
21225
  continue;
@@ -19358,12 +21227,20 @@ for (const row of chatFollowups()) {
19358
21227
  settleChatFollowups([row.id], "interrupted");
19359
21228
  const messages = store.messagesFor(row.threadId);
19360
21229
  const recovered = messages.find((message) => message.queueId === row.id && message.role === "user") ?? store.appendMessage(row.threadId, {
19361
- role: "user", kind: "text", text: row.payload.text, replyToId: row.payload.replyToId,
21230
+ // Aside rows recover enveloped: the words may already have run mid-turn,
21231
+ // and the recovered line must keep saying whose context it was.
21232
+ role: "user", kind: "text", text: row.kind === "aside" ? row.payload.prompt ?? row.payload.text : row.payload.text, replyToId: row.payload.replyToId,
19362
21233
  sendId: row.payload.sendId, queueId: row.id, sender: row.payload.sender,
19363
21234
  ...(row.kind === "channel" ? { channelMode: row.payload.mode, via: row.payload.via } : {}),
21235
+ ...(row.kind === "aside" ? {
21236
+ aside: true,
21237
+ peerAsk: row.payload.aside
21238
+ ? { botId: row.payload.aside.fromBotId, name: row.payload.aside.fromBotName, ...(row.payload.aside.unattended ? { unattended: true } : {}) }
21239
+ : undefined,
21240
+ } : {}),
19364
21241
  });
19365
21242
  // Nor as a message a resumed session has not seen: count it as handed.
19366
- const recoveredTask = row.kind === "bot" ? store.taskByThread(row.ownerId, row.threadId) : undefined;
21243
+ const recoveredTask = row.kind !== "channel" ? store.taskByThread(row.ownerId, row.threadId) : undefined;
19367
21244
  const order = store.activePath(row.threadId).filter(isContextMessage).map((m) => m.id);
19368
21245
  for (const [instanceId, state] of Object.entries(recoveredTask?.handedMessages ?? {})) {
19369
21246
  if (state.session !== undefined)
@@ -19380,7 +21257,20 @@ for (const row of chatFollowups()) {
19380
21257
  settleChatFollowups([row.id], null);
19381
21258
  }
19382
21259
  restoreSteeredMessages();
21260
+ restoreAsideMessages(store);
19383
21261
  restoreChannelMessages();
21262
+ // Repair known auto pins that conflict with Works on before dispatch starts.
21263
+ // Legacy pins have no provenance and may be deliberate person selections:
21264
+ // preserve them rather than guessing. The sweep is idempotent.
21265
+ {
21266
+ let movedAutoPins = 0;
21267
+ for (const bot of store.bots) {
21268
+ if (bot.computer !== undefined)
21269
+ movedAutoPins += store.clearAutoSurfacePins(bot.id, bot.computer);
21270
+ }
21271
+ if (movedAutoPins)
21272
+ console.log(`Works on: moved ${movedAutoPins} auto-pinned thread(s) to their bot's current Works on`);
21273
+ }
19384
21274
  server.listen(PORT, "127.0.0.1", () => {
19385
21275
  companyRuntimeReady();
19386
21276
  console.log(`openmausbot server on http://127.0.0.1:${PORT}`);
@@ -19458,6 +21348,7 @@ const gracefulShutdown = createGracefulShutdown({
19458
21348
  () => flushUsageLedger(DATA_DIR),
19459
21349
  () => flushDecisionLog(DATA_DIR),
19460
21350
  () => flushAdminActivity(DATA_DIR),
21351
+ () => closeMessageSearch(),
19461
21352
  ],
19462
21353
  // Cleanup jobs run concurrently. Release only after they settle (or reach
19463
21354
  // the shutdown deadline), immediately before the process exits, so no new