openmausbot 0.1.87 → 0.1.89

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 (150) hide show
  1. package/README.md +2 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-BTiuL79F.js → abnfDiagram-O67JEVCF-BwaMaZEm.js} +1 -1
  3. package/dist/assets/{arc-C88Blc2v.js → arc-Be-8GKxd.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-BZsJIZrb.js → architectureDiagram-NJMV4G6O-Dp_U2Kv1.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CVeMe9Ah.js → blockDiagram-BEXU5L5S-DQjUjYUk.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-Cedk3Bhx.js → c4Diagram-YGBWAQC7-Bd3aE4Kc.js} +1 -1
  7. package/dist/assets/channel-DqY_gMLg.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-D-VRqzTB.js → chunk-2Q5K7J3B-BijjTBeJ.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BChov01Y.js → chunk-3FUC2YCW-CMGdwvzW.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-C-TfwvHy.js → chunk-5VM5RSS4-0G09BfsZ.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-Db7CGj5Y.js → chunk-GTNCS2PH-FOp3vSHe.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-CNvIfqKT.js → chunk-GWA4HPMP-Dv-20VIn.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-C_bEp5tq.js → chunk-JWPE2WC7-CRgrdFcz.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-Coi4BOls.js → chunk-NETBCI7D-3hP4f7QB.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CzylEuL3.js → chunk-WEXAMYUT-CG98IjlG.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-m7p7ztaD.js → chunk-XXDRQBXY-BrSTC9xG.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-BMy6o9x2.js → classDiagram-v2-NBCMYWYE-rpQQQDFX.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-BDQ__0_J.js → cose-bilkent-JH36ORCC-G6RSafHA.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-Djv4ghUb.js → cynefin-OW5HDTMX-CXjv-IM2.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-BULrlGYH.js → cynefinDiagram-VND7K2PF-Dw07MR9f.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-D-jrT_hU.js → dagre-6A5THRUB-BwPnlTg2.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-p2UhQmfL.js → diagram-22UHCM2B-Cm6xcMiz.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-DHvGWZ7q.js → diagram-3UASUU5V-CDpogPbE.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-ChhKDot-.js → diagram-ATOU4E4O-BV8z3Pnc.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-B-T7A43R.js → diagram-CDSNMT55-CKCQhOOC.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-CJm8ENNY.js → diagram-MLGK6HIB-FGvJg69C.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-510tlza8.js → diagram-MPIPVDR6-CwATvgnS.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-PkvRPHel.js → ebnfDiagram-ZINNZB2B-CH_c3SSC.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-BVBVPoX3.js → elk-276RUBZZ-3guZi71R.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-BgYMVm7L.js → erDiagram-OPXOYQCR-BMHphR2n.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-BGqsnc_3.js → flowDiagram-KWPJA3E3-Vm5DkW5x.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-D_-UeReH.js → ganttDiagram-FUAMR5RP-DO-1ct07.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-D0uooONx.js → gitGraphDiagram-X574FWY7-nxeksEIN.js} +1 -1
  34. package/dist/assets/index-BIMdIbaw.css +1 -0
  35. package/dist/assets/index-BVPdbq2r.js +583 -0
  36. package/dist/assets/{index-zRsJERGJ.js → index-Oxk9x45i.js} +1 -1
  37. package/dist/assets/{infoDiagram-VRGFBTTK-B9e1z77f.js → infoDiagram-VRGFBTTK-Dkcvr0p7.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BB4vNwvD.js → ishikawaDiagram-OU5B5YK6-BwVibkoO.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DpkosOTB.js → journeyDiagram-ZHPQQLJL-ByiBZqgG.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-DbDP4kUu.js → kanban-definition-PNTS6WVX-qqjgXBF3.js} +1 -1
  41. package/dist/assets/{linear-DBJGPmjT.js → linear-CtJnKe6A.js} +1 -1
  42. package/dist/assets/{mermaid.core-DWPRi-ue.js → mermaid.core-BEiDklrY.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-nszBbJmx.js → mindmap-definition-NLK3R4M7-DfIYHia9.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-CWykJWu_.js → pegDiagram-GJSIUBJH-CYyRqa1a.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-g3Da-Vnv.js → pieDiagram-5QR66LMP-hOToy_zH.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-Wzrlpzgh.js → quadrantDiagram-O4NWA36T-DKAsNN9I.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-moHTFMC0.js → railroadDiagram-XR7U4H2S-BSA-d8JU.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-CwfXdFH8.js → requirementDiagram-PLB6GJNP-CSmlvb-p.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-3ZROYvnq.js → sankeyDiagram-IPEJSGJF-CD5F6fnG.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-E1TBebP8.js → sequenceDiagram-PO4LG4MO-JUq5sx6d.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-DHKEU5pu.js → sizeCapture-INFHLROL-BjIS-Stk.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-BguEj09D.js → stateDiagram-v2-GCMORJYK-DklkUxOg.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-C7IDYnhd.js → swimlanes-2SLR337P-CsjIU6be.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-Bk14M0_B.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-DCW-F5PW.js → timeline-definition-EJHVYXUP-BkFqK7AB.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-xBMFtWYV.js → usecaseDiagram-POWQR4AR-70gJB1EU.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-B5nIkuH8.js → vennDiagram-UO4OBE2U-83Tm62dN.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DYFAhdbr.js → wardleyDiagram-VNRHLVJA-tTmseoX9.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-DeXwX6Cz.js → xychartDiagram-PMCCYNJV-R1LBA55a.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 +11 -1
  64. package/dist-server/drivers/agents-proxy.js +277 -22
  65. package/dist-server/index.js +23722 -20272
  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 +755 -404
  71. package/dist-server/pair-cli.js +755 -404
  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/automatic-recovery.js +15 -0
  76. package/dist-server/server/bot-attachment.js +107 -0
  77. package/dist-server/server/bot-overview.js +19 -0
  78. package/dist-server/server/composio.js +297 -1
  79. package/dist-server/server/config.js +34 -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 +70 -0
  84. package/dist-server/server/contracts.js +32 -0
  85. package/dist-server/server/drivers/acp/core.js +148 -23
  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 +12 -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 +3 -17
  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 +31 -3
  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 +61 -0
  108. package/dist-server/server/index.js +1173 -262
  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 +37 -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/webhooks.js +21 -3
  134. package/dist-server/server/workspace-backup.js +40 -3
  135. package/dist-server/server/workspace-backup.worker.js +10 -0
  136. package/dist-server/shared/ask-question.js +19 -0
  137. package/dist-server/shared/credential-request.js +5 -1
  138. package/dist-server/shared/line-diff.js +23 -0
  139. package/dist-server/shared/model-request.js +1 -0
  140. package/dist-server/shared/options-card.js +51 -0
  141. package/dist-server/shared/profile-request.js +5 -4
  142. package/dist-server/shared/tightening-request.js +87 -0
  143. package/dist-server/src/lib/composer-attachments.js +42 -3
  144. package/dist-server/vps-container-mcp.js +11 -1
  145. package/dist-server/workspace-backup.worker.js +19482 -0
  146. package/package.json +1 -1
  147. package/dist/assets/channel-taCNRlUU.js +0 -1
  148. package/dist/assets/index-BwdMAQ6p.css +0 -1
  149. package/dist/assets/index-ByJennr7.js +0 -581
  150. package/dist/assets/swimlanesDiagram-TC7HE7FX-BpEdoB2e.js +0 -8
@@ -22,9 +22,10 @@ import { assertRequestTarget, guardedRequestPath, requestConflict, requestNeedsI
22
22
  import { botAvatarUrlFromStoredPath } from "../shared/bot-avatar.js";
23
23
  import { BOT_PROFILE_LIMITS } from "../shared/bot-profile.js";
24
24
  import { CLOUD_COMPUTER_BUSY_ERROR } from "../shared/computer-contention.js";
25
- import { approvalModeFor, supportsApprovalMode, modelSwitchNeedsAsk, isEmergencyApprovalDowngrade, isApprovalMode, } from "../shared/approval-mode.js";
25
+ import { approvalModeFor, modelSwitchNeedsAsk, isEmergencyApprovalDowngrade, isApprovalMode, } from "../shared/approval-mode.js";
26
+ import { createApprovalModeSupport } from "./harness-capabilities.js";
26
27
  import { escapeAttribute } from "../src/lib/composer-attachments.js";
27
- import { CREDENTIAL_TARGETS, credentialResumeOutcome, credentialIsConfigured, isReusableCredentialRequest, isCredentialTargetId, } from "../shared/credential-request.js";
28
+ import { CREDENTIAL_TARGETS, credentialResumeOutcome, credentialIsConfigured, isPendingCredentialRequest, isCredentialTargetId, } from "../shared/credential-request.js";
28
29
  import { approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval, delegationInheritsFullAccess } from "./auto-approve.js";
29
30
  import { CommandAllowlistStore, commandAllowlistCandidate } from "./command-allowlist.js";
30
31
  import { updateClaudeCli } from "./claude-update.js";
@@ -37,7 +38,7 @@ import { commandReceipt } from "./commands.js";
37
38
  import { buildTurnDigest, coverageForDriver, digestPromptLine, renderDigest, toolEvidence } from "./digest.js";
38
39
  import { appendDecision, bindDecisionRetention, boundRetentionDays, decisionRetentionDays, decisionsCsv, flushDecisionLog, pruneDecisions, readDecisionRange, readDecisions, withDecisionActor } from "./decision-log.js";
39
40
  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";
41
+ import { ATTACHMENTS_DIR, attachmentExists, cleanupStaleAttachmentPartials, deleteAttachment, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, parseAudioRange, readAttachment, saveAudio, saveFile, saveImage, saveImageUpload, validateAttachmentUploadId, } from "./attachments.js";
41
42
  import { messageFileDisposition, messageFileDownloadName, messageAttachmentName, messageFileRoots, messageImageTargetAt, messageReferencesFile, openMessageFile, } from "./message-file.js";
42
43
  import { avatarGenerationRequestSchema, avatarGenerationStateMatches, generateAvatarImage, avatarImageStatus, snapshotAvatarGenerationState, } from "./avatar-image.js";
43
44
  import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
@@ -52,9 +53,10 @@ import { boxAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourc
52
53
  import * as composio from "./composio.js";
53
54
  import { connectorCallFromFrame, connectorRefusalText, connectorUnrecognizedText, evaluateConnectorTools } from "./connector-verdict.js";
54
55
  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";
56
+ import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, livePeerRosterBlock, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
56
57
  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";
58
+ import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerExec, containerRuntimeStatus, localVmRecreatableOnDemand, localVmWorkspaceExists, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, VM_WORKSPACE_GUEST, } from "./container-computer.js";
59
+ import { attachForTurn, saveBotAttachment } from "./bot-attachment.js";
58
60
  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
61
  import { sweepThreadEventLogs } from "./thread-retention.js";
60
62
  import { ComputerControl } from "./computer-control.js";
@@ -70,22 +72,25 @@ import { entitled } from "./enterprise.js";
70
72
  import { HOSTED_CONTRACT_HEADER, HOSTED_CONTRACT_METADATA, HOSTED_CONTRACT_VERSION } from "./hosted-contract.js";
71
73
  import { describeSpawnFailure, execCli } from "./procs.js";
72
74
  import { blockedTarget, buildNotification, buildSpendNotification } from "./notify.js";
73
- import { isModelVariant, newId, } from "./contracts.js";
75
+ import { isModelVariant, TurnNotStartedError, newId, } from "./contracts.js";
74
76
  import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.js";
77
+ import { recoveryCapabilityError } from "./automatic-recovery.js";
75
78
  import { decodeGeneratedImage } from "./generated-image.js";
76
79
  import { MAX_MCP_SERVERS, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
77
80
  import { probeMcpServer } from "./mcp-probe.js";
78
81
  import { GROUP_GOAL_MAX_TURNS, groupGoalAssignmentKey, groupGoalCompletionTurnId, groupGoalCoordinatorInstructions, groupGoalWorkerInstructions, parseGroupGoalDecision, resolveGroupGoalMember, selectGroupGoalCoordinator, } from "./group-goal-run.js";
79
82
  import { BUILT_IN_DRIVERS } from "./drivers/builtIn.js";
80
83
  import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from "./comms-visibility.js";
81
- import { readMessageText, recallMessages, recentMessages, searchMessages, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups, threadsReferencing } from "./message-db.js";
84
+ import { readMessageText, recallMessages, recentMessages, searchMessagesAsync, closeMessageSearch, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups, threadsReferencing } from "./message-db.js";
82
85
  import { briefCrossingLabel, claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
83
86
  import { parseSince, parseUntil, recentWork, recentWorkPrompt, turnOutcomeLine } from "./recent-work.js";
84
87
  import { chiefForBot, INCIDENTS_THREAD_TITLE, IncidentLedger, incidentChip, incidentText } from "./incidents.js";
85
88
  /** A session_read answer competes with the transcript for the context
86
89
  * window; a computer-use turn's output can run to hundreds of KB. */
87
90
  const SESSION_READ_MAX_CHARS = 8_000;
91
+ import { attemptAsideInjection, drainAsideMessages, queueAsideMessage, restoreAsideMessages, } from "./aside-queue.js";
88
92
  import { promptWithReply, transcriptText } from "./replies.js";
93
+ import { admit } from "./admission.js";
89
94
  import { _loadPending, buildDelegationFailurePrompt, buildDelegationRevivalPrompt, DELEGATION_TTL_MS, DelegationWakeBudget, discardDelegations, drainDelegations, expireStaleDelegations, findDelegationReceipt, pendingDelegationInfo, pendingDelegationSnapshot, pendingThreads, queueDelegation, recordDelegationReceipt, releaseDelegationsWaitingOn, summarizeDelegatedActivity } from "./delegations.js";
90
95
  import { cancelSteeredMessage, drainSteeredMessages, hasQueuedSteeredMessages, holdSteeredQueue, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, restoreHeldSteeredQueue, restoreSteeredMessages, settleHeldSteeredQueue, } from "./steer-queue.js";
91
96
  import { cancelChannelMessage, drainChannelMessages, holdChannelQueue, queuedChannelMessage, queueChannelMessage, restoreChannelMessages, restoreHeldChannelQueue, resolveHeldReplyTarget, settleHeldChannelQueueHead, } from "./channel-queue.js";
@@ -97,6 +102,7 @@ import { computerKindForResource, ManagedDesktopPolicy } from "./managed-policy.
97
102
  import { hostedModelPolicy, HOSTED_MODEL_POLICY_HEADER, HOSTED_PROVIDER_SETTINGS_ERROR } from "./hosted-models.js";
98
103
  import { selectDefaultModelSelection, withNewBotEffort } from "./default-model-selection.js";
99
104
  import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, peerApprovalFailure, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
105
+ import { peerDeliveryReceipt } from "./peer-delivery.js";
100
106
  import { peerProvenanceNote, withPeerProvenance } from "./peer-provenance.js";
101
107
  import { decideRoomPost, emptyRoomPostBudget } from "./room-post-budget.js";
102
108
  import { isProjectEmoji, mentionedBots, parseConnectorTools, roomResponders, sectionKey, Store, titleFromLlm, toWireTask, } from "./store.js";
@@ -119,7 +125,7 @@ import { botSkillTemplateSchema, newBotDefaultsSchema, resolveBotCreationDefault
119
125
  import { expandLearnTurnText, learnSource } from "./skill-learn.js";
120
126
  import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
121
127
  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";
128
+ import { buildSystemPrompt, userProfileSystemPrompt, computerPrompt, composioSystemPrompt, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, resolveComputerPromptKind, } from "./system-prompt.js";
123
129
  import { readCuaConnection, readCuaUnavailableReason, gatedLocalComputer } from "./local-computer.js";
124
130
  import { discoverExistingPerBotLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
125
131
  import { LocalVmIdleTimer } from "./local-vm-idle.js";
@@ -136,8 +142,11 @@ import { agentBrowserFrame, agentBrowserIntegration, browserEngineEncryptionKey,
136
142
  import { createScreenFrameSource } from "./screen-frame-source.js";
137
143
  import { screenFrameHash, screenSurfaceForTool, screenTouchingTool, settledFrameIsNews } from "./screen-frame-gate.js";
138
144
  import { RoutineRequestService } from "./routine-requests.js";
145
+ import { createOptionsCard } from "./options-card.js";
139
146
  import { buildBotOverview, connectedAppsFacts } from "./bot-overview.js";
140
147
  import { ProfileRequestService } from "./profile-requests.js";
148
+ import { ModelRequestService } from "./model-requests.js";
149
+ import { TighteningRequestService } from "./tightening-requests.js";
141
150
  import { TeamSetupError, TeamSetupRequestService } from "./team-setup-requests.js";
142
151
  import { profileRevision, profileSnapshot } from "./profile-revision.js";
143
152
  import { flushAllProfileHistory, flushProfileHistory, readHistory, recordProfileChange } from "./profile-versions.js";
@@ -156,7 +165,7 @@ import { loadBundledSkills, loadUserSkills, mergeSkills, renderSkillInstructions
156
165
  import { installedPlaybookInstructions } from "./installed-playbooks.js";
157
166
  import { createBotPackageExport, createLibraryPackageExport, createTeamPackageExport, picture as sharedPicture, TeamExportError } from "./package-export.js";
158
167
  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";
168
+ import { applyPresetToBot, createPresetStore, DEFAULTS_PRESET_KEY, PRESET_UNAVAILABLE_MESSAGE, presetFromDefaults, presetOffered, readPublishedLibrary, writePublishedLibrary, } from "./presets.js";
160
169
  import { readPublishedTeam, writePublishedTeam } from "./published-teams.js";
161
170
  import { OrgLibrary } from "./org-library.js";
162
171
  import { createTeamBackup, importTeamBackup } from "./team-backup.js";
@@ -336,6 +345,10 @@ function customDomainStatus() {
336
345
  };
337
346
  }
338
347
  const registry = new ProviderRegistry(BUILT_IN_DRIVERS);
348
+ // The approval gates below hand this their model selection — or a driver
349
+ // view they already hold — and let the registry resolve the driver, instead
350
+ // of each site unwrapping registry.cliTarget(...)?.driverKind itself.
351
+ const supportsApprovalMode = createApprovalModeSupport(registry);
339
352
  // Engines installed from Settings live under the data directory and win over
340
353
  // any other copy on PATH.
341
354
  registerEnginesBinDir();
@@ -470,7 +483,7 @@ function cardAnswererFor(auth) {
470
483
  async function answeringCardAs(auth, threadId, requestId, work) {
471
484
  const open = (() => {
472
485
  const card = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId)?.card;
473
- return Boolean(card && !card.answered && !card.dismissed);
486
+ return Boolean(card && !card.answered && !card.dismissed && !card.expired);
474
487
  })();
475
488
  try {
476
489
  await withDecisionActor(decisionActorFor(auth), work);
@@ -1057,6 +1070,7 @@ utilityParentPort?.on("message", event => {
1057
1070
  utilityParentPort.postMessage({ type: "openmausbot:managed-desktop-result", requestId, ok: false, error: "Company connection could not be applied. Reconnect from desktop Settings." });
1058
1071
  });
1059
1072
  });
1073
+ const MAX_ATTACHED_FILES_PER_TURN = 10;
1060
1074
  // A capability lives for the exact provider-turn generation, including while
1061
1075
  // that turn is parked on a human approval. The long ceiling is only an orphan
1062
1076
  // backstop for an impossible-to-settle adapter; normal terminal paths revoke
@@ -1267,7 +1281,10 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation, r
1267
1281
  OMB_SHARED_COMPUTERS_ENABLED: sharedComputersEnabled(cfg) ? "1" : "0",
1268
1282
  // Same capability rule for voice: the tool is offered only when this
1269
1283
  // 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",
1284
+ OMB_VOICE_NOTES: (() => {
1285
+ const speaking = botForThread(botId, threadId) ?? store.bot(botId);
1286
+ return tts.voiceReady(cfg, speaking?.voice) && speaking?.voiceNotes !== false ? "1" : "0";
1287
+ })(),
1271
1288
  },
1272
1289
  };
1273
1290
  }
@@ -1307,10 +1324,10 @@ function settleDirectCoordination(generation, outcome) {
1307
1324
  directCoordinationSettlers.delete(generation);
1308
1325
  settle?.(outcome);
1309
1326
  }
1310
- function settleDirectFollowup(generation) {
1327
+ function settleDirectFollowup(generation, outcome = { ok: false, text: "The coordinated turn was interrupted" }) {
1311
1328
  if (!generation)
1312
1329
  return;
1313
- settleDirectCoordination(generation, { ok: false, text: "The coordinated turn was interrupted" });
1330
+ settleDirectCoordination(generation, outcome);
1314
1331
  const pending = directFollowupSettlers.get(generation);
1315
1332
  if (!pending)
1316
1333
  return;
@@ -1494,7 +1511,11 @@ function hasDirectDispatch(botId) {
1494
1511
  * share this test so peer and room work waits for a real slot, not total
1495
1512
  * bot idleness. */
1496
1513
  function canAdmitDirectTurn(botId, threadId) {
1497
- return !threadBusy(botId, threadId) && !botAtThreadCapacity(botId) && !activeGroupTurnForBot(botId);
1514
+ return admit("peer", {}, {
1515
+ threadBusy: threadBusy(botId, threadId),
1516
+ atCapacity: botAtThreadCapacity(botId),
1517
+ groupTurn: Boolean(activeGroupTurnForBot(botId)),
1518
+ }).action === "start";
1498
1519
  }
1499
1520
  /** Routine and webhook dispatch shares startTurn's admission preconditions
1500
1521
  * instead of waiting for whole-bot idleness: a free thread slot and no
@@ -1502,7 +1523,12 @@ function canAdmitDirectTurn(botId, threadId) {
1502
1523
  * blocks every other turn kind; it does not consume a capacity slot. */
1503
1524
  function unattendedDispatchState(botId) {
1504
1525
  const bot = store.bot(botId);
1505
- return !bot ? "missing" : botAtThreadCapacity(botId) || activeGroupTurnForBot(botId) ? "busy" : "ready";
1526
+ const decision = admit("unattended", {}, {
1527
+ botPresent: Boolean(bot),
1528
+ atCapacity: Boolean(bot) && botAtThreadCapacity(botId),
1529
+ groupTurn: Boolean(bot) && Boolean(activeGroupTurnForBot(botId)),
1530
+ });
1531
+ return decision.action !== "refuse" ? "ready" : decision.code === "missing" ? "missing" : "busy";
1506
1532
  }
1507
1533
  function requestedTaskBot(botId, rawThreadId) {
1508
1534
  const profile = store.bot(botId);
@@ -1776,9 +1802,9 @@ function phoneIntegration(botId, threadId, generation) {
1776
1802
  env.PH_ANDROID_SERIAL = process.env.PH_ANDROID_SERIAL;
1777
1803
  return { command: process.execPath, args: [phoneProxyPath], env };
1778
1804
  }
1779
- function connectedAppsIntegration(botId, threadId, generation) {
1805
+ function connectedAppsIntegration(bot, threadId, generation) {
1780
1806
  const token = mintInternalCapability({
1781
- botId,
1807
+ botId: bot.id,
1782
1808
  threadId,
1783
1809
  generation,
1784
1810
  depth: 0,
@@ -1790,8 +1816,12 @@ function connectedAppsIntegration(botId, threadId, generation) {
1790
1816
  return composio.mcpIntegration(cfg, {
1791
1817
  harnessUrl: `http://127.0.0.1:${PORT}`,
1792
1818
  commsToken: token,
1793
- botId,
1819
+ botId: bot.id,
1794
1820
  threadId,
1821
+ // Connector grants 3/5: the record (or its absence) rides along so
1822
+ // the bridge can filter tools/list; the verdict on the relay endpoint
1823
+ // stays the authority for every call.
1824
+ connectorTools: bot.connectorTools,
1795
1825
  });
1796
1826
  }
1797
1827
  // ── computer control (who is driving) ──────────────────────────────────
@@ -2257,7 +2287,7 @@ const wireTask = (task) => {
2257
2287
  return asleep ? { ...coordinated, snoozedUntil } : coordinated;
2258
2288
  };
2259
2289
  const wireBot = (bot) => {
2260
- const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, ...rest } = bot;
2290
+ const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, ...rest } = bot;
2261
2291
  // An elevated selection is inert until the desktop confirms its exact
2262
2292
  // private reply. Every ordinary client sees the effective Ask state during
2263
2293
  // that two-phase window, never a grant that may still roll back.
@@ -2270,7 +2300,7 @@ const wireBot = (bot) => {
2270
2300
  /** The correlated private response carries the requested value so Electron
2271
2301
  * can validate it before sending the confirmation that makes it effective. */
2272
2302
  const wireTrustedApprovalBot = (bot) => {
2273
- const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, ...rest } = bot;
2303
+ const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, ...rest } = bot;
2274
2304
  return { ...rest, approvalMode: approvalModeFor(rest), avatarUrl: rest.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
2275
2305
  };
2276
2306
  /** A settings-based preview, not a receipt of a dispatched turn. No
@@ -2290,13 +2320,13 @@ function previewSystemPrompt(bot) {
2290
2320
  const caps = instance?.adapter.capabilities;
2291
2321
  const teamComputer = inheritedTeamComputer(bot);
2292
2322
  const previewComputer = teamComputer ? "cloud" : bot.computer;
2293
- const computerPromptKind = previewComputer === "vm"
2294
- ? caps?.computerMcp ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared" : null
2295
- : previewComputer === "cloud"
2296
- ? instance?.driverKind === "boxAgent" ? "box-agent" : caps?.computerMcp ? bot.cloudBackend === "vps" ? "vps" : caps.cloudComputerMcp ? "box-chat" : "box" : null
2297
- : previewComputer === "local"
2298
- ? caps?.localComputerMcp ? "local" : null
2299
- : null;
2323
+ const computerPromptKind = resolveComputerPromptKind({
2324
+ kind: previewComputer === "vm" ? "vm" : previewComputer === "cloud"
2325
+ ? bot.cloudBackend === "vps" ? "vps" : "box" : previewComputer === "local" ? "local" : null,
2326
+ driverKind: instance?.driverKind,
2327
+ cloudComputerMcp: caps?.cloudComputerMcp,
2328
+ vmPrivate: localVmMode(cfg) === "per-bot",
2329
+ });
2300
2330
  const peers = reachablePeers(store.bots, bot);
2301
2331
  const coordination = bot.chiefOfStaff
2302
2332
  ? chiefOfStaffSystemPrompt(bot.id, store.bots, true, openMausStatusSystemPrompt())
@@ -2334,7 +2364,7 @@ function previewSystemPrompt(bot) {
2334
2364
  computer: previewPlan.computer && previewPlan.computer !== "off" && computerPromptKind ? previewPlan.computer : null,
2335
2365
  browser: previewPlan.computer === undefined ? false : previewPlan.browser,
2336
2366
  }, { note: previewPlan.note }) },
2337
- { id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? COMPOSIO_PROMPT : "" },
2367
+ { id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? composioSystemPrompt(bot.connectorTools) : "" },
2338
2368
  { id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(engineMcpServers(bot))) : "" },
2339
2369
  { id: "browser", label: "Browser", text: previewPlan.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
2340
2370
  { id: "coordination", label: "Team", text: agentsMounted && coordination ? ` ${coordination}` : "" },
@@ -2382,6 +2412,7 @@ async function botOverview(bot) {
2382
2412
  approvePeerComms: bot.approvePeerComms,
2383
2413
  peers: bot.peers,
2384
2414
  composio: bot.composio,
2415
+ connectorTools: bot.connectorTools,
2385
2416
  browser: bot.browser,
2386
2417
  chiefOfStaff: bot.chiefOfStaff,
2387
2418
  managedSections: bot.managedSections,
@@ -2430,7 +2461,7 @@ async function botOverview(bot) {
2430
2461
  * person gave the Chief covers the work the Chief hands out. */
2431
2462
  const approvalModeForTurn = (bot, peerInitiated = false) => {
2432
2463
  const mode = approvalModeForOrigin(approvalModeFor(bot), { peerInitiated });
2433
- if (!supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
2464
+ if (!supportsApprovalMode(bot.modelSelection, mode)) {
2434
2465
  return "ask";
2435
2466
  }
2436
2467
  return mode;
@@ -2503,8 +2534,8 @@ function handleDesktopTrustedApprovalMessage(raw) {
2503
2534
  const grantBusy = (bot) => bot.approvalGrant?.threadOnly
2504
2535
  ? threadBusy(bot.id, bot.approvalGrant.threadId) : bot.busy ||
2505
2536
  Boolean(bot.approvalGrant?.allThreads && store.tasks(bot.id).some(task => threadBusy(bot.id, task.threadId)));
2506
- const grantSupported = (bot, mode) => supportsApprovalMode(registry.cliTarget(grantTarget(bot)?.modelSelection.instanceId ?? "")?.driverKind, mode) &&
2507
- (!bot.approvalGrant?.allThreads || store.tasks(bot.id).every(task => supportsApprovalMode(registry.cliTarget((task.modelSelection ?? bot.modelSelection).instanceId)?.driverKind, mode)));
2537
+ const grantSupported = (bot, mode) => supportsApprovalMode(grantTarget(bot)?.modelSelection, mode) &&
2538
+ (!bot.approvalGrant?.allThreads || store.tasks(bot.id).every(task => supportsApprovalMode(task.modelSelection ?? bot.modelSelection, mode)));
2508
2539
  const clearGrant = (bot) => store.patchBot(bot.id, {
2509
2540
  ...(!bot.approvalGrant?.threadOnly ? { approvalMode: "ask", autoApprove: false } : {}),
2510
2541
  approvalGrant: undefined,
@@ -2737,7 +2768,7 @@ function handleDesktopTrustedApprovalMessage(raw) {
2737
2768
  void stopBotForEmergencyApprovalDowngrade(botId).then(() => respond({ ok: true, bot: wireBot(store.bot(botId) ?? updated) }), () => respond({ ok: false, error: "Approval was reset to Ask, but an active turn could not be stopped" }));
2738
2769
  return true;
2739
2770
  }
2740
- if (message.allThreads === true && (existing.approvalGrant || store.tasks(botId).some(task => threadBusy(botId, task.threadId) || !supportsApprovalMode(registry.cliTarget((task.modelSelection ?? existing.modelSelection).instanceId)?.driverKind, mode)))) {
2771
+ if (message.allThreads === true && (existing.approvalGrant || store.tasks(botId).some(task => threadBusy(botId, task.threadId) || !supportsApprovalMode(task.modelSelection ?? existing.modelSelection, mode)))) {
2741
2772
  respond({ ok: false, error: "Finish this bot's active turns and approval changes first. Every thread's provider must support Full access." });
2742
2773
  return true;
2743
2774
  }
@@ -2757,7 +2788,7 @@ function handleDesktopTrustedApprovalMessage(raw) {
2757
2788
  respond({ ok: false, error: "Stop this thread and finish its pending approval change first" });
2758
2789
  return true;
2759
2790
  }
2760
- if (!supportsApprovalMode(registry.cliTarget(target.modelSelection.instanceId)?.driverKind, mode)) {
2791
+ if (!supportsApprovalMode(target.modelSelection, mode)) {
2761
2792
  respond({ ok: false, error: "This thread's provider does not support that approval level" });
2762
2793
  return true;
2763
2794
  }
@@ -2817,7 +2848,7 @@ function handleDesktopTrustedApprovalMessage(raw) {
2817
2848
  respond({ ok: false, error: "Stop this bot's turn before changing its approval level" });
2818
2849
  return true;
2819
2850
  }
2820
- if (!supportsApprovalMode(registry.cliTarget(existing.modelSelection.instanceId)?.driverKind, mode)) {
2851
+ if (!supportsApprovalMode(existing.modelSelection, mode)) {
2821
2852
  respond({
2822
2853
  ok: false,
2823
2854
  error: mode === "full"
@@ -3117,7 +3148,7 @@ const channelTaskBlocked = (group) => groupIsWorking(group) ||
3117
3148
  store.groupTasks(group.id).some((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
3118
3149
  message.card?.requestId &&
3119
3150
  !message.card.answered &&
3120
- !message.card.dismissed));
3151
+ !message.card.dismissed && !message.card.expired));
3121
3152
  // Recovery can synchronously emit room changes. Load coordination state
3122
3153
  // before registering store listeners or recovering interrupted routines.
3123
3154
  const groupQueues = new Map();
@@ -3276,7 +3307,9 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
3276
3307
  const parent = node.parentId ? roomHandoffs.nodes.get(node.parentId) : undefined;
3277
3308
  const sender = parent ? store.bot(parent.botId) : undefined;
3278
3309
  const result = {};
3279
- const turnText = coordinationTurnText(node, resumed);
3310
+ const brief = coordinationTurnText(node, resumed);
3311
+ const liveRoster = resumed ? undefined : coordinationLiveRosterBlock(bot);
3312
+ const turnText = liveRoster ? `${brief}\n\n${liveRoster}` : brief;
3280
3313
  const systemInstructions = coordinationSystemInstructions();
3281
3314
  const request = roomHandoffs.sharedRequest(node);
3282
3315
  if (!resumed && !store.messagesFor(node.threadId).some(m => m.roomRequest?.id === request.id && m.roomRequest.phase === "request")) {
@@ -3348,7 +3381,7 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
3348
3381
  result.outcome = "cancelled";
3349
3382
  return;
3350
3383
  }
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);
3384
+ 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
3385
  });
3353
3386
  const tracked = run.finally(() => {
3354
3387
  signal.removeEventListener("abort", abort);
@@ -3618,6 +3651,35 @@ async function waitForChatRoomMember(operation, threadId, bot) {
3618
3651
  }
3619
3652
  }
3620
3653
  }
3654
+ /** The peer's newest stored message, across every task it owns: the recency
3655
+ * the live roster breaks ties with. A bot with no messages yet falls back
3656
+ * to its newest task's creation, which keeps a freshly created teammate
3657
+ * ahead of a silent veteran only when it really is the newer arrival. */
3658
+ function newestPeerActivityAt(botId) {
3659
+ let newest = 0;
3660
+ for (const task of store.tasks(botId)) {
3661
+ const at = store.messagesTail(task.threadId, 1).messages.at(-1)?.at ?? task.createdAt;
3662
+ if (at > newest)
3663
+ newest = at;
3664
+ }
3665
+ return newest;
3666
+ }
3667
+ /** The live-peer roster for a fresh coordination brief. Claude snapshots
3668
+ * system prompts, so dispatch-time data has to ride the user turn; and only
3669
+ * a recipient whose driver mounts the agents tools can act on the names, so
3670
+ * any other recipient gets nothing — the same gate the system-prompt roster
3671
+ * applies. The roster names the RECIPIENT's own reachable peers, which is
3672
+ * exactly the set its coordinate_bots calls will be allowed to reach. */
3673
+ function coordinationLiveRosterBlock(recipient) {
3674
+ const instance = registry.get(recipient.modelSelection.instanceId);
3675
+ if (instance?.adapter.capabilities.agentsMcp !== true)
3676
+ return "";
3677
+ const team = reachablePeers(store.bots, recipient).map(peer => ({
3678
+ ...peer,
3679
+ lastActivityAt: newestPeerActivityAt(peer.id),
3680
+ }));
3681
+ return livePeerRosterBlock(livePeerRoster(team));
3682
+ }
3621
3683
  function cancelGroupTurnOperations(groupId, threadId, outcome = {
3622
3684
  status: "stopped",
3623
3685
  detail: "Stopped by you.",
@@ -4602,8 +4664,8 @@ const autoVmClaims = new Map();
4602
4664
  * answers held. A free, ready VM claims in the time of one container
4603
4665
  * inspect; only a claim queued behind another holder outlives this. */
4604
4666
  const LAZY_VM_CLAIM_GRACE_MS = 5_000;
4605
- /** Local VM targets this process has seen ready or recreatable — at boot, in
4606
- * the inventory, or in a turn. Auto probes the container runtime for a VM
4667
+ /** Local VM targets with a durable workspace or seen ready/recreatable — at
4668
+ * boot, in the inventory, or in a turn. Auto probes the container runtime for a VM
4607
4669
  * only when one of these exists, so an ordinary Auto turn on a machine with
4608
4670
  * no VM never pays for a docker or podman call. */
4609
4671
  const localVmSeen = new Set();
@@ -4659,6 +4721,61 @@ function botComputerControlKey(bot) {
4659
4721
  const computer = inheritedTeamComputer(bot);
4660
4722
  return computer ? teamComputerOwner(computer.id) : bot.id;
4661
4723
  }
4724
+ async function computerCallGate(internalCapability) {
4725
+ const botId = internalCapability.botId;
4726
+ const snapshot = botComputerControlSnapshot(botId, internalCapability.teamComputerId);
4727
+ const slot = autoVmClaims.get(internalCapability.threadId);
4728
+ const lazyClaim = slot && slot.owner.generation === internalCapability.generation ? slot : undefined;
4729
+ if (!snapshot.held && lazyClaim?.lazy && !lazyClaim.begin) {
4730
+ // First screen tools/call on a lazily-attached Auto VM (issue
4731
+ // #1361): fire the exclusive claim — once — and give it a moment
4732
+ // to land. A free, ready VM claims in the time of one container
4733
+ // inspect, so this call then proceeds with an honest answer;
4734
+ // only a claim still queued behind another holder answers held
4735
+ // below, and then the contention text is true. Keyed on the
4736
+ // slot, never on the thread's turn-computer entry: a bind this
4737
+ // turn abandoned earlier (a VPS that turned out to be asleep)
4738
+ // must not hide the unclaimed VM and let the call through.
4739
+ startAutoVmClaim(autoVmClaims, internalCapability.threadId, internalCapability.generation);
4740
+ await Promise.race([
4741
+ lazyClaim.begin ?? Promise.resolve(),
4742
+ new Promise((resolve) => setTimeout(resolve, LAZY_VM_CLAIM_GRACE_MS)),
4743
+ ]);
4744
+ }
4745
+ if (!snapshot.held && lazyClaim?.failed === true) {
4746
+ // A rejected lazy claim (gate finding F1, issue #1361): the
4747
+ // computer MCP mounted at dispatch is still live, and the claim
4748
+ // may even have left a turn-computer entry behind (it can reject
4749
+ // after bindTurnComputer succeeded — lease lost to a person,
4750
+ // lifecycle busy, boot failure). Either way this turn owns no
4751
+ // usable VM, so keep refusing every screen call for the rest of
4752
+ // the generation; the bridge must never forward one onto a VM
4753
+ // this turn never claimed. Turn settle GC clears the slot. Say
4754
+ // why, and say not to retry: the contention text would send the
4755
+ // model into a screenshot loop against a claim that cannot land.
4756
+ return {
4757
+ held: true, helpOpen: false,
4758
+ 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.`,
4759
+ };
4760
+ }
4761
+ if (!snapshot.held && lazyClaim?.lazy && lazyClaim.begin && !lazyClaim.claimed) {
4762
+ // The claim fired and is still waiting on the exclusive bind:
4763
+ // another turn genuinely holds this desktop right now.
4764
+ return {
4765
+ held: true, helpOpen: false,
4766
+ 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.",
4767
+ };
4768
+ }
4769
+ const computer = turnComputerResources.get(internalCapability.threadId);
4770
+ if (!snapshot.held && computer && computer.owner.generation === internalCapability.generation &&
4771
+ !claimTurnResource(computer.owner, computer.resource)) {
4772
+ return {
4773
+ held: true, helpOpen: false,
4774
+ 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.",
4775
+ };
4776
+ }
4777
+ return { held: snapshot.held, helpOpen: snapshot.helpReason !== null };
4778
+ }
4662
4779
  function botComputerControlSnapshot(botId, pinnedComputerId) {
4663
4780
  const own = computerControl.snapshot(botId);
4664
4781
  const bot = store.bot(botId);
@@ -4919,7 +5036,7 @@ function turnProvider(bot, runOn, threadId) {
4919
5036
  const wants = turnSurfacePlan(bot, runOn, threadId).computer;
4920
5037
  if (wants !== undefined && wants !== "cloud")
4921
5038
  return null;
4922
- if (registry.get(bot.modelSelection.instanceId)?.driverKind === "boxAgent")
5039
+ if (registry.get(bot.modelSelection.instanceId)?.adapter.capabilities.remoteAgent === true)
4923
5040
  return "box";
4924
5041
  return bot.cloudBackend === "vps" ? "vps" : wants === "cloud" ? "box" : null;
4925
5042
  }
@@ -4950,7 +5067,7 @@ async function computerPreviewSurface(bot, threadId) {
4950
5067
  if (plan.computer !== undefined)
4951
5068
  return plan.computer === "off" && plan.browser ? "browser" : plan.computer;
4952
5069
  const instance = registry.get(bot.modelSelection.instanceId);
4953
- if (instance?.driverKind === "boxAgent")
5070
+ if (instance?.adapter.capabilities.remoteAgent === true)
4954
5071
  return "cloud";
4955
5072
  if (bot.cloudBackend === "vps") {
4956
5073
  const remote = await vps.vpsComputerStatus(cfg, bot.id);
@@ -4975,7 +5092,7 @@ async function computerPreviewSurface(bot, threadId) {
4975
5092
  async function selectableComputers(bot) {
4976
5093
  const caps = registry.get(bot.modelSelection.instanceId)?.adapter.capabilities;
4977
5094
  const off = bot.computer === "off";
4978
- const localEngine = registry.get(bot.modelSelection.instanceId)?.driverKind !== "boxAgent";
5095
+ const localEngine = caps?.remoteAgent !== true;
4979
5096
  return Promise.all(["cloud", "vm", "local", "browser"].map(async (surface) => {
4980
5097
  let ready = false;
4981
5098
  let canStart = false;
@@ -4993,7 +5110,7 @@ async function selectableComputers(bot) {
4993
5110
  canCreate = Boolean(status?.configured && status.daemonUp && status.container === "missing");
4994
5111
  reason = status?.problem ?? reason;
4995
5112
  }
4996
- else if (box.boxConfigured(cfg) && (caps?.cloudComputerMcp || registry.instances().some(instance => instance.driverKind === "boxAgent"))) {
5113
+ else if (box.boxConfigured(cfg) && (caps?.usesCloudComputer === true || registry.instances().some(instance => instance.driverKind === "boxAgent"))) {
4997
5114
  const status = await box.boxStatus(cfg, bot.id);
4998
5115
  const lifecycle = box.boxTurnLifecycleAction({ explicitCloud: true, canMount: true, state: status.box?.state ?? null });
4999
5116
  ready = lifecycle === "attach";
@@ -5186,6 +5303,13 @@ void (async () => {
5186
5303
  localVmIdleFor(SHARED_LOCAL_VM_TARGET).touch();
5187
5304
  return;
5188
5305
  }
5306
+ // Idle cleanup removes the container, not its provisioned workspace. Restore
5307
+ // Auto eligibility before runtime discovery, including when it is offline.
5308
+ for (const bot of store.bots) {
5309
+ const target = perBotLocalVmTarget(bot.id);
5310
+ if (localVmWorkspaceExists(target))
5311
+ localVmSeen.add(target.key);
5312
+ }
5189
5313
  const runtime = await containerRuntimeStatus().catch(() => null);
5190
5314
  if (!runtime?.runtime || !runtime.daemonUp)
5191
5315
  return;
@@ -5610,7 +5734,13 @@ bus.subscribe((event) => {
5610
5734
  pushMessage({
5611
5735
  role: "bot",
5612
5736
  kind: "activity",
5613
- tool: { name: `error: ${event.message.slice(0, 160)}`, ok: false, setup: event.setup, ...(event.terminal ? { terminal: true } : {}) },
5737
+ tool: {
5738
+ name: `error: ${event.message.slice(0, 160)}`,
5739
+ ok: false,
5740
+ setup: event.setup,
5741
+ ...(event.terminal ? { terminal: true } : {}),
5742
+ ...(event.claudeUpdate ? { claudeUpdate: true } : {}),
5743
+ },
5614
5744
  });
5615
5745
  // a setup error means the engine could not even start: the bot is
5616
5746
  // dead until something changes, not merely idle. The next successful
@@ -6594,26 +6724,85 @@ function drainQueuedSends() {
6594
6724
  // intact until that exact handshake releases its runtime-only claim.
6595
6725
  (botId, threadId) => threadBusy(botId, threadId) || botAtThreadCapacity(botId) || Boolean(activeGroupTurnForBot(botId))
6596
6726
  || parksBehindCoordination(botId, threadId));
6727
+ // Asides always drain after person follow-ups (see drainAsideLane): the
6728
+ // steer drain above can make a thread busy again, deferring its asides
6729
+ // to the next boundary. Every path that clears a thread's send queue
6730
+ // reaches this point, so the aside lane drains on all of them — not
6731
+ // just turn.completed.
6732
+ drainAsideLane();
6733
+ }
6734
+ /** The aside lane's boundary pass. Invoked at the end of drainQueuedSends, i.e. AFTER the steer drain on purpose:
6735
+ * a thread with both waiting starts its person follow-up first, and that
6736
+ * turn makes the thread busy again, so its asides defer to the next
6737
+ * boundary — the person's correction always lands ahead of peer context.
6738
+ * A thread already running a seam-capable turn gets its asides folded in
6739
+ * mid-turn instead (one batched call); an idle thread degrades them to a
6740
+ * single enveloped follow-up turn, exactly the shape the steer-queue
6741
+ * gives queued person messages. */
6742
+ function drainAsideLane() {
6743
+ if (!followupsReady)
6744
+ return;
6745
+ void drainAsideMessages({
6746
+ store,
6747
+ inject: async (botId, threadId, prompt) => {
6748
+ const bot = store.projectBotForTask(botId, threadId) ?? store.bot(botId);
6749
+ if (!bot?.busy)
6750
+ return "refused";
6751
+ const instance = runningTurnInstance(bot, threadId);
6752
+ if (!instance?.adapter.capabilities.queueing || !instance.adapter.steer)
6753
+ return "refused";
6754
+ return instance.adapter.steer(threadId, prompt).catch(() => "indeterminate");
6755
+ },
6756
+ run: (botId, threadId, prompt, userMessage, excludeIds, head) => new Promise((resolve, reject) => {
6757
+ void startTurn(botId, prompt, {
6758
+ threadId,
6759
+ userMessage,
6760
+ excludeMessageIds: excludeIds,
6761
+ commsDepth: head.aside.commsDepth + 1,
6762
+ unattended: head.aside.unattended === true,
6763
+ peerAsk: {
6764
+ botId: head.aside.fromBotId,
6765
+ name: head.aside.fromBotName,
6766
+ ...(head.aside.unattended ? { unattended: true } : {}),
6767
+ },
6768
+ onTurnSettled: resolve,
6769
+ }).catch((err) => {
6770
+ store.appendMessage(threadId, {
6771
+ role: "bot", kind: "activity",
6772
+ tool: {
6773
+ name: `error: queued aside could not start — ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`,
6774
+ ok: false,
6775
+ },
6776
+ });
6777
+ resolve();
6778
+ }).catch(reject);
6779
+ }),
6780
+ isBlocked: (botId, threadId) => threadBusy(botId, threadId) || botAtThreadCapacity(botId) || Boolean(activeGroupTurnForBot(botId))
6781
+ || parksBehindCoordination(botId, threadId),
6782
+ }).catch((error) => console.warn("aside-queue: drain failed", error));
6597
6783
  }
6598
6784
  /** Keep a person's words off the transcript until a direct-thread slot is
6599
6785
  * available. Reuse the existing cancellable, idempotent composer queue. */
6600
6786
  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;
6787
+ const decision = admit("direct", {}, {
6788
+ // A room turn holds the bot exactly like the sibling opened-thread queue
6789
+ // below: the drain's own block check waits it out, so the words queue
6790
+ // here rather than bounce off startTurn's 409.
6791
+ atCapacity: botAtThreadCapacity(botId),
6792
+ threadBusy: threadBusy(botId, threadId),
6793
+ groupTurn: Boolean(activeGroupTurnForBot(botId)),
6794
+ parksBehindCoordination: parksBehindCoordination(botId, threadId),
6795
+ });
6796
+ if (decision.action === "queue") {
6608
6797
  const queued = queueSteeredMessage(botId, threadId, text, {
6609
6798
  replyToId: replyTo?.id,
6610
6799
  sendId,
6611
- reason,
6800
+ reason: decision.reason,
6612
6801
  prompt: promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"),
6613
6802
  sender,
6614
6803
  trigger,
6615
6804
  });
6616
- return { ok: true, queued: true, queueId: queued.id, threadId, reason };
6805
+ return { ok: true, queued: true, queueId: queued.id, threadId, reason: decision.reason };
6617
6806
  }
6618
6807
  const message = await startTurn(botId, text, { threadId, replyTo, sendId, sender, trigger });
6619
6808
  return { ok: true, threadId, message };
@@ -6628,14 +6817,16 @@ const MAX_THREADS_OPENED_PER_TURN = 5;
6628
6817
  * bot's own request, not a new human request authorizing recursive fan-out. */
6629
6818
  async function startOrQueueOpenedThread(botId, threadId, text, unattended) {
6630
6819
  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) {
6820
+ const decision = admit("opened-thread", {}, {
6821
+ // A room turn holds the bot too (startTurn refuses a direct turn during
6822
+ // one); the drain's own block check already waits for it, so the words
6823
+ // queue here rather than bounce.
6824
+ atCapacity: botAtThreadCapacity(botId),
6825
+ groupTurn: Boolean(activeGroupTurnForBot(botId)),
6826
+ });
6827
+ if (decision.action === "queue") {
6637
6828
  queueSteeredMessage(botId, threadId, text, {
6638
- reason: capacity ? "capacity" : "group-turn",
6829
+ reason: decision.reason,
6639
6830
  unattended,
6640
6831
  peerAsk,
6641
6832
  });
@@ -6978,6 +7169,9 @@ async function startTurn(botId, text, opts) {
6978
7169
  sender: opts?.sender,
6979
7170
  });
6980
7171
  }
7172
+ const recoveryUserMessageId = opts?.coordination
7173
+ ? store.activePath(threadId).findLast(m => m.role === "user" && m.kind === "text")?.id
7174
+ : userMessage.id;
6981
7175
  // Admitted: every refusal above has passed and no other turn runs on this
6982
7176
  // thread, so this is the one moment the ledger's "who asked" may change.
6983
7177
  if (opts?.trigger)
@@ -7235,7 +7429,7 @@ async function startTurn(botId, text, opts) {
7235
7429
  // this engine can reach them — and only to a bot the user has not
7236
7430
  // switched off: the key is workspace-wide, the grant is per bot.
7237
7431
  if (bot.composio !== false && composio.configured(cfg) && instance.adapter.capabilities.composioMcp === true) {
7238
- const connection = await connectedAppsIntegration(bot.id, threadId, dispatchClaimId);
7432
+ const connection = await connectedAppsIntegration(bot, threadId, dispatchClaimId);
7239
7433
  if (connection)
7240
7434
  integrations.composio = connection;
7241
7435
  }
@@ -7293,7 +7487,6 @@ async function startTurn(botId, text, opts) {
7293
7487
  const teamComputer = inheritedTeamComputer(bot);
7294
7488
  const cloudBackend = teamComputer || opts?.runOn === "cloud" || bot.cloudBackend !== "vps" ? "box" : "vps";
7295
7489
  const mountsComputerMcp = instance.adapter.capabilities.computerMcp === true;
7296
- const mountsCloudComputer = instance.driverKind === "boxAgent" || instance.adapter.capabilities.cloudComputerMcp === true;
7297
7490
  const mountsLocalComputer = instance.adapter.capabilities.localComputerMcp === true;
7298
7491
  // Where this turn's hands may land. The bot's "Works on" choice is
7299
7492
  // strict; a browser-only bot gets no computer at all, and a bot whose
@@ -7307,7 +7500,7 @@ async function startTurn(botId, text, opts) {
7307
7500
  const dispatchTask = store.taskByThread(bot.id, threadId);
7308
7501
  if (plan.clearPin && dispatchTask)
7309
7502
  store.patchTask(bot.id, threadId, { surface: undefined });
7310
- if (plan.computer !== undefined && plan.computer !== "cloud" && instance.driverKind === "boxAgent") {
7503
+ if (plan.computer !== undefined && plan.computer !== "cloud" && instance.adapter.capabilities.remoteAgent === true) {
7311
7504
  throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
7312
7505
  }
7313
7506
  const wants = plan.computer;
@@ -7438,7 +7631,7 @@ async function startTurn(botId, text, opts) {
7438
7631
  // be recreated on demand after idling away — and never creates a first
7439
7632
  // VM on its own; every failure there is a quiet "not this place".
7440
7633
  const attachLocalVm = async (strict) => {
7441
- if (!mountsComputerMcp || instance.driverKind === "boxAgent") {
7634
+ if (!mountsComputerMcp || instance.adapter.capabilities.remoteAgent === true) {
7442
7635
  if (!strict)
7443
7636
  return false;
7444
7637
  throw new Error("this model engine cannot use the Local VM — choose Claude or an ACP engine, or select another computer destination");
@@ -7472,6 +7665,7 @@ async function startTurn(botId, text, opts) {
7472
7665
  owner: resourceOwner,
7473
7666
  lazy: true,
7474
7667
  label: "the Local VM",
7668
+ localVm: true,
7475
7669
  onRejected: surfaceLazyClaimRejection("the Local VM"),
7476
7670
  claim: async () => {
7477
7671
  await claimAutoLocalVm(threadId, localVmTarget);
@@ -7484,7 +7678,7 @@ async function startTurn(botId, text, opts) {
7484
7678
  // guard as dispatch: a poller started after its own
7485
7679
  // turn.completed would never be torn down.
7486
7680
  if (previewCapture && threadBusy(bot.id, threadId)) {
7487
- const touched = screenPollers.get(threadId)?.touched ?? instance.driverKind === "boxAgent";
7681
+ const touched = screenPollers.get(threadId)?.touched ?? instance.adapter.capabilities.remoteAgent === true;
7488
7682
  stopScreenPoller(bot.id, threadId);
7489
7683
  startScreenPoller(bot.id, threadId, { computer: previewCapture, ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: touched });
7490
7684
  }
@@ -7569,16 +7763,16 @@ async function startTurn(botId, text, opts) {
7569
7763
  // Cloud is strict when selected. Native Box and drivers with a Box
7570
7764
  // bridge may also reuse an already-ready Box on Auto.
7571
7765
  if (teamComputer) {
7572
- const attached = await attachTeamBox(teamComputer, bot.id, resourceOwner, mountsCloudComputer, instance.driverKind === "boxAgent");
7766
+ const attached = await attachTeamBox(teamComputer, bot.id, resourceOwner, instance.adapter.capabilities.usesCloudComputer === true, instance.adapter.capabilities.remoteAgent === true);
7573
7767
  integrations.computer = attached.integration;
7574
7768
  previewCapture = attached.capture;
7575
7769
  computerKind = "box";
7576
7770
  }
7577
- if (!teamComputer && mountsCloudComputer && (wants === "cloud" || wants === undefined) && cloudBackend === "box" && box.boxConfigured(cfg)) {
7771
+ if (!teamComputer && instance.adapter.capabilities.usesCloudComputer === true && (wants === "cloud" || wants === undefined) && cloudBackend === "box" && box.boxConfigured(cfg)) {
7578
7772
  const attached = await attachBotBox(bot, resourceOwner, {
7579
7773
  explicitCloud: wants === "cloud",
7580
- canMount: mountsCloudComputer,
7581
- remoteAgent: instance.driverKind === "boxAgent",
7774
+ canMount: instance.adapter.capabilities.usesCloudComputer === true,
7775
+ remoteAgent: instance.adapter.capabilities.remoteAgent === true,
7582
7776
  });
7583
7777
  if (attached) {
7584
7778
  previewCapture = attached.capture;
@@ -7765,15 +7959,12 @@ async function startTurn(botId, text, opts) {
7765
7959
  if (!markDirectTurnDispatching(bot.id, dispatchClaimId, threadId)) {
7766
7960
  throw new DirectTurnSetupCancelled("turn stopped before dispatch");
7767
7961
  }
7768
- const computerPromptKind = computerKind === "vm"
7769
- ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared"
7770
- : computerKind === "box"
7771
- ? instance.driverKind === "boxAgent" ? "box-agent" : instance.adapter.capabilities.cloudComputerMcp ? "box-chat" : "box"
7772
- : computerKind === "vps"
7773
- ? "vps"
7774
- : computerKind === "local"
7775
- ? "local"
7776
- : null;
7962
+ const computerPromptKind = resolveComputerPromptKind({
7963
+ kind: computerKind,
7964
+ driverKind: instance.driverKind,
7965
+ cloudComputerMcp: instance.adapter.capabilities.cloudComputerMcp,
7966
+ vmPrivate: localVmMode(cfg) === "per-bot",
7967
+ });
7777
7968
  const coordinationNode = opts?.coordination ? roomHandoffs.nodes.get(opts.coordination.id) : undefined;
7778
7969
  if (opts?.coordination && (!coordinationNode || coordinationNode.status !== "running" || roomHandoffProblem(coordinationNode, coordinationNode.parentId ? roomHandoffs.nodes.get(coordinationNode.parentId) : undefined))) {
7779
7970
  throw new DirectTurnSetupCancelled("Coordination access changed before dispatch");
@@ -7790,7 +7981,7 @@ async function startTurn(botId, text, opts) {
7790
7981
  { id: "plan", label: "Surface", text: surfacePrompt({ computer: mountedComputer, browser: Boolean(integrations.browser) }, { pinned: plan.pinned, note: plan.note, canSelect: computerSelectionTurns.has(threadId) }) },
7791
7982
  // gated on the integration, not the key: the hint only goes to a
7792
7983
  // bot whose driver actually mounted the tools
7793
- { id: "composio", label: "Connected apps", text: integrations.composio ? COMPOSIO_PROMPT : "" },
7984
+ { id: "composio", label: "Connected apps", text: integrations.composio ? composioSystemPrompt(liveBot?.connectorTools ?? bot.connectorTools) : "" },
7794
7985
  { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
7795
7986
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
7796
7987
  { id: "coordination", label: "Team", text: coordinationPrompt ? ` ${coordinationPrompt}` : "" },
@@ -7837,6 +8028,7 @@ async function startTurn(botId, text, opts) {
7837
8028
  const dispatch = await guardTurnDispatch(instance.adapter.sendTurn({
7838
8029
  threadId,
7839
8030
  botId: bot.id,
8031
+ startupRecovery: cfg.automaticRecovery?.enabled === true && !opts?.automaticRecoveryAttempted,
7840
8032
  text: dispatchContext.turnText,
7841
8033
  refreshSystemPrompt: true,
7842
8034
  images: turnImages,
@@ -7902,7 +8094,7 @@ async function startTurn(botId, text, opts) {
7902
8094
  // keep polling the box forever, carrying dead per-turn state. busy
7903
8095
  // is flipped false in the fold, so it is the honest "still running".
7904
8096
  if ((previewCapture || browserCapture) && threadBusy(bot.id, threadId)) {
7905
- startScreenPoller(bot.id, threadId, { ...(previewCapture ? { computer: previewCapture } : {}), ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: instance.driverKind === "boxAgent" });
8097
+ startScreenPoller(bot.id, threadId, { ...(previewCapture ? { computer: previewCapture } : {}), ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: instance.adapter.capabilities.remoteAgent === true });
7906
8098
  }
7907
8099
  // An adapter may publish completion synchronously just before its
7908
8100
  // dispatch promise resolves. The event could not use the turn-id map
@@ -7919,17 +8111,28 @@ async function startTurn(botId, text, opts) {
7919
8111
  drainDelegationWakes();
7920
8112
  }
7921
8113
  }
7922
- catch (e) {
8114
+ catch (cause) {
8115
+ let e = cause;
8116
+ // Only the driver can prove that a task was never submitted and its
8117
+ // old process is gone. A timeout/error string alone is not that proof.
8118
+ const mayRecover = e instanceof TurnNotStartedError &&
8119
+ directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId);
8120
+ if (e instanceof TurnNotStartedError) {
8121
+ retireProviderTurn(e.turnId);
8122
+ if (!mayRecover)
8123
+ e = new DirectTurnSetupCancelled("turn stopped during provider setup");
8124
+ }
7923
8125
  handoffs.abandon(threadId, dispatchClaimId);
7924
8126
  if (computerSelectionTurns.get(threadId)?.generation === dispatchClaimId)
7925
8127
  computerSelectionTurns.delete(threadId);
7926
- settleDirectFollowup(dispatchClaimId);
7927
8128
  clearCancelledProviderHandshake(threadId, `direct:${dispatchClaimId}`);
7928
8129
  clearDirectTurnDispatch(threadId, dispatchClaimId);
7929
8130
  revokeInternalCapabilityGeneration(threadId, dispatchClaimId);
7930
8131
  const ownsLatestGeneration = directTurnGenerationByThread.get(threadId) === dispatchClaimId;
7931
8132
  releaseTurnResources(resourceOwner);
7932
8133
  if (ownsLatestGeneration) {
8134
+ runningTurnEngines.delete(threadId);
8135
+ endMemoryTurn(threadId);
7933
8136
  clearTurnDigestState(threadId);
7934
8137
  releaseLocalVmThread(threadId);
7935
8138
  vpsThreadEnded(bot.id, threadId);
@@ -7938,6 +8141,7 @@ async function startTurn(botId, text, opts) {
7938
8141
  turnContext.delete(threadId);
7939
8142
  }
7940
8143
  if (e instanceof DirectTurnSetupCancelled) {
8144
+ settleDirectFollowup(dispatchClaimId, { ok: false, text: e.message });
7941
8145
  opts?.onDispatchError?.(e.message);
7942
8146
  if (ownsLatestGeneration && threadBusy(bot.id, threadId)) {
7943
8147
  store.setTaskActivity(bot.id, threadId, "idle");
@@ -7953,9 +8157,52 @@ async function startTurn(botId, text, opts) {
7953
8157
  }
7954
8158
  return;
7955
8159
  }
7956
- if (!ownsLatestGeneration)
8160
+ if (!ownsLatestGeneration) {
8161
+ settleDirectFollowup(dispatchClaimId);
7957
8162
  return;
7958
- const message = e instanceof Error ? e.message : String(e);
8163
+ }
8164
+ let message = e instanceof Error ? e.message : String(e);
8165
+ const backup = cfg.automaticRecovery?.enabled ? cfg.automaticRecovery.backup : undefined;
8166
+ const current = store.projectBotForTask(bot.id, threadId);
8167
+ if (mayRecover && backup && current && !opts?.automaticRecoveryAttempted && (!opts?.cardContinuation || opts.coordination) &&
8168
+ !directRequestOwners.get(threadId)?.stopped && !hasQueuedSteeredMessages(bot.id, threadId) &&
8169
+ JSON.stringify(current.modelSelection) === JSON.stringify(bot.modelSelection) &&
8170
+ (backup.instanceId !== instanceId || backup.model !== model) &&
8171
+ store.activePath(threadId).findLast(m => m.role === "user" && m.kind === "text")?.id === recoveryUserMessageId) {
8172
+ // No await between releasing the failed attempt and admitting its
8173
+ // replacement: a newer send or Stop must never lose to a stale retry.
8174
+ store.setTaskActivity(bot.id, threadId, "idle");
8175
+ directTurnBots.delete(threadId);
8176
+ const checked = checkedTaskModelSwitch(current, backup, false, false, true);
8177
+ const target = checked.ok ? registry.get(checked.selection.instanceId) : null;
8178
+ const refusal = !checked.ok ? checked.error : !target || !target.enabled ? "The backup engine is unavailable."
8179
+ : !store.taskByThread(bot.id, threadId)?.cwd ? "This older thread has no pinned working folder. Choose its backup manually."
8180
+ : policyModelRefusal(target) ?? recoveryCapabilityError({ driverKind: instance.driverKind, capabilities: instance.adapter.capabilities }, { driverKind: target.driverKind, capabilities: target.adapter.capabilities });
8181
+ if (!refusal && checked.ok && target) {
8182
+ try {
8183
+ assertWithinBudget(cfg, DATA_DIR);
8184
+ store.switchTaskModel(bot.id, threadId, checked.selection, false, false, {
8185
+ ...hostedModels?.resetTask(current.modelSelection, checked.selection), rewound: true,
8186
+ });
8187
+ store.appendMessage(threadId, { role: "bot", kind: "activity", tool: {
8188
+ name: `recovery: Automatic recovery: ${instance.displayName || instance.driverKind} could not start. Trying ${target.displayName || target.driverKind} · ${checked.selection.model} once in this thread.`, ok: true,
8189
+ } });
8190
+ await startTurn(bot.id, text, { ...opts, threadId, userMessage, editedMessageId: undefined,
8191
+ automaticRecoveryAttempted: true });
8192
+ // The backup now owns these callbacks; don't publish a failed
8193
+ // routine/peer result or release a queued receipt in between.
8194
+ directFollowupSettlers.delete(dispatchClaimId);
8195
+ directCoordinationSettlers.delete(dispatchClaimId);
8196
+ return;
8197
+ }
8198
+ catch (failure) {
8199
+ message += ` Backup could not start: ${failure instanceof Error ? failure.message : String(failure)}`;
8200
+ }
8201
+ }
8202
+ else if (refusal)
8203
+ message = `Automatic recovery unavailable: ${refusal} Original error: ${message}`;
8204
+ }
8205
+ settleDirectFollowup(dispatchClaimId, { ok: false, text: message });
7959
8206
  // The wait already wrote its failure resolution; keep all dispatch
7960
8207
  // failure bookkeeping below without adding the same error twice.
7961
8208
  if (!(e instanceof ComputerWaitGaveUp))
@@ -8622,22 +8869,89 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
8622
8869
  releasePhoneSecretMutation();
8623
8870
  }
8624
8871
  }
8872
+ /** Per-driver capability facts behind engine-switch warnings on proposal
8873
+ * cards. A live registry instance decides; a shadow or unknown instance
8874
+ * warns nothing rather than guessing from a stale catalog. */
8875
+ const driverCapabilitiesFor = (instanceId) => {
8876
+ const instance = registry.get(instanceId);
8877
+ return instance ? { driverKind: instance.driverKind, agentsMcp: instance.adapter.capabilities.agentsMcp === true } : undefined;
8878
+ };
8879
+ /** A Chief may target one section peer; anyone else only itself. Shared by
8880
+ * profile and default-model proposals, and re-checked at confirm. */
8881
+ const chiefPeerTargetRule = (noun) => (proposerBotId, targetBotId) => {
8882
+ const proposer = store.bot(proposerBotId);
8883
+ const target = store.bot(targetBotId);
8884
+ if (!target)
8885
+ return "that bot no longer exists";
8886
+ if (!proposer?.chiefOfStaff)
8887
+ return "only a section's Chief of Staff can change another bot's " + noun;
8888
+ if (!canReachPeer(proposer, target))
8889
+ return "that bot is not in a team this Chief is allowed to manage";
8890
+ return null;
8891
+ };
8892
+ /** Shared model validation for team-setup cards and propose_model: the
8893
+ * selection must resolve, and the destination driver must still support the
8894
+ * bot's existing approval setup. */
8895
+ const validateModelProposal = (selection, current) => {
8896
+ const checked = checkedModelSelection(selection, undefined, true);
8897
+ if (!checked.ok)
8898
+ return checked.error;
8899
+ if (current?.approvalGrant)
8900
+ return "Wait for the approval-level confirmation before changing this bot's model";
8901
+ if (current) {
8902
+ const mode = approvalModeFor(current);
8903
+ const driver = registry.cliTarget(selection.instanceId)?.driverKind;
8904
+ if (!supportsApprovalMode(selection, mode) || ((mode === "full" || mode === "custom") && driver !== registry.cliTarget(current.modelSelection.instanceId)?.driverKind)) {
8905
+ return `@${current.name}'s existing permissions are incompatible with that provider. Change its permissions in bot settings, then propose the model change again.`;
8906
+ }
8907
+ }
8908
+ return null;
8909
+ };
8625
8910
  const profileRequests = new ProfileRequestService({
8626
8911
  store,
8627
8912
  autoApply: fullAccessForSource,
8628
8913
  canPersist: proposalPersistence,
8629
8914
  // A Chief may change a section peer; anyone else only itself. Re-checked at confirm.
8915
+ validateTarget: chiefPeerTargetRule("profile"),
8916
+ });
8917
+ const modelRequests = new ModelRequestService({
8918
+ store,
8919
+ autoApply: fullAccessForSource,
8920
+ canPersist: proposalPersistence,
8921
+ // Same authority rule as profile proposals: a Chief may name one section peer.
8922
+ validateTarget: chiefPeerTargetRule("default model"),
8923
+ validateModel: validateModelProposal,
8924
+ driverCapabilities: driverCapabilitiesFor,
8925
+ });
8926
+ const tighteningRequests = new TighteningRequestService({
8927
+ store,
8928
+ autoApply: fullAccessForSource,
8929
+ canPersist: proposalPersistence,
8930
+ // Same reach as a profile change: a Chief may tighten a section peer;
8931
+ // anyone else only itself. Re-checked at confirm.
8630
8932
  validateTarget: (proposerBotId, targetBotId) => {
8631
8933
  const proposer = store.bot(proposerBotId);
8632
8934
  const target = store.bot(targetBotId);
8633
8935
  if (!target)
8634
8936
  return "that bot no longer exists";
8635
8937
  if (!proposer?.chiefOfStaff)
8636
- return "only a section's Chief of Staff can change another bot's profile";
8938
+ return "only a section's Chief of Staff can change another bot's permissions";
8637
8939
  if (!canReachPeer(proposer, target))
8638
8940
  return "that bot is not in a team this Chief is allowed to manage";
8639
8941
  return null;
8640
8942
  },
8943
+ // The card judges the effective mounts, so the snapshot resolves the same
8944
+ // way the engine does: through the config-aware custom-server filter.
8945
+ mountedMcpServers: (bot) => Object.keys(engineMcpServers(bot)),
8946
+ enabledSkills: (botId) => listSkills(botId).filter((skill) => skill.enabled).map((skill) => skill.name),
8947
+ disableSkill: (botId, name) => {
8948
+ const disabled = setSkillEnabled(botId, name, false);
8949
+ return typeof disabled === "object" && "error" in disabled ? { ok: false, error: disabled.error } : { ok: true };
8950
+ },
8951
+ // The owner's own routes stop a busy bot before touching its approval
8952
+ // level or MCP mounts; the card asks for the same, except an emergency
8953
+ // full/custom → ask downgrade, which applies first and stops after.
8954
+ targetBusy: (botId) => Boolean(store.bot(botId)?.busy || activeGroupTurnForBot(botId)),
8641
8955
  });
8642
8956
  const teamSetupTeams = () => [...new Set(["", ...readSections(), ...store.bots.map((bot) => sectionKey(bot.section)), ...store.groups.map((group) => sectionKey(group.section))])];
8643
8957
  const teamSetupRequests = new TeamSetupRequestService({
@@ -8654,21 +8968,10 @@ const teamSetupRequests = new TeamSetupRequestService({
8654
8968
  [...directTurnDispatchClaims].some(([threadId, claim]) => threadId !== sourceThreadId && claim.botId === botId) ||
8655
8969
  Boolean(group && group.threadId !== sourceThreadId) || Boolean(run && run.threadId !== sourceThreadId);
8656
8970
  },
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
- },
8971
+ validateModel: validateModelProposal,
8972
+ // Engine-switch capability warnings come from the live registry, so a
8973
+ // card built while an instance was down never guesses.
8974
+ driverCapabilities: driverCapabilitiesFor,
8672
8975
  deleteBot: async (botId, revalidate, request) => {
8673
8976
  const result = await deleteBotWithLifecycle(botId, revalidate, request);
8674
8977
  if (result.status >= 400)
@@ -8753,7 +9056,7 @@ async function resolveAndSendTeamSetup(res, args, ownerReview) {
8753
9056
  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
9057
  return true;
8755
9058
  }
8756
- if (args.behavior === "allow" && !card.answered && !card.dismissed) {
9059
+ if (args.behavior === "allow" && !card.answered && !card.dismissed && !card.expired) {
8757
9060
  // Confirmed Chief setup can move bots without the ordinary PATCH route.
8758
9061
  // Keep that atomic Store operation behind the same shared-machine fence.
8759
9062
  for (const operation of card.teamSetupRequest.operations) {
@@ -8920,6 +9223,81 @@ function resolveAndSendProfile(res, args) {
8920
9223
  json(res, 200, { ok: true, outcome: "rejected" });
8921
9224
  return true;
8922
9225
  }
9226
+ function resolveAndSendModel(res, args) {
9227
+ const card = store.messagesFor(args.threadId).find((message) => message.card?.requestId === args.requestId && message.card.modelRequest)?.card;
9228
+ if (!card)
9229
+ return false;
9230
+ const result = modelRequests.resolve(args);
9231
+ if (!result.claimed)
9232
+ return false;
9233
+ if (result.state === "applied" || result.state === "denied") {
9234
+ appendDecision(DATA_DIR, {
9235
+ threadId: args.threadId, requestId: args.requestId, botId: args.botId, botName: args.botName,
9236
+ tool: "update_model", summary: card.subtitle,
9237
+ decision: result.state === "applied" ? "user-approved" : "user-denied", source: "user",
9238
+ });
9239
+ }
9240
+ if (result.state === "applied") {
9241
+ const target = store.bot(result.targetBotId);
9242
+ if (target)
9243
+ broadcast({ kind: "bot", bot: wireBot(target) });
9244
+ json(res, 200, {
9245
+ ok: true, outcome: "allowed-once",
9246
+ ...(result.settlementPending ? { settlementPending: true, message: result.message } : {}),
9247
+ });
9248
+ return true;
9249
+ }
9250
+ if (result.state === "invalid") {
9251
+ json(res, result.status, { error: result.error });
9252
+ return true;
9253
+ }
9254
+ if (result.state === "already_settled") {
9255
+ json(res, 200, { ok: true, outcome: result.behavior === "allow" ? "allowed-once" : "rejected", alreadySettled: true });
9256
+ return true;
9257
+ }
9258
+ json(res, 200, { ok: true, outcome: "rejected" });
9259
+ return true;
9260
+ }
9261
+ function resolveAndSendTightening(res, args) {
9262
+ const card = store.messagesFor(args.threadId).find((message) => message.card?.requestId === args.requestId && message.card.tighteningRequest)?.card;
9263
+ if (!card)
9264
+ return false;
9265
+ const result = tighteningRequests.resolve(args);
9266
+ if (!result.claimed)
9267
+ return false;
9268
+ if (result.state === "applied" || result.state === "denied") {
9269
+ appendDecision(DATA_DIR, {
9270
+ threadId: args.threadId, requestId: args.requestId, botId: args.botId, botName: args.botName,
9271
+ tool: "tighten_permissions", summary: card.subtitle,
9272
+ decision: result.state === "applied" ? "user-approved" : "user-denied", source: "user",
9273
+ });
9274
+ }
9275
+ if (result.state === "applied") {
9276
+ const target = store.bot(result.targetBotId);
9277
+ if (target)
9278
+ broadcast({ kind: "bot", bot: wireBot(target) });
9279
+ if (result.emergencyStop) {
9280
+ // Mirror the owner's own route: the fail-closed Ask is already
9281
+ // persisted, then the exact turn still holding the elevated
9282
+ // per-turn snapshot is stopped. The response never waits on it.
9283
+ void stopBotForEmergencyApprovalDowngrade(result.targetBotId).catch((error) => {
9284
+ 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.`);
9285
+ });
9286
+ }
9287
+ json(res, 200, { ok: true, outcome: "allowed-once", tighteningFields: result.fields });
9288
+ return true;
9289
+ }
9290
+ if (result.state === "invalid") {
9291
+ json(res, result.status, { error: result.error });
9292
+ return true;
9293
+ }
9294
+ if (result.state === "already_settled") {
9295
+ json(res, 200, { ok: true, outcome: result.behavior === "allow" ? "allowed-once" : "rejected", alreadySettled: true });
9296
+ return true;
9297
+ }
9298
+ json(res, 200, { ok: true, outcome: "rejected" });
9299
+ return true;
9300
+ }
8923
9301
  // Webhook definitions are independent from calendar schedules, but every
8924
9302
  // delivery joins the same RoutineManager queue. That keeps unattended work
8925
9303
  // ordered behind a busy MAUS and gives webhook runs the same durable receipts.
@@ -9182,7 +9560,7 @@ setupRetry = 0) {
9182
9560
  }
9183
9561
  try {
9184
9562
  if (bot.composio !== false && composio.configured(cfg) && instance.adapter.capabilities.composioMcp === true) {
9185
- const connection = await connectedAppsIntegration(bot.id, threadId, internalGeneration);
9563
+ const connection = await connectedAppsIntegration(bot, threadId, internalGeneration);
9186
9564
  if (connection)
9187
9565
  integrations.composio = connection;
9188
9566
  }
@@ -9342,7 +9720,7 @@ setupRetry = 0) {
9342
9720
  throw Object.assign(new Error(roomPlaceRefusal), { code: "managed_policy" });
9343
9721
  // The Computer engine runs on the Box; this computer has no tools it can
9344
9722
  // reach, exactly as a bot thread refuses it.
9345
- if (roomPlan.computer === "local" && instance.driverKind === "boxAgent") {
9723
+ if (roomPlan.computer === "local" && instance.adapter.capabilities.remoteAgent === true) {
9346
9724
  throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
9347
9725
  }
9348
9726
  // One place per room turn as well: a team computer reached on Auto means
@@ -9363,12 +9741,12 @@ setupRetry = 0) {
9363
9741
  return false;
9364
9742
  }
9365
9743
  if (roomTeamComputer) {
9366
- const attached = await attachTeamBox(roomTeamComputer, readyBot.id, resourceOwner, instance.driverKind === "boxAgent" || instance.adapter.capabilities.cloudComputerMcp === true, instance.driverKind === "boxAgent");
9744
+ const attached = await attachTeamBox(roomTeamComputer, readyBot.id, resourceOwner, instance.adapter.capabilities.usesCloudComputer === true, instance.adapter.capabilities.remoteAgent === true);
9367
9745
  if (isCancelled?.() || groupSpeakers.get(threadId) !== roomSpeaker ||
9368
9746
  activeInternalGenerationByThread.get(threadId) !== internalGeneration)
9369
9747
  return false;
9370
9748
  integrations.computer = attached.integration;
9371
- startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: instance.driverKind === "boxAgent" });
9749
+ startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: instance.adapter.capabilities.remoteAgent === true });
9372
9750
  }
9373
9751
  // The speaker's own explicit place, mounted exactly as its bot thread
9374
9752
  // mounts it. A channel has no conversation pin and no Auto fallback here:
@@ -9408,9 +9786,9 @@ setupRetry = 0) {
9408
9786
  else {
9409
9787
  if (!box.boxConfigured(cfg))
9410
9788
  throw new Error("Cloud box is not configured — add a Box API key or choose Local VM");
9411
- const remoteAgent = instance.driverKind === "boxAgent";
9789
+ const remoteAgent = instance.adapter.capabilities.remoteAgent === true;
9412
9790
  const attached = await attachBotBox(readyBot, resourceOwner, { explicitCloud: true,
9413
- canMount: remoteAgent || instance.adapter.capabilities.cloudComputerMcp === true, remoteAgent });
9791
+ canMount: instance.adapter.capabilities.usesCloudComputer === true, remoteAgent });
9414
9792
  if (!roomSetupIsCurrent())
9415
9793
  return false;
9416
9794
  if (!attached?.integration)
@@ -9424,7 +9802,7 @@ setupRetry = 0) {
9424
9802
  // Room and Goal turns use the speaker's desktop, never the coordinator's.
9425
9803
  // Claim the same lease as direct turns before asynchronous VM setup.
9426
9804
  if (readyBot.computer === "vm") {
9427
- if (instance.adapter.capabilities.computerMcp !== true || instance.driverKind === "boxAgent") {
9805
+ if (instance.adapter.capabilities.computerMcp !== true || instance.adapter.capabilities.remoteAgent === true) {
9428
9806
  throw new Error("this model engine cannot use the Local VM");
9429
9807
  }
9430
9808
  // A distinct identity fences cleanup even in shared mode on the same room thread.
@@ -9506,7 +9884,11 @@ setupRetry = 0) {
9506
9884
  : !roomContextHasCoordination ? `\n\n${orchestration.turnInstructions}`
9507
9885
  : 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
9886
  : "";
9509
- const text = `${roomContext}\n\n(Reply to the conversation above as ${bot.name}.)${learnBlock}${cardContinuation ? `\n\n${cardContinuation}` : ""}${coordinationReminder}`;
9887
+ // The live roster is dispatch-time data the transcript can never carry,
9888
+ // so it rides every fresh hand-off turn even when the request text above
9889
+ // was deduplicated away. Resumed turns bring their own summary instead.
9890
+ const liveRosterBlock = !orchestration?.liveRoster || orchestration.resumed ? "" : `\n\n${orchestration.liveRoster}`;
9891
+ const text = `${roomContext}\n\n(Reply to the conversation above as ${bot.name}.)${learnBlock}${cardContinuation ? `\n\n${cardContinuation}` : ""}${coordinationReminder}${liveRosterBlock}`;
9510
9892
  // same workspace + memory as a 1:1 turn — the room is a different
9511
9893
  // conversation, not a different bot
9512
9894
  const worksInWorkspace = supportsWorkspaceFiles(instance.driverKind);
@@ -9533,6 +9915,11 @@ setupRetry = 0) {
9533
9915
  // (#754) the room is told, once per source thread, rather than the
9534
9916
  // crossing being blocked.
9535
9917
  const recentLines = recentWork(recentWorkSources(bot), bot, { userName, currentThreadId: threadId });
9918
+ const roomComputerPromptKind = resolveComputerPromptKind({
9919
+ kind: roomTeamComputer || roomComputerKind === "box" ? "box" : roomVmTarget ? "vm" : roomComputerKind,
9920
+ driverKind: instance.driverKind, cloudComputerMcp: instance.adapter.capabilities.cloudComputerMcp,
9921
+ vmPrivate: localVmMode(cfg) === "per-bot",
9922
+ });
9536
9923
  {
9537
9924
  const crossing = claimRecallCrossings(threadId, recentLines.filter((line) => line.private).map((line) => line.threadId));
9538
9925
  if (crossing.count) {
@@ -9548,7 +9935,7 @@ setupRetry = 0) {
9548
9935
  { id: "user-profile", label: "About the user", text: userProfileSystemPrompt(cfg.profile) },
9549
9936
  { id: "files", label: "File locations", text: workspace ? workspaceLocationsPrompt(bot.id, cwd, readyBot.cwd) : "" },
9550
9937
  { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
9551
- { id: "computer", label: "Computer", text: computerPrompt(roomTeamComputer || roomComputerKind === "box" ? instance.driverKind === "boxAgent" ? "box-agent" : instance.adapter.capabilities.cloudComputerMcp ? "box-chat" : "box" : roomVmTarget ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared" : roomComputerKind) },
9938
+ { id: "computer", label: "Computer", text: computerPrompt(roomComputerPromptKind) },
9552
9939
  { id: "team-computer", label: "Team computer", text: teamComputerPrompt(roomTeamComputer) },
9553
9940
  { id: "plan", label: "Surface", text: surfacePrompt({ computer: roomTeamComputer ? "cloud" : roomVmTarget ? "vm" : surfaceOfComputerKind(roomComputerKind), browser: Boolean(integrations.browser) }, { note: roomPlan.note }) },
9554
9941
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
@@ -10468,6 +10855,44 @@ function resolveReplyTarget(threadId, value) {
10468
10855
  }
10469
10856
  const CONNECTOR_SLUG = /^[a-z0-9][a-z0-9_-]{0,80}$/;
10470
10857
  const pendingConnectorResumes = new Map();
10858
+ /** The host folders a bot's own files may be read from in one conversation:
10859
+ * the conversation's pinned or configured working folder, the bot's own
10860
+ * workspace, and the private attachment store. */
10861
+ function messageFileRootsForThread(senderId, threadId) {
10862
+ const directBot = store.botByThread(threadId);
10863
+ const group = directBot ? undefined : store.groupByThread(threadId);
10864
+ let pinnedCwd;
10865
+ let configuredCwd;
10866
+ if (directBot) {
10867
+ pinnedCwd = store.taskByThread(directBot.id, threadId)?.cwd;
10868
+ configuredCwd = directBot.cwd;
10869
+ }
10870
+ else if (group) {
10871
+ const task = store.groupTaskByThread(group.id, threadId);
10872
+ pinnedCwd = task ? task.pinnedCwd : group.threadId === threadId ? group.pinnedCwd : undefined;
10873
+ configuredCwd = group.cwd;
10874
+ }
10875
+ return messageFileRoots({
10876
+ senderWorkspace: workspaceDir(senderId),
10877
+ attachments: ATTACHMENTS_DIR,
10878
+ pinnedCwd,
10879
+ configuredCwd,
10880
+ });
10881
+ }
10882
+ async function attachmentVmForTurn(capability) {
10883
+ const slot = autoVmClaims.get(capability.threadId);
10884
+ if (!localVmThreadTargets.has(capability.threadId) &&
10885
+ !(slot?.localVm && slot.owner.generation === capability.generation))
10886
+ return undefined;
10887
+ const gate = await computerCallGate(capability);
10888
+ if (gate.held)
10889
+ throw Object.assign(new Error(gate.blockedReason ?? "The person has control of this computer. Wait for them to release it."), { status: 409 });
10890
+ const vm = localVmThreadTargets.get(capability.threadId);
10891
+ if (!vm || !internalCapabilityIsActive({ ...capability, localVmTarget: vm })) {
10892
+ throw Object.assign(new Error("This turn no longer owns the Local VM."), { status: 409 });
10893
+ }
10894
+ return vm;
10895
+ }
10471
10896
  function connectorThread(botId, threadId) {
10472
10897
  const bot = store.bot(botId);
10473
10898
  if (!bot)
@@ -10558,11 +10983,11 @@ function proposalPersistence(botId, threadId) {
10558
10983
  return { ok: true };
10559
10984
  // Only cards on the visible branch can be acted on from the composer.
10560
10985
  // 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) &&
10986
+ // Routine, profile, default-model, tightening and team-setup proposals
10987
+ // share one budget per bot per thread, so one thread cannot pile up 8 of each.
10988
+ 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
10989
  !message.card.answered &&
10565
- !message.card.dismissed).length;
10990
+ !message.card.dismissed && !message.card.expired).length;
10566
10991
  return openRequests >= 8
10567
10992
  ? { ok: false, status: 429, error: "confirm or cancel an existing proposal first" }
10568
10993
  : { ok: true };
@@ -11033,6 +11458,8 @@ async function provideSecretFromPhone(context, authenticatedDeviceId) {
11033
11458
  throw new PhoneSecretError("No such credential request", 404);
11034
11459
  if (message.secret.dismissed)
11035
11460
  throw new PhoneSecretError("This credential request was dismissed", 409);
11461
+ if (message.secret.superseded)
11462
+ throw new PhoneSecretError("This credential request was superseded by a newer one", 409);
11036
11463
  assertPhoneSecretRequestMatches(context, authenticatedDeviceId, {
11037
11464
  target: message.secret.target,
11038
11465
  requestKey: message.secret.requestKey,
@@ -11090,6 +11517,12 @@ async function provideSecretFromPhone(context, authenticatedDeviceId) {
11090
11517
  if (current.secret.dismissed) {
11091
11518
  throw new PhoneSecretError("This credential request was dismissed", 409);
11092
11519
  }
11520
+ // The request route may have superseded this card while the encrypted
11521
+ // save was in flight. Completing the superseded card would mark it
11522
+ // provided and dispatch its continuation, leaving two cards resolved.
11523
+ if (current.secret.superseded) {
11524
+ throw new PhoneSecretError("This credential request was superseded by a newer one", 409);
11525
+ }
11093
11526
  // Electron acknowledges only after credentials.bin and the server's
11094
11527
  // external-secret config update both commit. Keep this assertion at the
11095
11528
  // boundary so a future parent handler cannot accidentally resume first.
@@ -11354,6 +11787,7 @@ function configStatus() {
11354
11787
  // not a secret — the settings picker shows it; "" = follow the system
11355
11788
  language: cfg.language ?? "",
11356
11789
  rooms: { turnTimeoutMinutes: roomTurnTimeoutMinutes(cfg) },
11790
+ automaticRecovery: cfg.automaticRecovery ?? { enabled: false },
11357
11791
  // absent effort = no level is sent, so clients can tell it from any level
11358
11792
  newBots: cfg.newBots?.effort ? { effort: cfg.newBots.effort } : {},
11359
11793
  threads: {
@@ -11747,6 +12181,7 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
11747
12181
  await Promise.all([flushAllProfileHistory(), flushAllMemoryJournals(), flushUsageLedger(DATA_DIR), flushDecisionLog(DATA_DIR), flushAdminActivity(DATA_DIR)]);
11748
12182
  // With writers gated and work idle, release our WAL connection for the
11749
12183
  // consistent snapshot. Store reopens it lazily after maintenance.
12184
+ await closeMessageSearch();
11750
12185
  closeMessageDb();
11751
12186
  },
11752
12187
  }, keepLocked),
@@ -11754,7 +12189,10 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
11754
12189
  // Route modules (server/routes/README.md). `workspaceAccess` is assigned at
11755
12190
  // boot, after this line, so the dependency reads it per request.
11756
12191
  ROUTES.push(createHostedSlackRoutes({ bot: (id) => store.bot(id), hostedReady: () => Boolean(workspaceAccess) && entitled("admin") }));
11757
- ROUTES.push(createBotPresetRoutes({ presets: presetStore, orgStatuses: readOrgInstallStatuses }));
12192
+ // Install statuses come from the organization library's own state, never
12193
+ // its file, so New bot cannot disagree with it. No organization: none.
12194
+ const orgInstallStatuses = () => orgLibrary?.installStatuses() ?? new Map();
12195
+ ROUTES.push(createBotPresetRoutes({ presets: presetStore, orgStatuses: orgInstallStatuses }));
11758
12196
  const toolResults = new ToolResults();
11759
12197
  const handleRequest = async (req, res) => {
11760
12198
  let url;
@@ -12362,6 +12800,22 @@ const handleRequest = async (req, res) => {
12362
12800
  const result = toolResults.read(internalCapability, id, offset);
12363
12801
  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
12802
  }
12803
+ if (method === "POST" && path === "/api/internal/options-card") {
12804
+ if (!connectorThread(internalSender.id, internalCapability.threadId)) {
12805
+ return json(res, 403, { error: "source conversation does not belong to sender" });
12806
+ }
12807
+ const body = await readInternalBody();
12808
+ requireActiveInternalCapability();
12809
+ const result = createOptionsCard({
12810
+ store,
12811
+ bot: internalSender,
12812
+ threadId: internalCapability.threadId,
12813
+ input: body,
12814
+ });
12815
+ return result.ok
12816
+ ? json(res, 201, { messageId: result.messageId })
12817
+ : json(res, result.status, { error: result.error });
12818
+ }
12365
12819
  // Notes written from a room fewer people can see than this bot would
12366
12820
  // carry that room's words to everyone who can see the bot.
12367
12821
  if ((path === "/api/internal/memory" || path === "/api/internal/memory/log") && method === "POST") {
@@ -12548,6 +13002,94 @@ const handleRequest = async (req, res) => {
12548
13002
  store.setTaskClosedBy(owner.id, threadId, { botId: from.id, name: from.name, at: Date.now() });
12549
13003
  return json(res, 200, { closed: true, threadId, title: task.title, botName: owner.name });
12550
13004
  }
13005
+ if (method === "POST" && path === "/api/internal/attach-file") {
13006
+ const from = internalSender;
13007
+ const threadId = internalCapability.threadId;
13008
+ const source = connectorThread(from.id, threadId);
13009
+ if (!source)
13010
+ return json(res, 403, { error: "source conversation does not belong to sender" });
13011
+ const body = await readInternalBody();
13012
+ const requestedPath = typeof body.path === "string" ? body.path : "";
13013
+ const requestedName = typeof body.name === "string" ? body.name : undefined;
13014
+ try {
13015
+ const vm = await attachmentVmForTurn(internalCapability);
13016
+ const outcome = await attachForTurn(internalCapability, MAX_ATTACHED_FILES_PER_TURN, {
13017
+ save: () => {
13018
+ // The agents capability carries no VM; the thread's claimed desktop does.
13019
+ return saveBotAttachment({
13020
+ path: requestedPath,
13021
+ name: requestedName,
13022
+ // The attachment store is not a source: only the bot's own files are.
13023
+ roots: messageFileRootsForThread(from.id, threadId).filter((root) => root !== ATTACHMENTS_DIR),
13024
+ ...(vm ? { guest: { root: VM_WORKSPACE_GUEST, host: vm.workspaceDir } } : {}),
13025
+ });
13026
+ },
13027
+ // The copy is awaited: the turn may have been stopped, replaced or
13028
+ // deleted, or the bot removed from the room, before it finished.
13029
+ stillLive: () => internalCapabilityIsActive({ ...internalCapability, ...(vm ? { localVmTarget: vm } : {}) }) &&
13030
+ connectorThread(from.id, threadId) !== null,
13031
+ publish: (saved) => {
13032
+ store.appendMessage(threadId, {
13033
+ role: "bot",
13034
+ kind: "text",
13035
+ text: "",
13036
+ attachments: [saved.attachment],
13037
+ ...(source.group ? { from: { botId: from.id, name: from.name, color: from.color } } : {}),
13038
+ });
13039
+ },
13040
+ // Each save gets its own stored file, so an unpublished one is ours to remove.
13041
+ discard: (saved) => deleteAttachment(saved.attachment.path),
13042
+ });
13043
+ if (outcome.status === "limit") {
13044
+ 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.` });
13045
+ }
13046
+ if (outcome.status === "ended") {
13047
+ return json(res, 409, { error: "This turn ended before the file could be attached, so nothing was posted." });
13048
+ }
13049
+ const { saved } = outcome;
13050
+ const label = saved.attachment.kind === "file"
13051
+ ? saved.attachment.name
13052
+ : (requestedName?.trim() || requestedPath.split(/[\\/]/).at(-1) || "image");
13053
+ return json(res, 200, { ok: true, name: label, bytes: saved.bytes });
13054
+ }
13055
+ catch (error) {
13056
+ const status = typeof error.status === "number" ? error.status : 500;
13057
+ const message = status === 404
13058
+ ? `No file was found at ${requestedPath}. Save it under /home/cua/workspace (or your working folder) and pass its exact path.`
13059
+ : error instanceof Error ? error.message : "could not attach that file";
13060
+ return json(res, status, { error: message });
13061
+ }
13062
+ }
13063
+ if (method === "POST" && path === "/api/internal/vm-exec") {
13064
+ const threadId = internalCapability.threadId;
13065
+ if (!connectorThread(internalSender.id, threadId)) {
13066
+ return json(res, 403, { error: "source conversation does not belong to sender" });
13067
+ }
13068
+ const body = await readInternalBody();
13069
+ // Only the desktop this thread has claimed for the running turn; a bot
13070
+ // without one has nothing to run a command in.
13071
+ try {
13072
+ const vm = await attachmentVmForTurn(internalCapability);
13073
+ if (!vm)
13074
+ return json(res, 409, { error: "You have no Local VM desktop in this turn, so there is nowhere to run a command." });
13075
+ const runtime = (await containerRuntimeStatus()).runtime;
13076
+ requireActiveInternalCapability();
13077
+ if (!runtime || !internalCapabilityIsActive({ ...internalCapability, localVmTarget: vm }) ||
13078
+ botComputerControlSnapshot(internalSender.id).held) {
13079
+ return json(res, 409, { error: "The Local VM is no longer available to this turn." });
13080
+ }
13081
+ const result = await containerExec(vm, typeof body.command === "string" ? body.command : "", {
13082
+ runtime,
13083
+ timeoutSeconds: typeof body.timeout_seconds === "number" ? body.timeout_seconds : undefined,
13084
+ });
13085
+ localVmIdleFor(vm).touch();
13086
+ return json(res, 200, result);
13087
+ }
13088
+ catch (error) {
13089
+ const status = typeof error.status === "number" ? error.status : 500;
13090
+ return json(res, status, { error: error instanceof Error ? error.message : "could not run the command" });
13091
+ }
13092
+ }
12551
13093
  if (method === "GET" && path === "/api/internal/rooms") {
12552
13094
  const from = internalSender;
12553
13095
  const fromThreadId = internalCapability.threadId;
@@ -12693,6 +13235,71 @@ const handleRequest = async (req, res) => {
12693
13235
  });
12694
13236
  return json(res, 201, proposed);
12695
13237
  }
13238
+ if (method === "POST" && path === "/api/internal/model-requests") {
13239
+ const parsed = z.object({
13240
+ fromBotId: z.string().min(1).max(128),
13241
+ fromThreadId: z.string().min(1).max(128),
13242
+ forBotId: z.string().max(128).optional(),
13243
+ modelSelection: z.unknown(),
13244
+ reason: z.unknown(),
13245
+ }).strict().safeParse(await readInternalBody());
13246
+ if (!parsed.success)
13247
+ return json(res, 400, { error: "invalid model proposal" });
13248
+ const body = parsed.data;
13249
+ const from = store.bot(body.fromBotId);
13250
+ if (!from)
13251
+ return json(res, 403, { error: "unknown sender" });
13252
+ const owner = connectorThread(from.id, body.fromThreadId);
13253
+ if (!owner)
13254
+ return json(res, 403, { error: "source conversation does not belong to sender" });
13255
+ const proposed = modelRequests.submit({
13256
+ botId: from.id,
13257
+ threadId: body.fromThreadId,
13258
+ targetBotId: body.forBotId?.trim() || from.id,
13259
+ selection: body.modelSelection,
13260
+ reason: body.reason,
13261
+ from: owner.group ? { botId: from.id, name: from.name, color: from.color } : undefined,
13262
+ });
13263
+ appendDecision(DATA_DIR, {
13264
+ threadId: body.fromThreadId, requestId: proposed.requestId, botId: from.id, botName: from.name,
13265
+ tool: "update_model", summary: proposed.detail, decision: proposed.state === "applied" ? "auto-approved" : "card-shown",
13266
+ source: proposed.state === "applied" ? "full-access" : "model",
13267
+ });
13268
+ return json(res, 201, proposed);
13269
+ }
13270
+ if (method === "POST" && path === "/api/internal/tightening-requests") {
13271
+ const parsed = z.object({
13272
+ fromBotId: z.string().min(1).max(128),
13273
+ fromThreadId: z.string().min(1).max(128),
13274
+ forBotId: z.string().max(128).optional(),
13275
+ intents: z.unknown(),
13276
+ reason: z.unknown(),
13277
+ }).strict().safeParse(await readInternalBody());
13278
+ if (!parsed.success)
13279
+ return json(res, 400, { error: "invalid tightening proposal" });
13280
+ const body = parsed.data;
13281
+ const from = store.bot(body.fromBotId);
13282
+ if (!from)
13283
+ return json(res, 403, { error: "unknown sender" });
13284
+ const owner = connectorThread(from.id, body.fromThreadId);
13285
+ if (!owner)
13286
+ return json(res, 403, { error: "source conversation does not belong to sender" });
13287
+ const targetBotId = body.forBotId?.trim() || from.id;
13288
+ const proposed = tighteningRequests.submit({
13289
+ botId: from.id,
13290
+ threadId: body.fromThreadId,
13291
+ targetBotId,
13292
+ intents: body.intents,
13293
+ reason: body.reason,
13294
+ from: owner.group ? { botId: from.id, name: from.name, color: from.color } : undefined,
13295
+ });
13296
+ appendDecision(DATA_DIR, {
13297
+ threadId: body.fromThreadId, requestId: proposed.requestId, botId: from.id, botName: from.name,
13298
+ tool: "tighten_permissions", summary: proposed.detail, decision: proposed.state === "applied" ? "auto-approved" : "card-shown",
13299
+ source: proposed.state === "applied" ? "full-access" : "tightening",
13300
+ });
13301
+ return json(res, 201, proposed);
13302
+ }
12696
13303
  if (method === "GET" && path === "/api/internal/team-setup-catalog") {
12697
13304
  let chief = store.bot(internalCapability.botId);
12698
13305
  if (!chief.chiefOfStaff || chief.hidden)
@@ -12979,7 +13586,9 @@ const handleRequest = async (req, res) => {
12979
13586
  if (toBotRef === fromBotId)
12980
13587
  return json(res, 400, { error: "a bot cannot message itself" });
12981
13588
  if (depth >= MAX_COMMS_DEPTH)
12982
- return json(res, 200, { error: "message chains are limited to one hop" });
13589
+ return json(res, 200, { error: "message chains are limited to one hop", receipt: peerDeliveryReceipt({
13590
+ botId: toBotRef, outcome: "failed", detail: "message chains are limited to one hop — the message was not sent",
13591
+ }) });
12983
13592
  // A unique reachable teammate name is accepted where an id is
12984
13593
  // expected; see resolveTeammate for why.
12985
13594
  const resolvedTo = resolveTeammate(store.bots, internalSender, toBotRef);
@@ -13024,17 +13633,70 @@ const handleRequest = async (req, res) => {
13024
13633
  const queueBusyFallback = (approvalAlreadyGranted = false) => {
13025
13634
  const queued = queueDelegation(commsBus, from, { toBotId, message, reason: "asked while busy", depth, approvalAlreadyGranted }, MAX_COMMS_DEPTH, fromThreadId);
13026
13635
  if (queued.result !== "ok" || !queued.id)
13027
- return json(res, 200, { busy: true });
13636
+ return json(res, 200, { busy: true, receipt: peerDeliveryReceipt({
13637
+ botId: toBotId, botName: target.name, outcome: "failed",
13638
+ detail: "the teammate was busy and the delegation queue refused the fallback — the message was not delivered",
13639
+ }) });
13028
13640
  // An external caller has no source turn whose completion can
13029
13641
  // begin the busy wait. Drain now so the target's idle release
13030
13642
  // retries this handoff, retaining any approval already granted.
13031
13643
  if (internalCapability.generation === EXTERNAL_RUNTIME_GENERATION && !threadBusy(from.id, fromThreadId)) {
13032
13644
  drainThreadDelegations(fromThreadId);
13033
13645
  }
13034
- return json(res, 200, { busy: true, taskId: queued.id, toBotName: target.name });
13646
+ return json(res, 200, { busy: true, taskId: queued.id, toBotName: target.name, receipt: peerDeliveryReceipt({
13647
+ botId: toBotId, botName: target.name, outcome: "queued", taskId: queued.id,
13648
+ detail: "the teammate was busy; the ask was queued as a delegation instead",
13649
+ }) });
13035
13650
  };
13036
- if (target.busy)
13651
+ // The aside lane: this ask was addressed to the conversation
13652
+ // target.threadId holds, and that conversation is mid-turn. When
13653
+ // the engine running it exposes a mid-turn seam, the words fold
13654
+ // into the live turn as enveloped peer context — no interruption,
13655
+ // no new turn, and the sender learns exactly how the send landed.
13656
+ // A mid-turn injection is delivery, so it never routes through the
13657
+ // peer-review card: review gates turns a peer would start, and a
13658
+ // bot whose comms need approval keeps the delegation queue below.
13659
+ const tryAsideDelivery = async () => {
13660
+ if (peerReviewRequired(from, fromThreadId))
13661
+ return null;
13662
+ // Busy elsewhere (room turn, sibling thread): the thread this ask
13663
+ // targets is not the running one, so there is nothing to fold
13664
+ // into here — the queue lane keeps it.
13665
+ if (!threadBusy(toBotId, target.threadId))
13666
+ return null;
13667
+ const instance = runningTurnInstance(target, target.threadId);
13668
+ if (!instance?.adapter.capabilities.queueing || !instance.adapter.steer)
13669
+ return null;
13670
+ const steer = instance.adapter.steer;
13671
+ queueAsideMessage(toBotId, target.threadId, message, {
13672
+ fromBotId: from.id,
13673
+ fromBotName: from.name,
13674
+ unattended: isUnattended(from.id, fromThreadId),
13675
+ commsDepth: depth,
13676
+ });
13677
+ const attempt = await attemptAsideInjection(store, toBotId, target.threadId, (_botId, threadId, prompt) => steer(threadId, prompt).catch(() => "indeterminate"));
13678
+ if (!attempt)
13679
+ return null;
13680
+ return attempt.delivered
13681
+ ? {
13682
+ busy: true,
13683
+ aside: "injected",
13684
+ toBotName: target.name,
13685
+ 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.`,
13686
+ }
13687
+ : {
13688
+ busy: true,
13689
+ aside: "queued",
13690
+ toBotName: target.name,
13691
+ 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.`,
13692
+ };
13693
+ };
13694
+ if (target.busy) {
13695
+ const asideReceipt = await tryAsideDelivery();
13696
+ if (asideReceipt)
13697
+ return json(res, 200, asideReceipt);
13037
13698
  return queueBusyFallback();
13699
+ }
13038
13700
  let currentFrom = from;
13039
13701
  let currentTarget = target;
13040
13702
  // the exchange is mirrored into a bot⇄bot channel: it shows up in
@@ -13051,8 +13713,13 @@ const handleRequest = async (req, res) => {
13051
13713
  if (peerReviewRequired(from, fromThreadId)) {
13052
13714
  const verdict = await requestPeerApproval(approvalBus, from, target, message, "ask_bot", fromThreadId);
13053
13715
  requireActiveInternalCapability();
13054
- if (verdict !== "allow")
13055
- return json(res, 200, peerApprovalFailure(verdict));
13716
+ if (verdict !== "allow") {
13717
+ const failure = peerApprovalFailure(verdict);
13718
+ return json(res, 200, { ...failure, receipt: peerDeliveryReceipt({
13719
+ botId: toBotId, botName: target.name, outcome: "failed",
13720
+ detail: `peer review ${failure.error} — the message was not sent`,
13721
+ }) });
13722
+ }
13056
13723
  // The card may have been open for minutes. Re-read both records so
13057
13724
  // deleted bots cannot recreate transcripts through stale objects.
13058
13725
  const freshFrom = store.bot(fromBotId);
@@ -13060,10 +13727,17 @@ const handleRequest = async (req, res) => {
13060
13727
  if (!freshFrom || !freshTarget)
13061
13728
  return json(res, 404, { error: "no such bot" });
13062
13729
  if (!canAccessTeam(freshFrom, freshTarget.section) || freshTarget.hidden) {
13063
- return json(res, 200, { error: "that bot moved to a different section" });
13730
+ return json(res, 200, { error: "that bot moved to a different section", receipt: peerDeliveryReceipt({
13731
+ // Access no longer permits reading the peer's current profile.
13732
+ botId: toBotId, outcome: "failed",
13733
+ detail: "that bot moved to a different section — the message was not sent",
13734
+ }) });
13064
13735
  }
13065
13736
  if (!peerAllowed(freshFrom, freshTarget)) {
13066
- return json(res, 200, { error: "that bot is no longer an allowed peer" });
13737
+ return json(res, 200, { error: "that bot is no longer an allowed peer", receipt: peerDeliveryReceipt({
13738
+ botId: toBotId, outcome: "failed",
13739
+ detail: "that bot is no longer an allowed peer — the message was not sent",
13740
+ }) });
13067
13741
  }
13068
13742
  // Membership can be revoked while the card is open: re-check the
13069
13743
  // same way, so a bot removed from a room mid-approval cannot go on
@@ -13121,20 +13795,37 @@ const handleRequest = async (req, res) => {
13121
13795
  kind: "activity",
13122
13796
  tool: { name: `@${currentTarget.name} is still working — ask converted to a delegation` },
13123
13797
  });
13124
- return json(res, 200, { timeout: true, taskId, toBotName: currentTarget.name, waitedMs: ASK_BOT_TIMEOUT_MS });
13798
+ return json(res, 200, { timeout: true, taskId, toBotName: currentTarget.name, waitedMs: ASK_BOT_TIMEOUT_MS,
13799
+ receipt: peerDeliveryReceipt({ botId: toBotId, botName: currentTarget.name, outcome: "injected", taskId,
13800
+ detail: "the teammate's turn started and is still running; only the synchronous wait ended" }) });
13125
13801
  }
13126
13802
  if (outcome.status === "failed" && !outcome.text.trim()) {
13127
13803
  // No partial answer to hand back — mirror the failure where the
13128
13804
  // exchange lives, with the provider's reason instead of silence.
13129
13805
  const why = outcome.stopReason?.trim() ? ` — ${outcome.stopReason.trim().slice(0, 120)}` : "";
13130
13806
  mirrorActivity(commsBus, currentTarget, channel, `Turn failed${why}`, false);
13131
- return json(res, 200, { botName: currentTarget.name, text: `(the bot's turn failed${why})` });
13807
+ return json(res, 200, { botName: currentTarget.name, text: `(the bot's turn failed${why})`,
13808
+ receipt: peerDeliveryReceipt({ botId: toBotId, botName: currentTarget.name, outcome: "injected",
13809
+ detail: `the teammate's turn started and failed${why}` }) });
13132
13810
  }
13133
13811
  const reply = outcome.status === "timeout"
13134
13812
  ? outcome.text || "(timed out waiting for the bot to reply)"
13135
13813
  : outcome.text;
13136
13814
  mirrorReply(commsBus, currentTarget, reply, channel);
13137
- return json(res, 200, { botName: currentTarget.name, text: reply });
13815
+ // A dispatch error arrives as outcome "error" with the reason folded
13816
+ // into text — to the sender it reads like a reply from the peer. The
13817
+ // receipt is what keeps that honest: the turn never started, so the
13818
+ // message was never delivered.
13819
+ const dispatchFailed = outcome.status === "error";
13820
+ return json(res, 200, { botName: currentTarget.name, text: reply,
13821
+ receipt: peerDeliveryReceipt({ botId: toBotId, botName: currentTarget.name,
13822
+ outcome: dispatchFailed ? "failed" : "injected",
13823
+ detail: dispatchFailed
13824
+ ? "the teammate's turn could not start — the message was not delivered"
13825
+ : outcome.status === "timeout"
13826
+ ? "the teammate's turn started and is still running"
13827
+ : "the teammate's turn ran to completion",
13828
+ }) });
13138
13829
  }
13139
13830
  // Async handoff: the source bot queues a task for a peer and goes
13140
13831
  // back to the user; the peer turn runs after the source's
@@ -13296,7 +13987,11 @@ const handleRequest = async (req, res) => {
13296
13987
  no_target: "no such bot",
13297
13988
  too_many: "too many delegations queued on this turn — finish some first",
13298
13989
  };
13299
- return json(res, 200, { error: said[queued.result === "ok" ? "no_target" : queued.result] });
13990
+ const refusal = said[queued.result === "ok" ? "no_target" : queued.result];
13991
+ return json(res, 200, { error: refusal, receipt: peerDeliveryReceipt({
13992
+ botId: toBotId, botName: store.bot(toBotId)?.name, outcome: "failed",
13993
+ detail: `${refusal} — nothing was queued`,
13994
+ }) });
13300
13995
  }
13301
13996
  const targetName = store.bot(toBotId)?.name ?? toBotId;
13302
13997
  // The queue normally drains when the source thread's turn completes. A
@@ -13306,20 +14001,30 @@ const handleRequest = async (req, res) => {
13306
14001
  // is nothing to wait for: drain now.
13307
14002
  if (internalCapability.generation === EXTERNAL_RUNTIME_GENERATION && !threadBusy(from.id, fromThreadId)) {
13308
14003
  drainThreadDelegations(fromThreadId);
14004
+ const reviewed = peerReviewRequired(from, fromThreadId);
13309
14005
  return json(res, 200, {
13310
14006
  queued: true,
13311
14007
  taskId: queued.id,
13312
- message: peerReviewRequired(from, fromThreadId)
14008
+ message: reviewed
13313
14009
  ? `Queued for review — @${targetName} will pick it up once the user approves.`
13314
14010
  : `Delegated — @${targetName} is picking it up now.`,
14011
+ receipt: peerDeliveryReceipt({ botId: toBotId, botName: targetName, outcome: "queued", taskId: queued.id,
14012
+ detail: reviewed
14013
+ ? "the teammate's turn waits on the user's approval"
14014
+ : "the drain started; the teammate's turn has not started yet" }),
13315
14015
  });
13316
14016
  }
14017
+ const reviewed = peerReviewRequired(from, internalCapability.threadId);
13317
14018
  return json(res, 200, {
13318
14019
  queued: true,
13319
14020
  taskId: queued.id,
13320
- message: peerReviewRequired(from, internalCapability.threadId)
14021
+ message: reviewed
13321
14022
  ? `Queued for review — @${targetName} will only pick it up if the user approves after your turn finishes.`
13322
14023
  : `Delegation queued — @${targetName} will pick it up after your current turn finishes.`,
14024
+ receipt: peerDeliveryReceipt({ botId: toBotId, botName: targetName, outcome: "queued", taskId: queued.id,
14025
+ detail: reviewed
14026
+ ? "the teammate's turn waits on the user's approval after your turn finishes"
14027
+ : "the teammate's turn runs after your current turn finishes" }),
13323
14028
  });
13324
14029
  }
13325
14030
  if (path === "/api/internal/room-targets" || path === "/api/internal/coordinate-bots") {
@@ -13407,6 +14112,11 @@ const handleRequest = async (req, res) => {
13407
14112
  }
13408
14113
  const accepted = [];
13409
14114
  const errors = [];
14115
+ // One receipt per recipient, in the order the caller addressed
14116
+ // them. Dispatch is asynchronous (RoomHandoffs.tick), so a fresh
14117
+ // enqueue is honestly "queued"; a duplicate inherits the state of
14118
+ // the node that already carries this request_key.
14119
+ const receipts = [];
13410
14120
  for (const target of targets) {
13411
14121
  let createdThread;
13412
14122
  try {
@@ -13447,6 +14157,32 @@ const handleRequest = async (req, res) => {
13447
14157
  store.deleteTask(target.botId, createdThread);
13448
14158
  createdThread = undefined; // The durable coordinator now owns this task.
13449
14159
  accepted.push({ requestId: node.id, botId: node.botId, duplicate, status: node.status });
14160
+ // A duplicate request_key lands on the node enqueue already
14161
+ // made. Only a live node can still report: a failed or
14162
+ // cancelled one already fired its report and tick() skips it,
14163
+ // so promising "its result will resume you" would be the one
14164
+ // lie a receipt must never tell.
14165
+ receipts.push(peerDeliveryReceipt({
14166
+ botId: node.botId,
14167
+ botName: store.bot(node.botId)?.name,
14168
+ outcome: duplicate
14169
+ ? node.status === "running" || node.status === "completed"
14170
+ ? "injected"
14171
+ : node.status === "failed" || node.status === "cancelled"
14172
+ ? "failed"
14173
+ : "queued"
14174
+ : "queued",
14175
+ detail: duplicate
14176
+ ? node.status === "running"
14177
+ ? "duplicate request_key — this teammate is already running that exact assignment"
14178
+ : node.status === "completed"
14179
+ ? "duplicate request_key — this teammate already completed that assignment; the earlier result stands"
14180
+ : node.status === "failed" || node.status === "cancelled"
14181
+ ? `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`
14182
+ : "duplicate request_key — already in flight; its result will resume you automatically"
14183
+ : "handed to the coordinator; the teammate's turn has not started yet",
14184
+ requestId: node.id,
14185
+ }));
13450
14186
  if (!duplicate) {
13451
14187
  const recipient = store.bot(target.botId);
13452
14188
  store.appendMessage(address.threadId, { role: "bot", kind: "activity",
@@ -13460,10 +14196,14 @@ const handleRequest = async (req, res) => {
13460
14196
  catch (error) {
13461
14197
  if (createdThread)
13462
14198
  store.deleteTask(target.botId, createdThread);
13463
- errors.push({ botId: target.botId, error: error instanceof Error ? error.message : String(error) });
14199
+ const said = error instanceof Error ? error.message : String(error);
14200
+ errors.push({ botId: target.botId, error: said });
14201
+ receipts.push(peerDeliveryReceipt({
14202
+ botId: target.botId, botName: store.bot(target.botId)?.name, outcome: "failed", detail: said,
14203
+ }));
13464
14204
  }
13465
14205
  }
13466
- return json(res, accepted.length ? 200 : 409, { accepted, errors,
14206
+ return json(res, accepted.length ? 200 : 409, { accepted, errors, receipts,
13467
14207
  ...(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
14208
  });
13469
14209
  }
@@ -13492,6 +14232,10 @@ const handleRequest = async (req, res) => {
13492
14232
  return json(res, 403, { error: "source conversation does not belong to sender" });
13493
14233
  const groupId = String(body.groupId ?? "").trim();
13494
14234
  const message = String(body.message ?? "").trim();
14235
+ const attachVoiceNote = body.attachVoiceNote === true;
14236
+ if (body.attachVoiceNote !== undefined && typeof body.attachVoiceNote !== "boolean") {
14237
+ return json(res, 400, { error: "attachVoiceNote must be true or false" });
14238
+ }
13495
14239
  if (!groupId || !message) {
13496
14240
  return json(res, 400, { error: "post_to_room needs group_id (from list_rooms) and message" });
13497
14241
  }
@@ -13584,13 +14328,46 @@ const handleRequest = async (req, res) => {
13584
14328
  // here is also what keeps its window alive through a turn that only
13585
14329
  // posts — an aged-out mark would hand the next hop to auto-approve.
13586
14330
  const unattended = isUnattended(poster.id, internalCapability.threadId);
14331
+ // A room post is the one send path that bypasses the settling reply,
14332
+ // so an explicit attach flag — never a silent default — moves this
14333
+ // turn's parked voice note onto the posted message. The removal
14334
+ // matters twice over: the clip is delivered exactly once (the
14335
+ // settling reply no longer finds it parked), and it survives a turn
14336
+ // that dies after the post, because it is no longer parked cleanup's
14337
+ // to delete. The post text stands as the caption.
14338
+ let attachedVoiceNote = false;
14339
+ let stagedAttachments = [];
14340
+ let detachParkedAudio;
14341
+ if (attachVoiceNote) {
14342
+ const parkedKey = turnAttachmentKey(fromThreadId, liveTurnByThread.get(fromThreadId));
14343
+ const parked = turnAttachmentsByTurn.get(parkedKey) ?? [];
14344
+ const audio = parked.filter((attachment) => attachment.kind === "audio");
14345
+ if (!audio.length) {
14346
+ return json(res, 400, { error: "no voice note to attach — call send_voice_note in this turn first" });
14347
+ }
14348
+ stagedAttachments = audio.map((attachment) => ({
14349
+ kind: "audio",
14350
+ path: attachment.path,
14351
+ mime: attachment.mime,
14352
+ }));
14353
+ attachedVoiceNote = true;
14354
+ // The parked set is mutated only after the append below lands.
14355
+ // Everything between is synchronous, so nothing can interleave,
14356
+ // and a persistence failure must leave the clip parked: the post
14357
+ // can then be retried, whereas a premature removal strands the
14358
+ // clip on disk attached to nothing — and the catalog already told
14359
+ // the model never to retry a refused post.
14360
+ detachParkedAudio = () => turnAttachmentsByTurn.set(parkedKey, parked.filter((attachment) => attachment.kind !== "audio"));
14361
+ }
13587
14362
  const posted = store.appendMessage(room.threadId, {
13588
14363
  role: "bot",
13589
14364
  kind: "text",
13590
14365
  text: message,
13591
14366
  from: { botId: poster.id, name: poster.name, color: poster.color },
13592
14367
  peerPost: unattended ? { unattended: true } : {},
14368
+ ...(attachedVoiceNote ? { attachments: stagedAttachments } : {}),
13593
14369
  });
14370
+ detachParkedAudio?.();
13594
14371
  store.patchGroup(room.id, { unread: true });
13595
14372
  // The same visibility contract the peer tools keep: whatever a bot
13596
14373
  // does elsewhere shows up in the conversation it is actually in.
@@ -13607,7 +14384,7 @@ const handleRequest = async (req, res) => {
13607
14384
  if (owner.group)
13608
14385
  chip.from = { botId: poster.id, name: poster.name, color: poster.color };
13609
14386
  store.appendMessage(fromThreadId, chip);
13610
- return json(res, 201, { ok: true, messageId: posted.id, roomName: room.name });
14387
+ return json(res, 201, { ok: true, messageId: posted.id, roomName: room.name, attachedVoiceNote });
13611
14388
  }
13612
14389
  // start_thread: a bot opens a real thread — on itself for separate
13613
14390
  // work, or on a teammate as a handoff that should run on its own.
@@ -13791,6 +14568,15 @@ const handleRequest = async (req, res) => {
13791
14568
  }
13792
14569
  if (hostedModels && !hostedModels.allows(selection))
13793
14570
  return json(res, 400, { error: hostedModels.error() });
14571
+ // The same check the profile path runs: absolute, exists, is a
14572
+ // folder. Creation names where the specialist works; nothing looser.
14573
+ let cwd;
14574
+ if (body.cwd !== undefined) {
14575
+ const checkedCwd = validateBotCwd(body.cwd);
14576
+ if (!checkedCwd.ok)
14577
+ return json(res, 400, { error: checkedCwd.error });
14578
+ cwd = checkedCwd.cwd ?? undefined;
14579
+ }
13794
14580
  // Discovery can yield; check current authority and capacity again before writing.
13795
14581
  if (store.bot(chief.id) !== chief || chief.hidden || !chief.chiefOfStaff || !connectorThread(chief.id, fromThreadId)) {
13796
14582
  return json(res, 403, { error: "only an active Chief of Staff can create operator bots" });
@@ -13811,6 +14597,7 @@ const handleRequest = async (req, res) => {
13811
14597
  description: instructions,
13812
14598
  modelSelection: selection,
13813
14599
  section: chief.section,
14600
+ ...(cwd !== undefined ? { cwd } : {}),
13814
14601
  // exactly the Chief's audience: a restricted Chief never makes a bot everyone sees
13815
14602
  ...(chief.visibility ? { visibility: chief.visibility } : {}),
13816
14603
  }, { seedMessages: false });
@@ -13925,15 +14712,12 @@ const handleRequest = async (req, res) => {
13925
14712
  if (credentialIsConfigured(cfg, credentialId)) {
13926
14713
  return json(res, 200, { alreadyConfigured: true, label: target.label });
13927
14714
  }
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
- }
14715
+ // A later request supersedes every still-pending card for this
14716
+ // credential instead of silently reusing it: the reason or task has
14717
+ // moved on, so the old card stops offering entry and the person gets
14718
+ // one fresh card they can act on. The fresh card is appended FIRST:
14719
+ // if persistence then fails, the prior cards stay actionable rather
14720
+ // than every pending request ending terminal with no card to act on.
13937
14721
  const reason = typeof body.reason === "string" ? body.reason.trim().slice(0, 240) : "";
13938
14722
  const message = store.appendMessage(fromThreadId, {
13939
14723
  role: "bot",
@@ -13949,6 +14733,13 @@ const handleRequest = async (req, res) => {
13949
14733
  requestKey: randomUUID(),
13950
14734
  },
13951
14735
  });
14736
+ for (const prior of store.activePath(fromThreadId)) {
14737
+ if (prior.id === message.id)
14738
+ continue;
14739
+ if (!prior.secret || !isPendingCredentialRequest(prior, credentialId, from.id, Boolean(owner.group)))
14740
+ continue;
14741
+ store.patchMessage(fromThreadId, prior.id, { secret: { ...prior.secret, superseded: true } });
14742
+ }
13952
14743
  return json(res, 201, { messageId: message.id, label: target.label });
13953
14744
  }
13954
14745
  if (method === "POST" && path === "/api/internal/voice-note") {
@@ -13962,6 +14753,9 @@ const handleRequest = async (req, res) => {
13962
14753
  // here so a call that reaches an unconfigured workspace answers with
13963
14754
  // the setup guidance instead of a provider failure.
13964
14755
  const voiceBot = botForThread(internalSender.id, internalCapability.threadId) ?? internalSender;
14756
+ if (voiceBot.voiceNotes === false) {
14757
+ return json(res, 403, { error: "voice notes are turned off for this bot" });
14758
+ }
13965
14759
  try {
13966
14760
  const audio = await tts.speak(cfg, text, voiceBot.voice);
13967
14761
  // Synthesis can outlive its turn: a stop or settle during the await
@@ -14008,14 +14802,16 @@ const handleRequest = async (req, res) => {
14008
14802
  // Rows are fire-and-forget: a log failure must never take the call
14009
14803
  // (or its refusal) down with it.
14010
14804
  const call = connectorCallFromFrame(body);
14011
- if (call.kind === "unrecognized") {
14805
+ // A bot with no grants record keeps the pre-grants relay: even an
14806
+ // unreadable frame passes through exactly as it always did.
14807
+ if (call.kind === "unrecognized" && currentSender.connectorTools !== undefined) {
14012
14808
  appendDecision(DATA_DIR, {
14013
14809
  threadId: internalCapability.threadId,
14014
14810
  botId: currentSender.id,
14015
14811
  botName: currentSender.name,
14016
14812
  tool: call.invoked,
14017
14813
  summary: call.reason,
14018
- decision: "user-denied",
14814
+ decision: "auto-denied",
14019
14815
  source: "connector-scope",
14020
14816
  rule: "connectorTools",
14021
14817
  });
@@ -14028,7 +14824,12 @@ const handleRequest = async (req, res) => {
14028
14824
  }));
14029
14825
  }
14030
14826
  if (call.kind === "tools") {
14031
- const verdict = evaluateConnectorTools(call.names, currentSender.connectorTools);
14827
+ // The resolver needs the real connected-service slugs, so an
14828
+ // underscored service (bland_ai) keeps its own tools instead of
14829
+ // a plain-prefix grant (bland) capturing them; an unreachable
14830
+ // catalog reads as empty and the plain split stands.
14831
+ const serviceSlugs = await composio.connectedServiceSlugs(cfg);
14832
+ const verdict = evaluateConnectorTools(call.names, currentSender.connectorTools, serviceSlugs);
14032
14833
  if (!verdict.allowed) {
14033
14834
  for (const denial of verdict.denials) {
14034
14835
  appendDecision(DATA_DIR, {
@@ -14041,7 +14842,7 @@ const handleRequest = async (req, res) => {
14041
14842
  : denial.onGrantedService
14042
14843
  ? "tool is not in this service's grant"
14043
14844
  : "service is not granted",
14044
- decision: "user-denied",
14845
+ decision: "auto-denied",
14045
14846
  source: "connector-scope",
14046
14847
  rule: denial.service ? "connectorTools." + denial.service : "connectorTools",
14047
14848
  });
@@ -14062,7 +14863,7 @@ const handleRequest = async (req, res) => {
14062
14863
  botName: currentSender.name,
14063
14864
  tool: call.names[0],
14064
14865
  summary: ("allowed " + call.names.join(", ")).slice(0, 240),
14065
- decision: "user-approved",
14866
+ decision: "auto-approved",
14066
14867
  source: "connector-scope",
14067
14868
  rule: verdict.rule,
14068
14869
  });
@@ -14086,58 +14887,7 @@ const handleRequest = async (req, res) => {
14086
14887
  if (!bot)
14087
14888
  return json(res, 404, { error: "no such bot" });
14088
14889
  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 });
14890
+ return json(res, 200, await computerCallGate(internalCapability));
14141
14891
  }
14142
14892
  if (method === "POST") {
14143
14893
  const body = await readInternalBody();
@@ -14667,9 +15417,10 @@ const handleRequest = async (req, res) => {
14667
15417
  const message = store.messagesFor(threadId).find((candidate) => candidate.id === m[2]);
14668
15418
  if (!message)
14669
15419
  return json(res, 404, { error: "no such message" });
14670
- if (message.kind !== "text") {
15420
+ if (message.kind !== "text" || (!message.text && !message.attachments?.length)) {
14671
15421
  return json(res, 403, { error: "that message does not share this file" });
14672
15422
  }
15423
+ const messageText = message.text ?? "";
14673
15424
  const body = method === "POST" ? await readBody(req) : null;
14674
15425
  const rawReference = streamsMessageImage ? url.searchParams.get("ref") : null;
14675
15426
  if (streamsMessageImage && (!rawReference || !/^\d+$/.test(rawReference))) {
@@ -14677,27 +15428,32 @@ const handleRequest = async (req, res) => {
14677
15428
  }
14678
15429
  const href = method === "POST"
14679
15430
  ? (typeof body.path === "string" ? body.path : "")
14680
- : messageImageTargetAt(message.text ?? "", Number(rawReference));
15431
+ : messageImageTargetAt(messageText, Number(rawReference));
14681
15432
  if (!href)
14682
15433
  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
15434
  let roots;
14688
15435
  let downloadName;
14689
- if (generatedImage) {
14690
- roots = [ATTACHMENTS_DIR];
14691
- }
14692
- else if (message.role === "user") {
14693
- downloadName = messageAttachmentName(message.text ?? "", href) ?? undefined;
15436
+ // A file the bot attached with attach_file lives in the private
15437
+ // attachment store; the message itself is the grant, exactly as a
15438
+ // user's attachment tag is for their own uploads.
15439
+ const botAttachment = message.role === "bot"
15440
+ ? message.attachments?.find((attachment) => attachment.path === href)
15441
+ : undefined;
15442
+ if (message.role === "user") {
15443
+ downloadName = messageAttachmentName(messageText, href) ?? undefined;
14694
15444
  if (!downloadName) {
14695
15445
  return json(res, 403, { error: "that message does not share this file" });
14696
15446
  }
14697
15447
  roots = [ATTACHMENTS_DIR];
14698
15448
  }
15449
+ else if (botAttachment) {
15450
+ // Files and images alike: the stored message is the grant, so the
15451
+ // mobile clients can fetch an attached image through this route too.
15452
+ downloadName = botAttachment.kind === "file" ? botAttachment.name : undefined;
15453
+ roots = [ATTACHMENTS_DIR];
15454
+ }
14699
15455
  else {
14700
- if (!messageReferencesFile(message.text ?? "", href)) {
15456
+ if (!messageReferencesFile(messageText, href)) {
14701
15457
  return json(res, 403, { error: "that bot message does not link to this file" });
14702
15458
  }
14703
15459
  const senderId = directBot?.id ?? message.from?.botId;
@@ -14707,26 +15463,10 @@ const handleRequest = async (req, res) => {
14707
15463
  if (!senderId) {
14708
15464
  return json(res, 403, { error: "the file's bot author could not be verified" });
14709
15465
  }
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
- });
15466
+ roots = messageFileRootsForThread(senderId, threadId);
14727
15467
  }
14728
15468
  const file = await openMessageFile(href, roots);
14729
- if ((streamsMessageImage || generatedImage) && !file.mime.startsWith("image/")) {
15469
+ if ((streamsMessageImage || botAttachment?.kind === "image") && !file.mime.startsWith("image/")) {
14730
15470
  await file.handle.close();
14731
15471
  return json(res, 415, { error: "only images can be previewed here" });
14732
15472
  }
@@ -14881,18 +15621,41 @@ const handleRequest = async (req, res) => {
14881
15621
  const attachment = readAttachment(m[1]);
14882
15622
  if (!attachment)
14883
15623
  return json(res, 404, { error: "no such attachment" });
14884
- res.writeHead(200, {
15624
+ const size = attachment.bytes.byteLength;
15625
+ // Who may see an attachment is decided per request — the dispatcher
15626
+ // 404s hidden ones above — so the browser must never reuse one
15627
+ // member's copy after a switch to another identity in the same profile.
15628
+ const headers = {
14885
15629
  "content-type": attachment.mime,
14886
- "content-length": String(attachment.bytes.byteLength),
14887
- "cache-control": "private, max-age=31536000, immutable",
15630
+ "cache-control": "private, no-store",
14888
15631
  "x-content-type-options": "nosniff",
14889
- });
15632
+ };
15633
+ // Range serving is audio-only (#1745): a player scrubbing a voice note
15634
+ // asks for byte windows, and images/documents keep the full response
15635
+ // the app has always relied on. A header we do not understand reads
15636
+ // as absent — the full 200 is always a correct answer to a GET.
15637
+ if (attachment.mime.startsWith("audio/")) {
15638
+ const rawRange = Array.isArray(req.headers.range) ? req.headers.range[0] : req.headers.range;
15639
+ const range = parseAudioRange(rawRange, size);
15640
+ if (range.kind === "unsatisfiable") {
15641
+ res.writeHead(416, { ...headers, "content-range": `bytes */${size}` });
15642
+ return res.end();
15643
+ }
15644
+ if (range.kind === "range") {
15645
+ res.writeHead(206, {
15646
+ ...headers,
15647
+ "content-length": String(range.end - range.start + 1),
15648
+ "content-range": `bytes ${range.start}-${range.end}/${size}`,
15649
+ });
15650
+ return res.end(attachment.bytes.subarray(range.start, range.end + 1));
15651
+ }
15652
+ }
15653
+ res.writeHead(200, { ...headers, "content-length": String(size) });
14890
15654
  return res.end(attachment.bytes);
14891
15655
  }
14892
15656
  // ── 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;
15657
+ // Literal substring scans run in a bounded read-only worker so a large
15658
+ // history cannot stall unrelated turns. Hits resolve to the owning bot/room;
14896
15659
  // rows belonging to deleted conversations resolve to nothing and drop.
14897
15660
  if (method === "GET" && path === "/api/search") {
14898
15661
  const q = url.searchParams.get("q") ?? "";
@@ -14913,8 +15676,27 @@ const handleRequest = async (req, res) => {
14913
15676
  };
14914
15677
  // A member who may not see every bot: look further, then keep only
14915
15678
  // 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))
15679
+ const found = await searchMessagesAsync(q, visible.everything ? limit : Math.min(limit * 10, 1_000), threadId);
15680
+ // A scan may outlive a revocation or audience edit. Authorize again
15681
+ // after the await, and resolve visibility from current store state.
15682
+ if (HOSTED_WORKSPACE && auth.kind === "session") {
15683
+ const failure = workspaceAccess ? await workspaceAccess.authorize(req, auth)
15684
+ : { status: 503, error: "Workspace sign-in is unavailable." };
15685
+ if (failure)
15686
+ return json(res, failure.status, { error: failure.error });
15687
+ }
15688
+ const current = resolveRequestAuth(req, {
15689
+ sessions, cookieName: SESSION_COOKIE, streamPath: "/api/events", url,
15690
+ loopbackMutationToken: desktopMutationToken, companionMutationToken,
15691
+ features: { sharedComputers: sharedComputersEnabled(cfg) }, loopbackTrust: LOOPBACK.trust, cliOwnerToken,
15692
+ });
15693
+ if (!current.auth)
15694
+ return json(res, current.status, { error: current.error });
15695
+ const currentVisible = visibleTo(viewerFor(current.auth));
15696
+ if (threadId && !currentVisible.thread(threadId))
15697
+ return json(res, 404, { error: "no such conversation" });
15698
+ const hits = found
15699
+ .filter((hit) => currentVisible.thread(hit.threadId))
14918
15700
  .slice(0, limit)
14919
15701
  .map((hit) => {
14920
15702
  const bot = store.botByThread(hit.threadId);
@@ -15441,7 +16223,7 @@ const handleRequest = async (req, res) => {
15441
16223
  const openApprovalThreads = store.groupTasks(group.id).flatMap((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
15442
16224
  message.card?.requestId &&
15443
16225
  !message.card.answered &&
15444
- !message.card.dismissed) ? [task.threadId] : []);
16226
+ !message.card.dismissed && !message.card.expired) ? [task.threadId] : []);
15445
16227
  return openApprovalThreads.length > 0 && !openApprovalThreads.includes(targetThreadId);
15446
16228
  };
15447
16229
  m = path.match(/^\/api\/groups\/([\w-]+)\/tasks$/);
@@ -15714,7 +16496,8 @@ const handleRequest = async (req, res) => {
15714
16496
  status: 409,
15715
16497
  });
15716
16498
  }
15717
- if (groupIsWorking(current)) {
16499
+ const decision = admit("room", {}, { roomWorking: groupIsWorking(current) });
16500
+ if (decision.action === "queue") {
15718
16501
  const queued = queueChannelMessage(current.id, threadId, text, {
15719
16502
  replyToId: replyTo?.id,
15720
16503
  sendId,
@@ -15783,21 +16566,30 @@ const handleRequest = async (req, res) => {
15783
16566
  const held = holdChannelQueue(current.id, targetThreadId, m[2]);
15784
16567
  if (!held)
15785
16568
  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
16569
  const [head] = held.items;
15791
- if (!head || head.id !== m[2]) {
16570
+ const decision = admit("room-steer", {
16571
+ speakerPresent: Boolean(speaker),
16572
+ engineCanSteer: Boolean(instance?.adapter.capabilities.queueing && instance.adapter.steer),
16573
+ isQueueHead: Boolean(head && head.id === m[2]),
16574
+ });
16575
+ if (decision.action === "refuse") {
15792
16576
  restoreHeldChannelQueue(held);
15793
16577
  return json(res, 409, { error: "only the first queued message can steer" });
15794
16578
  }
16579
+ if (decision.action === "queue") {
16580
+ restoreHeldChannelQueue(held);
16581
+ return json(res, 200, { ok: true, queued: true, threadId: targetThreadId });
16582
+ }
15795
16583
  // A reply target that cannot be resolved restores the held queue
15796
16584
  // before the request fails — the room's normal drain keeps the head.
15797
16585
  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");
16586
+ // steer was offered only when a live speaker instance could take it;
16587
+ // the check carries that invariant to the type system.
16588
+ const steered = instance?.adapter.steer
16589
+ ? await instance.adapter
16590
+ .steer(targetThreadId, promptWithReply(head.text, replyTo, cfg.profile?.name?.trim() || "User"))
16591
+ .catch(() => "indeterminate")
16592
+ : "refused";
15801
16593
  // The steer was awaited adapter work: re-read every ownership
15802
16594
  // invariant before writing anything, exactly like the 1:1 path. A
15803
16595
  // speaker change, a channel switch, or a settled room restores the
@@ -16062,7 +16854,7 @@ const handleRequest = async (req, res) => {
16062
16854
  return json(res, 400, { error: "preset must be a preset id from New bot" });
16063
16855
  }
16064
16856
  const resolvedPreset = typeof body.preset === "string" ? presetStore.resolve(body.preset) : null;
16065
- const preset = resolvedPreset && presetOffered(resolvedPreset.row, readOrgInstallStatuses()) ? resolvedPreset : null;
16857
+ const preset = resolvedPreset && presetOffered(resolvedPreset.row, orgInstallStatuses()) ? resolvedPreset : null;
16066
16858
  if (body.preset !== undefined && !preset)
16067
16859
  return json(res, 404, { error: PRESET_UNAVAILABLE_MESSAGE });
16068
16860
  body = { ...body, ...settings, name: settings.name };
@@ -16164,7 +16956,7 @@ const handleRequest = async (req, res) => {
16164
16956
  if (preset) {
16165
16957
  applyPresetToBot(bot.id, preset, {
16166
16958
  store,
16167
- skills: { install: installSkill, setEnabled: setSkillEnabled },
16959
+ skills: { install: installSkill, setEnabled: setSkillEnabled, installOrg: installOrgSkill },
16168
16960
  memory: { writeIndex: writeMemoryFile, writeTopic: writeMemoryTopic },
16169
16961
  });
16170
16962
  }
@@ -16308,7 +17100,7 @@ const handleRequest = async (req, res) => {
16308
17100
  if ([existing, selected].some((owner) => {
16309
17101
  const mode = approvalModeFor(owner);
16310
17102
  return (mode === "full" || mode === "custom") &&
16311
- (!supportsApprovalMode(registry.cliTarget(checked.selection.instanceId)?.driverKind, mode) ||
17103
+ (!supportsApprovalMode(checked.selection, mode) ||
16312
17104
  registry.cliTarget(checked.selection.instanceId)?.driverKind !== registry.cliTarget(owner.modelSelection.instanceId)?.driverKind);
16313
17105
  })) {
16314
17106
  return json(res, 400, {
@@ -16516,6 +17308,14 @@ const handleRequest = async (req, res) => {
16516
17308
  return json(res, 400, { error: "composio must be true or false" });
16517
17309
  patch.composio = body.composio;
16518
17310
  }
17311
+ // per-bot gate on sending voice notes: the toggle is about what this
17312
+ // bot may do (an admin decision), not how it looks, so it lives here
17313
+ // rather than the client-writable profile surface.
17314
+ if (body.voiceNotes !== undefined) {
17315
+ if (typeof body.voiceNotes !== "boolean")
17316
+ return json(res, 400, { error: "voiceNotes must be true or false" });
17317
+ patch.voiceNotes = body.voiceNotes;
17318
+ }
16519
17319
  // which of those apps' tools this bot may call (connector grants 1/5:
16520
17320
  // data model only — enforcement lands with slice 2). null returns the
16521
17321
  // bot to boolean-only legacy behavior; {} means no tools.
@@ -16527,6 +17327,13 @@ const handleRequest = async (req, res) => {
16527
17327
  const parsed = parseConnectorTools(body.connectorTools);
16528
17328
  if (!parsed.ok)
16529
17329
  return json(res, 400, { error: parsed.error });
17330
+ // Connector grants 3/5: past the shape checks, slugs must name
17331
+ // connected services and tool names must carry their service
17332
+ // prefix. Unreachable backends never block the patch — the
17333
+ // call-time verdict from slice 2 stays the authority.
17334
+ const semantic = await composio.validateConnectorGrants(cfg, parsed.grants);
17335
+ if (semantic)
17336
+ return json(res, 400, { error: semantic });
16530
17337
  patch.connectorTools = parsed.grants;
16531
17338
  }
16532
17339
  }
@@ -16655,14 +17462,14 @@ const handleRequest = async (req, res) => {
16655
17462
  if (normalizedSelection && selectedTask) {
16656
17463
  const mode = approvalModeFor(selectedTask);
16657
17464
  if ((mode === "full" || mode === "custom") &&
16658
- (!supportsApprovalMode(registry.cliTarget(normalizedSelection.instanceId)?.driverKind, mode) ||
17465
+ (!supportsApprovalMode(normalizedSelection, mode) ||
16659
17466
  registry.cliTarget(normalizedSelection.instanceId)?.driverKind !== registry.cliTarget(selectedTask.modelSelection.instanceId)?.driverKind)) {
16660
17467
  return json(res, 400, { error: "Choose Ask for the selected thread before changing providers with elevated permissions" });
16661
17468
  }
16662
17469
  }
16663
17470
  if ((requestedApprovalMode === "full" || requestedApprovalMode === "custom") &&
16664
17471
  (body.approvalMode !== undefined || normalizedSelection !== undefined) &&
16665
- (!targetSelection || !supportsApprovalMode(registry.cliTarget(targetSelection.instanceId)?.driverKind, requestedApprovalMode) ||
17472
+ (!targetSelection || !supportsApprovalMode(targetSelection, requestedApprovalMode) ||
16666
17473
  (existingBot && normalizedSelection && registry.cliTarget(normalizedSelection.instanceId)?.driverKind !== registry.cliTarget(existingBot.modelSelection.instanceId)?.driverKind))) {
16667
17474
  return json(res, 400, {
16668
17475
  error: "This provider does not support the selected approval level, or changing providers requires choosing Ask first",
@@ -16999,7 +17806,7 @@ const handleRequest = async (req, res) => {
16999
17806
  return json(res, 404, { error: "no such bot" });
17000
17807
  const parsed = z.object({ source: z.string().min(1).max(2000) }).safeParse(await readBody(req));
17001
17808
  if (!parsed.success)
17002
- return json(res, 400, { error: "source must be a GitHub URL or owner/repo" });
17809
+ return json(res, 400, { error: "source must be a GitHub or skills.sh URL, or owner/repo" });
17003
17810
  const fetched = await fetchSkillFromSource(parsed.data.source);
17004
17811
  if ("error" in fetched)
17005
17812
  return json(res, 422, { error: fetched.error });
@@ -17572,7 +18379,14 @@ const handleRequest = async (req, res) => {
17572
18379
  if (store.activeLeaf(threadId) !== body.expectedActiveLeafId) {
17573
18380
  throw Object.assign(new Error("the conversation changed before this message could start"), { status: 409, code: "guarded_branch" });
17574
18381
  }
17575
- if (currentAtStart.busy || threadBusy(bot.id, threadId) || botAtThreadCapacity(bot.id) || parksBehindCoordination(bot.id, threadId) || activeGroupTurnForBot(bot.id)) {
18382
+ const guardedAdmission = admit("guarded", {}, {
18383
+ botBusy: currentAtStart.busy,
18384
+ threadBusy: threadBusy(bot.id, threadId),
18385
+ atCapacity: botAtThreadCapacity(bot.id),
18386
+ parksBehindCoordination: parksBehindCoordination(bot.id, threadId),
18387
+ groupTurn: Boolean(activeGroupTurnForBot(bot.id)),
18388
+ });
18389
+ if (guardedAdmission.action === "refuse") {
17576
18390
  throw Object.assign(new Error("wait for a free thread slot before retrying this message"), { status: 409, code: "guarded_busy" });
17577
18391
  }
17578
18392
  const message = await startTurn(bot.id, text, { threadId, replyTo, sendId, sender: messageSender(auth), trigger });
@@ -17589,7 +18403,14 @@ const handleRequest = async (req, res) => {
17589
18403
  // admission can hand it to the provider natively.
17590
18404
  const carriesImages = extractTurnImages(text).images.length > 0;
17591
18405
  const steerTarget = handoffs.current(threadId);
17592
- if (!carriesImages && !computerSelectionTurns.get(threadId)?.selected && instance?.adapter.capabilities.queueing && instance.adapter.steer) {
18406
+ const busyAdmission = admit("direct-busy", {
18407
+ carriesImages,
18408
+ pendingComputerSelection: Boolean(computerSelectionTurns.get(threadId)?.selected),
18409
+ engineCanSteer: Boolean(instance?.adapter.capabilities.queueing && instance.adapter.steer),
18410
+ });
18411
+ // steer was offered only when a live instance could take it;
18412
+ // the second check carries that fact to the type system.
18413
+ if (busyAdmission.action === "steer" && instance?.adapter.steer) {
17593
18414
  steered = await instance.adapter
17594
18415
  .steer(threadId, promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"))
17595
18416
  .catch(() => "indeterminate");
@@ -17864,6 +18685,22 @@ const handleRequest = async (req, res) => {
17864
18685
  behavior,
17865
18686
  }))
17866
18687
  return;
18688
+ if (resolveAndSendModel(res, {
18689
+ botId: bot.id,
18690
+ botName: bot.name,
18691
+ threadId: bot.threadId,
18692
+ requestId: String(body.requestId),
18693
+ behavior,
18694
+ }))
18695
+ return;
18696
+ if (resolveAndSendTightening(res, {
18697
+ botId: bot.id,
18698
+ botName: bot.name,
18699
+ threadId: bot.threadId,
18700
+ requestId: String(body.requestId),
18701
+ behavior,
18702
+ }))
18703
+ return;
17867
18704
  if (sendSkillResolution(res, resolveSkillRequest({
17868
18705
  botId: bot.id,
17869
18706
  botName: bot.name,
@@ -17962,6 +18799,36 @@ const handleRequest = async (req, res) => {
17962
18799
  }))
17963
18800
  return;
17964
18801
  }
18802
+ const modelCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.modelRequest);
18803
+ if (modelCard?.card?.modelRequest) {
18804
+ const modelBotId = modelCard.from?.botId ?? store.botByThread(threadId)?.id;
18805
+ if (!modelBotId)
18806
+ return json(res, 400, { error: "this model request has no valid owner" });
18807
+ const modelOwner = store.bot(modelBotId);
18808
+ if (resolveAndSendModel(res, {
18809
+ botId: modelBotId,
18810
+ botName: modelOwner?.name,
18811
+ threadId,
18812
+ requestId,
18813
+ behavior,
18814
+ }))
18815
+ return;
18816
+ }
18817
+ const tighteningCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.tighteningRequest);
18818
+ if (tighteningCard?.card?.tighteningRequest) {
18819
+ const tighteningBotId = tighteningCard.from?.botId ?? store.botByThread(threadId)?.id;
18820
+ if (!tighteningBotId)
18821
+ return json(res, 400, { error: "this tightening request has no valid owner" });
18822
+ const tighteningOwner = store.bot(tighteningBotId);
18823
+ if (resolveAndSendTightening(res, {
18824
+ botId: tighteningBotId,
18825
+ botName: tighteningOwner?.name,
18826
+ threadId,
18827
+ requestId,
18828
+ behavior,
18829
+ }))
18830
+ return;
18831
+ }
17965
18832
  // peer-approval intercept (see /api/bots/:id/respond above). A peer card
17966
18833
  // belongs to the bus rather than to a speaker, so resolve it before we go
17967
18834
  // looking for one — a room between turns has no speaker to find.
@@ -18130,7 +18997,7 @@ const handleRequest = async (req, res) => {
18130
18997
  }
18131
18998
  if (bot.approvalGrant)
18132
18999
  return json(res, 409, { error: "the bot's approval mode is still being confirmed" });
18133
- if (!supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, "full")) {
19000
+ if (!supportsApprovalMode(bot.modelSelection, "full")) {
18134
19001
  return json(res, 400, { error: "This provider does not support Full access" });
18135
19002
  }
18136
19003
  }
@@ -18281,7 +19148,7 @@ const handleRequest = async (req, res) => {
18281
19148
  return json(res, 403, { error: "Full and Custom access require trusted desktop confirmation" });
18282
19149
  if (approvalModeFor(current) === "custom")
18283
19150
  return json(res, 403, { error: "Leaving Custom approval requires confirmation in the packaged desktop app" });
18284
- if (!supportsApprovalMode(registry.cliTarget((patch.modelSelection ?? current.modelSelection).instanceId)?.driverKind, mode)) {
19151
+ if (!supportsApprovalMode(patch.modelSelection ?? current.modelSelection, mode)) {
18285
19152
  return json(res, 400, { error: "This provider does not support the selected approval level" });
18286
19153
  }
18287
19154
  if (threadBusy(current.id, current.threadId))
@@ -19357,6 +20224,18 @@ const handleRequest = async (req, res) => {
19357
20224
  if (!checked.ok)
19358
20225
  return json(res, checked.status, { error: checked.error });
19359
20226
  }
20227
+ if (patch.automaticRecovery?.enabled) {
20228
+ const checked = checkedModelSelection(patch.automaticRecovery.backup, undefined, true);
20229
+ if (!checked.ok)
20230
+ return json(res, checked.status, { error: checked.error });
20231
+ const target = registry.get(checked.selection.instanceId);
20232
+ if (!target.enabled)
20233
+ return json(res, 400, { error: "Enable the backup engine before selecting it for recovery." });
20234
+ const refusal = policyModelRefusal(target);
20235
+ if (refusal)
20236
+ return json(res, 403, { error: refusal, code: "managed_policy" });
20237
+ patch.automaticRecovery.backup = checked.selection;
20238
+ }
19360
20239
  if (!Object.keys(patch).length)
19361
20240
  return json(res, 400, { error: "nothing to save" });
19362
20241
  const changingVoiceProvider = patch.tts?.provider !== undefined
@@ -19917,6 +20796,22 @@ const handleRequest = async (req, res) => {
19917
20796
  }
19918
20797
  return json(res, 200, { configured: true, credentialStore: "ok", services: await composio.connectedServices(cfg) });
19919
20798
  }
20799
+ if (method === "GET" && path === "/api/connectors/tools") {
20800
+ // The grant editor's inventory: every grantable tool name grouped by
20801
+ // service, read from the same MCP endpoint a mounted bot relays
20802
+ // through. Failure is a read-only editor, never a blocked save — the
20803
+ // exact-name model does not depend on the listing being reachable.
20804
+ const availability = composio.connectorAvailability(cfg);
20805
+ if (availability !== "configured") {
20806
+ return json(res, 200, { configured: false, services: {} });
20807
+ }
20808
+ try {
20809
+ return json(res, 200, { configured: true, services: await composio.listConnectorTools(cfg) });
20810
+ }
20811
+ catch (e) {
20812
+ return json(res, 502, { configured: true, services: {}, error: e instanceof Error ? e.message : String(e) });
20813
+ }
20814
+ }
19920
20815
  if (method === "GET" && path === "/api/connectors") {
19921
20816
  const services = (url.searchParams.get("services") ?? "").split(",").filter(Boolean);
19922
20817
  const availability = composio.connectorAvailability(cfg);
@@ -19986,6 +20881,8 @@ const handleRequest = async (req, res) => {
19986
20881
  if (m[3] === "provided") {
19987
20882
  if (message.secret.dismissed)
19988
20883
  return json(res, 409, { error: "this credential request was dismissed" });
20884
+ if (message.secret.superseded)
20885
+ return json(res, 409, { error: "this credential request was superseded by a newer one" });
19989
20886
  if (!credentialIsConfigured(cfg, message.secret.target)) {
19990
20887
  return json(res, 409, { error: `${message.secret.label} was not saved yet` });
19991
20888
  }
@@ -19998,6 +20895,8 @@ const handleRequest = async (req, res) => {
19998
20895
  return json(res, 200, state);
19999
20896
  }
20000
20897
  if (m[3] === "resume") {
20898
+ if (message.secret.superseded)
20899
+ return json(res, 409, { error: "this credential request was superseded by a newer one" });
20001
20900
  const outcome = credentialResumeOutcome(message.secret);
20002
20901
  if (!outcome) {
20003
20902
  return json(res, 409, { error: "this credential request is not ready to resume" });
@@ -20013,6 +20912,8 @@ const handleRequest = async (req, res) => {
20013
20912
  return json(res, 409, { error: "this credential request is no longer available" });
20014
20913
  return json(res, 200, { resumed: state.resumed });
20015
20914
  }
20915
+ if (message.secret.superseded)
20916
+ return json(res, 409, { error: "this credential request was superseded by a newer one" });
20016
20917
  if (!message.secret.provided && !resumeSecretCard(m[1], threadId, message.id, "dismissed")) {
20017
20918
  return json(res, 409, { error: "this credential request is no longer available" });
20018
20919
  }
@@ -20399,9 +21300,9 @@ setInterval(sweepThreadEventLogsNow, THREAD_LOG_RETENTION_SWEEP_MS).unref();
20399
21300
  for (const row of chatFollowups()) {
20400
21301
  if (row.status !== "dispatching" && row.status !== "interrupted")
20401
21302
  continue;
20402
- const owned = row.kind === "bot"
20403
- ? Boolean(store.taskByThread(row.ownerId, row.threadId))
20404
- : Boolean(store.groupByThread(row.threadId)?.id === row.ownerId);
21303
+ const owned = row.kind === "channel"
21304
+ ? Boolean(store.groupByThread(row.threadId)?.id === row.ownerId)
21305
+ : Boolean(store.taskByThread(row.ownerId, row.threadId));
20405
21306
  if (!owned) {
20406
21307
  settleChatFollowups([row.id], "cancelled");
20407
21308
  continue;
@@ -20409,12 +21310,20 @@ for (const row of chatFollowups()) {
20409
21310
  settleChatFollowups([row.id], "interrupted");
20410
21311
  const messages = store.messagesFor(row.threadId);
20411
21312
  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,
21313
+ // Aside rows recover enveloped: the words may already have run mid-turn,
21314
+ // and the recovered line must keep saying whose context it was.
21315
+ role: "user", kind: "text", text: row.kind === "aside" ? row.payload.prompt ?? row.payload.text : row.payload.text, replyToId: row.payload.replyToId,
20413
21316
  sendId: row.payload.sendId, queueId: row.id, sender: row.payload.sender,
20414
21317
  ...(row.kind === "channel" ? { channelMode: row.payload.mode, via: row.payload.via } : {}),
21318
+ ...(row.kind === "aside" ? {
21319
+ aside: true,
21320
+ peerAsk: row.payload.aside
21321
+ ? { botId: row.payload.aside.fromBotId, name: row.payload.aside.fromBotName, ...(row.payload.aside.unattended ? { unattended: true } : {}) }
21322
+ : undefined,
21323
+ } : {}),
20415
21324
  });
20416
21325
  // 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;
21326
+ const recoveredTask = row.kind !== "channel" ? store.taskByThread(row.ownerId, row.threadId) : undefined;
20418
21327
  const order = store.activePath(row.threadId).filter(isContextMessage).map((m) => m.id);
20419
21328
  for (const [instanceId, state] of Object.entries(recoveredTask?.handedMessages ?? {})) {
20420
21329
  if (state.session !== undefined)
@@ -20431,6 +21340,7 @@ for (const row of chatFollowups()) {
20431
21340
  settleChatFollowups([row.id], null);
20432
21341
  }
20433
21342
  restoreSteeredMessages();
21343
+ restoreAsideMessages(store);
20434
21344
  restoreChannelMessages();
20435
21345
  // Repair known auto pins that conflict with Works on before dispatch starts.
20436
21346
  // Legacy pins have no provenance and may be deliberate person selections:
@@ -20521,6 +21431,7 @@ const gracefulShutdown = createGracefulShutdown({
20521
21431
  () => flushUsageLedger(DATA_DIR),
20522
21432
  () => flushDecisionLog(DATA_DIR),
20523
21433
  () => flushAdminActivity(DATA_DIR),
21434
+ () => closeMessageSearch(),
20524
21435
  ],
20525
21436
  // Cleanup jobs run concurrently. Release only after they settle (or reach
20526
21437
  // the shutdown deadline), immediately before the process exits, so no new