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