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.
- package/dist/assets/{DesktopViewer-CKNR3SEW.js → DesktopViewer-bxM7NV3N.js} +1 -1
- package/dist/assets/{abnfDiagram-O67JEVCF-Ckrm1I1X.js → abnfDiagram-O67JEVCF-BhMdJhRJ.js} +1 -1
- package/dist/assets/{arc-BBdBuCgB.js → arc-B5itKDcx.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-CDdA3Hjs.js → architectureDiagram-NJMV4G6O-C5d7UY6U.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-AUrsx1YR.js → blockDiagram-BEXU5L5S-BrZIhMmu.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-BEqQ0Unh.js → c4Diagram-YGBWAQC7-7I0ArfbD.js} +1 -1
- package/dist/assets/channel-CdJX6i8k.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-CEEuvd4C.js → chunk-2Q5K7J3B-BnEsyN36.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-DyANCiHl.js → chunk-3FUC2YCW-BXATQ52s.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-Dx_Slpw9.js → chunk-5VM5RSS4-B0BpTkEx.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-Dl7h7iiD.js → chunk-GTNCS2PH-BtLTz6FR.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-BLx07SPb.js → chunk-GWA4HPMP-D6gcZOYt.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-BMxDj3n7.js → chunk-JWPE2WC7-DCLrosvf.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-CN66PT4H.js → chunk-NETBCI7D-B9JsknV9.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-6HwhsDKw.js → chunk-WEXAMYUT-CsoTxw7z.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-gWnLt7SN.js → chunk-XXDRQBXY-0F_frB0c.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-CMS5l2ea.js → classDiagram-v2-NBCMYWYE-DkrpnflX.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-CV7sQwIG.js → cose-bilkent-JH36ORCC-BNDwYBt9.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-CSyl-7vl.js → cynefin-OW5HDTMX-JUkNr1Mk.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-BCLKL6az.js → cynefinDiagram-VND7K2PF-DKeh9N4M.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-DiaZs6S1.js → dagre-6A5THRUB-ve-6T_wX.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-BeTtmt3I.js → diagram-22UHCM2B-BuJ2d8rT.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-CsdBG-QQ.js → diagram-3UASUU5V-BgOs_Ni-.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-BczvdbR1.js → diagram-ATOU4E4O-DMK6VmcP.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-DHTTTMhi.js → diagram-CDSNMT55-DksASlcY.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-RTjHyd8z.js → diagram-MLGK6HIB-Bgn8I5k6.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-BDenHEH_.js → diagram-MPIPVDR6-zK0jqO8N.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-BSiRSbtL.js → ebnfDiagram-ZINNZB2B-vKrbzTwr.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-BCAElss-.js → elk-276RUBZZ-Bzv2mPNf.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-C4_1ZPJe.js → erDiagram-OPXOYQCR-CvOyYuxu.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-Bcb4Z75S.js → flowDiagram-KWPJA3E3-CtbnDbc0.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-BiPOzxzb.js → ganttDiagram-FUAMR5RP-c4dMRYyG.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-B1wtF3ZS.js → gitGraphDiagram-X574FWY7-B34uaQho.js} +1 -1
- package/dist/assets/index-CPYrGsv1.js +610 -0
- package/dist/assets/{index-BkYwilkG.js → index-CPiqc_DN.js} +1 -1
- package/dist/assets/index-CzT42GR8.css +1 -0
- package/dist/assets/{infoDiagram-VRGFBTTK-A19No71U.js → infoDiagram-VRGFBTTK-o64ma4CI.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-BQsqJ_cV.js → ishikawaDiagram-OU5B5YK6-DV4C9BAm.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-DAO0pVan.js → journeyDiagram-ZHPQQLJL-Bf0AHk1H.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-NepmKEe2.js → kanban-definition-PNTS6WVX-B1UMSKmE.js} +1 -1
- package/dist/assets/{linear-bq1PTDTW.js → linear-BpKhuyLm.js} +1 -1
- package/dist/assets/{mermaid.core-s4LTbQle.js → mermaid.core-B_BKNCw5.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-LtdbgHFH.js → mindmap-definition-NLK3R4M7-DeARruT1.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-tdbaCxiK.js → pegDiagram-GJSIUBJH-BOQEk_1D.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-DvkbpW8l.js → pieDiagram-5QR66LMP-EIfoiTZo.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-Do4KaH-E.js → quadrantDiagram-O4NWA36T-GgZwT-fi.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-BQWULOAs.js → railroadDiagram-XR7U4H2S-D9rDQomj.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-w9kei1FU.js → requirementDiagram-PLB6GJNP-DAh1MIeo.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-D2gRy0e8.js → sankeyDiagram-IPEJSGJF-CPr6Uz64.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-a2sO5_Eo.js → sequenceDiagram-PO4LG4MO-BU6VIdcm.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-CiPJmFEB.js → sizeCapture-INFHLROL-qF2HO41r.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-DAkUUlL7.js → stateDiagram-v2-GCMORJYK-DlJ8G4MD.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-DANf2R8Y.js → swimlanes-2SLR337P-DzYE8I54.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-D-U4GtLQ.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-BKpQmzrT.js → timeline-definition-EJHVYXUP-B7wzEQ0A.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-BtI7qLN-.js → usecaseDiagram-POWQR4AR-BvGgrxIP.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-gXJXrAG_.js → vennDiagram-UO4OBE2U-CEECdgQg.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-GU8Po5-n.js → wardleyDiagram-VNRHLVJA-XQHA0P_P.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-BF4J30g6.js → xychartDiagram-PMCCYNJV-B5mLcZI2.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/cloud-home-start.js +1 -0
- package/dist-server/companion/src/routes.js +21 -0
- package/dist-server/container-mcp.js +93 -76
- package/dist-server/drivers/agents-proxy.js +38 -1
- package/dist-server/drivers/pi-mcp-extension.ts +245 -299
- package/dist-server/enterprise/server/index.js +1 -0
- package/dist-server/index.js +16942 -13191
- package/dist-server/local-computer.js +1 -0
- package/dist-server/mcp-gate.js +147 -19
- package/dist-server/mcp-remote-proxy.js +437 -0
- package/dist-server/openmausbot.js +8233 -7651
- package/dist-server/pair-cli.js +8233 -7651
- package/dist-server/permission-proxy.js +65 -1
- package/dist-server/proxy-paths.js +1 -0
- package/dist-server/server/auto-approve.js +33 -11
- package/dist-server/server/browser-live.js +14 -5
- package/dist-server/server/browser-runtime.js +6 -0
- package/dist-server/server/channel-queue.js +4 -1
- package/dist-server/server/cli.js +32 -12
- package/dist-server/server/cloud-move-http.js +9 -3
- package/dist-server/server/cloud-move.js +18 -0
- package/dist-server/server/computer-wait.js +33 -13
- package/dist-server/server/config.js +44 -10
- package/dist-server/server/decision-log.js +9 -6
- package/dist-server/server/delegations.js +47 -22
- package/dist-server/server/drivers/acp/core.js +57 -8
- package/dist-server/server/drivers/acp/grok.js +208 -3
- package/dist-server/server/drivers/agents-call.js +28 -0
- package/dist-server/server/drivers/agents-catalog.js +10 -1
- package/dist-server/server/drivers/boatagent.js +2 -0
- package/dist-server/server/drivers/chat-mcp-tools.js +27 -14
- package/dist-server/server/drivers/claude.js +18 -6
- package/dist-server/server/drivers/codex.js +84 -10
- package/dist-server/server/drivers/openai-chat.js +11 -3
- package/dist-server/server/drivers/pi.js +48 -4
- package/dist-server/server/environment.js +3 -0
- package/dist-server/server/guarded-requests.js +2 -1
- package/dist-server/server/index.js +1143 -254
- package/dist-server/server/live-call-controller.js +868 -0
- package/dist-server/server/live-call.js +216 -0
- package/dist-server/server/mcp-gate-config.js +43 -12
- package/dist-server/server/mcp-gate.js +127 -25
- package/dist-server/server/mcp-http.js +46 -8
- package/dist-server/server/mcp-remote-proxy.js +108 -0
- package/dist-server/server/new-bot-defaults.js +3 -0
- package/dist-server/server/peer-provenance.js +4 -2
- package/dist-server/server/permission-proxy.js +15 -1
- package/dist-server/server/plan-usage.js +797 -0
- package/dist-server/server/proxy-paths.js +1 -0
- package/dist-server/server/redact.js +2 -0
- package/dist-server/server/request-auth.js +3 -2
- package/dist-server/server/routes/live.js +98 -0
- package/dist-server/server/skill-library.js +207 -0
- package/dist-server/server/skills-library-migration.js +235 -0
- package/dist-server/server/skills.js +66 -3
- package/dist-server/server/steer-queue.js +13 -1
- package/dist-server/server/store.js +32 -4
- package/dist-server/server/surface.js +9 -3
- package/dist-server/server/team-backup.js +2 -1
- package/dist-server/server/thread-events.js +1 -1
- package/dist-server/server/tunnel.js +20 -1
- package/dist-server/server/turn-log.js +10 -0
- package/dist-server/server/turn-resources.js +124 -0
- package/dist-server/shared/ask-question.js +13 -0
- package/dist-server/shared/call-consent.js +28 -0
- package/dist-server/shared/live-approval.js +124 -0
- package/dist-server/shared/live-call.js +176 -0
- package/dist-server/shared/skill-md.js +10 -0
- package/dist-server/shared/team-backup.js +3 -0
- package/dist-server/shared/tool-scope-support.js +25 -0
- package/dist-server/shared/tool-scope.js +136 -0
- package/dist-server/src/lib/thread-refs.js +406 -0
- package/dist-server/vps-container-mcp.js +93 -76
- package/dist-server/workspace-backup.worker.js +89 -100
- package/enterprise/server/index.js +1 -0
- package/package.json +1 -1
- package/dist/assets/channel-BU94VpxE.js +0 -1
- package/dist/assets/index-Biji-bfL.css +0 -1
- package/dist/assets/index-DZlhoES0.js +0 -597
- 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,
|
|
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,
|
|
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
|
|
997
|
-
*
|
|
998
|
-
|
|
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
|
-
|
|
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() +
|
|
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 === "
|
|
2242
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
2316
|
-
|
|
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) =>
|
|
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
|
|
3039
|
+
for (const listing of listBotSkills(bot)) {
|
|
2954
3040
|
if (!selected.has(listing.name))
|
|
2955
3041
|
continue;
|
|
2956
|
-
const instructions =
|
|
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) =>
|
|
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
|
|
3082
|
+
for (const listing of listBotSkills(bot)) {
|
|
2997
3083
|
if (!chosen.has(listing.name))
|
|
2998
3084
|
continue;
|
|
2999
|
-
const instructions =
|
|
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:
|
|
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
|
|
3349
|
-
* threads it delegates
|
|
3350
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
3480
|
+
function sourceApprovalMode(botId, threadId) {
|
|
3366
3481
|
const owner = connectorThread(botId, threadId);
|
|
3367
3482
|
if (!owner)
|
|
3368
|
-
return
|
|
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)
|
|
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
|
-
/**
|
|
3377
|
-
* Chief
|
|
3378
|
-
*
|
|
3379
|
-
*
|
|
3380
|
-
*
|
|
3381
|
-
*
|
|
3382
|
-
*
|
|
3383
|
-
*
|
|
3384
|
-
*
|
|
3385
|
-
function
|
|
3386
|
-
return
|
|
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
|
-
|
|
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
|
-
/**
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
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:
|
|
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:
|
|
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
|
|
3407
|
-
*
|
|
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
|
-
|
|
3417
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
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
|
-
|
|
6943
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9540
|
-
|
|
9541
|
-
|
|
9542
|
-
|
|
9543
|
-
|
|
9544
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11797
|
-
|
|
11798
|
-
|
|
11799
|
-
|
|
11800
|
-
|
|
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
|
|
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
|
|
12052
|
-
// looping — three exhausted waits per run,
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
16292
|
-
|
|
16293
|
-
const queued = queueDelegation(commsBus, from, { toBotId: target.id, message
|
|
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:
|
|
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
|
|
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
|
|
20163
|
-
//
|
|
20164
|
-
|
|
20165
|
-
|
|
20166
|
-
|
|
20167
|
-
|
|
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
|
|
20176
|
-
|
|
20177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20234
|
-
|
|
20235
|
-
|
|
20236
|
-
|
|
20237
|
-
|
|
20238
|
-
|
|
20239
|
-
//
|
|
20240
|
-
//
|
|
20241
|
-
//
|
|
20242
|
-
|
|
20243
|
-
|
|
20244
|
-
|
|
20245
|
-
|
|
20246
|
-
|
|
20247
|
-
|
|
20248
|
-
|
|
20249
|
-
|
|
20250
|
-
|
|
20251
|
-
|
|
20252
|
-
|
|
20253
|
-
|
|
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
|
|
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
|
-
?
|
|
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),
|