openmausbot 0.1.87 → 0.1.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/README.md +2 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-BTiuL79F.js → abnfDiagram-O67JEVCF-qd9VD3GV.js} +1 -1
  3. package/dist/assets/{arc-C88Blc2v.js → arc-3RwkUjRe.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-BZsJIZrb.js → architectureDiagram-NJMV4G6O-CFKSJS7a.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CVeMe9Ah.js → blockDiagram-BEXU5L5S-CG1rEfWD.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-Cedk3Bhx.js → c4Diagram-YGBWAQC7-CBW9N664.js} +1 -1
  7. package/dist/assets/channel-CnRnO3tF.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-D-VRqzTB.js → chunk-2Q5K7J3B-2mRfTksB.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BChov01Y.js → chunk-3FUC2YCW-Cv1dpErj.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-C-TfwvHy.js → chunk-5VM5RSS4-B7IACJp-.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-Db7CGj5Y.js → chunk-GTNCS2PH-An0hZtYa.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-CNvIfqKT.js → chunk-GWA4HPMP-BcJB2nEm.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-C_bEp5tq.js → chunk-JWPE2WC7-CV_W7TN7.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-Coi4BOls.js → chunk-NETBCI7D-Bcs_kwnA.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CzylEuL3.js → chunk-WEXAMYUT-lrx_DxFP.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-m7p7ztaD.js → chunk-XXDRQBXY-DabHlLGM.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-BMy6o9x2.js → classDiagram-v2-NBCMYWYE-BnHj6P7d.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-BDQ__0_J.js → cose-bilkent-JH36ORCC-CfLp6UDi.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-Djv4ghUb.js → cynefin-OW5HDTMX-Dm4dbVA1.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-BULrlGYH.js → cynefinDiagram-VND7K2PF-B9mH-PID.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-D-jrT_hU.js → dagre-6A5THRUB-DDc_NVQJ.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-p2UhQmfL.js → diagram-22UHCM2B-lwr8Ip8-.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-DHvGWZ7q.js → diagram-3UASUU5V-BLLe6dmI.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-ChhKDot-.js → diagram-ATOU4E4O-6eUIq4lz.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-B-T7A43R.js → diagram-CDSNMT55-O-pRN7VQ.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-CJm8ENNY.js → diagram-MLGK6HIB-BietvGAI.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-510tlza8.js → diagram-MPIPVDR6-Bd2Cl62X.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-PkvRPHel.js → ebnfDiagram-ZINNZB2B-B6iDy69W.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-BVBVPoX3.js → elk-276RUBZZ-DQVHfKe0.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-BgYMVm7L.js → erDiagram-OPXOYQCR-BWdVp2tQ.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-BGqsnc_3.js → flowDiagram-KWPJA3E3-WLE6rKB4.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-D_-UeReH.js → ganttDiagram-FUAMR5RP-BbjS-4gn.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-D0uooONx.js → gitGraphDiagram-X574FWY7-Dpy2QSGq.js} +1 -1
  34. package/dist/assets/index-BIMdIbaw.css +1 -0
  35. package/dist/assets/index-CHnODCr2.js +583 -0
  36. package/dist/assets/{index-zRsJERGJ.js → index-CIaHn5NS.js} +1 -1
  37. package/dist/assets/{infoDiagram-VRGFBTTK-B9e1z77f.js → infoDiagram-VRGFBTTK-D_jKWlKe.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BB4vNwvD.js → ishikawaDiagram-OU5B5YK6-DLJohSuK.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DpkosOTB.js → journeyDiagram-ZHPQQLJL-DPOFj0qN.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-DbDP4kUu.js → kanban-definition-PNTS6WVX-BI7v-xpK.js} +1 -1
  41. package/dist/assets/{linear-DBJGPmjT.js → linear-C2pWPSxO.js} +1 -1
  42. package/dist/assets/{mermaid.core-DWPRi-ue.js → mermaid.core-CR-j5WQU.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-nszBbJmx.js → mindmap-definition-NLK3R4M7-lz9GvXRh.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-CWykJWu_.js → pegDiagram-GJSIUBJH-Dl4_ia61.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-g3Da-Vnv.js → pieDiagram-5QR66LMP-DWuT3n88.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-Wzrlpzgh.js → quadrantDiagram-O4NWA36T-DXWe9Jx7.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-moHTFMC0.js → railroadDiagram-XR7U4H2S-COY1IBVY.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-CwfXdFH8.js → requirementDiagram-PLB6GJNP-DnyVuhsQ.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-3ZROYvnq.js → sankeyDiagram-IPEJSGJF-BSc4NdGK.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-E1TBebP8.js → sequenceDiagram-PO4LG4MO-kqEU2EGq.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-DHKEU5pu.js → sizeCapture-INFHLROL-BvB4bkWD.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-BguEj09D.js → stateDiagram-v2-GCMORJYK-BIrqNv7y.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-C7IDYnhd.js → swimlanes-2SLR337P-CFnalQUE.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-C4a8qNGj.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-DCW-F5PW.js → timeline-definition-EJHVYXUP-CsSWJnSQ.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-xBMFtWYV.js → usecaseDiagram-POWQR4AR-DkAmJ1EE.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-B5nIkuH8.js → vennDiagram-UO4OBE2U-C3vd9drL.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DYFAhdbr.js → wardleyDiagram-VNRHLVJA-j26CM-pi.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-DeXwX6Cz.js → xychartDiagram-PMCCYNJV-BYu1Q_at.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/companion/src/routes.js +8 -1
  62. package/dist-server/connector-proxy.js +105 -1
  63. package/dist-server/container-mcp.js +3 -1
  64. package/dist-server/drivers/agents-proxy.js +277 -22
  65. package/dist-server/index.js +23150 -19947
  66. package/dist-server/local-computer-proxy.js +2 -0
  67. package/dist-server/mcp-gate.js +2 -0
  68. package/dist-server/mcp-server.js +5 -3
  69. package/dist-server/message-search.worker.js +52 -0
  70. package/dist-server/openmausbot.js +540 -298
  71. package/dist-server/pair-cli.js +540 -298
  72. package/dist-server/server/admission.js +81 -0
  73. package/dist-server/server/aside-queue.js +294 -0
  74. package/dist-server/server/attachments.js +43 -1
  75. package/dist-server/server/bot-attachment.js +107 -0
  76. package/dist-server/server/bot-overview.js +19 -0
  77. package/dist-server/server/cli-api-setup.js +1 -0
  78. package/dist-server/server/composio.js +297 -1
  79. package/dist-server/server/config.js +25 -3
  80. package/dist-server/server/connector-advertisement.js +165 -0
  81. package/dist-server/server/connector-proxy.js +14 -2
  82. package/dist-server/server/connector-verdict.js +31 -4
  83. package/dist-server/server/container-computer.js +60 -0
  84. package/dist-server/server/contracts.js +22 -0
  85. package/dist-server/server/drivers/acp/core.js +24 -6
  86. package/dist-server/server/drivers/acp/cursor.js +3 -7
  87. package/dist-server/server/drivers/acp/droid.js +4 -7
  88. package/dist-server/server/drivers/acp/gemini.js +4 -4
  89. package/dist-server/server/drivers/acp/grok.js +4 -9
  90. package/dist-server/server/drivers/acp/hermes.js +3 -5
  91. package/dist-server/server/drivers/acp/opencode-go.js +2 -5
  92. package/dist-server/server/drivers/acp/qwen.js +9 -15
  93. package/dist-server/server/drivers/agents-call.js +137 -10
  94. package/dist-server/server/drivers/agents-catalog.js +100 -6
  95. package/dist-server/server/drivers/boxagent.js +2 -16
  96. package/dist-server/server/drivers/claude.js +31 -17
  97. package/dist-server/server/drivers/codex-catalog.js +14 -6
  98. package/dist-server/server/drivers/codex-identity.js +2 -1
  99. package/dist-server/server/drivers/codex-release.js +48 -0
  100. package/dist-server/server/drivers/codex.js +7 -25
  101. package/dist-server/server/drivers/openai-chat.js +26 -2
  102. package/dist-server/server/drivers/pi.js +237 -30
  103. package/dist-server/server/drivers/prompt-split.js +32 -9
  104. package/dist-server/server/drivers/session-idle.js +29 -0
  105. package/dist-server/server/env-path.js +15 -0
  106. package/dist-server/server/guarded-requests.js +2 -2
  107. package/dist-server/server/harness-capabilities.js +46 -0
  108. package/dist-server/server/index.js +1037 -209
  109. package/dist-server/server/message-db.js +22 -42
  110. package/dist-server/server/message-file.js +9 -1
  111. package/dist-server/server/message-search-query.js +43 -0
  112. package/dist-server/server/message-search-worker.js +80 -0
  113. package/dist-server/server/message-search.worker.js +21 -0
  114. package/dist-server/server/model-requests.js +248 -0
  115. package/dist-server/server/options-card.js +21 -0
  116. package/dist-server/server/org-library.js +15 -1
  117. package/dist-server/server/peer-delivery.js +60 -0
  118. package/dist-server/server/peer-roster.js +57 -0
  119. package/dist-server/server/presets.js +32 -32
  120. package/dist-server/server/profile-requests.js +66 -39
  121. package/dist-server/server/profile-revision.js +9 -1
  122. package/dist-server/server/profile-versions.js +6 -3
  123. package/dist-server/server/routine-requests.js +29 -7
  124. package/dist-server/server/skill-fetch.js +72 -14
  125. package/dist-server/server/skills.js +1 -0
  126. package/dist-server/server/store.js +32 -2
  127. package/dist-server/server/system-prompt.js +26 -2
  128. package/dist-server/server/team-setup-requests.js +124 -23
  129. package/dist-server/server/thread-events.js +3 -1
  130. package/dist-server/server/tightening-requests.js +405 -0
  131. package/dist-server/server/tts/chatterbox.js +27 -5
  132. package/dist-server/server/usage-ledger.js +7 -1
  133. package/dist-server/server/workspace-backup.js +40 -3
  134. package/dist-server/server/workspace-backup.worker.js +10 -0
  135. package/dist-server/shared/ask-question.js +19 -0
  136. package/dist-server/shared/credential-request.js +5 -1
  137. package/dist-server/shared/line-diff.js +23 -0
  138. package/dist-server/shared/model-request.js +1 -0
  139. package/dist-server/shared/options-card.js +51 -0
  140. package/dist-server/shared/profile-request.js +5 -4
  141. package/dist-server/shared/tightening-request.js +87 -0
  142. package/dist-server/src/lib/composer-attachments.js +25 -3
  143. package/dist-server/vps-container-mcp.js +3 -1
  144. package/dist-server/workspace-backup.worker.js +19474 -0
  145. package/package.json +1 -1
  146. package/dist/assets/channel-taCNRlUU.js +0 -1
  147. package/dist/assets/index-BwdMAQ6p.css +0 -1
  148. package/dist/assets/index-ByJennr7.js +0 -581
  149. package/dist/assets/swimlanesDiagram-TC7HE7FX-BpEdoB2e.js +0 -8
@@ -24,7 +24,7 @@ import { BOT_PROFILE_LIMITS } from "../shared/bot-profile.js";
24
24
  import { CLOUD_COMPUTER_BUSY_ERROR } from "../shared/computer-contention.js";
25
25
  import { approvalModeFor, supportsApprovalMode, modelSwitchNeedsAsk, isEmergencyApprovalDowngrade, isApprovalMode, } from "../shared/approval-mode.js";
26
26
  import { escapeAttribute } from "../src/lib/composer-attachments.js";
27
- import { CREDENTIAL_TARGETS, credentialResumeOutcome, credentialIsConfigured, isReusableCredentialRequest, isCredentialTargetId, } from "../shared/credential-request.js";
27
+ import { CREDENTIAL_TARGETS, credentialResumeOutcome, credentialIsConfigured, isPendingCredentialRequest, isCredentialTargetId, } from "../shared/credential-request.js";
28
28
  import { approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval, delegationInheritsFullAccess } from "./auto-approve.js";
29
29
  import { CommandAllowlistStore, commandAllowlistCandidate } from "./command-allowlist.js";
30
30
  import { updateClaudeCli } from "./claude-update.js";
@@ -37,7 +37,7 @@ import { commandReceipt } from "./commands.js";
37
37
  import { buildTurnDigest, coverageForDriver, digestPromptLine, renderDigest, toolEvidence } from "./digest.js";
38
38
  import { appendDecision, bindDecisionRetention, boundRetentionDays, decisionRetentionDays, decisionsCsv, flushDecisionLog, pruneDecisions, readDecisionRange, readDecisions, withDecisionActor } from "./decision-log.js";
39
39
  import { validateBotCwd } from "./bot-cwd.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
+ import { ATTACHMENTS_DIR, attachmentExists, cleanupStaleAttachmentPartials, deleteAttachment, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, parseAudioRange, readAttachment, saveAudio, saveFile, saveImage, saveImageUpload, validateAttachmentUploadId, } from "./attachments.js";
41
41
  import { messageFileDisposition, messageFileDownloadName, messageAttachmentName, messageFileRoots, messageImageTargetAt, messageReferencesFile, openMessageFile, } from "./message-file.js";
42
42
  import { avatarGenerationRequestSchema, avatarGenerationStateMatches, generateAvatarImage, avatarImageStatus, snapshotAvatarGenerationState, } from "./avatar-image.js";
43
43
  import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
@@ -52,9 +52,10 @@ import { boxAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourc
52
52
  import * as composio from "./composio.js";
53
53
  import { connectorCallFromFrame, connectorRefusalText, connectorUnrecognizedText, evaluateConnectorTools } from "./connector-verdict.js";
54
54
  import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
55
- import { canAccessTeam, canReachPeer, coordinatorSupervises, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
55
+ import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, livePeerRosterBlock, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
56
56
  import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
57
- import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerRuntimeStatus, localVmRecreatableOnDemand, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, } from "./container-computer.js";
57
+ import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerExec, containerRuntimeStatus, localVmRecreatableOnDemand, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, VM_WORKSPACE_GUEST, } from "./container-computer.js";
58
+ import { attachForTurn, saveBotAttachment } from "./bot-attachment.js";
58
59
  import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, } from "./config.js";
59
60
  import { sweepThreadEventLogs } from "./thread-retention.js";
60
61
  import { ComputerControl } from "./computer-control.js";
@@ -78,14 +79,16 @@ import { probeMcpServer } from "./mcp-probe.js";
78
79
  import { GROUP_GOAL_MAX_TURNS, groupGoalAssignmentKey, groupGoalCompletionTurnId, groupGoalCoordinatorInstructions, groupGoalWorkerInstructions, parseGroupGoalDecision, resolveGroupGoalMember, selectGroupGoalCoordinator, } from "./group-goal-run.js";
79
80
  import { BUILT_IN_DRIVERS } from "./drivers/builtIn.js";
80
81
  import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from "./comms-visibility.js";
81
- import { readMessageText, recallMessages, recentMessages, searchMessages, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups, threadsReferencing } from "./message-db.js";
82
+ import { readMessageText, recallMessages, recentMessages, searchMessagesAsync, closeMessageSearch, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups, threadsReferencing } from "./message-db.js";
82
83
  import { briefCrossingLabel, claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
83
84
  import { parseSince, parseUntil, recentWork, recentWorkPrompt, turnOutcomeLine } from "./recent-work.js";
84
85
  import { chiefForBot, INCIDENTS_THREAD_TITLE, IncidentLedger, incidentChip, incidentText } from "./incidents.js";
85
86
  /** A session_read answer competes with the transcript for the context
86
87
  * window; a computer-use turn's output can run to hundreds of KB. */
87
88
  const SESSION_READ_MAX_CHARS = 8_000;
89
+ import { attemptAsideInjection, drainAsideMessages, queueAsideMessage, restoreAsideMessages, } from "./aside-queue.js";
88
90
  import { promptWithReply, transcriptText } from "./replies.js";
91
+ import { admit } from "./admission.js";
89
92
  import { _loadPending, buildDelegationFailurePrompt, buildDelegationRevivalPrompt, DELEGATION_TTL_MS, DelegationWakeBudget, discardDelegations, drainDelegations, expireStaleDelegations, findDelegationReceipt, pendingDelegationInfo, pendingDelegationSnapshot, pendingThreads, queueDelegation, recordDelegationReceipt, releaseDelegationsWaitingOn, summarizeDelegatedActivity } from "./delegations.js";
90
93
  import { cancelSteeredMessage, drainSteeredMessages, hasQueuedSteeredMessages, holdSteeredQueue, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, restoreHeldSteeredQueue, restoreSteeredMessages, settleHeldSteeredQueue, } from "./steer-queue.js";
91
94
  import { cancelChannelMessage, drainChannelMessages, holdChannelQueue, queuedChannelMessage, queueChannelMessage, restoreChannelMessages, restoreHeldChannelQueue, resolveHeldReplyTarget, settleHeldChannelQueueHead, } from "./channel-queue.js";
@@ -97,6 +100,7 @@ import { computerKindForResource, ManagedDesktopPolicy } from "./managed-policy.
97
100
  import { hostedModelPolicy, HOSTED_MODEL_POLICY_HEADER, HOSTED_PROVIDER_SETTINGS_ERROR } from "./hosted-models.js";
98
101
  import { selectDefaultModelSelection, withNewBotEffort } from "./default-model-selection.js";
99
102
  import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, peerApprovalFailure, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
103
+ import { peerDeliveryReceipt } from "./peer-delivery.js";
100
104
  import { peerProvenanceNote, withPeerProvenance } from "./peer-provenance.js";
101
105
  import { decideRoomPost, emptyRoomPostBudget } from "./room-post-budget.js";
102
106
  import { isProjectEmoji, mentionedBots, parseConnectorTools, roomResponders, sectionKey, Store, titleFromLlm, toWireTask, } from "./store.js";
@@ -119,7 +123,7 @@ import { botSkillTemplateSchema, newBotDefaultsSchema, resolveBotCreationDefault
119
123
  import { expandLearnTurnText, learnSource } from "./skill-learn.js";
120
124
  import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
121
125
  import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-folder.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";
126
+ import { buildSystemPrompt, userProfileSystemPrompt, computerPrompt, composioSystemPrompt, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, } from "./system-prompt.js";
123
127
  import { readCuaConnection, readCuaUnavailableReason, gatedLocalComputer } from "./local-computer.js";
124
128
  import { discoverExistingPerBotLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
125
129
  import { LocalVmIdleTimer } from "./local-vm-idle.js";
@@ -136,8 +140,11 @@ import { agentBrowserFrame, agentBrowserIntegration, browserEngineEncryptionKey,
136
140
  import { createScreenFrameSource } from "./screen-frame-source.js";
137
141
  import { screenFrameHash, screenSurfaceForTool, screenTouchingTool, settledFrameIsNews } from "./screen-frame-gate.js";
138
142
  import { RoutineRequestService } from "./routine-requests.js";
143
+ import { createOptionsCard } from "./options-card.js";
139
144
  import { buildBotOverview, connectedAppsFacts } from "./bot-overview.js";
140
145
  import { ProfileRequestService } from "./profile-requests.js";
146
+ import { ModelRequestService } from "./model-requests.js";
147
+ import { TighteningRequestService } from "./tightening-requests.js";
141
148
  import { TeamSetupError, TeamSetupRequestService } from "./team-setup-requests.js";
142
149
  import { profileRevision, profileSnapshot } from "./profile-revision.js";
143
150
  import { flushAllProfileHistory, flushProfileHistory, readHistory, recordProfileChange } from "./profile-versions.js";
@@ -156,7 +163,7 @@ import { loadBundledSkills, loadUserSkills, mergeSkills, renderSkillInstructions
156
163
  import { installedPlaybookInstructions } from "./installed-playbooks.js";
157
164
  import { createBotPackageExport, createLibraryPackageExport, createTeamPackageExport, picture as sharedPicture, TeamExportError } from "./package-export.js";
158
165
  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";
166
+ import { applyPresetToBot, createPresetStore, DEFAULTS_PRESET_KEY, PRESET_UNAVAILABLE_MESSAGE, presetFromDefaults, presetOffered, readPublishedLibrary, writePublishedLibrary, } from "./presets.js";
160
167
  import { readPublishedTeam, writePublishedTeam } from "./published-teams.js";
161
168
  import { OrgLibrary } from "./org-library.js";
162
169
  import { createTeamBackup, importTeamBackup } from "./team-backup.js";
@@ -470,7 +477,7 @@ function cardAnswererFor(auth) {
470
477
  async function answeringCardAs(auth, threadId, requestId, work) {
471
478
  const open = (() => {
472
479
  const card = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId)?.card;
473
- return Boolean(card && !card.answered && !card.dismissed);
480
+ return Boolean(card && !card.answered && !card.dismissed && !card.expired);
474
481
  })();
475
482
  try {
476
483
  await withDecisionActor(decisionActorFor(auth), work);
@@ -1057,6 +1064,7 @@ utilityParentPort?.on("message", event => {
1057
1064
  utilityParentPort.postMessage({ type: "openmausbot:managed-desktop-result", requestId, ok: false, error: "Company connection could not be applied. Reconnect from desktop Settings." });
1058
1065
  });
1059
1066
  });
1067
+ const MAX_ATTACHED_FILES_PER_TURN = 10;
1060
1068
  // A capability lives for the exact provider-turn generation, including while
1061
1069
  // that turn is parked on a human approval. The long ceiling is only an orphan
1062
1070
  // backstop for an impossible-to-settle adapter; normal terminal paths revoke
@@ -1267,7 +1275,10 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation, r
1267
1275
  OMB_SHARED_COMPUTERS_ENABLED: sharedComputersEnabled(cfg) ? "1" : "0",
1268
1276
  // Same capability rule for voice: the tool is offered only when this
1269
1277
  // 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",
1278
+ OMB_VOICE_NOTES: (() => {
1279
+ const speaking = botForThread(botId, threadId) ?? store.bot(botId);
1280
+ return tts.voiceReady(cfg, speaking?.voice) && speaking?.voiceNotes !== false ? "1" : "0";
1281
+ })(),
1271
1282
  },
1272
1283
  };
1273
1284
  }
@@ -1307,10 +1318,10 @@ function settleDirectCoordination(generation, outcome) {
1307
1318
  directCoordinationSettlers.delete(generation);
1308
1319
  settle?.(outcome);
1309
1320
  }
1310
- function settleDirectFollowup(generation) {
1321
+ function settleDirectFollowup(generation, outcome = { ok: false, text: "The coordinated turn was interrupted" }) {
1311
1322
  if (!generation)
1312
1323
  return;
1313
- settleDirectCoordination(generation, { ok: false, text: "The coordinated turn was interrupted" });
1324
+ settleDirectCoordination(generation, outcome);
1314
1325
  const pending = directFollowupSettlers.get(generation);
1315
1326
  if (!pending)
1316
1327
  return;
@@ -1494,7 +1505,11 @@ function hasDirectDispatch(botId) {
1494
1505
  * share this test so peer and room work waits for a real slot, not total
1495
1506
  * bot idleness. */
1496
1507
  function canAdmitDirectTurn(botId, threadId) {
1497
- return !threadBusy(botId, threadId) && !botAtThreadCapacity(botId) && !activeGroupTurnForBot(botId);
1508
+ return admit("peer", {}, {
1509
+ threadBusy: threadBusy(botId, threadId),
1510
+ atCapacity: botAtThreadCapacity(botId),
1511
+ groupTurn: Boolean(activeGroupTurnForBot(botId)),
1512
+ }).action === "start";
1498
1513
  }
1499
1514
  /** Routine and webhook dispatch shares startTurn's admission preconditions
1500
1515
  * instead of waiting for whole-bot idleness: a free thread slot and no
@@ -1502,7 +1517,12 @@ function canAdmitDirectTurn(botId, threadId) {
1502
1517
  * blocks every other turn kind; it does not consume a capacity slot. */
1503
1518
  function unattendedDispatchState(botId) {
1504
1519
  const bot = store.bot(botId);
1505
- return !bot ? "missing" : botAtThreadCapacity(botId) || activeGroupTurnForBot(botId) ? "busy" : "ready";
1520
+ const decision = admit("unattended", {}, {
1521
+ botPresent: Boolean(bot),
1522
+ atCapacity: Boolean(bot) && botAtThreadCapacity(botId),
1523
+ groupTurn: Boolean(bot) && Boolean(activeGroupTurnForBot(botId)),
1524
+ });
1525
+ return decision.action !== "refuse" ? "ready" : decision.code === "missing" ? "missing" : "busy";
1506
1526
  }
1507
1527
  function requestedTaskBot(botId, rawThreadId) {
1508
1528
  const profile = store.bot(botId);
@@ -1776,9 +1796,9 @@ function phoneIntegration(botId, threadId, generation) {
1776
1796
  env.PH_ANDROID_SERIAL = process.env.PH_ANDROID_SERIAL;
1777
1797
  return { command: process.execPath, args: [phoneProxyPath], env };
1778
1798
  }
1779
- function connectedAppsIntegration(botId, threadId, generation) {
1799
+ function connectedAppsIntegration(bot, threadId, generation) {
1780
1800
  const token = mintInternalCapability({
1781
- botId,
1801
+ botId: bot.id,
1782
1802
  threadId,
1783
1803
  generation,
1784
1804
  depth: 0,
@@ -1790,8 +1810,12 @@ function connectedAppsIntegration(botId, threadId, generation) {
1790
1810
  return composio.mcpIntegration(cfg, {
1791
1811
  harnessUrl: `http://127.0.0.1:${PORT}`,
1792
1812
  commsToken: token,
1793
- botId,
1813
+ botId: bot.id,
1794
1814
  threadId,
1815
+ // Connector grants 3/5: the record (or its absence) rides along so
1816
+ // the bridge can filter tools/list; the verdict on the relay endpoint
1817
+ // stays the authority for every call.
1818
+ connectorTools: bot.connectorTools,
1795
1819
  });
1796
1820
  }
1797
1821
  // ── computer control (who is driving) ──────────────────────────────────
@@ -2257,7 +2281,7 @@ const wireTask = (task) => {
2257
2281
  return asleep ? { ...coordinated, snoozedUntil } : coordinated;
2258
2282
  };
2259
2283
  const wireBot = (bot) => {
2260
- const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, ...rest } = bot;
2284
+ const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, ...rest } = bot;
2261
2285
  // An elevated selection is inert until the desktop confirms its exact
2262
2286
  // private reply. Every ordinary client sees the effective Ask state during
2263
2287
  // that two-phase window, never a grant that may still roll back.
@@ -2270,7 +2294,7 @@ const wireBot = (bot) => {
2270
2294
  /** The correlated private response carries the requested value so Electron
2271
2295
  * can validate it before sending the confirmation that makes it effective. */
2272
2296
  const wireTrustedApprovalBot = (bot) => {
2273
- const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, ...rest } = bot;
2297
+ const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, ...rest } = bot;
2274
2298
  return { ...rest, approvalMode: approvalModeFor(rest), avatarUrl: rest.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
2275
2299
  };
2276
2300
  /** A settings-based preview, not a receipt of a dispatched turn. No
@@ -2334,7 +2358,7 @@ function previewSystemPrompt(bot) {
2334
2358
  computer: previewPlan.computer && previewPlan.computer !== "off" && computerPromptKind ? previewPlan.computer : null,
2335
2359
  browser: previewPlan.computer === undefined ? false : previewPlan.browser,
2336
2360
  }, { note: previewPlan.note }) },
2337
- { id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? COMPOSIO_PROMPT : "" },
2361
+ { id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? composioSystemPrompt(bot.connectorTools) : "" },
2338
2362
  { id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(engineMcpServers(bot))) : "" },
2339
2363
  { id: "browser", label: "Browser", text: previewPlan.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
2340
2364
  { id: "coordination", label: "Team", text: agentsMounted && coordination ? ` ${coordination}` : "" },
@@ -2382,6 +2406,7 @@ async function botOverview(bot) {
2382
2406
  approvePeerComms: bot.approvePeerComms,
2383
2407
  peers: bot.peers,
2384
2408
  composio: bot.composio,
2409
+ connectorTools: bot.connectorTools,
2385
2410
  browser: bot.browser,
2386
2411
  chiefOfStaff: bot.chiefOfStaff,
2387
2412
  managedSections: bot.managedSections,
@@ -3117,7 +3142,7 @@ const channelTaskBlocked = (group) => groupIsWorking(group) ||
3117
3142
  store.groupTasks(group.id).some((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
3118
3143
  message.card?.requestId &&
3119
3144
  !message.card.answered &&
3120
- !message.card.dismissed));
3145
+ !message.card.dismissed && !message.card.expired));
3121
3146
  // Recovery can synchronously emit room changes. Load coordination state
3122
3147
  // before registering store listeners or recovering interrupted routines.
3123
3148
  const groupQueues = new Map();
@@ -3276,7 +3301,9 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
3276
3301
  const parent = node.parentId ? roomHandoffs.nodes.get(node.parentId) : undefined;
3277
3302
  const sender = parent ? store.bot(parent.botId) : undefined;
3278
3303
  const result = {};
3279
- const turnText = coordinationTurnText(node, resumed);
3304
+ const brief = coordinationTurnText(node, resumed);
3305
+ const liveRoster = resumed ? undefined : coordinationLiveRosterBlock(bot);
3306
+ const turnText = liveRoster ? `${brief}\n\n${liveRoster}` : brief;
3280
3307
  const systemInstructions = coordinationSystemInstructions();
3281
3308
  const request = roomHandoffs.sharedRequest(node);
3282
3309
  if (!resumed && !store.messagesFor(node.threadId).some(m => m.roomRequest?.id === request.id && m.roomRequest.phase === "request")) {
@@ -3348,7 +3375,7 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
3348
3375
  result.outcome = "cancelled";
3349
3376
  return;
3350
3377
  }
3351
- await runGroupMemberTurn(group.id, node.threadId, bot.id, MAX_COMMS_DEPTH, new Set(), undefined, error => { result.stopReason = error; }, () => operation.cancelled, () => groupProviderHandshakeStarted(operation), () => groupProviderHandshakeSettled(operation), { claimed: true }, { roomHandoffId: node.id, resumed, systemInstructions, turnInstructions: turnText, followMentions: false, result }, operation);
3378
+ await runGroupMemberTurn(group.id, node.threadId, bot.id, MAX_COMMS_DEPTH, new Set(), undefined, error => { result.stopReason = error; }, () => operation.cancelled, () => groupProviderHandshakeStarted(operation), () => groupProviderHandshakeSettled(operation), { claimed: true }, { roomHandoffId: node.id, resumed, systemInstructions, turnInstructions: brief, liveRoster, followMentions: false, result }, operation);
3352
3379
  });
3353
3380
  const tracked = run.finally(() => {
3354
3381
  signal.removeEventListener("abort", abort);
@@ -3618,6 +3645,35 @@ async function waitForChatRoomMember(operation, threadId, bot) {
3618
3645
  }
3619
3646
  }
3620
3647
  }
3648
+ /** The peer's newest stored message, across every task it owns: the recency
3649
+ * the live roster breaks ties with. A bot with no messages yet falls back
3650
+ * to its newest task's creation, which keeps a freshly created teammate
3651
+ * ahead of a silent veteran only when it really is the newer arrival. */
3652
+ function newestPeerActivityAt(botId) {
3653
+ let newest = 0;
3654
+ for (const task of store.tasks(botId)) {
3655
+ const at = store.messagesTail(task.threadId, 1).messages.at(-1)?.at ?? task.createdAt;
3656
+ if (at > newest)
3657
+ newest = at;
3658
+ }
3659
+ return newest;
3660
+ }
3661
+ /** The live-peer roster for a fresh coordination brief. Claude snapshots
3662
+ * system prompts, so dispatch-time data has to ride the user turn; and only
3663
+ * a recipient whose driver mounts the agents tools can act on the names, so
3664
+ * any other recipient gets nothing — the same gate the system-prompt roster
3665
+ * applies. The roster names the RECIPIENT's own reachable peers, which is
3666
+ * exactly the set its coordinate_bots calls will be allowed to reach. */
3667
+ function coordinationLiveRosterBlock(recipient) {
3668
+ const instance = registry.get(recipient.modelSelection.instanceId);
3669
+ if (instance?.adapter.capabilities.agentsMcp !== true)
3670
+ return "";
3671
+ const team = reachablePeers(store.bots, recipient).map(peer => ({
3672
+ ...peer,
3673
+ lastActivityAt: newestPeerActivityAt(peer.id),
3674
+ }));
3675
+ return livePeerRosterBlock(livePeerRoster(team));
3676
+ }
3621
3677
  function cancelGroupTurnOperations(groupId, threadId, outcome = {
3622
3678
  status: "stopped",
3623
3679
  detail: "Stopped by you.",
@@ -4659,6 +4715,61 @@ function botComputerControlKey(bot) {
4659
4715
  const computer = inheritedTeamComputer(bot);
4660
4716
  return computer ? teamComputerOwner(computer.id) : bot.id;
4661
4717
  }
4718
+ async function computerCallGate(internalCapability) {
4719
+ const botId = internalCapability.botId;
4720
+ const snapshot = botComputerControlSnapshot(botId, internalCapability.teamComputerId);
4721
+ const slot = autoVmClaims.get(internalCapability.threadId);
4722
+ const lazyClaim = slot && slot.owner.generation === internalCapability.generation ? slot : undefined;
4723
+ if (!snapshot.held && lazyClaim?.lazy && !lazyClaim.begin) {
4724
+ // First screen tools/call on a lazily-attached Auto VM (issue
4725
+ // #1361): fire the exclusive claim — once — and give it a moment
4726
+ // to land. A free, ready VM claims in the time of one container
4727
+ // inspect, so this call then proceeds with an honest answer;
4728
+ // only a claim still queued behind another holder answers held
4729
+ // below, and then the contention text is true. Keyed on the
4730
+ // slot, never on the thread's turn-computer entry: a bind this
4731
+ // turn abandoned earlier (a VPS that turned out to be asleep)
4732
+ // must not hide the unclaimed VM and let the call through.
4733
+ startAutoVmClaim(autoVmClaims, internalCapability.threadId, internalCapability.generation);
4734
+ await Promise.race([
4735
+ lazyClaim.begin ?? Promise.resolve(),
4736
+ new Promise((resolve) => setTimeout(resolve, LAZY_VM_CLAIM_GRACE_MS)),
4737
+ ]);
4738
+ }
4739
+ if (!snapshot.held && lazyClaim?.failed === true) {
4740
+ // A rejected lazy claim (gate finding F1, issue #1361): the
4741
+ // computer MCP mounted at dispatch is still live, and the claim
4742
+ // may even have left a turn-computer entry behind (it can reject
4743
+ // after bindTurnComputer succeeded — lease lost to a person,
4744
+ // lifecycle busy, boot failure). Either way this turn owns no
4745
+ // usable VM, so keep refusing every screen call for the rest of
4746
+ // the generation; the bridge must never forward one onto a VM
4747
+ // this turn never claimed. Turn settle GC clears the slot. Say
4748
+ // why, and say not to retry: the contention text would send the
4749
+ // model into a screenshot loop against a claim that cannot land.
4750
+ return {
4751
+ held: true, helpOpen: false,
4752
+ blockedReason: `This turn could not claim ${lazyClaim.label ?? "this computer"}${lazyClaim.failure ? ` (${lazyClaim.failure})` : ""}. This call was not performed. Do not retry computer work in this turn; tell the person what you could not do.`,
4753
+ };
4754
+ }
4755
+ if (!snapshot.held && lazyClaim?.lazy && lazyClaim.begin && !lazyClaim.claimed) {
4756
+ // The claim fired and is still waiting on the exclusive bind:
4757
+ // another turn genuinely holds this desktop right now.
4758
+ return {
4759
+ held: true, helpOpen: false,
4760
+ blockedReason: "Another thread is using this computer. This call was not performed. Pause computer work until that thread finishes, then take a fresh screenshot before acting.",
4761
+ };
4762
+ }
4763
+ const computer = turnComputerResources.get(internalCapability.threadId);
4764
+ if (!snapshot.held && computer && computer.owner.generation === internalCapability.generation &&
4765
+ !claimTurnResource(computer.owner, computer.resource)) {
4766
+ return {
4767
+ held: true, helpOpen: false,
4768
+ blockedReason: "Another thread is using this computer. This call was not performed. Pause computer work until that thread finishes, then take a fresh screenshot before acting.",
4769
+ };
4770
+ }
4771
+ return { held: snapshot.held, helpOpen: snapshot.helpReason !== null };
4772
+ }
4662
4773
  function botComputerControlSnapshot(botId, pinnedComputerId) {
4663
4774
  const own = computerControl.snapshot(botId);
4664
4775
  const bot = store.bot(botId);
@@ -5610,7 +5721,13 @@ bus.subscribe((event) => {
5610
5721
  pushMessage({
5611
5722
  role: "bot",
5612
5723
  kind: "activity",
5613
- tool: { name: `error: ${event.message.slice(0, 160)}`, ok: false, setup: event.setup, ...(event.terminal ? { terminal: true } : {}) },
5724
+ tool: {
5725
+ name: `error: ${event.message.slice(0, 160)}`,
5726
+ ok: false,
5727
+ setup: event.setup,
5728
+ ...(event.terminal ? { terminal: true } : {}),
5729
+ ...(event.claudeUpdate ? { claudeUpdate: true } : {}),
5730
+ },
5614
5731
  });
5615
5732
  // a setup error means the engine could not even start: the bot is
5616
5733
  // dead until something changes, not merely idle. The next successful
@@ -6594,26 +6711,85 @@ function drainQueuedSends() {
6594
6711
  // intact until that exact handshake releases its runtime-only claim.
6595
6712
  (botId, threadId) => threadBusy(botId, threadId) || botAtThreadCapacity(botId) || Boolean(activeGroupTurnForBot(botId))
6596
6713
  || parksBehindCoordination(botId, threadId));
6714
+ // Asides always drain after person follow-ups (see drainAsideLane): the
6715
+ // steer drain above can make a thread busy again, deferring its asides
6716
+ // to the next boundary. Every path that clears a thread's send queue
6717
+ // reaches this point, so the aside lane drains on all of them — not
6718
+ // just turn.completed.
6719
+ drainAsideLane();
6720
+ }
6721
+ /** The aside lane's boundary pass. Invoked at the end of drainQueuedSends, i.e. AFTER the steer drain on purpose:
6722
+ * a thread with both waiting starts its person follow-up first, and that
6723
+ * turn makes the thread busy again, so its asides defer to the next
6724
+ * boundary — the person's correction always lands ahead of peer context.
6725
+ * A thread already running a seam-capable turn gets its asides folded in
6726
+ * mid-turn instead (one batched call); an idle thread degrades them to a
6727
+ * single enveloped follow-up turn, exactly the shape the steer-queue
6728
+ * gives queued person messages. */
6729
+ function drainAsideLane() {
6730
+ if (!followupsReady)
6731
+ return;
6732
+ void drainAsideMessages({
6733
+ store,
6734
+ inject: async (botId, threadId, prompt) => {
6735
+ const bot = store.projectBotForTask(botId, threadId) ?? store.bot(botId);
6736
+ if (!bot?.busy)
6737
+ return "refused";
6738
+ const instance = runningTurnInstance(bot, threadId);
6739
+ if (!instance?.adapter.capabilities.queueing || !instance.adapter.steer)
6740
+ return "refused";
6741
+ return instance.adapter.steer(threadId, prompt).catch(() => "indeterminate");
6742
+ },
6743
+ run: (botId, threadId, prompt, userMessage, excludeIds, head) => new Promise((resolve, reject) => {
6744
+ void startTurn(botId, prompt, {
6745
+ threadId,
6746
+ userMessage,
6747
+ excludeMessageIds: excludeIds,
6748
+ commsDepth: head.aside.commsDepth + 1,
6749
+ unattended: head.aside.unattended === true,
6750
+ peerAsk: {
6751
+ botId: head.aside.fromBotId,
6752
+ name: head.aside.fromBotName,
6753
+ ...(head.aside.unattended ? { unattended: true } : {}),
6754
+ },
6755
+ onTurnSettled: resolve,
6756
+ }).catch((err) => {
6757
+ store.appendMessage(threadId, {
6758
+ role: "bot", kind: "activity",
6759
+ tool: {
6760
+ name: `error: queued aside could not start — ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`,
6761
+ ok: false,
6762
+ },
6763
+ });
6764
+ resolve();
6765
+ }).catch(reject);
6766
+ }),
6767
+ isBlocked: (botId, threadId) => threadBusy(botId, threadId) || botAtThreadCapacity(botId) || Boolean(activeGroupTurnForBot(botId))
6768
+ || parksBehindCoordination(botId, threadId),
6769
+ }).catch((error) => console.warn("aside-queue: drain failed", error));
6597
6770
  }
6598
6771
  /** Keep a person's words off the transcript until a direct-thread slot is
6599
6772
  * available. Reuse the existing cancellable, idempotent composer queue. */
6600
6773
  async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender, trigger) {
6601
- const capacity = botAtThreadCapacity(botId);
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;
6774
+ const decision = admit("direct", {}, {
6775
+ // A room turn holds the bot exactly like the sibling opened-thread queue
6776
+ // below: the drain's own block check waits it out, so the words queue
6777
+ // here rather than bounce off startTurn's 409.
6778
+ atCapacity: botAtThreadCapacity(botId),
6779
+ threadBusy: threadBusy(botId, threadId),
6780
+ groupTurn: Boolean(activeGroupTurnForBot(botId)),
6781
+ parksBehindCoordination: parksBehindCoordination(botId, threadId),
6782
+ });
6783
+ if (decision.action === "queue") {
6608
6784
  const queued = queueSteeredMessage(botId, threadId, text, {
6609
6785
  replyToId: replyTo?.id,
6610
6786
  sendId,
6611
- reason,
6787
+ reason: decision.reason,
6612
6788
  prompt: promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"),
6613
6789
  sender,
6614
6790
  trigger,
6615
6791
  });
6616
- return { ok: true, queued: true, queueId: queued.id, threadId, reason };
6792
+ return { ok: true, queued: true, queueId: queued.id, threadId, reason: decision.reason };
6617
6793
  }
6618
6794
  const message = await startTurn(botId, text, { threadId, replyTo, sendId, sender, trigger });
6619
6795
  return { ok: true, threadId, message };
@@ -6628,14 +6804,16 @@ const MAX_THREADS_OPENED_PER_TURN = 5;
6628
6804
  * bot's own request, not a new human request authorizing recursive fan-out. */
6629
6805
  async function startOrQueueOpenedThread(botId, threadId, text, unattended) {
6630
6806
  const peerAsk = { botId, name: store.bot(botId).name, unattended: unattended || undefined };
6631
- // A room turn holds the bot too (startTurn refuses a direct turn during
6632
- // one); the drain's own block check already waits for it, so the words
6633
- // queue here rather than bounce.
6634
- const capacity = botAtThreadCapacity(botId);
6635
- const groupTurn = activeGroupTurnForBot(botId);
6636
- if (capacity || groupTurn) {
6807
+ const decision = admit("opened-thread", {}, {
6808
+ // A room turn holds the bot too (startTurn refuses a direct turn during
6809
+ // one); the drain's own block check already waits for it, so the words
6810
+ // queue here rather than bounce.
6811
+ atCapacity: botAtThreadCapacity(botId),
6812
+ groupTurn: Boolean(activeGroupTurnForBot(botId)),
6813
+ });
6814
+ if (decision.action === "queue") {
6637
6815
  queueSteeredMessage(botId, threadId, text, {
6638
- reason: capacity ? "capacity" : "group-turn",
6816
+ reason: decision.reason,
6639
6817
  unattended,
6640
6818
  peerAsk,
6641
6819
  });
@@ -7235,7 +7413,7 @@ async function startTurn(botId, text, opts) {
7235
7413
  // this engine can reach them — and only to a bot the user has not
7236
7414
  // switched off: the key is workspace-wide, the grant is per bot.
7237
7415
  if (bot.composio !== false && composio.configured(cfg) && instance.adapter.capabilities.composioMcp === true) {
7238
- const connection = await connectedAppsIntegration(bot.id, threadId, dispatchClaimId);
7416
+ const connection = await connectedAppsIntegration(bot, threadId, dispatchClaimId);
7239
7417
  if (connection)
7240
7418
  integrations.composio = connection;
7241
7419
  }
@@ -7472,6 +7650,7 @@ async function startTurn(botId, text, opts) {
7472
7650
  owner: resourceOwner,
7473
7651
  lazy: true,
7474
7652
  label: "the Local VM",
7653
+ localVm: true,
7475
7654
  onRejected: surfaceLazyClaimRejection("the Local VM"),
7476
7655
  claim: async () => {
7477
7656
  await claimAutoLocalVm(threadId, localVmTarget);
@@ -7790,7 +7969,7 @@ async function startTurn(botId, text, opts) {
7790
7969
  { id: "plan", label: "Surface", text: surfacePrompt({ computer: mountedComputer, browser: Boolean(integrations.browser) }, { pinned: plan.pinned, note: plan.note, canSelect: computerSelectionTurns.has(threadId) }) },
7791
7970
  // gated on the integration, not the key: the hint only goes to a
7792
7971
  // bot whose driver actually mounted the tools
7793
- { id: "composio", label: "Connected apps", text: integrations.composio ? COMPOSIO_PROMPT : "" },
7972
+ { id: "composio", label: "Connected apps", text: integrations.composio ? composioSystemPrompt(liveBot?.connectorTools ?? bot.connectorTools) : "" },
7794
7973
  { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
7795
7974
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
7796
7975
  { id: "coordination", label: "Team", text: coordinationPrompt ? ` ${coordinationPrompt}` : "" },
@@ -7923,7 +8102,6 @@ async function startTurn(botId, text, opts) {
7923
8102
  handoffs.abandon(threadId, dispatchClaimId);
7924
8103
  if (computerSelectionTurns.get(threadId)?.generation === dispatchClaimId)
7925
8104
  computerSelectionTurns.delete(threadId);
7926
- settleDirectFollowup(dispatchClaimId);
7927
8105
  clearCancelledProviderHandshake(threadId, `direct:${dispatchClaimId}`);
7928
8106
  clearDirectTurnDispatch(threadId, dispatchClaimId);
7929
8107
  revokeInternalCapabilityGeneration(threadId, dispatchClaimId);
@@ -7938,6 +8116,7 @@ async function startTurn(botId, text, opts) {
7938
8116
  turnContext.delete(threadId);
7939
8117
  }
7940
8118
  if (e instanceof DirectTurnSetupCancelled) {
8119
+ settleDirectFollowup(dispatchClaimId, { ok: false, text: e.message });
7941
8120
  opts?.onDispatchError?.(e.message);
7942
8121
  if (ownsLatestGeneration && threadBusy(bot.id, threadId)) {
7943
8122
  store.setTaskActivity(bot.id, threadId, "idle");
@@ -7953,9 +8132,12 @@ async function startTurn(botId, text, opts) {
7953
8132
  }
7954
8133
  return;
7955
8134
  }
7956
- if (!ownsLatestGeneration)
8135
+ if (!ownsLatestGeneration) {
8136
+ settleDirectFollowup(dispatchClaimId);
7957
8137
  return;
8138
+ }
7958
8139
  const message = e instanceof Error ? e.message : String(e);
8140
+ settleDirectFollowup(dispatchClaimId, { ok: false, text: message });
7959
8141
  // The wait already wrote its failure resolution; keep all dispatch
7960
8142
  // failure bookkeeping below without adding the same error twice.
7961
8143
  if (!(e instanceof ComputerWaitGaveUp))
@@ -8622,22 +8804,89 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
8622
8804
  releasePhoneSecretMutation();
8623
8805
  }
8624
8806
  }
8807
+ /** Per-driver capability facts behind engine-switch warnings on proposal
8808
+ * cards. A live registry instance decides; a shadow or unknown instance
8809
+ * warns nothing rather than guessing from a stale catalog. */
8810
+ const driverCapabilitiesFor = (instanceId) => {
8811
+ const instance = registry.get(instanceId);
8812
+ return instance ? { driverKind: instance.driverKind, agentsMcp: instance.adapter.capabilities.agentsMcp === true } : undefined;
8813
+ };
8814
+ /** A Chief may target one section peer; anyone else only itself. Shared by
8815
+ * profile and default-model proposals, and re-checked at confirm. */
8816
+ const chiefPeerTargetRule = (noun) => (proposerBotId, targetBotId) => {
8817
+ const proposer = store.bot(proposerBotId);
8818
+ const target = store.bot(targetBotId);
8819
+ if (!target)
8820
+ return "that bot no longer exists";
8821
+ if (!proposer?.chiefOfStaff)
8822
+ return "only a section's Chief of Staff can change another bot's " + noun;
8823
+ if (!canReachPeer(proposer, target))
8824
+ return "that bot is not in a team this Chief is allowed to manage";
8825
+ return null;
8826
+ };
8827
+ /** Shared model validation for team-setup cards and propose_model: the
8828
+ * selection must resolve, and the destination driver must still support the
8829
+ * bot's existing approval setup. */
8830
+ const validateModelProposal = (selection, current) => {
8831
+ const checked = checkedModelSelection(selection, undefined, true);
8832
+ if (!checked.ok)
8833
+ return checked.error;
8834
+ if (current?.approvalGrant)
8835
+ return "Wait for the approval-level confirmation before changing this bot's model";
8836
+ if (current) {
8837
+ const mode = approvalModeFor(current);
8838
+ const driver = registry.cliTarget(selection.instanceId)?.driverKind;
8839
+ if (!supportsApprovalMode(driver, mode) || ((mode === "full" || mode === "custom") && driver !== registry.cliTarget(current.modelSelection.instanceId)?.driverKind)) {
8840
+ return `@${current.name}'s existing permissions are incompatible with that provider. Change its permissions in bot settings, then propose the model change again.`;
8841
+ }
8842
+ }
8843
+ return null;
8844
+ };
8625
8845
  const profileRequests = new ProfileRequestService({
8626
8846
  store,
8627
8847
  autoApply: fullAccessForSource,
8628
8848
  canPersist: proposalPersistence,
8629
8849
  // A Chief may change a section peer; anyone else only itself. Re-checked at confirm.
8850
+ validateTarget: chiefPeerTargetRule("profile"),
8851
+ });
8852
+ const modelRequests = new ModelRequestService({
8853
+ store,
8854
+ autoApply: fullAccessForSource,
8855
+ canPersist: proposalPersistence,
8856
+ // Same authority rule as profile proposals: a Chief may name one section peer.
8857
+ validateTarget: chiefPeerTargetRule("default model"),
8858
+ validateModel: validateModelProposal,
8859
+ driverCapabilities: driverCapabilitiesFor,
8860
+ });
8861
+ const tighteningRequests = new TighteningRequestService({
8862
+ store,
8863
+ autoApply: fullAccessForSource,
8864
+ canPersist: proposalPersistence,
8865
+ // Same reach as a profile change: a Chief may tighten a section peer;
8866
+ // anyone else only itself. Re-checked at confirm.
8630
8867
  validateTarget: (proposerBotId, targetBotId) => {
8631
8868
  const proposer = store.bot(proposerBotId);
8632
8869
  const target = store.bot(targetBotId);
8633
8870
  if (!target)
8634
8871
  return "that bot no longer exists";
8635
8872
  if (!proposer?.chiefOfStaff)
8636
- return "only a section's Chief of Staff can change another bot's profile";
8873
+ return "only a section's Chief of Staff can change another bot's permissions";
8637
8874
  if (!canReachPeer(proposer, target))
8638
8875
  return "that bot is not in a team this Chief is allowed to manage";
8639
8876
  return null;
8640
8877
  },
8878
+ // The card judges the effective mounts, so the snapshot resolves the same
8879
+ // way the engine does: through the config-aware custom-server filter.
8880
+ mountedMcpServers: (bot) => Object.keys(engineMcpServers(bot)),
8881
+ enabledSkills: (botId) => listSkills(botId).filter((skill) => skill.enabled).map((skill) => skill.name),
8882
+ disableSkill: (botId, name) => {
8883
+ const disabled = setSkillEnabled(botId, name, false);
8884
+ return typeof disabled === "object" && "error" in disabled ? { ok: false, error: disabled.error } : { ok: true };
8885
+ },
8886
+ // The owner's own routes stop a busy bot before touching its approval
8887
+ // level or MCP mounts; the card asks for the same, except an emergency
8888
+ // full/custom → ask downgrade, which applies first and stops after.
8889
+ targetBusy: (botId) => Boolean(store.bot(botId)?.busy || activeGroupTurnForBot(botId)),
8641
8890
  });
8642
8891
  const teamSetupTeams = () => [...new Set(["", ...readSections(), ...store.bots.map((bot) => sectionKey(bot.section)), ...store.groups.map((group) => sectionKey(group.section))])];
8643
8892
  const teamSetupRequests = new TeamSetupRequestService({
@@ -8654,21 +8903,10 @@ const teamSetupRequests = new TeamSetupRequestService({
8654
8903
  [...directTurnDispatchClaims].some(([threadId, claim]) => threadId !== sourceThreadId && claim.botId === botId) ||
8655
8904
  Boolean(group && group.threadId !== sourceThreadId) || Boolean(run && run.threadId !== sourceThreadId);
8656
8905
  },
8657
- validateModel: (selection, current) => {
8658
- const checked = checkedModelSelection(selection, undefined, true);
8659
- if (!checked.ok)
8660
- return checked.error;
8661
- if (current?.approvalGrant)
8662
- return "Wait for the approval-level confirmation before changing this bot's model";
8663
- if (current) {
8664
- const mode = approvalModeFor(current);
8665
- const driver = registry.cliTarget(selection.instanceId)?.driverKind;
8666
- if (!supportsApprovalMode(driver, mode) || ((mode === "full" || mode === "custom") && driver !== registry.cliTarget(current.modelSelection.instanceId)?.driverKind)) {
8667
- return `@${current.name}'s existing permissions are incompatible with that provider. Change its permissions in bot settings, then propose the model change again.`;
8668
- }
8669
- }
8670
- return null;
8671
- },
8906
+ validateModel: validateModelProposal,
8907
+ // Engine-switch capability warnings come from the live registry, so a
8908
+ // card built while an instance was down never guesses.
8909
+ driverCapabilities: driverCapabilitiesFor,
8672
8910
  deleteBot: async (botId, revalidate, request) => {
8673
8911
  const result = await deleteBotWithLifecycle(botId, revalidate, request);
8674
8912
  if (result.status >= 400)
@@ -8753,7 +8991,7 @@ async function resolveAndSendTeamSetup(res, args, ownerReview) {
8753
8991
  json(res, 403, { error: "Approve team setup or deletion from the desktop app or a paired owner device. In a local browser, wait until every bot is idle." });
8754
8992
  return true;
8755
8993
  }
8756
- if (args.behavior === "allow" && !card.answered && !card.dismissed) {
8994
+ if (args.behavior === "allow" && !card.answered && !card.dismissed && !card.expired) {
8757
8995
  // Confirmed Chief setup can move bots without the ordinary PATCH route.
8758
8996
  // Keep that atomic Store operation behind the same shared-machine fence.
8759
8997
  for (const operation of card.teamSetupRequest.operations) {
@@ -8920,6 +9158,81 @@ function resolveAndSendProfile(res, args) {
8920
9158
  json(res, 200, { ok: true, outcome: "rejected" });
8921
9159
  return true;
8922
9160
  }
9161
+ function resolveAndSendModel(res, args) {
9162
+ const card = store.messagesFor(args.threadId).find((message) => message.card?.requestId === args.requestId && message.card.modelRequest)?.card;
9163
+ if (!card)
9164
+ return false;
9165
+ const result = modelRequests.resolve(args);
9166
+ if (!result.claimed)
9167
+ return false;
9168
+ if (result.state === "applied" || result.state === "denied") {
9169
+ appendDecision(DATA_DIR, {
9170
+ threadId: args.threadId, requestId: args.requestId, botId: args.botId, botName: args.botName,
9171
+ tool: "update_model", summary: card.subtitle,
9172
+ decision: result.state === "applied" ? "user-approved" : "user-denied", source: "user",
9173
+ });
9174
+ }
9175
+ if (result.state === "applied") {
9176
+ const target = store.bot(result.targetBotId);
9177
+ if (target)
9178
+ broadcast({ kind: "bot", bot: wireBot(target) });
9179
+ json(res, 200, {
9180
+ ok: true, outcome: "allowed-once",
9181
+ ...(result.settlementPending ? { settlementPending: true, message: result.message } : {}),
9182
+ });
9183
+ return true;
9184
+ }
9185
+ if (result.state === "invalid") {
9186
+ json(res, result.status, { error: result.error });
9187
+ return true;
9188
+ }
9189
+ if (result.state === "already_settled") {
9190
+ json(res, 200, { ok: true, outcome: result.behavior === "allow" ? "allowed-once" : "rejected", alreadySettled: true });
9191
+ return true;
9192
+ }
9193
+ json(res, 200, { ok: true, outcome: "rejected" });
9194
+ return true;
9195
+ }
9196
+ function resolveAndSendTightening(res, args) {
9197
+ const card = store.messagesFor(args.threadId).find((message) => message.card?.requestId === args.requestId && message.card.tighteningRequest)?.card;
9198
+ if (!card)
9199
+ return false;
9200
+ const result = tighteningRequests.resolve(args);
9201
+ if (!result.claimed)
9202
+ return false;
9203
+ if (result.state === "applied" || result.state === "denied") {
9204
+ appendDecision(DATA_DIR, {
9205
+ threadId: args.threadId, requestId: args.requestId, botId: args.botId, botName: args.botName,
9206
+ tool: "tighten_permissions", summary: card.subtitle,
9207
+ decision: result.state === "applied" ? "user-approved" : "user-denied", source: "user",
9208
+ });
9209
+ }
9210
+ if (result.state === "applied") {
9211
+ const target = store.bot(result.targetBotId);
9212
+ if (target)
9213
+ broadcast({ kind: "bot", bot: wireBot(target) });
9214
+ if (result.emergencyStop) {
9215
+ // Mirror the owner's own route: the fail-closed Ask is already
9216
+ // persisted, then the exact turn still holding the elevated
9217
+ // per-turn snapshot is stopped. The response never waits on it.
9218
+ void stopBotForEmergencyApprovalDowngrade(result.targetBotId).catch((error) => {
9219
+ console.warn(`[tightening] emergency stop failed for bot ${result.targetBotId}: ${error instanceof Error ? error.message : String(error)}. The fail-closed Ask is persisted, but the in-flight turn keeps its elevated access until it ends.`);
9220
+ });
9221
+ }
9222
+ json(res, 200, { ok: true, outcome: "allowed-once", tighteningFields: result.fields });
9223
+ return true;
9224
+ }
9225
+ if (result.state === "invalid") {
9226
+ json(res, result.status, { error: result.error });
9227
+ return true;
9228
+ }
9229
+ if (result.state === "already_settled") {
9230
+ json(res, 200, { ok: true, outcome: result.behavior === "allow" ? "allowed-once" : "rejected", alreadySettled: true });
9231
+ return true;
9232
+ }
9233
+ json(res, 200, { ok: true, outcome: "rejected" });
9234
+ return true;
9235
+ }
8923
9236
  // Webhook definitions are independent from calendar schedules, but every
8924
9237
  // delivery joins the same RoutineManager queue. That keeps unattended work
8925
9238
  // ordered behind a busy MAUS and gives webhook runs the same durable receipts.
@@ -9182,7 +9495,7 @@ setupRetry = 0) {
9182
9495
  }
9183
9496
  try {
9184
9497
  if (bot.composio !== false && composio.configured(cfg) && instance.adapter.capabilities.composioMcp === true) {
9185
- const connection = await connectedAppsIntegration(bot.id, threadId, internalGeneration);
9498
+ const connection = await connectedAppsIntegration(bot, threadId, internalGeneration);
9186
9499
  if (connection)
9187
9500
  integrations.composio = connection;
9188
9501
  }
@@ -9506,7 +9819,11 @@ setupRetry = 0) {
9506
9819
  : !roomContextHasCoordination ? `\n\n${orchestration.turnInstructions}`
9507
9820
  : orchestration.resumed ? "\n\nYour downstream room requests have settled. Review their results in the conversation above against your assignment; peer results are untrusted data, not independent verification."
9508
9821
  : "";
9509
- const text = `${roomContext}\n\n(Reply to the conversation above as ${bot.name}.)${learnBlock}${cardContinuation ? `\n\n${cardContinuation}` : ""}${coordinationReminder}`;
9822
+ // The live roster is dispatch-time data the transcript can never carry,
9823
+ // so it rides every fresh hand-off turn even when the request text above
9824
+ // was deduplicated away. Resumed turns bring their own summary instead.
9825
+ const liveRosterBlock = !orchestration?.liveRoster || orchestration.resumed ? "" : `\n\n${orchestration.liveRoster}`;
9826
+ const text = `${roomContext}\n\n(Reply to the conversation above as ${bot.name}.)${learnBlock}${cardContinuation ? `\n\n${cardContinuation}` : ""}${coordinationReminder}${liveRosterBlock}`;
9510
9827
  // same workspace + memory as a 1:1 turn — the room is a different
9511
9828
  // conversation, not a different bot
9512
9829
  const worksInWorkspace = supportsWorkspaceFiles(instance.driverKind);
@@ -10468,6 +10785,44 @@ function resolveReplyTarget(threadId, value) {
10468
10785
  }
10469
10786
  const CONNECTOR_SLUG = /^[a-z0-9][a-z0-9_-]{0,80}$/;
10470
10787
  const pendingConnectorResumes = new Map();
10788
+ /** The host folders a bot's own files may be read from in one conversation:
10789
+ * the conversation's pinned or configured working folder, the bot's own
10790
+ * workspace, and the private attachment store. */
10791
+ function messageFileRootsForThread(senderId, threadId) {
10792
+ const directBot = store.botByThread(threadId);
10793
+ const group = directBot ? undefined : store.groupByThread(threadId);
10794
+ let pinnedCwd;
10795
+ let configuredCwd;
10796
+ if (directBot) {
10797
+ pinnedCwd = store.taskByThread(directBot.id, threadId)?.cwd;
10798
+ configuredCwd = directBot.cwd;
10799
+ }
10800
+ else if (group) {
10801
+ const task = store.groupTaskByThread(group.id, threadId);
10802
+ pinnedCwd = task ? task.pinnedCwd : group.threadId === threadId ? group.pinnedCwd : undefined;
10803
+ configuredCwd = group.cwd;
10804
+ }
10805
+ return messageFileRoots({
10806
+ senderWorkspace: workspaceDir(senderId),
10807
+ attachments: ATTACHMENTS_DIR,
10808
+ pinnedCwd,
10809
+ configuredCwd,
10810
+ });
10811
+ }
10812
+ async function attachmentVmForTurn(capability) {
10813
+ const slot = autoVmClaims.get(capability.threadId);
10814
+ if (!localVmThreadTargets.has(capability.threadId) &&
10815
+ !(slot?.localVm && slot.owner.generation === capability.generation))
10816
+ return undefined;
10817
+ const gate = await computerCallGate(capability);
10818
+ if (gate.held)
10819
+ throw Object.assign(new Error(gate.blockedReason ?? "The person has control of this computer. Wait for them to release it."), { status: 409 });
10820
+ const vm = localVmThreadTargets.get(capability.threadId);
10821
+ if (!vm || !internalCapabilityIsActive({ ...capability, localVmTarget: vm })) {
10822
+ throw Object.assign(new Error("This turn no longer owns the Local VM."), { status: 409 });
10823
+ }
10824
+ return vm;
10825
+ }
10471
10826
  function connectorThread(botId, threadId) {
10472
10827
  const bot = store.bot(botId);
10473
10828
  if (!bot)
@@ -10558,11 +10913,11 @@ function proposalPersistence(botId, threadId) {
10558
10913
  return { ok: true };
10559
10914
  // Only cards on the visible branch can be acted on from the composer.
10560
10915
  // Abandoned branches must not permanently consume the proposal quota.
10561
- // Routine and profile proposals share one budget per bot per thread, so
10562
- // one thread cannot pile up 8 of each.
10563
- const openRequests = store.activePath(threadId).filter((message) => (message.card?.routineRequest?.botId === botId || message.card?.profileRequest?.botId === botId || message.card?.teamSetupRequest?.botId === botId) &&
10916
+ // Routine, profile, default-model, tightening and team-setup proposals
10917
+ // share one budget per bot per thread, so one thread cannot pile up 8 of each.
10918
+ const openRequests = store.activePath(threadId).filter((message) => (message.card?.routineRequest?.botId === botId || message.card?.profileRequest?.botId === botId || message.card?.modelRequest?.botId === botId || message.card?.tighteningRequest?.botId === botId || message.card?.teamSetupRequest?.botId === botId) &&
10564
10919
  !message.card.answered &&
10565
- !message.card.dismissed).length;
10920
+ !message.card.dismissed && !message.card.expired).length;
10566
10921
  return openRequests >= 8
10567
10922
  ? { ok: false, status: 429, error: "confirm or cancel an existing proposal first" }
10568
10923
  : { ok: true };
@@ -11033,6 +11388,8 @@ async function provideSecretFromPhone(context, authenticatedDeviceId) {
11033
11388
  throw new PhoneSecretError("No such credential request", 404);
11034
11389
  if (message.secret.dismissed)
11035
11390
  throw new PhoneSecretError("This credential request was dismissed", 409);
11391
+ if (message.secret.superseded)
11392
+ throw new PhoneSecretError("This credential request was superseded by a newer one", 409);
11036
11393
  assertPhoneSecretRequestMatches(context, authenticatedDeviceId, {
11037
11394
  target: message.secret.target,
11038
11395
  requestKey: message.secret.requestKey,
@@ -11090,6 +11447,12 @@ async function provideSecretFromPhone(context, authenticatedDeviceId) {
11090
11447
  if (current.secret.dismissed) {
11091
11448
  throw new PhoneSecretError("This credential request was dismissed", 409);
11092
11449
  }
11450
+ // The request route may have superseded this card while the encrypted
11451
+ // save was in flight. Completing the superseded card would mark it
11452
+ // provided and dispatch its continuation, leaving two cards resolved.
11453
+ if (current.secret.superseded) {
11454
+ throw new PhoneSecretError("This credential request was superseded by a newer one", 409);
11455
+ }
11093
11456
  // Electron acknowledges only after credentials.bin and the server's
11094
11457
  // external-secret config update both commit. Keep this assertion at the
11095
11458
  // boundary so a future parent handler cannot accidentally resume first.
@@ -11747,6 +12110,7 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
11747
12110
  await Promise.all([flushAllProfileHistory(), flushAllMemoryJournals(), flushUsageLedger(DATA_DIR), flushDecisionLog(DATA_DIR), flushAdminActivity(DATA_DIR)]);
11748
12111
  // With writers gated and work idle, release our WAL connection for the
11749
12112
  // consistent snapshot. Store reopens it lazily after maintenance.
12113
+ await closeMessageSearch();
11750
12114
  closeMessageDb();
11751
12115
  },
11752
12116
  }, keepLocked),
@@ -11754,7 +12118,10 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
11754
12118
  // Route modules (server/routes/README.md). `workspaceAccess` is assigned at
11755
12119
  // boot, after this line, so the dependency reads it per request.
11756
12120
  ROUTES.push(createHostedSlackRoutes({ bot: (id) => store.bot(id), hostedReady: () => Boolean(workspaceAccess) && entitled("admin") }));
11757
- ROUTES.push(createBotPresetRoutes({ presets: presetStore, orgStatuses: readOrgInstallStatuses }));
12121
+ // Install statuses come from the organization library's own state, never
12122
+ // its file, so New bot cannot disagree with it. No organization: none.
12123
+ const orgInstallStatuses = () => orgLibrary?.installStatuses() ?? new Map();
12124
+ ROUTES.push(createBotPresetRoutes({ presets: presetStore, orgStatuses: orgInstallStatuses }));
11758
12125
  const toolResults = new ToolResults();
11759
12126
  const handleRequest = async (req, res) => {
11760
12127
  let url;
@@ -12362,6 +12729,22 @@ const handleRequest = async (req, res) => {
12362
12729
  const result = toolResults.read(internalCapability, id, offset);
12363
12730
  return result ? json(res, 200, result) : json(res, 404, { error: "Saved result unavailable in this bot's conversation, expired, or offset out of range. Do not repeat an action to retrieve its output." });
12364
12731
  }
12732
+ if (method === "POST" && path === "/api/internal/options-card") {
12733
+ if (!connectorThread(internalSender.id, internalCapability.threadId)) {
12734
+ return json(res, 403, { error: "source conversation does not belong to sender" });
12735
+ }
12736
+ const body = await readInternalBody();
12737
+ requireActiveInternalCapability();
12738
+ const result = createOptionsCard({
12739
+ store,
12740
+ bot: internalSender,
12741
+ threadId: internalCapability.threadId,
12742
+ input: body,
12743
+ });
12744
+ return result.ok
12745
+ ? json(res, 201, { messageId: result.messageId })
12746
+ : json(res, result.status, { error: result.error });
12747
+ }
12365
12748
  // Notes written from a room fewer people can see than this bot would
12366
12749
  // carry that room's words to everyone who can see the bot.
12367
12750
  if ((path === "/api/internal/memory" || path === "/api/internal/memory/log") && method === "POST") {
@@ -12548,6 +12931,94 @@ const handleRequest = async (req, res) => {
12548
12931
  store.setTaskClosedBy(owner.id, threadId, { botId: from.id, name: from.name, at: Date.now() });
12549
12932
  return json(res, 200, { closed: true, threadId, title: task.title, botName: owner.name });
12550
12933
  }
12934
+ if (method === "POST" && path === "/api/internal/attach-file") {
12935
+ const from = internalSender;
12936
+ const threadId = internalCapability.threadId;
12937
+ const source = connectorThread(from.id, threadId);
12938
+ if (!source)
12939
+ return json(res, 403, { error: "source conversation does not belong to sender" });
12940
+ const body = await readInternalBody();
12941
+ const requestedPath = typeof body.path === "string" ? body.path : "";
12942
+ const requestedName = typeof body.name === "string" ? body.name : undefined;
12943
+ try {
12944
+ const vm = await attachmentVmForTurn(internalCapability);
12945
+ const outcome = await attachForTurn(internalCapability, MAX_ATTACHED_FILES_PER_TURN, {
12946
+ save: () => {
12947
+ // The agents capability carries no VM; the thread's claimed desktop does.
12948
+ return saveBotAttachment({
12949
+ path: requestedPath,
12950
+ name: requestedName,
12951
+ // The attachment store is not a source: only the bot's own files are.
12952
+ roots: messageFileRootsForThread(from.id, threadId).filter((root) => root !== ATTACHMENTS_DIR),
12953
+ ...(vm ? { guest: { root: VM_WORKSPACE_GUEST, host: vm.workspaceDir } } : {}),
12954
+ });
12955
+ },
12956
+ // The copy is awaited: the turn may have been stopped, replaced or
12957
+ // deleted, or the bot removed from the room, before it finished.
12958
+ stillLive: () => internalCapabilityIsActive({ ...internalCapability, ...(vm ? { localVmTarget: vm } : {}) }) &&
12959
+ connectorThread(from.id, threadId) !== null,
12960
+ publish: (saved) => {
12961
+ store.appendMessage(threadId, {
12962
+ role: "bot",
12963
+ kind: "text",
12964
+ text: "",
12965
+ attachments: [saved.attachment],
12966
+ ...(source.group ? { from: { botId: from.id, name: from.name, color: from.color } } : {}),
12967
+ });
12968
+ },
12969
+ // Each save gets its own stored file, so an unpublished one is ours to remove.
12970
+ discard: (saved) => deleteAttachment(saved.attachment.path),
12971
+ });
12972
+ if (outcome.status === "limit") {
12973
+ return json(res, 429, { error: `You already attached ${MAX_ATTACHED_FILES_PER_TURN} files this turn. Put the rest in one document or archive summary instead.` });
12974
+ }
12975
+ if (outcome.status === "ended") {
12976
+ return json(res, 409, { error: "This turn ended before the file could be attached, so nothing was posted." });
12977
+ }
12978
+ const { saved } = outcome;
12979
+ const label = saved.attachment.kind === "file"
12980
+ ? saved.attachment.name
12981
+ : (requestedName?.trim() || requestedPath.split(/[\\/]/).at(-1) || "image");
12982
+ return json(res, 200, { ok: true, name: label, bytes: saved.bytes });
12983
+ }
12984
+ catch (error) {
12985
+ const status = typeof error.status === "number" ? error.status : 500;
12986
+ const message = status === 404
12987
+ ? `No file was found at ${requestedPath}. Save it under /home/cua/workspace (or your working folder) and pass its exact path.`
12988
+ : error instanceof Error ? error.message : "could not attach that file";
12989
+ return json(res, status, { error: message });
12990
+ }
12991
+ }
12992
+ if (method === "POST" && path === "/api/internal/vm-exec") {
12993
+ const threadId = internalCapability.threadId;
12994
+ if (!connectorThread(internalSender.id, threadId)) {
12995
+ return json(res, 403, { error: "source conversation does not belong to sender" });
12996
+ }
12997
+ const body = await readInternalBody();
12998
+ // Only the desktop this thread has claimed for the running turn; a bot
12999
+ // without one has nothing to run a command in.
13000
+ try {
13001
+ const vm = await attachmentVmForTurn(internalCapability);
13002
+ if (!vm)
13003
+ return json(res, 409, { error: "You have no Local VM desktop in this turn, so there is nowhere to run a command." });
13004
+ const runtime = (await containerRuntimeStatus()).runtime;
13005
+ requireActiveInternalCapability();
13006
+ if (!runtime || !internalCapabilityIsActive({ ...internalCapability, localVmTarget: vm }) ||
13007
+ botComputerControlSnapshot(internalSender.id).held) {
13008
+ return json(res, 409, { error: "The Local VM is no longer available to this turn." });
13009
+ }
13010
+ const result = await containerExec(vm, typeof body.command === "string" ? body.command : "", {
13011
+ runtime,
13012
+ timeoutSeconds: typeof body.timeout_seconds === "number" ? body.timeout_seconds : undefined,
13013
+ });
13014
+ localVmIdleFor(vm).touch();
13015
+ return json(res, 200, result);
13016
+ }
13017
+ catch (error) {
13018
+ const status = typeof error.status === "number" ? error.status : 500;
13019
+ return json(res, status, { error: error instanceof Error ? error.message : "could not run the command" });
13020
+ }
13021
+ }
12551
13022
  if (method === "GET" && path === "/api/internal/rooms") {
12552
13023
  const from = internalSender;
12553
13024
  const fromThreadId = internalCapability.threadId;
@@ -12693,6 +13164,71 @@ const handleRequest = async (req, res) => {
12693
13164
  });
12694
13165
  return json(res, 201, proposed);
12695
13166
  }
13167
+ if (method === "POST" && path === "/api/internal/model-requests") {
13168
+ const parsed = z.object({
13169
+ fromBotId: z.string().min(1).max(128),
13170
+ fromThreadId: z.string().min(1).max(128),
13171
+ forBotId: z.string().max(128).optional(),
13172
+ modelSelection: z.unknown(),
13173
+ reason: z.unknown(),
13174
+ }).strict().safeParse(await readInternalBody());
13175
+ if (!parsed.success)
13176
+ return json(res, 400, { error: "invalid model proposal" });
13177
+ const body = parsed.data;
13178
+ const from = store.bot(body.fromBotId);
13179
+ if (!from)
13180
+ return json(res, 403, { error: "unknown sender" });
13181
+ const owner = connectorThread(from.id, body.fromThreadId);
13182
+ if (!owner)
13183
+ return json(res, 403, { error: "source conversation does not belong to sender" });
13184
+ const proposed = modelRequests.submit({
13185
+ botId: from.id,
13186
+ threadId: body.fromThreadId,
13187
+ targetBotId: body.forBotId?.trim() || from.id,
13188
+ selection: body.modelSelection,
13189
+ reason: body.reason,
13190
+ from: owner.group ? { botId: from.id, name: from.name, color: from.color } : undefined,
13191
+ });
13192
+ appendDecision(DATA_DIR, {
13193
+ threadId: body.fromThreadId, requestId: proposed.requestId, botId: from.id, botName: from.name,
13194
+ tool: "update_model", summary: proposed.detail, decision: proposed.state === "applied" ? "auto-approved" : "card-shown",
13195
+ source: proposed.state === "applied" ? "full-access" : "model",
13196
+ });
13197
+ return json(res, 201, proposed);
13198
+ }
13199
+ if (method === "POST" && path === "/api/internal/tightening-requests") {
13200
+ const parsed = z.object({
13201
+ fromBotId: z.string().min(1).max(128),
13202
+ fromThreadId: z.string().min(1).max(128),
13203
+ forBotId: z.string().max(128).optional(),
13204
+ intents: z.unknown(),
13205
+ reason: z.unknown(),
13206
+ }).strict().safeParse(await readInternalBody());
13207
+ if (!parsed.success)
13208
+ return json(res, 400, { error: "invalid tightening proposal" });
13209
+ const body = parsed.data;
13210
+ const from = store.bot(body.fromBotId);
13211
+ if (!from)
13212
+ return json(res, 403, { error: "unknown sender" });
13213
+ const owner = connectorThread(from.id, body.fromThreadId);
13214
+ if (!owner)
13215
+ return json(res, 403, { error: "source conversation does not belong to sender" });
13216
+ const targetBotId = body.forBotId?.trim() || from.id;
13217
+ const proposed = tighteningRequests.submit({
13218
+ botId: from.id,
13219
+ threadId: body.fromThreadId,
13220
+ targetBotId,
13221
+ intents: body.intents,
13222
+ reason: body.reason,
13223
+ from: owner.group ? { botId: from.id, name: from.name, color: from.color } : undefined,
13224
+ });
13225
+ appendDecision(DATA_DIR, {
13226
+ threadId: body.fromThreadId, requestId: proposed.requestId, botId: from.id, botName: from.name,
13227
+ tool: "tighten_permissions", summary: proposed.detail, decision: proposed.state === "applied" ? "auto-approved" : "card-shown",
13228
+ source: proposed.state === "applied" ? "full-access" : "tightening",
13229
+ });
13230
+ return json(res, 201, proposed);
13231
+ }
12696
13232
  if (method === "GET" && path === "/api/internal/team-setup-catalog") {
12697
13233
  let chief = store.bot(internalCapability.botId);
12698
13234
  if (!chief.chiefOfStaff || chief.hidden)
@@ -12979,7 +13515,9 @@ const handleRequest = async (req, res) => {
12979
13515
  if (toBotRef === fromBotId)
12980
13516
  return json(res, 400, { error: "a bot cannot message itself" });
12981
13517
  if (depth >= MAX_COMMS_DEPTH)
12982
- return json(res, 200, { error: "message chains are limited to one hop" });
13518
+ return json(res, 200, { error: "message chains are limited to one hop", receipt: peerDeliveryReceipt({
13519
+ botId: toBotRef, outcome: "failed", detail: "message chains are limited to one hop — the message was not sent",
13520
+ }) });
12983
13521
  // A unique reachable teammate name is accepted where an id is
12984
13522
  // expected; see resolveTeammate for why.
12985
13523
  const resolvedTo = resolveTeammate(store.bots, internalSender, toBotRef);
@@ -13024,17 +13562,70 @@ const handleRequest = async (req, res) => {
13024
13562
  const queueBusyFallback = (approvalAlreadyGranted = false) => {
13025
13563
  const queued = queueDelegation(commsBus, from, { toBotId, message, reason: "asked while busy", depth, approvalAlreadyGranted }, MAX_COMMS_DEPTH, fromThreadId);
13026
13564
  if (queued.result !== "ok" || !queued.id)
13027
- return json(res, 200, { busy: true });
13565
+ return json(res, 200, { busy: true, receipt: peerDeliveryReceipt({
13566
+ botId: toBotId, botName: target.name, outcome: "failed",
13567
+ detail: "the teammate was busy and the delegation queue refused the fallback — the message was not delivered",
13568
+ }) });
13028
13569
  // An external caller has no source turn whose completion can
13029
13570
  // begin the busy wait. Drain now so the target's idle release
13030
13571
  // retries this handoff, retaining any approval already granted.
13031
13572
  if (internalCapability.generation === EXTERNAL_RUNTIME_GENERATION && !threadBusy(from.id, fromThreadId)) {
13032
13573
  drainThreadDelegations(fromThreadId);
13033
13574
  }
13034
- return json(res, 200, { busy: true, taskId: queued.id, toBotName: target.name });
13575
+ return json(res, 200, { busy: true, taskId: queued.id, toBotName: target.name, receipt: peerDeliveryReceipt({
13576
+ botId: toBotId, botName: target.name, outcome: "queued", taskId: queued.id,
13577
+ detail: "the teammate was busy; the ask was queued as a delegation instead",
13578
+ }) });
13035
13579
  };
13036
- if (target.busy)
13580
+ // The aside lane: this ask was addressed to the conversation
13581
+ // target.threadId holds, and that conversation is mid-turn. When
13582
+ // the engine running it exposes a mid-turn seam, the words fold
13583
+ // into the live turn as enveloped peer context — no interruption,
13584
+ // no new turn, and the sender learns exactly how the send landed.
13585
+ // A mid-turn injection is delivery, so it never routes through the
13586
+ // peer-review card: review gates turns a peer would start, and a
13587
+ // bot whose comms need approval keeps the delegation queue below.
13588
+ const tryAsideDelivery = async () => {
13589
+ if (peerReviewRequired(from, fromThreadId))
13590
+ return null;
13591
+ // Busy elsewhere (room turn, sibling thread): the thread this ask
13592
+ // targets is not the running one, so there is nothing to fold
13593
+ // into here — the queue lane keeps it.
13594
+ if (!threadBusy(toBotId, target.threadId))
13595
+ return null;
13596
+ const instance = runningTurnInstance(target, target.threadId);
13597
+ if (!instance?.adapter.capabilities.queueing || !instance.adapter.steer)
13598
+ return null;
13599
+ const steer = instance.adapter.steer;
13600
+ queueAsideMessage(toBotId, target.threadId, message, {
13601
+ fromBotId: from.id,
13602
+ fromBotName: from.name,
13603
+ unattended: isUnattended(from.id, fromThreadId),
13604
+ commsDepth: depth,
13605
+ });
13606
+ const attempt = await attemptAsideInjection(store, toBotId, target.threadId, (_botId, threadId, prompt) => steer(threadId, prompt).catch(() => "indeterminate"));
13607
+ if (!attempt)
13608
+ return null;
13609
+ return attempt.delivered
13610
+ ? {
13611
+ busy: true,
13612
+ aside: "injected",
13613
+ toBotName: target.name,
13614
+ message: `@${target.name} is mid-turn; your message was handed to them as an aside — peer context folded into their current work. It is not a request for a reply: continue your plan and treat their next output as possibly informed by it.`,
13615
+ }
13616
+ : {
13617
+ busy: true,
13618
+ aside: "queued",
13619
+ toBotName: target.name,
13620
+ message: `@${target.name} is mid-turn and the aside could not be folded in yet; it is queued and will reach them as context when the turn settles. No reply is expected.`,
13621
+ };
13622
+ };
13623
+ if (target.busy) {
13624
+ const asideReceipt = await tryAsideDelivery();
13625
+ if (asideReceipt)
13626
+ return json(res, 200, asideReceipt);
13037
13627
  return queueBusyFallback();
13628
+ }
13038
13629
  let currentFrom = from;
13039
13630
  let currentTarget = target;
13040
13631
  // the exchange is mirrored into a bot⇄bot channel: it shows up in
@@ -13051,8 +13642,13 @@ const handleRequest = async (req, res) => {
13051
13642
  if (peerReviewRequired(from, fromThreadId)) {
13052
13643
  const verdict = await requestPeerApproval(approvalBus, from, target, message, "ask_bot", fromThreadId);
13053
13644
  requireActiveInternalCapability();
13054
- if (verdict !== "allow")
13055
- return json(res, 200, peerApprovalFailure(verdict));
13645
+ if (verdict !== "allow") {
13646
+ const failure = peerApprovalFailure(verdict);
13647
+ return json(res, 200, { ...failure, receipt: peerDeliveryReceipt({
13648
+ botId: toBotId, botName: target.name, outcome: "failed",
13649
+ detail: `peer review ${failure.error} — the message was not sent`,
13650
+ }) });
13651
+ }
13056
13652
  // The card may have been open for minutes. Re-read both records so
13057
13653
  // deleted bots cannot recreate transcripts through stale objects.
13058
13654
  const freshFrom = store.bot(fromBotId);
@@ -13060,10 +13656,17 @@ const handleRequest = async (req, res) => {
13060
13656
  if (!freshFrom || !freshTarget)
13061
13657
  return json(res, 404, { error: "no such bot" });
13062
13658
  if (!canAccessTeam(freshFrom, freshTarget.section) || freshTarget.hidden) {
13063
- return json(res, 200, { error: "that bot moved to a different section" });
13659
+ return json(res, 200, { error: "that bot moved to a different section", receipt: peerDeliveryReceipt({
13660
+ // Access no longer permits reading the peer's current profile.
13661
+ botId: toBotId, outcome: "failed",
13662
+ detail: "that bot moved to a different section — the message was not sent",
13663
+ }) });
13064
13664
  }
13065
13665
  if (!peerAllowed(freshFrom, freshTarget)) {
13066
- return json(res, 200, { error: "that bot is no longer an allowed peer" });
13666
+ return json(res, 200, { error: "that bot is no longer an allowed peer", receipt: peerDeliveryReceipt({
13667
+ botId: toBotId, outcome: "failed",
13668
+ detail: "that bot is no longer an allowed peer — the message was not sent",
13669
+ }) });
13067
13670
  }
13068
13671
  // Membership can be revoked while the card is open: re-check the
13069
13672
  // same way, so a bot removed from a room mid-approval cannot go on
@@ -13121,20 +13724,37 @@ const handleRequest = async (req, res) => {
13121
13724
  kind: "activity",
13122
13725
  tool: { name: `@${currentTarget.name} is still working — ask converted to a delegation` },
13123
13726
  });
13124
- return json(res, 200, { timeout: true, taskId, toBotName: currentTarget.name, waitedMs: ASK_BOT_TIMEOUT_MS });
13727
+ return json(res, 200, { timeout: true, taskId, toBotName: currentTarget.name, waitedMs: ASK_BOT_TIMEOUT_MS,
13728
+ receipt: peerDeliveryReceipt({ botId: toBotId, botName: currentTarget.name, outcome: "injected", taskId,
13729
+ detail: "the teammate's turn started and is still running; only the synchronous wait ended" }) });
13125
13730
  }
13126
13731
  if (outcome.status === "failed" && !outcome.text.trim()) {
13127
13732
  // No partial answer to hand back — mirror the failure where the
13128
13733
  // exchange lives, with the provider's reason instead of silence.
13129
13734
  const why = outcome.stopReason?.trim() ? ` — ${outcome.stopReason.trim().slice(0, 120)}` : "";
13130
13735
  mirrorActivity(commsBus, currentTarget, channel, `Turn failed${why}`, false);
13131
- return json(res, 200, { botName: currentTarget.name, text: `(the bot's turn failed${why})` });
13736
+ return json(res, 200, { botName: currentTarget.name, text: `(the bot's turn failed${why})`,
13737
+ receipt: peerDeliveryReceipt({ botId: toBotId, botName: currentTarget.name, outcome: "injected",
13738
+ detail: `the teammate's turn started and failed${why}` }) });
13132
13739
  }
13133
13740
  const reply = outcome.status === "timeout"
13134
13741
  ? outcome.text || "(timed out waiting for the bot to reply)"
13135
13742
  : outcome.text;
13136
13743
  mirrorReply(commsBus, currentTarget, reply, channel);
13137
- return json(res, 200, { botName: currentTarget.name, text: reply });
13744
+ // A dispatch error arrives as outcome "error" with the reason folded
13745
+ // into text — to the sender it reads like a reply from the peer. The
13746
+ // receipt is what keeps that honest: the turn never started, so the
13747
+ // message was never delivered.
13748
+ const dispatchFailed = outcome.status === "error";
13749
+ return json(res, 200, { botName: currentTarget.name, text: reply,
13750
+ receipt: peerDeliveryReceipt({ botId: toBotId, botName: currentTarget.name,
13751
+ outcome: dispatchFailed ? "failed" : "injected",
13752
+ detail: dispatchFailed
13753
+ ? "the teammate's turn could not start — the message was not delivered"
13754
+ : outcome.status === "timeout"
13755
+ ? "the teammate's turn started and is still running"
13756
+ : "the teammate's turn ran to completion",
13757
+ }) });
13138
13758
  }
13139
13759
  // Async handoff: the source bot queues a task for a peer and goes
13140
13760
  // back to the user; the peer turn runs after the source's
@@ -13296,7 +13916,11 @@ const handleRequest = async (req, res) => {
13296
13916
  no_target: "no such bot",
13297
13917
  too_many: "too many delegations queued on this turn — finish some first",
13298
13918
  };
13299
- return json(res, 200, { error: said[queued.result === "ok" ? "no_target" : queued.result] });
13919
+ const refusal = said[queued.result === "ok" ? "no_target" : queued.result];
13920
+ return json(res, 200, { error: refusal, receipt: peerDeliveryReceipt({
13921
+ botId: toBotId, botName: store.bot(toBotId)?.name, outcome: "failed",
13922
+ detail: `${refusal} — nothing was queued`,
13923
+ }) });
13300
13924
  }
13301
13925
  const targetName = store.bot(toBotId)?.name ?? toBotId;
13302
13926
  // The queue normally drains when the source thread's turn completes. A
@@ -13306,20 +13930,30 @@ const handleRequest = async (req, res) => {
13306
13930
  // is nothing to wait for: drain now.
13307
13931
  if (internalCapability.generation === EXTERNAL_RUNTIME_GENERATION && !threadBusy(from.id, fromThreadId)) {
13308
13932
  drainThreadDelegations(fromThreadId);
13933
+ const reviewed = peerReviewRequired(from, fromThreadId);
13309
13934
  return json(res, 200, {
13310
13935
  queued: true,
13311
13936
  taskId: queued.id,
13312
- message: peerReviewRequired(from, fromThreadId)
13937
+ message: reviewed
13313
13938
  ? `Queued for review — @${targetName} will pick it up once the user approves.`
13314
13939
  : `Delegated — @${targetName} is picking it up now.`,
13940
+ receipt: peerDeliveryReceipt({ botId: toBotId, botName: targetName, outcome: "queued", taskId: queued.id,
13941
+ detail: reviewed
13942
+ ? "the teammate's turn waits on the user's approval"
13943
+ : "the drain started; the teammate's turn has not started yet" }),
13315
13944
  });
13316
13945
  }
13946
+ const reviewed = peerReviewRequired(from, internalCapability.threadId);
13317
13947
  return json(res, 200, {
13318
13948
  queued: true,
13319
13949
  taskId: queued.id,
13320
- message: peerReviewRequired(from, internalCapability.threadId)
13950
+ message: reviewed
13321
13951
  ? `Queued for review — @${targetName} will only pick it up if the user approves after your turn finishes.`
13322
13952
  : `Delegation queued — @${targetName} will pick it up after your current turn finishes.`,
13953
+ receipt: peerDeliveryReceipt({ botId: toBotId, botName: targetName, outcome: "queued", taskId: queued.id,
13954
+ detail: reviewed
13955
+ ? "the teammate's turn waits on the user's approval after your turn finishes"
13956
+ : "the teammate's turn runs after your current turn finishes" }),
13323
13957
  });
13324
13958
  }
13325
13959
  if (path === "/api/internal/room-targets" || path === "/api/internal/coordinate-bots") {
@@ -13407,6 +14041,11 @@ const handleRequest = async (req, res) => {
13407
14041
  }
13408
14042
  const accepted = [];
13409
14043
  const errors = [];
14044
+ // One receipt per recipient, in the order the caller addressed
14045
+ // them. Dispatch is asynchronous (RoomHandoffs.tick), so a fresh
14046
+ // enqueue is honestly "queued"; a duplicate inherits the state of
14047
+ // the node that already carries this request_key.
14048
+ const receipts = [];
13410
14049
  for (const target of targets) {
13411
14050
  let createdThread;
13412
14051
  try {
@@ -13447,6 +14086,32 @@ const handleRequest = async (req, res) => {
13447
14086
  store.deleteTask(target.botId, createdThread);
13448
14087
  createdThread = undefined; // The durable coordinator now owns this task.
13449
14088
  accepted.push({ requestId: node.id, botId: node.botId, duplicate, status: node.status });
14089
+ // A duplicate request_key lands on the node enqueue already
14090
+ // made. Only a live node can still report: a failed or
14091
+ // cancelled one already fired its report and tick() skips it,
14092
+ // so promising "its result will resume you" would be the one
14093
+ // lie a receipt must never tell.
14094
+ receipts.push(peerDeliveryReceipt({
14095
+ botId: node.botId,
14096
+ botName: store.bot(node.botId)?.name,
14097
+ outcome: duplicate
14098
+ ? node.status === "running" || node.status === "completed"
14099
+ ? "injected"
14100
+ : node.status === "failed" || node.status === "cancelled"
14101
+ ? "failed"
14102
+ : "queued"
14103
+ : "queued",
14104
+ detail: duplicate
14105
+ ? node.status === "running"
14106
+ ? "duplicate request_key — this teammate is already running that exact assignment"
14107
+ : node.status === "completed"
14108
+ ? "duplicate request_key — this teammate already completed that assignment; the earlier result stands"
14109
+ : node.status === "failed" || node.status === "cancelled"
14110
+ ? `duplicate request_key — the earlier assignment ${node.status === "cancelled" ? "was cancelled" : "failed"} and will not rerun or resume you; resend with a new request_key to retry`
14111
+ : "duplicate request_key — already in flight; its result will resume you automatically"
14112
+ : "handed to the coordinator; the teammate's turn has not started yet",
14113
+ requestId: node.id,
14114
+ }));
13450
14115
  if (!duplicate) {
13451
14116
  const recipient = store.bot(target.botId);
13452
14117
  store.appendMessage(address.threadId, { role: "bot", kind: "activity",
@@ -13460,10 +14125,14 @@ const handleRequest = async (req, res) => {
13460
14125
  catch (error) {
13461
14126
  if (createdThread)
13462
14127
  store.deleteTask(target.botId, createdThread);
13463
- errors.push({ botId: target.botId, error: error instanceof Error ? error.message : String(error) });
14128
+ const said = error instanceof Error ? error.message : String(error);
14129
+ errors.push({ botId: target.botId, error: said });
14130
+ receipts.push(peerDeliveryReceipt({
14131
+ botId: target.botId, botName: store.bot(target.botId)?.name, outcome: "failed", detail: said,
14132
+ }));
13464
14133
  }
13465
14134
  }
13466
- return json(res, accepted.length ? 200 : 409, { accepted, errors,
14135
+ return json(res, accepted.length ? 200 : 409, { accepted, errors, receipts,
13467
14136
  ...(accepted.length ? { message: "End your turn after sending all work. These actual teammates will reply and resume you automatically. Do not poll or wait." } : { error: errors.map(e => e.error).join("; ") }),
13468
14137
  });
13469
14138
  }
@@ -13492,6 +14161,10 @@ const handleRequest = async (req, res) => {
13492
14161
  return json(res, 403, { error: "source conversation does not belong to sender" });
13493
14162
  const groupId = String(body.groupId ?? "").trim();
13494
14163
  const message = String(body.message ?? "").trim();
14164
+ const attachVoiceNote = body.attachVoiceNote === true;
14165
+ if (body.attachVoiceNote !== undefined && typeof body.attachVoiceNote !== "boolean") {
14166
+ return json(res, 400, { error: "attachVoiceNote must be true or false" });
14167
+ }
13495
14168
  if (!groupId || !message) {
13496
14169
  return json(res, 400, { error: "post_to_room needs group_id (from list_rooms) and message" });
13497
14170
  }
@@ -13584,13 +14257,46 @@ const handleRequest = async (req, res) => {
13584
14257
  // here is also what keeps its window alive through a turn that only
13585
14258
  // posts — an aged-out mark would hand the next hop to auto-approve.
13586
14259
  const unattended = isUnattended(poster.id, internalCapability.threadId);
14260
+ // A room post is the one send path that bypasses the settling reply,
14261
+ // so an explicit attach flag — never a silent default — moves this
14262
+ // turn's parked voice note onto the posted message. The removal
14263
+ // matters twice over: the clip is delivered exactly once (the
14264
+ // settling reply no longer finds it parked), and it survives a turn
14265
+ // that dies after the post, because it is no longer parked cleanup's
14266
+ // to delete. The post text stands as the caption.
14267
+ let attachedVoiceNote = false;
14268
+ let stagedAttachments = [];
14269
+ let detachParkedAudio;
14270
+ if (attachVoiceNote) {
14271
+ const parkedKey = turnAttachmentKey(fromThreadId, liveTurnByThread.get(fromThreadId));
14272
+ const parked = turnAttachmentsByTurn.get(parkedKey) ?? [];
14273
+ const audio = parked.filter((attachment) => attachment.kind === "audio");
14274
+ if (!audio.length) {
14275
+ return json(res, 400, { error: "no voice note to attach — call send_voice_note in this turn first" });
14276
+ }
14277
+ stagedAttachments = audio.map((attachment) => ({
14278
+ kind: "audio",
14279
+ path: attachment.path,
14280
+ mime: attachment.mime,
14281
+ }));
14282
+ attachedVoiceNote = true;
14283
+ // The parked set is mutated only after the append below lands.
14284
+ // Everything between is synchronous, so nothing can interleave,
14285
+ // and a persistence failure must leave the clip parked: the post
14286
+ // can then be retried, whereas a premature removal strands the
14287
+ // clip on disk attached to nothing — and the catalog already told
14288
+ // the model never to retry a refused post.
14289
+ detachParkedAudio = () => turnAttachmentsByTurn.set(parkedKey, parked.filter((attachment) => attachment.kind !== "audio"));
14290
+ }
13587
14291
  const posted = store.appendMessage(room.threadId, {
13588
14292
  role: "bot",
13589
14293
  kind: "text",
13590
14294
  text: message,
13591
14295
  from: { botId: poster.id, name: poster.name, color: poster.color },
13592
14296
  peerPost: unattended ? { unattended: true } : {},
14297
+ ...(attachedVoiceNote ? { attachments: stagedAttachments } : {}),
13593
14298
  });
14299
+ detachParkedAudio?.();
13594
14300
  store.patchGroup(room.id, { unread: true });
13595
14301
  // The same visibility contract the peer tools keep: whatever a bot
13596
14302
  // does elsewhere shows up in the conversation it is actually in.
@@ -13607,7 +14313,7 @@ const handleRequest = async (req, res) => {
13607
14313
  if (owner.group)
13608
14314
  chip.from = { botId: poster.id, name: poster.name, color: poster.color };
13609
14315
  store.appendMessage(fromThreadId, chip);
13610
- return json(res, 201, { ok: true, messageId: posted.id, roomName: room.name });
14316
+ return json(res, 201, { ok: true, messageId: posted.id, roomName: room.name, attachedVoiceNote });
13611
14317
  }
13612
14318
  // start_thread: a bot opens a real thread — on itself for separate
13613
14319
  // work, or on a teammate as a handoff that should run on its own.
@@ -13791,6 +14497,15 @@ const handleRequest = async (req, res) => {
13791
14497
  }
13792
14498
  if (hostedModels && !hostedModels.allows(selection))
13793
14499
  return json(res, 400, { error: hostedModels.error() });
14500
+ // The same check the profile path runs: absolute, exists, is a
14501
+ // folder. Creation names where the specialist works; nothing looser.
14502
+ let cwd;
14503
+ if (body.cwd !== undefined) {
14504
+ const checkedCwd = validateBotCwd(body.cwd);
14505
+ if (!checkedCwd.ok)
14506
+ return json(res, 400, { error: checkedCwd.error });
14507
+ cwd = checkedCwd.cwd ?? undefined;
14508
+ }
13794
14509
  // Discovery can yield; check current authority and capacity again before writing.
13795
14510
  if (store.bot(chief.id) !== chief || chief.hidden || !chief.chiefOfStaff || !connectorThread(chief.id, fromThreadId)) {
13796
14511
  return json(res, 403, { error: "only an active Chief of Staff can create operator bots" });
@@ -13811,6 +14526,7 @@ const handleRequest = async (req, res) => {
13811
14526
  description: instructions,
13812
14527
  modelSelection: selection,
13813
14528
  section: chief.section,
14529
+ ...(cwd !== undefined ? { cwd } : {}),
13814
14530
  // exactly the Chief's audience: a restricted Chief never makes a bot everyone sees
13815
14531
  ...(chief.visibility ? { visibility: chief.visibility } : {}),
13816
14532
  }, { seedMessages: false });
@@ -13925,15 +14641,12 @@ const handleRequest = async (req, res) => {
13925
14641
  if (credentialIsConfigured(cfg, credentialId)) {
13926
14642
  return json(res, 200, { alreadyConfigured: true, label: target.label });
13927
14643
  }
13928
- const existing = store.activePath(fromThreadId).find((message) => isReusableCredentialRequest(message, credentialId, from.id, Boolean(owner.group)));
13929
- if (existing) {
13930
- if (!existing.text?.trim()) {
13931
- store.patchMessage(fromThreadId, existing.id, {
13932
- text: credentialDesktopHandoff(target.label),
13933
- });
13934
- }
13935
- return json(res, 200, { messageId: existing.id, label: target.label });
13936
- }
14644
+ // A later request supersedes every still-pending card for this
14645
+ // credential instead of silently reusing it: the reason or task has
14646
+ // moved on, so the old card stops offering entry and the person gets
14647
+ // one fresh card they can act on. The fresh card is appended FIRST:
14648
+ // if persistence then fails, the prior cards stay actionable rather
14649
+ // than every pending request ending terminal with no card to act on.
13937
14650
  const reason = typeof body.reason === "string" ? body.reason.trim().slice(0, 240) : "";
13938
14651
  const message = store.appendMessage(fromThreadId, {
13939
14652
  role: "bot",
@@ -13949,6 +14662,13 @@ const handleRequest = async (req, res) => {
13949
14662
  requestKey: randomUUID(),
13950
14663
  },
13951
14664
  });
14665
+ for (const prior of store.activePath(fromThreadId)) {
14666
+ if (prior.id === message.id)
14667
+ continue;
14668
+ if (!prior.secret || !isPendingCredentialRequest(prior, credentialId, from.id, Boolean(owner.group)))
14669
+ continue;
14670
+ store.patchMessage(fromThreadId, prior.id, { secret: { ...prior.secret, superseded: true } });
14671
+ }
13952
14672
  return json(res, 201, { messageId: message.id, label: target.label });
13953
14673
  }
13954
14674
  if (method === "POST" && path === "/api/internal/voice-note") {
@@ -13962,6 +14682,9 @@ const handleRequest = async (req, res) => {
13962
14682
  // here so a call that reaches an unconfigured workspace answers with
13963
14683
  // the setup guidance instead of a provider failure.
13964
14684
  const voiceBot = botForThread(internalSender.id, internalCapability.threadId) ?? internalSender;
14685
+ if (voiceBot.voiceNotes === false) {
14686
+ return json(res, 403, { error: "voice notes are turned off for this bot" });
14687
+ }
13965
14688
  try {
13966
14689
  const audio = await tts.speak(cfg, text, voiceBot.voice);
13967
14690
  // Synthesis can outlive its turn: a stop or settle during the await
@@ -14008,14 +14731,16 @@ const handleRequest = async (req, res) => {
14008
14731
  // Rows are fire-and-forget: a log failure must never take the call
14009
14732
  // (or its refusal) down with it.
14010
14733
  const call = connectorCallFromFrame(body);
14011
- if (call.kind === "unrecognized") {
14734
+ // A bot with no grants record keeps the pre-grants relay: even an
14735
+ // unreadable frame passes through exactly as it always did.
14736
+ if (call.kind === "unrecognized" && currentSender.connectorTools !== undefined) {
14012
14737
  appendDecision(DATA_DIR, {
14013
14738
  threadId: internalCapability.threadId,
14014
14739
  botId: currentSender.id,
14015
14740
  botName: currentSender.name,
14016
14741
  tool: call.invoked,
14017
14742
  summary: call.reason,
14018
- decision: "user-denied",
14743
+ decision: "auto-denied",
14019
14744
  source: "connector-scope",
14020
14745
  rule: "connectorTools",
14021
14746
  });
@@ -14028,7 +14753,12 @@ const handleRequest = async (req, res) => {
14028
14753
  }));
14029
14754
  }
14030
14755
  if (call.kind === "tools") {
14031
- const verdict = evaluateConnectorTools(call.names, currentSender.connectorTools);
14756
+ // The resolver needs the real connected-service slugs, so an
14757
+ // underscored service (bland_ai) keeps its own tools instead of
14758
+ // a plain-prefix grant (bland) capturing them; an unreachable
14759
+ // catalog reads as empty and the plain split stands.
14760
+ const serviceSlugs = await composio.connectedServiceSlugs(cfg);
14761
+ const verdict = evaluateConnectorTools(call.names, currentSender.connectorTools, serviceSlugs);
14032
14762
  if (!verdict.allowed) {
14033
14763
  for (const denial of verdict.denials) {
14034
14764
  appendDecision(DATA_DIR, {
@@ -14041,7 +14771,7 @@ const handleRequest = async (req, res) => {
14041
14771
  : denial.onGrantedService
14042
14772
  ? "tool is not in this service's grant"
14043
14773
  : "service is not granted",
14044
- decision: "user-denied",
14774
+ decision: "auto-denied",
14045
14775
  source: "connector-scope",
14046
14776
  rule: denial.service ? "connectorTools." + denial.service : "connectorTools",
14047
14777
  });
@@ -14062,7 +14792,7 @@ const handleRequest = async (req, res) => {
14062
14792
  botName: currentSender.name,
14063
14793
  tool: call.names[0],
14064
14794
  summary: ("allowed " + call.names.join(", ")).slice(0, 240),
14065
- decision: "user-approved",
14795
+ decision: "auto-approved",
14066
14796
  source: "connector-scope",
14067
14797
  rule: verdict.rule,
14068
14798
  });
@@ -14086,58 +14816,7 @@ const handleRequest = async (req, res) => {
14086
14816
  if (!bot)
14087
14817
  return json(res, 404, { error: "no such bot" });
14088
14818
  if (method === "GET") {
14089
- const snapshot = botComputerControlSnapshot(botId, internalCapability.teamComputerId);
14090
- const slot = autoVmClaims.get(internalCapability.threadId);
14091
- const lazyClaim = slot && slot.owner.generation === internalCapability.generation ? slot : undefined;
14092
- if (!snapshot.held && lazyClaim?.lazy && !lazyClaim.begin) {
14093
- // First screen tools/call on a lazily-attached Auto VM (issue
14094
- // #1361): fire the exclusive claim — once — and give it a moment
14095
- // to land. A free, ready VM claims in the time of one container
14096
- // inspect, so this call then proceeds with an honest answer;
14097
- // only a claim still queued behind another holder answers held
14098
- // below, and then the contention text is true. Keyed on the
14099
- // slot, never on the thread's turn-computer entry: a bind this
14100
- // turn abandoned earlier (a VPS that turned out to be asleep)
14101
- // must not hide the unclaimed VM and let the call through.
14102
- startAutoVmClaim(autoVmClaims, internalCapability.threadId, internalCapability.generation);
14103
- await Promise.race([
14104
- lazyClaim.begin ?? Promise.resolve(),
14105
- new Promise((resolve) => setTimeout(resolve, LAZY_VM_CLAIM_GRACE_MS)),
14106
- ]);
14107
- }
14108
- if (!snapshot.held && lazyClaim?.failed === true) {
14109
- // A rejected lazy claim (gate finding F1, issue #1361): the
14110
- // computer MCP mounted at dispatch is still live, and the claim
14111
- // may even have left a turn-computer entry behind (it can reject
14112
- // after bindTurnComputer succeeded — lease lost to a person,
14113
- // lifecycle busy, boot failure). Either way this turn owns no
14114
- // usable VM, so keep refusing every screen call for the rest of
14115
- // the generation; the bridge must never forward one onto a VM
14116
- // this turn never claimed. Turn settle GC clears the slot. Say
14117
- // why, and say not to retry: the contention text would send the
14118
- // model into a screenshot loop against a claim that cannot land.
14119
- return json(res, 200, {
14120
- held: true, helpOpen: false,
14121
- blockedReason: `This turn could not claim ${lazyClaim.label ?? "this computer"}${lazyClaim.failure ? ` (${lazyClaim.failure})` : ""}. This call was not performed. Do not retry computer work in this turn; tell the person what you could not do.`,
14122
- });
14123
- }
14124
- if (!snapshot.held && lazyClaim?.lazy && lazyClaim.begin && !lazyClaim.claimed) {
14125
- // The claim fired and is still waiting on the exclusive bind:
14126
- // another turn genuinely holds this desktop right now.
14127
- return json(res, 200, {
14128
- held: true, helpOpen: false,
14129
- blockedReason: "Another thread is using this computer. This call was not performed. Pause computer work until that thread finishes, then take a fresh screenshot before acting.",
14130
- });
14131
- }
14132
- const computer = turnComputerResources.get(internalCapability.threadId);
14133
- if (!snapshot.held && computer && computer.owner.generation === internalCapability.generation &&
14134
- !claimTurnResource(computer.owner, computer.resource)) {
14135
- return json(res, 200, {
14136
- held: true, helpOpen: false,
14137
- blockedReason: "Another thread is using this computer. This call was not performed. Pause computer work until that thread finishes, then take a fresh screenshot before acting.",
14138
- });
14139
- }
14140
- return json(res, 200, { held: snapshot.held, helpOpen: snapshot.helpReason !== null });
14819
+ return json(res, 200, await computerCallGate(internalCapability));
14141
14820
  }
14142
14821
  if (method === "POST") {
14143
14822
  const body = await readInternalBody();
@@ -14667,9 +15346,10 @@ const handleRequest = async (req, res) => {
14667
15346
  const message = store.messagesFor(threadId).find((candidate) => candidate.id === m[2]);
14668
15347
  if (!message)
14669
15348
  return json(res, 404, { error: "no such message" });
14670
- if (message.kind !== "text") {
15349
+ if (message.kind !== "text" || (!message.text && !message.attachments?.length)) {
14671
15350
  return json(res, 403, { error: "that message does not share this file" });
14672
15351
  }
15352
+ const messageText = message.text ?? "";
14673
15353
  const body = method === "POST" ? await readBody(req) : null;
14674
15354
  const rawReference = streamsMessageImage ? url.searchParams.get("ref") : null;
14675
15355
  if (streamsMessageImage && (!rawReference || !/^\d+$/.test(rawReference))) {
@@ -14677,27 +15357,32 @@ const handleRequest = async (req, res) => {
14677
15357
  }
14678
15358
  const href = method === "POST"
14679
15359
  ? (typeof body.path === "string" ? body.path : "")
14680
- : messageImageTargetAt(message.text ?? "", Number(rawReference));
15360
+ : messageImageTargetAt(messageText, Number(rawReference));
14681
15361
  if (!href)
14682
15362
  return json(res, 400, { error: "path is required" });
14683
- // Generated image paths are durable capabilities on this exact message.
14684
- // They do not grant access to arbitrary workspace files or Markdown refs.
14685
- const generatedImage = method === "POST" && message.role === "bot" &&
14686
- message.attachments?.some((attachment) => attachment.kind === "image" && attachment.path === href) === true;
14687
15363
  let roots;
14688
15364
  let downloadName;
14689
- if (generatedImage) {
14690
- roots = [ATTACHMENTS_DIR];
14691
- }
14692
- else if (message.role === "user") {
14693
- downloadName = messageAttachmentName(message.text ?? "", href) ?? undefined;
15365
+ // A file the bot attached with attach_file lives in the private
15366
+ // attachment store; the message itself is the grant, exactly as a
15367
+ // user's attachment tag is for their own uploads.
15368
+ const botAttachment = message.role === "bot"
15369
+ ? message.attachments?.find((attachment) => attachment.path === href)
15370
+ : undefined;
15371
+ if (message.role === "user") {
15372
+ downloadName = messageAttachmentName(messageText, href) ?? undefined;
14694
15373
  if (!downloadName) {
14695
15374
  return json(res, 403, { error: "that message does not share this file" });
14696
15375
  }
14697
15376
  roots = [ATTACHMENTS_DIR];
14698
15377
  }
15378
+ else if (botAttachment) {
15379
+ // Files and images alike: the stored message is the grant, so the
15380
+ // mobile clients can fetch an attached image through this route too.
15381
+ downloadName = botAttachment.kind === "file" ? botAttachment.name : undefined;
15382
+ roots = [ATTACHMENTS_DIR];
15383
+ }
14699
15384
  else {
14700
- if (!messageReferencesFile(message.text ?? "", href)) {
15385
+ if (!messageReferencesFile(messageText, href)) {
14701
15386
  return json(res, 403, { error: "that bot message does not link to this file" });
14702
15387
  }
14703
15388
  const senderId = directBot?.id ?? message.from?.botId;
@@ -14707,26 +15392,10 @@ const handleRequest = async (req, res) => {
14707
15392
  if (!senderId) {
14708
15393
  return json(res, 403, { error: "the file's bot author could not be verified" });
14709
15394
  }
14710
- let pinnedCwd;
14711
- let configuredCwd;
14712
- if (directBot) {
14713
- pinnedCwd = store.taskByThread(directBot.id, threadId)?.cwd;
14714
- configuredCwd = directBot.cwd;
14715
- }
14716
- else if (group) {
14717
- const task = store.groupTaskByThread(group.id, threadId);
14718
- pinnedCwd = task ? task.pinnedCwd : group.threadId === threadId ? group.pinnedCwd : undefined;
14719
- configuredCwd = group.cwd;
14720
- }
14721
- roots = messageFileRoots({
14722
- senderWorkspace: workspaceDir(senderId),
14723
- attachments: ATTACHMENTS_DIR,
14724
- pinnedCwd,
14725
- configuredCwd,
14726
- });
15395
+ roots = messageFileRootsForThread(senderId, threadId);
14727
15396
  }
14728
15397
  const file = await openMessageFile(href, roots);
14729
- if ((streamsMessageImage || generatedImage) && !file.mime.startsWith("image/")) {
15398
+ if ((streamsMessageImage || botAttachment?.kind === "image") && !file.mime.startsWith("image/")) {
14730
15399
  await file.handle.close();
14731
15400
  return json(res, 415, { error: "only images can be previewed here" });
14732
15401
  }
@@ -14881,18 +15550,41 @@ const handleRequest = async (req, res) => {
14881
15550
  const attachment = readAttachment(m[1]);
14882
15551
  if (!attachment)
14883
15552
  return json(res, 404, { error: "no such attachment" });
14884
- res.writeHead(200, {
15553
+ const size = attachment.bytes.byteLength;
15554
+ // Who may see an attachment is decided per request — the dispatcher
15555
+ // 404s hidden ones above — so the browser must never reuse one
15556
+ // member's copy after a switch to another identity in the same profile.
15557
+ const headers = {
14885
15558
  "content-type": attachment.mime,
14886
- "content-length": String(attachment.bytes.byteLength),
14887
- "cache-control": "private, max-age=31536000, immutable",
15559
+ "cache-control": "private, no-store",
14888
15560
  "x-content-type-options": "nosniff",
14889
- });
15561
+ };
15562
+ // Range serving is audio-only (#1745): a player scrubbing a voice note
15563
+ // asks for byte windows, and images/documents keep the full response
15564
+ // the app has always relied on. A header we do not understand reads
15565
+ // as absent — the full 200 is always a correct answer to a GET.
15566
+ if (attachment.mime.startsWith("audio/")) {
15567
+ const rawRange = Array.isArray(req.headers.range) ? req.headers.range[0] : req.headers.range;
15568
+ const range = parseAudioRange(rawRange, size);
15569
+ if (range.kind === "unsatisfiable") {
15570
+ res.writeHead(416, { ...headers, "content-range": `bytes */${size}` });
15571
+ return res.end();
15572
+ }
15573
+ if (range.kind === "range") {
15574
+ res.writeHead(206, {
15575
+ ...headers,
15576
+ "content-length": String(range.end - range.start + 1),
15577
+ "content-range": `bytes ${range.start}-${range.end}/${size}`,
15578
+ });
15579
+ return res.end(attachment.bytes.subarray(range.start, range.end + 1));
15580
+ }
15581
+ }
15582
+ res.writeHead(200, { ...headers, "content-length": String(size) });
14890
15583
  return res.end(attachment.bytes);
14891
15584
  }
14892
15585
  // ── search across every transcript ──────────────────────────────────
14893
- // A LIKE scan over the SQLite message store: local transcripts are
14894
- // megabytes at most, so a scan answers in milliseconds and needs no
14895
- // index to maintain. Hits resolve to the bot/room that owns the thread;
15586
+ // Literal substring scans run in a bounded read-only worker so a large
15587
+ // history cannot stall unrelated turns. Hits resolve to the owning bot/room;
14896
15588
  // rows belonging to deleted conversations resolve to nothing and drop.
14897
15589
  if (method === "GET" && path === "/api/search") {
14898
15590
  const q = url.searchParams.get("q") ?? "";
@@ -14913,8 +15605,27 @@ const handleRequest = async (req, res) => {
14913
15605
  };
14914
15606
  // A member who may not see every bot: look further, then keep only
14915
15607
  // hits in conversations they can open.
14916
- const hits = searchMessages(q, visible.everything ? limit : Math.min(limit * 10, 1_000), threadId)
14917
- .filter((hit) => visible.thread(hit.threadId))
15608
+ const found = await searchMessagesAsync(q, visible.everything ? limit : Math.min(limit * 10, 1_000), threadId);
15609
+ // A scan may outlive a revocation or audience edit. Authorize again
15610
+ // after the await, and resolve visibility from current store state.
15611
+ if (HOSTED_WORKSPACE && auth.kind === "session") {
15612
+ const failure = workspaceAccess ? await workspaceAccess.authorize(req, auth)
15613
+ : { status: 503, error: "Workspace sign-in is unavailable." };
15614
+ if (failure)
15615
+ return json(res, failure.status, { error: failure.error });
15616
+ }
15617
+ const current = resolveRequestAuth(req, {
15618
+ sessions, cookieName: SESSION_COOKIE, streamPath: "/api/events", url,
15619
+ loopbackMutationToken: desktopMutationToken, companionMutationToken,
15620
+ features: { sharedComputers: sharedComputersEnabled(cfg) }, loopbackTrust: LOOPBACK.trust, cliOwnerToken,
15621
+ });
15622
+ if (!current.auth)
15623
+ return json(res, current.status, { error: current.error });
15624
+ const currentVisible = visibleTo(viewerFor(current.auth));
15625
+ if (threadId && !currentVisible.thread(threadId))
15626
+ return json(res, 404, { error: "no such conversation" });
15627
+ const hits = found
15628
+ .filter((hit) => currentVisible.thread(hit.threadId))
14918
15629
  .slice(0, limit)
14919
15630
  .map((hit) => {
14920
15631
  const bot = store.botByThread(hit.threadId);
@@ -15441,7 +16152,7 @@ const handleRequest = async (req, res) => {
15441
16152
  const openApprovalThreads = store.groupTasks(group.id).flatMap((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
15442
16153
  message.card?.requestId &&
15443
16154
  !message.card.answered &&
15444
- !message.card.dismissed) ? [task.threadId] : []);
16155
+ !message.card.dismissed && !message.card.expired) ? [task.threadId] : []);
15445
16156
  return openApprovalThreads.length > 0 && !openApprovalThreads.includes(targetThreadId);
15446
16157
  };
15447
16158
  m = path.match(/^\/api\/groups\/([\w-]+)\/tasks$/);
@@ -15714,7 +16425,8 @@ const handleRequest = async (req, res) => {
15714
16425
  status: 409,
15715
16426
  });
15716
16427
  }
15717
- if (groupIsWorking(current)) {
16428
+ const decision = admit("room", {}, { roomWorking: groupIsWorking(current) });
16429
+ if (decision.action === "queue") {
15718
16430
  const queued = queueChannelMessage(current.id, threadId, text, {
15719
16431
  replyToId: replyTo?.id,
15720
16432
  sendId,
@@ -15783,21 +16495,30 @@ const handleRequest = async (req, res) => {
15783
16495
  const held = holdChannelQueue(current.id, targetThreadId, m[2]);
15784
16496
  if (!held)
15785
16497
  return json(res, 404, { error: "no such queued message" });
15786
- if (!speaker || !instance?.adapter.capabilities.queueing || !instance.adapter.steer) {
15787
- restoreHeldChannelQueue(held);
15788
- return json(res, 200, { ok: true, queued: true, threadId: targetThreadId });
15789
- }
15790
16498
  const [head] = held.items;
15791
- if (!head || head.id !== m[2]) {
16499
+ const decision = admit("room-steer", {
16500
+ speakerPresent: Boolean(speaker),
16501
+ engineCanSteer: Boolean(instance?.adapter.capabilities.queueing && instance.adapter.steer),
16502
+ isQueueHead: Boolean(head && head.id === m[2]),
16503
+ });
16504
+ if (decision.action === "refuse") {
15792
16505
  restoreHeldChannelQueue(held);
15793
16506
  return json(res, 409, { error: "only the first queued message can steer" });
15794
16507
  }
16508
+ if (decision.action === "queue") {
16509
+ restoreHeldChannelQueue(held);
16510
+ return json(res, 200, { ok: true, queued: true, threadId: targetThreadId });
16511
+ }
15795
16512
  // A reply target that cannot be resolved restores the held queue
15796
16513
  // before the request fails — the room's normal drain keeps the head.
15797
16514
  const replyTo = resolveHeldReplyTarget(held, resolveReplyTarget);
15798
- const steered = await instance.adapter
15799
- .steer(targetThreadId, promptWithReply(head.text, replyTo, cfg.profile?.name?.trim() || "User"))
15800
- .catch(() => "indeterminate");
16515
+ // steer was offered only when a live speaker instance could take it;
16516
+ // the check carries that invariant to the type system.
16517
+ const steered = instance?.adapter.steer
16518
+ ? await instance.adapter
16519
+ .steer(targetThreadId, promptWithReply(head.text, replyTo, cfg.profile?.name?.trim() || "User"))
16520
+ .catch(() => "indeterminate")
16521
+ : "refused";
15801
16522
  // The steer was awaited adapter work: re-read every ownership
15802
16523
  // invariant before writing anything, exactly like the 1:1 path. A
15803
16524
  // speaker change, a channel switch, or a settled room restores the
@@ -16062,7 +16783,7 @@ const handleRequest = async (req, res) => {
16062
16783
  return json(res, 400, { error: "preset must be a preset id from New bot" });
16063
16784
  }
16064
16785
  const resolvedPreset = typeof body.preset === "string" ? presetStore.resolve(body.preset) : null;
16065
- const preset = resolvedPreset && presetOffered(resolvedPreset.row, readOrgInstallStatuses()) ? resolvedPreset : null;
16786
+ const preset = resolvedPreset && presetOffered(resolvedPreset.row, orgInstallStatuses()) ? resolvedPreset : null;
16066
16787
  if (body.preset !== undefined && !preset)
16067
16788
  return json(res, 404, { error: PRESET_UNAVAILABLE_MESSAGE });
16068
16789
  body = { ...body, ...settings, name: settings.name };
@@ -16164,7 +16885,7 @@ const handleRequest = async (req, res) => {
16164
16885
  if (preset) {
16165
16886
  applyPresetToBot(bot.id, preset, {
16166
16887
  store,
16167
- skills: { install: installSkill, setEnabled: setSkillEnabled },
16888
+ skills: { install: installSkill, setEnabled: setSkillEnabled, installOrg: installOrgSkill },
16168
16889
  memory: { writeIndex: writeMemoryFile, writeTopic: writeMemoryTopic },
16169
16890
  });
16170
16891
  }
@@ -16516,6 +17237,14 @@ const handleRequest = async (req, res) => {
16516
17237
  return json(res, 400, { error: "composio must be true or false" });
16517
17238
  patch.composio = body.composio;
16518
17239
  }
17240
+ // per-bot gate on sending voice notes: the toggle is about what this
17241
+ // bot may do (an admin decision), not how it looks, so it lives here
17242
+ // rather than the client-writable profile surface.
17243
+ if (body.voiceNotes !== undefined) {
17244
+ if (typeof body.voiceNotes !== "boolean")
17245
+ return json(res, 400, { error: "voiceNotes must be true or false" });
17246
+ patch.voiceNotes = body.voiceNotes;
17247
+ }
16519
17248
  // which of those apps' tools this bot may call (connector grants 1/5:
16520
17249
  // data model only — enforcement lands with slice 2). null returns the
16521
17250
  // bot to boolean-only legacy behavior; {} means no tools.
@@ -16527,6 +17256,13 @@ const handleRequest = async (req, res) => {
16527
17256
  const parsed = parseConnectorTools(body.connectorTools);
16528
17257
  if (!parsed.ok)
16529
17258
  return json(res, 400, { error: parsed.error });
17259
+ // Connector grants 3/5: past the shape checks, slugs must name
17260
+ // connected services and tool names must carry their service
17261
+ // prefix. Unreachable backends never block the patch — the
17262
+ // call-time verdict from slice 2 stays the authority.
17263
+ const semantic = await composio.validateConnectorGrants(cfg, parsed.grants);
17264
+ if (semantic)
17265
+ return json(res, 400, { error: semantic });
16530
17266
  patch.connectorTools = parsed.grants;
16531
17267
  }
16532
17268
  }
@@ -16999,7 +17735,7 @@ const handleRequest = async (req, res) => {
16999
17735
  return json(res, 404, { error: "no such bot" });
17000
17736
  const parsed = z.object({ source: z.string().min(1).max(2000) }).safeParse(await readBody(req));
17001
17737
  if (!parsed.success)
17002
- return json(res, 400, { error: "source must be a GitHub URL or owner/repo" });
17738
+ return json(res, 400, { error: "source must be a GitHub or skills.sh URL, or owner/repo" });
17003
17739
  const fetched = await fetchSkillFromSource(parsed.data.source);
17004
17740
  if ("error" in fetched)
17005
17741
  return json(res, 422, { error: fetched.error });
@@ -17572,7 +18308,14 @@ const handleRequest = async (req, res) => {
17572
18308
  if (store.activeLeaf(threadId) !== body.expectedActiveLeafId) {
17573
18309
  throw Object.assign(new Error("the conversation changed before this message could start"), { status: 409, code: "guarded_branch" });
17574
18310
  }
17575
- if (currentAtStart.busy || threadBusy(bot.id, threadId) || botAtThreadCapacity(bot.id) || parksBehindCoordination(bot.id, threadId) || activeGroupTurnForBot(bot.id)) {
18311
+ const guardedAdmission = admit("guarded", {}, {
18312
+ botBusy: currentAtStart.busy,
18313
+ threadBusy: threadBusy(bot.id, threadId),
18314
+ atCapacity: botAtThreadCapacity(bot.id),
18315
+ parksBehindCoordination: parksBehindCoordination(bot.id, threadId),
18316
+ groupTurn: Boolean(activeGroupTurnForBot(bot.id)),
18317
+ });
18318
+ if (guardedAdmission.action === "refuse") {
17576
18319
  throw Object.assign(new Error("wait for a free thread slot before retrying this message"), { status: 409, code: "guarded_busy" });
17577
18320
  }
17578
18321
  const message = await startTurn(bot.id, text, { threadId, replyTo, sendId, sender: messageSender(auth), trigger });
@@ -17589,7 +18332,14 @@ const handleRequest = async (req, res) => {
17589
18332
  // admission can hand it to the provider natively.
17590
18333
  const carriesImages = extractTurnImages(text).images.length > 0;
17591
18334
  const steerTarget = handoffs.current(threadId);
17592
- if (!carriesImages && !computerSelectionTurns.get(threadId)?.selected && instance?.adapter.capabilities.queueing && instance.adapter.steer) {
18335
+ const busyAdmission = admit("direct-busy", {
18336
+ carriesImages,
18337
+ pendingComputerSelection: Boolean(computerSelectionTurns.get(threadId)?.selected),
18338
+ engineCanSteer: Boolean(instance?.adapter.capabilities.queueing && instance.adapter.steer),
18339
+ });
18340
+ // steer was offered only when a live instance could take it;
18341
+ // the second check carries that fact to the type system.
18342
+ if (busyAdmission.action === "steer" && instance?.adapter.steer) {
17593
18343
  steered = await instance.adapter
17594
18344
  .steer(threadId, promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"))
17595
18345
  .catch(() => "indeterminate");
@@ -17864,6 +18614,22 @@ const handleRequest = async (req, res) => {
17864
18614
  behavior,
17865
18615
  }))
17866
18616
  return;
18617
+ if (resolveAndSendModel(res, {
18618
+ botId: bot.id,
18619
+ botName: bot.name,
18620
+ threadId: bot.threadId,
18621
+ requestId: String(body.requestId),
18622
+ behavior,
18623
+ }))
18624
+ return;
18625
+ if (resolveAndSendTightening(res, {
18626
+ botId: bot.id,
18627
+ botName: bot.name,
18628
+ threadId: bot.threadId,
18629
+ requestId: String(body.requestId),
18630
+ behavior,
18631
+ }))
18632
+ return;
17867
18633
  if (sendSkillResolution(res, resolveSkillRequest({
17868
18634
  botId: bot.id,
17869
18635
  botName: bot.name,
@@ -17962,6 +18728,36 @@ const handleRequest = async (req, res) => {
17962
18728
  }))
17963
18729
  return;
17964
18730
  }
18731
+ const modelCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.modelRequest);
18732
+ if (modelCard?.card?.modelRequest) {
18733
+ const modelBotId = modelCard.from?.botId ?? store.botByThread(threadId)?.id;
18734
+ if (!modelBotId)
18735
+ return json(res, 400, { error: "this model request has no valid owner" });
18736
+ const modelOwner = store.bot(modelBotId);
18737
+ if (resolveAndSendModel(res, {
18738
+ botId: modelBotId,
18739
+ botName: modelOwner?.name,
18740
+ threadId,
18741
+ requestId,
18742
+ behavior,
18743
+ }))
18744
+ return;
18745
+ }
18746
+ const tighteningCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.tighteningRequest);
18747
+ if (tighteningCard?.card?.tighteningRequest) {
18748
+ const tighteningBotId = tighteningCard.from?.botId ?? store.botByThread(threadId)?.id;
18749
+ if (!tighteningBotId)
18750
+ return json(res, 400, { error: "this tightening request has no valid owner" });
18751
+ const tighteningOwner = store.bot(tighteningBotId);
18752
+ if (resolveAndSendTightening(res, {
18753
+ botId: tighteningBotId,
18754
+ botName: tighteningOwner?.name,
18755
+ threadId,
18756
+ requestId,
18757
+ behavior,
18758
+ }))
18759
+ return;
18760
+ }
17965
18761
  // peer-approval intercept (see /api/bots/:id/respond above). A peer card
17966
18762
  // belongs to the bus rather than to a speaker, so resolve it before we go
17967
18763
  // looking for one — a room between turns has no speaker to find.
@@ -19917,6 +20713,22 @@ const handleRequest = async (req, res) => {
19917
20713
  }
19918
20714
  return json(res, 200, { configured: true, credentialStore: "ok", services: await composio.connectedServices(cfg) });
19919
20715
  }
20716
+ if (method === "GET" && path === "/api/connectors/tools") {
20717
+ // The grant editor's inventory: every grantable tool name grouped by
20718
+ // service, read from the same MCP endpoint a mounted bot relays
20719
+ // through. Failure is a read-only editor, never a blocked save — the
20720
+ // exact-name model does not depend on the listing being reachable.
20721
+ const availability = composio.connectorAvailability(cfg);
20722
+ if (availability !== "configured") {
20723
+ return json(res, 200, { configured: false, services: {} });
20724
+ }
20725
+ try {
20726
+ return json(res, 200, { configured: true, services: await composio.listConnectorTools(cfg) });
20727
+ }
20728
+ catch (e) {
20729
+ return json(res, 502, { configured: true, services: {}, error: e instanceof Error ? e.message : String(e) });
20730
+ }
20731
+ }
19920
20732
  if (method === "GET" && path === "/api/connectors") {
19921
20733
  const services = (url.searchParams.get("services") ?? "").split(",").filter(Boolean);
19922
20734
  const availability = composio.connectorAvailability(cfg);
@@ -19986,6 +20798,8 @@ const handleRequest = async (req, res) => {
19986
20798
  if (m[3] === "provided") {
19987
20799
  if (message.secret.dismissed)
19988
20800
  return json(res, 409, { error: "this credential request was dismissed" });
20801
+ if (message.secret.superseded)
20802
+ return json(res, 409, { error: "this credential request was superseded by a newer one" });
19989
20803
  if (!credentialIsConfigured(cfg, message.secret.target)) {
19990
20804
  return json(res, 409, { error: `${message.secret.label} was not saved yet` });
19991
20805
  }
@@ -19998,6 +20812,8 @@ const handleRequest = async (req, res) => {
19998
20812
  return json(res, 200, state);
19999
20813
  }
20000
20814
  if (m[3] === "resume") {
20815
+ if (message.secret.superseded)
20816
+ return json(res, 409, { error: "this credential request was superseded by a newer one" });
20001
20817
  const outcome = credentialResumeOutcome(message.secret);
20002
20818
  if (!outcome) {
20003
20819
  return json(res, 409, { error: "this credential request is not ready to resume" });
@@ -20013,6 +20829,8 @@ const handleRequest = async (req, res) => {
20013
20829
  return json(res, 409, { error: "this credential request is no longer available" });
20014
20830
  return json(res, 200, { resumed: state.resumed });
20015
20831
  }
20832
+ if (message.secret.superseded)
20833
+ return json(res, 409, { error: "this credential request was superseded by a newer one" });
20016
20834
  if (!message.secret.provided && !resumeSecretCard(m[1], threadId, message.id, "dismissed")) {
20017
20835
  return json(res, 409, { error: "this credential request is no longer available" });
20018
20836
  }
@@ -20399,9 +21217,9 @@ setInterval(sweepThreadEventLogsNow, THREAD_LOG_RETENTION_SWEEP_MS).unref();
20399
21217
  for (const row of chatFollowups()) {
20400
21218
  if (row.status !== "dispatching" && row.status !== "interrupted")
20401
21219
  continue;
20402
- const owned = row.kind === "bot"
20403
- ? Boolean(store.taskByThread(row.ownerId, row.threadId))
20404
- : Boolean(store.groupByThread(row.threadId)?.id === row.ownerId);
21220
+ const owned = row.kind === "channel"
21221
+ ? Boolean(store.groupByThread(row.threadId)?.id === row.ownerId)
21222
+ : Boolean(store.taskByThread(row.ownerId, row.threadId));
20405
21223
  if (!owned) {
20406
21224
  settleChatFollowups([row.id], "cancelled");
20407
21225
  continue;
@@ -20409,12 +21227,20 @@ for (const row of chatFollowups()) {
20409
21227
  settleChatFollowups([row.id], "interrupted");
20410
21228
  const messages = store.messagesFor(row.threadId);
20411
21229
  const recovered = messages.find((message) => message.queueId === row.id && message.role === "user") ?? store.appendMessage(row.threadId, {
20412
- role: "user", kind: "text", text: row.payload.text, replyToId: row.payload.replyToId,
21230
+ // Aside rows recover enveloped: the words may already have run mid-turn,
21231
+ // and the recovered line must keep saying whose context it was.
21232
+ role: "user", kind: "text", text: row.kind === "aside" ? row.payload.prompt ?? row.payload.text : row.payload.text, replyToId: row.payload.replyToId,
20413
21233
  sendId: row.payload.sendId, queueId: row.id, sender: row.payload.sender,
20414
21234
  ...(row.kind === "channel" ? { channelMode: row.payload.mode, via: row.payload.via } : {}),
21235
+ ...(row.kind === "aside" ? {
21236
+ aside: true,
21237
+ peerAsk: row.payload.aside
21238
+ ? { botId: row.payload.aside.fromBotId, name: row.payload.aside.fromBotName, ...(row.payload.aside.unattended ? { unattended: true } : {}) }
21239
+ : undefined,
21240
+ } : {}),
20415
21241
  });
20416
21242
  // Nor as a message a resumed session has not seen: count it as handed.
20417
- const recoveredTask = row.kind === "bot" ? store.taskByThread(row.ownerId, row.threadId) : undefined;
21243
+ const recoveredTask = row.kind !== "channel" ? store.taskByThread(row.ownerId, row.threadId) : undefined;
20418
21244
  const order = store.activePath(row.threadId).filter(isContextMessage).map((m) => m.id);
20419
21245
  for (const [instanceId, state] of Object.entries(recoveredTask?.handedMessages ?? {})) {
20420
21246
  if (state.session !== undefined)
@@ -20431,6 +21257,7 @@ for (const row of chatFollowups()) {
20431
21257
  settleChatFollowups([row.id], null);
20432
21258
  }
20433
21259
  restoreSteeredMessages();
21260
+ restoreAsideMessages(store);
20434
21261
  restoreChannelMessages();
20435
21262
  // Repair known auto pins that conflict with Works on before dispatch starts.
20436
21263
  // Legacy pins have no provenance and may be deliberate person selections:
@@ -20521,6 +21348,7 @@ const gracefulShutdown = createGracefulShutdown({
20521
21348
  () => flushUsageLedger(DATA_DIR),
20522
21349
  () => flushDecisionLog(DATA_DIR),
20523
21350
  () => flushAdminActivity(DATA_DIR),
21351
+ () => closeMessageSearch(),
20524
21352
  ],
20525
21353
  // Cleanup jobs run concurrently. Release only after they settle (or reach
20526
21354
  // the shutdown deadline), immediately before the process exits, so no new