openmausbot 0.1.92 → 0.1.94

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 (163) hide show
  1. package/dist/assets/{DesktopViewer-Do6lBa4C.js → DesktopViewer-bxM7NV3N.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-Cd3X7Hxc.js → abnfDiagram-O67JEVCF-BhMdJhRJ.js} +1 -1
  3. package/dist/assets/{arc-DIYc7Ymx.js → arc-B5itKDcx.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-DBmRGVyP.js → architectureDiagram-NJMV4G6O-C5d7UY6U.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CPFePSEE.js → blockDiagram-BEXU5L5S-BrZIhMmu.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-DD_DO1qy.js → c4Diagram-YGBWAQC7-7I0ArfbD.js} +1 -1
  7. package/dist/assets/channel-CdJX6i8k.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-BNEeNSjX.js → chunk-2Q5K7J3B-BnEsyN36.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BbinLaqH.js → chunk-3FUC2YCW-BXATQ52s.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-BBgYrgDR.js → chunk-5VM5RSS4-B0BpTkEx.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-ezXj-kA-.js → chunk-GTNCS2PH-BtLTz6FR.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-9FxZ3diV.js → chunk-GWA4HPMP-D6gcZOYt.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-CO-40JpI.js → chunk-JWPE2WC7-DCLrosvf.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-BEhjslYE.js → chunk-NETBCI7D-B9JsknV9.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CNeq4n7_.js → chunk-WEXAMYUT-CsoTxw7z.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-OsCY7KSi.js → chunk-XXDRQBXY-0F_frB0c.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-CloRUEtk.js → classDiagram-v2-NBCMYWYE-DkrpnflX.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-D1BGZhp4.js → cose-bilkent-JH36ORCC-BNDwYBt9.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-CUf4T2CN.js → cynefin-OW5HDTMX-JUkNr1Mk.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-DPEZrFGA.js → cynefinDiagram-VND7K2PF-DKeh9N4M.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-bDe6GIv5.js → dagre-6A5THRUB-ve-6T_wX.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-CdcUQz55.js → diagram-22UHCM2B-BuJ2d8rT.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-BZpqGg7c.js → diagram-3UASUU5V-BgOs_Ni-.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-DvGM6ugp.js → diagram-ATOU4E4O-DMK6VmcP.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-DSTmMm0s.js → diagram-CDSNMT55-DksASlcY.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-15cucnMn.js → diagram-MLGK6HIB-Bgn8I5k6.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-tMbuaE6n.js → diagram-MPIPVDR6-zK0jqO8N.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-BYxYzjHI.js → ebnfDiagram-ZINNZB2B-vKrbzTwr.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-v6bj852W.js → elk-276RUBZZ-Bzv2mPNf.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-5lBBlnd2.js → erDiagram-OPXOYQCR-CvOyYuxu.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-CZyl1zIj.js → flowDiagram-KWPJA3E3-CtbnDbc0.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-BiAI-ltZ.js → ganttDiagram-FUAMR5RP-c4dMRYyG.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-BAZISkj3.js → gitGraphDiagram-X574FWY7-B34uaQho.js} +1 -1
  34. package/dist/assets/index-CPYrGsv1.js +610 -0
  35. package/dist/assets/{index-DGBaEXrM.js → index-CPiqc_DN.js} +1 -1
  36. package/dist/assets/index-CzT42GR8.css +1 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-B6ooOy-Z.js → infoDiagram-VRGFBTTK-o64ma4CI.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BZ9ksi-B.js → ishikawaDiagram-OU5B5YK6-DV4C9BAm.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DWqlzIEe.js → journeyDiagram-ZHPQQLJL-Bf0AHk1H.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-JX6piDwL.js → kanban-definition-PNTS6WVX-B1UMSKmE.js} +1 -1
  41. package/dist/assets/{linear-CmOWzX0P.js → linear-BpKhuyLm.js} +1 -1
  42. package/dist/assets/{mermaid.core-CF1zwoQ-.js → mermaid.core-B_BKNCw5.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-CGh2K4fW.js → mindmap-definition-NLK3R4M7-DeARruT1.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-CDwYN0s7.js → pegDiagram-GJSIUBJH-BOQEk_1D.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-1rfjJocB.js → pieDiagram-5QR66LMP-EIfoiTZo.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-tKPvPiok.js → quadrantDiagram-O4NWA36T-GgZwT-fi.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-BHLm5vXR.js → railroadDiagram-XR7U4H2S-D9rDQomj.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-CB45STp7.js → requirementDiagram-PLB6GJNP-DAh1MIeo.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-55QA_huC.js → sankeyDiagram-IPEJSGJF-CPr6Uz64.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-BckFmQHn.js → sequenceDiagram-PO4LG4MO-BU6VIdcm.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-BFCOJF11.js → sizeCapture-INFHLROL-qF2HO41r.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-CZvmiVVN.js → stateDiagram-v2-GCMORJYK-DlJ8G4MD.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-BpDUCEKy.js → swimlanes-2SLR337P-DzYE8I54.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-D-U4GtLQ.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BpxHq6IK.js → timeline-definition-EJHVYXUP-B7wzEQ0A.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-Cj66vm02.js → usecaseDiagram-POWQR4AR-BvGgrxIP.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-C9nRkWE_.js → vennDiagram-UO4OBE2U-CEECdgQg.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-D-me_nWj.js → wardleyDiagram-VNRHLVJA-XQHA0P_P.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-Dc_fL4mk.js → xychartDiagram-PMCCYNJV-B5mLcZI2.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/cloud-home-start.js +1 -0
  62. package/dist-server/companion/src/routes.js +45 -1
  63. package/dist-server/container-mcp.js +99 -77
  64. package/dist-server/drivers/agents-proxy.js +40 -1
  65. package/dist-server/drivers/pi-mcp-extension.ts +245 -299
  66. package/dist-server/enterprise/server/index.js +1 -0
  67. package/dist-server/index.js +18361 -13794
  68. package/dist-server/local-computer-proxy.js +5 -1
  69. package/dist-server/local-computer.js +1 -0
  70. package/dist-server/mcp-gate.js +152 -20
  71. package/dist-server/mcp-remote-proxy.js +437 -0
  72. package/dist-server/openmausbot.js +8872 -7620
  73. package/dist-server/pair-cli.js +8872 -7620
  74. package/dist-server/permission-proxy.js +65 -1
  75. package/dist-server/proxy-paths.js +1 -0
  76. package/dist-server/server/auto-approve.js +33 -11
  77. package/dist-server/server/boat.js +3 -3
  78. package/dist-server/server/browser-live.js +14 -5
  79. package/dist-server/server/browser-runtime.js +6 -0
  80. package/dist-server/server/channel-queue.js +4 -1
  81. package/dist-server/server/cli.js +32 -12
  82. package/dist-server/server/cloud-move-http.js +9 -3
  83. package/dist-server/server/cloud-move.js +18 -0
  84. package/dist-server/server/cloud-owner.js +1 -1
  85. package/dist-server/server/computer-control.js +6 -0
  86. package/dist-server/server/computer-wait.js +33 -13
  87. package/dist-server/server/config.js +57 -10
  88. package/dist-server/server/container-computer.js +43 -32
  89. package/dist-server/server/decision-log.js +9 -6
  90. package/dist-server/server/delegations.js +47 -22
  91. package/dist-server/server/digest.js +12 -2
  92. package/dist-server/server/drivers/acp/core.js +229 -20
  93. package/dist-server/server/drivers/acp/cursor.js +13 -1
  94. package/dist-server/server/drivers/acp/grok.js +208 -3
  95. package/dist-server/server/drivers/acp/quiet-status.js +257 -0
  96. package/dist-server/server/drivers/acp/qwen.js +18 -0
  97. package/dist-server/server/drivers/agents-call.js +28 -0
  98. package/dist-server/server/drivers/agents-catalog.js +10 -1
  99. package/dist-server/server/drivers/boatagent.js +2 -0
  100. package/dist-server/server/drivers/builtIn.js +2 -0
  101. package/dist-server/server/drivers/cerebras.js +108 -0
  102. package/dist-server/server/drivers/chat-mcp-tools.js +45 -15
  103. package/dist-server/server/drivers/claude.js +35 -9
  104. package/dist-server/server/drivers/codex.js +84 -10
  105. package/dist-server/server/drivers/openai-chat.js +37 -5
  106. package/dist-server/server/drivers/pi.js +48 -4
  107. package/dist-server/server/drivers/prompt-split.js +2 -0
  108. package/dist-server/server/env-path.js +6 -2
  109. package/dist-server/server/environment.js +3 -0
  110. package/dist-server/server/guarded-requests.js +2 -1
  111. package/dist-server/server/index.js +1353 -311
  112. package/dist-server/server/live-call-controller.js +868 -0
  113. package/dist-server/server/live-call.js +216 -0
  114. package/dist-server/server/local-vm-stop-reason.js +26 -0
  115. package/dist-server/server/mcp-gate-config.js +43 -12
  116. package/dist-server/server/mcp-gate.js +127 -25
  117. package/dist-server/server/mcp-http.js +46 -8
  118. package/dist-server/server/mcp-remote-proxy.js +108 -0
  119. package/dist-server/server/new-bot-defaults.js +3 -0
  120. package/dist-server/server/peer-provenance.js +4 -2
  121. package/dist-server/server/permission-proxy.js +15 -1
  122. package/dist-server/server/plan-usage.js +797 -0
  123. package/dist-server/server/provider-key-check.js +2 -1
  124. package/dist-server/server/proxy-paths.js +1 -0
  125. package/dist-server/server/redact.js +2 -0
  126. package/dist-server/server/request-auth.js +3 -2
  127. package/dist-server/server/routes/desktop-viewer.js +50 -6
  128. package/dist-server/server/routes/live.js +98 -0
  129. package/dist-server/server/routines.js +6 -3
  130. package/dist-server/server/skill-library.js +207 -0
  131. package/dist-server/server/skills-library-migration.js +235 -0
  132. package/dist-server/server/skills.js +66 -3
  133. package/dist-server/server/steer-queue.js +13 -1
  134. package/dist-server/server/store.js +86 -4
  135. package/dist-server/server/surface.js +9 -3
  136. package/dist-server/server/team-backup.js +2 -1
  137. package/dist-server/server/thread-events.js +1 -1
  138. package/dist-server/server/tunnel.js +20 -1
  139. package/dist-server/server/turn-log.js +10 -0
  140. package/dist-server/server/turn-resources.js +124 -0
  141. package/dist-server/server/vps-computer.js +1 -1
  142. package/dist-server/server/workspace-backup.js +22 -0
  143. package/dist-server/server/workspace.js +1 -1
  144. package/dist-server/shared/approval-mode.js +1 -1
  145. package/dist-server/shared/ask-question.js +13 -0
  146. package/dist-server/shared/call-consent.js +28 -0
  147. package/dist-server/shared/live-approval.js +124 -0
  148. package/dist-server/shared/live-call.js +176 -0
  149. package/dist-server/shared/redact.js +1 -0
  150. package/dist-server/shared/skill-md.js +10 -0
  151. package/dist-server/shared/team-backup.js +3 -0
  152. package/dist-server/shared/tool-name.js +22 -0
  153. package/dist-server/shared/tool-scope-support.js +25 -0
  154. package/dist-server/shared/tool-scope.js +136 -0
  155. package/dist-server/src/lib/thread-refs.js +406 -0
  156. package/dist-server/vps-container-mcp.js +99 -77
  157. package/dist-server/workspace-backup.worker.js +103 -100
  158. package/enterprise/server/index.js +1 -0
  159. package/package.json +1 -1
  160. package/dist/assets/channel-Dtk5Mtic.js +0 -1
  161. package/dist/assets/index-Cdmjrl28.css +0 -1
  162. package/dist/assets/index-DR7YPMkW.js +0 -597
  163. package/dist/assets/swimlanesDiagram-TC7HE7FX-M0GYW9Y3.js +0 -8
@@ -30,8 +30,9 @@ import { CLOUD_COMPUTER_BUSY_ERROR } from "../shared/computer-contention.js";
30
30
  import { approvalModeFor, modelSwitchNeedsAsk, isEmergencyApprovalDowngrade, isApprovalMode, } from "../shared/approval-mode.js";
31
31
  import { createApprovalModeSupport } from "./harness-capabilities.js";
32
32
  import { escapeAttribute } from "../src/lib/composer-attachments.js";
33
+ import { threadRefUrl } from "../src/lib/thread-refs.js";
33
34
  import { CREDENTIAL_TARGETS, credentialResumeOutcome, credentialIsConfigured, isPendingCredentialRequest, isCredentialTargetId, } from "../shared/credential-request.js";
34
- import { approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval, delegationInheritsFullAccess } from "./auto-approve.js";
35
+ import { approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, deliverFullAccessApproval, delegatedApprovalMode } from "./auto-approve.js";
35
36
  import { CommandAllowlistStore, commandAllowlistCandidate } from "./command-allowlist.js";
36
37
  import { updateClaudeCli } from "./claude-update.js";
37
38
  import { configuredAccountDirectory, assertSeparateClaudeAccount, claudeAccountInfo, createClaudeAccountSchema, instanceSettingsSchema, newClaudeAccount } from "./claude-accounts.js";
@@ -52,8 +53,11 @@ import { RoomTurnDeadline, RoomTurnStallRegistry, roomTurnTimeoutMessage } from
52
53
  import * as boat from "./boat.js";
53
54
  import { TeamComputers, teamComputerAssignment, teamComputerCreate, teamComputerOwner } from "./team-computers.js";
54
55
  import { isEffortLevel } from "../shared/wire.js";
56
+ import { isPersistentQuestionCard, QUESTION_DISMISS_MESSAGE, shouldSettleRequestCard } from "../shared/ask-question.js";
57
+ import { parseToolScope, toolScopeWidens } from "../shared/tool-scope.js";
55
58
  import { boatCreateRecoverySnapshot, retireDeletedBoatCreate } from "./boat-create-idempotency.js";
56
59
  import { boatDeletionSnapshot } from "./boat-delete-journal.js";
60
+ import { liveDecisionRefusal } from "../shared/live-approval.js";
57
61
  import { boatAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourceChangeError, } from "./cloud-backend.js";
58
62
  import * as composio from "./composio.js";
59
63
  import { connectorCallFromFrame, connectorRefusalText, connectorUnrecognizedText, evaluateConnectorTools } from "./connector-verdict.js";
@@ -63,14 +67,15 @@ import { createMemoryUpkeep, upkeepEnabled } from "./memory-upkeep.js";
63
67
  import { appendAboutMe, commitLearned, planLearned } from "./profile-learned.js";
64
68
  import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, livePeerRosterBlock, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
65
69
  import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
66
- import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerExec, containerRuntimeStatus, localVmRecreatableOnDemand, localVmWorkspaceExists, perBotLocalVmTarget, poolLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, VM_WORKSPACE_GUEST, } from "./container-computer.js";
70
+ import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerExec, containerRuntimeStatus, localVmWakeAction, localVmWorkspaceExists, perBotLocalVmTarget, poolLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, VM_WORKSPACE_GUEST, } from "./container-computer.js";
67
71
  import { attachForTurn, saveBotAttachment } from "./bot-attachment.js";
68
- import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmIdleTimeoutMinutes, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, computerClaimIdleReleaseEnabled, cloudOverflowAllowlistedThreads, cloudOverflowEnabled, cloudOverflowIdleStopMs, cloudOverflowPerSecondCostUsd, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, stripWorkspaceCredentialEnv, PROVIDER_CREDENTIAL_ENV, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, CLAUDE_API_INSTANCE, } from "./config.js";
72
+ import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmIdleTimeoutMinutes, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, computerClaimIdleReleaseEnabled, cloudOverflowAllowlistedThreads, cloudOverflowEnabled, cloudOverflowIdleStopMs, cloudOverflowPerSecondCostUsd, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, stripWorkspaceCredentialEnv, PROVIDER_CREDENTIAL_ENV, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, skillsLibraryEnabled, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, CLAUDE_API_INSTANCE, liveSettingsFor, } from "./config.js";
69
73
  import { sweepThreadEventLogs } from "./thread-retention.js";
70
74
  import { ComputerControl } from "./computer-control.js";
71
75
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
72
76
  import { registerEnginesBinDir } from "./engine-install.js";
73
77
  import { appendUsage, parseUsageRange, readUsage, summarizeUsage, usageCsv, USAGE_GROUPINGS, flushUsageLedger } from "./usage-ledger.js";
78
+ import { loadPlanUsage, planAccountsFromInstances } from "./plan-usage.js";
74
79
  import { GroupUsageReader } from "./group-thread-usage.js";
75
80
  import { ledgerCost } from "./model-prices.js";
76
81
  import { checkProviderKey, PROVIDER_KEY_KINDS } from "./provider-key-check.js";
@@ -102,7 +107,7 @@ import { attemptAsideInjection, drainAsideMessages, queueAsideMessage, restoreAs
102
107
  import { promptWithReply, transcriptText } from "./replies.js";
103
108
  import { admit, DRAIN_COALESCE_MAX_ITEMS } from "./admission.js";
104
109
  import { _loadPending, buildDelegationFailurePrompt, buildDelegationRevivalPrompt, DELEGATION_TTL_MS, DelegationWakeBudget, discardDelegations, drainDelegations, expireStaleDelegations, findDelegationReceipt, pendingDelegationInfo, pendingDelegationSnapshot, pendingThreads, queueDelegation, recordDelegationReceipt, releaseDelegationsWaitingOn, summarizeDelegatedActivity } from "./delegations.js";
105
- import { cancelSteeredMessage, drainSteeredMessages, hasQueuedSteeredMessages, holdSteeredQueue, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, restoreHeldSteeredQueue, restoreSteeredMessages, settleHeldSteeredQueue, } from "./steer-queue.js";
110
+ import { cancelSteeredMessage, drainSteeredMessages, hasQueuedSteeredMessages, holdSteeredQueue, isSteeredMessageQueued, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, restoreHeldSteeredQueue, restoreSteeredMessages, settleHeldSteeredQueue, } from "./steer-queue.js";
106
111
  import { cancelChannelMessage, drainChannelMessages, holdChannelQueue, headChannelGroup, queuedChannelMessage, queueChannelMessage, restoreChannelMessages, restoreHeldChannelQueue, resolveHeldReplyTarget, settleHeldChannelQueueHead, } from "./channel-queue.js";
107
112
  import { acceptedSendMatch, parseSendId, sendFingerprint, SendSequencer, } from "./send-idempotency.js";
108
113
  import { EventBus } from "./harness/bus.js";
@@ -123,7 +128,10 @@ import { isProjectEmoji, mentionedBots, parseConnectorTools, roomResponders, sec
123
128
  import * as tts from "./tts/index.js";
124
129
  import { createDecider, deciderIncludedHere, deciderReady, deciderSavePatch, describeDecider } from "./decider/index.js";
125
130
  import { decideRoomResponder } from "./decider/room-routing.js";
131
+ import { createLiveSession, liveAttachUrl, LiveSessionError } from "./live-call.js";
132
+ import { LiveCallController, LiveCallSignedOutError } from "./live-call-controller.js";
126
133
  import { narrateTool, toUtterances } from "./tts/speech-text.js";
134
+ import { turnStartLogLine } from "./turn-log.js";
127
135
  import { buildRecoveryText, buildTurnContext, engineIsFresh, NATIVELY_REPLAYING_DRIVER_KINDS, peerMessageText } from "./turn-context.js";
128
136
  import { Handoffs, handedStateUsable, recordHanded, renderUnseen, sessionStart, unseenMessages, withUnseenMessages } from "./delta-context.js";
129
137
  import { extractTurnImages } from "./turn-images.js";
@@ -135,7 +143,7 @@ import { ensureWorkspace, ensureTaskWorkspace, workspaceLocationsPrompt, support
135
143
  import { listMemoryTopics, memoryDate, readMemoryFile, readMemoryTopic, writeMemoryFile, writeMemoryTopic } from "./workspace.js";
136
144
  import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, journalMemoryWrite, } from "./memory-journal.js";
137
145
  import { readSectionContext, readSections, sectionContextKey, sectionContextLabel, sectionContextSystemPrompt, writeSectionContext, SECTION_CONTEXT_MAX_BYTES, } from "./section-context.js";
138
- import { applyStagedSkillWrite, applySkillWriteWithReceipt, getStagedSkillWrite, installOrgSkill, installSkill, parseSkillMd, scanSkillText, listSkills, listStagedSkillWrites, isSkillName, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillPackageStamps, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
146
+ import { applyStagedSkillWrite, applySkillWriteWithReceipt, getStagedSkillWrite, installOrgSkill, installSkill, listBotSkillsWithOrigin, resolveBotSkills, parseSkillMd, scanSkillText, listSkills, listStagedSkillWrites, isSkillName, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillPackageStamps, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
139
147
  import { fetchSkillFromSource } from "./skill-fetch.js";
140
148
  import { pickBotName } from "./names.js";
141
149
  import { botSkillTemplateSchema, newBotDefaultsSchema, resolveBotCreationDefaults } from "./new-bot-defaults.js";
@@ -145,6 +153,7 @@ import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-
145
153
  import { buildSystemPrompt, userProfileSystemPrompt, cloudHomePrompt, computerPrompt, composioSystemPrompt, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, resolveComputerPromptKind, } from "./system-prompt.js";
146
154
  import { readCuaConnection, readCuaUnavailableReason, gatedLocalComputer } from "./local-computer.js";
147
155
  import { discoverExistingPerBotLocalVms, discoverExistingPoolLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
156
+ import { localVmStopReason, recordLocalVmIdleStop } from "./local-vm-stop-reason.js";
148
157
  import { LocalVmIdleTimer } from "./local-vm-idle.js";
149
158
  import { LocalVmLeasePool } from "./local-vm-lease.js";
150
159
  import { LocalVmSeatPool } from "./local-vm-seat-pool.js";
@@ -179,7 +188,8 @@ import { listenWebhookIngress, webhookCredential } from "./webhook-ingress.js";
179
188
  import { assertModelVariantSupported, memberTurnSelection } from "./member-turn.js";
180
189
  import { WebhookManager } from "./webhooks.js";
181
190
  import { SPAWNED_PROXIES } from "./proxy-paths.js";
182
- import { loadBundledSkills, loadUserSkills, mergeSkills, renderSkillInstructions, selectBundledSkills } from "./skill-library.js";
191
+ import { installLibrarySkill, listLibrarySkills, loadBundledSkills, loadUserSkills, mergeSkills, readLibrarySkillFile, readSkillLibraryIndex, renderSkillInstructions, selectBundledSkills, setLibrarySkillReviewState, } from "./skill-library.js";
192
+ import { runSkillsLibraryBootSweep } from "./skills-library-migration.js";
183
193
  import { installedPlaybookInstructions } from "./installed-playbooks.js";
184
194
  import { createBotPackageExport, createLibraryPackageExport, createTeamPackageExport, picture as sharedPicture, TeamExportError } from "./package-export.js";
185
195
  import { importPackageDocument, importTeamManifest, PackageImportError } from "./package-import.js";
@@ -191,7 +201,7 @@ import { MAX_TEAM_BACKUP_BYTES } from "../shared/team-backup.js";
191
201
  import { shouldMountLocalComputer } from "./local-routing.js";
192
202
  import { autoLocalVmAttachable } from "./container-computer.js";
193
203
  import { startAutoVmClaim } from "./auto-vm-claims.js";
194
- import { computerFreeAfterText, computerStillBusyText, computerStoppedWaitingText, computerWaitingText } from "./computer-wait.js";
204
+ import { computerFreeAfterText, computerParkedText, computerStoppedWaitingText, computerWaitingText } from "./computer-wait.js";
195
205
  import { CloudOverflowConsent, CloudSeatLease, cloudOverflowAction, cloudOverflowConsentText, cloudOverflowOfferText, cloudSeatStartedText, cloudSeatStoppedText } from "./cloud-overflow.js";
196
206
  import { modelContextWindow } from "./model-context-window.js";
197
207
  import { parseSurface, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
@@ -226,6 +236,7 @@ import { createDesktopViewer, desktopViewerUrl } from "./routes/desktop-viewer.j
226
236
  import { localDesktopTarget, localVmViewerStatus, viewerTargetId } from "./desktop-viewer-targets.js";
227
237
  import { createAntigravityLeftoverRoutes } from "./routes/antigravity-leftovers.js";
228
238
  import { findAntigravityLeftovers, removeAntigravityLeftovers } from "./drivers/antigravity-temp.js";
239
+ import { createLiveRoutes } from "./routes/live.js";
229
240
  const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799);
230
241
  const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1);
231
242
  // Behind a proxy or tunnel, the base URL senders should use (docs/self-hosting.md).
@@ -992,9 +1003,10 @@ function cloudCardAnswerer(card) {
992
1003
  return card.requestId ? cloudCardAnswersInFlight.get(card.requestId) : undefined;
993
1004
  }
994
1005
  /** Answer a card as `auth`: the decision rows written meanwhile name the
995
- * answerer, and a card this answer settled records who settled it. A card
996
- * that was already settled keeps whatever it said. */
997
- async function answeringCardAs(auth, threadId, requestId, work) {
1006
+ * answerer, and a card this answer settled records who settled it (and
1007
+ * `via: "call"` when it was decided by voice on a Live call). A card that
1008
+ * was already settled keeps whatever it said. */
1009
+ async function answeringCardAs(auth, threadId, requestId, work, via) {
998
1010
  const open = (() => {
999
1011
  const card = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId)?.card;
1000
1012
  return Boolean(card && !card.answered && !card.dismissed && !card.expired);
@@ -1002,13 +1014,13 @@ async function answeringCardAs(auth, threadId, requestId, work) {
1002
1014
  if (CLOUD_HOME && open)
1003
1015
  cloudCardAnswersInFlight.set(requestId, auth.kind === "session" ? actorKey(auth) : undefined);
1004
1016
  try {
1005
- await withDecisionActor(decisionActorFor(auth), work);
1017
+ await withDecisionActor(decisionActorFor(auth), work, via);
1006
1018
  }
1007
1019
  finally {
1008
1020
  const message = open ? store.messagesFor(threadId).find((candidate) => candidate.card?.requestId === requestId) : undefined;
1009
1021
  const card = message?.card;
1010
1022
  if (message && card && !card.answeredBy && card.answered !== "unavailable" && (card.answered || card.dismissed)) {
1011
- store.patchMessage(threadId, message.id, { card: { ...card, answeredBy: cardAnswererFor(auth) } });
1023
+ store.patchMessage(threadId, message.id, { card: { ...card, answeredBy: { ...cardAnswererFor(auth), ...(via ? { via } : {}) } } });
1012
1024
  }
1013
1025
  if (CLOUD_HOME && open)
1014
1026
  cloudCardAnswersInFlight.delete(requestId);
@@ -1518,6 +1530,25 @@ function resyncOpenCodeProviderKeys() {
1518
1530
  await registry.load(providerConfigs(), decorateHostedProvider);
1519
1531
  const bundledSkills = loadBundledSkills();
1520
1532
  const availableSkills = () => mergeSkills(bundledSkills, loadUserSkills(join(DATA_DIR, "skills")));
1533
+ // Skills library (features.skillsLibrary). While the flag is off both
1534
+ // helpers reduce exactly to today's per-bot calls; assignments recorded on
1535
+ // bot records stay inert until it is on.
1536
+ const listBotSkills = (bot) => skillsLibraryEnabled(cfg) ? resolveBotSkills(bot.id, bot.assignedSkills) : listSkills(bot.id);
1537
+ const readBotSkillFile = (bot, name) => {
1538
+ const own = readSkillFile(bot.id, name);
1539
+ if (own !== null || !skillsLibraryEnabled(cfg))
1540
+ return own;
1541
+ // A private listing whose stored bytes no longer verify must not fall
1542
+ // through to a same-named library skill: private wins collisions, so a
1543
+ // broken private file stays unreadable instead of exporting another
1544
+ // bot's library instructions under this bot's name.
1545
+ if (listSkills(bot.id).some((skill) => skill.name === name))
1546
+ return null;
1547
+ // The library fallback is by assignment only, never by name alone.
1548
+ if (!bot.assignedSkills?.includes(name))
1549
+ return null;
1550
+ return readLibrarySkillFile(name);
1551
+ };
1521
1552
  // SAFETY: Electron's utility-process runtime is the only environment that
1522
1553
  // supplies parentPort; plain Node intentionally leaves it absent.
1523
1554
  const utilityParentPort = process.parentPort;
@@ -1974,7 +2005,20 @@ function hooksIntegration(botId, threadId, generation) {
1974
2005
  }
1975
2006
  class DirectTurnSetupCancelled extends Error {
1976
2007
  }
1977
- class ComputerWaitGaveUp extends Error {
2008
+ /** The computer wait ceiling fired (ADR-2, #1651). The turn parks — settled
2009
+ * with a registered resume — instead of failing; the carried resource names
2010
+ * the seat the resume must wait to free. */
2011
+ class ComputerWaitParked extends Error {
2012
+ resource;
2013
+ /** The parked turn's generation: the drain distinguishes the turn's own
2014
+ * settle window (keep, retry later) from a newer turn that superseded it
2015
+ * (delete). */
2016
+ generation;
2017
+ constructor(message, resource, generation) {
2018
+ super(message);
2019
+ this.resource = resource;
2020
+ this.generation = generation;
2021
+ }
1978
2022
  }
1979
2023
  const directTurnDispatchClaims = new Map();
1980
2024
  const directTurnGenerationByThread = new Map();
@@ -2018,6 +2062,12 @@ const turnResources = new TurnResources();
2018
2062
  const sharedComputerControl = new SharedComputerControl(turnResources, () => store.bots.some(bot => botComputerControlSnapshot(bot.id).held));
2019
2063
  const turnResourceOwners = new Map();
2020
2064
  const turnComputerResources = new Map();
2065
+ /** Screen-touching computer calls that started before their turn's computer
2066
+ * claim was bound (#1653 over the lazy attach of #1361): the start event
2067
+ * finds no claim to fence, so it waits here and bindTurnComputer carries it
2068
+ * into the fresh claim, keeping the quiet window from releasing a seat
2069
+ * under a call the desktop is already executing. */
2070
+ const pendingComputerCallStarts = new Map();
2021
2071
  const teamComputerTurns = new Map();
2022
2072
  const settlingResourceOwners = new Map();
2023
2073
  /** Idle release for computer claims (#1653), disabled until the feature
@@ -2027,10 +2077,11 @@ const settlingResourceOwners = new Map();
2027
2077
  * yields once another turn holds the seat. The durable VM profile keeps
2028
2078
  * its login state, so that re-claim is cheap. Read per claim: a flag
2029
2079
  * change applies to new claims, never to a seat held under the old
2030
- * setting. */
2080
+ * setting. computer:phone is excluded: it is the #1663 exclusivity lock,
2081
+ * claimed once per turn with no activity signal to renew it. */
2031
2082
  const COMPUTER_CLAIM_IDLE_POLICY = new IdleReleasePolicy();
2032
2083
  function computerClaimIdlePolicy(resource) {
2033
- return resource.startsWith("computer:") && computerClaimIdleReleaseEnabled(cfg) ? COMPUTER_CLAIM_IDLE_POLICY : undefined;
2084
+ return resource.startsWith("computer:") && resource !== "computer:phone" && computerClaimIdleReleaseEnabled(cfg) ? COMPUTER_CLAIM_IDLE_POLICY : undefined;
2034
2085
  }
2035
2086
  /** #1655: the consent ledger for local-wait cloud overflow, and the live
2036
2087
  * cloud seats it has started. One seat per bot — it is the bot's own Box,
@@ -2190,11 +2241,17 @@ function releaseTurnResources(owner) {
2190
2241
  turnResourceOwners.delete(owner.threadId);
2191
2242
  if (turnComputerResources.get(owner.threadId)?.owner.generation === owner.generation)
2192
2243
  turnComputerResources.delete(owner.threadId);
2244
+ if (pendingComputerCallStarts.get(owner.threadId)?.generation === owner.generation)
2245
+ pendingComputerCallStarts.delete(owner.threadId);
2193
2246
  const teamTurn = teamComputerTurns.get(owner.threadId);
2194
2247
  if (teamTurn?.owner.generation === owner.generation) {
2195
2248
  stopScreenPoller(teamTurn.botId, owner.threadId);
2196
2249
  teamComputerTurns.delete(owner.threadId);
2197
2250
  }
2251
+ // A freed seat is the parked-resume drain's trigger (#1651). Deferred past
2252
+ // this synchronous settle so the drain reads post-cleanup state.
2253
+ if (pendingComputerResumes.size > 0)
2254
+ queueMicrotask(drainComputerResumes);
2198
2255
  }
2199
2256
  async function bindTurnComputer(owner, resource, exclusive = false) {
2200
2257
  // Every computer claim passes here: a kind the organisation disallows, or a
@@ -2209,7 +2266,7 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2209
2266
  // bot running a titled thread, or a room. Read once, when the wait begins.
2210
2267
  let holder;
2211
2268
  let holderThreadRef;
2212
- const deadline = Date.now() + GROUP_GOAL_WAIT_MAX_MS;
2269
+ const deadline = Date.now() + COMPUTER_WAIT_MAX_MS;
2213
2270
  // The wait is history the transcript keeps (#1647): the waiting chip stays
2214
2271
  // exactly as written, a resolution line is appended beside it, and the
2215
2272
  // turn.wait_* events carry the same facts for the inspector log.
@@ -2235,15 +2292,18 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2235
2292
  return;
2236
2293
  waitEnded = true;
2237
2294
  const waitedMs = waitedSince === undefined ? 0 : Date.now() - waitedSince;
2295
+ // Acquisition is the only wait that completes: it is the sample the
2296
+ // per-resource estimate is built from (#1652).
2297
+ if (outcome === "acquired")
2298
+ turnResources.noteWait(resource, waitedMs);
2238
2299
  const text = outcome === "acquired"
2239
2300
  ? computerFreeAfterText(holder, waitedMs)
2240
- : outcome === "gave_up"
2241
- ? computerStillBusyText(holder, waitedMs)
2301
+ : outcome === "parked"
2302
+ ? computerParkedText(holder, COMPUTER_WAIT_MAX_MS)
2242
2303
  : computerStoppedWaitingText(holder, waitedMs);
2243
2304
  store.appendMessage(owner.threadId, {
2244
2305
  role: "bot", kind: "activity",
2245
- ...(outcome === "gave_up" ? { turnSucceeded: false } : {}),
2246
- tool: { name: text, ok: outcome !== "gave_up" },
2306
+ tool: { name: text, ok: true },
2247
2307
  ...(holderThreadRef ? { threadRef: holderThreadRef } : {}),
2248
2308
  });
2249
2309
  bus.publish({
@@ -2262,6 +2322,10 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2262
2322
  if (!exclusive || claimTurnResource(owner, resource))
2263
2323
  break;
2264
2324
  if (!waitingMessage) {
2325
+ // Arrival in the waitlist is the chip's timestamp: the position it
2326
+ // reads is where this turn sits the moment it starts waiting (#1652).
2327
+ const position = turnResources.startWaiting(resource, owner);
2328
+ const estimateMs = turnResources.waitEstimateMs(resource);
2265
2329
  const blocker = turnResources.blocker(resource, owner);
2266
2330
  const holderBot = blocker && store.botByThread(blocker.threadId);
2267
2331
  const holderTask = holderBot && blocker && store.taskByThread(holderBot.id, blocker.threadId);
@@ -2274,7 +2338,7 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2274
2338
  : undefined;
2275
2339
  waitingMessage = store.appendMessage(owner.threadId, {
2276
2340
  role: "bot", kind: "activity",
2277
- tool: { name: computerWaitingText(holder) },
2341
+ tool: { name: computerWaitingText(holder, { position, estimateMs }) },
2278
2342
  ...(holderThreadRef ? { threadRef: holderThreadRef } : {}),
2279
2343
  });
2280
2344
  waitedSince = Date.now();
@@ -2282,6 +2346,7 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2282
2346
  ...waitEventBase(),
2283
2347
  type: "turn.wait_started",
2284
2348
  resource,
2349
+ position,
2285
2350
  ...(holder ? { holder } : {}),
2286
2351
  });
2287
2352
  // #1655: the wait is where an overflow choice gets made. The card
@@ -2311,8 +2376,12 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2311
2376
  }
2312
2377
  sweepIdleCloudSeats();
2313
2378
  if (Date.now() >= deadline) {
2314
- endWait("gave_up");
2315
- throw new ComputerWaitGaveUp(computerStillBusyText(holder, GROUP_GOAL_WAIT_MAX_MS));
2379
+ // ADR-2 (#1651): the ceiling parks the turn — settled, with a
2380
+ // registered resume — instead of failing it. The owner mark lets the
2381
+ // live lazy-claim rejection path park the same way.
2382
+ endWait("parked");
2383
+ owner.computerParkedOn = resource;
2384
+ throw new ComputerWaitParked(computerParkedText(holder, COMPUTER_WAIT_MAX_MS), resource, owner.generation);
2316
2385
  }
2317
2386
  await new Promise(resolve => setTimeout(resolve, 100));
2318
2387
  }
@@ -2321,9 +2390,21 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2321
2390
  // Never patch the waiting chip over: the queue position this turn held
2322
2391
  // stays visible beside what the wait came to.
2323
2392
  endWait(active() && turnResources.owns(resource, owner) ? "acquired" : "stopped");
2393
+ // Whatever the wait came to, this turn is no longer waiting: an owner
2394
+ // left at the front would block the grant for everyone behind it.
2395
+ turnResources.stopWaiting(resource, owner);
2324
2396
  }
2325
2397
  turnResourceOwners.set(owner.threadId, owner);
2326
2398
  turnComputerResources.set(owner.threadId, { owner, resource });
2399
+ // A screen call that started before this bind (#1653) fenced nothing
2400
+ // because no claim existed yet: carry its pending start into the fresh
2401
+ // claim so the idle window still defers to the running call.
2402
+ const pendingStarts = pendingComputerCallStarts.get(owner.threadId);
2403
+ if (pendingStarts?.generation === owner.generation && pendingStarts.count > 0) {
2404
+ pendingComputerCallStarts.delete(owner.threadId);
2405
+ for (let i = 0; i < pendingStarts.count; i += 1)
2406
+ turnResources.beginComputerCall(resource, owner);
2407
+ }
2327
2408
  }
2328
2409
  function botForThread(botId, threadId) {
2329
2410
  return directTurnBots.get(threadId) ?? store.projectBotForTask(botId, threadId) ?? store.bot(botId);
@@ -2412,7 +2493,13 @@ function requestedTaskBot(botId, rawThreadId) {
2412
2493
  throw Object.assign(new Error("no such task"), { status: 404 });
2413
2494
  return task;
2414
2495
  }
2415
- async function interruptDirectThread(botId, threadId) {
2496
+ async function interruptDirectThread(botId, threadId, options) {
2497
+ // A parked turn has settled its provider but still owns a future resume.
2498
+ // Stop cancels that work too. The parking teardown itself preserves it.
2499
+ if (!options?.preserveComputerResume && pendingComputerResumes.delete(threadId)
2500
+ && store.taskByThread(botId, threadId)?.activity === "parked.computer") {
2501
+ store.setTaskActivity(botId, threadId, "idle");
2502
+ }
2416
2503
  const requestOwner = directRequestOwners.get(threadId);
2417
2504
  if (requestOwner) {
2418
2505
  requestOwner.stopped = true;
@@ -2454,7 +2541,7 @@ function noteTeammatesLeftRunning(botId, threadId, running) {
2454
2541
  }
2455
2542
  }
2456
2543
  async function interruptAllDirectThreads(botId) {
2457
- const threads = store.tasks(botId).filter((task) => task.busy || directTurnDispatchClaims.has(task.threadId) || roomHandoffs.activeDirect(task.threadId));
2544
+ const threads = store.tasks(botId).filter((task) => task.busy || directTurnDispatchClaims.has(task.threadId) || roomHandoffs.activeDirect(task.threadId) || pendingComputerResumes.has(task.threadId));
2458
2545
  // Revoke every sibling before yielding to any provider teardown.
2459
2546
  for (const task of threads) {
2460
2547
  cancelDirectTurnDispatch(botId, task.threadId);
@@ -2936,7 +3023,7 @@ function checkedExportSkillNames(value, bots) {
2936
3023
  return { ok: false, error: "skillIds must not contain duplicates" };
2937
3024
  if (names.length > BOT_PACKAGE_MAX_SKILLS)
2938
3025
  return { ok: false, error: `skillIds must contain at most ${BOT_PACKAGE_MAX_SKILLS} names` };
2939
- const available = new Set(bots.flatMap((bot) => listSkills(bot.id).map((skill) => skill.name)));
3026
+ const available = new Set(bots.flatMap((bot) => listBotSkills(bot).map((skill) => skill.name)));
2940
3027
  const unknown = names.find((name) => !available.has(name));
2941
3028
  if (unknown)
2942
3029
  return { ok: false, error: `skillIds contains unknown imported skill "${unknown}"` };
@@ -2949,10 +3036,10 @@ function collectExportSkills(bots, names) {
2949
3036
  return byBot;
2950
3037
  for (const bot of bots) {
2951
3038
  const assigned = [];
2952
- for (const listing of listSkills(bot.id)) {
3039
+ for (const listing of listBotSkills(bot)) {
2953
3040
  if (!selected.has(listing.name))
2954
3041
  continue;
2955
- const instructions = readSkillFile(bot.id, listing.name);
3042
+ const instructions = readBotSkillFile(bot, listing.name);
2956
3043
  if (instructions === null) {
2957
3044
  throw new Error(`Skill "${listing.name}" changed or is unavailable and cannot be exported safely`);
2958
3045
  }
@@ -2980,7 +3067,7 @@ function collectExportSkills(bots, names) {
2980
3067
  * skill choices from it, so a refused choice can always be changed. */
2981
3068
  function collectTeamSkills(bots, selection) {
2982
3069
  const all = selection === undefined || selection === "all";
2983
- const available = [...new Set(bots.flatMap((bot) => listSkills(bot.id).map((skill) => skill.name)))].sort();
3070
+ const available = [...new Set(bots.flatMap((bot) => listBotSkills(bot).map((skill) => skill.name)))].sort();
2984
3071
  if (!all && (!Array.isArray(selection) || selection.some((name) => typeof name !== "string" || !isSkillName(name)))) {
2985
3072
  return { ok: false, error: "skills must be \"all\" or a list of skill names", available };
2986
3073
  }
@@ -2992,10 +3079,10 @@ function collectTeamSkills(bots, selection) {
2992
3079
  const skipped = [];
2993
3080
  for (const bot of bots) {
2994
3081
  const assigned = [];
2995
- for (const listing of listSkills(bot.id)) {
3082
+ for (const listing of listBotSkills(bot)) {
2996
3083
  if (!chosen.has(listing.name))
2997
3084
  continue;
2998
- const instructions = readSkillFile(bot.id, listing.name);
3085
+ const instructions = readBotSkillFile(bot, listing.name);
2999
3086
  if (instructions === null) {
3000
3087
  if (!all)
3001
3088
  return { ok: false, error: `Skill "${listing.name}" changed or is unavailable and cannot be shared safely`, available };
@@ -3132,6 +3219,26 @@ const sendSequencer = new SendSequencer();
3132
3219
  bootSelection = await defaultSelection();
3133
3220
  store.seedIfEmpty();
3134
3221
  hostedModels?.reconcile(store);
3222
+ // Skills library boot sweep (features.skillsLibrary): migrate per-bot
3223
+ // copies into the shared library and apply assignments. Flag-off boots
3224
+ // never enter this block, keeping per-bot behavior byte-identical.
3225
+ if (skillsLibraryEnabled(cfg)) {
3226
+ try {
3227
+ const sweep = runSkillsLibraryBootSweep({
3228
+ bots: store.bots,
3229
+ patch: (botId, assignedSkills) => {
3230
+ store.patchBot(botId, { assignedSkills });
3231
+ },
3232
+ });
3233
+ const applied = sweep.outcomes.filter((outcome) => outcome.outcome !== "skipped").length;
3234
+ if (sweep.outcomes.length) {
3235
+ console.log(`[skills-library] boot sweep: ${applied} skill(s) migrated or deduplicated, ${sweep.outcomes.length - applied} skipped`);
3236
+ }
3237
+ }
3238
+ catch (error) {
3239
+ console.error(`[skills-library] boot sweep failed: ${error instanceof Error ? error.message : String(error)}`);
3240
+ }
3241
+ }
3135
3242
  // A committed profile cleanup means both its config deletion and bot-reference
3136
3243
  // cleanup were intended to be durable. Reconcile stale secondary references
3137
3244
  // before Electron can ACK and remove the journal: a crash between those writes
@@ -3173,7 +3280,7 @@ const wireTask = (task) => {
3173
3280
  return asleep ? { ...coordinated, snoozedUntil } : coordinated;
3174
3281
  };
3175
3282
  const wireBot = (bot) => {
3176
- const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, ...rest } = bot;
3283
+ const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, assignedSkills: _assignedSkills, ...rest } = bot;
3177
3284
  // An elevated selection is inert until the desktop confirms its exact
3178
3285
  // private reply. Every ordinary client sees the effective Ask state during
3179
3286
  // that two-phase window, never a grant that may still roll back.
@@ -3183,10 +3290,19 @@ const wireBot = (bot) => {
3183
3290
  return { ...visible, waitingForTeammates: activeCoordinationForThread(bot.threadId) && !threadBusy(bot.id, bot.threadId),
3184
3291
  avatarUrl: visible.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
3185
3292
  };
3293
+ function toolScopeForTurn(botId) {
3294
+ const bot = store.bot(botId);
3295
+ if (!bot)
3296
+ throw Object.assign(new Error("no such bot"), { status: 404 });
3297
+ const parsed = parseToolScope(bot.toolScope);
3298
+ if (!parsed.ok)
3299
+ throw Object.assign(new Error("This bot's tool selection is invalid. Open Access settings and save a valid selection before starting a turn."), { status: 409, code: "tool_scope" });
3300
+ return parsed.scope;
3301
+ }
3186
3302
  /** The correlated private response carries the requested value so Electron
3187
3303
  * can validate it before sending the confirmation that makes it effective. */
3188
3304
  const wireTrustedApprovalBot = (bot) => {
3189
- const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, ...rest } = bot;
3305
+ const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTighteningRequestId: _lastTighteningRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, assignedSkills: _assignedSkills, ...rest } = bot;
3190
3306
  return { ...rest, approvalMode: approvalModeFor(rest), avatarUrl: rest.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
3191
3307
  };
3192
3308
  /** A settings-based preview, not a receipt of a dispatched turn. No
@@ -3260,7 +3376,7 @@ function previewSystemPrompt(bot) {
3260
3376
  { id: "profile", label: "Profile changes", text: agentsMounted ? PROFILE_PROMPT : "" },
3261
3377
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
3262
3378
  { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: agentsMounted, fileTools: Boolean(privateWorkspace), enabled: bot.memoryEnabled !== false }) },
3263
- { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id) : "" },
3379
+ { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id, skillsLibraryEnabled(cfg) ? bot.assignedSkills : undefined) : "" },
3264
3380
  ]);
3265
3381
  const totalBytes = built.sections.reduce((n, s) => n + s.bytes, 0);
3266
3382
  return {
@@ -3325,7 +3441,7 @@ async function botOverview(bot) {
3325
3441
  webhooks: webhooks.list()
3326
3442
  .filter((webhook) => webhook.botId === bot.id)
3327
3443
  .map((webhook) => ({ name: webhook.name, enabled: webhook.enabled })),
3328
- skills: listSkills(bot.id).map((skill) => ({
3444
+ skills: listBotSkills(bot).map((skill) => ({
3329
3445
  name: skill.name,
3330
3446
  description: skill.description,
3331
3447
  enabled: skill.enabled,
@@ -3344,9 +3460,9 @@ async function botOverview(bot) {
3344
3460
  * this too, but no provider dispatch or later permission callback relies on
3345
3461
  * persistence having been produced exclusively by that route. Delegation
3346
3462
  * uses the receiving bot's grant, never the sender's (approvalModeForOrigin) —
3347
- * with one deliberate exception: a Chief of Staff with Full access makes the
3348
- * threads it delegates Full too (delegatedFullAccess), so the grant the
3349
- * person gave the Chief covers the work the Chief hands out. */
3463
+ * with one deliberate exception: a Chief of Staff's level flows down to the
3464
+ * threads it delegates (delegatedLevel), so the level the person gave the
3465
+ * Chief covers the work the Chief hands out. */
3350
3466
  const approvalModeForTurn = (bot, peerInitiated = false, threadId = bot.threadId) => {
3351
3467
  // On a Cloud home a turn a guest drives runs in Ask, whatever the bot's
3352
3468
  // own level. Judged by the conversation the turn runs in (a room's, for a
@@ -3359,51 +3475,62 @@ const approvalModeForTurn = (bot, peerInitiated = false, threadId = bot.threadId
3359
3475
  }
3360
3476
  return mode;
3361
3477
  };
3362
- /** Full belongs to the requesting conversation, not whichever sibling is
3478
+ /** A level belongs to the requesting conversation, not whichever sibling is
3363
3479
  * selected in the UI or the bot's default for future conversations. */
3364
- function fullAccessForSource(botId, threadId) {
3480
+ function sourceApprovalMode(botId, threadId) {
3365
3481
  const owner = connectorThread(botId, threadId);
3366
3482
  if (!owner)
3367
- return false;
3483
+ return "ask";
3368
3484
  const bot = store.projectBotForTask(botId, threadId) ?? owner.bot;
3369
3485
  // Origin changes Custom to Auto, never Full; no live-turn state is needed.
3370
- return approvalModeForTurn(bot, false, threadId) === "full";
3486
+ return approvalModeForTurn(bot, false, threadId);
3487
+ }
3488
+ function fullAccessForSource(botId, threadId) {
3489
+ return sourceApprovalMode(botId, threadId) === "full";
3371
3490
  }
3372
3491
  function peerReviewRequired(bot, threadId) {
3373
3492
  return Boolean(bot.approvePeerComms && !fullAccessForSource(bot.id, threadId));
3374
3493
  }
3375
- /** Full access flows down a Chief of Staff's delegation. The person gave the
3376
- * Chief Full access so its work runs without prompts; a teammate stopping
3377
- * that same work to ask defeats the grant — and in practice the person was
3378
- * answering every one of those cards, all day, for the whole team. So a
3379
- * teammate a Full-access Chief delegates to runs Full for that work: the
3380
- * recipient switches, whatever its own level says. The recipient's engine
3381
- * has to implement Full (supportsApprovalMode); otherwise the work keeps the
3382
- * recipient's own level, as before. Only a Chief passes access on — an
3383
- * ordinary bot's delegation still uses the recipient's setting. */
3384
- function delegatedFullAccess(from, fromThreadId, target) {
3385
- return delegationInheritsFullAccess({
3494
+ /** A Chief of Staff's level flows down its delegation. The person set the
3495
+ * Chief's level so its work runs that way; a teammate the Chief brings in
3496
+ * starting at "Ask for approval" meant switching every such thread by hand,
3497
+ * all day, for the whole team. So a teammate a Chief delegates to starts at
3498
+ * the Chief's level for that work: Auto-accept edits, Approve for me or Full
3499
+ * access (delegatedApprovalMode: never lower than the teammate's own level,
3500
+ * capped at what its engine implements). Only a Chief passes its level on —
3501
+ * an ordinary bot's delegation still uses the recipient's setting. Returns
3502
+ * null to keep the recipient's own level. */
3503
+ function delegatedLevel(from, fromThreadId, target, recipientMode) {
3504
+ return delegatedApprovalMode({
3386
3505
  senderIsChief: Boolean(from.chiefOfStaff),
3387
- senderHasFullAccess: fullAccessForSource(from.id, fromThreadId),
3506
+ senderMode: sourceApprovalMode(from.id, fromThreadId),
3388
3507
  sameBot: from.id === target.id,
3508
+ recipientMode,
3389
3509
  recipientDriverKind: registry.cliTarget(target.modelSelection.instanceId)?.driverKind,
3390
3510
  });
3391
3511
  }
3392
- /** Make a delegated thread Full and say so in it once, so the level the
3393
- * chip shows and the level the turns run at agree, and the person can see
3394
- * where the access came from. */
3395
- function grantDelegatedFullAccess(from, target, threadId) {
3396
- if (store.taskByThread(target.id, threadId)?.approvalMode === "full")
3512
+ /** The teammate's own level for a thread of its own, before any delegation. */
3513
+ function ownThreadLevel(target, threadId) {
3514
+ return approvalModeForTurn(store.projectBotForTask(target.id, threadId) ?? target, false, threadId);
3515
+ }
3516
+ const DELEGATED_LEVEL_NAMES = { edits: "Auto-accept edits", auto: "Approve for me", full: "Full access" };
3517
+ /** Start a thread the Chief opened at the Chief's level, and say so in it
3518
+ * once, so the level the chip shows and the level the turns run at agree,
3519
+ * and the person can see where it came from (and change it there). */
3520
+ function applyDelegatedLevel(from, fromThreadId, target, threadId) {
3521
+ const level = delegatedLevel(from, fromThreadId, target, ownThreadLevel(target, threadId));
3522
+ if (!level || store.taskByThread(target.id, threadId)?.approvalMode === level)
3397
3523
  return;
3398
- store.patchTask(target.id, threadId, { approvalMode: "full", autoApprove: false, alwaysAllow: [] });
3524
+ store.patchTask(target.id, threadId, { approvalMode: level, autoApprove: false, alwaysAllow: [] });
3525
+ const name = DELEGATED_LEVEL_NAMES[level] ?? level;
3399
3526
  store.appendMessage(threadId, {
3400
3527
  role: "bot",
3401
3528
  kind: "activity",
3402
- tool: { name: `Full access — delegated by ${from.name}, a Chief of Staff with Full access`, ok: true },
3529
+ tool: { name: `${name} — delegated by ${from.name}, a Chief of Staff on ${name}`, ok: true },
3403
3530
  });
3404
3531
  }
3405
- /** A room member's level for one turn. Work a Full-access Chief hands out
3406
- * in a room runs Full for that turn: the room thread is shared, so the
3532
+ /** A room member's level for one turn. Work a Chief hands out in a room runs
3533
+ * at the Chief's level for that turn: the room thread is shared, so the
3407
3534
  * level is not stored on it — it rides the handoff. */
3408
3535
  function roomTurnApprovalMode(bot, threadId, orchestration) {
3409
3536
  // On a Cloud home a room turn a guest drives runs in Ask (cloudGuestDriven).
@@ -3412,9 +3539,8 @@ function roomTurnApprovalMode(bot, threadId, orchestration) {
3412
3539
  const handoff = orchestration?.roomHandoffId ? roomHandoffs.nodes.get(orchestration.roomHandoffId) : undefined;
3413
3540
  const source = handoff?.parentId ? roomHandoffs.nodes.get(handoff.parentId) : undefined;
3414
3541
  const from = source ? store.bot(source.botId) : undefined;
3415
- if (from && source && delegatedFullAccess(from, source.threadId, bot))
3416
- return "full";
3417
- return approvalModeForTurn(bot, Boolean(orchestration?.roomHandoffId), threadId);
3542
+ const own = approvalModeForTurn(bot, Boolean(orchestration?.roomHandoffId), threadId);
3543
+ return (from && source && delegatedLevel(from, source.threadId, bot, own)) || own;
3418
3544
  }
3419
3545
  /** Privileged approval-mode transitions are deliberately absent from the
3420
3546
  * loopback HTTP authority model: a bot with shell access can curl that
@@ -3466,6 +3592,9 @@ function handleDesktopTrustedApprovalMessage(raw) {
3466
3592
  if (bot.approvalGrant.allThreads && mode === "full") {
3467
3593
  store.setAllThreadApprovalMode(botId, mode);
3468
3594
  }
3595
+ else if (bot.approvalGrant.refreshPermissions && bot.approvalGrant.threadId) {
3596
+ store.refreshTaskPermissions(botId, bot.approvalGrant.threadId);
3597
+ }
3469
3598
  else if (bot.approvalGrant.threadId) {
3470
3599
  store.patchTask(botId, bot.approvalGrant.threadId, { approvalMode: mode, autoApprove: false });
3471
3600
  }
@@ -3671,6 +3800,39 @@ function handleDesktopTrustedApprovalMessage(raw) {
3671
3800
  respond({ ok: false, error: "Invalid thread approval scope" });
3672
3801
  return true;
3673
3802
  }
3803
+ if (message.refreshPermissions !== undefined && message.refreshPermissions !== true) {
3804
+ respond({ ok: false, error: "Invalid permission refresh" });
3805
+ return true;
3806
+ }
3807
+ if (message.refreshPermissions === true) {
3808
+ const target = typeof threadId === "string" ? store.projectBotForTask(botId, threadId) : null;
3809
+ const savedMode = approvalModeFor({ ...existing, approvalGrant: undefined });
3810
+ if (message.threadOnly !== true || !target || message.allThreads === true || message.modelSelection !== undefined || message.updateBotDefault !== undefined || savedMode !== mode) {
3811
+ respond({ ok: false, error: "Choose this bot's approval level in bot settings before refreshing a thread" });
3812
+ return true;
3813
+ }
3814
+ if (existing.approvalGrant || threadBusy(botId, target.threadId)) {
3815
+ respond({ ok: false, error: "Stop this thread and finish its pending approval change first" });
3816
+ return true;
3817
+ }
3818
+ if (!supportsApprovalMode(target.modelSelection, mode)) {
3819
+ respond({ ok: false, error: "This thread's provider does not support that approval level" });
3820
+ return true;
3821
+ }
3822
+ if (mode === "auto" && target.computer === "local" && approvalModeFor(target) !== "auto" && message.acknowledgeLocalAuto !== true) {
3823
+ respond({ ok: false, error: "Auto mode on this computer requires confirming the warning" });
3824
+ return true;
3825
+ }
3826
+ if (mode === "full" || mode === "custom") {
3827
+ store.patchBot(botId, { approvalGrant: { requestId, mode, phase: "prepared", threadId: target.threadId, threadOnly: true, refreshPermissions: true } });
3828
+ }
3829
+ else if (!store.refreshTaskPermissions(botId, target.threadId)) {
3830
+ respond({ ok: false, error: "That thread is no longer available" });
3831
+ return true;
3832
+ }
3833
+ respond({ ok: true, bot: wireTrustedApprovalBot(store.bot(botId)) });
3834
+ return true;
3835
+ }
3674
3836
  if (message.threadOnly === true) {
3675
3837
  const target = typeof threadId === "string" ? store.projectBotForTask(botId, threadId) : null;
3676
3838
  if (!target || message.modelSelection !== undefined || message.updateBotDefault !== undefined) {
@@ -4044,6 +4206,7 @@ function updateChannel(groupId, value) {
4044
4206
  const channelTaskBlocked = (group) => groupIsWorking(group) ||
4045
4207
  store.groupTasks(group.id).some((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
4046
4208
  message.card?.requestId &&
4209
+ (!isPersistentQuestionCard(message.card) || runningTurnEngines.has(task.threadId)) &&
4047
4210
  !message.card.answered &&
4048
4211
  !message.card.dismissed && !message.card.expired));
4049
4212
  // Recovery can synchronously emit room changes. Load coordination state
@@ -4580,6 +4743,7 @@ function cancelGroupTurnOperations(groupId, threadId, outcome = {
4580
4743
  status: "stopped",
4581
4744
  detail: "Stopped by you.",
4582
4745
  }) {
4746
+ pendingComputerResumes.delete(threadId);
4583
4747
  cancelTeamSetupResumesForThread(threadId);
4584
4748
  roomHandoffs.cancelRoom(groupId, threadId);
4585
4749
  for (const operation of groupTurnOperations.get(groupId) ?? []) {
@@ -4864,6 +5028,18 @@ const desktopViewer = createDesktopViewer({
4864
5028
  });
4865
5029
  },
4866
5030
  live: auth => auth.kind === "loopback" || sessions.isLive(auth.session.id),
5031
+ // A phone paired with this server directly drives a bot's Local VM through
5032
+ // this proxy under its control lease (see the local-computer join route).
5033
+ // The lease must hold the bot's computer, and that computer must be the
5034
+ // desktop being viewed: a lease on one bot opens no other bot's VM.
5035
+ lease: (id, botId, controlLeaseId, threadId) => {
5036
+ const bot = store.bot(botId);
5037
+ if (!bot || viewerTargetId(localVmTargetForStatus(bot.id, threadId)) !== id)
5038
+ return;
5039
+ const key = botComputerControlKey(bot);
5040
+ const holds = () => computerControl.ownsLease(key, controlLeaseId);
5041
+ return holds() ? holds : undefined;
5042
+ },
4867
5043
  });
4868
5044
  function closeSessionStreams(sessionId) {
4869
5045
  browserLive.closeForOwner(sessionId);
@@ -5012,7 +5188,7 @@ const askMessageByRequest = new Map(); // threadId:requestId -> messageId
5012
5188
  const pendingCommandRules = new Map();
5013
5189
  function commandRememberRefusal(auth, threadId, requestId, behavior) {
5014
5190
  if (!canManageCommandAllowlist(auth))
5015
- return "Only the workspace owner or an admin can save bot-wide command permissions.";
5191
+ return "Only the owner or an admin can save bot-wide command permissions.";
5016
5192
  if (behavior !== "allow" || !pendingCommandRules.has(`${threadId}:${requestId}`)) {
5017
5193
  return "This live request does not contain a command that can be saved. Allow it once instead.";
5018
5194
  }
@@ -5066,7 +5242,7 @@ always, rememberCommand) {
5066
5242
  // An answered question keeps its words. `request.resolved` only records
5067
5243
  // the behavior, so without this the card reads "answer" forever and the
5068
5244
  // person can no longer see what they told the bot.
5069
- if (outcome !== "unavailable" && behavior === "answer" && message && cardMessage) {
5245
+ if (outcome === "answered" && behavior === "answer" && message && cardMessage) {
5070
5246
  const settled = store.messagesFor(threadId).find((m) => m.id === cardMessage.id)?.card;
5071
5247
  if (settled) {
5072
5248
  store.patchMessage(threadId, cardMessage.id, { card: { ...settled, answeredText: message } });
@@ -5098,19 +5274,78 @@ always, rememberCommand) {
5098
5274
  const existing = messageId
5099
5275
  ? thread.find((m) => m.id === messageId)
5100
5276
  : thread.find((m) => m.card?.requestId === requestId);
5101
- if (existing?.card && !existing.card.answered) {
5277
+ const persistentQuestion = isPersistentQuestionCard(existing?.card);
5278
+ if (existing?.card && !existing.card.answered && !persistentQuestion) {
5102
5279
  store.patchMessage(threadId, existing.id, { card: { ...existing.card, answered: "unavailable", dismissed: true } });
5103
5280
  }
5104
5281
  if (messageId)
5105
5282
  askMessageByRequest.delete(`${threadId}:${requestId}`);
5106
- store.appendMessage(threadId, {
5107
- role: "bot",
5108
- kind: "activity",
5109
- tool: { name: "Couldn't deliver that answer — the request is no longer open, so the action was not run", ok: false },
5110
- });
5283
+ if (!persistentQuestion)
5284
+ store.appendMessage(threadId, {
5285
+ role: "bot",
5286
+ kind: "activity",
5287
+ tool: { name: "Couldn't deliver that answer — the request is no longer open, so the action was not run", ok: false },
5288
+ });
5111
5289
  }
5112
5290
  return outcome;
5113
5291
  }
5292
+ /** A decision or answer on a provider/peer card, made on behalf of `auth`
5293
+ * (a Live call answers as the person who started the call). Harness-native
5294
+ * proposals (skill, routine, profile, default model, tightening, team setup)
5295
+ * are not handled here; they are reviewed on screen (liveDecisionRefusal).
5296
+ * Mirrors POST /api/threads/:id/respond. */
5297
+ async function respondToCard(input) {
5298
+ const { auth, threadId, requestId, behavior, message } = input;
5299
+ // A call outlives the request that started it: the session must still be valid.
5300
+ if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
5301
+ return { ok: false, status: 401, error: "The session that started this call has ended." };
5302
+ }
5303
+ const refusal = cardAnswerRefusal(auth, threadId, requestId, behavior);
5304
+ if (refusal)
5305
+ return { ok: false, status: 403, error: refusal };
5306
+ const bot = store.botByThread(threadId);
5307
+ if (!bot)
5308
+ return { ok: false, status: 404, error: "The chat is gone." };
5309
+ const cardMessage = store.messagesFor(threadId).find((candidate) => candidate.card?.requestId === requestId);
5310
+ const card = cardMessage?.card;
5311
+ // Refused before anything runs: answerRequest would close a proposal it
5312
+ // cannot deliver as "unavailable", and would add a false "not run" line to
5313
+ // a card someone settled on screen a moment ago.
5314
+ const refused = liveDecisionRefusal(card);
5315
+ if (refused)
5316
+ return { ok: false, status: 409, error: refused };
5317
+ let result = { ok: true };
5318
+ await answeringCardAs(auth, threadId, requestId, async () => {
5319
+ // peer-approval intercept, as the route: only a card on this thread
5320
+ if (card && resolvePeerComms(approvalBus, requestId, behavior))
5321
+ return;
5322
+ const outcome = await answerRequest(threadId, botForThread(bot.id, threadId)?.modelSelection.instanceId ?? "", requestId, behavior, message, { id: bot.id, name: bot.name });
5323
+ if (outcome === "unavailable" && isPersistentQuestionCard(card) && behavior === "answer" && cardMessage) {
5324
+ const answer = message?.trim() ?? "";
5325
+ if (!answer) {
5326
+ result = { ok: false, status: 400, error: "A question answer is required." };
5327
+ }
5328
+ else if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
5329
+ result = { ok: false, status: 401, error: "The session that started this call has ended." };
5330
+ }
5331
+ else {
5332
+ try {
5333
+ await deliverLateQuestionAnswer(auth, threadId, requestId, answer, cardMessage, bot, "call");
5334
+ }
5335
+ catch (error) {
5336
+ result = { ok: false, status: 409, error: error instanceof Error ? error.message : "The late answer could not be queued." };
5337
+ }
5338
+ }
5339
+ }
5340
+ else if (outcome === "unavailable") {
5341
+ result = { ok: false, status: 409, error: "The request is no longer open." };
5342
+ }
5343
+ else if (behavior === "answer" && outcome !== "answered") {
5344
+ result = { ok: false, status: 409, error: "The provider did not accept that answer. Try an offered option." };
5345
+ }
5346
+ }, "call");
5347
+ return result;
5348
+ }
5114
5349
  /** Close every provider-owned approval still open on a thread. Interrupting a
5115
5350
  * turn kills the process that raised its questions, so those cards can never
5116
5351
  * be answered. Routine proposals are harness-owned and durable, so they stay
@@ -5128,6 +5363,12 @@ function closeOpenApprovals(threadId) {
5128
5363
  continue;
5129
5364
  if (card.routineRequest || card.skillRequest)
5130
5365
  continue;
5366
+ if (isPersistentQuestionCard(card)) {
5367
+ // Keep the durable card; its in-flight adapter mapping cannot survive
5368
+ // turn cleanup, and the response route can resume it from the transcript.
5369
+ askMessageByRequest.delete(`${threadId}:${card.requestId}`);
5370
+ continue;
5371
+ }
5131
5372
  store.patchMessage(threadId, message.id, { card: { ...card, answered: "unavailable", dismissed: true } });
5132
5373
  askMessageByRequest.delete(`${threadId}:${card.requestId}`);
5133
5374
  }
@@ -5189,6 +5430,9 @@ const ASK_BOT_TIMEOUT_MS = Math.max(5_000, Number(process.env.OMB_ASK_BOT_TIMEOU
5189
5430
  // free up and reassigns, and a chat round moves on with a chip that says so —
5190
5431
  // the wait ends as data, not as a dead room. Tests shrink it.
5191
5432
  const GROUP_GOAL_WAIT_MAX_MS = Math.max(1_000, Number(process.env.OMB_GOAL_WAIT_MAX_MS) || 30 * 60_000);
5433
+ // ADR-2 (#1651): the computer wait has its own ceiling, decoupled from the
5434
+ // room cap above, and the deadline parks the turn instead of failing it.
5435
+ const COMPUTER_WAIT_MAX_MS = Math.max(1_000, Number(process.env.OMB_COMPUTER_WAIT_MAX_MS) || 30 * 60_000);
5192
5436
  // Reassigning around a busy teammate is bounded too: after this many
5193
5437
  // exhausted waits in one run the team is blocked on availability, not stuck.
5194
5438
  const GROUP_GOAL_MAX_WAIT_EXHAUSTIONS = 3;
@@ -5199,10 +5443,9 @@ const watchdog = new TurnWatchdog({
5199
5443
  onStall: (turn) => {
5200
5444
  const stalledResourceOwner = turnResourceOwners.get(turn.threadId);
5201
5445
  const stalledGeneration = directTurnGenerationByThread.get(turn.threadId);
5446
+ const stalledProviderTurn = directRequestOwners.get(turn.threadId);
5202
5447
  cancelDirectTurnDispatch(turn.botId, turn.threadId);
5203
- // Room targets carry an invocation identity; only those claims belong
5204
- // to the room grace cleanup added here.
5205
- const stalledVmTarget = groupSpeakers.has(turn.threadId) ? localVmThreadTargets.get(turn.threadId) : undefined;
5448
+ const stalledVmTarget = localVmThreadTargets.get(turn.threadId);
5206
5449
  revokeInternalCapabilitiesForThread(turn.threadId);
5207
5450
  repeats.settle(turn.threadId);
5208
5451
  const bot = botForThread(turn.botId, turn.threadId);
@@ -5240,6 +5483,9 @@ const watchdog = new TurnWatchdog({
5240
5483
  return;
5241
5484
  if (stalledResourceOwner && turnResourceOwners.get(turn.threadId)?.generation !== stalledResourceOwner.generation)
5242
5485
  return;
5486
+ if (stalledGeneration && stalledProviderTurn?.generation === stalledGeneration && stalledProviderTurn.turnId) {
5487
+ retireProviderTurn(stalledProviderTurn.turnId);
5488
+ }
5243
5489
  // A goal coordinator can stall before sendTurn reveals its provider
5244
5490
  // turn id. Reusing the room during that ambiguous pre-id window would
5245
5491
  // make old and replacement events indistinguishable. Keep ownership
@@ -5254,7 +5500,8 @@ const watchdog = new TurnWatchdog({
5254
5500
  }
5255
5501
  const group = store.groupByThread(turn.threadId);
5256
5502
  const speaker = groupSpeakers.get(turn.threadId);
5257
- if (group && group.busyBotId === turn.botId && speaker?.botId === turn.botId) {
5503
+ const stalledRoomSpeaker = group && group.busyBotId === turn.botId && speaker?.botId === turn.botId;
5504
+ if (stalledRoomSpeaker) {
5258
5505
  groupSpeakers.delete(turn.threadId);
5259
5506
  store.patchGroup(group.id, { busyBotId: null, unread: true });
5260
5507
  }
@@ -5262,7 +5509,7 @@ const watchdog = new TurnWatchdog({
5262
5509
  // computer claim. The generation checks above protect replacements.
5263
5510
  releaseTurnResources(stalledResourceOwner);
5264
5511
  const currentBot = store.bot(turn.botId);
5265
- if (currentBot?.busy) {
5512
+ if (currentBot?.busy && (threadBusy(currentBot.id, turn.threadId) || stalledRoomSpeaker)) {
5266
5513
  stopScreenPoller(currentBot.id, turn.threadId);
5267
5514
  vpsThreadEnded(currentBot.id, turn.threadId);
5268
5515
  if (store.taskByThread(currentBot.id, turn.threadId))
@@ -5276,6 +5523,7 @@ const watchdog = new TurnWatchdog({
5276
5523
  // connector and credential continuations.
5277
5524
  drainQueuedSends();
5278
5525
  drainConnectorResumes();
5526
+ drainComputerResumes();
5279
5527
  drainSecretResumes();
5280
5528
  drainTeamSetupResumes();
5281
5529
  }
@@ -6378,11 +6626,11 @@ function localVmIdleFor(target) {
6378
6626
  localVmLifecycleBusy.add(target.key);
6379
6627
  try {
6380
6628
  const status = await containerComputerStatus(undefined, undefined, target);
6381
- // The desktop leaves a stale X lock after stop, so idle cleanup
6382
- // removes only the disposable container. Its target-specific durable
6383
- // workspace and the shared prepared image remain.
6384
- if (status.container === "running") {
6385
- await containerComputerAction("remove", undefined, undefined, target);
6629
+ // The pinned VNC startup clears stale X locks. Keep the container's
6630
+ // home and installed software so inactivity is a reversible stop.
6631
+ if (status.container === "running" && status.managed) {
6632
+ const stopped = await containerComputerAction("stop", undefined, undefined, target);
6633
+ recordLocalVmIdleStop(target.key, stopped.stopped_at);
6386
6634
  }
6387
6635
  }
6388
6636
  finally {
@@ -6424,7 +6672,7 @@ function releaseLocalVmThread(threadId) {
6424
6672
  // Browser, This computer, Auto, or Off does not delete its old Local VM.
6425
6673
  void (async () => {
6426
6674
  if (localVmMode(cfg) === "pool") {
6427
- // Same restore rule as per-bot: idle cleanup removes the container, not
6675
+ // Same restore rule as per-bot: idle shutdown preserves the container and
6428
6676
  // its provisioned workspace, so every surviving seat stays Auto-eligible.
6429
6677
  const seats = localVmMaxInstances(cfg);
6430
6678
  for (let seat = 0; seat < seats; seat += 1) {
@@ -6667,8 +6915,23 @@ bus.subscribe((event) => {
6667
6915
  // they cannot keep a quiet seat held.
6668
6916
  if (touches && surface === "computer") {
6669
6917
  const computer = turnComputerResources.get(event.threadId);
6670
- if (computer)
6918
+ if (computer) {
6919
+ // A quiet window cannot expire while this call executes.
6920
+ turnResources.endComputerCall(computer.resource, computer.owner);
6671
6921
  turnResources.activity(computer.resource, computer.owner);
6922
+ }
6923
+ else {
6924
+ // The call never forwarded onto a bound desktop, or the lazy
6925
+ // bind never landed: drop its pending start so it cannot
6926
+ // fence a claim taken later (#1653).
6927
+ const pending = pendingComputerCallStarts.get(event.threadId);
6928
+ if (pending) {
6929
+ if (pending.count > 1)
6930
+ pendingComputerCallStarts.set(event.threadId, { ...pending, count: pending.count - 1 });
6931
+ else
6932
+ pendingComputerCallStarts.delete(event.threadId);
6933
+ }
6934
+ }
6672
6935
  // Real cloud-screen work refreshes the #1655 idle stop for
6673
6936
  // that seat; the poller's own frames never arrive here, so
6674
6937
  // preview traffic cannot keep a paid machine awake.
@@ -6700,6 +6963,26 @@ bus.subscribe((event) => {
6700
6963
  });
6701
6964
  if (event.itemId)
6702
6965
  toolMessageByItem.set(`${event.threadId}:${event.itemId}`, message.id);
6966
+ // A screen-touching computer tool opening is an in-flight call on
6967
+ // that turn's claim (#1653): the seat cannot be idle-released out
6968
+ // from under I/O the desktop is still executing. Its completion —
6969
+ // or the turn settling — drops the fence.
6970
+ if (screenTouchingTool(name) && screenSurfaceForTool(name) === "computer") {
6971
+ const computer = turnComputerResources.get(event.threadId);
6972
+ if (computer) {
6973
+ turnResources.beginComputerCall(computer.resource, computer.owner);
6974
+ }
6975
+ else {
6976
+ // A lazy attach (#1361) may not have seated the claim yet: the
6977
+ // gate finishes the bind before the call forwards, so hold the
6978
+ // start here and let bindTurnComputer fence the fresh claim.
6979
+ const generation = activeInternalGenerationByThread.get(event.threadId);
6980
+ if (generation) {
6981
+ const pending = pendingComputerCallStarts.get(event.threadId);
6982
+ pendingComputerCallStarts.set(event.threadId, pending?.generation === generation ? { generation, count: pending.count + 1 } : { generation, count: 1 });
6983
+ }
6984
+ }
6985
+ }
6703
6986
  }
6704
6987
  break;
6705
6988
  case "request.opened": {
@@ -6820,6 +7103,7 @@ bus.subscribe((event) => {
6820
7103
  subtitle: event.summary,
6821
7104
  options: event.choices?.length ? event.choices : permission ? ["Allow", "Deny"] : [],
6822
7105
  requestId: event.requestId,
7106
+ requestType: permission ? "permission" : "question",
6823
7107
  tool: permission ? event.tool : undefined,
6824
7108
  questionRequest: questions
6825
7109
  ? { version: 1, questions, ...(event.origin === "output" ? { origin: "output" } : {}) }
@@ -6887,9 +7171,15 @@ bus.subscribe((event) => {
6887
7171
  if (messageId) {
6888
7172
  const existing = store.messagesFor(event.threadId).find((m) => m.id === messageId);
6889
7173
  if (existing?.card && !existing.card.answered) {
6890
- store.patchMessage(event.threadId, messageId, {
6891
- card: { ...existing.card, answered: event.behavior, dismissed: event.source !== "user" },
6892
- });
7174
+ if (shouldSettleRequestCard(existing.card, event.source)) {
7175
+ store.patchMessage(event.threadId, messageId, {
7176
+ card: {
7177
+ ...existing.card,
7178
+ answered: event.behavior,
7179
+ dismissed: isPersistentQuestionCard(existing.card) ? false : event.source !== "user",
7180
+ },
7181
+ });
7182
+ }
6893
7183
  }
6894
7184
  if (event.requestId)
6895
7185
  askMessageByRequest.delete(`${event.threadId}:${event.requestId}`);
@@ -7017,9 +7307,13 @@ bus.subscribe((event) => {
7017
7307
  // interrupted the turn; Claude settles that interrupt as
7018
7308
  // exit_before_result, not "interrupted", so this generation's marker
7019
7309
  // keeps the same failure from reporting a second incident.
7310
+ // A turn parked at the computer ceiling (#1651) settles itself the
7311
+ // same way: interrupted for a resume, not broken.
7020
7312
  const lazyClaimAlreadyReported = event.stopReason === "exit_before_result" &&
7021
7313
  turnResourceOwners.get(event.threadId)?.lazyClaimFailureReported === true;
7022
- if (!event.ok && event.stopReason !== "interrupted" && !lazyClaimAlreadyReported && !routines?.runForThread(event.threadId)) {
7314
+ const computerParked = event.stopReason === "exit_before_result" &&
7315
+ turnResourceOwners.get(event.threadId)?.computerParkedOn !== undefined;
7316
+ if (!event.ok && event.stopReason !== "interrupted" && !lazyClaimAlreadyReported && !computerParked && !routines?.runForThread(event.threadId)) {
7023
7317
  const broken = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
7024
7318
  if (broken)
7025
7319
  reportIncident({ kind: "failed", bot: broken, threadId: event.threadId, detail: event.stopReason?.trim() || "the run ended without a result" });
@@ -7049,7 +7343,7 @@ bus.subscribe((event) => {
7049
7343
  if (!isCurrent())
7050
7344
  return;
7051
7345
  if (store.taskByThread(bot.id, event.threadId)?.activity !== "dead") {
7052
- store.setTaskActivity(bot.id, event.threadId, "idle");
7346
+ store.setTaskActivity(bot.id, event.threadId, resourceOwner?.computerParkedOn !== undefined ? "parked.computer" : "idle");
7053
7347
  }
7054
7348
  directTurnBots.delete(event.threadId);
7055
7349
  if (continueComputerSelection(event.threadId, generation, event.ok))
@@ -7060,6 +7354,7 @@ bus.subscribe((event) => {
7060
7354
  retryDelegationsWaitingOn(bot.id);
7061
7355
  drainQueuedSends();
7062
7356
  drainConnectorResumes();
7357
+ drainComputerResumes();
7063
7358
  drainSecretResumes();
7064
7359
  drainTeamSetupResumes();
7065
7360
  drainDelegationWakes();
@@ -7567,7 +7862,7 @@ function finalizeDelegationWatch(threadId, ok, reply = "", failureName = "Delega
7567
7862
  const source = watched.sourceBotId
7568
7863
  ? store.bot(watched.sourceBotId)
7569
7864
  : (watched.sourceThreadId ? store.botByThread(watched.sourceThreadId) : undefined);
7570
- if (source && target && !canReachPeer(source, target)) {
7865
+ if (!watched.oneWay && source && target && !canReachPeer(source, target)) {
7571
7866
  ok = false;
7572
7867
  reply = "";
7573
7868
  failureName = "Result withheld: team or peer access changed while the teammate was working";
@@ -7585,6 +7880,16 @@ function finalizeDelegationWatch(threadId, ok, reply = "", failureName = "Delega
7585
7880
  result: ok ? reply : failureName,
7586
7881
  });
7587
7882
  }
7883
+ if (watched.oneWay) {
7884
+ if (!ok && store.taskByThread(watched.toBotId, threadId)) {
7885
+ store.appendMessage(threadId, {
7886
+ role: "bot",
7887
+ kind: "activity",
7888
+ tool: { name: `Send failed — ${failureName}`, ok: false, ...(watched.taskId ? { handoffId: watched.taskId } : {}) },
7889
+ });
7890
+ }
7891
+ return true;
7892
+ }
7588
7893
  let channel = watched.channelId ? store.group(watched.channelId) : undefined;
7589
7894
  let terminalThreadId = watched.sourceThreadId;
7590
7895
  if (source && watched.sourceThreadId) {
@@ -7728,7 +8033,7 @@ bus.subscribe((event) => {
7728
8033
  /** How a drained delegation becomes a real turn on the target. Shared by
7729
8034
  * the settle-time drain and the boot-time drain of what a previous process
7730
8035
  * left queued. */
7731
- const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel, taskId, sourceBotId, openedThreadId) => {
8036
+ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel, taskId, sourceBotId, openedThreadId, oneWay) => {
7732
8037
  // startTurn REJECTS on an ordinary condition — busy target, deleted bot,
7733
8038
  // unavailable provider. Unhandled, that rejection is fatal to the
7734
8039
  // harness (Node's default), which in the packaged app kills the server
@@ -7749,7 +8054,7 @@ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel,
7749
8054
  ? { botId: opener.id, name: opener.name, unattended: unattended || undefined }
7750
8055
  : undefined;
7751
8056
  const text = openedThreadId && opener
7752
- ? withPeerProvenance(rawText, { botName: opener.name, delivery: "start_thread", unattended })
8057
+ ? withPeerProvenance(rawText, { botName: opener.name, delivery: oneWay ? "send_to_bot" : "start_thread", unattended })
7753
8058
  : rawText;
7754
8059
  if (targetThreadId) {
7755
8060
  delegationWatch.set(targetThreadId, {
@@ -7761,6 +8066,7 @@ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel,
7761
8066
  sourceBotId,
7762
8067
  routineRunId: activeRoutineRunForThread(sourceThreadId)?.id,
7763
8068
  startedAtMs: Date.now(),
8069
+ ...(oneWay ? { oneWay: true } : {}),
7764
8070
  });
7765
8071
  }
7766
8072
  let failureReported = false;
@@ -7846,8 +8152,7 @@ bus.subscribe((event) => {
7846
8152
  // that turn queued to run anyway, minutes later, on an unrelated turn.
7847
8153
  if (!event.ok)
7848
8154
  discardDelegations(commsBus, event.threadId);
7849
- else
7850
- drainThreadDelegations(event.threadId);
8155
+ drainThreadDelegations(event.threadId);
7851
8156
  // A settling bot frees itself as a delegation TARGET too: handoffs that
7852
8157
  // found it busy earlier were kept queued — waiting until it's free or the
7853
8158
  // 24-hour expiry, not counting retries — on their own source threads, and
@@ -7968,7 +8273,7 @@ function drainAsideLane() {
7968
8273
  }
7969
8274
  /** Keep a person's words off the transcript until a direct-thread slot is
7970
8275
  * available. Reuse the existing cancellable, idempotent composer queue. */
7971
- async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender, trigger) {
8276
+ async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender, trigger, via) {
7972
8277
  const decision = admit("direct", {}, {
7973
8278
  // A room turn holds the bot exactly like the sibling opened-thread queue
7974
8279
  // below: the drain's own block check waits it out, so the words queue
@@ -7986,12 +8291,235 @@ async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId,
7986
8291
  prompt: promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"),
7987
8292
  sender,
7988
8293
  trigger,
8294
+ via,
7989
8295
  });
7990
8296
  return { ok: true, queued: true, queueId: queued.id, threadId, reason: decision.reason };
7991
8297
  }
7992
- const message = await startTurn(botId, text, { threadId, replyTo, sendId, sender, trigger });
8298
+ const message = await startTurn(botId, text, { threadId, replyTo, sendId, sender, trigger, via });
7993
8299
  return { ok: true, threadId, message };
7994
8300
  }
8301
+ /** Refusal from acceptDirectSend: the route turns it into its HTTP answer. */
8302
+ class DirectSendRefused extends Error {
8303
+ status;
8304
+ body;
8305
+ constructor(status, body) {
8306
+ super(typeof body.error === "string" ? body.error : "send refused");
8307
+ this.status = status;
8308
+ this.body = body;
8309
+ }
8310
+ }
8311
+ /** The refusals a direct send meets before it is sequenced, or null. */
8312
+ function directSendRefusal(botId, threadId) {
8313
+ // The send is acknowledged before the turn starts, so a workspace at its
8314
+ // spend limit is refused here, where the person can see it.
8315
+ try {
8316
+ assertWithinBudget(cfg, DATA_DIR);
8317
+ }
8318
+ catch (error) {
8319
+ return new DirectSendRefused(409, { error: error instanceof Error ? error.message : String(error), code: "spend_cap" });
8320
+ }
8321
+ if (!store.taskByThread(botId, threadId)) {
8322
+ return new DirectSendRefused(409, { error: "the bot switched tasks before it could receive the message" });
8323
+ }
8324
+ return null;
8325
+ }
8326
+ /** The one path a person's direct message takes into a bot turn: spend cap,
8327
+ * idempotency, steer into a running turn, queue, or start. Used by
8328
+ * POST /api/bots/:id/messages and by Live calls (via "call").
8329
+ * `guardedStart` is POST /api/bots/:id/messages/guarded's own start, in
8330
+ * place of steer, queue or start: it checks its preconditions against the
8331
+ * task as it stands, then starts a turn or refuses. */
8332
+ async function acceptDirectSend(input, guardedStart) {
8333
+ const { botId, threadId, text, sendId, replyTo, sender, trigger, via, personPresent } = input;
8334
+ const refused = directSendRefusal(botId, threadId);
8335
+ if (refused)
8336
+ throw refused;
8337
+ return sendSequencer.run(sendId ? `bot:${botId}:${threadId}:${sendId}` : undefined, sendFingerprint(text, replyTo?.id), async () => {
8338
+ if (sendId) {
8339
+ if (cancelledChatFollowup("bot", botId, threadId, sendId)) {
8340
+ throw Object.assign(new Error("this queued sendId was cancelled; send a new message to try again"), { status: 409 });
8341
+ }
8342
+ const accepted = acceptedSendMatch(store.messagesFor(threadId), sendId, text, replyTo?.id);
8343
+ if (accepted.kind === "conflict") {
8344
+ throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
8345
+ }
8346
+ if (accepted.kind === "match") {
8347
+ const canonical = {
8348
+ ok: true,
8349
+ threadId,
8350
+ message: accepted.message,
8351
+ };
8352
+ return accepted.message.steered
8353
+ ? { ...canonical, steered: true }
8354
+ : canonical;
8355
+ }
8356
+ const queued = queuedSteeredMessage(botId, threadId, sendId);
8357
+ if (queued) {
8358
+ if (queued.text !== text || queued.replyToId !== replyTo?.id) {
8359
+ throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
8360
+ }
8361
+ return { ok: true, queued: true, queueId: queued.id, threadId, reason: queued.reason };
8362
+ }
8363
+ }
8364
+ const currentAtStart = store.projectBotForTask(botId, threadId);
8365
+ if (!currentAtStart)
8366
+ throw Object.assign(new Error("no such bot"), { status: 404 });
8367
+ if (!store.taskByThread(currentAtStart.id, threadId)) {
8368
+ throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
8369
+ }
8370
+ if (guardedStart)
8371
+ return guardedStart(currentAtStart);
8372
+ // Claude can accept the message inside its live turn. If the write
8373
+ // loses a race with turn settlement, or the engine cannot steer, the
8374
+ // existing server-side queue records it atomically for the next turn.
8375
+ if (currentAtStart.busy) {
8376
+ const instance = runningTurnInstance(currentAtStart, threadId);
8377
+ let steered = "refused";
8378
+ // A live text steer has no image side channel. Keep an attachment
8379
+ // message intact for the next ordinary turn, where central image
8380
+ // admission can hand it to the provider natively.
8381
+ const carriesImages = extractTurnImages(text).images.length > 0;
8382
+ const steerTarget = handoffs.current(threadId);
8383
+ const busyAdmission = admit("direct-busy", {
8384
+ carriesImages,
8385
+ pendingComputerSelection: Boolean(computerSelectionTurns.get(threadId)?.selected),
8386
+ engineCanSteer: Boolean(instance?.adapter.capabilities.queueing && instance.adapter.steer),
8387
+ });
8388
+ // steer was offered only when a live instance could take it;
8389
+ // the second check carries that fact to the type system.
8390
+ if (busyAdmission.action === "steer" && instance?.adapter.steer) {
8391
+ steered = await instance.adapter
8392
+ .steer(threadId, promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"))
8393
+ .catch(() => "indeterminate");
8394
+ }
8395
+ // steer() is awaited adapter work. The turn can settle, the task can
8396
+ // switch, or the whole bot can be deleted before its acknowledgement
8397
+ // arrives. Re-read every ownership invariant before appending even a
8398
+ // successful steer; otherwise that late acknowledgement writes a user
8399
+ // message into a task the bot no longer owns. A conflict leaves the
8400
+ // text in the client's composer/outbox to resend deliberately.
8401
+ const current = store.projectBotForTask(botId, threadId);
8402
+ if (!current)
8403
+ throw Object.assign(new Error("no such bot"), { status: 404 });
8404
+ if (!store.taskByThread(botId, threadId)) {
8405
+ throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
8406
+ }
8407
+ const delivered = steered !== "refused";
8408
+ if (delivered) {
8409
+ if (steered === "steered" && !current.busy) {
8410
+ throw Object.assign(new Error("the running turn ended before the steered message could be recorded"), { status: 409 });
8411
+ }
8412
+ // "indeterminate" falls through to the same record: the words
8413
+ // may already be folded into a turn whose acknowledgement was
8414
+ // lost, and handing them back for a resend could run them
8415
+ // twice. Recording them once is the honest outcome.
8416
+ // A person steering a webhook turn is present, and auto mode may
8417
+ // follow them again; words that do not prove a person never lift it.
8418
+ if (personPresent)
8419
+ clearUnattended(threadId);
8420
+ const message = store.appendMessage(threadId, {
8421
+ role: "user",
8422
+ kind: "text",
8423
+ text,
8424
+ replyToId: replyTo?.id,
8425
+ sendId,
8426
+ steered: true,
8427
+ sender,
8428
+ ...(via ? { via } : {}),
8429
+ });
8430
+ // Offered to the next turn again unless the person stops this one.
8431
+ handoffs.steered(threadId, steerTarget, instance?.instanceId, message.id);
8432
+ return { ok: true, steered: true, threadId, message };
8433
+ }
8434
+ if (!current.busy) {
8435
+ return startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender, trigger, via);
8436
+ }
8437
+ const queued = queueSteeredMessage(current.id, threadId, text, {
8438
+ replyToId: replyTo?.id,
8439
+ sendId,
8440
+ prompt: promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"),
8441
+ sender,
8442
+ trigger,
8443
+ via,
8444
+ });
8445
+ return { ok: true, queued: true, queueId: queued.id, threadId };
8446
+ }
8447
+ return startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender, trigger, via);
8448
+ });
8449
+ }
8450
+ /** A provider can finish its run before the owner answers. Persist that answer
8451
+ * as a normal reply and run/queue it against the original conversation. */
8452
+ async function deliverLateQuestionAnswer(auth, threadId, requestId, answer, cardMessage, owner, via) {
8453
+ const group = store.groupByThread(threadId);
8454
+ const roomTarget = group
8455
+ ? group.dm
8456
+ ? group.threadId === threadId
8457
+ : Boolean(store.groupTaskByThread(group.id, threadId))
8458
+ : false;
8459
+ if (group && (!roomTarget || !group.memberIds.includes(owner.id))) {
8460
+ throw Object.assign(new Error("the bot that asked this question is no longer in this conversation"), { status: 409 });
8461
+ }
8462
+ if (!group && !store.taskByThread(owner.id, threadId)) {
8463
+ throw Object.assign(new Error("the question's conversation no longer exists"), { status: 409 });
8464
+ }
8465
+ const text = group ? `@${owner.name}: ${answer}` : answer;
8466
+ const sendId = `late-question-${createHash("sha256").update(`${threadId}:${requestId}`).digest("hex").slice(0, 32)}`;
8467
+ const key = group
8468
+ ? `group:${group.id}:${threadId}:${sendId}`
8469
+ : `bot:${owner.id}:${threadId}:${sendId}`;
8470
+ const receipt = await sendSequencer.run(key, sendFingerprint(text, cardMessage.id, group ? "chat" : undefined), async () => {
8471
+ const accepted = acceptedSendMatch(store.messagesFor(threadId), sendId, text, cardMessage.id, group ? "chat" : undefined);
8472
+ if (accepted.kind === "conflict")
8473
+ throw Object.assign(new Error("this question already has a different recorded answer"), { status: 409 });
8474
+ if (accepted.kind === "match")
8475
+ return { queued: false };
8476
+ const queuedDirect = group ? null : queuedSteeredMessage(owner.id, threadId, sendId);
8477
+ if (queuedDirect) {
8478
+ if (queuedDirect.text !== text || queuedDirect.replyToId !== cardMessage.id) {
8479
+ throw Object.assign(new Error("this question already has a different queued answer"), { status: 409 });
8480
+ }
8481
+ return { queued: true };
8482
+ }
8483
+ const queuedRoom = group ? queuedChannelMessage(group.id, threadId, sendId) : null;
8484
+ if (queuedRoom) {
8485
+ if (queuedRoom.text !== text || queuedRoom.replyToId !== cardMessage.id || queuedRoom.mode !== "chat") {
8486
+ throw Object.assign(new Error("this question already has a different queued answer"), { status: 409 });
8487
+ }
8488
+ return { queued: true };
8489
+ }
8490
+ const latestCard = store.messagesFor(threadId).find((message) => message.id === cardMessage.id)?.card;
8491
+ if (!latestCard || latestCard.answered || latestCard.dismissed) {
8492
+ throw Object.assign(new Error("this question has already been answered"), { status: 409 });
8493
+ }
8494
+ assertWithinBudget(cfg, DATA_DIR);
8495
+ const sender = messageSender(auth);
8496
+ const trigger = usageTriggerFor(auth);
8497
+ if (group) {
8498
+ const current = store.group(group.id);
8499
+ if (!current || !roomTarget)
8500
+ throw Object.assign(new Error("the room switched tasks before it could receive the answer"), { status: 409 });
8501
+ if (groupIsWorking(current)) {
8502
+ const queued = queueChannelMessage(current.id, threadId, text, {
8503
+ replyToId: cardMessage.id,
8504
+ sendId,
8505
+ mode: "chat",
8506
+ sender,
8507
+ trigger,
8508
+ });
8509
+ return { queued: true, queueId: queued.id };
8510
+ }
8511
+ startGroupTurn(current.id, text, cardMessage, sendId, "chat", undefined, { threadId, sender, trigger });
8512
+ return { queued: false };
8513
+ }
8514
+ const direct = await startOrQueueDirectMessage(owner.id, threadId, text, cardMessage, sendId, sender, trigger, via);
8515
+ return { queued: "queued" in direct && Boolean(direct.queued) };
8516
+ });
8517
+ const latest = store.messagesFor(threadId).find((message) => message.id === cardMessage.id)?.card;
8518
+ if (latest && !latest.answered && !latest.dismissed) {
8519
+ store.patchMessage(threadId, cardMessage.id, { card: { ...latest, answered: "answer", answeredText: answer, dismissed: false } });
8520
+ }
8521
+ return receipt;
8522
+ }
7995
8523
  /** How many start_thread calls one turn may make. Same spirit as the
7996
8524
  * create-bot ceiling above: a handful is a plan, more is a fan-out. */
7997
8525
  const MAX_THREADS_OPENED_PER_TURN = 5;
@@ -8277,6 +8805,7 @@ async function startTurn(botId, text, opts) {
8277
8805
  const policyRefusal = policyModelRefusal(instance);
8278
8806
  if (policyRefusal)
8279
8807
  throw Object.assign(new Error(policyRefusal), { status: 409, code: "managed_policy" });
8808
+ const toolScope = toolScopeForTurn(bot.id);
8280
8809
  // On a Cloud home a guest's turn never gets a shell or reads outside its
8281
8810
  // own folder (docs/cloud-pro.md): an engine that cannot run it that way is
8282
8811
  // refused, in one plain line, before anything is recorded.
@@ -8319,7 +8848,14 @@ async function startTurn(botId, text, opts) {
8319
8848
  .catch(() => undefined);
8320
8849
  }
8321
8850
  }
8322
- console.error(`[omb-turn] bot=${botId} text=${JSON.stringify(resolvedImages.text.slice(0, 70))} images=${turnImages.length} depth=${commsDepth} card=${Boolean(opts?.cardContinuation)}`);
8851
+ // Spoken words never reach the log. A direct send says so itself; a
8852
+ // drained queue (or a continuation) carries it on the user lines it runs.
8853
+ const turnLineIds = new Set(opts?.excludeMessageIds ?? []);
8854
+ const spoken = opts?.via === "call" || opts?.userMessage?.via === "call" ||
8855
+ (turnLineIds.size > 0 && store.messagesFor(threadId).some((message) => turnLineIds.has(message.id) && message.via === "call"));
8856
+ console.error(turnStartLogLine({
8857
+ botId, text: resolvedImages.text, images: turnImages.length, depth: commsDepth, card: Boolean(opts?.cardContinuation), spoken,
8858
+ }));
8323
8859
  const instanceId = instance.instanceId;
8324
8860
  if (providerInstancesChanging.has(instanceId)) {
8325
8861
  throw Object.assign(new Error("this provider account is being updated — try again shortly"), { status: 409 });
@@ -8357,6 +8893,8 @@ async function startTurn(botId, text, opts) {
8357
8893
  sendId: opts?.sendId,
8358
8894
  peerAsk: opts?.peerAsk,
8359
8895
  sender: opts?.sender,
8896
+ ...(opts?.via ? { via: opts.via } : {}),
8897
+ ...(opts?.relayed ? { relayed: true } : {}),
8360
8898
  });
8361
8899
  }
8362
8900
  const recoveryUserMessageId = opts?.coordination
@@ -8471,6 +9009,7 @@ async function startTurn(botId, text, opts) {
8471
9009
  retryDelegationsWaitingOn(bot.id);
8472
9010
  drainQueuedSends();
8473
9011
  drainConnectorResumes();
9012
+ drainComputerResumes();
8474
9013
  drainSecretResumes();
8475
9014
  drainTeamSetupResumes();
8476
9015
  drainDelegationWakes();
@@ -8759,7 +9298,7 @@ async function startTurn(botId, text, opts) {
8759
9298
  * desktop, not whatever localVmTargetForBot resolves to by the time
8760
9299
  * the first screen call arrives. */
8761
9300
  const claimAutoLocalVm = async (claimThreadId, pinnedTarget) => {
8762
- const localVmTarget = pinnedTarget ?? localVmTargetForThread(bot.id, claimThreadId);
9301
+ const localVmTarget = pinnedTarget ?? localVmThreadTargets.get(claimThreadId) ?? { ...localVmTargetForThread(bot.id, claimThreadId) };
8763
9302
  await bindTurnComputer(resourceOwner, `computer:vm:${localVmTarget.key}`, true);
8764
9303
  if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) {
8765
9304
  throw new Error("this Local VM is being started, stopped, or replaced — wait for setup to finish");
@@ -8783,6 +9322,8 @@ async function startTurn(botId, text, opts) {
8783
9322
  // without this the exclusive lease would sit held for the rest of a
8784
9323
  // turn that never got the VM — the very serialisation #1361 removes.
8785
9324
  const dropLease = () => {
9325
+ if (localVmThreadTargets.get(claimThreadId) !== localVmTarget)
9326
+ return;
8786
9327
  localVmLeaseFor(localVmTarget).release(claimThreadId);
8787
9328
  if (localVmActiveThreads.get(localVmTarget.key) === claimThreadId)
8788
9329
  localVmActiveThreads.delete(localVmTarget.key);
@@ -8839,6 +9380,22 @@ async function startTurn(botId, text, opts) {
8839
9380
  const surfaceLazyClaimRejection = (label) => (failure) => {
8840
9381
  if (activeInternalGenerationByThread.get(threadId) !== resourceOwner.generation || !threadBusy(bot.id, threadId))
8841
9382
  return;
9383
+ // The wait ceiling parked this turn (#1651): register the resume and
9384
+ // settle the live turn. No error entry, no incident — the parked chip
9385
+ // the wait appended is the whole story. The slot still fails, so the
9386
+ // gate keeps refusing screen calls for the rest of this generation
9387
+ // while the interrupt lands.
9388
+ if (resourceOwner.computerParkedOn) {
9389
+ registerComputerResume({
9390
+ botId: bot.id,
9391
+ threadId,
9392
+ resource: resourceOwner.computerParkedOn,
9393
+ generation: resourceOwner.generation,
9394
+ afterMessageId: store.activePath(threadId).findLast((entry) => entry.role === "user")?.id,
9395
+ });
9396
+ void interruptDirectThread(bot.id, threadId, { preserveComputerResume: true }).catch(() => { });
9397
+ return;
9398
+ }
8842
9399
  const message = `computer unavailable — ${label} could not be claimed for this turn (${failure})`;
8843
9400
  store.appendMessage(threadId, {
8844
9401
  role: "bot",
@@ -8876,7 +9433,7 @@ async function startTurn(botId, text, opts) {
8876
9433
  if (!localVmSeen.has(poolCandidate.key) && !opts?.automationSource)
8877
9434
  return false;
8878
9435
  }
8879
- const localVmTarget = localVmTargetForThread(bot.id, threadId);
9436
+ const localVmTarget = { ...localVmTargetForThread(bot.id, threadId) };
8880
9437
  let lazyReadyVm = null;
8881
9438
  if (!strict) {
8882
9439
  // Nothing this process has ever seen for this target, and nobody is
@@ -8939,9 +9496,13 @@ async function startTurn(botId, text, opts) {
8939
9496
  return true;
8940
9497
  }
8941
9498
  catch (error) {
8942
- if (strict)
9499
+ // A wait ceiling settled this turn as parked, not as an unavailable
9500
+ // Auto destination. Let dispatch register its resume instead of
9501
+ // continuing the provider without the requested computer.
9502
+ if (strict || error instanceof ComputerWaitParked)
8943
9503
  throw error;
8944
- releaseLocalVmThread(threadId);
9504
+ if (directTurnGenerationByThread.get(threadId) === dispatchClaimId)
9505
+ releaseLocalVmThread(threadId);
8945
9506
  return false;
8946
9507
  }
8947
9508
  };
@@ -9264,7 +9825,9 @@ async function startTurn(botId, text, opts) {
9264
9825
  // never redoes — or forgets — what another one already did
9265
9826
  { id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(recentWorkSources(bot), bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId, ...recentWorkFilter() })) },
9266
9827
  { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace, enabled: bot.memoryEnabled !== false }) },
9267
- { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id) : "" },
9828
+ // liveBot was captured before awaited setup work; an assignment PUT
9829
+ // in that window must still reach this turn's prompt.
9830
+ { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id, skillsLibraryEnabled(cfg) ? (store.bot(bot.id)?.assignedSkills ?? bot.assignedSkills) : undefined) : "" },
9268
9831
  { id: "skill-instructions", label: "Skill instructions", text: skillInstructions },
9269
9832
  { id: "playbooks", label: "Playbooks", text: packagePlaybooks },
9270
9833
  { id: "webhook", label: "Webhook provenance", text: opts?.automationSource === "webhook" ? WEBHOOK_PROMPT : "" },
@@ -9308,6 +9871,7 @@ async function startTurn(botId, text, opts) {
9308
9871
  refreshSystemPrompt: true,
9309
9872
  images: turnImages,
9310
9873
  approvalMode: approvalModeForTurn(bot, commsDepth > 0, threadId),
9874
+ toolScope,
9311
9875
  ...(guestConfined ? { guestConfined: true, confinedWhy: confinedWhy(threadId) } : {}),
9312
9876
  model,
9313
9877
  effort,
@@ -9382,6 +9946,7 @@ async function startTurn(botId, text, opts) {
9382
9946
  retryDelegationsWaitingOn(bot.id);
9383
9947
  drainQueuedSends();
9384
9948
  drainConnectorResumes();
9949
+ drainComputerResumes();
9385
9950
  drainSecretResumes();
9386
9951
  drainTeamSetupResumes();
9387
9952
  drainDelegationWakes();
@@ -9427,6 +9992,7 @@ async function startTurn(botId, text, opts) {
9427
9992
  if (ownsLatestGeneration) {
9428
9993
  drainQueuedSends();
9429
9994
  drainConnectorResumes();
9995
+ drainComputerResumes();
9430
9996
  drainSecretResumes();
9431
9997
  drainTeamSetupResumes();
9432
9998
  drainDelegationWakes();
@@ -9437,6 +10003,31 @@ async function startTurn(botId, text, opts) {
9437
10003
  settleDirectFollowup(dispatchClaimId);
9438
10004
  return;
9439
10005
  }
10006
+ if (e instanceof ComputerWaitParked) {
10007
+ settleDirectFollowup(dispatchClaimId, { ok: false, text: e.message });
10008
+ registerComputerResume({
10009
+ botId: bot.id,
10010
+ threadId,
10011
+ resource: e.resource,
10012
+ generation: e.generation,
10013
+ afterMessageId: store.activePath(threadId).findLast((entry) => entry.role === "user")?.id,
10014
+ });
10015
+ // Settled as parked, not failed (#1651): the chip the wait appended
10016
+ // is the whole transcript story, and the registered resume owns what
10017
+ // happens next on this thread.
10018
+ if (store.taskByThread(bot.id, threadId)?.activity !== "dead") {
10019
+ store.setTaskActivity(bot.id, threadId, "parked.computer");
10020
+ }
10021
+ directTurnBots.delete(threadId);
10022
+ retryDelegationsWaitingOn(bot.id);
10023
+ drainQueuedSends();
10024
+ drainConnectorResumes();
10025
+ drainComputerResumes();
10026
+ drainSecretResumes();
10027
+ drainTeamSetupResumes();
10028
+ drainDelegationWakes();
10029
+ return;
10030
+ }
9440
10031
  let message = e instanceof Error ? e.message : String(e);
9441
10032
  const backup = cfg.automaticRecovery?.enabled ? cfg.automaticRecovery.backup : undefined;
9442
10033
  const current = store.projectBotForTask(bot.id, threadId);
@@ -9481,13 +10072,12 @@ async function startTurn(botId, text, opts) {
9481
10072
  settleDirectFollowup(dispatchClaimId, { ok: false, text: message });
9482
10073
  // The wait already wrote its failure resolution; keep all dispatch
9483
10074
  // failure bookkeeping below without adding the same error twice.
9484
- if (!(e instanceof ComputerWaitGaveUp))
9485
- store.appendMessage(threadId, {
9486
- role: "bot",
9487
- kind: "activity",
9488
- turnSucceeded: false,
9489
- tool: { name: `error: ${message.slice(0, 160)}`, ok: false },
9490
- });
10075
+ store.appendMessage(threadId, {
10076
+ role: "bot",
10077
+ kind: "activity",
10078
+ turnSucceeded: false,
10079
+ tool: { name: `error: ${message.slice(0, 160)}`, ok: false },
10080
+ });
9491
10081
  // Worth a buzz for the same reason a routine failure is, and the rule
9492
10082
  // notify.ts encodes: the bot is not working, and the cause is usually
9493
10083
  // a setting only a person can change — an unattended user would
@@ -9510,6 +10100,7 @@ async function startTurn(botId, text, opts) {
9510
10100
  // drain would strand anything queued behind this turn
9511
10101
  drainQueuedSends();
9512
10102
  drainConnectorResumes();
10103
+ drainComputerResumes();
9513
10104
  drainSecretResumes();
9514
10105
  drainTeamSetupResumes();
9515
10106
  drainDelegationWakes();
@@ -10304,8 +10895,24 @@ const tighteningRequests = new TighteningRequestService({
10304
10895
  // The card judges the effective mounts, so the snapshot resolves the same
10305
10896
  // way the engine does: through the config-aware custom-server filter.
10306
10897
  mountedMcpServers: (bot) => Object.keys(engineMcpServers(bot)),
10307
- enabledSkills: (botId) => listSkills(botId).filter((skill) => skill.enabled).map((skill) => skill.name),
10898
+ enabledSkills: (botId) => {
10899
+ const record = store.bot(botId);
10900
+ return (record ? listBotSkills(record) : listSkills(botId)).filter((skill) => skill.enabled).map((skill) => skill.name);
10901
+ },
10308
10902
  disableSkill: (botId, name) => {
10903
+ const record = store.bot(botId);
10904
+ // Private skills win collisions, including disabled/private copies.
10905
+ // Disabling one must not reveal a same-named shared skill instead.
10906
+ if (skillsLibraryEnabled(cfg) && !listSkills(botId).some((skill) => skill.name === name)
10907
+ && record?.assignedSkills?.includes(name)) {
10908
+ try {
10909
+ const saved = store.patchBot(botId, { assignedSkills: record.assignedSkills.filter((assigned) => assigned !== name) });
10910
+ return saved ? { ok: true } : { ok: false, error: "that bot no longer exists" };
10911
+ }
10912
+ catch (error) {
10913
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
10914
+ }
10915
+ }
10309
10916
  const disabled = setSkillEnabled(botId, name, false);
10310
10917
  return typeof disabled === "object" && "error" in disabled ? { ok: false, error: disabled.error } : { ok: true };
10311
10918
  },
@@ -10414,7 +11021,7 @@ async function resolveAndSendTeamSetup(res, args, ownerReview) {
10414
11021
  if (!card)
10415
11022
  return false;
10416
11023
  if (args.behavior === "allow" && !ownerReview) {
10417
- 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." });
11024
+ json(res, 403, { error: "Approve team setup or deletion from the desktop app or a paired Full-access device. In a local browser, wait until every bot is idle." });
10418
11025
  return true;
10419
11026
  }
10420
11027
  if (args.behavior === "allow" && !card.answered && !card.dismissed && !card.expired) {
@@ -11041,6 +11648,7 @@ setupRetry = 0) {
11041
11648
  const readyBot = store.bot(bot.id);
11042
11649
  if (!readyBot)
11043
11650
  return false;
11651
+ toolScopeForTurn(readyBot.id);
11044
11652
  const readyGroup = store.group(group.id);
11045
11653
  const stillOwnsThread = readyGroup?.dm
11046
11654
  ? readyGroup.threadId === threadId
@@ -11403,7 +12011,9 @@ setupRetry = 0) {
11403
12011
  // mounted, exactly as the 1:1 path decides it: memory_update is on the
11404
12012
  // agents server, so a room turn with it must be told to use it too.
11405
12013
  { id: "memory", label: "Memory", text: roomMemory ? `\n${roomMemory.trim()}` : "" },
11406
- { id: "skills", label: "Skills index", text: workspace ? skillsSystemPrompt(bot.id) : "" },
12014
+ // readyBot was captured before awaited setup work; an assignment PUT in
12015
+ // that window must still reach this turn's prompt.
12016
+ { id: "skills", label: "Skills index", text: workspace ? skillsSystemPrompt(bot.id, skillsLibraryEnabled(cfg) ? (store.bot(bot.id)?.assignedSkills ?? bot.assignedSkills) : undefined) : "" },
11407
12017
  { id: "skill-instructions", label: "Skill instructions", text: renderSkillInstructions(selectedSkills, { includeRoot: Boolean(workspace) }) },
11408
12018
  { id: "playbooks", label: "Playbooks", text: installedPlaybookInstructions(text, bot.playbooks) },
11409
12019
  ]);
@@ -11530,6 +12140,7 @@ setupRetry = 0) {
11530
12140
  refreshSystemPrompt: true,
11531
12141
  images: turnImages,
11532
12142
  approvalMode: roomTurnApprovalMode(readyBot, threadId, orchestration),
12143
+ toolScope: toolScopeForTurn(readyBot.id),
11533
12144
  ...(roomGuestConfined ? { guestConfined: true, confinedWhy: confinedWhy(threadId) } : {}),
11534
12145
  system: roomSystem.text,
11535
12146
  systemStable: roomSystem.stable,
@@ -11612,6 +12223,7 @@ setupRetry = 0) {
11612
12223
  watchdog.settle(threadId);
11613
12224
  drainQueuedSends();
11614
12225
  drainConnectorResumes();
12226
+ drainComputerResumes();
11615
12227
  drainSecretResumes();
11616
12228
  drainTeamSetupResumes();
11617
12229
  return false;
@@ -11642,6 +12254,7 @@ setupRetry = 0) {
11642
12254
  retryDelegationsWaitingOn(bot.id);
11643
12255
  drainQueuedSends();
11644
12256
  drainConnectorResumes();
12257
+ drainComputerResumes();
11645
12258
  drainSecretResumes();
11646
12259
  drainTeamSetupResumes();
11647
12260
  }
@@ -11670,6 +12283,7 @@ setupRetry = 0) {
11670
12283
  // queued while this bot briefly owned the room must be retried now.
11671
12284
  drainQueuedSends();
11672
12285
  drainConnectorResumes();
12286
+ drainComputerResumes();
11673
12287
  drainSecretResumes();
11674
12288
  drainTeamSetupResumes();
11675
12289
  }
@@ -11735,15 +12349,35 @@ setupRetry = 0) {
11735
12349
  return false;
11736
12350
  const isSpendCap = typeof error === "object" && error !== null && error.code === "spend_cap";
11737
12351
  const isVariantError = typeof error === "object" && error !== null && error.code === "unsupported_model_variant";
11738
- if (!roomSpeaker && !isSpendCap && !isVariantError)
12352
+ const isToolScopeError = typeof error === "object" && error !== null && error.code === "tool_scope";
12353
+ if (error instanceof ComputerWaitParked) {
12354
+ registerComputerResume({
12355
+ botId: bot.id,
12356
+ threadId,
12357
+ resource: error.resource,
12358
+ generation: error.generation,
12359
+ afterMessageId: store.activePath(threadId).findLast((entry) => entry.role === "user")?.id,
12360
+ });
12361
+ // The room settles with the parked chip the wait appended (#1651);
12362
+ // the registered resume re-runs the speaker turn through the group
12363
+ // queue once the seat frees. "parked" is its own outcome, not "busy":
12364
+ // the member never ran, an in-step retry would only wait the ceiling
12365
+ // out again and burn goal turns, and the goal loop routes around the
12366
+ // member while the registered resume owns the re-run.
12367
+ if (orchestration) {
12368
+ orchestration.result.outcome = "parked";
12369
+ orchestration.result.stopReason = `${bot.name} parked waiting for the computer`;
12370
+ }
12371
+ return false;
12372
+ }
12373
+ if (!roomSpeaker && !isSpendCap && !isVariantError && !isToolScopeError)
11739
12374
  throw error;
11740
12375
  const message = error instanceof Error ? error.message : "Local VM setup failed";
11741
- if (!(error instanceof ComputerWaitGaveUp))
11742
- store.appendMessage(threadId, {
11743
- role: "bot", kind: "activity",
11744
- from: { botId: bot.id, name: bot.name, color: bot.color },
11745
- tool: { name: `error: ${message}`, ok: false },
11746
- });
12376
+ store.appendMessage(threadId, {
12377
+ role: "bot", kind: "activity",
12378
+ from: { botId: bot.id, name: bot.name, color: bot.color },
12379
+ tool: { name: `error: ${message}`, ok: false },
12380
+ });
11747
12381
  onDispatchError?.(message);
11748
12382
  if (orchestration) {
11749
12383
  // A spend-cap refusal is deterministic: record it as its own
@@ -11753,7 +12387,7 @@ setupRetry = 0) {
11753
12387
  if (isSpendCap)
11754
12388
  orchestration.result.stopReason = message;
11755
12389
  }
11756
- return false;
12390
+ return isToolScopeError;
11757
12391
  }
11758
12392
  finally {
11759
12393
  // Covers connector/setup failures, cancellation before dispatch, and all
@@ -11774,6 +12408,7 @@ setupRetry = 0) {
11774
12408
  }
11775
12409
  drainQueuedSends();
11776
12410
  drainConnectorResumes();
12411
+ drainComputerResumes();
11777
12412
  drainSecretResumes();
11778
12413
  drainTeamSetupResumes();
11779
12414
  }
@@ -11832,6 +12467,12 @@ async function runGroupGoalStep(args) {
11832
12467
  coordinatorTurn.turnId = turnId;
11833
12468
  },
11834
12469
  });
12470
+ // Busy here is a lost claim race — a 1:1 re-claiming the bot between
12471
+ // the wait settling and this turn starting — so wait and retry
12472
+ // in-step. A parked member is deliberately absent from both this
12473
+ // retry and the transient one below: the computer seat is still held,
12474
+ // an immediate retry would wait the ceiling out again while the
12475
+ // registered resume already owns re-running the member.
11835
12476
  if (result.outcome === "busy")
11836
12477
  continue;
11837
12478
  // One retry for a transient provider failure: a 13-turn goal must not
@@ -11935,6 +12576,13 @@ async function runGroupGoalOperation(args) {
11935
12576
  finishGroupGoalRun(args.groupId, args.operation, "blocked", `${coordinatorResult.stopReason ?? `${args.coordinator.name} stayed busy`} — send the goal again when they are free.`);
11936
12577
  return;
11937
12578
  }
12579
+ if (coordinatorResult.outcome === "parked") {
12580
+ // The lead parked at the computer wait ceiling: like lead-busy, the
12581
+ // run cannot route around them, but the parked lead turn itself
12582
+ // resumes through the registered entry once the seat frees.
12583
+ finishGroupGoalRun(args.groupId, args.operation, "blocked", `${coordinatorResult.stopReason ?? `${args.coordinator.name} parked waiting for the computer`} — send the goal again when the computer is free.`);
12584
+ return;
12585
+ }
11938
12586
  if (!coordinatorResult.ran || coordinatorResult.outcome !== "settled") {
11939
12587
  const reason = coordinatorResult.stopReason?.trim().slice(0, 120);
11940
12588
  finishGroupGoalRun(args.groupId, args.operation, "failed", `${args.coordinator.name} could not complete the coordination step${reason ? ` — ${reason}` : ""}.`);
@@ -11992,9 +12640,10 @@ async function runGroupGoalOperation(args) {
11992
12640
  finishGroupGoalRun(args.groupId, args.operation, "blocked", workerResult.stopReason ?? `${workerBot.name} hit the workspace spend cap.`);
11993
12641
  return;
11994
12642
  }
11995
- if (workerResult.outcome === "busy") {
11996
- // bounded: a team that keeps landing on busy teammates is blocked, not
11997
- // looping — three exhausted waits per run, then stop and say so
12643
+ if (workerResult.outcome === "busy" || workerResult.outcome === "parked") {
12644
+ // bounded: a team that keeps landing on busy or computer-parked
12645
+ // teammates is blocked, not looping — three exhausted waits per run,
12646
+ // then stop and say so
11998
12647
  waitExhaustions += 1;
11999
12648
  if (waitExhaustions >= GROUP_GOAL_MAX_WAIT_EXHAUSTIONS) {
12000
12649
  finishGroupGoalRun(args.groupId, args.operation, "blocked", `Teammates stayed busy past the wait limit ${waitExhaustions} times — try again when the team is free.`);
@@ -12889,6 +13538,95 @@ function drainConnectorResumes() {
12889
13538
  dispatchConnectorResume(entry);
12890
13539
  }
12891
13540
  }
13541
+ const pendingComputerResumes = new Map();
13542
+ function registerComputerResume(entry) {
13543
+ // One per thread: the newest park replaces an older one.
13544
+ pendingComputerResumes.set(entry.threadId, entry);
13545
+ // The seat may already be free (it freed before this park registered):
13546
+ // an idle install would otherwise never drain. #1651.
13547
+ queueMicrotask(drainComputerResumes);
13548
+ }
13549
+ function markComputerResumeFailed(entry, message) {
13550
+ const owner = connectorThread(entry.botId, entry.threadId);
13551
+ if (!owner)
13552
+ return;
13553
+ store.appendMessage(entry.threadId, {
13554
+ role: "bot", kind: "activity",
13555
+ ...(owner.group ? { from: { botId: owner.bot.id, name: owner.bot.name, color: owner.bot.color } } : {}),
13556
+ tool: { name: `Parked turn could not continue: ${message.slice(0, 160)}`, ok: false },
13557
+ });
13558
+ }
13559
+ function dispatchComputerResume(entry) {
13560
+ const owner = connectorThread(entry.botId, entry.threadId);
13561
+ if (!owner)
13562
+ return;
13563
+ const prompt = "OpenMausBot computer update: the computer this conversation waited for is free again. Continue the task that parked waiting for it.";
13564
+ if (owner.group) {
13565
+ const groupId = owner.group.id;
13566
+ const operation = beginGroupTurnOperation(groupId, entry.threadId, [entry.botId]);
13567
+ const previous = groupQueues.get(groupId) ?? Promise.resolve();
13568
+ const next = previous.then(async () => {
13569
+ if (operation.cancelled)
13570
+ return;
13571
+ const current = connectorThread(entry.botId, entry.threadId);
13572
+ if (!current?.group)
13573
+ return;
13574
+ if (current.bot.busy) {
13575
+ pendingComputerResumes.set(entry.threadId, entry);
13576
+ return;
13577
+ }
13578
+ await runGroupMemberTurn(current.group.id, entry.threadId, entry.botId, 0, new Set(), prompt, (message) => markComputerResumeFailed(entry, message), () => operation.cancelled, () => groupProviderHandshakeStarted(operation), () => groupProviderHandshakeSettled(operation));
13579
+ });
13580
+ const tracked = next.finally(() => finishGroupTurnOperation(groupId, operation));
13581
+ groupQueues.set(groupId, tracked.catch((error) => {
13582
+ markComputerResumeFailed(entry, error instanceof Error ? error.message : String(error));
13583
+ }));
13584
+ return;
13585
+ }
13586
+ void startTurn(entry.botId, prompt, {
13587
+ threadId: entry.threadId,
13588
+ cardContinuation: true,
13589
+ onDispatchError: (message) => markComputerResumeFailed(entry, message),
13590
+ }).catch((error) => {
13591
+ const message = error instanceof Error ? error.message : String(error);
13592
+ if (isTurnAdmissionBlocked(error))
13593
+ pendingComputerResumes.set(entry.threadId, entry);
13594
+ else
13595
+ markComputerResumeFailed(entry, message);
13596
+ });
13597
+ }
13598
+ function drainComputerResumes() {
13599
+ for (const [key, entry] of pendingComputerResumes) {
13600
+ if (!connectorThread(entry.botId, entry.threadId)) {
13601
+ pendingComputerResumes.delete(key);
13602
+ continue;
13603
+ }
13604
+ // A newer ask replaced the parked work: the resume is stale, not blocked.
13605
+ const latestUser = store.activePath(entry.threadId).findLast((message) => message.role === "user")?.id;
13606
+ if (entry.afterMessageId !== undefined && latestUser !== entry.afterMessageId) {
13607
+ pendingComputerResumes.delete(key);
13608
+ continue;
13609
+ }
13610
+ // A newer generation owns the thread — a new turn superseded the parked
13611
+ // one — so the parked resume is stale too. The parked turn's OWN
13612
+ // generation is still current while it settles: the lazy-claim park
13613
+ // registers its resume before the interrupt lands, and a drain in that
13614
+ // window must keep the entry (#1651), not read busy as superseded.
13615
+ const activeGeneration = activeInternalGenerationByThread.get(entry.threadId);
13616
+ if (activeGeneration !== undefined && activeGeneration !== entry.generation) {
13617
+ pendingComputerResumes.delete(key);
13618
+ continue;
13619
+ }
13620
+ // Busy under the parked generation: the turn is still settling. Keep the
13621
+ // entry; the next drain (its completion, the next release) dispatches.
13622
+ if (threadBusy(entry.botId, entry.threadId))
13623
+ continue;
13624
+ if (!turnResources.free(entry.resource))
13625
+ continue;
13626
+ pendingComputerResumes.delete(key);
13627
+ dispatchComputerResume(entry);
13628
+ }
13629
+ }
12892
13630
  const pendingSecretResumes = new Map();
12893
13631
  const phoneSecretSubmissions = new PhoneSecretSubmissionRegistry();
12894
13632
  function claimPhoneSecretBotDeletion(botId) {
@@ -13120,6 +13858,7 @@ bus.subscribe((event) => {
13120
13858
  return;
13121
13859
  if (event.type === "turn.completed") {
13122
13860
  drainConnectorResumes();
13861
+ drainComputerResumes();
13123
13862
  drainSecretResumes();
13124
13863
  drainTeamSetupResumes();
13125
13864
  }
@@ -13183,34 +13922,15 @@ async function localVmPayload(target, auth) {
13183
13922
  const status = await containerComputerStatus(undefined, undefined, target);
13184
13923
  return {
13185
13924
  ...localVmViewerStatus(status, auth),
13925
+ stop_reason: localVmStopReason(target.key, status),
13186
13926
  commands: setupCommands(status.runtime, process.platform, target),
13187
13927
  idle_timeout_ms: localVmIdleMs(),
13188
13928
  mode: localVmMode(cfg),
13189
13929
  max_instances: localVmMaxInstances(cfg),
13190
13930
  };
13191
13931
  }
13192
- /** The Local VM a turn is about to use, recreated if the idle timer took it.
13193
- *
13194
- * `LocalVmIdleTimer` REMOVES an unused Local VM rather than pausing it. The
13195
- * turn then failed with "Create the Local VM (App Settings → Local VM)" —
13196
- * which reads like a fault the person must repair by hand, for a container the
13197
- * app itself deleted eight hours earlier. Someone who steps away overnight
13198
- * comes back to an error on their first message.
13199
- *
13200
- * The cloud branch below already does the opposite: an absent boat is
13201
- * provisioned on first use behind a `provisioning` broadcast. This gives the
13202
- * Local VM the same lifecycle for the same reason.
13203
- *
13204
- * Only `missing` is recovered, and only when a fresh `run` is all it takes.
13205
- * Every other problem still surfaces: no runtime installed, no image pulled,
13206
- * `create_supported` false, or an existing container that is stale, unmanaged
13207
- * or unsafe. Those need a decision — install podman, download 1.4 GB, replace
13208
- * a container someone else made — and a stopped container is deliberately not
13209
- * resumed here, because `localVmProblem` says this desktop image cannot safely
13210
- * resume and asks for a recreate rather than a start. Per-bot mode keeps its
13211
- * instance cap; creating past it would quietly do what the lifecycle route
13212
- * refuses.
13213
- */
13932
+ /** Wake an idle desktop, or recreate a missing one from an already prepared
13933
+ * image. Incompatible or unsafe containers still require an explicit decision. */
13214
13934
  async function readyLocalVmForTurn(botId, target, isCurrent = () => true) {
13215
13935
  localVmLifecycleBusy.add(target.key);
13216
13936
  // Fence this target, and the cross-target capacity decision for creates,
@@ -13230,21 +13950,22 @@ async function readyLocalVmForTurn(botId, target, isCurrent = () => true) {
13230
13950
  noteLocalVmSeen(target, status);
13231
13951
  if (!isCurrent())
13232
13952
  return status;
13233
- if (status.ready || !localVmRecreatableOnDemand(status))
13953
+ const action = localVmWakeAction(status);
13954
+ if (status.ready || !action || !status.runtime)
13234
13955
  return status;
13235
13956
  // Another creation is already mid-flight and its container is not yet
13236
13957
  // visible to a count, so the safe answer is the inspected status —
13237
13958
  // exactly what the over-cap path below returns.
13238
- if (!pooled && !ownsProvision)
13959
+ if (action === "run" && !pooled && !ownsProvision)
13239
13960
  return status;
13240
- if (target.key.startsWith("bot:")) {
13961
+ if (action === "run" && target.key.startsWith("bot:")) {
13241
13962
  const count = await existingPerBotLocalVmCount(status.runtime);
13242
13963
  if (!isCurrent() || count >= localVmMaxInstances(cfg))
13243
13964
  return status;
13244
13965
  }
13245
13966
  broadcast({ kind: "computer", botId, state: "provisioning" });
13246
13967
  try {
13247
- status = await containerComputerAction("run", undefined, undefined, target);
13968
+ status = await containerComputerAction(action, undefined, undefined, target);
13248
13969
  }
13249
13970
  catch {
13250
13971
  // Keep the inspected status: its `problem` names the real obstacle,
@@ -13312,6 +14033,7 @@ function configStatus() {
13312
14033
  return {
13313
14034
  xai: { configured: Boolean(cfg.xai?.key) },
13314
14035
  mistral: { configured: Boolean(cfg.mistral?.key) },
14036
+ cerebras: { configured: Boolean(cfg.cerebras?.key) },
13315
14037
  anthropic: { configured: Boolean(cfg.anthropic?.key), everyClaudeBot: cfg.anthropic?.everyClaudeBot !== false },
13316
14038
  openai: { configured: Boolean(cfg.openai?.key) },
13317
14039
  openrouter: { configured: Boolean(cfg.openrouter?.key) },
@@ -13344,6 +14066,8 @@ function configStatus() {
13344
14066
  // the decision model: switches and configured-or-not, never the key
13345
14067
  decider: describeDecider(cfg),
13346
14068
  imageGen: avatarImageStatus(cfg),
14069
+ // Live calls: configured-or-not only; the voice name is a setting
14070
+ live: liveSettingsFor(cfg),
13347
14071
  // not a secret — the sidebar shows it
13348
14072
  profile: { name: cfg.profile?.name ?? "", email: cfg.profile?.email ?? "", aboutMe: cfg.profile?.aboutMe ?? "" },
13349
14073
  // the enrolled organisation's read-only desktop policy; null when not enrolled
@@ -13369,6 +14093,7 @@ function configStatus() {
13369
14093
  },
13370
14094
  features: {
13371
14095
  skillAuthoring: skillAuthoringEnabled(cfg),
14096
+ skillsLibrary: skillsLibraryEnabled(cfg),
13372
14097
  showToolCalls: showToolCallsEnabled(cfg),
13373
14098
  routinesInConversation: routinesInConversationEnabled(cfg),
13374
14099
  browser: builtInBrowserEnabled(cfg),
@@ -13670,6 +14395,7 @@ async function reloadProviders() {
13670
14395
  // queued behind them drains now — onto the freshly loaded fleet
13671
14396
  drainQueuedSends();
13672
14397
  drainConnectorResumes();
14398
+ drainComputerResumes();
13673
14399
  drainSecretResumes();
13674
14400
  drainTeamSetupResumes();
13675
14401
  }
@@ -13826,6 +14552,102 @@ ROUTES.push(createAntigravityLeftoverRoutes({
13826
14552
  remove: () => removeAntigravityLeftovers(),
13827
14553
  }));
13828
14554
  ROUTES.push(desktopViewer.route);
14555
+ // Live calls (GPT-Live as the voice, the bot as the brain). A client holds
14556
+ // the WebRTC audio; the harness creates the session with the key (which
14557
+ // never leaves it) and runs the call in LiveCallController.
14558
+ /** Who the voice speaks for, for its instructions. */
14559
+ function liveBotFor(botId) {
14560
+ const bot = store.bot(botId);
14561
+ if (!bot)
14562
+ throw new LiveSessionError("That bot no longer exists.", 404);
14563
+ return { name: bot.name, title: bot.title, description: bot.description };
14564
+ }
14565
+ /** The chat's text so far, so the voice can follow "and the other one?". */
14566
+ function liveHistoryFor(threadId) {
14567
+ return store.activePath(threadId)
14568
+ .filter((message) => message.kind === "text" && typeof message.text === "string" && (message.role === "user" || message.role === "bot"))
14569
+ .map((message) => ({ role: message.role === "user" ? "user" : "assistant", text: message.text ?? "" }));
14570
+ }
14571
+ /** A call outlives the request that started it: a signed-out or removed
14572
+ * person's call must not keep reaching the bot (or spending the key). */
14573
+ function liveSignedIn(auth) {
14574
+ return auth.kind !== "session" || sessions.isLive(auth.session.id);
14575
+ }
14576
+ const liveCalls = new LiveCallController({
14577
+ store,
14578
+ send: async ({ auth, botId, threadId, text }) => {
14579
+ // the controller ends the call on this error
14580
+ if (!liveSignedIn(auth))
14581
+ throw new LiveCallSignedOutError();
14582
+ const receipt = await acceptDirectSend({
14583
+ botId, threadId, text,
14584
+ sendId: randomUUID().replaceAll("-", ""),
14585
+ sender: messageSender(auth),
14586
+ trigger: usageTriggerFor(auth),
14587
+ via: "call",
14588
+ // a person is on the call: these are their words
14589
+ personPresent: true,
14590
+ });
14591
+ if ("queued" in receipt)
14592
+ return { kind: "queued", queueId: receipt.queueId };
14593
+ return { kind: receipt.steered ? "steered" : "started", messageId: receipt.message.id };
14594
+ },
14595
+ respond: async (input) => {
14596
+ const result = await respondToCard(input);
14597
+ if (result.ok)
14598
+ return { ok: true };
14599
+ // respondToCard's 401: the session that started the call has ended
14600
+ if (result.status === 401)
14601
+ throw new LiveCallSignedOutError();
14602
+ return { ok: false, error: result.error };
14603
+ },
14604
+ // send() queues through the steer queue; an edit or cancel there removes a request undelivered
14605
+ queued: isSteeredMessageQueued,
14606
+ signedIn: liveSignedIn,
14607
+ // the caller's own typed lines carry this sender (none for the owner)
14608
+ personKey: (auth) => messageSender(auth)?.id,
14609
+ activity: (botId, threadId) => {
14610
+ const task = store.taskByThread(botId, threadId);
14611
+ if (!task?.busy)
14612
+ return "idle";
14613
+ return task.activity === "waiting-on-you" ? "waiting" : "working";
14614
+ },
14615
+ broadcast: (frame) => broadcast(frame, { adminOnly: true }),
14616
+ settings: () => ({ key: cfg.live?.key ?? "", ...liveSettingsFor(cfg) }),
14617
+ createSession: ({ key, sdp, botId, threadId, voice }) => createLiveSession({ key, sdp, voice, bot: liveBotFor(botId), history: liveHistoryFor(threadId) }),
14618
+ // Node's WebSocket (undici) accepts headers in its second argument.
14619
+ openSocket: (url, key) => new WebSocket(url, { headers: { authorization: `Bearer ${key}` } }),
14620
+ attachUrl: (sessionId) => liveAttachUrl(sessionId),
14621
+ speakable: (text) => toUtterances(text),
14622
+ log: (line) => console.log(line),
14623
+ });
14624
+ // A signed-out or revoked sign-in ends the call it started at once (the idle
14625
+ // check would only notice within 15 s). A paired phone's unpairing arrives
14626
+ // from the companion instead (POST /api/live/device-revoked).
14627
+ sessions.onSessionRevoked((sessionId) => liveCalls.sessionRevoked(sessionId));
14628
+ ROUTES.push(createLiveRoutes({
14629
+ calls: liveCalls,
14630
+ resolveTarget: (botId, threadId) => {
14631
+ const bot = store.bot(botId);
14632
+ if (!bot)
14633
+ return null;
14634
+ const target = threadId ?? bot.threadId;
14635
+ if (store.botByThread(target)?.id !== bot.id)
14636
+ return null;
14637
+ return { botId: bot.id, botName: bot.name, threadId: target };
14638
+ },
14639
+ settings: () => liveSettingsFor(cfg),
14640
+ // Non-secret settings only, written the way PUT /api/config writes a
14641
+ // section: saveConfig merges into `live`, so the key stays where it is.
14642
+ saveSettings: async (patch) => {
14643
+ if (providerConfigBusy)
14644
+ throw Object.assign(new Error("Settings are already being updated. Try again in a moment."), { status: 409 });
14645
+ saveConfig({ live: patch });
14646
+ Object.assign(cfg, loadConfig());
14647
+ broadcast({ kind: "config", ...configStatus() });
14648
+ return liveSettingsFor(cfg);
14649
+ },
14650
+ }));
13829
14651
  const toolResults = new ToolResults();
13830
14652
  const handleRequest = async (req, res) => {
13831
14653
  let url;
@@ -13882,7 +14704,7 @@ const handleRequest = async (req, res) => {
13882
14704
  if (method === "GET" && !path.startsWith("/api/") && !path.startsWith("/.well-known/") && serveStatic(res, path))
13883
14705
  return;
13884
14706
  if (method === "GET" && path === "/.well-known/openmausbot/environment") {
13885
- return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !HOSTED_WORKSPACE && !CLOUD_HOME && emailSignIn.enabled(), sharedComputers: lendingEnabled() }));
14707
+ return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !HOSTED_WORKSPACE && !CLOUD_HOME && emailSignIn.enabled(), sharedComputers: lendingEnabled(), cloudHome: Boolean(CLOUD_HOME) }));
13886
14708
  }
13887
14709
  const domainCheck = /^\/\.well-known\/openmausbot\/domain-check\/([a-f0-9]{64})$/.exec(path);
13888
14710
  if (method === "GET" && domainCheck) {
@@ -13981,7 +14803,7 @@ const handleRequest = async (req, res) => {
13981
14803
  console.warn(`pairing refused from ${requestSource(req)}: ${result.error}`);
13982
14804
  return json(res, result.status, { error: result.error });
13983
14805
  }
13984
- const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !CLOUD_HOME && emailSignIn.enabled(), sharedComputers: lendingEnabled() });
14806
+ const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !CLOUD_HOME && emailSignIn.enabled(), sharedComputers: lendingEnabled(), cloudHome: Boolean(CLOUD_HOME) });
13985
14807
  if (wantsCookie) {
13986
14808
  // A browser sign-in replaces this browser's own session here, if it had one, rather than leaving it behind.
13987
14809
  const previous = browser ? sessions.authenticate(parseCookies(req.headers.cookie).get(SESSION_COOKIE)) : null;
@@ -15252,7 +16074,7 @@ const handleRequest = async (req, res) => {
15252
16074
  return json(res, 403, { error: "source conversation does not belong to sender" });
15253
16075
  }
15254
16076
  return json(res, 200, {
15255
- skills: listSkills(from.id),
16077
+ skills: listBotSkills(from),
15256
16078
  staged: listStagedSkillWrites(from.id).map(stagedSkillListing),
15257
16079
  });
15258
16080
  }
@@ -15907,9 +16729,7 @@ const handleRequest = async (req, res) => {
15907
16729
  if (!task)
15908
16730
  throw new Error("The recipient no longer exists");
15909
16731
  target.threadId = createdThread = task.threadId;
15910
- if (delegatedFullAccess(internalSender, internalCapability.threadId, store.bot(target.botId))) {
15911
- grantDelegatedFullAccess(internalSender, store.bot(target.botId), target.threadId);
15912
- }
16732
+ applyDelegatedLevel(internalSender, internalCapability.threadId, store.bot(target.botId), target.threadId);
15913
16733
  }
15914
16734
  const { node, duplicate } = roomHandoffs.enqueue(address, internalCapability.generation, internalCapability.roomHandoffId, target, parsed.data.requestKey + ":" + target.botId, parsed.data.message, approvalGranted, parsed.data.rework, [...store.messagesFor(address.threadId)].reverse().find(m => m.role === "user" && m.kind === "text")?.text ?? "", destination ? parsed.data.requestKey : undefined);
15915
16735
  // A retry keeps its original task; discard the speculative row.
@@ -16180,11 +17000,15 @@ const handleRequest = async (req, res) => {
16180
17000
  const target = store.bot(toBotId);
16181
17001
  if (!target)
16182
17002
  return json(res, 404, { error: "no such bot" });
17003
+ const oneWay = body.oneWay === true;
17004
+ if (oneWay && target.id === from.id) {
17005
+ return json(res, 409, { error: "send_to_bot is cross-bot only; use start_thread to send independent work to yourself" });
17006
+ }
16183
17007
  if (internalCapability.roomCoordination) {
16184
- if (target.id !== from.id) {
17008
+ if (target.id !== from.id && !oneWay) {
16185
17009
  return json(res, 409, { error: "Use coordinate_bots for teamwork; it queues actual teammates and resumes you automatically." });
16186
17010
  }
16187
- if (!internalCapability.ownThreadCreation) {
17011
+ if (target.id === from.id && !internalCapability.ownThreadCreation) {
16188
17012
  return json(res, 409, { error: "Only a direct user request can open separate self-owned threads. Finish this assigned work here; use coordinate_bots for teammates." });
16189
17013
  }
16190
17014
  }
@@ -16236,7 +17060,7 @@ const handleRequest = async (req, res) => {
16236
17060
  // classic handoff has applies unchanged, here at queue time and
16237
17061
  // again in the drain at dispatch time.
16238
17062
  const depth = internalCapability.depth;
16239
- if (depth >= MAX_COMMS_DEPTH) {
17063
+ if (!oneWay && depth >= MAX_COMMS_DEPTH) {
16240
17064
  return json(res, 200, { error: "thread chains are limited to one hop — open the thread on yourself, or do this one here" });
16241
17065
  }
16242
17066
  if (!canAccessTeam(from, target.section) || target.hidden) {
@@ -16250,9 +17074,11 @@ const handleRequest = async (req, res) => {
16250
17074
  return json(res, 500, { error: "couldn't create that thread" });
16251
17075
  // The work is still for the person whose request the opener is on.
16252
17076
  threadStarters.set(task.threadId, openerFrom(fromThreadId));
16253
- if (delegatedFullAccess(from, fromThreadId, target))
16254
- grantDelegatedFullAccess(from, target, task.threadId);
16255
- const queued = queueDelegation(commsBus, from, { toBotId: target.id, message, depth, targetThreadId: task.threadId }, MAX_COMMS_DEPTH, fromThreadId);
17077
+ applyDelegatedLevel(from, fromThreadId, target, task.threadId);
17078
+ const sourceUrl = threadRefUrl({ botId: owner.group?.id ?? from.id, threadId: fromThreadId });
17079
+ const queued = queueDelegation(commsBus, from, { toBotId: target.id, message: oneWay
17080
+ ? `${message}\n\n[Source conversation](${sourceUrl}). This link grants no additional access and contains no copied transcript.`
17081
+ : message, depth, targetThreadId: task.threadId, oneWay }, MAX_COMMS_DEPTH, fromThreadId);
16256
17082
  if (queued.result !== "ok" || !queued.id) {
16257
17083
  // nothing will ever run there: take the row back before the person
16258
17084
  // sees a thread that goes nowhere
@@ -18077,6 +18903,7 @@ const handleRequest = async (req, res) => {
18077
18903
  return true;
18078
18904
  const openApprovalThreads = store.groupTasks(group.id).flatMap((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
18079
18905
  message.card?.requestId &&
18906
+ (!isPersistentQuestionCard(message.card) || runningTurnEngines.has(task.threadId)) &&
18080
18907
  !message.card.answered &&
18081
18908
  !message.card.dismissed && !message.card.expired) ? [task.threadId] : []);
18082
18909
  return openApprovalThreads.length > 0 && !openApprovalThreads.includes(targetThreadId);
@@ -18828,10 +19655,11 @@ const handleRequest = async (req, res) => {
18828
19655
  if (created && !created.ok)
18829
19656
  return json(res, 400, { error: created.error });
18830
19657
  const bot = store.createBot({ ...profile.patch, soul: settings.soul, section, modelSelection: selection,
19658
+ toolScope: settings.toolScope ?? undefined,
18831
19659
  ...(created?.ok ? { visibility: created.visibility } : {}) });
18832
19660
  const createdRoutines = [];
18833
19661
  try {
18834
- const { chiefOfStaff: _chief, managedSections: _managed, ...ordinary } = settings;
19662
+ const { chiefOfStaff: _chief, managedSections: _managed, toolScope: _toolScope, ...ordinary } = settings;
18835
19663
  store.patchBot(bot.id, {
18836
19664
  // Store creation completes workspace defaults (including effort).
18837
19665
  // Applying the rest of the template must not undo that selection.
@@ -19045,7 +19873,7 @@ const handleRequest = async (req, res) => {
19045
19873
  m = path.match(/^\/api\/bots\/([\w-]+)\/command-allowlist(?:\/([\w-]+))?$/);
19046
19874
  if (m && ["GET", "POST", "DELETE"].includes(method)) {
19047
19875
  if (!canManageCommandAllowlist(auth))
19048
- return json(res, 403, { error: "Only the workspace owner or an admin can manage command permissions." });
19876
+ return json(res, 403, { error: "Only the owner or an admin can manage command permissions." });
19049
19877
  const bot = requestedTaskBot(m[1], url.searchParams.get("threadId") ?? undefined);
19050
19878
  if (method === "GET" && !m[2])
19051
19879
  return json(res, 200, commandAllowlistResponse(bot));
@@ -19271,6 +20099,12 @@ const handleRequest = async (req, res) => {
19271
20099
  // Per-bot selection of app-wide MCP servers. Omitted keeps the current
19272
20100
  // selection; null restores all enabled servers; [] explicitly mounts none.
19273
20101
  let requestedMcpServers = existingBot?.mcpServers;
20102
+ if (Object.hasOwn(body, "toolScope")) {
20103
+ const parsed = parseToolScope(body.toolScope === null ? undefined : body.toolScope);
20104
+ if (!parsed.ok)
20105
+ return json(res, 400, { error: parsed.error });
20106
+ patch.toolScope = parsed.scope;
20107
+ }
19274
20108
  if (body.mcpServers !== undefined) {
19275
20109
  if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
19276
20110
  return json(res, 401, { error: "unauthorized: this session has expired or was revoked" });
@@ -19529,7 +20363,7 @@ const handleRequest = async (req, res) => {
19529
20363
  // come from local scripts only when all bots are idle; paired owners
19530
20364
  // and the packaged desktop's private capability can grant it mid-turn.
19531
20365
  if (loosened.includes("managedSections") && auth.kind === "loopback" && !DESKTOP_MANAGED && store.bots.some(bot => bot.busy)) {
19532
- return json(res, 409, { error: "Stop running bots before granting access to another team, or use the desktop app or a paired owner session." });
20366
+ return json(res, 409, { error: "Stop running bots before granting access to another team, or use the desktop app or a paired Full-access session." });
19533
20367
  }
19534
20368
  if (body.peers !== undefined && Array.isArray(existingBot?.peers)) {
19535
20369
  const nextPeers = patch.peers;
@@ -19548,6 +20382,8 @@ const handleRequest = async (req, res) => {
19548
20382
  (requestedMcpServers === undefined || requestedMcpServers.some((name) => !existingBot.mcpServers.includes(name)))) {
19549
20383
  loosened.push("mcpServers");
19550
20384
  }
20385
+ if (Object.hasOwn(body, "toolScope") && toolScopeWidens(existingBot?.toolScope, patch.toolScope))
20386
+ loosened.push("toolScope");
19551
20387
  const browserOrigin = typeof req.headers.origin === "string" && req.headers.origin.trim() !== "";
19552
20388
  if (loosened.length && auth.kind === "loopback" && !DESKTOP_MANAGED && !browserOrigin) {
19553
20389
  if (store.bots.some((candidate) => candidate.busy)) {
@@ -19583,6 +20419,18 @@ const handleRequest = async (req, res) => {
19583
20419
  (freshBrowserBot.busy || activeGroupTurnForBot(freshBrowserBot.id))) {
19584
20420
  return json(res, 409, { error: "stop this bot's turn before changing its memory setting" });
19585
20421
  }
20422
+ if (Object.hasOwn(body, "toolScope")) {
20423
+ if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
20424
+ return json(res, 401, { error: "unauthorized: this session has expired or was revoked" });
20425
+ }
20426
+ if (toolScopeWidens(freshBrowserBot?.toolScope, patch.toolScope) && auth.kind === "loopback" && !DESKTOP_MANAGED && !browserOrigin && store.bots.some((candidate) => candidate.busy)) {
20427
+ return json(res, 409, { error: "A bot is working right now, so this change has to come from the desktop app or a paired device. Try again once every bot is idle." });
20428
+ }
20429
+ if (freshBrowserBot && JSON.stringify(freshBrowserBot.toolScope) !== JSON.stringify(patch.toolScope)
20430
+ && (freshBrowserBot.busy || activeGroupTurnForBot(freshBrowserBot.id))) {
20431
+ return json(res, 409, { error: "Stop this bot's turns before changing its tool selection." });
20432
+ }
20433
+ }
19586
20434
  // Custom servers are process configuration: a running turn cannot
19587
20435
  // unmount them. Recheck after any awaited runtime revocation above.
19588
20436
  if (body.mcpServers !== undefined) {
@@ -19734,8 +20582,19 @@ const handleRequest = async (req, res) => {
19734
20582
  if (m && method === "GET") {
19735
20583
  if (!store.bot(m[1]))
19736
20584
  return json(res, 404, { error: "no such bot" });
20585
+ if (skillsLibraryEnabled(cfg)) {
20586
+ const { skills, library } = listBotSkillsWithOrigin(m[1], store.bot(m[1]).assignedSkills);
20587
+ return json(res, 200, {
20588
+ skills,
20589
+ library,
20590
+ // the authoritative assignment list: merged rows hide
20591
+ // assignments a same-name private skill shadows
20592
+ assignedSkills: store.bot(m[1]).assignedSkills,
20593
+ staged: listStagedSkillWrites(m[1]).map(stagedSkillListing),
20594
+ });
20595
+ }
19737
20596
  return json(res, 200, {
19738
- skills: listSkills(m[1]),
20597
+ skills: listBotSkills(store.bot(m[1])),
19739
20598
  staged: listStagedSkillWrites(m[1]).map(stagedSkillListing),
19740
20599
  });
19741
20600
  }
@@ -19757,7 +20616,8 @@ const handleRequest = async (req, res) => {
19757
20616
  }
19758
20617
  m = path.match(/^\/api\/bots\/([\w-]+)\/skills\/([a-z0-9-]+)$/);
19759
20618
  if (m && method === "GET") {
19760
- const text = readSkillFile(m[1], m[2]);
20619
+ const bot = store.bot(m[1]);
20620
+ const text = bot ? readBotSkillFile(bot, m[2]) : null;
19761
20621
  if (text === null)
19762
20622
  return json(res, 404, { error: "no such skill" });
19763
20623
  return json(res, 200, { text });
@@ -19777,6 +20637,95 @@ const handleRequest = async (req, res) => {
19777
20637
  return json(res, 404, { error: result.error });
19778
20638
  return json(res, 200, { ok: true });
19779
20639
  }
20640
+ // ── skills library (features.skillsLibrary): local browse surface ────
20641
+ // Everything here is local: the library on disk, assignments on bot
20642
+ // records, imports from pasted SKILL.md text. No registry, no network.
20643
+ m = path.match(/^\/api\/skills-library$/);
20644
+ if (m && method === "GET") {
20645
+ if (!skillsLibraryEnabled(cfg))
20646
+ return json(res, 404, { error: "skills library is not enabled" });
20647
+ const index = readSkillLibraryIndex();
20648
+ const skills = listLibrarySkills()
20649
+ .map((listing) => ({
20650
+ name: listing.name,
20651
+ description: listing.description,
20652
+ source: listing.source,
20653
+ enabled: listing.enabled,
20654
+ tags: listing.tags ?? [],
20655
+ version: index[listing.name]?.package?.release ?? null,
20656
+ importedAt: listing.importedAt,
20657
+ ...(listing.license ? { license: listing.license } : {}),
20658
+ ...(listing.compatibility ? { compatibility: listing.compatibility } : {}),
20659
+ warnings: listing.warnings,
20660
+ assignedBots: store.bots
20661
+ .filter((bot) => bot.assignedSkills?.includes(listing.name))
20662
+ .map((bot) => ({ id: bot.id, name: bot.name })),
20663
+ }))
20664
+ .sort((a, b) => a.name.localeCompare(b.name));
20665
+ return json(res, 200, { skills });
20666
+ }
20667
+ if (m && method === "POST") {
20668
+ if (!skillsLibraryEnabled(cfg))
20669
+ return json(res, 404, { error: "skills library is not enabled" });
20670
+ const parsed = z.object({ text: z.string().min(1) }).safeParse(await readBody(req));
20671
+ if (!parsed.success)
20672
+ return json(res, 400, { error: "text must be the SKILL.md contents to import" });
20673
+ const skill = parseSkillMd(parsed.data.text);
20674
+ if ("error" in skill)
20675
+ return json(res, 400, { error: skill.error });
20676
+ // Imports land disabled with their scan warnings attached: enabling is
20677
+ // a separate, explicit review step on the row.
20678
+ const installed = installLibrarySkill({
20679
+ name: skill.name,
20680
+ instructions: parsed.data.text,
20681
+ source: "local-import",
20682
+ license: skill.license,
20683
+ compatibility: skill.compatibility,
20684
+ tags: skill.tags,
20685
+ warnings: scanSkillText(parsed.data.text),
20686
+ reviewState: "disabled",
20687
+ });
20688
+ if ("error" in installed)
20689
+ return json(res, 422, { error: installed.error });
20690
+ return json(res, 201, { skill: installed });
20691
+ }
20692
+ m = path.match(/^\/api\/skills-library\/([a-z0-9-]+)$/);
20693
+ if (m && method === "GET") {
20694
+ if (!skillsLibraryEnabled(cfg))
20695
+ return json(res, 404, { error: "skills library is not enabled" });
20696
+ const text = readLibrarySkillFile(m[1]);
20697
+ if (text === null)
20698
+ return json(res, 404, { error: "no such skill" });
20699
+ return json(res, 200, { text });
20700
+ }
20701
+ if (m && method === "PATCH") {
20702
+ if (!skillsLibraryEnabled(cfg))
20703
+ return json(res, 404, { error: "skills library is not enabled" });
20704
+ const parsed = z.object({ enabled: z.boolean() }).safeParse(await readBody(req));
20705
+ if (!parsed.success)
20706
+ return json(res, 400, { error: "enabled must be true or false" });
20707
+ const result = setLibrarySkillReviewState(m[1], parsed.data.enabled ? "approved" : "disabled");
20708
+ if ("error" in result)
20709
+ return json(res, 404, { error: result.error });
20710
+ return json(res, 200, { skill: result });
20711
+ }
20712
+ m = path.match(/^\/api\/bots\/([\w-]+)\/skills-library$/);
20713
+ if (m && method === "PUT") {
20714
+ if (!skillsLibraryEnabled(cfg))
20715
+ return json(res, 404, { error: "skills library is not enabled" });
20716
+ if (!store.bot(m[1]))
20717
+ return json(res, 404, { error: "no such bot" });
20718
+ const parsed = z.object({ skills: z.array(z.string().min(1).max(64)).max(200) }).safeParse(await readBody(req));
20719
+ if (!parsed.success)
20720
+ return json(res, 400, { error: "skills must be a list of skill names" });
20721
+ const index = readSkillLibraryIndex();
20722
+ const names = [...new Set(parsed.data.skills)];
20723
+ const unknown = names.filter((name) => !index[name]);
20724
+ if (unknown.length)
20725
+ return json(res, 422, { error: "not in the library: " + unknown.join(", ") });
20726
+ store.patchBot(m[1], { assignedSkills: names });
20727
+ return json(res, 200, { assignedSkills: names });
20728
+ }
19780
20729
  // ── section context: a user-owned team brief ────────────────────────
19781
20730
  // Bots receive this in their system context, but no agent tool can write
19782
20731
  // it. That keeps one bot from silently changing every teammate's future
@@ -20121,53 +21070,17 @@ const handleRequest = async (req, res) => {
20121
21070
  const trigger = onBehalfOf
20122
21071
  ? { kind: "user", ...(onBehalfOf.email ? { email: onBehalfOf.email } : {}), ...(onBehalfOf.name ? { label: onBehalfOf.name } : {}) }
20123
21072
  : usageTriggerFor(auth);
20124
- // The send is acknowledged before the turn starts, so a workspace at its
20125
- // spend limit is refused here, where the person can see it.
20126
- try {
20127
- assertWithinBudget(cfg, DATA_DIR);
20128
- }
20129
- catch (error) {
20130
- return json(res, 409, { error: error instanceof Error ? error.message : String(error), code: "spend_cap" });
20131
- }
20132
- if (!store.taskByThread(bot.id, threadId)) {
20133
- return json(res, 409, { error: "the bot switched tasks before it could receive the message" });
20134
- }
21073
+ // The spend cap and a task that is gone answer before a malformed
21074
+ // sendId or reply target does. acceptDirectSend checks both again for
21075
+ // callers that do not come through this route; nothing awaits between.
21076
+ const refused = directSendRefusal(bot.id, threadId);
21077
+ if (refused)
21078
+ return json(res, refused.status, refused.body);
20135
21079
  const sendId = parseSendId(body.sendId);
20136
21080
  const replyTo = resolveReplyTarget(threadId, body.replyToId);
20137
- const receipt = await sendSequencer.run(sendId ? `bot:${bot.id}:${threadId}:${sendId}` : undefined, sendFingerprint(text, replyTo?.id), async () => {
20138
- if (sendId) {
20139
- if (cancelledChatFollowup("bot", bot.id, threadId, sendId)) {
20140
- throw Object.assign(new Error("this queued sendId was cancelled; send a new message to try again"), { status: 409 });
20141
- }
20142
- const accepted = acceptedSendMatch(store.messagesFor(threadId), sendId, text, replyTo?.id);
20143
- if (accepted.kind === "conflict") {
20144
- throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
20145
- }
20146
- if (accepted.kind === "match") {
20147
- const canonical = {
20148
- ok: true,
20149
- threadId,
20150
- message: accepted.message,
20151
- };
20152
- return accepted.message.steered
20153
- ? { ...canonical, steered: true }
20154
- : canonical;
20155
- }
20156
- const queued = queuedSteeredMessage(bot.id, threadId, sendId);
20157
- if (queued) {
20158
- if (queued.text !== text || queued.replyToId !== replyTo?.id) {
20159
- throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
20160
- }
20161
- return { ok: true, queued: true, queueId: queued.id, threadId, reason: queued.reason };
20162
- }
20163
- }
20164
- const currentAtStart = store.projectBotForTask(bot.id, threadId);
20165
- if (!currentAtStart)
20166
- throw Object.assign(new Error("no such bot"), { status: 404 });
20167
- if (!store.taskByThread(currentAtStart.id, threadId)) {
20168
- throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
20169
- }
20170
- if (guarded) {
21081
+ const sender = messageSender(auth);
21082
+ const guardedStart = guarded
21083
+ ? async (currentAtStart) => {
20171
21084
  // There is no await between these checks and startTurn's
20172
21085
  // synchronous transcript append / runtime reservation. In
20173
21086
  // particular, never steer or enqueue under stale permissions.
@@ -20189,91 +21102,32 @@ const handleRequest = async (req, res) => {
20189
21102
  if (guardedAdmission.action === "refuse") {
20190
21103
  throw Object.assign(new Error("wait for a free thread slot before retrying this message"), { status: 409, code: "guarded_busy" });
20191
21104
  }
20192
- const message = await startTurn(bot.id, text, { threadId, replyTo, sendId, sender: messageSender(auth), trigger });
21105
+ // Stored as relayed: a worker's line for someone else, which a
21106
+ // Live call on this thread must not read back as typed there.
21107
+ const message = await startTurn(bot.id, text, { threadId, replyTo, sendId, sender, trigger, relayed: true });
20193
21108
  return { ok: true, threadId, message };
20194
21109
  }
20195
- // Claude can accept the message inside its live turn. If the write
20196
- // loses a race with turn settlement, or the engine cannot steer, the
20197
- // existing server-side queue records it atomically for the next turn.
20198
- if (currentAtStart.busy) {
20199
- const instance = runningTurnInstance(currentAtStart, threadId);
20200
- let steered = "refused";
20201
- // A live text steer has no image side channel. Keep an attachment
20202
- // message intact for the next ordinary turn, where central image
20203
- // admission can hand it to the provider natively.
20204
- const carriesImages = extractTurnImages(text).images.length > 0;
20205
- const steerTarget = handoffs.current(threadId);
20206
- const busyAdmission = admit("direct-busy", {
20207
- carriesImages,
20208
- pendingComputerSelection: Boolean(computerSelectionTurns.get(threadId)?.selected),
20209
- engineCanSteer: Boolean(instance?.adapter.capabilities.queueing && instance.adapter.steer),
20210
- });
20211
- // steer was offered only when a live instance could take it;
20212
- // the second check carries that fact to the type system.
20213
- if (busyAdmission.action === "steer" && instance?.adapter.steer) {
20214
- steered = await instance.adapter
20215
- .steer(threadId, promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"))
20216
- .catch(() => "indeterminate");
20217
- }
20218
- // steer() is awaited adapter work. The turn can settle, the task can
20219
- // switch, or the whole bot can be deleted before its acknowledgement
20220
- // arrives. Re-read every ownership invariant before appending even a
20221
- // successful steer; otherwise that late acknowledgement writes a user
20222
- // message into a task the bot no longer owns. A conflict leaves the
20223
- // text in the client's composer/outbox to resend deliberately.
20224
- const current = store.projectBotForTask(bot.id, threadId);
20225
- if (!current)
20226
- throw Object.assign(new Error("no such bot"), { status: 404 });
20227
- if (!store.taskByThread(bot.id, threadId)) {
20228
- throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
20229
- }
20230
- const delivered = steered !== "refused";
20231
- if (delivered) {
20232
- if (steered === "steered" && !current.busy) {
20233
- throw Object.assign(new Error("the running turn ended before the steered message could be recorded"), { status: 409 });
20234
- }
20235
- // "indeterminate" falls through to the same record: the words
20236
- // may already be folded into a turn whose acknowledgement was
20237
- // lost, and handing them back for a resend could run them
20238
- // twice. Recording them once is the honest outcome.
20239
- // A person steering a webhook turn is present, and auto mode may
20240
- // follow them again. But this route is also reachable from the
20241
- // bot's own shell on a headless server (loopback is the owner
20242
- // there), and "continue" typed by the turn itself must not be
20243
- // the thing that lifts the block written against it — so only
20244
- // a request that proves a person (a paired session, or the
20245
- // desktop's owner capability, which every mutation there has
20246
- // already shown) clears the mark.
20247
- if (auth.kind === "session" || DESKTOP_MANAGED)
20248
- clearUnattended(threadId);
20249
- const message = store.appendMessage(threadId, {
20250
- role: "user",
20251
- kind: "text",
20252
- text,
20253
- replyToId: replyTo?.id,
20254
- sendId,
20255
- steered: true,
20256
- sender: messageSender(auth),
20257
- });
20258
- // Offered to the next turn again unless the person stops this one.
20259
- handoffs.steered(threadId, steerTarget, instance?.instanceId, message.id);
20260
- return { ok: true, steered: true, threadId, message };
20261
- }
20262
- if (!current.busy) {
20263
- return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId, messageSender(auth), trigger);
20264
- }
20265
- const queued = queueSteeredMessage(current.id, threadId, text, {
20266
- replyToId: replyTo?.id,
20267
- sendId,
20268
- prompt: promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"),
20269
- sender: messageSender(auth),
20270
- trigger,
20271
- });
20272
- return { ok: true, queued: true, queueId: queued.id, threadId };
20273
- }
20274
- return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId, messageSender(auth), trigger);
20275
- });
20276
- return json(res, 202, receipt);
21110
+ : undefined;
21111
+ try {
21112
+ const receipt = await acceptDirectSend({
21113
+ botId: bot.id, threadId, text, sendId, replyTo, sender, trigger,
21114
+ // A person steering a webhook turn is present, and auto mode may
21115
+ // follow them again. But this route is also reachable from the
21116
+ // bot's own shell on a headless server (loopback is the owner
21117
+ // there), and "continue" typed by the turn itself must not be the
21118
+ // thing that lifts the block written against it — so only a
21119
+ // request that proves a person (a paired session, or the desktop's
21120
+ // owner capability, which every mutation there has already shown)
21121
+ // clears the mark.
21122
+ personPresent: auth.kind === "session" || DESKTOP_MANAGED,
21123
+ }, guardedStart);
21124
+ return json(res, 202, receipt);
21125
+ }
21126
+ catch (error) {
21127
+ if (error instanceof DirectSendRefused)
21128
+ return json(res, error.status, error.body);
21129
+ throw error;
21130
+ }
20277
21131
  }
20278
21132
  m = path.match(/^\/api\/bots\/([\w-]+)\/queue\/([\w-]+)$/);
20279
21133
  if (m && method === "DELETE") {
@@ -20340,6 +21194,7 @@ const handleRequest = async (req, res) => {
20340
21194
  peerAsk: item.peerAsk,
20341
21195
  steered: true,
20342
21196
  sender: item.sender,
21197
+ ...(item.via ? { via: item.via } : {}),
20343
21198
  }));
20344
21199
  // Offered to the next turn again unless the person stops this one.
20345
21200
  for (const message of messages)
@@ -20529,7 +21384,32 @@ const handleRequest = async (req, res) => {
20529
21384
  resolvePeerComms(approvalBus, String(body.requestId), behavior)) {
20530
21385
  return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
20531
21386
  }
20532
- const outcome = await answerRequest(bot.threadId, bot.modelSelection.instanceId, String(body.requestId), behavior, body.message, { id: bot.id, name: bot.name }, body.always === true, body.rememberCommand === true);
21387
+ const requestId = String(body.requestId);
21388
+ const pending = store.messagesFor(bot.threadId).find((message) => message.card?.requestId === requestId);
21389
+ if (isPersistentQuestionCard(pending?.card) && (body.dismiss === true || body.message === QUESTION_DISMISS_MESSAGE)) {
21390
+ if (!pending?.card)
21391
+ return json(res, 404, { error: "this question is no longer available" });
21392
+ if (pending.card.answered !== "answer" || pending.card.dismissed) {
21393
+ return json(res, 409, { error: "answer this question before dismissing it" });
21394
+ }
21395
+ store.patchMessage(bot.threadId, pending.id, { card: { ...pending.card, dismissed: true } });
21396
+ return json(res, 200, { ok: true, dismissed: true });
21397
+ }
21398
+ const outcome = await answerRequest(bot.threadId, bot.modelSelection.instanceId, requestId, behavior, body.message, { id: bot.id, name: bot.name }, body.always === true, body.rememberCommand === true);
21399
+ if (outcome === "unavailable" && isPersistentQuestionCard(pending?.card) && behavior === "answer") {
21400
+ if (!pending)
21401
+ return json(res, 404, { error: "this question is no longer available" });
21402
+ const answer = typeof body.message === "string" ? body.message.trim() : "";
21403
+ if (!answer)
21404
+ return json(res, 400, { error: "a question answer is required" });
21405
+ try {
21406
+ const reply = await deliverLateQuestionAnswer(auth, bot.threadId, requestId, answer, pending, bot);
21407
+ return json(res, 200, { ok: true, outcome: "answered", late: true, ...(reply.queued ? { queued: true } : {}) });
21408
+ }
21409
+ catch (error) {
21410
+ return json(res, 409, { error: error instanceof Error ? error.message : "the late answer could not be queued" });
21411
+ }
21412
+ }
20533
21413
  return json(res, 200, { ok: true, outcome });
20534
21414
  });
20535
21415
  }
@@ -20655,14 +21535,46 @@ const handleRequest = async (req, res) => {
20655
21535
  // answerRequest closes an unreachable card, and a pending approval owns
20656
21536
  // the composer, so a dead end here locks the room for good.
20657
21537
  const pending = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId);
21538
+ if (isPersistentQuestionCard(pending?.card) && (body.dismiss === true || body.message === QUESTION_DISMISS_MESSAGE)) {
21539
+ if (!pending?.card)
21540
+ return json(res, 404, { error: "this question is no longer available" });
21541
+ if (pending.card.answered !== "answer" || pending.card.dismissed) {
21542
+ return json(res, 409, { error: "answer this question before dismissing it" });
21543
+ }
21544
+ store.patchMessage(threadId, pending.id, { card: { ...pending.card, dismissed: true } });
21545
+ return json(res, 200, { ok: true, dismissed: true });
21546
+ }
21547
+ const persistentQuestion = isPersistentQuestionCard(pending?.card);
20658
21548
  const owner = group
20659
- ? (group.busyBotId ? store.bot(group.busyBotId) : undefined) ??
20660
- (pending?.from ? store.bot(pending.from.botId) : undefined)
21549
+ ? persistentQuestion
21550
+ ? (pending?.from ? store.bot(pending.from.botId) : undefined)
21551
+ : (group.busyBotId ? store.bot(group.busyBotId) : undefined) ??
21552
+ (pending?.from ? store.bot(pending.from.botId) : undefined)
20661
21553
  : store.botByThread(threadId);
21554
+ // A surviving question belongs to its original asker, never a later speaker.
21555
+ if (group && persistentQuestion && !owner) {
21556
+ return json(res, 409, { error: "the bot that asked this question is no longer available" });
21557
+ }
20662
21558
  if (!owner && !pending)
20663
21559
  return json(res, 404, { error: "nothing is waiting on an answer in this conversation" });
20664
21560
  const requestOwner = owner ? botForThread(owner.id, threadId) : null;
20665
21561
  const outcome = await answerRequest(threadId, requestOwner?.modelSelection.instanceId ?? "", requestId, behavior, body.message, owner ? { id: owner.id, name: owner.name } : undefined, body.always === true, body.rememberCommand === true);
21562
+ if (outcome === "unavailable" && isPersistentQuestionCard(pending?.card) && behavior === "answer") {
21563
+ if (!pending?.card)
21564
+ return json(res, 404, { error: "this question is no longer available" });
21565
+ const answer = typeof body.message === "string" ? body.message.trim() : "";
21566
+ if (!answer)
21567
+ return json(res, 400, { error: "a question answer is required" });
21568
+ if (!owner)
21569
+ return json(res, 409, { error: "the bot that asked this question is no longer available" });
21570
+ try {
21571
+ const reply = await deliverLateQuestionAnswer(auth, threadId, requestId, answer, pending, owner);
21572
+ return json(res, 200, { ok: true, outcome: "answered", late: true, ...(reply.queued ? { queued: true } : {}) });
21573
+ }
21574
+ catch (error) {
21575
+ return json(res, 409, { error: error instanceof Error ? error.message : "the late answer could not be queued" });
21576
+ }
21577
+ }
20666
21578
  return json(res, 200, { ok: true, outcome });
20667
21579
  });
20668
21580
  }
@@ -20867,7 +21779,7 @@ const handleRequest = async (req, res) => {
20867
21779
  const current = store.projectBotForTask(m[1], m[2]);
20868
21780
  if (!current)
20869
21781
  return json(res, 404, { error: "no such task" });
20870
- const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt", "pinned", "snoozedUntil", "surface"]);
21782
+ const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt", "pinned", "snoozedUntil", "surface", "refreshPermissions"]);
20871
21783
  if (Object.keys(body).some((key) => !allowed.has(key)))
20872
21784
  return json(res, 400, { error: "unsupported thread setting" });
20873
21785
  const notYours = cloudThreadRefusal(auth, m[2]);
@@ -20877,7 +21789,7 @@ const handleRequest = async (req, res) => {
20877
21789
  // asking them, even in a conversation someone else started, and what
20878
21790
  // the bot itself defaults to.
20879
21791
  if (CLOUD_HOME && !cloudOwnerSession(auth) &&
20880
- (body.approvalMode !== undefined || body.autoApprove !== undefined || body.acknowledgeLocalAuto !== undefined || body.updateBotDefault === true)) {
21792
+ (body.approvalMode !== undefined || body.autoApprove !== undefined || body.acknowledgeLocalAuto !== undefined || body.updateBotDefault === true || body.refreshPermissions === true)) {
20881
21793
  return json(res, 403, { error: "On this Cloud only its owner can change how a bot asks for approval, or its default model." });
20882
21794
  }
20883
21795
  for (const key of ["requireAvailableModel", "acknowledgeLocalAuto", "updateBotDefault", "resetApprovalToAsk"]) {
@@ -20892,6 +21804,50 @@ const handleRequest = async (req, res) => {
20892
21804
  (body.approvalMode !== undefined && body.approvalMode !== "ask") || body.autoApprove === true)) {
20893
21805
  return json(res, 400, { error: "resetApprovalToAsk requires a model selection and cannot be combined with another approval mode" });
20894
21806
  }
21807
+ if (body.refreshPermissions !== undefined && body.refreshPermissions !== true) {
21808
+ return json(res, 400, { error: "refreshPermissions must be true" });
21809
+ }
21810
+ if (body.refreshPermissions === true) {
21811
+ const extra = Object.keys(body).filter((key) => key !== "refreshPermissions" && key !== "acknowledgeLocalAuto");
21812
+ if (extra.length)
21813
+ return json(res, 400, { error: "refreshPermissions cannot be combined with other thread settings" });
21814
+ const profile = store.bot(m[1]);
21815
+ if (!profile)
21816
+ return json(res, 404, { error: "no such bot" });
21817
+ if (profile.approvalGrant)
21818
+ return json(res, 409, { error: "the bot's approval mode is still being confirmed" });
21819
+ if (threadBusy(profile.id, m[2]))
21820
+ return json(res, 409, { error: "stop this thread before changing its approval mode" });
21821
+ const nextMode = approvalModeFor({ ...profile, approvalGrant: undefined });
21822
+ // Full and Custom never travel over the bot-reachable HTTP surface.
21823
+ // The desktop's private channel copies those levels, and it is also
21824
+ // the only way to leave Custom.
21825
+ if (nextMode === "full" || nextMode === "custom") {
21826
+ return json(res, 403, { error: "Refresh Full or Custom access from the packaged desktop app" });
21827
+ }
21828
+ if (approvalModeFor(current) === "custom") {
21829
+ return json(res, 403, { error: "Leaving Custom approval requires the packaged desktop app" });
21830
+ }
21831
+ if (!supportsApprovalMode(current.modelSelection, nextMode)) {
21832
+ return json(res, 400, { error: "This provider does not support the selected approval level" });
21833
+ }
21834
+ if (nextMode === "auto" && current.computer === "local" && approvalModeFor(current) !== "auto" && body.acknowledgeLocalAuto !== true) {
21835
+ return json(res, 400, { error: "Auto mode on this computer requires confirming the warning first (acknowledgeLocalAuto)" });
21836
+ }
21837
+ const approvalRank = { ask: 0, edits: 1, auto: 2, full: 3, custom: 4 };
21838
+ const widensMode = approvalRank[nextMode] > approvalRank[approvalModeFor(current)];
21839
+ const nextAllow = profile.alwaysAllow ?? [];
21840
+ const widensAllow = nextAllow.some((key) => !(current.alwaysAllow ?? []).includes(key));
21841
+ if ((widensMode || widensAllow) && auth.kind === "loopback" && !DESKTOP_MANAGED && !req.headers.origin && store.bots.some((bot) => bot.busy)) {
21842
+ return json(res, 409, { error: "Change approval mode from the app or a paired device while bots are working." });
21843
+ }
21844
+ const task = store.refreshTaskPermissions(profile.id, m[2]);
21845
+ if (!task)
21846
+ return json(res, 404, { error: "no such task" });
21847
+ const fresh = botWithThread(store.bot(profile.id));
21848
+ broadcast({ kind: "bot", bot: fresh });
21849
+ return json(res, 200, { task: wireTask(task), bot: fresh });
21850
+ }
20895
21851
  const patch = {};
20896
21852
  if (body.projectId !== undefined) {
20897
21853
  if (body.projectId === null)
@@ -21311,8 +22267,10 @@ const handleRequest = async (req, res) => {
21311
22267
  localVmLifecycleBusy.add(SHARED_LOCAL_VM_TARGET.key);
21312
22268
  try {
21313
22269
  const status = await containerComputerAction(action, undefined, undefined, SHARED_LOCAL_VM_TARGET);
21314
- if (action === "run" || action === "start")
22270
+ if (action === "run" || action === "start") {
22271
+ localVmSeen.add(SHARED_LOCAL_VM_TARGET.key);
21315
22272
  localVmIdleFor(SHARED_LOCAL_VM_TARGET).touch();
22273
+ }
21316
22274
  if (action === "stop" || action === "remove")
21317
22275
  localVmIdleFor(SHARED_LOCAL_VM_TARGET).cancel();
21318
22276
  return json(res, 200, {
@@ -21344,7 +22302,7 @@ const handleRequest = async (req, res) => {
21344
22302
  return json(res, 404, { error: "no such bot" });
21345
22303
  return json(res, 200, await localVmPayload(localVmTargetForStatus(bot.id, bot.threadId), auth));
21346
22304
  }
21347
- m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer\/(run|stop|remove)$/);
22305
+ m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer\/(run|start|stop|remove)$/);
21348
22306
  if (m && method === "POST") {
21349
22307
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
21350
22308
  return json(res, 415, { error: "content-type must be application/json" });
@@ -21355,7 +22313,7 @@ const handleRequest = async (req, res) => {
21355
22313
  if (boatLifecycleBusyBots.has(bot.id)) {
21356
22314
  return json(res, 409, { error: "this bot's computer is being changed or deleted — wait for it to finish" });
21357
22315
  }
21358
- const action = z.enum(["run", "stop", "remove"]).parse(m[2]);
22316
+ const action = z.enum(["run", "start", "stop", "remove"]).parse(m[2]);
21359
22317
  const target = localVmTargetForBot(bot.id);
21360
22318
  if (target.key === SHARED_LOCAL_VM_TARGET.key) {
21361
22319
  return json(res, 409, { error: "Shared mode manages this desktop in App Settings → Computers" });
@@ -21394,8 +22352,10 @@ const handleRequest = async (req, res) => {
21394
22352
  }
21395
22353
  }
21396
22354
  const status = await containerComputerAction(action, undefined, undefined, target);
21397
- if (action === "run")
22355
+ if (action === "run" || action === "start") {
22356
+ localVmSeen.add(target.key);
21398
22357
  localVmIdleFor(target).touch();
22358
+ }
21399
22359
  if (action === "stop" || action === "remove")
21400
22360
  localVmIdleFor(target).cancel();
21401
22361
  return json(res, 200, {
@@ -21427,6 +22387,61 @@ const handleRequest = async (req, res) => {
21427
22387
  image: await containerComputerScreenshot(undefined, undefined, target),
21428
22388
  });
21429
22389
  }
22390
+ // The Local VM's live desktop for a phone, granted only to the control
22391
+ // lease that holds this bot's computer right now. Two callers:
22392
+ //
22393
+ // The companion sidecar (a loopback caller) gets the VM's own noVNC
22394
+ // address, VNC password included, relays it to a paired phone the owner
22395
+ // has allowed computer access, keeps re-checking the lease with
22396
+ // `action: "check"`, and cuts the relay when it no longer holds.
22397
+ //
22398
+ // A phone paired with this server directly (`openmausbot serve`, no
22399
+ // sidecar) never sees that address. It gets this server's own
22400
+ // authenticated desktop proxy, bound to its lease: the proxy re-checks
22401
+ // the lease and the session every few seconds and closes the socket when
22402
+ // either lapses. Reaching here at all took the admin scope, which a
22403
+ // chat-only pairing (`openmausbot pair --client`) does not have, so
22404
+ // computer access is the same explicit choice the owner's browser makes.
22405
+ m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer\/join$/);
22406
+ if (m && method === "POST") {
22407
+ if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
22408
+ return json(res, 415, { error: "content-type must be application/json" });
22409
+ }
22410
+ const bot = computerPreviewBot(m[1], url);
22411
+ if (!bot)
22412
+ return json(res, 404, { error: "no such bot" });
22413
+ const threadId = url.searchParams.has("threadId") ? bot.threadId : undefined;
22414
+ if (threadId && await computerPreviewSurface(bot, threadId) !== "vm") {
22415
+ return json(res, 409, { error: "This conversation is not using the Local VM" });
22416
+ }
22417
+ // A bot's human-control hold does not reserve a shared desktop or pool
22418
+ // seat. Another bot could drive it while the phone is holding this bot.
22419
+ if (localVmMode(cfg) !== "per-bot") {
22420
+ return json(res, 409, { error: "Phone control requires a per-bot Local VM. Select per-bot mode in Settings → Computers." });
22421
+ }
22422
+ const lease = controlLeaseIdSchema.safeParse(url.searchParams.get("controlLeaseId") ?? undefined);
22423
+ if (!lease.success)
22424
+ return json(res, 400, { error: "controlLeaseId is required" });
22425
+ const controlBot = store.bot(bot.id);
22426
+ if (!controlBot || !computerControl.ownsLease(botComputerControlKey(controlBot), lease.data)) {
22427
+ return json(res, 409, { error: "Take control of this computer first" });
22428
+ }
22429
+ const target = localVmTargetForStatus(bot.id, threadId);
22430
+ const status = await containerComputerStatus(undefined, undefined, target);
22431
+ if (!status.ready || !status.managed || status.container !== "running" || status.network !== "loopback"
22432
+ || !status.viewer_url) {
22433
+ return json(res, 409, { error: status.problem ?? "The Local VM is not ready" });
22434
+ }
22435
+ localVmIdleFor(target).touch();
22436
+ res.setHeader("cache-control", "private, no-store");
22437
+ if (auth.kind === "loopback")
22438
+ return json(res, 200, { joinUrl: status.viewer_url });
22439
+ const socket = new URLSearchParams({ botId: bot.id, ...(threadId ? { threadId } : {}), controlLeaseId: lease.data });
22440
+ return json(res, 200, {
22441
+ socketPath: `api/desktop-viewer/${viewerTargetId(target)}/websockify?${socket}`,
22442
+ password: new URLSearchParams(new URL(status.viewer_url).hash.slice(1)).get("password"),
22443
+ });
22444
+ }
21430
22445
  // identity handshake for the packaged app's port fallback: the forked
21431
22446
  // child proves it is OURS by echoing its pid (a stray dev server has
21432
22447
  // the same API shape but a different pid)
@@ -21556,6 +22571,16 @@ const handleRequest = async (req, res) => {
21556
22571
  billing: prices ? { currency: cfg.billing?.currency ?? "USD" } : null,
21557
22572
  });
21558
22573
  }
22574
+ // Subscription windows (5-hour and weekly), not the token ledger above.
22575
+ // Same admin gate as /api/usage: this route is unlisted, so it stays admin.
22576
+ if (method === "GET" && path === "/api/plan-usage") {
22577
+ res.setHeader("cache-control", "no-store");
22578
+ const report = await loadPlanUsage({
22579
+ accounts: planAccountsFromInstances(instanceConfigs(cfg)),
22580
+ refresh: url.searchParams.get("refresh") === "1",
22581
+ });
22582
+ return json(res, 200, report);
22583
+ }
21559
22584
  // ── provider key check: does a pasted or saved key open the provider's door ──
21560
22585
  // One read-only request from this server; the verdict never carries the
21561
22586
  // key. Admin scope by default, like every route not opened to clients.
@@ -21566,7 +22591,7 @@ const handleRequest = async (req, res) => {
21566
22591
  return json(res, 400, { error: `provider must be one of ${PROVIDER_KEY_KINDS.join(", ")}` });
21567
22592
  }
21568
22593
  const kind = provider;
21569
- const saved = { anthropic: cfg.anthropic, openai: cfg.openai, openrouter: cfg.openrouter, openaiCompat: cfg.openaiCompat, mistral: cfg.mistral, xai: cfg.xai }[kind];
22594
+ const saved = { anthropic: cfg.anthropic, openai: cfg.openai, openrouter: cfg.openrouter, openaiCompat: cfg.openaiCompat, mistral: cfg.mistral, cerebras: cfg.cerebras, xai: cfg.xai }[kind];
21570
22595
  if (body?.key !== undefined && typeof body.key !== "string") {
21571
22596
  return json(res, 400, { error: "key must be a string" });
21572
22597
  }
@@ -21907,8 +22932,8 @@ const handleRequest = async (req, res) => {
21907
22932
  return json(res, 400, { error: "Account settings are currently available for Claude only." });
21908
22933
  }
21909
22934
  if (body.tools !== undefined) {
21910
- if (!["openai-compat", "grok", "minimax", "mistral"].includes(entry.driver)) {
21911
- return json(res, 400, { error: "The tools setting is available for OpenAI-compatible, Grok API, MiniMax API and Mistral API instances only." });
22935
+ if (!["openai-compat", "grok", "minimax", "mistral", "cerebras"].includes(entry.driver)) {
22936
+ return json(res, 400, { error: "The tools setting is available for OpenAI-compatible, Grok API, MiniMax API, Mistral API and Cerebras API instances only." });
21912
22937
  }
21913
22938
  entry.config = { ...entry.config, tools: body.tools };
21914
22939
  }
@@ -22187,7 +23212,7 @@ const handleRequest = async (req, res) => {
22187
23212
  }
22188
23213
  if ((method === "PUT" || method === "PATCH") && path === "/api/config") {
22189
23214
  const body = await readBody(req);
22190
- if (hostedModels && ["instances", "anthropic", "openai", "openrouter", "openaiCompat", "xai", "mistral", "opencodeGo"].some(key => Object.hasOwn(body, key)))
23215
+ if (hostedModels && ["instances", "anthropic", "openai", "openrouter", "openaiCompat", "xai", "mistral", "cerebras", "opencodeGo"].some(key => Object.hasOwn(body, key)))
22191
23216
  return json(res, 403, { error: HOSTED_PROVIDER_SETTINGS_ERROR });
22192
23217
  const patch = parseConfigPatch(body);
22193
23218
  // A Cloud home is personal: nobody is invited to sign in to it.
@@ -22605,6 +23630,8 @@ const handleRequest = async (req, res) => {
22605
23630
  persisted.imageGen.key = "";
22606
23631
  if (persisted.imageGen?.customApiKey !== undefined)
22607
23632
  persisted.imageGen.customApiKey = "";
23633
+ if (persisted.live?.key !== undefined)
23634
+ persisted.live.key = "";
22608
23635
  saveConfig(persisted);
22609
23636
  configWriteCommitted = true;
22610
23637
  syncCredentialEnv(patch);
@@ -22715,6 +23742,7 @@ const handleRequest = async (req, res) => {
22715
23742
  drainQueuedSends();
22716
23743
  drainDelegationWakes();
22717
23744
  drainConnectorResumes();
23745
+ drainComputerResumes();
22718
23746
  drainSecretResumes();
22719
23747
  drainTeamSetupResumes();
22720
23748
  }
@@ -23065,13 +24093,19 @@ const handleRequest = async (req, res) => {
23065
24093
  const result = computerControl.releaseLease(controlKey, controlLeaseId);
23066
24094
  return json(res, 200, { ...result.snapshot, released: result.released });
23067
24095
  }
24096
+ // Read-only: does this lease still hold the wheel? The companion
24097
+ // sidecar asks this while it relays a phone's live Local VM desktop,
24098
+ // and cuts the relay the moment the answer is no.
24099
+ if (action === "check" && controlLeaseId) {
24100
+ return json(res, 200, { ...computerControl.snapshot(controlKey), owned: computerControl.ownsLease(controlKey, controlLeaseId) });
24101
+ }
23068
24102
  if (action === "take")
23069
24103
  return json(res, 200, computerControl.take(controlKey));
23070
24104
  if (action === "release")
23071
24105
  return json(res, 200, computerControl.release(controlKey));
23072
24106
  if (action === "dismiss-help")
23073
24107
  return json(res, 200, computerControl.dismissHelp(controlKey));
23074
- return json(res, 400, { error: "action must be take, release, or dismiss-help" });
24108
+ return json(res, 400, { error: "action must be take, release, check, or dismiss-help" });
23075
24109
  }
23076
24110
  return json(res, 405, { error: "method not allowed" });
23077
24111
  }
@@ -23083,7 +24117,13 @@ const handleRequest = async (req, res) => {
23083
24117
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
23084
24118
  return json(res, 415, { error: "content-type must be application/json" });
23085
24119
  }
23086
- return json(res, 200, bot.cloudBackend === "vps" ? vps.closeVpsDesktopTunnel(bot.id) : { closed: false });
24120
+ // A directly paired phone's Local VM desktop runs through this server's
24121
+ // own proxy under its lease on this bot; hand-back closes it here, ahead
24122
+ // of the lease re-check. The session's other viewers are left alone.
24123
+ const closedViewers = auth.kind === "session" ? desktopViewer.closeForOwner(auth.session.id, bot.id) : 0;
24124
+ if (bot.cloudBackend === "vps")
24125
+ return json(res, 200, vps.closeVpsDesktopTunnel(bot.id));
24126
+ return json(res, 200, { closed: closedViewers > 0 });
23087
24127
  }
23088
24128
  m = path.match(/^\/api\/bots\/([\w-]+)\/computer\/(provision|join|sleep|exec|screenshot|remove)$/);
23089
24129
  if (m && method === "POST") {
@@ -23349,6 +24389,7 @@ for (const row of chatFollowups()) {
23349
24389
  role: "user", kind: "text", text: row.kind === "aside" ? row.payload.prompt ?? row.payload.text : row.payload.text, replyToId: row.payload.replyToId,
23350
24390
  sendId: row.payload.sendId, queueId: row.id, sender: row.payload.sender,
23351
24391
  ...(row.kind === "channel" ? { channelMode: row.payload.mode, via: row.payload.via } : {}),
24392
+ ...(row.kind === "bot" && row.payload.via === "call" ? { via: "call" } : {}),
23352
24393
  ...(row.kind === "aside" ? {
23353
24394
  aside: true,
23354
24395
  peerAsk: row.payload.aside
@@ -23464,6 +24505,7 @@ const gracefulShutdown = createGracefulShutdown({
23464
24505
  await Promise.all([...temporaryBrowserSessions.keys()].map((botId) => forgetTemporaryBrowser(botId)));
23465
24506
  await browserRuntime.closeAll();
23466
24507
  },
24508
+ () => liveCalls.shutdown(),
23467
24509
  () => flushAllProfileHistory(),
23468
24510
  () => flushAllMemoryJournals(),
23469
24511
  () => flushUsageLedger(DATA_DIR),