openmausbot 0.1.93 → 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 (140) hide show
  1. package/dist/assets/{DesktopViewer-CKNR3SEW.js → DesktopViewer-bxM7NV3N.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-Ckrm1I1X.js → abnfDiagram-O67JEVCF-BhMdJhRJ.js} +1 -1
  3. package/dist/assets/{arc-BBdBuCgB.js → arc-B5itKDcx.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CDdA3Hjs.js → architectureDiagram-NJMV4G6O-C5d7UY6U.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-AUrsx1YR.js → blockDiagram-BEXU5L5S-BrZIhMmu.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-BEqQ0Unh.js → c4Diagram-YGBWAQC7-7I0ArfbD.js} +1 -1
  7. package/dist/assets/channel-CdJX6i8k.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-CEEuvd4C.js → chunk-2Q5K7J3B-BnEsyN36.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-DyANCiHl.js → chunk-3FUC2YCW-BXATQ52s.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-Dx_Slpw9.js → chunk-5VM5RSS4-B0BpTkEx.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-Dl7h7iiD.js → chunk-GTNCS2PH-BtLTz6FR.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-BLx07SPb.js → chunk-GWA4HPMP-D6gcZOYt.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-BMxDj3n7.js → chunk-JWPE2WC7-DCLrosvf.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-CN66PT4H.js → chunk-NETBCI7D-B9JsknV9.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-6HwhsDKw.js → chunk-WEXAMYUT-CsoTxw7z.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-gWnLt7SN.js → chunk-XXDRQBXY-0F_frB0c.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-CMS5l2ea.js → classDiagram-v2-NBCMYWYE-DkrpnflX.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-CV7sQwIG.js → cose-bilkent-JH36ORCC-BNDwYBt9.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-CSyl-7vl.js → cynefin-OW5HDTMX-JUkNr1Mk.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-BCLKL6az.js → cynefinDiagram-VND7K2PF-DKeh9N4M.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-DiaZs6S1.js → dagre-6A5THRUB-ve-6T_wX.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-BeTtmt3I.js → diagram-22UHCM2B-BuJ2d8rT.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-CsdBG-QQ.js → diagram-3UASUU5V-BgOs_Ni-.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-BczvdbR1.js → diagram-ATOU4E4O-DMK6VmcP.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-DHTTTMhi.js → diagram-CDSNMT55-DksASlcY.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-RTjHyd8z.js → diagram-MLGK6HIB-Bgn8I5k6.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-BDenHEH_.js → diagram-MPIPVDR6-zK0jqO8N.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-BSiRSbtL.js → ebnfDiagram-ZINNZB2B-vKrbzTwr.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-BCAElss-.js → elk-276RUBZZ-Bzv2mPNf.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-C4_1ZPJe.js → erDiagram-OPXOYQCR-CvOyYuxu.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-Bcb4Z75S.js → flowDiagram-KWPJA3E3-CtbnDbc0.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-BiPOzxzb.js → ganttDiagram-FUAMR5RP-c4dMRYyG.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-B1wtF3ZS.js → gitGraphDiagram-X574FWY7-B34uaQho.js} +1 -1
  34. package/dist/assets/index-CPYrGsv1.js +610 -0
  35. package/dist/assets/{index-BkYwilkG.js → index-CPiqc_DN.js} +1 -1
  36. package/dist/assets/index-CzT42GR8.css +1 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-A19No71U.js → infoDiagram-VRGFBTTK-o64ma4CI.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BQsqJ_cV.js → ishikawaDiagram-OU5B5YK6-DV4C9BAm.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DAO0pVan.js → journeyDiagram-ZHPQQLJL-Bf0AHk1H.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-NepmKEe2.js → kanban-definition-PNTS6WVX-B1UMSKmE.js} +1 -1
  41. package/dist/assets/{linear-bq1PTDTW.js → linear-BpKhuyLm.js} +1 -1
  42. package/dist/assets/{mermaid.core-s4LTbQle.js → mermaid.core-B_BKNCw5.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-LtdbgHFH.js → mindmap-definition-NLK3R4M7-DeARruT1.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-tdbaCxiK.js → pegDiagram-GJSIUBJH-BOQEk_1D.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-DvkbpW8l.js → pieDiagram-5QR66LMP-EIfoiTZo.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-Do4KaH-E.js → quadrantDiagram-O4NWA36T-GgZwT-fi.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-BQWULOAs.js → railroadDiagram-XR7U4H2S-D9rDQomj.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-w9kei1FU.js → requirementDiagram-PLB6GJNP-DAh1MIeo.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-D2gRy0e8.js → sankeyDiagram-IPEJSGJF-CPr6Uz64.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-a2sO5_Eo.js → sequenceDiagram-PO4LG4MO-BU6VIdcm.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-CiPJmFEB.js → sizeCapture-INFHLROL-qF2HO41r.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-DAkUUlL7.js → stateDiagram-v2-GCMORJYK-DlJ8G4MD.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DANf2R8Y.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-BKpQmzrT.js → timeline-definition-EJHVYXUP-B7wzEQ0A.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-BtI7qLN-.js → usecaseDiagram-POWQR4AR-BvGgrxIP.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-gXJXrAG_.js → vennDiagram-UO4OBE2U-CEECdgQg.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-GU8Po5-n.js → wardleyDiagram-VNRHLVJA-XQHA0P_P.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-BF4J30g6.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 +21 -0
  63. package/dist-server/container-mcp.js +93 -76
  64. package/dist-server/drivers/agents-proxy.js +38 -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 +16942 -13191
  68. package/dist-server/local-computer.js +1 -0
  69. package/dist-server/mcp-gate.js +147 -19
  70. package/dist-server/mcp-remote-proxy.js +437 -0
  71. package/dist-server/openmausbot.js +8233 -7651
  72. package/dist-server/pair-cli.js +8233 -7651
  73. package/dist-server/permission-proxy.js +65 -1
  74. package/dist-server/proxy-paths.js +1 -0
  75. package/dist-server/server/auto-approve.js +33 -11
  76. package/dist-server/server/browser-live.js +14 -5
  77. package/dist-server/server/browser-runtime.js +6 -0
  78. package/dist-server/server/channel-queue.js +4 -1
  79. package/dist-server/server/cli.js +32 -12
  80. package/dist-server/server/cloud-move-http.js +9 -3
  81. package/dist-server/server/cloud-move.js +18 -0
  82. package/dist-server/server/computer-wait.js +33 -13
  83. package/dist-server/server/config.js +44 -10
  84. package/dist-server/server/decision-log.js +9 -6
  85. package/dist-server/server/delegations.js +47 -22
  86. package/dist-server/server/drivers/acp/core.js +57 -8
  87. package/dist-server/server/drivers/acp/grok.js +208 -3
  88. package/dist-server/server/drivers/agents-call.js +28 -0
  89. package/dist-server/server/drivers/agents-catalog.js +10 -1
  90. package/dist-server/server/drivers/boatagent.js +2 -0
  91. package/dist-server/server/drivers/chat-mcp-tools.js +27 -14
  92. package/dist-server/server/drivers/claude.js +18 -6
  93. package/dist-server/server/drivers/codex.js +84 -10
  94. package/dist-server/server/drivers/openai-chat.js +11 -3
  95. package/dist-server/server/drivers/pi.js +48 -4
  96. package/dist-server/server/environment.js +3 -0
  97. package/dist-server/server/guarded-requests.js +2 -1
  98. package/dist-server/server/index.js +1143 -254
  99. package/dist-server/server/live-call-controller.js +868 -0
  100. package/dist-server/server/live-call.js +216 -0
  101. package/dist-server/server/mcp-gate-config.js +43 -12
  102. package/dist-server/server/mcp-gate.js +127 -25
  103. package/dist-server/server/mcp-http.js +46 -8
  104. package/dist-server/server/mcp-remote-proxy.js +108 -0
  105. package/dist-server/server/new-bot-defaults.js +3 -0
  106. package/dist-server/server/peer-provenance.js +4 -2
  107. package/dist-server/server/permission-proxy.js +15 -1
  108. package/dist-server/server/plan-usage.js +797 -0
  109. package/dist-server/server/proxy-paths.js +1 -0
  110. package/dist-server/server/redact.js +2 -0
  111. package/dist-server/server/request-auth.js +3 -2
  112. package/dist-server/server/routes/live.js +98 -0
  113. package/dist-server/server/skill-library.js +207 -0
  114. package/dist-server/server/skills-library-migration.js +235 -0
  115. package/dist-server/server/skills.js +66 -3
  116. package/dist-server/server/steer-queue.js +13 -1
  117. package/dist-server/server/store.js +32 -4
  118. package/dist-server/server/surface.js +9 -3
  119. package/dist-server/server/team-backup.js +2 -1
  120. package/dist-server/server/thread-events.js +1 -1
  121. package/dist-server/server/tunnel.js +20 -1
  122. package/dist-server/server/turn-log.js +10 -0
  123. package/dist-server/server/turn-resources.js +124 -0
  124. package/dist-server/shared/ask-question.js +13 -0
  125. package/dist-server/shared/call-consent.js +28 -0
  126. package/dist-server/shared/live-approval.js +124 -0
  127. package/dist-server/shared/live-call.js +176 -0
  128. package/dist-server/shared/skill-md.js +10 -0
  129. package/dist-server/shared/team-backup.js +3 -0
  130. package/dist-server/shared/tool-scope-support.js +25 -0
  131. package/dist-server/shared/tool-scope.js +136 -0
  132. package/dist-server/src/lib/thread-refs.js +406 -0
  133. package/dist-server/vps-container-mcp.js +93 -76
  134. package/dist-server/workspace-backup.worker.js +89 -100
  135. package/enterprise/server/index.js +1 -0
  136. package/package.json +1 -1
  137. package/dist/assets/channel-BU94VpxE.js +0 -1
  138. package/dist/assets/index-Biji-bfL.css +0 -1
  139. package/dist/assets/index-DZlhoES0.js +0 -597
  140. package/dist/assets/swimlanesDiagram-TC7HE7FX-CdHxG8aM.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";
@@ -65,12 +69,13 @@ import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, liv
65
69
  import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
66
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";
@@ -180,7 +188,8 @@ import { listenWebhookIngress, webhookCredential } from "./webhook-ingress.js";
180
188
  import { assertModelVariantSupported, memberTurnSelection } from "./member-turn.js";
181
189
  import { WebhookManager } from "./webhooks.js";
182
190
  import { SPAWNED_PROXIES } from "./proxy-paths.js";
183
- 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";
184
193
  import { installedPlaybookInstructions } from "./installed-playbooks.js";
185
194
  import { createBotPackageExport, createLibraryPackageExport, createTeamPackageExport, picture as sharedPicture, TeamExportError } from "./package-export.js";
186
195
  import { importPackageDocument, importTeamManifest, PackageImportError } from "./package-import.js";
@@ -192,7 +201,7 @@ import { MAX_TEAM_BACKUP_BYTES } from "../shared/team-backup.js";
192
201
  import { shouldMountLocalComputer } from "./local-routing.js";
193
202
  import { autoLocalVmAttachable } from "./container-computer.js";
194
203
  import { startAutoVmClaim } from "./auto-vm-claims.js";
195
- import { computerFreeAfterText, computerStillBusyText, computerStoppedWaitingText, computerWaitingText } from "./computer-wait.js";
204
+ import { computerFreeAfterText, computerParkedText, computerStoppedWaitingText, computerWaitingText } from "./computer-wait.js";
196
205
  import { CloudOverflowConsent, CloudSeatLease, cloudOverflowAction, cloudOverflowConsentText, cloudOverflowOfferText, cloudSeatStartedText, cloudSeatStoppedText } from "./cloud-overflow.js";
197
206
  import { modelContextWindow } from "./model-context-window.js";
198
207
  import { parseSurface, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
@@ -227,6 +236,7 @@ import { createDesktopViewer, desktopViewerUrl } from "./routes/desktop-viewer.j
227
236
  import { localDesktopTarget, localVmViewerStatus, viewerTargetId } from "./desktop-viewer-targets.js";
228
237
  import { createAntigravityLeftoverRoutes } from "./routes/antigravity-leftovers.js";
229
238
  import { findAntigravityLeftovers, removeAntigravityLeftovers } from "./drivers/antigravity-temp.js";
239
+ import { createLiveRoutes } from "./routes/live.js";
230
240
  const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799);
231
241
  const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1);
232
242
  // Behind a proxy or tunnel, the base URL senders should use (docs/self-hosting.md).
@@ -993,9 +1003,10 @@ function cloudCardAnswerer(card) {
993
1003
  return card.requestId ? cloudCardAnswersInFlight.get(card.requestId) : undefined;
994
1004
  }
995
1005
  /** Answer a card as `auth`: the decision rows written meanwhile name the
996
- * answerer, and a card this answer settled records who settled it. A card
997
- * that was already settled keeps whatever it said. */
998
- 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) {
999
1010
  const open = (() => {
1000
1011
  const card = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId)?.card;
1001
1012
  return Boolean(card && !card.answered && !card.dismissed && !card.expired);
@@ -1003,13 +1014,13 @@ async function answeringCardAs(auth, threadId, requestId, work) {
1003
1014
  if (CLOUD_HOME && open)
1004
1015
  cloudCardAnswersInFlight.set(requestId, auth.kind === "session" ? actorKey(auth) : undefined);
1005
1016
  try {
1006
- await withDecisionActor(decisionActorFor(auth), work);
1017
+ await withDecisionActor(decisionActorFor(auth), work, via);
1007
1018
  }
1008
1019
  finally {
1009
1020
  const message = open ? store.messagesFor(threadId).find((candidate) => candidate.card?.requestId === requestId) : undefined;
1010
1021
  const card = message?.card;
1011
1022
  if (message && card && !card.answeredBy && card.answered !== "unavailable" && (card.answered || card.dismissed)) {
1012
- store.patchMessage(threadId, message.id, { card: { ...card, answeredBy: cardAnswererFor(auth) } });
1023
+ store.patchMessage(threadId, message.id, { card: { ...card, answeredBy: { ...cardAnswererFor(auth), ...(via ? { via } : {}) } } });
1013
1024
  }
1014
1025
  if (CLOUD_HOME && open)
1015
1026
  cloudCardAnswersInFlight.delete(requestId);
@@ -1519,6 +1530,25 @@ function resyncOpenCodeProviderKeys() {
1519
1530
  await registry.load(providerConfigs(), decorateHostedProvider);
1520
1531
  const bundledSkills = loadBundledSkills();
1521
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
+ };
1522
1552
  // SAFETY: Electron's utility-process runtime is the only environment that
1523
1553
  // supplies parentPort; plain Node intentionally leaves it absent.
1524
1554
  const utilityParentPort = process.parentPort;
@@ -1975,7 +2005,20 @@ function hooksIntegration(botId, threadId, generation) {
1975
2005
  }
1976
2006
  class DirectTurnSetupCancelled extends Error {
1977
2007
  }
1978
- 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
+ }
1979
2022
  }
1980
2023
  const directTurnDispatchClaims = new Map();
1981
2024
  const directTurnGenerationByThread = new Map();
@@ -2019,6 +2062,12 @@ const turnResources = new TurnResources();
2019
2062
  const sharedComputerControl = new SharedComputerControl(turnResources, () => store.bots.some(bot => botComputerControlSnapshot(bot.id).held));
2020
2063
  const turnResourceOwners = new Map();
2021
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();
2022
2071
  const teamComputerTurns = new Map();
2023
2072
  const settlingResourceOwners = new Map();
2024
2073
  /** Idle release for computer claims (#1653), disabled until the feature
@@ -2028,10 +2077,11 @@ const settlingResourceOwners = new Map();
2028
2077
  * yields once another turn holds the seat. The durable VM profile keeps
2029
2078
  * its login state, so that re-claim is cheap. Read per claim: a flag
2030
2079
  * change applies to new claims, never to a seat held under the old
2031
- * 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. */
2032
2082
  const COMPUTER_CLAIM_IDLE_POLICY = new IdleReleasePolicy();
2033
2083
  function computerClaimIdlePolicy(resource) {
2034
- 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;
2035
2085
  }
2036
2086
  /** #1655: the consent ledger for local-wait cloud overflow, and the live
2037
2087
  * cloud seats it has started. One seat per bot — it is the bot's own Box,
@@ -2191,11 +2241,17 @@ function releaseTurnResources(owner) {
2191
2241
  turnResourceOwners.delete(owner.threadId);
2192
2242
  if (turnComputerResources.get(owner.threadId)?.owner.generation === owner.generation)
2193
2243
  turnComputerResources.delete(owner.threadId);
2244
+ if (pendingComputerCallStarts.get(owner.threadId)?.generation === owner.generation)
2245
+ pendingComputerCallStarts.delete(owner.threadId);
2194
2246
  const teamTurn = teamComputerTurns.get(owner.threadId);
2195
2247
  if (teamTurn?.owner.generation === owner.generation) {
2196
2248
  stopScreenPoller(teamTurn.botId, owner.threadId);
2197
2249
  teamComputerTurns.delete(owner.threadId);
2198
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);
2199
2255
  }
2200
2256
  async function bindTurnComputer(owner, resource, exclusive = false) {
2201
2257
  // Every computer claim passes here: a kind the organisation disallows, or a
@@ -2210,7 +2266,7 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2210
2266
  // bot running a titled thread, or a room. Read once, when the wait begins.
2211
2267
  let holder;
2212
2268
  let holderThreadRef;
2213
- const deadline = Date.now() + GROUP_GOAL_WAIT_MAX_MS;
2269
+ const deadline = Date.now() + COMPUTER_WAIT_MAX_MS;
2214
2270
  // The wait is history the transcript keeps (#1647): the waiting chip stays
2215
2271
  // exactly as written, a resolution line is appended beside it, and the
2216
2272
  // turn.wait_* events carry the same facts for the inspector log.
@@ -2236,15 +2292,18 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2236
2292
  return;
2237
2293
  waitEnded = true;
2238
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);
2239
2299
  const text = outcome === "acquired"
2240
2300
  ? computerFreeAfterText(holder, waitedMs)
2241
- : outcome === "gave_up"
2242
- ? computerStillBusyText(holder, waitedMs)
2301
+ : outcome === "parked"
2302
+ ? computerParkedText(holder, COMPUTER_WAIT_MAX_MS)
2243
2303
  : computerStoppedWaitingText(holder, waitedMs);
2244
2304
  store.appendMessage(owner.threadId, {
2245
2305
  role: "bot", kind: "activity",
2246
- ...(outcome === "gave_up" ? { turnSucceeded: false } : {}),
2247
- tool: { name: text, ok: outcome !== "gave_up" },
2306
+ tool: { name: text, ok: true },
2248
2307
  ...(holderThreadRef ? { threadRef: holderThreadRef } : {}),
2249
2308
  });
2250
2309
  bus.publish({
@@ -2263,6 +2322,10 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2263
2322
  if (!exclusive || claimTurnResource(owner, resource))
2264
2323
  break;
2265
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);
2266
2329
  const blocker = turnResources.blocker(resource, owner);
2267
2330
  const holderBot = blocker && store.botByThread(blocker.threadId);
2268
2331
  const holderTask = holderBot && blocker && store.taskByThread(holderBot.id, blocker.threadId);
@@ -2275,7 +2338,7 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2275
2338
  : undefined;
2276
2339
  waitingMessage = store.appendMessage(owner.threadId, {
2277
2340
  role: "bot", kind: "activity",
2278
- tool: { name: computerWaitingText(holder) },
2341
+ tool: { name: computerWaitingText(holder, { position, estimateMs }) },
2279
2342
  ...(holderThreadRef ? { threadRef: holderThreadRef } : {}),
2280
2343
  });
2281
2344
  waitedSince = Date.now();
@@ -2283,6 +2346,7 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2283
2346
  ...waitEventBase(),
2284
2347
  type: "turn.wait_started",
2285
2348
  resource,
2349
+ position,
2286
2350
  ...(holder ? { holder } : {}),
2287
2351
  });
2288
2352
  // #1655: the wait is where an overflow choice gets made. The card
@@ -2312,8 +2376,12 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2312
2376
  }
2313
2377
  sweepIdleCloudSeats();
2314
2378
  if (Date.now() >= deadline) {
2315
- endWait("gave_up");
2316
- 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);
2317
2385
  }
2318
2386
  await new Promise(resolve => setTimeout(resolve, 100));
2319
2387
  }
@@ -2322,9 +2390,21 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
2322
2390
  // Never patch the waiting chip over: the queue position this turn held
2323
2391
  // stays visible beside what the wait came to.
2324
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);
2325
2396
  }
2326
2397
  turnResourceOwners.set(owner.threadId, owner);
2327
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
+ }
2328
2408
  }
2329
2409
  function botForThread(botId, threadId) {
2330
2410
  return directTurnBots.get(threadId) ?? store.projectBotForTask(botId, threadId) ?? store.bot(botId);
@@ -2413,7 +2493,13 @@ function requestedTaskBot(botId, rawThreadId) {
2413
2493
  throw Object.assign(new Error("no such task"), { status: 404 });
2414
2494
  return task;
2415
2495
  }
2416
- 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
+ }
2417
2503
  const requestOwner = directRequestOwners.get(threadId);
2418
2504
  if (requestOwner) {
2419
2505
  requestOwner.stopped = true;
@@ -2455,7 +2541,7 @@ function noteTeammatesLeftRunning(botId, threadId, running) {
2455
2541
  }
2456
2542
  }
2457
2543
  async function interruptAllDirectThreads(botId) {
2458
- 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));
2459
2545
  // Revoke every sibling before yielding to any provider teardown.
2460
2546
  for (const task of threads) {
2461
2547
  cancelDirectTurnDispatch(botId, task.threadId);
@@ -2937,7 +3023,7 @@ function checkedExportSkillNames(value, bots) {
2937
3023
  return { ok: false, error: "skillIds must not contain duplicates" };
2938
3024
  if (names.length > BOT_PACKAGE_MAX_SKILLS)
2939
3025
  return { ok: false, error: `skillIds must contain at most ${BOT_PACKAGE_MAX_SKILLS} names` };
2940
- 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)));
2941
3027
  const unknown = names.find((name) => !available.has(name));
2942
3028
  if (unknown)
2943
3029
  return { ok: false, error: `skillIds contains unknown imported skill "${unknown}"` };
@@ -2950,10 +3036,10 @@ function collectExportSkills(bots, names) {
2950
3036
  return byBot;
2951
3037
  for (const bot of bots) {
2952
3038
  const assigned = [];
2953
- for (const listing of listSkills(bot.id)) {
3039
+ for (const listing of listBotSkills(bot)) {
2954
3040
  if (!selected.has(listing.name))
2955
3041
  continue;
2956
- const instructions = readSkillFile(bot.id, listing.name);
3042
+ const instructions = readBotSkillFile(bot, listing.name);
2957
3043
  if (instructions === null) {
2958
3044
  throw new Error(`Skill "${listing.name}" changed or is unavailable and cannot be exported safely`);
2959
3045
  }
@@ -2981,7 +3067,7 @@ function collectExportSkills(bots, names) {
2981
3067
  * skill choices from it, so a refused choice can always be changed. */
2982
3068
  function collectTeamSkills(bots, selection) {
2983
3069
  const all = selection === undefined || selection === "all";
2984
- 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();
2985
3071
  if (!all && (!Array.isArray(selection) || selection.some((name) => typeof name !== "string" || !isSkillName(name)))) {
2986
3072
  return { ok: false, error: "skills must be \"all\" or a list of skill names", available };
2987
3073
  }
@@ -2993,10 +3079,10 @@ function collectTeamSkills(bots, selection) {
2993
3079
  const skipped = [];
2994
3080
  for (const bot of bots) {
2995
3081
  const assigned = [];
2996
- for (const listing of listSkills(bot.id)) {
3082
+ for (const listing of listBotSkills(bot)) {
2997
3083
  if (!chosen.has(listing.name))
2998
3084
  continue;
2999
- const instructions = readSkillFile(bot.id, listing.name);
3085
+ const instructions = readBotSkillFile(bot, listing.name);
3000
3086
  if (instructions === null) {
3001
3087
  if (!all)
3002
3088
  return { ok: false, error: `Skill "${listing.name}" changed or is unavailable and cannot be shared safely`, available };
@@ -3133,6 +3219,26 @@ const sendSequencer = new SendSequencer();
3133
3219
  bootSelection = await defaultSelection();
3134
3220
  store.seedIfEmpty();
3135
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
+ }
3136
3242
  // A committed profile cleanup means both its config deletion and bot-reference
3137
3243
  // cleanup were intended to be durable. Reconcile stale secondary references
3138
3244
  // before Electron can ACK and remove the journal: a crash between those writes
@@ -3174,7 +3280,7 @@ const wireTask = (task) => {
3174
3280
  return asleep ? { ...coordinated, snoozedUntil } : coordinated;
3175
3281
  };
3176
3282
  const wireBot = (bot) => {
3177
- 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;
3178
3284
  // An elevated selection is inert until the desktop confirms its exact
3179
3285
  // private reply. Every ordinary client sees the effective Ask state during
3180
3286
  // that two-phase window, never a grant that may still roll back.
@@ -3184,10 +3290,19 @@ const wireBot = (bot) => {
3184
3290
  return { ...visible, waitingForTeammates: activeCoordinationForThread(bot.threadId) && !threadBusy(bot.id, bot.threadId),
3185
3291
  avatarUrl: visible.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
3186
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
+ }
3187
3302
  /** The correlated private response carries the requested value so Electron
3188
3303
  * can validate it before sending the confirmation that makes it effective. */
3189
3304
  const wireTrustedApprovalBot = (bot) => {
3190
- 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;
3191
3306
  return { ...rest, approvalMode: approvalModeFor(rest), avatarUrl: rest.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
3192
3307
  };
3193
3308
  /** A settings-based preview, not a receipt of a dispatched turn. No
@@ -3261,7 +3376,7 @@ function previewSystemPrompt(bot) {
3261
3376
  { id: "profile", label: "Profile changes", text: agentsMounted ? PROFILE_PROMPT : "" },
3262
3377
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
3263
3378
  { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: agentsMounted, fileTools: Boolean(privateWorkspace), enabled: bot.memoryEnabled !== false }) },
3264
- { 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) : "" },
3265
3380
  ]);
3266
3381
  const totalBytes = built.sections.reduce((n, s) => n + s.bytes, 0);
3267
3382
  return {
@@ -3326,7 +3441,7 @@ async function botOverview(bot) {
3326
3441
  webhooks: webhooks.list()
3327
3442
  .filter((webhook) => webhook.botId === bot.id)
3328
3443
  .map((webhook) => ({ name: webhook.name, enabled: webhook.enabled })),
3329
- skills: listSkills(bot.id).map((skill) => ({
3444
+ skills: listBotSkills(bot).map((skill) => ({
3330
3445
  name: skill.name,
3331
3446
  description: skill.description,
3332
3447
  enabled: skill.enabled,
@@ -3345,9 +3460,9 @@ async function botOverview(bot) {
3345
3460
  * this too, but no provider dispatch or later permission callback relies on
3346
3461
  * persistence having been produced exclusively by that route. Delegation
3347
3462
  * uses the receiving bot's grant, never the sender's (approvalModeForOrigin) —
3348
- * with one deliberate exception: a Chief of Staff with Full access makes the
3349
- * threads it delegates Full too (delegatedFullAccess), so the grant the
3350
- * 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. */
3351
3466
  const approvalModeForTurn = (bot, peerInitiated = false, threadId = bot.threadId) => {
3352
3467
  // On a Cloud home a turn a guest drives runs in Ask, whatever the bot's
3353
3468
  // own level. Judged by the conversation the turn runs in (a room's, for a
@@ -3360,51 +3475,62 @@ const approvalModeForTurn = (bot, peerInitiated = false, threadId = bot.threadId
3360
3475
  }
3361
3476
  return mode;
3362
3477
  };
3363
- /** Full belongs to the requesting conversation, not whichever sibling is
3478
+ /** A level belongs to the requesting conversation, not whichever sibling is
3364
3479
  * selected in the UI or the bot's default for future conversations. */
3365
- function fullAccessForSource(botId, threadId) {
3480
+ function sourceApprovalMode(botId, threadId) {
3366
3481
  const owner = connectorThread(botId, threadId);
3367
3482
  if (!owner)
3368
- return false;
3483
+ return "ask";
3369
3484
  const bot = store.projectBotForTask(botId, threadId) ?? owner.bot;
3370
3485
  // Origin changes Custom to Auto, never Full; no live-turn state is needed.
3371
- return approvalModeForTurn(bot, false, threadId) === "full";
3486
+ return approvalModeForTurn(bot, false, threadId);
3487
+ }
3488
+ function fullAccessForSource(botId, threadId) {
3489
+ return sourceApprovalMode(botId, threadId) === "full";
3372
3490
  }
3373
3491
  function peerReviewRequired(bot, threadId) {
3374
3492
  return Boolean(bot.approvePeerComms && !fullAccessForSource(bot.id, threadId));
3375
3493
  }
3376
- /** Full access flows down a Chief of Staff's delegation. The person gave the
3377
- * Chief Full access so its work runs without prompts; a teammate stopping
3378
- * that same work to ask defeats the grant — and in practice the person was
3379
- * answering every one of those cards, all day, for the whole team. So a
3380
- * teammate a Full-access Chief delegates to runs Full for that work: the
3381
- * recipient switches, whatever its own level says. The recipient's engine
3382
- * has to implement Full (supportsApprovalMode); otherwise the work keeps the
3383
- * recipient's own level, as before. Only a Chief passes access on — an
3384
- * ordinary bot's delegation still uses the recipient's setting. */
3385
- function delegatedFullAccess(from, fromThreadId, target) {
3386
- 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({
3387
3505
  senderIsChief: Boolean(from.chiefOfStaff),
3388
- senderHasFullAccess: fullAccessForSource(from.id, fromThreadId),
3506
+ senderMode: sourceApprovalMode(from.id, fromThreadId),
3389
3507
  sameBot: from.id === target.id,
3508
+ recipientMode,
3390
3509
  recipientDriverKind: registry.cliTarget(target.modelSelection.instanceId)?.driverKind,
3391
3510
  });
3392
3511
  }
3393
- /** Make a delegated thread Full and say so in it once, so the level the
3394
- * chip shows and the level the turns run at agree, and the person can see
3395
- * where the access came from. */
3396
- function grantDelegatedFullAccess(from, target, threadId) {
3397
- 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)
3398
3523
  return;
3399
- 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;
3400
3526
  store.appendMessage(threadId, {
3401
3527
  role: "bot",
3402
3528
  kind: "activity",
3403
- 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 },
3404
3530
  });
3405
3531
  }
3406
- /** A room member's level for one turn. Work a Full-access Chief hands out
3407
- * 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
3408
3534
  * level is not stored on it — it rides the handoff. */
3409
3535
  function roomTurnApprovalMode(bot, threadId, orchestration) {
3410
3536
  // On a Cloud home a room turn a guest drives runs in Ask (cloudGuestDriven).
@@ -3413,9 +3539,8 @@ function roomTurnApprovalMode(bot, threadId, orchestration) {
3413
3539
  const handoff = orchestration?.roomHandoffId ? roomHandoffs.nodes.get(orchestration.roomHandoffId) : undefined;
3414
3540
  const source = handoff?.parentId ? roomHandoffs.nodes.get(handoff.parentId) : undefined;
3415
3541
  const from = source ? store.bot(source.botId) : undefined;
3416
- if (from && source && delegatedFullAccess(from, source.threadId, bot))
3417
- return "full";
3418
- 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;
3419
3544
  }
3420
3545
  /** Privileged approval-mode transitions are deliberately absent from the
3421
3546
  * loopback HTTP authority model: a bot with shell access can curl that
@@ -4081,6 +4206,7 @@ function updateChannel(groupId, value) {
4081
4206
  const channelTaskBlocked = (group) => groupIsWorking(group) ||
4082
4207
  store.groupTasks(group.id).some((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
4083
4208
  message.card?.requestId &&
4209
+ (!isPersistentQuestionCard(message.card) || runningTurnEngines.has(task.threadId)) &&
4084
4210
  !message.card.answered &&
4085
4211
  !message.card.dismissed && !message.card.expired));
4086
4212
  // Recovery can synchronously emit room changes. Load coordination state
@@ -4617,6 +4743,7 @@ function cancelGroupTurnOperations(groupId, threadId, outcome = {
4617
4743
  status: "stopped",
4618
4744
  detail: "Stopped by you.",
4619
4745
  }) {
4746
+ pendingComputerResumes.delete(threadId);
4620
4747
  cancelTeamSetupResumesForThread(threadId);
4621
4748
  roomHandoffs.cancelRoom(groupId, threadId);
4622
4749
  for (const operation of groupTurnOperations.get(groupId) ?? []) {
@@ -5115,7 +5242,7 @@ always, rememberCommand) {
5115
5242
  // An answered question keeps its words. `request.resolved` only records
5116
5243
  // the behavior, so without this the card reads "answer" forever and the
5117
5244
  // person can no longer see what they told the bot.
5118
- if (outcome !== "unavailable" && behavior === "answer" && message && cardMessage) {
5245
+ if (outcome === "answered" && behavior === "answer" && message && cardMessage) {
5119
5246
  const settled = store.messagesFor(threadId).find((m) => m.id === cardMessage.id)?.card;
5120
5247
  if (settled) {
5121
5248
  store.patchMessage(threadId, cardMessage.id, { card: { ...settled, answeredText: message } });
@@ -5147,19 +5274,78 @@ always, rememberCommand) {
5147
5274
  const existing = messageId
5148
5275
  ? thread.find((m) => m.id === messageId)
5149
5276
  : thread.find((m) => m.card?.requestId === requestId);
5150
- if (existing?.card && !existing.card.answered) {
5277
+ const persistentQuestion = isPersistentQuestionCard(existing?.card);
5278
+ if (existing?.card && !existing.card.answered && !persistentQuestion) {
5151
5279
  store.patchMessage(threadId, existing.id, { card: { ...existing.card, answered: "unavailable", dismissed: true } });
5152
5280
  }
5153
5281
  if (messageId)
5154
5282
  askMessageByRequest.delete(`${threadId}:${requestId}`);
5155
- store.appendMessage(threadId, {
5156
- role: "bot",
5157
- kind: "activity",
5158
- tool: { name: "Couldn't deliver that answer — the request is no longer open, so the action was not run", ok: false },
5159
- });
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
+ });
5160
5289
  }
5161
5290
  return outcome;
5162
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
+ }
5163
5349
  /** Close every provider-owned approval still open on a thread. Interrupting a
5164
5350
  * turn kills the process that raised its questions, so those cards can never
5165
5351
  * be answered. Routine proposals are harness-owned and durable, so they stay
@@ -5177,6 +5363,12 @@ function closeOpenApprovals(threadId) {
5177
5363
  continue;
5178
5364
  if (card.routineRequest || card.skillRequest)
5179
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
+ }
5180
5372
  store.patchMessage(threadId, message.id, { card: { ...card, answered: "unavailable", dismissed: true } });
5181
5373
  askMessageByRequest.delete(`${threadId}:${card.requestId}`);
5182
5374
  }
@@ -5238,6 +5430,9 @@ const ASK_BOT_TIMEOUT_MS = Math.max(5_000, Number(process.env.OMB_ASK_BOT_TIMEOU
5238
5430
  // free up and reassigns, and a chat round moves on with a chip that says so —
5239
5431
  // the wait ends as data, not as a dead room. Tests shrink it.
5240
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);
5241
5436
  // Reassigning around a busy teammate is bounded too: after this many
5242
5437
  // exhausted waits in one run the team is blocked on availability, not stuck.
5243
5438
  const GROUP_GOAL_MAX_WAIT_EXHAUSTIONS = 3;
@@ -5328,6 +5523,7 @@ const watchdog = new TurnWatchdog({
5328
5523
  // connector and credential continuations.
5329
5524
  drainQueuedSends();
5330
5525
  drainConnectorResumes();
5526
+ drainComputerResumes();
5331
5527
  drainSecretResumes();
5332
5528
  drainTeamSetupResumes();
5333
5529
  }
@@ -6719,8 +6915,23 @@ bus.subscribe((event) => {
6719
6915
  // they cannot keep a quiet seat held.
6720
6916
  if (touches && surface === "computer") {
6721
6917
  const computer = turnComputerResources.get(event.threadId);
6722
- if (computer)
6918
+ if (computer) {
6919
+ // A quiet window cannot expire while this call executes.
6920
+ turnResources.endComputerCall(computer.resource, computer.owner);
6723
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
+ }
6724
6935
  // Real cloud-screen work refreshes the #1655 idle stop for
6725
6936
  // that seat; the poller's own frames never arrive here, so
6726
6937
  // preview traffic cannot keep a paid machine awake.
@@ -6752,6 +6963,26 @@ bus.subscribe((event) => {
6752
6963
  });
6753
6964
  if (event.itemId)
6754
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
+ }
6755
6986
  }
6756
6987
  break;
6757
6988
  case "request.opened": {
@@ -6872,6 +7103,7 @@ bus.subscribe((event) => {
6872
7103
  subtitle: event.summary,
6873
7104
  options: event.choices?.length ? event.choices : permission ? ["Allow", "Deny"] : [],
6874
7105
  requestId: event.requestId,
7106
+ requestType: permission ? "permission" : "question",
6875
7107
  tool: permission ? event.tool : undefined,
6876
7108
  questionRequest: questions
6877
7109
  ? { version: 1, questions, ...(event.origin === "output" ? { origin: "output" } : {}) }
@@ -6939,9 +7171,15 @@ bus.subscribe((event) => {
6939
7171
  if (messageId) {
6940
7172
  const existing = store.messagesFor(event.threadId).find((m) => m.id === messageId);
6941
7173
  if (existing?.card && !existing.card.answered) {
6942
- store.patchMessage(event.threadId, messageId, {
6943
- card: { ...existing.card, answered: event.behavior, dismissed: event.source !== "user" },
6944
- });
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
+ }
6945
7183
  }
6946
7184
  if (event.requestId)
6947
7185
  askMessageByRequest.delete(`${event.threadId}:${event.requestId}`);
@@ -7069,9 +7307,13 @@ bus.subscribe((event) => {
7069
7307
  // interrupted the turn; Claude settles that interrupt as
7070
7308
  // exit_before_result, not "interrupted", so this generation's marker
7071
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.
7072
7312
  const lazyClaimAlreadyReported = event.stopReason === "exit_before_result" &&
7073
7313
  turnResourceOwners.get(event.threadId)?.lazyClaimFailureReported === true;
7074
- 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)) {
7075
7317
  const broken = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
7076
7318
  if (broken)
7077
7319
  reportIncident({ kind: "failed", bot: broken, threadId: event.threadId, detail: event.stopReason?.trim() || "the run ended without a result" });
@@ -7101,7 +7343,7 @@ bus.subscribe((event) => {
7101
7343
  if (!isCurrent())
7102
7344
  return;
7103
7345
  if (store.taskByThread(bot.id, event.threadId)?.activity !== "dead") {
7104
- store.setTaskActivity(bot.id, event.threadId, "idle");
7346
+ store.setTaskActivity(bot.id, event.threadId, resourceOwner?.computerParkedOn !== undefined ? "parked.computer" : "idle");
7105
7347
  }
7106
7348
  directTurnBots.delete(event.threadId);
7107
7349
  if (continueComputerSelection(event.threadId, generation, event.ok))
@@ -7112,6 +7354,7 @@ bus.subscribe((event) => {
7112
7354
  retryDelegationsWaitingOn(bot.id);
7113
7355
  drainQueuedSends();
7114
7356
  drainConnectorResumes();
7357
+ drainComputerResumes();
7115
7358
  drainSecretResumes();
7116
7359
  drainTeamSetupResumes();
7117
7360
  drainDelegationWakes();
@@ -7619,7 +7862,7 @@ function finalizeDelegationWatch(threadId, ok, reply = "", failureName = "Delega
7619
7862
  const source = watched.sourceBotId
7620
7863
  ? store.bot(watched.sourceBotId)
7621
7864
  : (watched.sourceThreadId ? store.botByThread(watched.sourceThreadId) : undefined);
7622
- if (source && target && !canReachPeer(source, target)) {
7865
+ if (!watched.oneWay && source && target && !canReachPeer(source, target)) {
7623
7866
  ok = false;
7624
7867
  reply = "";
7625
7868
  failureName = "Result withheld: team or peer access changed while the teammate was working";
@@ -7637,6 +7880,16 @@ function finalizeDelegationWatch(threadId, ok, reply = "", failureName = "Delega
7637
7880
  result: ok ? reply : failureName,
7638
7881
  });
7639
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
+ }
7640
7893
  let channel = watched.channelId ? store.group(watched.channelId) : undefined;
7641
7894
  let terminalThreadId = watched.sourceThreadId;
7642
7895
  if (source && watched.sourceThreadId) {
@@ -7780,7 +8033,7 @@ bus.subscribe((event) => {
7780
8033
  /** How a drained delegation becomes a real turn on the target. Shared by
7781
8034
  * the settle-time drain and the boot-time drain of what a previous process
7782
8035
  * left queued. */
7783
- const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel, taskId, sourceBotId, openedThreadId) => {
8036
+ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel, taskId, sourceBotId, openedThreadId, oneWay) => {
7784
8037
  // startTurn REJECTS on an ordinary condition — busy target, deleted bot,
7785
8038
  // unavailable provider. Unhandled, that rejection is fatal to the
7786
8039
  // harness (Node's default), which in the packaged app kills the server
@@ -7801,7 +8054,7 @@ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel,
7801
8054
  ? { botId: opener.id, name: opener.name, unattended: unattended || undefined }
7802
8055
  : undefined;
7803
8056
  const text = openedThreadId && opener
7804
- ? withPeerProvenance(rawText, { botName: opener.name, delivery: "start_thread", unattended })
8057
+ ? withPeerProvenance(rawText, { botName: opener.name, delivery: oneWay ? "send_to_bot" : "start_thread", unattended })
7805
8058
  : rawText;
7806
8059
  if (targetThreadId) {
7807
8060
  delegationWatch.set(targetThreadId, {
@@ -7813,6 +8066,7 @@ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel,
7813
8066
  sourceBotId,
7814
8067
  routineRunId: activeRoutineRunForThread(sourceThreadId)?.id,
7815
8068
  startedAtMs: Date.now(),
8069
+ ...(oneWay ? { oneWay: true } : {}),
7816
8070
  });
7817
8071
  }
7818
8072
  let failureReported = false;
@@ -7898,8 +8152,7 @@ bus.subscribe((event) => {
7898
8152
  // that turn queued to run anyway, minutes later, on an unrelated turn.
7899
8153
  if (!event.ok)
7900
8154
  discardDelegations(commsBus, event.threadId);
7901
- else
7902
- drainThreadDelegations(event.threadId);
8155
+ drainThreadDelegations(event.threadId);
7903
8156
  // A settling bot frees itself as a delegation TARGET too: handoffs that
7904
8157
  // found it busy earlier were kept queued — waiting until it's free or the
7905
8158
  // 24-hour expiry, not counting retries — on their own source threads, and
@@ -8020,7 +8273,7 @@ function drainAsideLane() {
8020
8273
  }
8021
8274
  /** Keep a person's words off the transcript until a direct-thread slot is
8022
8275
  * available. Reuse the existing cancellable, idempotent composer queue. */
8023
- async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender, trigger) {
8276
+ async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender, trigger, via) {
8024
8277
  const decision = admit("direct", {}, {
8025
8278
  // A room turn holds the bot exactly like the sibling opened-thread queue
8026
8279
  // below: the drain's own block check waits it out, so the words queue
@@ -8038,12 +8291,235 @@ async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId,
8038
8291
  prompt: promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"),
8039
8292
  sender,
8040
8293
  trigger,
8294
+ via,
8041
8295
  });
8042
8296
  return { ok: true, queued: true, queueId: queued.id, threadId, reason: decision.reason };
8043
8297
  }
8044
- const message = await startTurn(botId, text, { threadId, replyTo, sendId, sender, trigger });
8298
+ const message = await startTurn(botId, text, { threadId, replyTo, sendId, sender, trigger, via });
8045
8299
  return { ok: true, threadId, message };
8046
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
+ }
8047
8523
  /** How many start_thread calls one turn may make. Same spirit as the
8048
8524
  * create-bot ceiling above: a handful is a plan, more is a fan-out. */
8049
8525
  const MAX_THREADS_OPENED_PER_TURN = 5;
@@ -8329,6 +8805,7 @@ async function startTurn(botId, text, opts) {
8329
8805
  const policyRefusal = policyModelRefusal(instance);
8330
8806
  if (policyRefusal)
8331
8807
  throw Object.assign(new Error(policyRefusal), { status: 409, code: "managed_policy" });
8808
+ const toolScope = toolScopeForTurn(bot.id);
8332
8809
  // On a Cloud home a guest's turn never gets a shell or reads outside its
8333
8810
  // own folder (docs/cloud-pro.md): an engine that cannot run it that way is
8334
8811
  // refused, in one plain line, before anything is recorded.
@@ -8371,7 +8848,14 @@ async function startTurn(botId, text, opts) {
8371
8848
  .catch(() => undefined);
8372
8849
  }
8373
8850
  }
8374
- 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
+ }));
8375
8859
  const instanceId = instance.instanceId;
8376
8860
  if (providerInstancesChanging.has(instanceId)) {
8377
8861
  throw Object.assign(new Error("this provider account is being updated — try again shortly"), { status: 409 });
@@ -8409,6 +8893,8 @@ async function startTurn(botId, text, opts) {
8409
8893
  sendId: opts?.sendId,
8410
8894
  peerAsk: opts?.peerAsk,
8411
8895
  sender: opts?.sender,
8896
+ ...(opts?.via ? { via: opts.via } : {}),
8897
+ ...(opts?.relayed ? { relayed: true } : {}),
8412
8898
  });
8413
8899
  }
8414
8900
  const recoveryUserMessageId = opts?.coordination
@@ -8523,6 +9009,7 @@ async function startTurn(botId, text, opts) {
8523
9009
  retryDelegationsWaitingOn(bot.id);
8524
9010
  drainQueuedSends();
8525
9011
  drainConnectorResumes();
9012
+ drainComputerResumes();
8526
9013
  drainSecretResumes();
8527
9014
  drainTeamSetupResumes();
8528
9015
  drainDelegationWakes();
@@ -8893,6 +9380,22 @@ async function startTurn(botId, text, opts) {
8893
9380
  const surfaceLazyClaimRejection = (label) => (failure) => {
8894
9381
  if (activeInternalGenerationByThread.get(threadId) !== resourceOwner.generation || !threadBusy(bot.id, threadId))
8895
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
+ }
8896
9399
  const message = `computer unavailable — ${label} could not be claimed for this turn (${failure})`;
8897
9400
  store.appendMessage(threadId, {
8898
9401
  role: "bot",
@@ -8993,7 +9496,10 @@ async function startTurn(botId, text, opts) {
8993
9496
  return true;
8994
9497
  }
8995
9498
  catch (error) {
8996
- 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)
8997
9503
  throw error;
8998
9504
  if (directTurnGenerationByThread.get(threadId) === dispatchClaimId)
8999
9505
  releaseLocalVmThread(threadId);
@@ -9319,7 +9825,9 @@ async function startTurn(botId, text, opts) {
9319
9825
  // never redoes — or forgets — what another one already did
9320
9826
  { id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(recentWorkSources(bot), bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId, ...recentWorkFilter() })) },
9321
9827
  { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace, enabled: bot.memoryEnabled !== false }) },
9322
- { 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) : "" },
9323
9831
  { id: "skill-instructions", label: "Skill instructions", text: skillInstructions },
9324
9832
  { id: "playbooks", label: "Playbooks", text: packagePlaybooks },
9325
9833
  { id: "webhook", label: "Webhook provenance", text: opts?.automationSource === "webhook" ? WEBHOOK_PROMPT : "" },
@@ -9363,6 +9871,7 @@ async function startTurn(botId, text, opts) {
9363
9871
  refreshSystemPrompt: true,
9364
9872
  images: turnImages,
9365
9873
  approvalMode: approvalModeForTurn(bot, commsDepth > 0, threadId),
9874
+ toolScope,
9366
9875
  ...(guestConfined ? { guestConfined: true, confinedWhy: confinedWhy(threadId) } : {}),
9367
9876
  model,
9368
9877
  effort,
@@ -9437,6 +9946,7 @@ async function startTurn(botId, text, opts) {
9437
9946
  retryDelegationsWaitingOn(bot.id);
9438
9947
  drainQueuedSends();
9439
9948
  drainConnectorResumes();
9949
+ drainComputerResumes();
9440
9950
  drainSecretResumes();
9441
9951
  drainTeamSetupResumes();
9442
9952
  drainDelegationWakes();
@@ -9482,6 +9992,7 @@ async function startTurn(botId, text, opts) {
9482
9992
  if (ownsLatestGeneration) {
9483
9993
  drainQueuedSends();
9484
9994
  drainConnectorResumes();
9995
+ drainComputerResumes();
9485
9996
  drainSecretResumes();
9486
9997
  drainTeamSetupResumes();
9487
9998
  drainDelegationWakes();
@@ -9492,6 +10003,31 @@ async function startTurn(botId, text, opts) {
9492
10003
  settleDirectFollowup(dispatchClaimId);
9493
10004
  return;
9494
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
+ }
9495
10031
  let message = e instanceof Error ? e.message : String(e);
9496
10032
  const backup = cfg.automaticRecovery?.enabled ? cfg.automaticRecovery.backup : undefined;
9497
10033
  const current = store.projectBotForTask(bot.id, threadId);
@@ -9536,13 +10072,12 @@ async function startTurn(botId, text, opts) {
9536
10072
  settleDirectFollowup(dispatchClaimId, { ok: false, text: message });
9537
10073
  // The wait already wrote its failure resolution; keep all dispatch
9538
10074
  // failure bookkeeping below without adding the same error twice.
9539
- if (!(e instanceof ComputerWaitGaveUp))
9540
- store.appendMessage(threadId, {
9541
- role: "bot",
9542
- kind: "activity",
9543
- turnSucceeded: false,
9544
- tool: { name: `error: ${message.slice(0, 160)}`, ok: false },
9545
- });
10075
+ store.appendMessage(threadId, {
10076
+ role: "bot",
10077
+ kind: "activity",
10078
+ turnSucceeded: false,
10079
+ tool: { name: `error: ${message.slice(0, 160)}`, ok: false },
10080
+ });
9546
10081
  // Worth a buzz for the same reason a routine failure is, and the rule
9547
10082
  // notify.ts encodes: the bot is not working, and the cause is usually
9548
10083
  // a setting only a person can change — an unattended user would
@@ -9565,6 +10100,7 @@ async function startTurn(botId, text, opts) {
9565
10100
  // drain would strand anything queued behind this turn
9566
10101
  drainQueuedSends();
9567
10102
  drainConnectorResumes();
10103
+ drainComputerResumes();
9568
10104
  drainSecretResumes();
9569
10105
  drainTeamSetupResumes();
9570
10106
  drainDelegationWakes();
@@ -10359,8 +10895,24 @@ const tighteningRequests = new TighteningRequestService({
10359
10895
  // The card judges the effective mounts, so the snapshot resolves the same
10360
10896
  // way the engine does: through the config-aware custom-server filter.
10361
10897
  mountedMcpServers: (bot) => Object.keys(engineMcpServers(bot)),
10362
- 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
+ },
10363
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
+ }
10364
10916
  const disabled = setSkillEnabled(botId, name, false);
10365
10917
  return typeof disabled === "object" && "error" in disabled ? { ok: false, error: disabled.error } : { ok: true };
10366
10918
  },
@@ -11096,6 +11648,7 @@ setupRetry = 0) {
11096
11648
  const readyBot = store.bot(bot.id);
11097
11649
  if (!readyBot)
11098
11650
  return false;
11651
+ toolScopeForTurn(readyBot.id);
11099
11652
  const readyGroup = store.group(group.id);
11100
11653
  const stillOwnsThread = readyGroup?.dm
11101
11654
  ? readyGroup.threadId === threadId
@@ -11458,7 +12011,9 @@ setupRetry = 0) {
11458
12011
  // mounted, exactly as the 1:1 path decides it: memory_update is on the
11459
12012
  // agents server, so a room turn with it must be told to use it too.
11460
12013
  { id: "memory", label: "Memory", text: roomMemory ? `\n${roomMemory.trim()}` : "" },
11461
- { 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) : "" },
11462
12017
  { id: "skill-instructions", label: "Skill instructions", text: renderSkillInstructions(selectedSkills, { includeRoot: Boolean(workspace) }) },
11463
12018
  { id: "playbooks", label: "Playbooks", text: installedPlaybookInstructions(text, bot.playbooks) },
11464
12019
  ]);
@@ -11585,6 +12140,7 @@ setupRetry = 0) {
11585
12140
  refreshSystemPrompt: true,
11586
12141
  images: turnImages,
11587
12142
  approvalMode: roomTurnApprovalMode(readyBot, threadId, orchestration),
12143
+ toolScope: toolScopeForTurn(readyBot.id),
11588
12144
  ...(roomGuestConfined ? { guestConfined: true, confinedWhy: confinedWhy(threadId) } : {}),
11589
12145
  system: roomSystem.text,
11590
12146
  systemStable: roomSystem.stable,
@@ -11667,6 +12223,7 @@ setupRetry = 0) {
11667
12223
  watchdog.settle(threadId);
11668
12224
  drainQueuedSends();
11669
12225
  drainConnectorResumes();
12226
+ drainComputerResumes();
11670
12227
  drainSecretResumes();
11671
12228
  drainTeamSetupResumes();
11672
12229
  return false;
@@ -11697,6 +12254,7 @@ setupRetry = 0) {
11697
12254
  retryDelegationsWaitingOn(bot.id);
11698
12255
  drainQueuedSends();
11699
12256
  drainConnectorResumes();
12257
+ drainComputerResumes();
11700
12258
  drainSecretResumes();
11701
12259
  drainTeamSetupResumes();
11702
12260
  }
@@ -11725,6 +12283,7 @@ setupRetry = 0) {
11725
12283
  // queued while this bot briefly owned the room must be retried now.
11726
12284
  drainQueuedSends();
11727
12285
  drainConnectorResumes();
12286
+ drainComputerResumes();
11728
12287
  drainSecretResumes();
11729
12288
  drainTeamSetupResumes();
11730
12289
  }
@@ -11790,15 +12349,35 @@ setupRetry = 0) {
11790
12349
  return false;
11791
12350
  const isSpendCap = typeof error === "object" && error !== null && error.code === "spend_cap";
11792
12351
  const isVariantError = typeof error === "object" && error !== null && error.code === "unsupported_model_variant";
11793
- 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)
11794
12374
  throw error;
11795
12375
  const message = error instanceof Error ? error.message : "Local VM setup failed";
11796
- if (!(error instanceof ComputerWaitGaveUp))
11797
- store.appendMessage(threadId, {
11798
- role: "bot", kind: "activity",
11799
- from: { botId: bot.id, name: bot.name, color: bot.color },
11800
- tool: { name: `error: ${message}`, ok: false },
11801
- });
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
+ });
11802
12381
  onDispatchError?.(message);
11803
12382
  if (orchestration) {
11804
12383
  // A spend-cap refusal is deterministic: record it as its own
@@ -11808,7 +12387,7 @@ setupRetry = 0) {
11808
12387
  if (isSpendCap)
11809
12388
  orchestration.result.stopReason = message;
11810
12389
  }
11811
- return false;
12390
+ return isToolScopeError;
11812
12391
  }
11813
12392
  finally {
11814
12393
  // Covers connector/setup failures, cancellation before dispatch, and all
@@ -11829,6 +12408,7 @@ setupRetry = 0) {
11829
12408
  }
11830
12409
  drainQueuedSends();
11831
12410
  drainConnectorResumes();
12411
+ drainComputerResumes();
11832
12412
  drainSecretResumes();
11833
12413
  drainTeamSetupResumes();
11834
12414
  }
@@ -11887,6 +12467,12 @@ async function runGroupGoalStep(args) {
11887
12467
  coordinatorTurn.turnId = turnId;
11888
12468
  },
11889
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.
11890
12476
  if (result.outcome === "busy")
11891
12477
  continue;
11892
12478
  // One retry for a transient provider failure: a 13-turn goal must not
@@ -11990,6 +12576,13 @@ async function runGroupGoalOperation(args) {
11990
12576
  finishGroupGoalRun(args.groupId, args.operation, "blocked", `${coordinatorResult.stopReason ?? `${args.coordinator.name} stayed busy`} — send the goal again when they are free.`);
11991
12577
  return;
11992
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
+ }
11993
12586
  if (!coordinatorResult.ran || coordinatorResult.outcome !== "settled") {
11994
12587
  const reason = coordinatorResult.stopReason?.trim().slice(0, 120);
11995
12588
  finishGroupGoalRun(args.groupId, args.operation, "failed", `${args.coordinator.name} could not complete the coordination step${reason ? ` — ${reason}` : ""}.`);
@@ -12047,9 +12640,10 @@ async function runGroupGoalOperation(args) {
12047
12640
  finishGroupGoalRun(args.groupId, args.operation, "blocked", workerResult.stopReason ?? `${workerBot.name} hit the workspace spend cap.`);
12048
12641
  return;
12049
12642
  }
12050
- if (workerResult.outcome === "busy") {
12051
- // bounded: a team that keeps landing on busy teammates is blocked, not
12052
- // 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
12053
12647
  waitExhaustions += 1;
12054
12648
  if (waitExhaustions >= GROUP_GOAL_MAX_WAIT_EXHAUSTIONS) {
12055
12649
  finishGroupGoalRun(args.groupId, args.operation, "blocked", `Teammates stayed busy past the wait limit ${waitExhaustions} times — try again when the team is free.`);
@@ -12944,6 +13538,95 @@ function drainConnectorResumes() {
12944
13538
  dispatchConnectorResume(entry);
12945
13539
  }
12946
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
+ }
12947
13630
  const pendingSecretResumes = new Map();
12948
13631
  const phoneSecretSubmissions = new PhoneSecretSubmissionRegistry();
12949
13632
  function claimPhoneSecretBotDeletion(botId) {
@@ -13175,6 +13858,7 @@ bus.subscribe((event) => {
13175
13858
  return;
13176
13859
  if (event.type === "turn.completed") {
13177
13860
  drainConnectorResumes();
13861
+ drainComputerResumes();
13178
13862
  drainSecretResumes();
13179
13863
  drainTeamSetupResumes();
13180
13864
  }
@@ -13382,6 +14066,8 @@ function configStatus() {
13382
14066
  // the decision model: switches and configured-or-not, never the key
13383
14067
  decider: describeDecider(cfg),
13384
14068
  imageGen: avatarImageStatus(cfg),
14069
+ // Live calls: configured-or-not only; the voice name is a setting
14070
+ live: liveSettingsFor(cfg),
13385
14071
  // not a secret — the sidebar shows it
13386
14072
  profile: { name: cfg.profile?.name ?? "", email: cfg.profile?.email ?? "", aboutMe: cfg.profile?.aboutMe ?? "" },
13387
14073
  // the enrolled organisation's read-only desktop policy; null when not enrolled
@@ -13407,6 +14093,7 @@ function configStatus() {
13407
14093
  },
13408
14094
  features: {
13409
14095
  skillAuthoring: skillAuthoringEnabled(cfg),
14096
+ skillsLibrary: skillsLibraryEnabled(cfg),
13410
14097
  showToolCalls: showToolCallsEnabled(cfg),
13411
14098
  routinesInConversation: routinesInConversationEnabled(cfg),
13412
14099
  browser: builtInBrowserEnabled(cfg),
@@ -13708,6 +14395,7 @@ async function reloadProviders() {
13708
14395
  // queued behind them drains now — onto the freshly loaded fleet
13709
14396
  drainQueuedSends();
13710
14397
  drainConnectorResumes();
14398
+ drainComputerResumes();
13711
14399
  drainSecretResumes();
13712
14400
  drainTeamSetupResumes();
13713
14401
  }
@@ -13864,6 +14552,102 @@ ROUTES.push(createAntigravityLeftoverRoutes({
13864
14552
  remove: () => removeAntigravityLeftovers(),
13865
14553
  }));
13866
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
+ }));
13867
14651
  const toolResults = new ToolResults();
13868
14652
  const handleRequest = async (req, res) => {
13869
14653
  let url;
@@ -13920,7 +14704,7 @@ const handleRequest = async (req, res) => {
13920
14704
  if (method === "GET" && !path.startsWith("/api/") && !path.startsWith("/.well-known/") && serveStatic(res, path))
13921
14705
  return;
13922
14706
  if (method === "GET" && path === "/.well-known/openmausbot/environment") {
13923
- 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) }));
13924
14708
  }
13925
14709
  const domainCheck = /^\/\.well-known\/openmausbot\/domain-check\/([a-f0-9]{64})$/.exec(path);
13926
14710
  if (method === "GET" && domainCheck) {
@@ -14019,7 +14803,7 @@ const handleRequest = async (req, res) => {
14019
14803
  console.warn(`pairing refused from ${requestSource(req)}: ${result.error}`);
14020
14804
  return json(res, result.status, { error: result.error });
14021
14805
  }
14022
- 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) });
14023
14807
  if (wantsCookie) {
14024
14808
  // A browser sign-in replaces this browser's own session here, if it had one, rather than leaving it behind.
14025
14809
  const previous = browser ? sessions.authenticate(parseCookies(req.headers.cookie).get(SESSION_COOKIE)) : null;
@@ -15290,7 +16074,7 @@ const handleRequest = async (req, res) => {
15290
16074
  return json(res, 403, { error: "source conversation does not belong to sender" });
15291
16075
  }
15292
16076
  return json(res, 200, {
15293
- skills: listSkills(from.id),
16077
+ skills: listBotSkills(from),
15294
16078
  staged: listStagedSkillWrites(from.id).map(stagedSkillListing),
15295
16079
  });
15296
16080
  }
@@ -15945,9 +16729,7 @@ const handleRequest = async (req, res) => {
15945
16729
  if (!task)
15946
16730
  throw new Error("The recipient no longer exists");
15947
16731
  target.threadId = createdThread = task.threadId;
15948
- if (delegatedFullAccess(internalSender, internalCapability.threadId, store.bot(target.botId))) {
15949
- grantDelegatedFullAccess(internalSender, store.bot(target.botId), target.threadId);
15950
- }
16732
+ applyDelegatedLevel(internalSender, internalCapability.threadId, store.bot(target.botId), target.threadId);
15951
16733
  }
15952
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);
15953
16735
  // A retry keeps its original task; discard the speculative row.
@@ -16218,11 +17000,15 @@ const handleRequest = async (req, res) => {
16218
17000
  const target = store.bot(toBotId);
16219
17001
  if (!target)
16220
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
+ }
16221
17007
  if (internalCapability.roomCoordination) {
16222
- if (target.id !== from.id) {
17008
+ if (target.id !== from.id && !oneWay) {
16223
17009
  return json(res, 409, { error: "Use coordinate_bots for teamwork; it queues actual teammates and resumes you automatically." });
16224
17010
  }
16225
- if (!internalCapability.ownThreadCreation) {
17011
+ if (target.id === from.id && !internalCapability.ownThreadCreation) {
16226
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." });
16227
17013
  }
16228
17014
  }
@@ -16274,7 +17060,7 @@ const handleRequest = async (req, res) => {
16274
17060
  // classic handoff has applies unchanged, here at queue time and
16275
17061
  // again in the drain at dispatch time.
16276
17062
  const depth = internalCapability.depth;
16277
- if (depth >= MAX_COMMS_DEPTH) {
17063
+ if (!oneWay && depth >= MAX_COMMS_DEPTH) {
16278
17064
  return json(res, 200, { error: "thread chains are limited to one hop — open the thread on yourself, or do this one here" });
16279
17065
  }
16280
17066
  if (!canAccessTeam(from, target.section) || target.hidden) {
@@ -16288,9 +17074,11 @@ const handleRequest = async (req, res) => {
16288
17074
  return json(res, 500, { error: "couldn't create that thread" });
16289
17075
  // The work is still for the person whose request the opener is on.
16290
17076
  threadStarters.set(task.threadId, openerFrom(fromThreadId));
16291
- if (delegatedFullAccess(from, fromThreadId, target))
16292
- grantDelegatedFullAccess(from, target, task.threadId);
16293
- 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);
16294
17082
  if (queued.result !== "ok" || !queued.id) {
16295
17083
  // nothing will ever run there: take the row back before the person
16296
17084
  // sees a thread that goes nowhere
@@ -18115,6 +18903,7 @@ const handleRequest = async (req, res) => {
18115
18903
  return true;
18116
18904
  const openApprovalThreads = store.groupTasks(group.id).flatMap((task) => store.messagesFor(task.threadId).some((message) => message.kind === "options" &&
18117
18905
  message.card?.requestId &&
18906
+ (!isPersistentQuestionCard(message.card) || runningTurnEngines.has(task.threadId)) &&
18118
18907
  !message.card.answered &&
18119
18908
  !message.card.dismissed && !message.card.expired) ? [task.threadId] : []);
18120
18909
  return openApprovalThreads.length > 0 && !openApprovalThreads.includes(targetThreadId);
@@ -18866,10 +19655,11 @@ const handleRequest = async (req, res) => {
18866
19655
  if (created && !created.ok)
18867
19656
  return json(res, 400, { error: created.error });
18868
19657
  const bot = store.createBot({ ...profile.patch, soul: settings.soul, section, modelSelection: selection,
19658
+ toolScope: settings.toolScope ?? undefined,
18869
19659
  ...(created?.ok ? { visibility: created.visibility } : {}) });
18870
19660
  const createdRoutines = [];
18871
19661
  try {
18872
- const { chiefOfStaff: _chief, managedSections: _managed, ...ordinary } = settings;
19662
+ const { chiefOfStaff: _chief, managedSections: _managed, toolScope: _toolScope, ...ordinary } = settings;
18873
19663
  store.patchBot(bot.id, {
18874
19664
  // Store creation completes workspace defaults (including effort).
18875
19665
  // Applying the rest of the template must not undo that selection.
@@ -19309,6 +20099,12 @@ const handleRequest = async (req, res) => {
19309
20099
  // Per-bot selection of app-wide MCP servers. Omitted keeps the current
19310
20100
  // selection; null restores all enabled servers; [] explicitly mounts none.
19311
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
+ }
19312
20108
  if (body.mcpServers !== undefined) {
19313
20109
  if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
19314
20110
  return json(res, 401, { error: "unauthorized: this session has expired or was revoked" });
@@ -19586,6 +20382,8 @@ const handleRequest = async (req, res) => {
19586
20382
  (requestedMcpServers === undefined || requestedMcpServers.some((name) => !existingBot.mcpServers.includes(name)))) {
19587
20383
  loosened.push("mcpServers");
19588
20384
  }
20385
+ if (Object.hasOwn(body, "toolScope") && toolScopeWidens(existingBot?.toolScope, patch.toolScope))
20386
+ loosened.push("toolScope");
19589
20387
  const browserOrigin = typeof req.headers.origin === "string" && req.headers.origin.trim() !== "";
19590
20388
  if (loosened.length && auth.kind === "loopback" && !DESKTOP_MANAGED && !browserOrigin) {
19591
20389
  if (store.bots.some((candidate) => candidate.busy)) {
@@ -19621,6 +20419,18 @@ const handleRequest = async (req, res) => {
19621
20419
  (freshBrowserBot.busy || activeGroupTurnForBot(freshBrowserBot.id))) {
19622
20420
  return json(res, 409, { error: "stop this bot's turn before changing its memory setting" });
19623
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
+ }
19624
20434
  // Custom servers are process configuration: a running turn cannot
19625
20435
  // unmount them. Recheck after any awaited runtime revocation above.
19626
20436
  if (body.mcpServers !== undefined) {
@@ -19772,8 +20582,19 @@ const handleRequest = async (req, res) => {
19772
20582
  if (m && method === "GET") {
19773
20583
  if (!store.bot(m[1]))
19774
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
+ }
19775
20596
  return json(res, 200, {
19776
- skills: listSkills(m[1]),
20597
+ skills: listBotSkills(store.bot(m[1])),
19777
20598
  staged: listStagedSkillWrites(m[1]).map(stagedSkillListing),
19778
20599
  });
19779
20600
  }
@@ -19795,7 +20616,8 @@ const handleRequest = async (req, res) => {
19795
20616
  }
19796
20617
  m = path.match(/^\/api\/bots\/([\w-]+)\/skills\/([a-z0-9-]+)$/);
19797
20618
  if (m && method === "GET") {
19798
- const text = readSkillFile(m[1], m[2]);
20619
+ const bot = store.bot(m[1]);
20620
+ const text = bot ? readBotSkillFile(bot, m[2]) : null;
19799
20621
  if (text === null)
19800
20622
  return json(res, 404, { error: "no such skill" });
19801
20623
  return json(res, 200, { text });
@@ -19815,6 +20637,95 @@ const handleRequest = async (req, res) => {
19815
20637
  return json(res, 404, { error: result.error });
19816
20638
  return json(res, 200, { ok: true });
19817
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
+ }
19818
20729
  // ── section context: a user-owned team brief ────────────────────────
19819
20730
  // Bots receive this in their system context, but no agent tool can write
19820
20731
  // it. That keeps one bot from silently changing every teammate's future
@@ -20159,53 +21070,17 @@ const handleRequest = async (req, res) => {
20159
21070
  const trigger = onBehalfOf
20160
21071
  ? { kind: "user", ...(onBehalfOf.email ? { email: onBehalfOf.email } : {}), ...(onBehalfOf.name ? { label: onBehalfOf.name } : {}) }
20161
21072
  : usageTriggerFor(auth);
20162
- // The send is acknowledged before the turn starts, so a workspace at its
20163
- // spend limit is refused here, where the person can see it.
20164
- try {
20165
- assertWithinBudget(cfg, DATA_DIR);
20166
- }
20167
- catch (error) {
20168
- return json(res, 409, { error: error instanceof Error ? error.message : String(error), code: "spend_cap" });
20169
- }
20170
- if (!store.taskByThread(bot.id, threadId)) {
20171
- return json(res, 409, { error: "the bot switched tasks before it could receive the message" });
20172
- }
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);
20173
21079
  const sendId = parseSendId(body.sendId);
20174
21080
  const replyTo = resolveReplyTarget(threadId, body.replyToId);
20175
- const receipt = await sendSequencer.run(sendId ? `bot:${bot.id}:${threadId}:${sendId}` : undefined, sendFingerprint(text, replyTo?.id), async () => {
20176
- if (sendId) {
20177
- if (cancelledChatFollowup("bot", bot.id, threadId, sendId)) {
20178
- throw Object.assign(new Error("this queued sendId was cancelled; send a new message to try again"), { status: 409 });
20179
- }
20180
- const accepted = acceptedSendMatch(store.messagesFor(threadId), sendId, text, replyTo?.id);
20181
- if (accepted.kind === "conflict") {
20182
- throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
20183
- }
20184
- if (accepted.kind === "match") {
20185
- const canonical = {
20186
- ok: true,
20187
- threadId,
20188
- message: accepted.message,
20189
- };
20190
- return accepted.message.steered
20191
- ? { ...canonical, steered: true }
20192
- : canonical;
20193
- }
20194
- const queued = queuedSteeredMessage(bot.id, threadId, sendId);
20195
- if (queued) {
20196
- if (queued.text !== text || queued.replyToId !== replyTo?.id) {
20197
- throw Object.assign(new Error("sendId already belongs to another message"), { status: 409 });
20198
- }
20199
- return { ok: true, queued: true, queueId: queued.id, threadId, reason: queued.reason };
20200
- }
20201
- }
20202
- const currentAtStart = store.projectBotForTask(bot.id, threadId);
20203
- if (!currentAtStart)
20204
- throw Object.assign(new Error("no such bot"), { status: 404 });
20205
- if (!store.taskByThread(currentAtStart.id, threadId)) {
20206
- throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
20207
- }
20208
- if (guarded) {
21081
+ const sender = messageSender(auth);
21082
+ const guardedStart = guarded
21083
+ ? async (currentAtStart) => {
20209
21084
  // There is no await between these checks and startTurn's
20210
21085
  // synchronous transcript append / runtime reservation. In
20211
21086
  // particular, never steer or enqueue under stale permissions.
@@ -20227,91 +21102,32 @@ const handleRequest = async (req, res) => {
20227
21102
  if (guardedAdmission.action === "refuse") {
20228
21103
  throw Object.assign(new Error("wait for a free thread slot before retrying this message"), { status: 409, code: "guarded_busy" });
20229
21104
  }
20230
- 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 });
20231
21108
  return { ok: true, threadId, message };
20232
21109
  }
20233
- // Claude can accept the message inside its live turn. If the write
20234
- // loses a race with turn settlement, or the engine cannot steer, the
20235
- // existing server-side queue records it atomically for the next turn.
20236
- if (currentAtStart.busy) {
20237
- const instance = runningTurnInstance(currentAtStart, threadId);
20238
- let steered = "refused";
20239
- // A live text steer has no image side channel. Keep an attachment
20240
- // message intact for the next ordinary turn, where central image
20241
- // admission can hand it to the provider natively.
20242
- const carriesImages = extractTurnImages(text).images.length > 0;
20243
- const steerTarget = handoffs.current(threadId);
20244
- const busyAdmission = admit("direct-busy", {
20245
- carriesImages,
20246
- pendingComputerSelection: Boolean(computerSelectionTurns.get(threadId)?.selected),
20247
- engineCanSteer: Boolean(instance?.adapter.capabilities.queueing && instance.adapter.steer),
20248
- });
20249
- // steer was offered only when a live instance could take it;
20250
- // the second check carries that fact to the type system.
20251
- if (busyAdmission.action === "steer" && instance?.adapter.steer) {
20252
- steered = await instance.adapter
20253
- .steer(threadId, promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"))
20254
- .catch(() => "indeterminate");
20255
- }
20256
- // steer() is awaited adapter work. The turn can settle, the task can
20257
- // switch, or the whole bot can be deleted before its acknowledgement
20258
- // arrives. Re-read every ownership invariant before appending even a
20259
- // successful steer; otherwise that late acknowledgement writes a user
20260
- // message into a task the bot no longer owns. A conflict leaves the
20261
- // text in the client's composer/outbox to resend deliberately.
20262
- const current = store.projectBotForTask(bot.id, threadId);
20263
- if (!current)
20264
- throw Object.assign(new Error("no such bot"), { status: 404 });
20265
- if (!store.taskByThread(bot.id, threadId)) {
20266
- throw Object.assign(new Error("the target task no longer exists"), { status: 409 });
20267
- }
20268
- const delivered = steered !== "refused";
20269
- if (delivered) {
20270
- if (steered === "steered" && !current.busy) {
20271
- throw Object.assign(new Error("the running turn ended before the steered message could be recorded"), { status: 409 });
20272
- }
20273
- // "indeterminate" falls through to the same record: the words
20274
- // may already be folded into a turn whose acknowledgement was
20275
- // lost, and handing them back for a resend could run them
20276
- // twice. Recording them once is the honest outcome.
20277
- // A person steering a webhook turn is present, and auto mode may
20278
- // follow them again. But this route is also reachable from the
20279
- // bot's own shell on a headless server (loopback is the owner
20280
- // there), and "continue" typed by the turn itself must not be
20281
- // the thing that lifts the block written against it — so only
20282
- // a request that proves a person (a paired session, or the
20283
- // desktop's owner capability, which every mutation there has
20284
- // already shown) clears the mark.
20285
- if (auth.kind === "session" || DESKTOP_MANAGED)
20286
- clearUnattended(threadId);
20287
- const message = store.appendMessage(threadId, {
20288
- role: "user",
20289
- kind: "text",
20290
- text,
20291
- replyToId: replyTo?.id,
20292
- sendId,
20293
- steered: true,
20294
- sender: messageSender(auth),
20295
- });
20296
- // Offered to the next turn again unless the person stops this one.
20297
- handoffs.steered(threadId, steerTarget, instance?.instanceId, message.id);
20298
- return { ok: true, steered: true, threadId, message };
20299
- }
20300
- if (!current.busy) {
20301
- return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId, messageSender(auth), trigger);
20302
- }
20303
- const queued = queueSteeredMessage(current.id, threadId, text, {
20304
- replyToId: replyTo?.id,
20305
- sendId,
20306
- prompt: promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"),
20307
- sender: messageSender(auth),
20308
- trigger,
20309
- });
20310
- return { ok: true, queued: true, queueId: queued.id, threadId };
20311
- }
20312
- return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId, messageSender(auth), trigger);
20313
- });
20314
- 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
+ }
20315
21131
  }
20316
21132
  m = path.match(/^\/api\/bots\/([\w-]+)\/queue\/([\w-]+)$/);
20317
21133
  if (m && method === "DELETE") {
@@ -20378,6 +21194,7 @@ const handleRequest = async (req, res) => {
20378
21194
  peerAsk: item.peerAsk,
20379
21195
  steered: true,
20380
21196
  sender: item.sender,
21197
+ ...(item.via ? { via: item.via } : {}),
20381
21198
  }));
20382
21199
  // Offered to the next turn again unless the person stops this one.
20383
21200
  for (const message of messages)
@@ -20567,7 +21384,32 @@ const handleRequest = async (req, res) => {
20567
21384
  resolvePeerComms(approvalBus, String(body.requestId), behavior)) {
20568
21385
  return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
20569
21386
  }
20570
- 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
+ }
20571
21413
  return json(res, 200, { ok: true, outcome });
20572
21414
  });
20573
21415
  }
@@ -20693,14 +21535,46 @@ const handleRequest = async (req, res) => {
20693
21535
  // answerRequest closes an unreachable card, and a pending approval owns
20694
21536
  // the composer, so a dead end here locks the room for good.
20695
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);
20696
21548
  const owner = group
20697
- ? (group.busyBotId ? store.bot(group.busyBotId) : undefined) ??
20698
- (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)
20699
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
+ }
20700
21558
  if (!owner && !pending)
20701
21559
  return json(res, 404, { error: "nothing is waiting on an answer in this conversation" });
20702
21560
  const requestOwner = owner ? botForThread(owner.id, threadId) : null;
20703
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
+ }
20704
21578
  return json(res, 200, { ok: true, outcome });
20705
21579
  });
20706
21580
  }
@@ -21697,6 +22571,16 @@ const handleRequest = async (req, res) => {
21697
22571
  billing: prices ? { currency: cfg.billing?.currency ?? "USD" } : null,
21698
22572
  });
21699
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
+ }
21700
22584
  // ── provider key check: does a pasted or saved key open the provider's door ──
21701
22585
  // One read-only request from this server; the verdict never carries the
21702
22586
  // key. Admin scope by default, like every route not opened to clients.
@@ -22746,6 +23630,8 @@ const handleRequest = async (req, res) => {
22746
23630
  persisted.imageGen.key = "";
22747
23631
  if (persisted.imageGen?.customApiKey !== undefined)
22748
23632
  persisted.imageGen.customApiKey = "";
23633
+ if (persisted.live?.key !== undefined)
23634
+ persisted.live.key = "";
22749
23635
  saveConfig(persisted);
22750
23636
  configWriteCommitted = true;
22751
23637
  syncCredentialEnv(patch);
@@ -22856,6 +23742,7 @@ const handleRequest = async (req, res) => {
22856
23742
  drainQueuedSends();
22857
23743
  drainDelegationWakes();
22858
23744
  drainConnectorResumes();
23745
+ drainComputerResumes();
22859
23746
  drainSecretResumes();
22860
23747
  drainTeamSetupResumes();
22861
23748
  }
@@ -23502,6 +24389,7 @@ for (const row of chatFollowups()) {
23502
24389
  role: "user", kind: "text", text: row.kind === "aside" ? row.payload.prompt ?? row.payload.text : row.payload.text, replyToId: row.payload.replyToId,
23503
24390
  sendId: row.payload.sendId, queueId: row.id, sender: row.payload.sender,
23504
24391
  ...(row.kind === "channel" ? { channelMode: row.payload.mode, via: row.payload.via } : {}),
24392
+ ...(row.kind === "bot" && row.payload.via === "call" ? { via: "call" } : {}),
23505
24393
  ...(row.kind === "aside" ? {
23506
24394
  aside: true,
23507
24395
  peerAsk: row.payload.aside
@@ -23617,6 +24505,7 @@ const gracefulShutdown = createGracefulShutdown({
23617
24505
  await Promise.all([...temporaryBrowserSessions.keys()].map((botId) => forgetTemporaryBrowser(botId)));
23618
24506
  await browserRuntime.closeAll();
23619
24507
  },
24508
+ () => liveCalls.shutdown(),
23620
24509
  () => flushAllProfileHistory(),
23621
24510
  () => flushAllMemoryJournals(),
23622
24511
  () => flushUsageLedger(DATA_DIR),