openmausbot 0.1.86 → 0.1.87

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 (166) hide show
  1. package/README.md +2 -2
  2. package/dist/assets/{abnfDiagram-O67JEVCF-DpUZNnFD.js → abnfDiagram-O67JEVCF-BTiuL79F.js} +1 -1
  3. package/dist/assets/{arc-DW7mrI4p.js → arc-C88Blc2v.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CyeZ5WLZ.js → architectureDiagram-NJMV4G6O-BZsJIZrb.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CBVaNjO7.js → blockDiagram-BEXU5L5S-CVeMe9Ah.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-sf8d41uT.js → c4Diagram-YGBWAQC7-Cedk3Bhx.js} +1 -1
  7. package/dist/assets/channel-taCNRlUU.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-BOGvy7Y_.js → chunk-2Q5K7J3B-D-VRqzTB.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BiF9zyiz.js → chunk-3FUC2YCW-BChov01Y.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-EFuZ1SBZ.js → chunk-5VM5RSS4-C-TfwvHy.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-pJLNSAT7.js → chunk-GTNCS2PH-Db7CGj5Y.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-Cb-3_3IT.js → chunk-GWA4HPMP-CNvIfqKT.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-DAf5aogn.js → chunk-JWPE2WC7-C_bEp5tq.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-DR3kiwZz.js → chunk-NETBCI7D-Coi4BOls.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-BmU8PeyM.js → chunk-WEXAMYUT-CzylEuL3.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-Buh8g8OT.js → chunk-XXDRQBXY-m7p7ztaD.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-BurfmMWM.js → classDiagram-v2-NBCMYWYE-BMy6o9x2.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-KkGZUEkU.js → cose-bilkent-JH36ORCC-BDQ__0_J.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-C3PpV96d.js → cynefin-OW5HDTMX-Djv4ghUb.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-Dt8JwJaI.js → cynefinDiagram-VND7K2PF-BULrlGYH.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-DkjN8266.js → dagre-6A5THRUB-D-jrT_hU.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-DUAJS0JV.js → diagram-22UHCM2B-p2UhQmfL.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-Dw4KhF1a.js → diagram-3UASUU5V-DHvGWZ7q.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-Dd1gmQqf.js → diagram-ATOU4E4O-ChhKDot-.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-CjwGs0Yw.js → diagram-CDSNMT55-B-T7A43R.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-BL-69Zrq.js → diagram-MLGK6HIB-CJm8ENNY.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-5Zi2N7wo.js → diagram-MPIPVDR6-510tlza8.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-D9NekWUX.js → ebnfDiagram-ZINNZB2B-PkvRPHel.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-CkaSQjxw.js → elk-276RUBZZ-BVBVPoX3.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-CdAVBV8-.js → erDiagram-OPXOYQCR-BgYMVm7L.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-Bm_lWWH7.js → flowDiagram-KWPJA3E3-BGqsnc_3.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-FZ8ejYWJ.js → ganttDiagram-FUAMR5RP-D_-UeReH.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-Dlv9rizZ.js → gitGraphDiagram-X574FWY7-D0uooONx.js} +1 -1
  34. package/dist/assets/index-BwdMAQ6p.css +1 -0
  35. package/dist/assets/index-ByJennr7.js +581 -0
  36. package/dist/assets/{index-h9FFNwTu.js → index-zRsJERGJ.js} +1 -1
  37. package/dist/assets/{infoDiagram-VRGFBTTK-BdQ_gW5B.js → infoDiagram-VRGFBTTK-B9e1z77f.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-EwssEp2X.js → ishikawaDiagram-OU5B5YK6-BB4vNwvD.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-BX1G1vOd.js → journeyDiagram-ZHPQQLJL-DpkosOTB.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-BB4kAsyC.js → kanban-definition-PNTS6WVX-DbDP4kUu.js} +1 -1
  41. package/dist/assets/{linear-3su2TG93.js → linear-DBJGPmjT.js} +1 -1
  42. package/dist/assets/{mermaid.core-CDD1E0s9.js → mermaid.core-DWPRi-ue.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-Ba50MsNK.js → mindmap-definition-NLK3R4M7-nszBbJmx.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-DZLkkZlM.js → pegDiagram-GJSIUBJH-CWykJWu_.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-DaTz77Uq.js → pieDiagram-5QR66LMP-g3Da-Vnv.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-CpaiidpR.js → quadrantDiagram-O4NWA36T-Wzrlpzgh.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-mN4JrMeI.js → railroadDiagram-XR7U4H2S-moHTFMC0.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-BSNDRpg7.js → requirementDiagram-PLB6GJNP-CwfXdFH8.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-FngzGNug.js → sankeyDiagram-IPEJSGJF-3ZROYvnq.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-CM4pCC3L.js → sequenceDiagram-PO4LG4MO-E1TBebP8.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-CRICHXkw.js → sizeCapture-INFHLROL-DHKEU5pu.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-oyTWpxH2.js → stateDiagram-v2-GCMORJYK-BguEj09D.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-D9h9mPBq.js → swimlanes-2SLR337P-C7IDYnhd.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-BpEdoB2e.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BJUlN9bW.js → timeline-definition-EJHVYXUP-DCW-F5PW.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-3uBNkbAb.js → usecaseDiagram-POWQR4AR-xBMFtWYV.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-D2odalzT.js → vennDiagram-UO4OBE2U-B5nIkuH8.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DHgFaB9S.js → wardleyDiagram-VNRHLVJA-DYFAhdbr.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-w5GYM2h0.js → xychartDiagram-PMCCYNJV-DeXwX6Cz.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/browser-proxy.js +2 -1
  62. package/dist-server/container-mcp.js +1029 -164
  63. package/dist-server/drivers/agents-proxy.js +64 -10
  64. package/dist-server/drivers/phone-proxy.js +33 -2
  65. package/dist-server/index.js +14577 -9001
  66. package/dist-server/local-computer-proxy.js +3 -3
  67. package/dist-server/local-computer.js +53 -17
  68. package/dist-server/openmausbot.js +3981 -1165
  69. package/dist-server/pair-cli.js +3981 -1165
  70. package/dist-server/permission-proxy.js +29 -9
  71. package/dist-server/server/attachments.js +55 -0
  72. package/dist-server/server/auto-approve.js +4 -3
  73. package/dist-server/server/auto-vm-claims.js +1 -0
  74. package/dist-server/server/bot-package.js +18 -292
  75. package/dist-server/server/bot-profile.js +5 -17
  76. package/dist-server/server/box-create-idempotency.js +3 -3
  77. package/dist-server/server/box.js +57 -55
  78. package/dist-server/server/browser-proxy.js +3 -2
  79. package/dist-server/server/browser-runtime.js +37 -1
  80. package/dist-server/server/cli.js +2 -2
  81. package/dist-server/server/command-allowlist.js +154 -0
  82. package/dist-server/server/computer-wait.js +22 -9
  83. package/dist-server/server/config.js +59 -9
  84. package/dist-server/server/connector-verdict.js +141 -0
  85. package/dist-server/server/container-computer.js +1 -1
  86. package/dist-server/server/default-model-selection.js +9 -0
  87. package/dist-server/server/delegations.js +85 -34
  88. package/dist-server/server/drivers/acp/core.js +57 -15
  89. package/dist-server/server/drivers/agents-call.js +42 -3
  90. package/dist-server/server/drivers/agents-catalog.js +34 -4
  91. package/dist-server/server/drivers/boxagent.js +174 -36
  92. package/dist-server/server/drivers/builtIn.js +2 -0
  93. package/dist-server/server/drivers/chat-box-tools.js +90 -0
  94. package/dist-server/server/drivers/chat-images.js +54 -0
  95. package/dist-server/server/drivers/chat-mcp-tools.js +38 -8
  96. package/dist-server/server/drivers/chat-tool-approval.js +70 -18
  97. package/dist-server/server/drivers/claude.js +7 -2
  98. package/dist-server/server/drivers/codex-instructions.js +48 -6
  99. package/dist-server/server/drivers/codex-tool-surface.js +20 -0
  100. package/dist-server/server/drivers/codex.js +95 -30
  101. package/dist-server/server/drivers/mistral.js +115 -0
  102. package/dist-server/server/drivers/openai-chat.js +155 -32
  103. package/dist-server/server/drivers/openai-compat.js +1 -0
  104. package/dist-server/server/drivers/permission-command.js +35 -0
  105. package/dist-server/server/drivers/phone-proxy.js +47 -2
  106. package/dist-server/server/drivers/pi.js +75 -4
  107. package/dist-server/server/drivers/prompt-split.js +87 -0
  108. package/dist-server/server/fleet.js +1 -1
  109. package/dist-server/server/group-thread-usage.js +103 -0
  110. package/dist-server/server/harness/registry.js +1 -0
  111. package/dist-server/server/hosted-models.js +1 -1
  112. package/dist-server/server/index.js +1385 -322
  113. package/dist-server/server/local-computer.js +72 -23
  114. package/dist-server/server/managed-desktop.js +1 -1
  115. package/dist-server/server/mcp-bridge.js +6 -6
  116. package/dist-server/server/new-bot-defaults.js +115 -0
  117. package/dist-server/server/org-library.js +1051 -0
  118. package/dist-server/server/package-export.js +627 -39
  119. package/dist-server/server/package-import.js +458 -0
  120. package/dist-server/server/package-parts.js +87 -0
  121. package/dist-server/server/peer-approval.js +16 -7
  122. package/dist-server/server/peer-roster.js +10 -0
  123. package/dist-server/server/presets.js +492 -0
  124. package/dist-server/server/provider-key-check.js +3 -2
  125. package/dist-server/server/published-teams.js +80 -0
  126. package/dist-server/server/room-handoffs.js +19 -3
  127. package/dist-server/server/routes/bot-presets.js +25 -0
  128. package/dist-server/server/routine-requests.js +6 -3
  129. package/dist-server/server/routines.js +41 -1
  130. package/dist-server/server/section-context.js +10 -0
  131. package/dist-server/server/skills.js +34 -61
  132. package/dist-server/server/steer-queue.js +3 -2
  133. package/dist-server/server/store.js +259 -15
  134. package/dist-server/server/surface.js +10 -4
  135. package/dist-server/server/system-prompt.js +12 -4
  136. package/dist-server/server/team-backup.js +6 -2
  137. package/dist-server/server/team-computers.js +10 -0
  138. package/dist-server/server/team-library.js +6 -2
  139. package/dist-server/server/team-manifest.js +4 -3
  140. package/dist-server/server/team-setup-requests.js +16 -5
  141. package/dist-server/server/thread-events.js +17 -1
  142. package/dist-server/server/turn-context.js +2 -0
  143. package/dist-server/server/turn-watchdog.js +12 -3
  144. package/dist-server/server/usage-ledger.js +12 -1
  145. package/dist-server/server/vps-computer.js +139 -51
  146. package/dist-server/server/vps-container-mcp.js +7 -0
  147. package/dist-server/server/vps-ssh.js +36 -7
  148. package/dist-server/server/workspace-backup-policy.js +12 -0
  149. package/dist-server/server/workspace-backup.js +2 -2
  150. package/dist-server/server/workspace.js +1 -1
  151. package/dist-server/shared/approval-mode.js +1 -1
  152. package/dist-server/shared/ask-question.js +188 -12
  153. package/dist-server/shared/bot-profile.js +15 -0
  154. package/dist-server/shared/command-allowlist.js +1 -0
  155. package/dist-server/shared/credential-request.js +1 -1
  156. package/dist-server/shared/package-format.js +967 -0
  157. package/dist-server/shared/skill-md.js +64 -0
  158. package/dist-server/shared/team-backup.js +7 -0
  159. package/dist-server/shared/wire.js +4 -0
  160. package/dist-server/vps-container-mcp.js +1129 -170
  161. package/enterprise/README.md +1 -1
  162. package/package.json +1 -1
  163. package/dist/assets/channel-BXFkgus9.js +0 -1
  164. package/dist/assets/index-BGgqlWPJ.css +0 -1
  165. package/dist/assets/index-BtHo8ZKl.js +0 -577
  166. 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";
@@ -26,6 +26,7 @@ import { approvalModeFor, supportsApprovalMode, modelSwitchNeedsAsk, isEmergency
26
26
  import { escapeAttribute } from "../src/lib/composer-attachments.js";
27
27
  import { CREDENTIAL_TARGETS, credentialResumeOutcome, credentialIsConfigured, isReusableCredentialRequest, 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, 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,8 +50,9 @@ 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, 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
57
  import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerRuntimeStatus, localVmRecreatableOnDemand, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, } from "./container-computer.js";
56
58
  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";
@@ -59,6 +61,7 @@ import { ComputerControl } from "./computer-control.js";
59
61
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
60
62
  import { registerEnginesBinDir } from "./engine-install.js";
61
63
  import { appendUsage, parseUsageRange, readUsage, summarizeUsage, usageCsv, USAGE_GROUPINGS, flushUsageLedger } from "./usage-ledger.js";
64
+ import { GroupUsageReader } from "./group-thread-usage.js";
62
65
  import { ledgerCost } from "./model-prices.js";
63
66
  import { checkProviderKey, PROVIDER_KEY_KINDS } from "./provider-key-check.js";
64
67
  import { assertWithinBudget, noteSpend, spendAlertText, spendState, takeSpendAlert } from "./spend.js";
@@ -92,30 +95,32 @@ import { ProviderRegistry } from "./harness/registry.js";
92
95
  import { ManagedDesktopProviders } from "./managed-desktop.js";
93
96
  import { computerKindForResource, ManagedDesktopPolicy } from "./managed-policy.js";
94
97
  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";
98
+ import { selectDefaultModelSelection, withNewBotEffort } from "./default-model-selection.js";
99
+ import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, peerApprovalFailure, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
97
100
  import { peerProvenanceNote, withPeerProvenance } from "./peer-provenance.js";
98
101
  import { decideRoomPost, emptyRoomPostBudget } from "./room-post-budget.js";
99
- import { isProjectEmoji, mentionedBots, roomResponders, sectionKey, Store, titleFromLlm, toWireTask, } from "./store.js";
102
+ import { isProjectEmoji, mentionedBots, parseConnectorTools, roomResponders, sectionKey, Store, titleFromLlm, toWireTask, } from "./store.js";
100
103
  import * as tts from "./tts/index.js";
101
104
  import { narrateTool, toUtterances } from "./tts/speech-text.js";
102
- import { buildRecoveryText, buildTurnContext, engineIsFresh, peerMessageText } from "./turn-context.js";
105
+ import { buildRecoveryText, buildTurnContext, engineIsFresh, NATIVELY_REPLAYING_DRIVER_KINDS, peerMessageText } from "./turn-context.js";
103
106
  import { Handoffs, handedStateUsable, recordHanded, renderUnseen, sessionStart, unseenMessages, withUnseenMessages } from "./delta-context.js";
104
107
  import { extractTurnImages } from "./turn-images.js";
105
108
  import { TurnWatchdog } from "./turn-watchdog.js";
106
109
  import { TurnResources, workspaceResource } from "./turn-resources.js";
107
110
  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";
111
+ import { listMemoryTopics, readMemoryFile, readMemoryTopic, writeMemoryFile, writeMemoryTopic } from "./workspace.js";
109
112
  import { MEMORY_INDEX, MemoryStoreError, memoryCapacity, memoryOverview, openMemoryLocation, readMemoryDoc, } from "./memory-store.js";
110
113
  import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, flushMemoryJournal, journalMemoryDelete, journalMemoryWrite, readMemoryJournal, revertMemoryChange, } from "./memory-journal.js";
111
114
  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";
115
+ import { applyStagedSkillWrite, applySkillWriteWithReceipt, getStagedSkillWrite, installOrgSkill, installSkill, parseSkillMd, scanSkillText, listSkills, listStagedSkillWrites, isSkillName, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillPackageStamps, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
113
116
  import { fetchSkillFromSource } from "./skill-fetch.js";
117
+ import { pickBotName } from "./names.js";
118
+ import { botSkillTemplateSchema, newBotDefaultsSchema, resolveBotCreationDefaults } from "./new-bot-defaults.js";
114
119
  import { expandLearnTurnText, learnSource } from "./skill-learn.js";
115
120
  import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
116
121
  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";
122
+ import { buildSystemPrompt, userProfileSystemPrompt, computerPrompt, COMPOSIO_PROMPT, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, } from "./system-prompt.js";
123
+ import { readCuaConnection, readCuaUnavailableReason, gatedLocalComputer } from "./local-computer.js";
119
124
  import { discoverExistingPerBotLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
120
125
  import { LocalVmIdleTimer } from "./local-vm-idle.js";
121
126
  import { LocalVmLeasePool } from "./local-vm-lease.js";
@@ -139,9 +144,8 @@ import { flushAllProfileHistory, flushProfileHistory, readHistory, recordProfile
139
144
  import { fetchBotDirectory, matchDirectoryBots } from "./bot-directory.js";
140
145
  import { scoutProject, suggestTeam } from "./project-scout.js";
141
146
  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";
147
+ import { BOT_PACKAGE_MAX_SKILLS, isBotPackage, packageSummary, parsePackageDocument, renderBotPackageMarkdown } from "./bot-package.js";
148
+ import { createTeamManifest, parseTeamManifest } from "./team-manifest.js";
145
149
  import { readThreadEvents } from "./thread-events.js";
146
150
  import { bindThreadLogCapProvider } from "./thread-log-rotation.js";
147
151
  import { listenWebhookIngress, webhookCredential } from "./webhook-ingress.js";
@@ -150,13 +154,17 @@ import { WebhookManager } from "./webhooks.js";
150
154
  import { SPAWNED_PROXIES } from "./proxy-paths.js";
151
155
  import { loadBundledSkills, loadUserSkills, mergeSkills, renderSkillInstructions, selectBundledSkills } from "./skill-library.js";
152
156
  import { installedPlaybookInstructions } from "./installed-playbooks.js";
153
- import { createBotPackageExport } from "./package-export.js";
157
+ import { createBotPackageExport, createLibraryPackageExport, createTeamPackageExport, picture as sharedPicture, TeamExportError } from "./package-export.js";
158
+ import { importPackageDocument, importTeamManifest, PackageImportError } from "./package-import.js";
159
+ import { applyPresetToBot, createPresetStore, DEFAULTS_PRESET_KEY, PRESET_UNAVAILABLE_MESSAGE, presetFromDefaults, presetOffered, readOrgInstallStatuses, readPublishedLibrary, writePublishedLibrary, } from "./presets.js";
160
+ import { readPublishedTeam, writePublishedTeam } from "./published-teams.js";
161
+ import { OrgLibrary } from "./org-library.js";
154
162
  import { createTeamBackup, importTeamBackup } from "./team-backup.js";
155
163
  import { MAX_TEAM_BACKUP_BYTES } from "../shared/team-backup.js";
156
164
  import { shouldMountLocalComputer } from "./local-routing.js";
157
165
  import { autoLocalVmAttachable } from "./container-computer.js";
158
166
  import { startAutoVmClaim } from "./auto-vm-claims.js";
159
- import { computerFreeText, computerStillBusyText, computerWaitEndedText, computerWaitingText } from "./computer-wait.js";
167
+ import { computerFreeAfterText, computerStillBusyText, computerStoppedWaitingText, computerWaitingText } from "./computer-wait.js";
160
168
  import { modelContextWindow } from "./model-context-window.js";
161
169
  import { parseSurface, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
162
170
  import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
@@ -183,6 +191,7 @@ import { PHONE_SECRET_PROTOCOL_VERSION, PhoneSecretBridge, PhoneSecretError, Pho
183
191
  import { json, onJsonBody, parsedBodyOf, readBody } from "./harness/http.js";
184
192
  import { ROUTES, dispatchRoutes } from "./routes/table.js";
185
193
  import { createHostedSlackRoutes } from "./routes/hosted-slack.js";
194
+ import { createBotPresetRoutes } from "./routes/bot-presets.js";
186
195
  const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799);
187
196
  const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1);
188
197
  // Behind a proxy or tunnel, the base URL senders should use (docs/self-hosting.md).
@@ -248,6 +257,7 @@ const sessions = new SessionRegistry({
248
257
  const sharedComputers = new SharedComputers(id => sessions.isLive(id));
249
258
  // Who each thread is for, when a signed-in person can be named (server-private).
250
259
  const threadStarters = new ThreadStarters(join(DATA_DIR, "thread-starters.json"));
260
+ const commandAllowlist = new CommandAllowlistStore(join(DATA_DIR, "command-allowlist.json"));
251
261
  const SESSION_COOKIE = sessionCookieName(PORT, ENVIRONMENT_ID);
252
262
  const HOSTED_WORKSPACE = hostedWorkspaceConfigured();
253
263
  let workspaceAccess = null;
@@ -416,6 +426,28 @@ function cardAnswerRefusal(auth, threadId, requestId, behavior) {
416
426
  return null;
417
427
  return "Only the person who started this conversation or sent this request, or a workspace admin, can answer this card.";
418
428
  }
429
+ function canManageCommandAllowlist(auth) {
430
+ return auth.kind === "loopback" ? auth.trust !== "service" : auth.scopes.includes("admin");
431
+ }
432
+ const COMMAND_ALLOWLIST_DRIVERS = new Set([
433
+ "claudeAgent", "codex", "antigravityAgent", "grokAgent", "opencodeGo", "kimiAgent",
434
+ "droidAgent", "cursorAgent", "qwenAgent", "geminiAgent", "hermesAgent", "customAcp",
435
+ ]);
436
+ function commandAllowlistResponse(bot) {
437
+ const instance = registry.get(bot.modelSelection.instanceId);
438
+ const task = store.taskByThread(bot.id, bot.threadId);
439
+ let cwd = task?.cwd !== undefined ? task.cwd : bot.cwd ?? join(realpathSync(DATA_DIR), "task-workspaces", bot.id, bot.threadId);
440
+ if (cwd && existsSync(cwd))
441
+ cwd = realpathSync(cwd);
442
+ return {
443
+ rules: commandAllowlist.list(bot.id),
444
+ supported: COMMAND_ALLOWLIST_DRIVERS.has(instance?.driverKind ?? ""),
445
+ context: {
446
+ providerInstanceId: bot.modelSelection.instanceId,
447
+ cwd,
448
+ },
449
+ };
450
+ }
419
451
  function decisionActorFor(auth) {
420
452
  if (auth.kind === "loopback")
421
453
  return auth.trust === "service" ? { kind: "worker" } : { kind: "loopback" };
@@ -655,7 +687,7 @@ function adminAuditPlan(method, path) {
655
687
  return null;
656
688
  const plan = {
657
689
  config: requiredScope(method, path, { sharedComputers: sharedComputersEnabled(cfg) }) === "admin",
658
- createsBots: method === "POST" && (path === "/api/bots" || path === "/api/teams/import"),
690
+ createsBots: method === "POST" && (path === "/api/bots" || path === "/api/teams/import" || path === "/api/org-library/add"),
659
691
  createsWebhook: method === "POST" && path === "/api/webhooks",
660
692
  pairing: method === "POST" && path === "/api/auth/pairing",
661
693
  };
@@ -994,6 +1026,24 @@ utilityParentPort?.on("message", event => {
994
1026
  }
995
1027
  utilityParentPort.postMessage({ type: "openmausbot:managed-desktop-result", requestId, ok });
996
1028
  });
1029
+ // The organization library (server/org-library.ts): Electron relays the
1030
+ // verified catalog; the runtime swaps it and acknowledges at once, then
1031
+ // reconciles and reports on its own. A relay that arrives before the library
1032
+ // exists waits for it (the acknowledgement does not).
1033
+ let orgLibrary = null;
1034
+ let pendingOrgLibraryRelay = null;
1035
+ utilityParentPort?.on("message", event => {
1036
+ const message = event.data;
1037
+ if (message?.type !== "openmausbot:managed-library")
1038
+ return;
1039
+ const requestId = typeof message.requestId === "string" && message.requestId.length <= 100 ? message.requestId : undefined;
1040
+ let ok = true;
1041
+ if (orgLibrary)
1042
+ ok = orgLibrary.applyRelay(message.library ?? null).ok;
1043
+ else
1044
+ pendingOrgLibraryRelay = { library: message.library ?? null };
1045
+ utilityParentPort.postMessage({ type: "openmausbot:managed-desktop-result", requestId, ok });
1046
+ });
997
1047
  // Only Electron owns this port. There is deliberately no HTTP equivalent or
998
1048
  // config patch for its organization identity, endpoint, or model capability.
999
1049
  utilityParentPort?.on("message", event => {
@@ -1215,6 +1265,9 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation, r
1215
1265
  // The shared-computer tools are advertised only while the workspace
1216
1266
  // gate is on; the routes behind them refuse regardless.
1217
1267
  OMB_SHARED_COMPUTERS_ENABLED: sharedComputersEnabled(cfg) ? "1" : "0",
1268
+ // Same capability rule for voice: the tool is offered only when this
1269
+ // bot can actually speak, and the route re-checks on every call.
1270
+ OMB_VOICE_NOTES: tts.voiceReady(cfg, (botForThread(botId, threadId) ?? store.bot(botId))?.voice) ? "1" : "0",
1218
1271
  },
1219
1272
  };
1220
1273
  }
@@ -1236,6 +1289,8 @@ function hooksIntegration(botId, threadId, generation) {
1236
1289
  }
1237
1290
  class DirectTurnSetupCancelled extends Error {
1238
1291
  }
1292
+ class ComputerWaitGaveUp extends Error {
1293
+ }
1239
1294
  const directTurnDispatchClaims = new Map();
1240
1295
  const directTurnGenerationByThread = new Map();
1241
1296
  // Only the latest direct request per thread is retained. A fresh user turn
@@ -1269,6 +1324,10 @@ function settleDirectFollowup(generation) {
1269
1324
  // Keep the exact provider/profile settings that own a running conversation.
1270
1325
  // Selecting another thread or changing a default must not retarget its tools.
1271
1326
  const directTurnBots = new Map();
1327
+ /** Prompt bytes for the turn in flight, by thread, until the settled turn
1328
+ * books them next to its token usage: stable bytes ride the cacheable
1329
+ * prefix, volatile bytes are the part that legitimately changes. */
1330
+ const turnPromptBytes = new Map();
1272
1331
  let providerFleetReloading = false;
1273
1332
  const turnResources = new TurnResources();
1274
1333
  const sharedComputerControl = new SharedComputerControl(turnResources, () => store.bots.some(bot => botComputerControlSnapshot(bot.id).held));
@@ -1312,7 +1371,49 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
1312
1371
  // Who holds the desktop, as the chip and the give-up error name them: a
1313
1372
  // bot running a titled thread, or a room. Read once, when the wait begins.
1314
1373
  let holder;
1374
+ let holderThreadRef;
1315
1375
  const deadline = Date.now() + GROUP_GOAL_WAIT_MAX_MS;
1376
+ // The wait is history the transcript keeps (#1647): the waiting chip stays
1377
+ // exactly as written, a resolution line is appended beside it, and the
1378
+ // turn.wait_* events carry the same facts for the inspector log.
1379
+ let waitedSince;
1380
+ let waitEnded = false;
1381
+ const waitEventBase = () => {
1382
+ const selection = store.botByThread(owner.threadId)?.modelSelection;
1383
+ const instance = selection ? registry.get(selection.instanceId) : null;
1384
+ return {
1385
+ eventId: newId(),
1386
+ provider: instance?.driverKind ?? "harness",
1387
+ ...(instance && selection ? { providerInstanceId: selection.instanceId } : {}),
1388
+ threadId: owner.threadId,
1389
+ createdAt: new Date().toISOString(),
1390
+ };
1391
+ };
1392
+ const endWait = (outcome) => {
1393
+ if (!waitingMessage || waitEnded)
1394
+ return;
1395
+ waitEnded = true;
1396
+ const waitedMs = waitedSince === undefined ? 0 : Date.now() - waitedSince;
1397
+ const text = outcome === "acquired"
1398
+ ? computerFreeAfterText(holder, waitedMs)
1399
+ : outcome === "gave_up"
1400
+ ? computerStillBusyText(holder, waitedMs)
1401
+ : computerStoppedWaitingText(holder, waitedMs);
1402
+ store.appendMessage(owner.threadId, {
1403
+ role: "bot", kind: "activity",
1404
+ ...(outcome === "gave_up" ? { turnSucceeded: false } : {}),
1405
+ tool: { name: text, ok: outcome !== "gave_up" },
1406
+ ...(holderThreadRef ? { threadRef: holderThreadRef } : {}),
1407
+ });
1408
+ bus.publish({
1409
+ ...waitEventBase(),
1410
+ type: "turn.wait_ended",
1411
+ resource,
1412
+ ...(holder ? { holder } : {}),
1413
+ waitedMs,
1414
+ outcome,
1415
+ });
1416
+ };
1316
1417
  try {
1317
1418
  while (true) {
1318
1419
  if (!active())
@@ -1327,22 +1428,33 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
1327
1428
  holder = holderBot
1328
1429
  ? { name: holderBot.name, ...(holderTask?.title ? { task: holderTask.title } : {}) }
1329
1430
  : holderRoom ? { name: holderRoom.name } : undefined;
1431
+ holderThreadRef = holderBot && holderTask?.title
1432
+ ? { botId: holderBot.id, threadId: holderTask.threadId, title: holderTask.title }
1433
+ : undefined;
1330
1434
  waitingMessage = store.appendMessage(owner.threadId, {
1331
1435
  role: "bot", kind: "activity",
1332
1436
  tool: { name: computerWaitingText(holder) },
1333
- ...(holderBot && holderTask ? { threadRef: { botId: holderBot.id, threadId: holderTask.threadId, title: holderTask.title } } : {}),
1437
+ ...(holderThreadRef ? { threadRef: holderThreadRef } : {}),
1334
1438
  });
1439
+ waitedSince = Date.now();
1440
+ bus.publish({
1441
+ ...waitEventBase(),
1442
+ type: "turn.wait_started",
1443
+ resource,
1444
+ ...(holder ? { holder } : {}),
1445
+ });
1446
+ }
1447
+ if (Date.now() >= deadline) {
1448
+ endWait("gave_up");
1449
+ throw new ComputerWaitGaveUp(computerStillBusyText(holder, GROUP_GOAL_WAIT_MAX_MS));
1335
1450
  }
1336
- if (Date.now() >= deadline)
1337
- throw new Error(computerStillBusyText(holder, GROUP_GOAL_WAIT_MAX_MS));
1338
1451
  await new Promise(resolve => setTimeout(resolve, 100));
1339
1452
  }
1340
1453
  }
1341
1454
  finally {
1342
- if (waitingMessage)
1343
- store.patchMessage(owner.threadId, waitingMessage.id, {
1344
- tool: { name: active() && turnResources.owns(resource, owner) ? computerFreeText() : computerWaitEndedText(), ok: true },
1345
- });
1455
+ // Never patch the waiting chip over: the queue position this turn held
1456
+ // stays visible beside what the wait came to.
1457
+ endWait(active() && turnResources.owns(resource, owner) ? "acquired" : "stopped");
1346
1458
  }
1347
1459
  turnResourceOwners.set(owner.threadId, owner);
1348
1460
  turnComputerResources.set(owner.threadId, { owner, resource });
@@ -1457,15 +1569,15 @@ async function interruptAllDirectThreads(botId) {
1457
1569
  }
1458
1570
  const retiredProviderTurns = new RetiredTurnRegistry();
1459
1571
  const pendingCancelledProviderHandshakes = new PendingTurnCancellations();
1460
- const generatedImagesByTurn = new Map();
1461
- function generatedImageTurnKey(threadId, turnId) {
1572
+ const turnAttachmentsByTurn = new Map();
1573
+ function turnAttachmentKey(threadId, turnId) {
1462
1574
  return `${threadId}:${turnId ?? "active"}`;
1463
1575
  }
1464
- function purgeGeneratedImagesForThread(threadId) {
1465
- for (const [key, attachments] of generatedImagesByTurn) {
1576
+ function purgeTurnAttachmentsForThread(threadId) {
1577
+ for (const [key, attachments] of turnAttachmentsByTurn) {
1466
1578
  if (!key.startsWith(`${threadId}:`))
1467
1579
  continue;
1468
- generatedImagesByTurn.delete(key);
1580
+ turnAttachmentsByTurn.delete(key);
1469
1581
  for (const attachment of attachments) {
1470
1582
  deleteAttachment(attachment.path);
1471
1583
  }
@@ -1479,13 +1591,13 @@ function clearCancelledProviderHandshake(threadId, ownerId) {
1479
1591
  }
1480
1592
  function retireProviderTurn(turnId) {
1481
1593
  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) {
1594
+ // A stopped/replaced turn is never folded again. Delete only staged files
1595
+ // (images, voice notes) that were parked for that exact provider turn so
1596
+ // unattached output does not accumulate invisibly on disk.
1597
+ for (const [key, attachments] of turnAttachmentsByTurn) {
1486
1598
  if (!key.endsWith(`:${turnId}`))
1487
1599
  continue;
1488
- generatedImagesByTurn.delete(key);
1600
+ turnAttachmentsByTurn.delete(key);
1489
1601
  for (const attachment of attachments) {
1490
1602
  deleteAttachment(attachment.path);
1491
1603
  }
@@ -1636,8 +1748,26 @@ export function browserEngineSummary() {
1636
1748
  ? { kind: "engine", version: status.version, ...progress }
1637
1749
  : { kind: "unavailable", reason: status.reason, installable: status.installable, ...progress };
1638
1750
  }
1639
- function phoneIntegration() {
1640
- const env = { ...AGENTS_NODE_FLAG };
1751
+ function phoneIntegration(botId, threadId, generation) {
1752
+ // The phone is claimed lazily, at the first tools/call the engine makes
1753
+ // through this proxy (/api/internal/phone/claim): trigger-term matching
1754
+ // only decides whether the tools are mounted, so a conversation that
1755
+ // merely mentions "android" holds nothing while it runs.
1756
+ const token = mintInternalCapability({
1757
+ botId,
1758
+ threadId,
1759
+ generation,
1760
+ depth: 0,
1761
+ kind: "phone",
1762
+ skillAuthoring: false,
1763
+ createdBots: 0,
1764
+ openedThreads: 0,
1765
+ });
1766
+ const env = {
1767
+ ...AGENTS_NODE_FLAG,
1768
+ OMB_PHONE_TOKEN: token,
1769
+ OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`,
1770
+ };
1641
1771
  if (process.env.OMB_ADB_PATH)
1642
1772
  env.OMB_ADB_PATH = process.env.OMB_ADB_PATH;
1643
1773
  if (process.env.OMB_RESOURCES_PATH)
@@ -1933,6 +2063,113 @@ function collectExportSkills(bots, names) {
1933
2063
  }
1934
2064
  return byBot;
1935
2065
  }
2066
+ /** Skills on one team's bots for a whole-team share. "all" skips a skill
2067
+ * whose stored SKILL.md changed (listed to the person); an explicit list
2068
+ * refuses instead, because the person asked for that exact skill. */
2069
+ /** The team's skills for a whole-team export. `available` (every skill name
2070
+ * on the team's bots) comes back on refusals too: the Share dialog draws its
2071
+ * skill choices from it, so a refused choice can always be changed. */
2072
+ function collectTeamSkills(bots, selection) {
2073
+ const all = selection === undefined || selection === "all";
2074
+ const available = [...new Set(bots.flatMap((bot) => listSkills(bot.id).map((skill) => skill.name)))].sort();
2075
+ if (!all && (!Array.isArray(selection) || selection.some((name) => typeof name !== "string" || !isSkillName(name)))) {
2076
+ return { ok: false, error: "skills must be \"all\" or a list of skill names", available };
2077
+ }
2078
+ const chosen = new Set(all ? available : selection);
2079
+ const unknown = [...chosen].find((name) => !available.includes(name));
2080
+ if (unknown)
2081
+ return { ok: false, error: `This team has no skill named "${unknown}"`, available };
2082
+ const skillsByBot = new Map();
2083
+ const skipped = [];
2084
+ for (const bot of bots) {
2085
+ const assigned = [];
2086
+ for (const listing of listSkills(bot.id)) {
2087
+ if (!chosen.has(listing.name))
2088
+ continue;
2089
+ const instructions = readSkillFile(bot.id, listing.name);
2090
+ if (instructions === null) {
2091
+ if (!all)
2092
+ return { ok: false, error: `Skill "${listing.name}" changed or is unavailable and cannot be shared safely`, available };
2093
+ const part = `skills[${listing.name}]`;
2094
+ if (!skipped.some((skip) => skip.part === part))
2095
+ skipped.push({ part, reason: "skill_changed" });
2096
+ continue;
2097
+ }
2098
+ assigned.push({
2099
+ name: listing.name,
2100
+ description: listing.description,
2101
+ ...(listing.source ? { source: listing.source } : {}),
2102
+ ...(listing.license ? { license: listing.license } : {}),
2103
+ ...(listing.compatibility ? { compatibility: listing.compatibility } : {}),
2104
+ instructions,
2105
+ enabled: listing.enabled,
2106
+ });
2107
+ }
2108
+ if (assigned.length)
2109
+ skillsByBot.set(bot.id, assigned);
2110
+ }
2111
+ return { ok: true, all, skillsByBot, skipped, available };
2112
+ }
2113
+ /** A bot's starter notes: MEMORY.md and its topic files. Daily logs never
2114
+ * travel (they are closer to chat history than to notes). */
2115
+ function starterNotes(botId) {
2116
+ const notes = [];
2117
+ const index = readMemoryFile(botId).text;
2118
+ if (index.trim())
2119
+ notes.push({ path: "MEMORY.md", text: index });
2120
+ for (const topic of listMemoryTopics(botId).sort((a, b) => a.name.localeCompare(b.name))) {
2121
+ const text = readMemoryTopic(botId, topic.name);
2122
+ if (text?.trim())
2123
+ notes.push({ path: `memory/${topic.name}`, text });
2124
+ }
2125
+ return notes;
2126
+ }
2127
+ /** The importer's view of this installation. Pictures become ordinary local
2128
+ * avatars, connection slots ordinary (switched-off) MCP servers, starter
2129
+ * notes ordinary memory writes. */
2130
+ function packageImportDeps(selection) {
2131
+ return {
2132
+ store,
2133
+ routines: routines,
2134
+ skills: { install: installSkill, setEnabled: setSkillEnabled, installOrg: installOrgSkill },
2135
+ memory: { writeIndex: writeMemoryFile, writeTopic: writeMemoryTopic },
2136
+ mcp: { servers: () => cfg.mcpServers ?? {}, refusal: mcpPolicyRefusal, persist: persistMcpServers },
2137
+ sections: { writeBrief: (section, text) => void writeSectionContext(section, text) },
2138
+ images: {
2139
+ save: (bytes, mime) => {
2140
+ const avatarUrl = botAvatarUrlFromStoredPath(saveImage(Buffer.from(bytes), mime).path);
2141
+ if (!avatarUrl)
2142
+ throw new Error("A picture in this package could not be stored");
2143
+ return avatarUrl;
2144
+ },
2145
+ },
2146
+ broadcast: (event) => event.kind === "bot"
2147
+ ? broadcast({ kind: "bot", bot: publicBot(event.bot) })
2148
+ : broadcast({ kind: "group", group: publicGroupState(event.group) }),
2149
+ defaultSelection: () => selection,
2150
+ presets: presetStore,
2151
+ };
2152
+ }
2153
+ /** "Include my New bot defaults as a preset" from an export body: the saved
2154
+ * defaults through the allowlist, with the picture the dialog prepared. */
2155
+ function defaultsPresetFor(body, includeNotes) {
2156
+ const skipped = [];
2157
+ let avatar = null;
2158
+ if (body.presetAvatar !== undefined && body.presetAvatar !== null && body.presetAvatar !== "") {
2159
+ const offered = sharedPicture(body.presetAvatar);
2160
+ if (typeof offered === "string")
2161
+ skipped.push({ part: `presets[${DEFAULTS_PRESET_KEY}].bot.appearance.avatar`, reason: offered });
2162
+ else
2163
+ avatar = offered;
2164
+ }
2165
+ const built = presetFromDefaults(cfg.newBotDefaults, {
2166
+ name: typeof body.presetName === "string" ? body.presetName : undefined,
2167
+ description: typeof body.presetDescription === "string" ? body.presetDescription : undefined,
2168
+ includeNotes,
2169
+ avatar,
2170
+ });
2171
+ return { preset: built.value, skipped: [...skipped, ...built.skipped] };
2172
+ }
1936
2173
  function checkedGroupResponder(value, memberIds) {
1937
2174
  if (!value || typeof value !== "object" || Array.isArray(value))
1938
2175
  return null;
@@ -1970,7 +2207,9 @@ function checkedMemberIds(value) {
1970
2207
  return { ok: true, memberIds };
1971
2208
  }
1972
2209
  let bootSelection = { instanceId: "", model: "" };
1973
- const store = new Store(() => bootSelection);
2210
+ /** Preset bots for New bot, from shared files and the organization (presets.ts). */
2211
+ const presetStore = createPresetStore();
2212
+ const store = new Store(() => bootSelection, (selection) => withNewBotEffort(selection, cfg.newBots?.effort, registry.get(selection.instanceId)?.adapter.capabilities.effortLevels));
1974
2213
  const teamComputers = new TeamComputers(join(DATA_DIR, "team-computers.json"), ENVIRONMENT_ID);
1975
2214
  let followupsReady = false;
1976
2215
  const sendSequencer = new SendSequencer();
@@ -2008,9 +2247,17 @@ if (browserCleanupReferencesReconciled)
2008
2247
  * than the desktop window did. Stripped here rather than at each call site
2009
2248
  * so a new broadcast cannot forget. */
2010
2249
  let activeCoordinationForThread = (_threadId) => false;
2011
- const wireTask = (task) => ({ ...toWireTask(task), waitingForTeammates: activeCoordinationForThread(task.threadId) && !task.busy });
2250
+ const wireTask = (task) => {
2251
+ // Time-based snoozes heal on read against the server clock — no client
2252
+ // timer, no device skew. The 0 sentinel ("until new activity") is not a
2253
+ // time and survives reads; only a wake event in the store clears it.
2254
+ const { snoozedUntil, ...base } = toWireTask(task);
2255
+ const coordinated = { ...base, waitingForTeammates: activeCoordinationForThread(task.threadId) && !task.busy };
2256
+ const asleep = snoozedUntil === 0 || (snoozedUntil !== undefined && snoozedUntil > Date.now());
2257
+ return asleep ? { ...coordinated, snoozedUntil } : coordinated;
2258
+ };
2012
2259
  const wireBot = (bot) => {
2013
- const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, ...rest } = bot;
2260
+ const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, ...rest } = bot;
2014
2261
  // An elevated selection is inert until the desktop confirms its exact
2015
2262
  // private reply. Every ordinary client sees the effective Ask state during
2016
2263
  // that two-phase window, never a grant that may still roll back.
@@ -2023,7 +2270,7 @@ const wireBot = (bot) => {
2023
2270
  /** The correlated private response carries the requested value so Electron
2024
2271
  * can validate it before sending the confirmation that makes it effective. */
2025
2272
  const wireTrustedApprovalBot = (bot) => {
2026
- const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, ...rest } = bot;
2273
+ const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, ...rest } = bot;
2027
2274
  return { ...rest, approvalMode: approvalModeFor(rest), avatarUrl: rest.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
2028
2275
  };
2029
2276
  /** A settings-based preview, not a receipt of a dispatched turn. No
@@ -2046,7 +2293,7 @@ function previewSystemPrompt(bot) {
2046
2293
  const computerPromptKind = previewComputer === "vm"
2047
2294
  ? caps?.computerMcp ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared" : null
2048
2295
  : previewComputer === "cloud"
2049
- ? instance?.driverKind === "boxAgent" ? "box-agent" : caps?.computerMcp ? bot.cloudBackend === "vps" ? "vps" : "box" : null
2296
+ ? instance?.driverKind === "boxAgent" ? "box-agent" : caps?.computerMcp ? bot.cloudBackend === "vps" ? "vps" : caps.cloudComputerMcp ? "box-chat" : "box" : null
2050
2297
  : previewComputer === "local"
2051
2298
  ? caps?.localComputerMcp ? "local" : null
2052
2299
  : null;
@@ -2070,6 +2317,7 @@ function previewSystemPrompt(bot) {
2070
2317
  });
2071
2318
  const privateWorkspace = instance && supportsWorkspaceFiles(instance.driverKind);
2072
2319
  const built = buildSystemPrompt(persona, bot.soul ?? "", [
2320
+ { id: "user-profile", label: "About the user", text: userProfileSystemPrompt(cfg.profile) },
2073
2321
  {
2074
2322
  id: "setup",
2075
2323
  label: "Setup",
@@ -2889,7 +3137,7 @@ function roomHandoffProblem(node, parent) {
2889
3137
  // cannot silently give a specialist access to another team's transcript.
2890
3138
  const outsideSection = (room, speaker) => room.memberIds.some(id => {
2891
3139
  const member = store.bot(id);
2892
- return member && !canAccessTeam(speaker, member.section);
3140
+ return member && !canAccessTeam(speaker, member.section) && !coordinatorSupervises(member, speaker);
2893
3141
  });
2894
3142
  if (group && outsideSection(group, bot))
2895
3143
  return "Destination room includes a member outside the agent's section";
@@ -2900,7 +3148,10 @@ function roomHandoffProblem(node, parent) {
2900
3148
  const source = parent.groupId ? store.group(parent.groupId) : undefined;
2901
3149
  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
3150
  return "Source membership or task was removed";
2903
- if (!canAccessTeam(from, bot.section) || (source && outsideSection(source, from)))
3151
+ // A specialist may address its supervisor in their shared conversation;
3152
+ // supervision alone never grants a direct chat or another room's access.
3153
+ const sharedSupervisor = source && group && source.id === group.id && parent.threadId === node.threadId && coordinatorSupervises(bot, from);
3154
+ if ((!canAccessTeam(from, bot.section) && !sharedSupervisor) || (source && outsideSection(source, from)))
2904
3155
  return "Room work cannot cross the sender's section boundary";
2905
3156
  if (source && group && source.id === group.id && parent.threadId !== node.threadId)
2906
3157
  return "Same-room work must stay in the originating conversation";
@@ -3027,11 +3278,12 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
3027
3278
  const result = {};
3028
3279
  const turnText = coordinationTurnText(node, resumed);
3029
3280
  const systemInstructions = coordinationSystemInstructions();
3030
- if (!resumed && !store.messagesFor(node.threadId).some(m => m.roomRequest?.id === node.id && m.roomRequest.phase === "request")) {
3281
+ const request = roomHandoffs.sharedRequest(node);
3282
+ if (!resumed && !store.messagesFor(node.threadId).some(m => m.roomRequest?.id === request.id && m.roomRequest.phase === "request")) {
3031
3283
  store.appendMessage(node.threadId, { role: "bot", kind: "text",
3032
- roomRequest: { id: node.id, phase: "request" },
3284
+ roomRequest: { id: request.id, phase: "request" },
3033
3285
  from: sender ? { botId: sender.id, name: sender.name, color: sender.color } : undefined,
3034
- text: `@${bot.name} ${node.text}`,
3286
+ text: `${request.botIds.map(id => `@${store.bot(id)?.name ?? "Teammate"}`).join(" ")} ${node.text}`,
3035
3287
  });
3036
3288
  }
3037
3289
  markInternalTurn(node.threadId);
@@ -3108,8 +3360,20 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
3108
3360
  },
3109
3361
  }, Date.now, roomHandoffLimits(cfg));
3110
3362
  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)) };
3363
+ const groupUsageReader = new GroupUsageReader(DATA_DIR);
3364
+ try {
3365
+ groupUsageReader.refresh();
3366
+ }
3367
+ catch { /* accounting must not block startup */ }
3368
+ function publicGroupState(record) {
3369
+ // The organization library's part hashes stay server-side.
3370
+ const { installedPackage: _installedPackage, ...group } = record;
3371
+ let usage;
3372
+ try {
3373
+ usage = groupUsageReader.forThread(group.threadId);
3374
+ }
3375
+ catch { /* accounting must not block chat */ }
3376
+ return { ...group, usage: usage ?? null, working: groupIsWorking(group) || [...roomHandoffs.nodes.values()].some(n => n.groupId === group.id && !["completed", "failed", "cancelled"].includes(n.status)) };
3113
3377
  }
3114
3378
  function beginGroupTurnOperation(groupId, threadId, botIds = []) {
3115
3379
  const operation = {
@@ -3140,6 +3404,13 @@ function finishGroupTurnOperation(groupId, operation) {
3140
3404
  const group = store.group(groupId);
3141
3405
  if (group)
3142
3406
  broadcast({ kind: "group", group: publicGroupState(group) });
3407
+ // The operation can outlive the last member's turn.completed: the fold
3408
+ // releases the speaker, but this map entry is what holds a 1:1 message
3409
+ // queued behind the room (reason "group-turn"), and no later event fires
3410
+ // once the run settles. Drain here, at the true end — the drain's own
3411
+ // block check re-reads activeGroupTurnForBot, so a replacement operation
3412
+ // begun in the interim still keeps its ground.
3413
+ drainQueuedSends();
3143
3414
  // A follow-up sent while this operation was running belongs to the
3144
3415
  // harness, not whichever composer happened to be mounted. Hand the next
3145
3416
  // one to the ordinary channel runner as soon as the channel is truly idle.
@@ -3448,6 +3719,12 @@ store.onChange((change) => {
3448
3719
  break;
3449
3720
  }
3450
3721
  case "bot.deleted":
3722
+ try {
3723
+ commandAllowlist.clear(change.botId);
3724
+ }
3725
+ catch {
3726
+ console.error("[command-allowlist] Could not remove deleted bot's saved rules.");
3727
+ }
3451
3728
  broadcast({ kind: "bot.deleted", botId: change.botId });
3452
3729
  break;
3453
3730
  case "group": {
@@ -3718,6 +3995,17 @@ onSteeredQueueChange(() => broadcast({ kind: "bot.queued", queues: publicBotQueu
3718
3995
  // once can collide on a bare id and patch each other's messages.
3719
3996
  const toolMessageByItem = new Map(); // threadId:itemId -> messageId
3720
3997
  const askMessageByRequest = new Map(); // threadId:requestId -> messageId
3998
+ // Only native, live permission metadata can become a remembered grant. Never
3999
+ // rebuild executable authority from an imported transcript or display summary.
4000
+ const pendingCommandRules = new Map();
4001
+ function commandRememberRefusal(auth, threadId, requestId, behavior) {
4002
+ if (!canManageCommandAllowlist(auth))
4003
+ return "Only the workspace owner or an admin can save bot-wide command permissions.";
4004
+ if (behavior !== "allow" || !pendingCommandRules.has(`${threadId}:${requestId}`)) {
4005
+ return "This live request does not contain a command that can be saved. Allow it once instead.";
4006
+ }
4007
+ return null;
4008
+ }
3721
4009
  /** Deliver a person's answer to the engine that asked, and tell the truth
3722
4010
  * about what happened. `unavailable` — the turn ended, the ask timed out,
3723
4011
  * the engine has no asks — is fail-closed: the action was never run. The
@@ -3725,7 +4013,7 @@ const askMessageByRequest = new Map(); // threadId:requestId -> messageId
3725
4013
  * a 500 while the card sits open forever. */
3726
4014
  async function answerRequest(threadId, instanceId, requestId, behavior, message, decidedFor,
3727
4015
  /** "Always allow this session": the provider keeps the allow, not the app */
3728
- always) {
4016
+ always, rememberCommand) {
3729
4017
  // Snapshot the card BEFORE delivering the answer: a delivered answer
3730
4018
  // resolves the request synchronously through the fold, which consumes
3731
4019
  // the askMessageByRequest entry — by the time the await returns, nobody
@@ -3738,7 +4026,10 @@ always) {
3738
4026
  ? thread.find((m) => m.id === cardMessageId)
3739
4027
  : thread.find((m) => m.card?.requestId === requestId);
3740
4028
  const card = cardMessage?.card;
3741
- const instance = registry.get(instanceId);
4029
+ const remembered = rememberCommand ? pendingCommandRules.get(`${threadId}:${requestId}`) : undefined;
4030
+ // Cloud routing and a model change can differ from the bot's current
4031
+ // settings. The live turn owns this question, just as it owns Stop/steer.
4032
+ const instance = runningTurnEngines.get(threadId) ?? registry.get(instanceId);
3742
4033
  let outcome = "unavailable";
3743
4034
  if (instance) {
3744
4035
  try {
@@ -3748,6 +4039,18 @@ always) {
3748
4039
  outcome = "unavailable";
3749
4040
  }
3750
4041
  }
4042
+ if (remembered && outcome === "allowed-once" && behavior === "allow" && store.bot(remembered.botId)) {
4043
+ // Persist only after the provider accepted this exact live request. A dead
4044
+ // card, denied request, failed delivery or process restart saves no grant.
4045
+ try {
4046
+ commandAllowlist.add(remembered.botId, remembered.candidate);
4047
+ }
4048
+ catch {
4049
+ store.appendMessage(threadId, { role: "bot", kind: "activity", tool: {
4050
+ name: "Command allowed once, but its allowlist rule could not be saved. Try again from the permissions menu.", ok: false,
4051
+ } });
4052
+ }
4053
+ }
3751
4054
  // An answered question keeps its words. `request.resolved` only records
3752
4055
  // the behavior, so without this the card reads "answer" forever and the
3753
4056
  // person can no longer see what they told the bot.
@@ -3801,6 +4104,9 @@ always) {
3801
4104
  * be answered. Routine proposals are harness-owned and durable, so they stay
3802
4105
  * actionable even after the proposing turn has stopped. */
3803
4106
  function closeOpenApprovals(threadId) {
4107
+ for (const key of pendingCommandRules.keys())
4108
+ if (key.startsWith(`${threadId}:`))
4109
+ pendingCommandRules.delete(key);
3804
4110
  // Peer approvals also hold an in-memory promise. Resolve those first; merely
3805
4111
  // patching their cards would leave the delegation queue waiting 15 minutes.
3806
4112
  cancelPeerApprovalsForThread(threadId);
@@ -3970,6 +4276,10 @@ bus.subscribe((event) => {
3970
4276
  watchdog.setWaitingOnHuman(event.threadId, true);
3971
4277
  else if (event.type === "request.resolved")
3972
4278
  watchdog.setWaitingOnHuman(event.threadId, false);
4279
+ else if (event.type === "turn.wait_started")
4280
+ watchdog.setWaitingOnComputer(event.threadId, true);
4281
+ else if (event.type === "turn.wait_ended")
4282
+ watchdog.setWaitingOnComputer(event.threadId, false);
3973
4283
  else if (event.type === "turn.completed") {
3974
4284
  watchdog.settle(event.threadId);
3975
4285
  revokeInternalCapabilityForProviderEvent(event);
@@ -4444,8 +4754,12 @@ async function mountHostComputer(owner, botId, providerSupportsLocal) {
4444
4754
  : "this model engine cannot control this computer — choose Claude or an ACP engine, or select another destination");
4445
4755
  }
4446
4756
  const cua = readCuaConnection();
4447
- if (!cua)
4448
- throw new Error("CUA Driver is not ready for this computer — check permissions and restart OpenMausBot");
4757
+ if (!cua) {
4758
+ const reason = readCuaUnavailableReason();
4759
+ throw new Error(reason
4760
+ ? `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." : ""}`
4761
+ : "CUA Driver is not ready for this computer — check permissions and restart OpenMausBot");
4762
+ }
4449
4763
  await bindTurnComputer(owner, "computer:host");
4450
4764
  return gatedLocalComputer(cua, controlIntegration(botId, owner.threadId, owner.generation));
4451
4765
  }
@@ -4472,6 +4786,7 @@ async function mountBotVps(bot, owner, opts) {
4472
4786
  owner,
4473
4787
  lazy: true,
4474
4788
  label: "the VPS computer",
4789
+ onRejected: opts.onRejected,
4475
4790
  claim: async () => {
4476
4791
  await bindTurnComputer(owner, vpsResource, true);
4477
4792
  opts.onClaimed(vpsCapture);
@@ -4608,11 +4923,12 @@ function turnProvider(bot, runOn, threadId) {
4608
4923
  return "box";
4609
4924
  return bot.cloudBackend === "vps" ? "vps" : wants === "cloud" ? "box" : null;
4610
4925
  }
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. */
4926
+ /** A driver with a Box bridge keeps the selected model. Other engines use
4927
+ * Box's native runner. Start and interrupt must resolve the same owner. */
4615
4928
  function turnInstance(bot, runOn, threadId) {
4929
+ const selected = registry.get(bot.modelSelection.instanceId);
4930
+ if (selected?.adapter.capabilities.cloudComputerMcp)
4931
+ return selected;
4616
4932
  const onBox = turnProvider(bot, runOn, threadId) === "box";
4617
4933
  return onBox
4618
4934
  ? registry.instances().find((candidate) => candidate.driverKind === "boxAgent") ?? null
@@ -4677,7 +4993,7 @@ async function selectableComputers(bot) {
4677
4993
  canCreate = Boolean(status?.configured && status.daemonUp && status.container === "missing");
4678
4994
  reason = status?.problem ?? reason;
4679
4995
  }
4680
- else if (box.boxConfigured(cfg) && registry.instances().some(instance => instance.driverKind === "boxAgent")) {
4996
+ else if (box.boxConfigured(cfg) && (caps?.cloudComputerMcp || registry.instances().some(instance => instance.driverKind === "boxAgent"))) {
4681
4997
  const status = await box.boxStatus(cfg, bot.id);
4682
4998
  const lifecycle = box.boxTurnLifecycleAction({ explicitCloud: true, canMount: true, state: status.box?.state ?? null });
4683
4999
  ready = lifecycle === "attach";
@@ -4740,7 +5056,10 @@ function continueComputerSelection(threadId, generation, succeeded) {
4740
5056
  }
4741
5057
  if (store.activePath(threadId).findLast(message => message.role === "user" && message.kind === "text")?.id !== selection.source.id)
4742
5058
  return;
4743
- store.patchTask(bot.id, threadId, { surface });
5059
+ // The model picked this surface through select_computer; the person
5060
+ // never chose it in the composer or thread settings. It is the
5061
+ // machine's record, so a later Works on change may sweep it.
5062
+ store.patchTask(bot.id, threadId, { surface, surfaceSource: "auto" });
4744
5063
  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
5064
  void startTurn(bot.id, text, { threadId, userMessage: selection.source, computerSelectionContinuation: true }).catch(error => {
4746
5065
  if (store.taskByThread(bot.id, threadId))
@@ -4965,8 +5284,12 @@ bus.subscribe((event) => {
4965
5284
  const privateImageEvent = event.type === "item.completed" && event.itemType === "assistant_image";
4966
5285
  // The durable message patch below is the public frame. Sending raw base64
4967
5286
  // through runtime SSE would multiply large bytes across every app window.
4968
- if (!privateImageEvent)
4969
- broadcast({ kind: "runtime", event });
5287
+ if (!privateImageEvent) {
5288
+ // Exact executable input is internal matching metadata, not inspector UI.
5289
+ // The event bus's persistence redactor does not scrub its live event.
5290
+ const publicEvent = event.type === "request.opened" ? { ...event, command: undefined } : event;
5291
+ broadcast({ kind: "runtime", event: publicEvent });
5292
+ }
4970
5293
  const routineRun = privateImageEvent ? null : (routines?.handleRuntimeEvent(event) ?? null);
4971
5294
  const ownerBot = store.botByThread(event.threadId);
4972
5295
  const bot = ownerBot ? botForThread(ownerBot.id, event.threadId) ?? undefined : undefined;
@@ -5018,10 +5341,10 @@ bus.subscribe((event) => {
5018
5341
  try {
5019
5342
  const decoded = decodeGeneratedImage(event.data);
5020
5343
  const saved = saveImage(decoded.bytes, decoded.mime);
5021
- const key = generatedImageTurnKey(event.threadId, event.turnId);
5022
- const current = generatedImagesByTurn.get(key) ?? [];
5344
+ const key = turnAttachmentKey(event.threadId, event.turnId);
5345
+ const current = turnAttachmentsByTurn.get(key) ?? [];
5023
5346
  current.push({ kind: "image", path: saved.path, mime: saved.mime });
5024
- generatedImagesByTurn.set(key, current);
5347
+ turnAttachmentsByTurn.set(key, current);
5025
5348
  }
5026
5349
  catch (error) {
5027
5350
  pushMessage({
@@ -5092,14 +5415,22 @@ bus.subscribe((event) => {
5092
5415
  const permission = event.requestType === "permission" && !event.questions?.length;
5093
5416
  // A permission request here is one the provider left for a person: its
5094
5417
  // 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
5418
+ // Only the person's explicit Full access or exact saved command answers.
5419
+ // The app does not guess whether the action is safe. A QUESTION
5097
5420
  // always reaches the human — even Full access never invents an answer.
5098
5421
  const asker = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
5099
5422
  const unattended = permission && asker && event.requestId ? isUnattended(asker.id, event.threadId) : false;
5100
5423
  const effectiveApprovalMode = asker ? approvalModeForTurn(asker, isInternalTurn(event.threadId)) : "ask";
5424
+ // Native shell descriptors are distinct from computer/MCP permissions;
5425
+ // choosing a local desktop must not disable an exact shell grant.
5426
+ const command = permission && asker && event.requestId && !event.requiresExplicitApproval && event.command
5427
+ ? commandAllowlistCandidate({ ...event.command, providerInstanceId: event.providerInstanceId ?? asker.modelSelection.instanceId })
5428
+ : null;
5101
5429
  const verdict = permission && asker && event.requestId
5102
- ? autoVerdict(effectiveApprovalMode, event.tool, { requiresExplicitApproval: event.requiresExplicitApproval })
5430
+ ? autoVerdict(effectiveApprovalMode, event.tool, {
5431
+ requiresExplicitApproval: event.requiresExplicitApproval,
5432
+ commandAllowed: Boolean(command && commandAllowlist.matches(asker.id, command)),
5433
+ })
5103
5434
  : null;
5104
5435
  // Auto's reviewer is the engine's own. Claude accepts `--permission-mode
5105
5436
  // auto` for any model and starts in Manual without a word when auto is
@@ -5146,8 +5477,8 @@ bus.subscribe((event) => {
5146
5477
  pushMessage({
5147
5478
  role: "bot", kind: "activity",
5148
5479
  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 },
5480
+ ? `The provider rejected this action despite ${verdict.source === "command-allowlist" ? "the saved command permission" : "Full access"}.`
5481
+ : "error: could not deliver approval to the provider; retry the task after reconnecting.", ok: false },
5151
5482
  });
5152
5483
  return;
5153
5484
  }
@@ -5170,7 +5501,7 @@ bus.subscribe((event) => {
5170
5501
  })().catch(() => {
5171
5502
  // A receipt failure must neither crash the server nor manufacture
5172
5503
  // a new permission request after the provider took our answer.
5173
- console.error("[full-access] Could not record the provider approval result.");
5504
+ console.error("[approval] Could not record the provider approval result.");
5174
5505
  });
5175
5506
  break;
5176
5507
  }
@@ -5192,9 +5523,11 @@ bus.subscribe((event) => {
5192
5523
  options: event.choices?.length ? event.choices : permission ? ["Allow", "Deny"] : [],
5193
5524
  requestId: event.requestId,
5194
5525
  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
5526
+ questionRequest: questions
5527
+ ? { version: 1, questions, ...(event.origin === "output" ? { origin: "output" } : {}) }
5528
+ : undefined,
5529
+ commandAllowlist: command ?? undefined,
5530
+ // Provider-owned session grants remain separate from exact commands.
5198
5531
  allowSession: permission && event.allowSession && !event.requiresExplicitApproval ? true : undefined,
5199
5532
  // The text stays for cards saved before heldCode existed, and for
5200
5533
  // clients that do not know the key yet.
@@ -5205,6 +5538,8 @@ bus.subscribe((event) => {
5205
5538
  });
5206
5539
  if (event.requestId)
5207
5540
  askMessageByRequest.set(`${event.threadId}:${event.requestId}`, message.id);
5541
+ if (command && asker && event.requestId)
5542
+ pendingCommandRules.set(`${event.threadId}:${event.requestId}`, { botId: asker.id, candidate: command });
5208
5543
  // Every card that reaches a human is a decision too: "the provider
5209
5544
  // left this for you, in this mode". `question` marks the cards no
5210
5545
  // rule may ever answer; a permission card without a verdict (no known
@@ -5219,6 +5554,7 @@ bus.subscribe((event) => {
5219
5554
  summary: event.summary,
5220
5555
  decision: "card-shown",
5221
5556
  source: !permission ? "question" : verdict ? verdict.source : "no-grant",
5557
+ origin: !permission && event.origin === "output" ? "output" : undefined,
5222
5558
  unattended: unattended || undefined,
5223
5559
  });
5224
5560
  // Notify from HERE, not from a separate subscriber on request.opened:
@@ -5239,6 +5575,8 @@ bus.subscribe((event) => {
5239
5575
  break;
5240
5576
  }
5241
5577
  case "request.resolved": {
5578
+ if (event.requestId)
5579
+ pendingCommandRules.delete(`${event.threadId}:${event.requestId}`);
5242
5580
  // answered (by whoever): the turn is working again, unless it settled
5243
5581
  const waiting = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
5244
5582
  if (bot && store.taskByThread(bot.id, event.threadId)?.activity === "waiting-on-you") {
@@ -5296,26 +5634,60 @@ bus.subscribe((event) => {
5296
5634
  // thread nobody types in again (a deleted bot's) is not held forever.
5297
5635
  const internal = isInternalTurn(event.threadId);
5298
5636
  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) {
5637
+ const generatedKey = turnAttachmentKey(event.threadId, event.turnId);
5638
+ const generated = turnAttachmentsByTurn.get(generatedKey) ?? [];
5639
+ turnAttachmentsByTurn.delete(generatedKey);
5640
+ // A turn that never completed never lands its half-said note: parked
5641
+ // audio is deleted when the settle is not ok (person stop, provider
5642
+ // failure) rather than posted as if the turn finished (#1742).
5643
+ // Generated images keep their long-standing settle-anyway behavior.
5644
+ for (const attachment of generated) {
5645
+ if (event.ok || attachment.kind !== "audio")
5646
+ continue;
5647
+ deleteAttachment(attachment.path);
5648
+ }
5649
+ const settled = event.ok || !generated.length
5650
+ ? generated
5651
+ : generated.filter((attachment) => attachment.kind !== "audio");
5652
+ if (settled.length) {
5303
5653
  const response = [...store.messagesFor(event.threadId)].reverse().find((message) => message.role === "bot" &&
5304
5654
  message.kind === "text" &&
5305
5655
  message.turnId === completedTurnId);
5656
+ // A voice note's text is its transcript: the visible caption search,
5657
+ // compaction and notifications read (#1740 decision 3).
5658
+ const transcript = settled
5659
+ .filter((attachment) => attachment.kind === "audio" && attachment.text)
5660
+ .map((attachment) => attachment.text.trim())
5661
+ .filter(Boolean)
5662
+ .join("\n\n");
5663
+ const staged = settled.map((attachment) => attachment.kind === "audio"
5664
+ ? { kind: "audio", path: attachment.path, mime: attachment.mime }
5665
+ : attachment);
5306
5666
  if (response) {
5307
5667
  store.patchMessage(event.threadId, response.id, {
5308
- attachments: [...(response.attachments ?? []), ...generated],
5668
+ attachments: [...(response.attachments ?? []), ...staged],
5669
+ // The note's text is its transcript and stays visible beside a
5670
+ // written epilogue: the wire audio carries no text, so letting
5671
+ // the epilogue replace it would erase what was said (#1740
5672
+ // decision 3).
5673
+ ...(transcript
5674
+ ? {
5675
+ text: [response.text?.trim(), redactSecretsInText(transcript)]
5676
+ .filter(Boolean)
5677
+ .join("\n\n"),
5678
+ }
5679
+ : {}),
5309
5680
  });
5310
5681
  }
5311
5682
  else {
5312
- // Some image turns have no textual epilogue. Keep the image as the
5313
- // terminal assistant response instead of inventing model words.
5683
+ // Some turns have no textual epilogue. Keep the attachment as the
5684
+ // terminal assistant response instead of inventing model words; a
5685
+ // voice note's own text is the visible transcript there.
5314
5686
  pushMessage({
5315
5687
  role: "bot",
5316
5688
  kind: "text",
5317
- text: "",
5318
- attachments: generated,
5689
+ text: transcript,
5690
+ attachments: staged,
5319
5691
  turnId: completedTurnId,
5320
5692
  });
5321
5693
  }
@@ -5329,7 +5701,13 @@ bus.subscribe((event) => {
5329
5701
  lastReply.delete(event.threadId);
5330
5702
  // A run that broke — not one the person stopped, and not a routine's,
5331
5703
  // which reports through its own failure path — is the Chief's to see.
5332
- if (!event.ok && event.stopReason !== "interrupted" && !routines?.runForThread(event.threadId)) {
5704
+ // A lazy computer-claim rejection already reported its failure and
5705
+ // interrupted the turn; Claude settles that interrupt as
5706
+ // exit_before_result, not "interrupted", so this generation's marker
5707
+ // keeps the same failure from reporting a second incident.
5708
+ const lazyClaimAlreadyReported = event.stopReason === "exit_before_result" &&
5709
+ turnResourceOwners.get(event.threadId)?.lazyClaimFailureReported === true;
5710
+ if (!event.ok && event.stopReason !== "interrupted" && !lazyClaimAlreadyReported && !routines?.runForThread(event.threadId)) {
5333
5711
  const broken = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
5334
5712
  if (broken)
5335
5713
  reportIncident({ kind: "failed", bot: broken, threadId: event.threadId, detail: event.stopReason?.trim() || "the run ended without a result" });
@@ -5400,6 +5778,8 @@ bus.subscribe((event) => {
5400
5778
  const measuredSelection = directTurnBots.get(event.threadId)?.modelSelection ?? selection;
5401
5779
  store.patchTask(bot.id, event.threadId, { lastContextModel: Number.isFinite(lastContext?.tokens) && (lastContext?.tokens ?? 0) > 0
5402
5780
  ? `${measuredSelection.instanceId}:${measuredSelection.model}` : undefined });
5781
+ const promptBytes = turnPromptBytes.get(event.threadId);
5782
+ turnPromptBytes.delete(event.threadId);
5403
5783
  bookTurnUsage({
5404
5784
  botId: bot.id,
5405
5785
  botName: bot.name,
@@ -5410,6 +5790,7 @@ bus.subscribe((event) => {
5410
5790
  input: tokens?.input ?? 0,
5411
5791
  output: tokens?.output ?? 0,
5412
5792
  ...(typeof tokens?.cachedInput === "number" ? { cachedInput: tokens.cachedInput } : {}),
5793
+ ...(promptBytes ? { promptBytes } : {}),
5413
5794
  costUsd: event.cost ?? null,
5414
5795
  trigger: routineRun
5415
5796
  ? { kind: "routine", routineId: routineRun.routineId, label: routineRun.routineName }
@@ -5485,6 +5866,8 @@ bus.subscribe((event) => {
5485
5866
  const tokens = event.usage ?? lastReported;
5486
5867
  const speakingBot = store.bot(speaker.botId);
5487
5868
  const selection = speakingBot?.modelSelection;
5869
+ const promptBytes = turnPromptBytes.get(event.threadId);
5870
+ turnPromptBytes.delete(event.threadId);
5488
5871
  bookTurnUsage({
5489
5872
  botId: speaker.botId,
5490
5873
  botName: speaker.name,
@@ -5495,11 +5878,20 @@ bus.subscribe((event) => {
5495
5878
  input: tokens?.input ?? 0,
5496
5879
  output: tokens?.output ?? 0,
5497
5880
  ...(typeof tokens?.cachedInput === "number" ? { cachedInput: tokens.cachedInput } : {}),
5881
+ ...(promptBytes ? { promptBytes } : {}),
5498
5882
  costUsd: event.cost ?? null,
5499
5883
  trigger: routineRun
5500
5884
  ? { kind: "routine", routineId: routineRun.routineId, label: routineRun.routineName }
5501
5885
  : turnTriggers.get(event.threadId) ?? { kind: "owner" },
5502
5886
  });
5887
+ // Appends are asynchronous. Refresh after persistence so the settled
5888
+ // turn appears immediately, without waiting for another message.
5889
+ void flushUsageLedger(DATA_DIR).then(() => {
5890
+ groupUsageReader.refresh();
5891
+ const current = store.group(group.id);
5892
+ if (current)
5893
+ broadcast({ kind: "group", group: publicGroupState(current) });
5894
+ }).catch(() => { });
5503
5895
  if (completedTurnId) {
5504
5896
  void scheduleTurnDigest({
5505
5897
  botId: speaker.botId, botName: speaker.name,
@@ -6207,8 +6599,12 @@ function drainQueuedSends() {
6207
6599
  * available. Reuse the existing cancellable, idempotent composer queue. */
6208
6600
  async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender, trigger) {
6209
6601
  const capacity = botAtThreadCapacity(botId);
6210
- if (capacity || threadBusy(botId, threadId) || parksBehindCoordination(botId, threadId)) {
6211
- const reason = capacity ? "capacity" : undefined;
6602
+ // A room turn holds the bot exactly like the sibling opened-thread queue
6603
+ // below: the drain's own block check waits it out, so the words queue
6604
+ // here rather than bounce off startTurn's 409.
6605
+ const groupTurn = activeGroupTurnForBot(botId);
6606
+ if (capacity || threadBusy(botId, threadId) || groupTurn || parksBehindCoordination(botId, threadId)) {
6607
+ const reason = capacity ? "capacity" : groupTurn ? "group-turn" : undefined;
6212
6608
  const queued = queueSteeredMessage(botId, threadId, text, {
6213
6609
  replyToId: replyTo?.id,
6214
6610
  sendId,
@@ -6235,8 +6631,14 @@ async function startOrQueueOpenedThread(botId, threadId, text, unattended) {
6235
6631
  // A room turn holds the bot too (startTurn refuses a direct turn during
6236
6632
  // one); the drain's own block check already waits for it, so the words
6237
6633
  // queue here rather than bounce.
6238
- if (botAtThreadCapacity(botId) || activeGroupTurnForBot(botId)) {
6239
- queueSteeredMessage(botId, threadId, text, { reason: "capacity", unattended, peerAsk });
6634
+ const capacity = botAtThreadCapacity(botId);
6635
+ const groupTurn = activeGroupTurnForBot(botId);
6636
+ if (capacity || groupTurn) {
6637
+ queueSteeredMessage(botId, threadId, text, {
6638
+ reason: capacity ? "capacity" : "group-turn",
6639
+ unattended,
6640
+ peerAsk,
6641
+ });
6240
6642
  return { state: "queued", position: queuedThreadPosition(botId, threadId) ?? 1 };
6241
6643
  }
6242
6644
  try {
@@ -6542,11 +6944,12 @@ async function startTurn(botId, text, opts) {
6542
6944
  throw Object.assign(new Error("this provider account is being updated — try again shortly"), { status: 409 });
6543
6945
  }
6544
6946
  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;
6947
+ const useInstanceDefaults = switchedEngine || (opts?.runOn === "cloud" && !instance.adapter.capabilities.cloudComputerMcp);
6948
+ const model = useInstanceDefaults ? instance.models.default : bot.modelSelection.model;
6949
+ // A native cloud runner borrows its instance defaults; a Box bridge keeps
6950
+ // the bot's selected model, effort and variant.
6951
+ const effort = useInstanceDefaults ? undefined : bot.modelSelection.effort;
6952
+ const variant = useInstanceDefaults ? undefined : bot.modelSelection.variant;
6550
6953
  assertModelVariantSupported({ variant, effort }, instance.adapter.capabilities);
6551
6954
  // A selection can be persisted while its engine is offline. Re-check when
6552
6955
  // the engine returns so an old or unsupported value never reaches a CLI.
@@ -6556,7 +6959,7 @@ async function startTurn(botId, text, opts) {
6556
6959
  // an edit hands us its already-branched user message; a plain send appends
6557
6960
  let userMessage = opts?.userMessage;
6558
6961
  if (opts?.editedMessageId) {
6559
- const edited = store.branchMessage(threadId, opts.editedMessageId, text);
6962
+ const edited = store.branchMessage(threadId, opts.editedMessageId, text, opts.sendId);
6560
6963
  if (!edited)
6561
6964
  throw Object.assign(new Error("only a user text message can be edited"), { status: 400 });
6562
6965
  store.patchTask(bot.id, threadId, { rewound: true });
@@ -6632,6 +7035,12 @@ async function startTurn(botId, text, opts) {
6632
7035
  computerSelectionTurns.set(threadId, { generation: dispatchClaimId, botId: bot.id, source, text });
6633
7036
  }
6634
7037
  store.setTaskActivity(bot.id, threadId, "working");
7038
+ // Watch from admission, not dispatch: a turn can wedge in setup — context
7039
+ // compaction that never returns, a hung browser or VM mount — long before
7040
+ // any provider event exists, and a watch armed only at dispatch never saw
7041
+ // those. Every setup exit below settles or throws into the catch, which
7042
+ // settles under the same generation ownership it already enforces.
7043
+ watchdog.watch(threadId, bot.id);
6635
7044
  // A closed thread that gets a new turn is open again: the person (or the
6636
7045
  // opener) picked it back up, so its row returns to the sidebar and
6637
7046
  // list_threads stops calling it closed. No-op on an open thread.
@@ -6646,6 +7055,16 @@ async function startTurn(botId, text, opts) {
6646
7055
  turnContext.delete(threadId);
6647
7056
  void (async () => {
6648
7057
  try {
7058
+ // Readiness can wait on the network. Admit the task first so its busy
7059
+ // state, Stop action and watchdog own that wait just like VM setup.
7060
+ if (opts?.runOn === "cloud") {
7061
+ const readiness = await cloudRoutineReadiness(botId, threadId);
7062
+ if (!directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId)) {
7063
+ throw new DirectTurnSetupCancelled("turn stopped during cloud readiness");
7064
+ }
7065
+ if (!readiness.ready)
7066
+ throw new Error(readiness.reason ?? "The cloud computer is not ready");
7067
+ }
6649
7068
  await compactConversation({ bot, threadId, generation: dispatchClaimId, instance, model,
6650
7069
  excludedIds: new Set([userMessage.id, ...(opts?.excludeMessageIds ?? [])]), manual: opts?.compactOnly === true });
6651
7070
  if (!directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId))
@@ -6656,6 +7075,7 @@ async function startTurn(botId, text, opts) {
6656
7075
  settleDirectFollowup(dispatchClaimId);
6657
7076
  releaseTurnResources(resourceOwner);
6658
7077
  store.setTaskActivity(bot.id, threadId, "idle");
7078
+ watchdog.settle(threadId);
6659
7079
  directTurnBots.delete(threadId);
6660
7080
  retryDelegationsWaitingOn(bot.id);
6661
7081
  drainQueuedSends();
@@ -6752,7 +7172,7 @@ async function startTurn(botId, text, opts) {
6752
7172
  rewound,
6753
7173
  fresh: fresh || contextReset,
6754
7174
  externallyUpdated: Boolean(externalContextMarker) || handedStale,
6755
- replaysNatively: instance.driverKind === "grok",
7175
+ replaysNatively: NATIVELY_REPLAYING_DRIVER_KINDS.includes(instance.driverKind),
6756
7176
  });
6757
7177
  // Snapshot the cursor alongside the context decision. An external result
6758
7178
  // can arrive during async computer/setup work and clear the task cursor;
@@ -6808,9 +7228,7 @@ async function startTurn(botId, text, opts) {
6808
7228
  ...(skillAuthoring ? ["skillAuthoring"] : []),
6809
7229
  ], availableSkills());
6810
7230
  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();
7231
+ integrations.phone = phoneIntegration(bot.id, threadId, dispatchClaimId);
6814
7232
  }
6815
7233
  // the user's connected apps, but only to a driver that can mount
6816
7234
  // them — a key in the config says the connections exist, not that
@@ -6875,9 +7293,7 @@ async function startTurn(botId, text, opts) {
6875
7293
  const teamComputer = inheritedTeamComputer(bot);
6876
7294
  const cloudBackend = teamComputer || opts?.runOn === "cloud" || bot.cloudBackend !== "vps" ? "box" : "vps";
6877
7295
  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";
7296
+ const mountsCloudComputer = instance.driverKind === "boxAgent" || instance.adapter.capabilities.cloudComputerMcp === true;
6881
7297
  const mountsLocalComputer = instance.adapter.capabilities.localComputerMcp === true;
6882
7298
  // Where this turn's hands may land. The bot's "Works on" choice is
6883
7299
  // strict; a browser-only bot gets no computer at all, and a bot whose
@@ -6988,6 +7404,32 @@ async function startTurn(botId, text, opts) {
6988
7404
  previewCapture = localVmPreviewFor(localVmTarget, claimThreadId);
6989
7405
  return { target: localVmTarget, runtime: localVm.runtime };
6990
7406
  };
7407
+ // Issue #1369: a lazy claim that rejects must not leave its turn busy
7408
+ // behind a gate that can only refuse. Surface the terminal
7409
+ // computer-unavailable error, then end the turn. Fenced to this
7410
+ // dispatch generation and a still-busy thread, so a rejection landing
7411
+ // after settle — or after a newer turn owns the thread — never touches
7412
+ // the newer turn. The gate's refusals stay fail-closed for any screen
7413
+ // call racing this teardown; the turn-failed buzz and incident follow
7414
+ // the dispatch-failure rules (person-started turns only).
7415
+ const surfaceLazyClaimRejection = (label) => (failure) => {
7416
+ if (activeInternalGenerationByThread.get(threadId) !== resourceOwner.generation || !threadBusy(bot.id, threadId))
7417
+ return;
7418
+ const message = `computer unavailable — ${label} could not be claimed for this turn (${failure})`;
7419
+ store.appendMessage(threadId, {
7420
+ role: "bot",
7421
+ kind: "activity",
7422
+ tool: { name: `error: ${message.slice(0, 160)}`, ok: false },
7423
+ });
7424
+ if (opts?.automationSource === undefined && !opts?.commsDepth && !opts?.cardContinuation) {
7425
+ notify(buildNotification("turn-failed", bot, threadId, redactSecretsInText(message), { avatarUrl: bot.avatarUrl }));
7426
+ reportIncident({ kind: "could-not-start", bot, threadId, detail: message });
7427
+ // Claude settles the interrupt below as exit_before_result; the
7428
+ // completion fold must not report this failure a second time.
7429
+ resourceOwner.lazyClaimFailureReported = true;
7430
+ }
7431
+ void interruptDirectThread(bot.id, threadId).catch(() => { });
7432
+ };
6991
7433
  // Explicit destinations are strict. In particular, Local VM must never
6992
7434
  // fall through to host CUA and accidentally click on the user's Mac.
6993
7435
  // The Local VM attach, shared by explicit "Local VM" and by Auto. Explicit
@@ -7030,6 +7472,7 @@ async function startTurn(botId, text, opts) {
7030
7472
  owner: resourceOwner,
7031
7473
  lazy: true,
7032
7474
  label: "the Local VM",
7475
+ onRejected: surfaceLazyClaimRejection("the Local VM"),
7033
7476
  claim: async () => {
7034
7477
  await claimAutoLocalVm(threadId, localVmTarget);
7035
7478
  // The dispatch-site poller start saw a null previewCapture
@@ -7108,6 +7551,7 @@ async function startTurn(botId, text, opts) {
7108
7551
  startScreenPoller(bot.id, threadId, { computer: vpsCapture, ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: touched });
7109
7552
  }
7110
7553
  },
7554
+ onRejected: surfaceLazyClaimRejection("the VPS computer"),
7111
7555
  });
7112
7556
  if ("integration" in mounted) {
7113
7557
  integrations.localComputer = mounted.integration;
@@ -7122,8 +7566,8 @@ async function startTurn(botId, text, opts) {
7122
7566
  }
7123
7567
  }
7124
7568
  }
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.
7569
+ // Cloud is strict when selected. Native Box and drivers with a Box
7570
+ // bridge may also reuse an already-ready Box on Auto.
7127
7571
  if (teamComputer) {
7128
7572
  const attached = await attachTeamBox(teamComputer, bot.id, resourceOwner, mountsCloudComputer, instance.driverKind === "boxAgent");
7129
7573
  integrations.computer = attached.integration;
@@ -7303,11 +7747,12 @@ async function startTurn(botId, text, opts) {
7303
7747
  }
7304
7748
  // An Auto conversation remembers where its first turn landed, so later
7305
7749
  // 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.
7750
+ // not recorded: an auto pin yields to a later Works on change (which
7751
+ // sweeps it — store.clearAutoSurfacePins); a person's pin does not.
7307
7752
  if (bot.computer === undefined && !teamComputer && opts?.runOn !== "cloud" && !plan.pinned) {
7308
7753
  const used = mountedComputer ?? (integrations.browser ? "browser" : null);
7309
7754
  if (used)
7310
- store.patchTask(bot.id, threadId, { surface: used });
7755
+ store.patchTask(bot.id, threadId, { surface: used, surfaceSource: "auto" });
7311
7756
  }
7312
7757
  const computerSelection = computerSelectionTurns.get(threadId);
7313
7758
  if (computerSelection)
@@ -7320,11 +7765,10 @@ async function startTurn(botId, text, opts) {
7320
7765
  if (!markDirectTurnDispatching(bot.id, dispatchClaimId, threadId)) {
7321
7766
  throw new DirectTurnSetupCancelled("turn stopped before dispatch");
7322
7767
  }
7323
- watchdog.watch(threadId, bot.id);
7324
7768
  const computerPromptKind = computerKind === "vm"
7325
7769
  ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared"
7326
7770
  : computerKind === "box"
7327
- ? instance.driverKind === "boxAgent" ? "box-agent" : "box"
7771
+ ? instance.driverKind === "boxAgent" ? "box-agent" : instance.adapter.capabilities.cloudComputerMcp ? "box-chat" : "box"
7328
7772
  : computerKind === "vps"
7329
7773
  ? "vps"
7330
7774
  : computerKind === "local"
@@ -7335,6 +7779,7 @@ async function startTurn(botId, text, opts) {
7335
7779
  throw new DirectTurnSetupCancelled("Coordination access changed before dispatch");
7336
7780
  }
7337
7781
  const prompt = buildSystemPrompt(persona, liveBot?.soul ?? bot.soul ?? "", [
7782
+ { id: "user-profile", label: "About the user", text: userProfileSystemPrompt(cfg.profile) },
7338
7783
  // first after the soul: the block names agent tools, so it only goes
7339
7784
  // to a turn whose engine actually mounted them (setupMode is already
7340
7785
  // false when they are not — see agentsMounted above)
@@ -7368,6 +7813,7 @@ async function startTurn(botId, text, opts) {
7368
7813
  { id: "webhook", label: "Webhook provenance", text: opts?.automationSource === "webhook" ? WEBHOOK_PROMPT : "" },
7369
7814
  { 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
7815
  ]);
7816
+ turnPromptBytes.set(threadId, { stable: Buffer.byteLength(prompt.stable), volatile: Buffer.byteLength(prompt.volatile) });
7371
7817
  runningTurnEngines.set(threadId, instance);
7372
7818
  // The prompt carries the soul as saved now. If it changed during setup,
7373
7819
  // decide again from what is actually sent — except on a continuation
@@ -7409,6 +7855,9 @@ async function startTurn(botId, text, opts) {
7409
7855
  system: prompt.text,
7410
7856
  systemStable: prompt.stable,
7411
7857
  systemVolatile: prompt.volatile,
7858
+ // the mentions half describes this turn: identical consecutive tags
7859
+ // must still deliver their note (SendTurnInput.mentionTurn)
7860
+ mentionTurn: tagged.length > 0,
7412
7861
  integrations,
7413
7862
  mcpFromUserConfig: claudeUserMcpEnabled(cfg) && !managedPolicy.restrictsMcp(),
7414
7863
  cwd,
@@ -7507,12 +7956,15 @@ async function startTurn(botId, text, opts) {
7507
7956
  if (!ownsLatestGeneration)
7508
7957
  return;
7509
7958
  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
- });
7959
+ // The wait already wrote its failure resolution; keep all dispatch
7960
+ // failure bookkeeping below without adding the same error twice.
7961
+ if (!(e instanceof ComputerWaitGaveUp))
7962
+ store.appendMessage(threadId, {
7963
+ role: "bot",
7964
+ kind: "activity",
7965
+ turnSucceeded: false,
7966
+ tool: { name: `error: ${message.slice(0, 160)}`, ok: false },
7967
+ });
7516
7968
  // Worth a buzz for the same reason a routine failure is, and the rule
7517
7969
  // notify.ts encodes: the bot is not working, and the cause is usually
7518
7970
  // a setting only a person can change — an unattended user would
@@ -7791,8 +8243,9 @@ routines = new RoutineManager({
7791
8243
  handoffs.forget(threadId);
7792
8244
  }
7793
8245
  },
7794
- startTurn: (botId, threadId, prompt, runOn, triggerSource, onDispatchError) => startTurn(botId, prompt, { threadId, runOn, automationSource: triggerSource, onDispatchError })
7795
- .then(() => undefined),
8246
+ startTurn: async (botId, threadId, prompt, runOn, triggerSource, onDispatchError) => {
8247
+ await startTurn(botId, prompt, { threadId, runOn, automationSource: triggerSource, onDispatchError });
8248
+ },
7796
8249
  startGoal: async (groupId, threadId, prompt, coordinatorBotId, runId, _onDispatchError) => {
7797
8250
  startGroupTurn(groupId, prompt, undefined, undefined, "goal", undefined, {
7798
8251
  threadId,
@@ -7843,6 +8296,23 @@ routines = new RoutineManager({
7843
8296
  notify(buildNotification("routine-deferred", notificationBot, routineSourceThread(run) ?? bot.threadId, detail));
7844
8297
  },
7845
8298
  });
8299
+ orgLibrary = new OrgLibrary({
8300
+ dataDir: DATA_DIR,
8301
+ store,
8302
+ routines: routines,
8303
+ skills: { list: listSkills, stamps: skillPackageStamps, setEnabled: setSkillEnabled, installOrg: installOrgSkill },
8304
+ presets: presetStore,
8305
+ // Runtime → Electron main, which posts the report to Admin. Plain Node
8306
+ // (no parentPort) keeps it in memory only.
8307
+ postState: (message) => utilityParentPort?.postMessage(message),
8308
+ });
8309
+ {
8310
+ // Set by the port listener above, which TypeScript cannot see run first.
8311
+ const early = pendingOrgLibraryRelay;
8312
+ if (early)
8313
+ orgLibrary.applyRelay(early.library);
8314
+ pendingOrgLibraryRelay = null;
8315
+ }
7846
8316
  // The scheduler receipt and room transcript live in separate durable stores.
7847
8317
  // If the process exited between those two writes, prefer the correlated
7848
8318
  // RoutineRun's terminal truth; an uncorrelated manual goal is simply failed
@@ -7885,27 +8355,37 @@ if (recoveryOwners.length > 0) {
7885
8355
  // Chat tools can prepare routine changes, but the harness applies them only
7886
8356
  // after the user confirms a durable card. Keeping this beside the scheduler
7887
8357
  // makes the card resolvable after an app restart without involving the model.
7888
- async function cloudRoutineReadiness() {
8358
+ async function cloudRoutineReadiness(botId, threadId) {
8359
+ const bot = threadId ? store.projectBotForTask(botId, threadId) : store.bot(botId);
8360
+ if (!bot || bot.hidden)
8361
+ return { ready: false, reason: "The routine's target bot no longer exists." };
7889
8362
  if (!box.boxConfigured(cfg)) {
7890
8363
  return {
7891
8364
  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.',
8365
+ 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
8366
  };
7894
8367
  }
7895
- const instance = registry.instances().find((candidate) => candidate.driverKind === "boxAgent");
7896
- if (!instance) {
8368
+ const instance = turnInstance(bot, "cloud", threadId);
8369
+ if (!instance)
7897
8370
  return { ready: false, reason: "The Cloud VM runner is unavailable. Restart OpenMausBot and try again." };
7898
- }
7899
8371
  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." };
8372
+ if ((await instance.snapshot()).state !== "available") {
8373
+ return { ready: false, reason: "The target bot's model engine is not ready to use the Box cloud computer." };
8374
+ }
8375
+ const teamComputer = inheritedTeamComputer(bot);
8376
+ const ownerId = teamComputer ? teamComputerOwner(teamComputer.id) : bot.id;
8377
+ const machine = await box.findBox(cfg, ownerId);
8378
+ if (teamComputer && !machine) {
8379
+ return { ready: false, reason: "The team's Box computer is missing; explicitly create or retry it from the Team map." };
8380
+ }
8381
+ // Explicit Cloud may provision or wake the bot's own Box at dispatch.
8382
+ // This probe only checks its identity/account, without starting billing.
8383
+ return { ready: true };
7904
8384
  }
7905
8385
  catch (error) {
7906
8386
  return {
7907
8387
  ready: false,
7908
- reason: `The Cloud VM runner could not be checked: ${error instanceof Error ? error.message : String(error)}`,
8388
+ reason: `The target bot's cloud computer could not be checked: ${redactSecretsInText(error instanceof Error ? error.message : String(error))}`,
7909
8389
  };
7910
8390
  }
7911
8391
  }
@@ -7965,7 +8445,7 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
7965
8445
  const botBoxDeletions = boxDeletionSnapshot().filter((entry) => entry.ownerBotId === bot.id);
7966
8446
  if (botBoxRecovery.some((entry) => !entry.resolved)) {
7967
8447
  return deletionResponse(409, {
7968
- error: "finish reconciling this bot's pending cloud computer creation before deleting it — check ascii.dev, then retry Box setup",
8448
+ error: "finish reconciling this bot's pending cloud computer creation before deleting it — check boat.dev, then retry Box setup",
7969
8449
  });
7970
8450
  }
7971
8451
  // Bot deletion awaits VM/browser/provider cleanup. Claim the bot and
@@ -8083,7 +8563,7 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
8083
8563
  // Deletion removes the thread before a late turn.completed can fold
8084
8564
  // staged provider images into a message, so dispose them here.
8085
8565
  for (const task of store.tasks(bot.id)) {
8086
- purgeGeneratedImagesForThread(task.threadId);
8566
+ purgeTurnAttachmentsForThread(task.threadId);
8087
8567
  clearTurnDigestState(task.threadId);
8088
8568
  settleDirectFollowup(directTurnGenerationByThread.get(task.threadId));
8089
8569
  directTurnGenerationByThread.delete(task.threadId);
@@ -8645,6 +9125,8 @@ setupRetry = 0) {
8645
9125
  let retainRoomVmLease = false;
8646
9126
  let roomSpeaker;
8647
9127
  let providerDispatched = false;
9128
+ let setupStalled = false;
9129
+ let unregisterSetupStall = () => { };
8648
9130
  // The speaker's own This computer / Cloud mount, and what it must give back.
8649
9131
  let roomComputerKind = null;
8650
9132
  let roomVpsBotId = null;
@@ -8696,9 +9178,7 @@ setupRetry = 0) {
8696
9178
  const skills = availableSkills();
8697
9179
  const selectedSkills = mergeSkills(selectBundledSkills(roomContext, instance.adapter.capabilities.phoneMcp === true ? ["phoneMcp"] : [], skills), selectBundledSkills(latestUser?.text ?? "", skillAuthoring ? ["skillAuthoring"] : [], skills));
8698
9180
  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();
9181
+ integrations.phone = phoneIntegration(bot.id, threadId, internalGeneration);
8702
9182
  }
8703
9183
  try {
8704
9184
  if (bot.composio !== false && composio.configured(cfg) && instance.adapter.capabilities.composioMcp === true) {
@@ -8822,6 +9302,22 @@ setupRetry = 0) {
8822
9302
  groupSpeakers.set(threadId, roomSpeaker);
8823
9303
  store.patchGroup(readyGroup.id, { busyBotId: bot.id });
8824
9304
  orchestration?.onClaimed?.();
9305
+ // The stall completion handler only exists once the provider turn runs
9306
+ // (registered at dispatch below), but this watch is armed at claim. Latch
9307
+ // a stall that fires during setup — browser, box, and VM mounts can all
9308
+ // wedge — so the dispatch site completes the turn instead of launching a
9309
+ // provider turn the person was already told was stopped. The finally
9310
+ // below unregisters the latch on every other exit.
9311
+ setupStalled = false;
9312
+ unregisterSetupStall = roomStallCompletions.register(threadId, () => {
9313
+ setupStalled = true;
9314
+ });
9315
+ // Watch from claim, not provider dispatch: room setup (connected-app
9316
+ // discovery, browser, box, and VM mounts) can wedge before any provider
9317
+ // event exists. The exits between here and dispatch either throw into the
9318
+ // finally's guarded cleanup or return through the revalidation block,
9319
+ // both of which settle the watch.
9320
+ watchdog.watch(threadId, bot.id);
8825
9321
  // Connected-app discovery above can yield for a network round trip. A
8826
9322
  // profile may be removed, or the browser feature switched off, during that
8827
9323
  // window. Mint the capability only after this turn has synchronously
@@ -8867,7 +9363,7 @@ setupRetry = 0) {
8867
9363
  return false;
8868
9364
  }
8869
9365
  if (roomTeamComputer) {
8870
- const attached = await attachTeamBox(roomTeamComputer, readyBot.id, resourceOwner, instance.driverKind === "boxAgent", instance.driverKind === "boxAgent");
9366
+ const attached = await attachTeamBox(roomTeamComputer, readyBot.id, resourceOwner, instance.driverKind === "boxAgent" || instance.adapter.capabilities.cloudComputerMcp === true, instance.driverKind === "boxAgent");
8871
9367
  if (isCancelled?.() || groupSpeakers.get(threadId) !== roomSpeaker ||
8872
9368
  activeInternalGenerationByThread.get(threadId) !== internalGeneration)
8873
9369
  return false;
@@ -8913,7 +9409,8 @@ setupRetry = 0) {
8913
9409
  if (!box.boxConfigured(cfg))
8914
9410
  throw new Error("Cloud box is not configured — add a Box API key or choose Local VM");
8915
9411
  const remoteAgent = instance.driverKind === "boxAgent";
8916
- const attached = await attachBotBox(readyBot, resourceOwner, { explicitCloud: true, canMount: remoteAgent, remoteAgent });
9412
+ const attached = await attachBotBox(readyBot, resourceOwner, { explicitCloud: true,
9413
+ canMount: remoteAgent || instance.adapter.capabilities.cloudComputerMcp === true, remoteAgent });
8917
9414
  if (!roomSetupIsCurrent())
8918
9415
  return false;
8919
9416
  if (!attached?.integration)
@@ -9048,9 +9545,10 @@ setupRetry = 0) {
9048
9545
  }
9049
9546
  }
9050
9547
  const roomSystem = buildSystemPrompt(system, store.bot(bot.id)?.soul ?? bot.soul ?? "", [
9548
+ { id: "user-profile", label: "About the user", text: userProfileSystemPrompt(cfg.profile) },
9051
9549
  { id: "files", label: "File locations", text: workspace ? workspaceLocationsPrompt(bot.id, cwd, readyBot.cwd) : "" },
9052
9550
  { 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) },
9551
+ { 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
9552
  { id: "team-computer", label: "Team computer", text: teamComputerPrompt(roomTeamComputer) },
9055
9553
  { id: "plan", label: "Surface", text: surfacePrompt({ computer: roomTeamComputer ? "cloud" : roomVmTarget ? "vm" : surfaceOfComputerKind(roomComputerKind), browser: Boolean(integrations.browser) }, { note: roomPlan.note }) },
9056
9554
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
@@ -9089,6 +9587,7 @@ setupRetry = 0) {
9089
9587
  store.setActivity(bot.id, "idle");
9090
9588
  retryDelegationsWaitingOn(bot.id);
9091
9589
  }
9590
+ watchdog.settle(threadId);
9092
9591
  return false;
9093
9592
  }
9094
9593
  let replyText = "";
@@ -9157,13 +9656,27 @@ setupRetry = 0) {
9157
9656
  deadline.setWaitingOnHuman(false);
9158
9657
  });
9159
9658
  deadline.start();
9659
+ // Swap the setup latch for the real completion handler. The swap is
9660
+ // synchronous with the dispatch below, so no stall can fall between
9661
+ // them. A stall latched during setup completes the turn here without a
9662
+ // provider turn: the stopped message is already on the thread, and the
9663
+ // watchdog's bounded cleanup owns releasing the claim.
9664
+ unregisterSetupStall();
9665
+ if (setupStalled) {
9666
+ // No provider turn was launched, so there is no ambiguous event
9667
+ // window to quarantine: marking a cancelled handshake here would
9668
+ // strand the next turn behind its TTL for no benefit. The readiness
9669
+ // stop path above skips the mark for the same reason.
9670
+ finish("stalled");
9671
+ return;
9672
+ }
9160
9673
  unregisterStall = roomStallCompletions.register(threadId, () => {
9161
9674
  abandonProviderTurn();
9162
9675
  finish("stalled");
9163
9676
  });
9164
- watchdog.watch(threadId, bot.id);
9165
9677
  onProviderHandshakeStarted?.();
9166
9678
  providerDispatched = true;
9679
+ turnPromptBytes.set(threadId, { stable: Buffer.byteLength(roomSystem.stable), volatile: Buffer.byteLength(roomSystem.volatile) });
9167
9680
  runningTurnEngines.set(threadId, instance);
9168
9681
  guardTurnDispatch(instance.adapter.sendTurn({
9169
9682
  threadId,
@@ -9379,11 +9892,12 @@ setupRetry = 0) {
9379
9892
  if (!roomSpeaker && !isSpendCap && !isVariantError)
9380
9893
  throw error;
9381
9894
  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
- });
9895
+ if (!(error instanceof ComputerWaitGaveUp))
9896
+ store.appendMessage(threadId, {
9897
+ role: "bot", kind: "activity",
9898
+ from: { botId: bot.id, name: bot.name, color: bot.color },
9899
+ tool: { name: `error: ${message}`, ok: false },
9900
+ });
9387
9901
  onDispatchError?.(message);
9388
9902
  if (orchestration) {
9389
9903
  // A spend-cap refusal is deterministic: record it as its own
@@ -9398,12 +9912,14 @@ setupRetry = 0) {
9398
9912
  finally {
9399
9913
  // Covers connector/setup failures, cancellation before dispatch, and all
9400
9914
  // other early returns that never produce a provider terminal event.
9915
+ unregisterSetupStall();
9401
9916
  revokeInternalCapabilityGeneration(threadId, internalGeneration);
9402
9917
  roomHandoffs.sourceSettled(internalGeneration, roomHandoffSourceSucceeded);
9403
9918
  if (!retainRoomVmLease)
9404
9919
  releaseRoomVmLease();
9405
9920
  if (!providerDispatched && roomSpeaker && groupSpeakers.get(threadId) === roomSpeaker) {
9406
9921
  groupSpeakers.delete(threadId);
9922
+ watchdog.settle(threadId);
9407
9923
  if (store.group(group.id)?.busyBotId === bot.id)
9408
9924
  store.patchGroup(group.id, { busyBotId: null });
9409
9925
  if (store.bot(bot.id)?.busy) {
@@ -10015,7 +10531,7 @@ function roomPostEligibility(bot, group) {
10015
10531
  }
10016
10532
  const outsider = group.memberIds
10017
10533
  .map((id) => store.bot(id))
10018
- .find((member) => member && !canAccessTeam(bot, member.section));
10534
+ .find((member) => member && !canAccessTeam(bot, member.section) && !coordinatorSupervises(member, bot));
10019
10535
  if (outsider) {
10020
10536
  return {
10021
10537
  ok: false,
@@ -10804,6 +11320,7 @@ function configStatus() {
10804
11320
  const eventLogRetentionDays = threadEventLogRetentionDays(cfg);
10805
11321
  return {
10806
11322
  xai: { configured: Boolean(cfg.xai?.key) },
11323
+ mistral: { configured: Boolean(cfg.mistral?.key) },
10807
11324
  anthropic: { configured: Boolean(cfg.anthropic?.key) },
10808
11325
  // a fleet agent on this server means Settings → Workspaces has something to drive
10809
11326
  fleet: { available: fleetAvailable(fleetSocketPath()) },
@@ -10831,12 +11348,14 @@ function configStatus() {
10831
11348
  tts: tts.describeVoice(cfg),
10832
11349
  imageGen: avatarImageStatus(cfg),
10833
11350
  // not a secret — the sidebar shows it
10834
- profile: { name: cfg.profile?.name ?? "", email: cfg.profile?.email ?? "" },
11351
+ profile: { name: cfg.profile?.name ?? "", email: cfg.profile?.email ?? "", aboutMe: cfg.profile?.aboutMe ?? "" },
10835
11352
  // the enrolled organisation's read-only desktop policy; null when not enrolled
10836
11353
  managedPolicy: managedPolicy.summary(),
10837
11354
  // not a secret — the settings picker shows it; "" = follow the system
10838
11355
  language: cfg.language ?? "",
10839
11356
  rooms: { turnTimeoutMinutes: roomTurnTimeoutMinutes(cfg) },
11357
+ // absent effort = no level is sent, so clients can tell it from any level
11358
+ newBots: cfg.newBots?.effort ? { effort: cfg.newBots.effort } : {},
10840
11359
  threads: {
10841
11360
  maxConcurrentPerBot: maxConcurrentBotThreads(cfg),
10842
11361
  ...(eventLogMaxBytes !== null ? { eventLogMaxBytes } : {}),
@@ -11235,6 +11754,7 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
11235
11754
  // Route modules (server/routes/README.md). `workspaceAccess` is assigned at
11236
11755
  // boot, after this line, so the dependency reads it per request.
11237
11756
  ROUTES.push(createHostedSlackRoutes({ bot: (id) => store.bot(id), hostedReady: () => Boolean(workspaceAccess) && entitled("admin") }));
11757
+ ROUTES.push(createBotPresetRoutes({ presets: presetStore, orgStatuses: readOrgInstallStatuses }));
11238
11758
  const toolResults = new ToolResults();
11239
11759
  const handleRequest = async (req, res) => {
11240
11760
  let url;
@@ -11659,6 +12179,21 @@ const handleRequest = async (req, res) => {
11659
12179
  // through a per-process high-entropy test key. The route does not exist
11660
12180
  // unless the launcher explicitly sets that key; production builds never
11661
12181
  // set it.
12182
+ // Isolated fixtures have no Electron parent to relay an organization's
12183
+ // library, so a test relays one here instead. Like the capability route
12184
+ // below, it exists only when the launcher sets its high-entropy key.
12185
+ if (method === "POST" && path === "/api/testing/org-library") {
12186
+ const expected = Buffer.from(process.env.OMB_TEST_ORG_LIBRARY_KEY ?? "");
12187
+ const header = req.headers["x-openmausbot-test-org-library"];
12188
+ const actual = Buffer.from(Array.isArray(header) ? "" : String(header ?? ""));
12189
+ if (expected.length < 32 || actual.length !== expected.length || !timingSafeEqual(actual, expected) || !orgLibrary) {
12190
+ return json(res, 404, { error: "not found" });
12191
+ }
12192
+ const body = await readBody(req);
12193
+ const applied = orgLibrary.applyRelay(body?.library ?? null);
12194
+ await orgLibrary.settled();
12195
+ return json(res, applied.ok ? 200 : 400, { ...applied, report: orgLibrary.lastReport() });
12196
+ }
11662
12197
  if (method === "POST" && path === "/api/testing/internal-capability") {
11663
12198
  const expected = process.env.OMB_TEST_INTERNAL_CAPABILITY_KEY ?? "";
11664
12199
  const actual = Array.isArray(req.headers["x-openmausbot-test-capability"])
@@ -11708,11 +12243,13 @@ const handleRequest = async (req, res) => {
11708
12243
  ? "hooks"
11709
12244
  : path === "/api/internal/browser/mcp"
11710
12245
  ? "browser"
11711
- : path.startsWith("/api/internal/connectors/")
11712
- ? "connectors"
11713
- : path === "/api/internal/computer-control"
11714
- ? "computer"
11715
- : "agents";
12246
+ : path === "/api/internal/phone/claim"
12247
+ ? "phone"
12248
+ : path.startsWith("/api/internal/connectors/")
12249
+ ? "connectors"
12250
+ : path === "/api/internal/computer-control"
12251
+ ? "computer"
12252
+ : "agents";
11716
12253
  if (internalCapability.kind !== requiredCapabilityKind) {
11717
12254
  return json(res, 403, { error: "this internal capability cannot access that service" });
11718
12255
  }
@@ -11870,6 +12407,17 @@ const handleRequest = async (req, res) => {
11870
12407
  requireActiveInternalCapability();
11871
12408
  return json(res, 200, { result });
11872
12409
  }
12410
+ if (method === "POST" && path === "/api/internal/phone/claim") {
12411
+ // Lazy phone exclusivity (issue #1663): the turn holds computer:phone
12412
+ // from its first real tool call until it settles. The proxy answers a
12413
+ // conflict as a blocked tool result, so a concurrent caller waits or
12414
+ // does other work instead of losing the whole turn.
12415
+ requireActiveInternalCapability();
12416
+ if (!claimTurnResource(internalCapability, "computer:phone")) {
12417
+ return json(res, 409, { error: "another thread is using the phone" });
12418
+ }
12419
+ return json(res, 200, { claimed: true });
12420
+ }
11873
12421
  // Off by default: both fall through to the same "unknown internal
11874
12422
  // endpoint" 404 a never-implemented route returns.
11875
12423
  if (method === "GET" && path === "/api/internal/shared-computers" && sharedComputersEnabled(cfg))
@@ -12157,7 +12705,7 @@ const handleRequest = async (req, res) => {
12157
12705
  return json(res, 200, {
12158
12706
  teams: teamSetupTeams().filter((name) => canAccessTeam(chief, name)),
12159
12707
  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 })),
12708
+ .map((bot) => ({ id: bot.id, name: bot.name, title: bot.title, section: bot.section ?? "", modelSelection: bot.modelSelection, chiefOfStaff: Boolean(bot.chiefOfStaff) })),
12161
12709
  instances: instances.map((instance) => ({ instanceId: instance.instanceId, driverKind: instance.driverKind, displayName: instance.displayName,
12162
12710
  state: instance.snapshot.state, models: instance.models, effortLevels: instance.capabilities?.effortLevels ?? [] })),
12163
12711
  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.",
@@ -12497,14 +13045,14 @@ const handleRequest = async (req, res) => {
12497
13045
  //
12498
13046
  // per-bot approval gate: a chief-of-staff bot without this on is
12499
13047
  // 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
13048
+ // (15-min timeout → expired) before its peer turn starts. The channel
12501
13049
  // and the chips are created only AFTER the verdict, so a denied
12502
13050
  // contact leaves no trace of an exchange that never happened.
12503
13051
  if (peerReviewRequired(from, fromThreadId)) {
12504
13052
  const verdict = await requestPeerApproval(approvalBus, from, target, message, "ask_bot", fromThreadId);
12505
13053
  requireActiveInternalCapability();
12506
13054
  if (verdict !== "allow")
12507
- return json(res, 200, { error: "denied by user" });
13055
+ return json(res, 200, peerApprovalFailure(verdict));
12508
13056
  // The card may have been open for minutes. Re-read both records so
12509
13057
  // deleted bots cannot recreate transcripts through stale objects.
12510
13058
  const freshFrom = store.bot(fromBotId);
@@ -12614,7 +13162,12 @@ const handleRequest = async (req, res) => {
12614
13162
  if (!sender || (target && !canReachPeer(sender, target))) {
12615
13163
  return json(res, 403, { error: "Result withheld: team access changed while the teammate was working" });
12616
13164
  }
12617
- return json(res, 200, { status: receipt.status, toBotName: receipt.toBotName, result: receipt.result ?? "" });
13165
+ return json(res, 200, {
13166
+ status: receipt.status, toBotName: receipt.toBotName, result: receipt.result ?? "",
13167
+ ...(receipt.approvalOutcome ? {
13168
+ approvalOutcome: receipt.approvalOutcome, approvalSource: receipt.approvalSource,
13169
+ } : {}),
13170
+ });
12618
13171
  }
12619
13172
  const stillQueued = pendingDelegationInfo(taskId);
12620
13173
  const runningEntry = [...delegationWatch.entries()].find(([, watch]) => watch.taskId === taskId);
@@ -12841,8 +13394,15 @@ const handleRequest = async (req, res) => {
12841
13394
  if (peerReviewRequired(internalSender, address.threadId)) {
12842
13395
  const verdicts = await Promise.all(targets.map(target => requestPeerApproval(approvalBus, internalSender, store.bot(target.botId), parsed.data.message, "delegate_bot", address.threadId)));
12843
13396
  requireActiveInternalCapability();
12844
- if (verdicts.some(verdict => verdict !== "allow"))
12845
- return json(res, 403, { error: "Denied by user; no work sent." });
13397
+ const blocked = verdicts.flatMap((verdict, index) => verdict === "allow" ? []
13398
+ : [{ botId: targets[index].botId, ...peerApprovalFailure(verdict) }]);
13399
+ if (blocked.length)
13400
+ return json(res, 403, {
13401
+ error: blocked.every(failure => failure.approvalOutcome === "deny")
13402
+ ? "Denied by user; no work sent."
13403
+ : `${blocked.map(failure => failure.error).join("; ")}; no work sent.`,
13404
+ approvals: blocked,
13405
+ });
12846
13406
  approvalGranted = true;
12847
13407
  }
12848
13408
  const accepted = [];
@@ -12878,7 +13438,7 @@ const handleRequest = async (req, res) => {
12878
13438
  grantDelegatedFullAccess(internalSender, store.bot(target.botId), target.threadId);
12879
13439
  }
12880
13440
  }
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 ?? "");
13441
+ 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
13442
  // A re-dispatched request_key is answered by the request it
12883
13443
  // already made, so a thread resolved for the retry (the pair
12884
13444
  // conversation was busy with that very request) goes back
@@ -12998,7 +13558,7 @@ const handleRequest = async (req, res) => {
12998
13558
  const verdict = await requestPeerApproval(approvalBus, from, { id: room.id, name: room.name }, message, "post_to_room", fromThreadId);
12999
13559
  requireActiveInternalCapability();
13000
13560
  if (verdict !== "allow")
13001
- return json(res, 200, { error: "denied by user" });
13561
+ return json(res, 200, peerApprovalFailure(verdict));
13002
13562
  // The card may have been open for minutes. Re-read both records so a
13003
13563
  // roster change, a section move, or a deletion during that window
13004
13564
  // cannot be posted through on a stale decision.
@@ -13219,6 +13779,26 @@ const handleRequest = async (req, res) => {
13219
13779
  if (instructions.length > 1_000) {
13220
13780
  return json(res, 400, { error: "instructions must be at most 1000 characters" });
13221
13781
  }
13782
+ let selection;
13783
+ if (body.modelSelection === undefined) {
13784
+ selection = await defaultSelection();
13785
+ }
13786
+ else {
13787
+ const checked = checkedModelSelection(body.modelSelection, undefined, true);
13788
+ if (!checked.ok)
13789
+ return json(res, checked.status, { error: checked.error });
13790
+ selection = checked.selection;
13791
+ }
13792
+ if (hostedModels && !hostedModels.allows(selection))
13793
+ return json(res, 400, { error: hostedModels.error() });
13794
+ // Discovery can yield; check current authority and capacity again before writing.
13795
+ if (store.bot(chief.id) !== chief || chief.hidden || !chief.chiefOfStaff || !connectorThread(chief.id, fromThreadId)) {
13796
+ return json(res, 403, { error: "only an active Chief of Staff can create operator bots" });
13797
+ }
13798
+ if (internalCapability.createdBots >= 4)
13799
+ return json(res, 429, { error: "you can create at most 4 bots in one turn" });
13800
+ if (store.bots.length >= MAX_WORKSPACE_BOTS)
13801
+ return json(res, 409, { error: `this workspace is limited to ${MAX_WORKSPACE_BOTS} bots` });
13222
13802
  const duplicate = store.bots.find((candidate) => !candidate.hidden &&
13223
13803
  sectionKey(candidate.section) === sectionKey(chief.section) &&
13224
13804
  candidate.name.trim().toLowerCase() === name.toLowerCase());
@@ -13229,13 +13809,14 @@ const handleRequest = async (req, res) => {
13229
13809
  name,
13230
13810
  title: role,
13231
13811
  description: instructions,
13232
- modelSelection: { ...chief.modelSelection },
13812
+ modelSelection: selection,
13233
13813
  section: chief.section,
13234
13814
  // exactly the Chief's audience: a restricted Chief never makes a bot everyone sees
13235
13815
  ...(chief.visibility ? { visibility: chief.visibility } : {}),
13236
13816
  }, { seedMessages: false });
13237
13817
  const safeBot = store.patchBot(created.id, {
13238
13818
  composio: false,
13819
+ connectorTools: {},
13239
13820
  autoApprove: false,
13240
13821
  approvePeerComms: false,
13241
13822
  });
@@ -13246,6 +13827,7 @@ const handleRequest = async (req, res) => {
13246
13827
  title: safeBot.title,
13247
13828
  section: safeBot.section || "General",
13248
13829
  model: safeBot.modelSelection.model,
13830
+ modelSelection: safeBot.modelSelection,
13249
13831
  });
13250
13832
  }
13251
13833
  if (method === "POST" && (path === "/api/internal/create-room" || path === "/api/internal/manage-room")) {
@@ -13369,6 +13951,47 @@ const handleRequest = async (req, res) => {
13369
13951
  });
13370
13952
  return json(res, 201, { messageId: message.id, label: target.label });
13371
13953
  }
13954
+ if (method === "POST" && path === "/api/internal/voice-note") {
13955
+ const body = await readInternalBody();
13956
+ const text = typeof body.text === "string" ? body.text.trim() : "";
13957
+ if (!text)
13958
+ return json(res, 400, { error: "text required" });
13959
+ if (text.length > 1000)
13960
+ return json(res, 413, { error: "voice notes are limited to 1000 characters" });
13961
+ // Advertisement is presentation only; the capability is re-checked
13962
+ // here so a call that reaches an unconfigured workspace answers with
13963
+ // the setup guidance instead of a provider failure.
13964
+ const voiceBot = botForThread(internalSender.id, internalCapability.threadId) ?? internalSender;
13965
+ try {
13966
+ const audio = await tts.speak(cfg, text, voiceBot.voice);
13967
+ // Synthesis can outlive its turn: a stop or settle during the await
13968
+ // revokes the capability and clears the live turn. Re-check both
13969
+ // before saving so the clip cannot park under the :active fallback
13970
+ // or onto a later turn's key (#1742).
13971
+ requireActiveInternalCapability();
13972
+ const turnId = liveTurnByThread.get(internalCapability.threadId);
13973
+ if (!turnId) {
13974
+ return json(res, 409, { error: "this turn has not started yet or already ended" });
13975
+ }
13976
+ const saved = saveAudio(Buffer.from(audio.bytes), audio.mime);
13977
+ const key = turnAttachmentKey(internalCapability.threadId, turnId);
13978
+ const current = turnAttachmentsByTurn.get(key) ?? [];
13979
+ current.push({ kind: "audio", path: saved.path, mime: saved.mime, text });
13980
+ turnAttachmentsByTurn.set(key, current);
13981
+ return json(res, 201, { attached: true });
13982
+ }
13983
+ catch (error) {
13984
+ // "no voice configured" is a setup state, not a provider failure —
13985
+ // 409 with the hint, exactly like /api/tts/speak.
13986
+ if (error instanceof tts.NoVoiceConfigured)
13987
+ return json(res, 409, { error: error.message });
13988
+ const status = error.status;
13989
+ if (typeof status === "number" && status >= 400 && status < 500) {
13990
+ return json(res, status, { error: error instanceof Error ? error.message : "voice note failed" });
13991
+ }
13992
+ throw error;
13993
+ }
13994
+ }
13372
13995
  if (method === "POST" && path === "/api/internal/connectors/mcp") {
13373
13996
  const body = await readInternalBody();
13374
13997
  // Reading a streamed MCP body yields to ordinary settings requests.
@@ -13378,6 +14001,72 @@ const handleRequest = async (req, res) => {
13378
14001
  if (!currentSender || currentSender.composio === false || !composio.configured(cfg)) {
13379
14002
  return json(res, 403, { error: "connected apps are not enabled for this bot" });
13380
14003
  }
14004
+ // Per-bot tool grants (issue #1736): verdict every tools/call frame
14005
+ // against the calling bot's connectorTools before it reaches
14006
+ // Composio. A bot with no grants record keeps the legacy all-tools
14007
+ // behavior; a grants record makes every unrecognized shape a deny.
14008
+ // Rows are fire-and-forget: a log failure must never take the call
14009
+ // (or its refusal) down with it.
14010
+ const call = connectorCallFromFrame(body);
14011
+ if (call.kind === "unrecognized") {
14012
+ appendDecision(DATA_DIR, {
14013
+ threadId: internalCapability.threadId,
14014
+ botId: currentSender.id,
14015
+ botName: currentSender.name,
14016
+ tool: call.invoked,
14017
+ summary: call.reason,
14018
+ decision: "user-denied",
14019
+ source: "connector-scope",
14020
+ rule: "connectorTools",
14021
+ });
14022
+ const refusal = connectorUnrecognizedText(call.invoked, call.reason);
14023
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
14024
+ return res.end(JSON.stringify({
14025
+ jsonrpc: "2.0",
14026
+ id: body.id ?? null,
14027
+ result: { content: [{ type: "text", text: refusal }], isError: true },
14028
+ }));
14029
+ }
14030
+ if (call.kind === "tools") {
14031
+ const verdict = evaluateConnectorTools(call.names, currentSender.connectorTools);
14032
+ if (!verdict.allowed) {
14033
+ for (const denial of verdict.denials) {
14034
+ appendDecision(DATA_DIR, {
14035
+ threadId: internalCapability.threadId,
14036
+ botId: currentSender.id,
14037
+ botName: currentSender.name,
14038
+ tool: denial.tool,
14039
+ summary: denial.service === null
14040
+ ? "tool name does not name a service"
14041
+ : denial.onGrantedService
14042
+ ? "tool is not in this service's grant"
14043
+ : "service is not granted",
14044
+ decision: "user-denied",
14045
+ source: "connector-scope",
14046
+ rule: denial.service ? "connectorTools." + denial.service : "connectorTools",
14047
+ });
14048
+ }
14049
+ const refusal = connectorRefusalText(verdict.denials);
14050
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
14051
+ return res.end(JSON.stringify({
14052
+ jsonrpc: "2.0",
14053
+ id: body.id ?? null,
14054
+ result: { content: [{ type: "text", text: refusal }], isError: true },
14055
+ }));
14056
+ }
14057
+ // One row per allowed call, naming the first target: the audit
14058
+ // trail reads "which bot ran what", not one row per tool.
14059
+ appendDecision(DATA_DIR, {
14060
+ threadId: internalCapability.threadId,
14061
+ botId: currentSender.id,
14062
+ botName: currentSender.name,
14063
+ tool: call.names[0],
14064
+ summary: ("allowed " + call.names.join(", ")).slice(0, 240),
14065
+ decision: "user-approved",
14066
+ source: "connector-scope",
14067
+ rule: verdict.rule,
14068
+ });
14069
+ }
13381
14070
  const upstream = await composio.relayMcp(cfg, body, Array.isArray(req.headers["mcp-session-id"])
13382
14071
  ? req.headers["mcp-session-id"][0]
13383
14072
  : req.headers["mcp-session-id"]);
@@ -13873,6 +14562,10 @@ const handleRequest = async (req, res) => {
13873
14562
  }
13874
14563
  };
13875
14564
  if (method === "GET" && path === "/api/bots") {
14565
+ try {
14566
+ groupUsageReader.refresh();
14567
+ }
14568
+ catch { /* accounting must not block a snapshot */ }
13876
14569
  const limit = pageSize(url.searchParams.get("messages"));
13877
14570
  if (limit === null)
13878
14571
  return json(res, 400, { error: "messages must be a non-negative whole number" });
@@ -14304,8 +14997,102 @@ const handleRequest = async (req, res) => {
14304
14997
  return json(res, 201, { group: { ...publicGroupState(group), messages: [] } });
14305
14998
  }
14306
14999
  if (method === "POST" && path === "/api/teams/export") {
14307
- const body = await readBody(req);
15000
+ // Pictures the Share dialog prepared ride this body, so it is larger
15001
+ // than the default JSON limit (the document itself is capped at 4 MB).
15002
+ const body = await readBody(req, 8 * 1024 * 1024);
14308
15003
  const profileName = cfg.profile?.name?.trim();
15004
+ if (body.format === "package" && body.version === 2 && body.kind === "library") {
15005
+ // Preset bots and their skills, no team: my New bot defaults as a
15006
+ // preset someone else picks in New bot (presets.ts has the allowlist).
15007
+ const optional = (value) => (typeof value === "string" ? value : undefined);
15008
+ const built = defaultsPresetFor(body, body.includeMemory === true);
15009
+ try {
15010
+ const exported = createLibraryPackageExport({
15011
+ name: optional(body.name),
15012
+ tagline: optional(body.tagline),
15013
+ summary: optional(body.summary),
15014
+ release: optional(body.release),
15015
+ notes: optional(body.notes),
15016
+ authorName: profileName,
15017
+ published: readPublishedLibrary(),
15018
+ preset: built.preset,
15019
+ skipped: built.skipped,
15020
+ });
15021
+ if (body.dryRun !== true)
15022
+ writePublishedLibrary(exported.published);
15023
+ return json(res, 200, {
15024
+ document: exported.document,
15025
+ filename: exported.filename,
15026
+ redacted: exported.redacted,
15027
+ skipped: exported.skipped,
15028
+ summary: packageSummary(exported.document),
15029
+ });
15030
+ }
15031
+ catch (error) {
15032
+ if (error instanceof TeamExportError)
15033
+ return json(res, error.status, { error: error.message });
15034
+ throw error;
15035
+ }
15036
+ }
15037
+ if (body.format === "package" && body.version === 2) {
15038
+ // One team, whole, except its chat history (package format v2).
15039
+ if (typeof body.team !== "string")
15040
+ return json(res, 400, { error: "Choose a team to share." });
15041
+ const team = body.team.trim();
15042
+ if (team && !store.sections.includes(team) && !store.bots.some((bot) => sectionKey(bot.section) === team)) {
15043
+ return json(res, 404, { error: "That team no longer exists." });
15044
+ }
15045
+ const optional = (value) => (typeof value === "string" ? value : undefined);
15046
+ const teamBots = store.bots.filter((bot) => !bot.hidden && sectionKey(bot.section) === team);
15047
+ const skills = collectTeamSkills(teamBots, body.skills);
15048
+ if (!skills.ok)
15049
+ return json(res, 400, { error: skills.error, choices: { skills: skills.available } });
15050
+ const avatars = body.avatars && typeof body.avatars === "object" && !Array.isArray(body.avatars)
15051
+ ? body.avatars
15052
+ : undefined;
15053
+ // Off unless asked: New bot defaults are personal until shared.
15054
+ const defaults = body.includeDefaultsPreset === true ? defaultsPresetFor(body, body.includeMemory === true) : null;
15055
+ try {
15056
+ const exported = createTeamPackageExport({
15057
+ team,
15058
+ name: optional(body.name),
15059
+ tagline: optional(body.tagline),
15060
+ summary: optional(body.summary),
15061
+ release: optional(body.release),
15062
+ notes: optional(body.notes),
15063
+ authorName: profileName,
15064
+ bots: store.bots,
15065
+ groups: store.groups,
15066
+ routines: routines.listRoutines(),
15067
+ brief: readSectionContext(team)?.text,
15068
+ published: readPublishedTeam(team),
15069
+ skillsByBot: skills.skillsByBot,
15070
+ skillSelection: skills.all ? "all" : "chosen",
15071
+ memoryByBot: body.includeMemory === true ? new Map(teamBots.map((bot) => [bot.id, starterNotes(bot.id)])) : undefined,
15072
+ avatars,
15073
+ mcpServers: cfg.mcpServers ?? {},
15074
+ skipped: [...skills.skipped, ...(defaults?.skipped ?? [])],
15075
+ preset: defaults?.preset ?? null,
15076
+ });
15077
+ // A preview (the dialog's live counts) never records keys; saving
15078
+ // the file does, so renaming anything later keeps its identity.
15079
+ if (body.dryRun !== true)
15080
+ writePublishedTeam(team, exported.published);
15081
+ return json(res, 200, {
15082
+ document: exported.document,
15083
+ filename: exported.filename,
15084
+ redacted: exported.redacted,
15085
+ skipped: exported.skipped,
15086
+ summary: packageSummary(exported.document),
15087
+ choices: { skills: skills.available },
15088
+ });
15089
+ }
15090
+ catch (error) {
15091
+ if (error instanceof TeamExportError)
15092
+ return json(res, error.status, { error: error.message, choices: { skills: skills.available } });
15093
+ throw error;
15094
+ }
15095
+ }
14309
15096
  const name = typeof body.name === "string" && body.name.trim()
14310
15097
  ? body.name.trim()
14311
15098
  : profileName
@@ -14414,6 +15201,64 @@ const handleRequest = async (req, res) => {
14414
15201
  }
14415
15202
  return json(res, 200, { directory });
14416
15203
  }
15204
+ // ── the organization library (server/org-library.ts) ─────────────────
15205
+ // Admin scope, as import is. With no organization every read answers
15206
+ // {organization: null} and nothing else changes.
15207
+ if (method === "GET" && path === "/api/org-library") {
15208
+ return json(res, 200, orgLibrary?.list() ?? { organization: null, packages: [] });
15209
+ }
15210
+ m = path.match(/^\/api\/org-library\/packages\/([a-f0-9-]{36})$/);
15211
+ if (m && method === "GET") {
15212
+ const outcome = orgLibrary?.preview(m[1]);
15213
+ if (!outcome)
15214
+ return json(res, 404, { error: "This package is not in your organization's library.", code: "not_listed" });
15215
+ return outcome.ok ? json(res, 200, outcome.value) : json(res, outcome.status, { error: outcome.error, code: outcome.code });
15216
+ }
15217
+ if (method === "POST" && path === "/api/org-library/add") {
15218
+ // One click, no confirmation: the person's click is the decision.
15219
+ const body = await readBody(req);
15220
+ const packageId = typeof body?.packageId === "string" && /^[a-f0-9-]{36}$/.test(body.packageId) ? body.packageId : null;
15221
+ if (!packageId)
15222
+ return json(res, 400, { error: "Choose a package from your organization's library." });
15223
+ if (!orgLibrary)
15224
+ return json(res, 404, { error: "This package is not in your organization's library.", code: "not_listed" });
15225
+ const outcome = orgLibrary.add(packageId, packageImportDeps(await defaultSelection()));
15226
+ if (!outcome.ok)
15227
+ return json(res, outcome.status, { error: outcome.error, code: outcome.code });
15228
+ if (outcome.value.alreadyAdded)
15229
+ return json(res, 200, outcome.value);
15230
+ const imported = outcome.value.result;
15231
+ return json(res, 201, {
15232
+ alreadyAdded: false,
15233
+ installId: imported.installId,
15234
+ name: imported.name,
15235
+ section: imported.section,
15236
+ bots: imported.bots.map((bot) => publicBot(store.bot(bot.id) ?? bot)),
15237
+ groups: imported.groups.map((created) => ({ ...publicGroupState(store.group(created.id) ?? created), messages: [] })),
15238
+ routines: imported.routines,
15239
+ offeredSkills: imported.offeredSkills,
15240
+ connections: imported.connections,
15241
+ skipped: imported.skipped,
15242
+ brief: imported.brief,
15243
+ notes: imported.notes,
15244
+ presets: imported.presets ?? [],
15245
+ });
15246
+ }
15247
+ if (method === "GET" && path === "/api/org-library/skills") {
15248
+ const botId = url.searchParams.get("botId") ?? undefined;
15249
+ return json(res, 200, orgLibrary?.offeredSkills(botId) ?? { organization: null, skills: [] });
15250
+ }
15251
+ if (method === "POST" && path === "/api/org-library/skills") {
15252
+ const body = await readBody(req);
15253
+ 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) })
15254
+ .safeParse(body);
15255
+ if (!parsed.success)
15256
+ return json(res, 400, { error: "Choose a bot and a skill from your organization's library." });
15257
+ if (!orgLibrary)
15258
+ return json(res, 404, { error: "This package is not in your organization's library.", code: "not_listed" });
15259
+ const outcome = orgLibrary.addOfferedSkill(parsed.data.botId, parsed.data.installId, parsed.data.name);
15260
+ return outcome.ok ? json(res, 201, { skill: outcome.value }) : json(res, outcome.status, { error: outcome.error, code: outcome.code });
15261
+ }
14417
15262
  if (method === "POST" && path === "/api/teams/import") {
14418
15263
  // Import is additive-only. A manifest is untrusted input (catalog,
14419
15264
  // GitHub, a shared file), so it must be structurally unable to reach
@@ -14479,163 +15324,59 @@ const handleRequest = async (req, res) => {
14479
15324
  return json(res, 400, { error: error instanceof Error ? error.message : "Backup could not be imported" });
14480
15325
  }
14481
15326
  }
15327
+ // Every package (v1 JSON, BotMRR Markdown, v2) is read by the one shared
15328
+ // parser as a FILE: it cannot claim a publisher, and its skills and
15329
+ // routines arrive off. A body field such as `trust` is just an unknown
15330
+ // field of the document and is stripped; only the organization library
15331
+ // module ever imports with trust "org".
14482
15332
  let packageDocument = null;
14483
15333
  let manifest = null;
14484
15334
  try {
14485
15335
  if (isBotPackage(body))
14486
- packageDocument = parseBotPackage(body);
15336
+ packageDocument = parsePackageDocument(body, { trust: "file" });
14487
15337
  else
14488
15338
  manifest = parseTeamManifest(body);
14489
15339
  }
14490
15340
  catch (error) {
14491
15341
  return json(res, 400, { error: error instanceof Error ? error.message : "Invalid bot package" });
14492
15342
  }
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;
15343
+ const deps = packageImportDeps(await defaultSelection());
15344
+ const options = {
15345
+ mode: importMode,
15346
+ cwd: projectCwd,
15347
+ room: url.searchParams.get("room") ?? undefined,
15348
+ ...(importVisibility ? { visibility: importVisibility } : {}),
15349
+ };
15350
+ let imported;
14508
15351
  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
- });
15352
+ const result = packageDocument
15353
+ ? importPackageDocument(packageDocument, { ...options, trust: "file" }, deps)
15354
+ : importTeamManifest(manifest, options, deps);
15355
+ // A file import always gets a fresh install id, so it never reports
15356
+ // "already added"; that answer belongs to the organization library.
15357
+ if (result.alreadyAdded)
15358
+ return json(res, 200, result);
15359
+ imported = result;
14623
15360
  }
14624
15361
  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);
15362
+ if (error instanceof PackageImportError)
15363
+ return json(res, error.status, { error: error.message, code: error.code });
14637
15364
  throw error;
14638
15365
  }
15366
+ return json(res, 201, {
15367
+ name: imported.name,
15368
+ section: imported.section,
15369
+ bots: imported.bots.map((bot) => publicBot(store.bot(bot.id) ?? bot)),
15370
+ group: imported.group,
15371
+ groups: imported.groups.map((created) => ({ ...created, messages: [] })),
15372
+ routines: imported.routines,
15373
+ offeredSkills: imported.offeredSkills,
15374
+ connections: imported.connections,
15375
+ skipped: imported.skipped,
15376
+ brief: imported.brief,
15377
+ notes: imported.notes,
15378
+ presets: imported.presets ?? [],
15379
+ });
14639
15380
  }
14640
15381
  m = path.match(/^\/api\/groups\/([\w-]+)\/setup$/);
14641
15382
  if (m && method === "PATCH") {
@@ -15187,8 +15928,8 @@ const handleRequest = async (req, res) => {
15187
15928
  const section = url.searchParams.get("section")?.trim();
15188
15929
  if (!section)
15189
15930
  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" });
15931
+ if (method === "DELETE" && teamComputers.forSection(section))
15932
+ return json(res, 409, { error: "Unassign this team's computer before deleting the team" });
15192
15933
  let nextName = null;
15193
15934
  if (method === "PATCH") {
15194
15935
  const parsed = z.object({ name: z.string().trim().min(1).max(60) }).strict().safeParse(await readBody(req));
@@ -15196,11 +15937,33 @@ const handleRequest = async (req, res) => {
15196
15937
  return json(res, 400, { error: "Team name must be 1 to 60 characters" });
15197
15938
  nextName = parsed.data.name;
15198
15939
  }
15199
- const error = store.changeEmptySection(section, nextName);
15940
+ const error = nextName === null ? store.deleteSection(section) : store.renameSection(section, nextName, teamComputers);
15200
15941
  if (error)
15201
15942
  return json(res, error === "No such team" ? 404 : 409, { error });
15202
15943
  return json(res, 200, { sections: store.sections });
15203
15944
  }
15945
+ if (method === "PUT" && path === "/api/sidebar-sections") {
15946
+ const section = url.searchParams.get("section")?.trim();
15947
+ if (!section || !store.sections.includes(section))
15948
+ return json(res, 404, { error: "No such team" });
15949
+ const ids = z.array(z.string().regex(/^[\w-]+$/)).max(MAX_WORKSPACE_BOTS);
15950
+ const parsed = z.object({ addBotIds: ids, removeBotIds: ids }).strict().safeParse(await readBody(req));
15951
+ if (!parsed.success)
15952
+ return json(res, 400, { error: "Provide addBotIds and removeBotIds" });
15953
+ for (const [botIds, destination] of [[parsed.data.addBotIds, section], [parsed.data.removeBotIds, undefined]]) {
15954
+ for (const id of botIds) {
15955
+ const bot = store.bot(id);
15956
+ if (bot)
15957
+ assertTeamComputerChangeIdle(bot, { ...bot, section: destination });
15958
+ }
15959
+ }
15960
+ const result = store.updateTeamMembers(section, parsed.data.addBotIds, parsed.data.removeBotIds);
15961
+ if (!result.ok)
15962
+ 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."
15963
+ : result.reason === "membership-changed" ? "Team membership changed. Reopen the dialog and try again."
15964
+ : "One or more bots are unavailable" });
15965
+ return json(res, 200, { sections: store.sections, bots: result.bots.map(wireBot) });
15966
+ }
15204
15967
  if (method === "POST" && path === "/api/sidebar-sections") {
15205
15968
  const parsed = createSidebarSectionSchema.safeParse(await readBody(req));
15206
15969
  if (!parsed.success) {
@@ -15229,11 +15992,108 @@ const handleRequest = async (req, res) => {
15229
15992
  }
15230
15993
  return json(res, 200, { section: name, sections: store.sections, bots: result.bots.map(wireBot) });
15231
15994
  }
15232
- if (method === "POST" && path === "/api/bots") {
15995
+ if (method === "GET" && path === "/api/bot-defaults") {
15996
+ return json(res, 200, {
15997
+ defaults: structuredClone(cfg.newBotDefaults ?? newBotDefaultsSchema.parse({})),
15998
+ modelSelection: await defaultSelection(),
15999
+ suggestedName: pickBotName(store.bots.map(bot => bot.name)),
16000
+ });
16001
+ }
16002
+ if (method === "POST" && path === "/api/bot-defaults/skills/preview") {
16003
+ const checked = z.object({ source: z.string().min(1).max(2000) }).strict().safeParse(await readBody(req));
16004
+ if (!checked.success)
16005
+ return json(res, 400, { error: checked.error.message });
16006
+ const input = checked.data;
16007
+ const fetched = await fetchSkillFromSource(input.source);
16008
+ if ("error" in fetched)
16009
+ return json(res, 422, { error: fetched.error });
16010
+ const skills = [];
16011
+ for (const skill of fetched.skills) {
16012
+ const file = skill.files.find(file => file.path === "SKILL.md" || file.path.endsWith("/SKILL.md"));
16013
+ if (!file)
16014
+ continue;
16015
+ const parsed = parseSkillMd(file.content);
16016
+ if ("error" in parsed)
16017
+ return json(res, 422, { error: parsed.error });
16018
+ const skippedWarnings = skill.files.filter(candidate => candidate !== file)
16019
+ .map(candidate => `skipped supporting file "${candidate.path}" — v1 imports only SKILL.md`);
16020
+ const template = botSkillTemplateSchema.safeParse({ name: parsed.name, description: parsed.description, source: skill.source, text: file.content,
16021
+ enabled: false, warnings: [...scanSkillText(file.content), ...skippedWarnings] });
16022
+ if (!template.success)
16023
+ return json(res, 422, { error: template.error.message });
16024
+ skills.push(template.data);
16025
+ }
16026
+ return json(res, 200, { skills });
16027
+ }
16028
+ if (method === "POST" && path === "/api/bot-defaults/avatar") {
15233
16029
  const body = await readBody(req);
16030
+ if (!body || typeof body !== "object" || Array.isArray(body))
16031
+ return json(res, 400, { error: "avatar preview must be a JSON object" });
16032
+ const profile = parseBotProfilePatch(body.profile, true);
16033
+ if (!profile.ok)
16034
+ return json(res, 400, { error: profile.error });
16035
+ const checked = avatarGenerationRequestSchema.safeParse({ prompt: body.prompt });
16036
+ if (!checked.success)
16037
+ return json(res, 400, { error: checked.error.message });
16038
+ const { prompt } = checked.data;
16039
+ const generated = await generateAvatarImage(cfg, {
16040
+ name: profile.patch.name ?? "", title: profile.patch.title ?? "", description: profile.patch.description ?? "",
16041
+ }, prompt);
16042
+ // A preview belongs to the draft. Upload it through the existing image
16043
+ // endpoint only when the user commits the new bot or its defaults.
16044
+ return json(res, 200, { dataUrl: `data:${generated.mime};base64,${generated.bytes.toString("base64")}` });
16045
+ }
16046
+ if (method === "POST" && path === "/api/bots") {
16047
+ let body = await readBody(req);
15234
16048
  if (!body || typeof body !== "object" || Array.isArray(body)) {
15235
16049
  return json(res, 400, { error: "bot must be a JSON object" });
15236
16050
  }
16051
+ const template = resolveBotCreationDefaults(cfg.newBotDefaults, body);
16052
+ const settings = template.profile;
16053
+ if (auth.kind === "session" && !auth.scopes.includes("admin")) {
16054
+ const field = clientBotPatchViolation(settings);
16055
+ if (field || body.preset !== undefined || template.skills.length || template.routines.length || Object.keys(template.memory).length) {
16056
+ return json(res, 403, { error: "Creating a bot with these defaults needs the admin scope" });
16057
+ }
16058
+ }
16059
+ // A preset (presets.ts) supplies content: playbooks, skills and
16060
+ // starter notes. Name, look and instructions stay the request's own.
16061
+ if (body.preset !== undefined && (typeof body.preset !== "string" || !/^[\w-]{1,64}$/.test(body.preset))) {
16062
+ return json(res, 400, { error: "preset must be a preset id from New bot" });
16063
+ }
16064
+ const resolvedPreset = typeof body.preset === "string" ? presetStore.resolve(body.preset) : null;
16065
+ const preset = resolvedPreset && presetOffered(resolvedPreset.row, readOrgInstallStatuses()) ? resolvedPreset : null;
16066
+ if (body.preset !== undefined && !preset)
16067
+ return json(res, 404, { error: PRESET_UNAVAILABLE_MESSAGE });
16068
+ body = { ...body, ...settings, name: settings.name };
16069
+ if (settings.approvalMode === "full" || settings.approvalMode === "custom") {
16070
+ 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." });
16071
+ }
16072
+ if (settings.computer === "local" && settings.approvalMode === "auto" && body.acknowledgeLocalAuto !== true) {
16073
+ return json(res, 400, { error: "Auto mode on this computer requires confirming the warning first (acknowledgeLocalAuto)" });
16074
+ }
16075
+ if (settings.avatarUrl && !storedAvatarExists(settings.avatarUrl)) {
16076
+ return json(res, 400, { error: "avatarUrl must reference an existing stored image" });
16077
+ }
16078
+ if (settings.browserProfile && settings.browserProfile !== "guest" && !(cfg.browserProfiles ?? []).some(profile => profile.id === settings.browserProfile)) {
16079
+ return json(res, 400, { error: "browserProfile must name an existing browser profile" });
16080
+ }
16081
+ if (settings.mcpServers?.some(name => mcpServerNameError(name) !== null) || (settings.mcpServers?.length ?? 0) > MAX_MCP_SERVERS) {
16082
+ return json(res, 400, { error: "Invalid MCP server selection" });
16083
+ }
16084
+ if (settings.managedSections?.length) {
16085
+ if (!settings.chiefOfStaff || settings.managedSections.some(name => name !== "" && !store.sections.includes(name))) {
16086
+ return json(res, 400, { error: "Only a Chief may manage existing additional teams" });
16087
+ }
16088
+ if (body.acknowledgePeerScope !== true)
16089
+ return json(res, 400, { error: "Confirm additional team access (acknowledgePeerScope)" });
16090
+ if (auth.kind === "loopback" && !DESKTOP_MANAGED && store.bots.some(bot => bot.busy)) {
16091
+ return json(res, 409, { error: "Stop running bots before granting access to another team" });
16092
+ }
16093
+ }
16094
+ const checkedCwd = validateBotCwd(settings.cwd ?? null);
16095
+ if (!checkedCwd.ok)
16096
+ return json(res, 400, { error: checkedCwd.error });
15237
16097
  if (body.requireAvailableModel !== undefined && typeof body.requireAvailableModel !== "boolean") {
15238
16098
  return json(res, 400, { error: "requireAvailableModel must be true or false" });
15239
16099
  }
@@ -15272,14 +16132,91 @@ const handleRequest = async (req, res) => {
15272
16132
  if (store.bots.length >= MAX_WORKSPACE_BOTS) {
15273
16133
  return json(res, 409, { error: `this workspace is limited to ${MAX_WORKSPACE_BOTS} bots` });
15274
16134
  }
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.
16135
+ // Apply the audience before creation can announce the bot to members.
15278
16136
  const created = body.visibility === undefined ? null : parseVisibility(body.visibility);
15279
16137
  if (created && !created.ok)
15280
16138
  return json(res, 400, { error: created.error });
15281
- const bot = store.createBot({ ...profile.patch, section, modelSelection: selection, ...(created?.ok ? { visibility: created.visibility } : {}) });
16139
+ const bot = store.createBot({ ...profile.patch, soul: settings.soul, section, modelSelection: selection,
16140
+ ...(created?.ok ? { visibility: created.visibility } : {}) });
16141
+ const createdRoutines = [];
16142
+ try {
16143
+ const { chiefOfStaff: _chief, managedSections: _managed, ...ordinary } = settings;
16144
+ store.patchBot(bot.id, {
16145
+ // Store creation completes workspace defaults (including effort).
16146
+ // Applying the rest of the template must not undo that selection.
16147
+ ...ordinary, name: bot.name, section, modelSelection: bot.modelSelection,
16148
+ mascotExpression: ordinary.mascotExpression ?? undefined,
16149
+ avatarUrl: ordinary.avatarUrl || undefined,
16150
+ computer: ordinary.computer ?? undefined, cwd: checkedCwd.cwd ?? undefined,
16151
+ peers: ordinary.peers ?? undefined, mcpServers: ordinary.mcpServers ?? undefined,
16152
+ browserProfile: ordinary.browserProfile || undefined,
16153
+ autoApprove: ordinary.approvalMode === "auto",
16154
+ });
16155
+ // What the preset brings wins over the saved defaults for the same
16156
+ // skill name or note file.
16157
+ const presetSkills = new Set(preset?.preset.skills ?? []);
16158
+ const presetNotes = new Set(Object.keys(preset?.preset.seed?.memory ?? {}));
16159
+ for (const [file, text] of Object.entries(template.memory)) {
16160
+ if (presetNotes.has(file))
16161
+ continue;
16162
+ journalMemoryWrite(bot.id, file, text, { actor: "person", via: "api" });
16163
+ }
16164
+ if (preset) {
16165
+ applyPresetToBot(bot.id, preset, {
16166
+ store,
16167
+ skills: { install: installSkill, setEnabled: setSkillEnabled },
16168
+ memory: { writeIndex: writeMemoryFile, writeTopic: writeMemoryTopic },
16169
+ });
16170
+ }
16171
+ for (const skill of template.skills) {
16172
+ if (presetSkills.has(skill.name))
16173
+ continue;
16174
+ const parsed = parseSkillMd(skill.text);
16175
+ if ("error" in parsed || parsed.name !== skill.name)
16176
+ throw Object.assign(new Error("Skill name does not match its contents"), { status: 400 });
16177
+ const installed = installSkill(bot.id, skill.source, [{ path: "SKILL.md", content: skill.text }]);
16178
+ if ("error" in installed)
16179
+ throw Object.assign(new Error(installed.error), { status: 422 });
16180
+ if (skill.enabled) {
16181
+ const enabled = setSkillEnabled(bot.id, skill.name, true);
16182
+ if ("error" in enabled)
16183
+ throw Object.assign(new Error(enabled.error), { status: 422 });
16184
+ }
16185
+ }
16186
+ for (const routine of template.routines) {
16187
+ const created = routines.create({ ...routine, botId: bot.id, enabled: false });
16188
+ createdRoutines.push({ id: created.id, enabled: routine.enabled !== false });
16189
+ }
16190
+ }
16191
+ catch (error) {
16192
+ for (const routine of createdRoutines)
16193
+ routines.remove(routine.id);
16194
+ store.deleteBot(bot.id);
16195
+ throw error;
16196
+ }
16197
+ // Creation is complete. Activation errors must not invite a duplicate
16198
+ // creation retry or delete a bot whose routine may already have run.
16199
+ const warnings = [];
16200
+ if (settings.chiefOfStaff) {
16201
+ try {
16202
+ store.patchBot(bot.id, { managedSections: settings.managedSections ?? [] });
16203
+ store.setChiefOfStaff(bot.id);
16204
+ }
16205
+ catch {
16206
+ warnings.push("Bot created; review its Chief of Staff setting before delegating work.");
16207
+ }
16208
+ }
16209
+ for (const routine of createdRoutines)
16210
+ if (routine.enabled) {
16211
+ try {
16212
+ routines.update(routine.id, { enabled: true });
16213
+ }
16214
+ catch {
16215
+ warnings.push(`Bot created; routine ${routine.id} could not be activated.`);
16216
+ }
16217
+ }
15282
16218
  return json(res, 201, {
16219
+ ...(warnings.length ? { warnings } : {}),
15283
16220
  bot: {
15284
16221
  ...wireBot(bot),
15285
16222
  messages: store.messagesFor(bot.threadId),
@@ -15398,6 +16335,30 @@ const handleRequest = async (req, res) => {
15398
16335
  broadcast({ kind: "bot", bot: visible });
15399
16336
  return json(res, 200, { bot: visible });
15400
16337
  }
16338
+ m = path.match(/^\/api\/bots\/([\w-]+)\/command-allowlist(?:\/([\w-]+))?$/);
16339
+ if (m && ["GET", "POST", "DELETE"].includes(method)) {
16340
+ if (!canManageCommandAllowlist(auth))
16341
+ return json(res, 403, { error: "Only the workspace owner or an admin can manage command permissions." });
16342
+ const bot = requestedTaskBot(m[1], url.searchParams.get("threadId") ?? undefined);
16343
+ if (method === "GET" && !m[2])
16344
+ return json(res, 200, commandAllowlistResponse(bot));
16345
+ if (method === "POST" && !m[2]) {
16346
+ const candidate = commandAllowlistCandidate(await readBody(req));
16347
+ if (!candidate)
16348
+ return json(res, 400, { error: "Enter an exact command without secret values, an absolute working folder, and a provider." });
16349
+ const instance = registry.get(candidate.providerInstanceId);
16350
+ if (!instance || !COMMAND_ALLOWLIST_DRIVERS.has(instance.driverKind)) {
16351
+ return json(res, 400, { error: "This provider does not supply structured command approvals." });
16352
+ }
16353
+ commandAllowlist.add(bot.id, candidate);
16354
+ return json(res, 200, commandAllowlistResponse(bot));
16355
+ }
16356
+ if (method === "DELETE" && m[2]) {
16357
+ commandAllowlist.remove(bot.id, m[2]);
16358
+ return json(res, 200, commandAllowlistResponse(bot));
16359
+ }
16360
+ return json(res, 405, { error: "Unsupported command allowlist operation" });
16361
+ }
15401
16362
  m = path.match(/^\/api\/bots\/([\w-]+)\/always-allow$/);
15402
16363
  if (m && method === "POST") {
15403
16364
  const body = await readBody(req);
@@ -15555,6 +16516,20 @@ const handleRequest = async (req, res) => {
15555
16516
  return json(res, 400, { error: "composio must be true or false" });
15556
16517
  patch.composio = body.composio;
15557
16518
  }
16519
+ // which of those apps' tools this bot may call (connector grants 1/5:
16520
+ // data model only — enforcement lands with slice 2). null returns the
16521
+ // bot to boolean-only legacy behavior; {} means no tools.
16522
+ if (body.connectorTools !== undefined) {
16523
+ if (body.connectorTools === null) {
16524
+ patch.connectorTools = undefined;
16525
+ }
16526
+ else {
16527
+ const parsed = parseConnectorTools(body.connectorTools);
16528
+ if (!parsed.ok)
16529
+ return json(res, 400, { error: parsed.error });
16530
+ patch.connectorTools = parsed.grants;
16531
+ }
16532
+ }
15558
16533
  // Queue this bot's direct messages behind outstanding delegated work
15559
16534
  // instead of steering the conversation immediately (#1194).
15560
16535
  if (body.parkDirectMessages !== undefined) {
@@ -15920,6 +16895,12 @@ const handleRequest = async (req, res) => {
15920
16895
  }
15921
16896
  if (!bot)
15922
16897
  return json(res, 404, { error: "no such bot" });
16898
+ // A defined Works on is the newest explicit choice: this bot's
16899
+ // auto-recorded pins that now point elsewhere give way immediately, so
16900
+ // the next turn on each thread follows the new setting. Person-set pins
16901
+ // and pins that already match stay; returning to Auto sweeps nothing.
16902
+ if (computerSpecified && requestedComputer !== undefined)
16903
+ store.clearAutoSurfacePins(m[1], requestedComputer);
15923
16904
  if (normalizedSelection && selectedTask)
15924
16905
  store.patchTask(bot.id, selectedTask.threadId, { modelSelection: normalizedSelection,
15925
16906
  ...hostedModels?.resetTask(selectedTask.modelSelection, normalizedSelection) });
@@ -15983,6 +16964,27 @@ const handleRequest = async (req, res) => {
15983
16964
  // ── bot skills: imported Agent Skills (SKILL.md) ────────────────────
15984
16965
  // Import lands DISABLED; the UI shows SKILL.md + scan warnings and a
15985
16966
  // person enables after reading. See server/skills.ts for the policy.
16967
+ m = path.match(/^\/api\/bots\/([\w-]+)\/skill-template$/);
16968
+ if (m && method === "POST") {
16969
+ if (!store.bot(m[1]))
16970
+ return json(res, 404, { error: "no such bot" });
16971
+ const checked = botSkillTemplateSchema.safeParse(await readBody(req));
16972
+ if (!checked.success)
16973
+ return json(res, 400, { error: checked.error.message });
16974
+ const input = checked.data;
16975
+ const parsed = parseSkillMd(input.text);
16976
+ if ("error" in parsed || parsed.name !== input.name)
16977
+ return json(res, 400, { error: "Skill name does not match its contents" });
16978
+ const installed = installSkill(m[1], input.source, [{ path: "SKILL.md", content: input.text }]);
16979
+ if ("error" in installed)
16980
+ return json(res, 422, { error: installed.error });
16981
+ if (input.enabled) {
16982
+ const enabled = setSkillEnabled(m[1], input.name, true);
16983
+ if ("error" in enabled)
16984
+ return json(res, 422, { error: enabled.error });
16985
+ }
16986
+ return json(res, 201, { installed });
16987
+ }
15986
16988
  m = path.match(/^\/api\/bots\/([\w-]+)\/skills$/);
15987
16989
  if (m && method === "GET") {
15988
16990
  if (!store.bot(m[1]))
@@ -16766,6 +17768,23 @@ const handleRequest = async (req, res) => {
16766
17768
  const text = String(body.text ?? "").trim();
16767
17769
  if (!text)
16768
17770
  return json(res, 400, { error: "text required" });
17771
+ const threadMessages = store.messagesFor(bot.threadId);
17772
+ const source = threadMessages.find((msg) => msg.id === messageId);
17773
+ if (!source || source.role !== "user" || source.kind !== "text") {
17774
+ return json(res, 404, { error: "only user messages can be edited" });
17775
+ }
17776
+ // The client's id for this edit. A retry of an edit the server already
17777
+ // forked answers with that fork instead of forking (and rerunning) again.
17778
+ // Checked before the busy guard: that fork's own turn is what is busy.
17779
+ const sendId = parseSendId(body.sendId);
17780
+ if (sendId) {
17781
+ const accepted = acceptedSendMatch(threadMessages, sendId, text, source.replyToId);
17782
+ if (accepted.kind === "conflict" || (accepted.kind === "match" && (accepted.message.parentId ?? null) !== (source.parentId ?? null))) {
17783
+ return json(res, 409, { error: "sendId already belongs to another message" });
17784
+ }
17785
+ if (accepted.kind === "match")
17786
+ return json(res, 202, { ok: true, message: accepted.message });
17787
+ }
16769
17788
  // everything from here down is synchronous, so two racing edits can
16770
17789
  // never both get past this check: startTurn flips busy before the
16771
17790
  // next request is handled
@@ -16774,10 +17793,6 @@ const handleRequest = async (req, res) => {
16774
17793
  if (phoneSecretSubmissions.hasThread(bot.threadId)) {
16775
17794
  return json(res, 409, { error: "this task is securely saving a credential — try again when it finishes" });
16776
17795
  }
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
17796
  if (!registry.get(bot.modelSelection.instanceId)) {
16782
17797
  return json(res, 409, {
16783
17798
  error: `provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`,
@@ -16790,7 +17805,7 @@ const handleRequest = async (req, res) => {
16790
17805
  // startTurn admits the rerun before branching. A shared-resource or
16791
17806
  // concurrency-limit refusal must leave the original transcript intact.
16792
17807
  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) });
17808
+ const message = await startTurn(bot.id, text, { threadId: bot.threadId, editedMessageId: messageId, replyTo, sendId, trigger: usageTriggerFor(auth) });
16794
17809
  return json(res, 202, { ok: true, message });
16795
17810
  }
16796
17811
  // switch which fork of the conversation is visible (no new turn)
@@ -16823,6 +17838,11 @@ const handleRequest = async (req, res) => {
16823
17838
  const refusal = cardAnswerRefusal(auth, bot.threadId, String(body.requestId), behavior);
16824
17839
  if (refusal)
16825
17840
  return json(res, 403, { error: refusal });
17841
+ if (body.rememberCommand === true) {
17842
+ const refusal = commandRememberRefusal(auth, bot.threadId, String(body.requestId), behavior);
17843
+ if (refusal)
17844
+ return json(res, 403, { error: refusal });
17845
+ }
16826
17846
  return await answeringCardAs(auth, bot.threadId, String(body.requestId), async () => {
16827
17847
  if (await resolveAndSendTeamSetup(res, {
16828
17848
  botId: bot.id, threadId: bot.threadId, requestId: String(body.requestId), behavior,
@@ -16860,7 +17880,7 @@ const handleRequest = async (req, res) => {
16860
17880
  resolvePeerComms(approvalBus, String(body.requestId), behavior)) {
16861
17881
  return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
16862
17882
  }
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);
17883
+ 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
17884
  return json(res, 200, { ok: true, outcome });
16865
17885
  });
16866
17886
  }
@@ -16879,6 +17899,11 @@ const handleRequest = async (req, res) => {
16879
17899
  const refusal = cardAnswerRefusal(auth, threadId, requestId, behavior);
16880
17900
  if (refusal)
16881
17901
  return json(res, 403, { error: refusal });
17902
+ if (body.rememberCommand === true) {
17903
+ const refusal = commandRememberRefusal(auth, threadId, requestId, behavior);
17904
+ if (refusal)
17905
+ return json(res, 403, { error: refusal });
17906
+ }
16882
17907
  return await answeringCardAs(auth, threadId, requestId, async () => {
16883
17908
  const skillCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.skillRequest);
16884
17909
  if (skillCard?.card?.skillRequest) {
@@ -16958,7 +17983,7 @@ const handleRequest = async (req, res) => {
16958
17983
  if (!owner && !pending)
16959
17984
  return json(res, 404, { error: "nothing is waiting on an answer in this conversation" });
16960
17985
  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);
17986
+ 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
17987
  return json(res, 200, { ok: true, outcome });
16963
17988
  });
16964
17989
  }
@@ -17163,7 +18188,7 @@ const handleRequest = async (req, res) => {
17163
18188
  const current = store.projectBotForTask(m[1], m[2]);
17164
18189
  if (!current)
17165
18190
  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"]);
18191
+ const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt", "pinned", "snoozedUntil", "surface"]);
17167
18192
  if (Object.keys(body).some((key) => !allowed.has(key)))
17168
18193
  return json(res, 400, { error: "unsupported thread setting" });
17169
18194
  for (const key of ["requireAvailableModel", "acknowledgeLocalAuto", "updateBotDefault", "resetApprovalToAsk"]) {
@@ -17205,15 +18230,28 @@ const handleRequest = async (req, res) => {
17205
18230
  return json(res, 400, { error: "pinned must be a boolean" });
17206
18231
  patch.pinned = body.pinned ? true : undefined;
17207
18232
  }
18233
+ if (body.snoozedUntil !== undefined) {
18234
+ if (body.snoozedUntil === null)
18235
+ patch.snoozedUntil = undefined;
18236
+ else if (typeof body.snoozedUntil === "number" && Number.isFinite(body.snoozedUntil) && body.snoozedUntil >= 0)
18237
+ patch.snoozedUntil = body.snoozedUntil;
18238
+ else
18239
+ return json(res, 400, { error: "snoozedUntil must be a timestamp, 0 to snooze until activity, or null to wake now" });
18240
+ }
17208
18241
  if (body.surface !== undefined) {
17209
18242
  if (threadBusy(current.id, current.threadId))
17210
18243
  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)
18244
+ // Where this conversation works, chosen by the person and recorded as
18245
+ // theirs, so a later Works on change never sweeps it. Null follows the
18246
+ // bot's Works on again. Reachability is the turn's to judge.
18247
+ if (body.surface === null) {
17214
18248
  patch.surface = undefined;
17215
- else if (parseSurface(body.surface))
18249
+ patch.surfaceSource = undefined;
18250
+ }
18251
+ else if (parseSurface(body.surface)) {
17216
18252
  patch.surface = parseSurface(body.surface);
18253
+ patch.surfaceSource = "user";
18254
+ }
17217
18255
  else
17218
18256
  return json(res, 400, { error: "surface must be cloud, vm, local, browser, or null to follow the bot" });
17219
18257
  }
@@ -17783,7 +18821,7 @@ const handleRequest = async (req, res) => {
17783
18821
  return json(res, 400, { error: `provider must be one of ${PROVIDER_KEY_KINDS.join(", ")}` });
17784
18822
  }
17785
18823
  const kind = provider;
17786
- const saved = kind === "anthropic" ? cfg.anthropic : kind === "openaiCompat" ? cfg.openaiCompat : cfg.xai;
18824
+ const saved = kind === "anthropic" ? cfg.anthropic : kind === "openaiCompat" ? cfg.openaiCompat : kind === "mistral" ? cfg.mistral : cfg.xai;
17787
18825
  if (body?.key !== undefined && typeof body.key !== "string") {
17788
18826
  return json(res, 400, { error: "key must be a string" });
17789
18827
  }
@@ -17792,7 +18830,7 @@ const handleRequest = async (req, res) => {
17792
18830
  return json(res, 400, { error: "No key to test. Paste one or save one first." });
17793
18831
  if (key.length > 512)
17794
18832
  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;
18833
+ const url = typeof body?.url === "string" && body.url.trim() ? body.url.trim() : (saved && "url" in saved ? saved.url : undefined);
17796
18834
  res.setHeader("cache-control", "no-store");
17797
18835
  return json(res, 200, await checkProviderKey({ provider: kind, key, url }));
17798
18836
  }
@@ -18095,8 +19133,8 @@ const handleRequest = async (req, res) => {
18095
19133
  return json(res, 400, { error: "Account settings are currently available for Claude only." });
18096
19134
  }
18097
19135
  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." });
19136
+ if (!["openai-compat", "grok", "minimax", "mistral"].includes(entry.driver)) {
19137
+ return json(res, 400, { error: "The tools setting is available for OpenAI-compatible, Grok API, MiniMax API and Mistral API instances only." });
18100
19138
  }
18101
19139
  entry.config = { ...entry.config, tools: body.tools };
18102
19140
  }
@@ -18298,9 +19336,22 @@ const handleRequest = async (req, res) => {
18298
19336
  }
18299
19337
  if ((method === "PUT" || method === "PATCH") && path === "/api/config") {
18300
19338
  const body = await readBody(req);
18301
- if (hostedModels && ["instances", "anthropic", "openaiCompat", "xai", "opencodeGo"].some(key => Object.hasOwn(body, key)))
19339
+ if (hostedModels && ["instances", "anthropic", "openaiCompat", "xai", "mistral", "opencodeGo"].some(key => Object.hasOwn(body, key)))
18302
19340
  return json(res, 403, { error: HOSTED_PROVIDER_SETTINGS_ERROR });
18303
19341
  const patch = parseConfigPatch(body);
19342
+ if (patch.newBotDefaults) {
19343
+ if (patch.newBotDefaults.profile.modelSelection) {
19344
+ const checked = checkedModelSelection(patch.newBotDefaults.profile.modelSelection);
19345
+ if (!checked.ok)
19346
+ return json(res, checked.status, { error: checked.error });
19347
+ patch.newBotDefaults.profile.modelSelection = checked.selection;
19348
+ }
19349
+ for (const skill of patch.newBotDefaults.skills) {
19350
+ const parsed = parseSkillMd(skill.text);
19351
+ if ("error" in parsed || parsed.name !== skill.name)
19352
+ return json(res, 400, { error: "Skill name does not match its contents" });
19353
+ }
19354
+ }
18304
19355
  if (hostedModels && patch.defaultModelSelection) {
18305
19356
  const checked = checkedModelSelection(patch.defaultModelSelection);
18306
19357
  if (!checked.ok)
@@ -18470,7 +19521,7 @@ const handleRequest = async (req, res) => {
18470
19521
  }
18471
19522
  const listed = currentById.get(inspected.identity.boxId);
18472
19523
  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" });
19524
+ return json(res, 503, { error: "boat.dev returned conflicting cloud computer identities; keep the current Box account and retry" });
18474
19525
  }
18475
19526
  currentById.set(inspected.identity.boxId, inspected.identity);
18476
19527
  journalBoxResources.push(inspected.identity);
@@ -19381,6 +20432,18 @@ for (const row of chatFollowups()) {
19381
20432
  }
19382
20433
  restoreSteeredMessages();
19383
20434
  restoreChannelMessages();
20435
+ // Repair known auto pins that conflict with Works on before dispatch starts.
20436
+ // Legacy pins have no provenance and may be deliberate person selections:
20437
+ // preserve them rather than guessing. The sweep is idempotent.
20438
+ {
20439
+ let movedAutoPins = 0;
20440
+ for (const bot of store.bots) {
20441
+ if (bot.computer !== undefined)
20442
+ movedAutoPins += store.clearAutoSurfacePins(bot.id, bot.computer);
20443
+ }
20444
+ if (movedAutoPins)
20445
+ console.log(`Works on: moved ${movedAutoPins} auto-pinned thread(s) to their bot's current Works on`);
20446
+ }
19384
20447
  server.listen(PORT, "127.0.0.1", () => {
19385
20448
  companyRuntimeReady();
19386
20449
  console.log(`openmausbot server on http://127.0.0.1:${PORT}`);