openmausbot 0.1.95 → 0.1.97
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-BwWpc4RK.js → DesktopViewer-CPidMCtt.js} +1 -1
- package/dist/assets/{abnfDiagram-O67JEVCF-jsQkIqTn.js → abnfDiagram-O67JEVCF-k1UCNXb7.js} +1 -1
- package/dist/assets/{arc-7kei4Hsu.js → arc-BUWGa9UU.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-CRWDFDMq.js → architectureDiagram-NJMV4G6O-DiPor3UT.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-CTKW_IKs.js → blockDiagram-BEXU5L5S-Ag9nLW4H.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-BBTW7-6M.js → c4Diagram-YGBWAQC7-Bh0sjgUa.js} +1 -1
- package/dist/assets/channel-DR8eD6QW.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-M_Ox-MkD.js → chunk-2Q5K7J3B-BFBcIIqu.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-D2YiN-Ud.js → chunk-3FUC2YCW-Dvfpex-f.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-BmVYJ0f8.js → chunk-5VM5RSS4-CMjP2YZW.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-BtiLVVnZ.js → chunk-GTNCS2PH-BryoeTc7.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-DFYSb6J6.js → chunk-GWA4HPMP-BhfvcchO.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-mknKBzgV.js → chunk-JWPE2WC7-bcCVp8rp.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-DgSYGiOu.js → chunk-NETBCI7D-yKIjIlkQ.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-C7ZvuguH.js → chunk-WEXAMYUT-D3Zqbmup.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-CPqxINpA.js → chunk-XXDRQBXY-CjVoIMG6.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-Cd6xfJaC.js → classDiagram-v2-NBCMYWYE-mUCWdVyH.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-BSXY5qRh.js → cose-bilkent-JH36ORCC-BBmR1bbX.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-D_lhEMj1.js → cynefin-OW5HDTMX-BpRrxoO4.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-Da9FrFwk.js → cynefinDiagram-VND7K2PF-D3VBWCPK.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-T77jMOXe.js → dagre-6A5THRUB-DxEIACOO.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-Dkj96X5E.js → diagram-22UHCM2B-BANgZEOS.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-CMi9zlD_.js → diagram-3UASUU5V-Cnq7nJir.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-CXK6YUfS.js → diagram-ATOU4E4O-4TBDIWnz.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-BE7B1u9Q.js → diagram-CDSNMT55-CD_B39D4.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-CCMQphKr.js → diagram-MLGK6HIB-CzvnFhEC.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-iHJ_-tGH.js → diagram-MPIPVDR6-C4w2SNml.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-DONsCWQ7.js → ebnfDiagram-ZINNZB2B-gkUEBm2s.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-CU_WOaXk.js → elk-276RUBZZ-B_56X6mw.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-BeYwM04G.js → erDiagram-OPXOYQCR-C_oByoDS.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-Bdw9DqEl.js → flowDiagram-KWPJA3E3-CR9xXJkC.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-C9iwq3qm.js → ganttDiagram-FUAMR5RP-gtYBTu0B.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-Dx-VWzUg.js → gitGraphDiagram-X574FWY7-VJ6-Tp_p.js} +1 -1
- package/dist/assets/{index-KygHRE3E.js → index-Cp4S4NOL.js} +1 -1
- package/dist/assets/index-CpaIXRLp.js +614 -0
- package/dist/assets/index-Z81SMsgR.css +1 -0
- package/dist/assets/{infoDiagram-VRGFBTTK-BG7uaEd4.js → infoDiagram-VRGFBTTK-2Lu9te4D.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-D72WAXnM.js → ishikawaDiagram-OU5B5YK6-DR49XY-e.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-DKH1gjYT.js → journeyDiagram-ZHPQQLJL-ZoR0M305.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-3upm9JUT.js → kanban-definition-PNTS6WVX-DLKHxrO-.js} +1 -1
- package/dist/assets/{linear-CcR4pc0-.js → linear-BASsfWly.js} +1 -1
- package/dist/assets/{mermaid.core-C6yU5FSS.js → mermaid.core-BVidiPkk.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-DOI2xEuv.js → mindmap-definition-NLK3R4M7-g_Qx0U5C.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-BVWJSgLd.js → pegDiagram-GJSIUBJH-DI9Cjs8w.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-jtbYezhq.js → pieDiagram-5QR66LMP-sRI0Uhr5.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-g0u8mtPX.js → quadrantDiagram-O4NWA36T-CamKTby3.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-k8689Wny.js → railroadDiagram-XR7U4H2S-DOLlkbf3.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-DsJ22PDt.js → requirementDiagram-PLB6GJNP-DrRQ5r97.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-CneWBRgG.js → sankeyDiagram-IPEJSGJF-DGS_lXcX.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-DWsFCWap.js → sequenceDiagram-PO4LG4MO-CkjWqwtf.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-Bvf0YE3S.js → sizeCapture-INFHLROL-CepRgful.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-Bxb3Iei8.js → stateDiagram-v2-GCMORJYK-hykr-9RS.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-DLkk7W4A.js → swimlanes-2SLR337P-CKOHEZbI.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-D-8vqZ4-.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-KR9bIHYC.js → timeline-definition-EJHVYXUP-CB8pJ6AC.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-DfsOrj8t.js → usecaseDiagram-POWQR4AR-DrclJHdl.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-DRSTzG3h.js → vennDiagram-UO4OBE2U-zS23qjv8.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-DtEdhPgT.js → wardleyDiagram-VNRHLVJA-52jgeKxz.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-B-XhTxjG.js → xychartDiagram-PMCCYNJV-DSdl9rQ8.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/cloud-home-start.js +26 -13
- package/dist-server/companion/src/listener.js +39 -18
- package/dist-server/container-mcp.js +14 -27
- package/dist-server/drivers/agents-proxy.js +108 -49
- package/dist-server/harness-mcp-proxy.js +1 -2
- package/dist-server/index.js +4664 -4644
- package/dist-server/openmausbot.js +1425 -1437
- package/dist-server/pair-cli.js +1425 -1437
- package/dist-server/permission-proxy.js +0 -4
- package/dist-server/server/admin-activity.js +1 -4
- package/dist-server/server/auto-approve.js +20 -5
- package/dist-server/server/auto-vm-claims.js +13 -1
- package/dist-server/server/automatic-recovery.js +1 -2
- package/dist-server/server/boat.js +71 -87
- package/dist-server/server/bot-overview.js +1 -1
- package/dist-server/server/browser-engine.js +38 -1
- package/dist-server/server/browser-live.js +112 -25
- package/dist-server/server/browser-runtime.js +31 -1
- package/dist-server/server/checkpoints.js +340 -167
- package/dist-server/server/chief-of-staff.js +7 -6
- package/dist-server/server/cli-setup.js +3 -3
- package/dist-server/server/cli.js +33 -26
- package/dist-server/server/cloud-computer-tools.js +11 -4
- package/dist-server/server/cloud-home-start.js +13 -16
- package/dist-server/server/cloud-home.js +3 -13
- package/dist-server/server/cloud-move-http.js +44 -30
- package/dist-server/server/cloud-move.js +20 -18
- package/dist-server/server/cloud-owner.js +1 -1
- package/dist-server/server/composio.js +10 -3
- package/dist-server/server/computer-engine-removal.js +70 -0
- package/dist-server/server/computer-selection.js +18 -0
- package/dist-server/server/config.js +99 -75
- package/dist-server/server/connector-scope-hint.js +83 -0
- package/dist-server/server/container-computer.js +16 -5
- package/dist-server/server/contracts.js +0 -9
- package/dist-server/server/decider/jev.js +1 -4
- package/dist-server/server/decider/log.js +1 -3
- package/dist-server/server/decision-log.js +1 -4
- package/dist-server/server/default-model-selection.js +20 -14
- package/dist-server/server/delegations.js +14 -7
- package/dist-server/server/delta-context.js +6 -1
- package/dist-server/server/drivers/acp/core.js +5 -4
- package/dist-server/server/drivers/acp/opencode-go.js +19 -23
- package/dist-server/server/drivers/agents-call.js +47 -64
- package/dist-server/server/drivers/agents-catalog.js +69 -3
- package/dist-server/server/drivers/agents-proxy.js +2 -1
- package/dist-server/server/drivers/builtIn.js +0 -2
- package/dist-server/server/drivers/claude-login-auth.js +2 -11
- package/dist-server/server/drivers/claude.js +49 -30
- package/dist-server/server/drivers/codex-device-auth.js +3 -1
- package/dist-server/server/drivers/codex.js +7 -6
- package/dist-server/server/drivers/grok.js +1 -1
- package/dist-server/server/drivers/openai-chat-protocol.js +4 -2
- package/dist-server/server/drivers/openai-chat.js +113 -11
- package/dist-server/server/drivers/pi.js +184 -49
- package/dist-server/server/drivers/prompt-split.js +4 -4
- package/dist-server/server/harness/bus.js +49 -0
- package/dist-server/server/harness-mcp-proxy.js +1 -1
- package/dist-server/server/index.js +942 -980
- package/dist-server/server/live-call.js +11 -6
- package/dist-server/server/managed-policy.js +1 -1
- package/dist-server/server/mcp-registry.js +35 -0
- package/dist-server/server/message-file.js +4 -4
- package/dist-server/server/model-catalog/presets.js +8 -0
- package/dist-server/server/peer-delivery.js +4 -19
- package/dist-server/server/peer-roster.js +40 -6
- package/dist-server/server/profile-versions.js +0 -31
- package/dist-server/server/provider-key-check.js +2 -1
- package/dist-server/server/restart.js +40 -0
- package/dist-server/server/room-handoffs.js +12 -0
- package/dist-server/server/room-turn-end.js +15 -0
- package/dist-server/server/routes/bot-memory.js +0 -24
- package/dist-server/server/routes/live.js +3 -3
- package/dist-server/server/routine-requests.js +2 -5
- package/dist-server/server/routines.js +6 -5
- package/dist-server/server/screen-frame-gate.js +1 -3
- package/dist-server/server/server-launcher.js +53 -0
- package/dist-server/server/store.js +126 -2
- package/dist-server/server/surface.js +40 -64
- package/dist-server/server/system-prompt.js +23 -13
- package/dist-server/server/team-manifest.js +1 -6
- package/dist-server/server/tunnel.js +4 -1
- package/dist-server/server/turn-continuation.js +162 -0
- package/dist-server/server/turn-images.js +1 -21
- package/dist-server/server/turn-resources.js +19 -150
- package/dist-server/server/usage-ledger.js +3 -1
- package/dist-server/server/workspace.js +3 -4
- package/dist-server/server-launcher.js +72 -0
- package/dist-server/shared/ask-question.js +1 -71
- package/dist-server/shared/cloud-computer.js +10 -0
- package/dist-server/shared/credential-request.js +1 -1
- package/dist-server/shared/failed-turn.js +22 -0
- package/dist-server/shared/live-approval.js +2 -2
- package/dist-server/shared/package-format.js +3 -3
- package/dist-server/shared/pairing-link.js +144 -0
- package/dist-server/shared/place-view.js +409 -0
- package/dist-server/shared/tool-scope-support.js +1 -2
- package/dist-server/vps-container-mcp.js +14 -27
- package/dist-server/workspace-backup.worker.js +14 -27
- package/package.json +1 -1
- package/dist/assets/channel-DS2NbT5x.js +0 -1
- package/dist/assets/index-NF7XCHAy.css +0 -1
- package/dist/assets/index-X_5ydXIE.js +0 -610
- package/dist/assets/swimlanesDiagram-TC7HE7FX-C6bZtBXk.js +0 -8
- package/dist-server/server/claim-idle.js +0 -33
- package/dist-server/server/cloud-overflow.js +0 -167
- package/dist-server/server/drivers/boatagent.js +0 -443
- package/dist-server/server/tightening-requests.js +0 -435
- package/dist-server/shared/tightening-request.js +0 -87
|
@@ -27,6 +27,9 @@ import { assertRequestTarget, guardedRequestPath, requestConflict, requestNeedsI
|
|
|
27
27
|
import { botAvatarUrlFromStoredPath } from "../shared/bot-avatar.js";
|
|
28
28
|
import { BOT_PROFILE_LIMITS } from "../shared/bot-profile.js";
|
|
29
29
|
import { CLOUD_COMPUTER_BUSY_ERROR } from "../shared/computer-contention.js";
|
|
30
|
+
import { failedTurnTool } from "../shared/failed-turn.js";
|
|
31
|
+
import { phonePairingLink } from "../shared/pairing-link.js";
|
|
32
|
+
import { canWorkOnCloud } from "../shared/cloud-computer.js";
|
|
30
33
|
import { approvalModeFor, modelSwitchNeedsAsk, isEmergencyApprovalDowngrade, isApprovalMode, } from "../shared/approval-mode.js";
|
|
31
34
|
import { createApprovalModeSupport } from "./harness-capabilities.js";
|
|
32
35
|
import { escapeAttribute } from "../src/lib/composer-attachments.js";
|
|
@@ -50,6 +53,8 @@ import { avatarGenerationRequestSchema, avatarGenerationStateMatches, generateAv
|
|
|
50
53
|
import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
|
|
51
54
|
import { groupTurnCwd } from "./room-cwd.js";
|
|
52
55
|
import { RoomTurnDeadline, RoomTurnStallRegistry, roomTurnTimeoutMessage } from "./room-turn-timeout.js";
|
|
56
|
+
import { roomTurnEnd } from "./room-turn-end.js";
|
|
57
|
+
import { pickComputer } from "./computer-selection.js";
|
|
53
58
|
import * as boat from "./boat.js";
|
|
54
59
|
import { cloudComputerRpc } from "./cloud-computer-tools.js";
|
|
55
60
|
import { TeamComputers, teamComputerAssignment, teamComputerCreate, teamComputerOwner } from "./team-computers.js";
|
|
@@ -58,7 +63,7 @@ import { isPersistentQuestionCard, QUESTION_DISMISS_MESSAGE, shouldSettleRequest
|
|
|
58
63
|
import { parseToolScope, toolScopeWidens } from "../shared/tool-scope.js";
|
|
59
64
|
import { boatCreateRecoverySnapshot, retireDeletedBoatCreate } from "./boat-create-idempotency.js";
|
|
60
65
|
import { boatDeletionSnapshot } from "./boat-delete-journal.js";
|
|
61
|
-
import { liveDecisionRefusal } from "../shared/live-approval.js";
|
|
66
|
+
import { liveCardKind, liveDecisionRefusal } from "../shared/live-approval.js";
|
|
62
67
|
import { boatAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourceChangeError, } from "./cloud-backend.js";
|
|
63
68
|
import * as composio from "./composio.js";
|
|
64
69
|
import { connectorCallFromFrame, connectorRefusalText, connectorUnrecognizedText, evaluateConnectorTools } from "./connector-verdict.js";
|
|
@@ -69,7 +74,7 @@ import { appendAboutMe, commitLearned, planLearned } from "./profile-learned.js"
|
|
|
69
74
|
import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, livePeerRosterBlock, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
|
|
70
75
|
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";
|
|
71
76
|
import { attachForTurn, saveBotAttachment } from "./bot-attachment.js";
|
|
72
|
-
import { cacheUntilConfigChanges, ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmIdleTimeoutMinutes, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled,
|
|
77
|
+
import { cacheUntilConfigChanges, ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmIdleTimeoutMinutes, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, 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, mergeOpenCodeProviderKeys, openCodeProviderKeys, } from "./config.js";
|
|
73
78
|
import { sweepThreadEventLogs } from "./thread-retention.js";
|
|
74
79
|
import { ComputerControl } from "./computer-control.js";
|
|
75
80
|
import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
|
|
@@ -85,11 +90,11 @@ import { entitled } from "./enterprise.js";
|
|
|
85
90
|
import { HOSTED_CONTRACT_HEADER, HOSTED_CONTRACT_METADATA, HOSTED_CONTRACT_VERSION } from "./hosted-contract.js";
|
|
86
91
|
import { describeSpawnFailure, execCli } from "./procs.js";
|
|
87
92
|
import { blockedTarget, buildNotification, buildSpendNotification } from "./notify.js";
|
|
88
|
-
import { isModelVariant, TurnNotStartedError, newId,
|
|
93
|
+
import { isModelVariant, TurnNotStartedError, newId, } from "./contracts.js";
|
|
89
94
|
import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.js";
|
|
90
95
|
import { recoveryCapabilityError } from "./automatic-recovery.js";
|
|
91
96
|
import { decodeGeneratedImage } from "./generated-image.js";
|
|
92
|
-
import { MAX_MCP_SERVERS, isRemoteMcpServer, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
|
|
97
|
+
import { addDisabledMcpServer, MAX_MCP_SERVERS, isRemoteMcpServer, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
|
|
93
98
|
import { McpOAuthError, McpSignInError, McpOAuthManager, mcpOAuthRedirectUri, withMcpSignIn, withoutPendingSignIn } from "./mcp-oauth.js";
|
|
94
99
|
import { probeMcpServer } from "./mcp-probe.js";
|
|
95
100
|
import { GROUP_GOAL_MAX_TURNS, groupGoalAssignmentKey, groupGoalCompletionTurnId, groupGoalCoordinatorInstructions, groupGoalWorkerInstructions, parseGroupGoalDecision, resolveGroupGoalMember, selectGroupGoalCoordinator, } from "./group-goal-run.js";
|
|
@@ -115,11 +120,13 @@ import { ProviderRegistry } from "./harness/registry.js";
|
|
|
115
120
|
import { ManagedDesktopProviders } from "./managed-desktop.js";
|
|
116
121
|
import { computerKindForResource, ManagedDesktopPolicy } from "./managed-policy.js";
|
|
117
122
|
import { hostedModelPolicy, HOSTED_MODEL_POLICY_HEADER, HOSTED_PROVIDER_SETTINGS_ERROR } from "./hosted-models.js";
|
|
118
|
-
import {
|
|
123
|
+
import { CLOUD_HOME_SECRET_KEYS, CLOUD_IGNORED_KEYS, CLOUD_PAIRING_PATH, cloudHomeConfiguration, cloudHomeOffersPlace, cloudHomePlaceRefusal, createCloudPairing, firstCloudTurnPatch, readSignedBody, } from "./cloud-home.js";
|
|
119
124
|
import { CLOUD_PERSONAL_REFUSAL, settleCloudOwnership } from "./cloud-owner.js";
|
|
120
|
-
import { createCloudMoveRoutes } from "./cloud-move-http.js";
|
|
125
|
+
import { createCloudMoveRoutes, workspaceShared } from "./cloud-move-http.js";
|
|
126
|
+
import { RESTART_EXIT_CODE } from "./restart.js";
|
|
121
127
|
import { holdIncludedServices } from "./included-services.js";
|
|
122
128
|
import { selectDefaultModelSelection, withNewBotEffort } from "./default-model-selection.js";
|
|
129
|
+
import { computerEngineMoveText, removedComputerInstanceIds, writeComputerEngineMoveLines } from "./computer-engine-removal.js";
|
|
123
130
|
import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, peerApprovalFailure, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
|
|
124
131
|
import { peerDeliveryReceipt } from "./peer-delivery.js";
|
|
125
132
|
import { peerProvenanceNote, withPeerProvenance } from "./peer-provenance.js";
|
|
@@ -132,13 +139,13 @@ import { createLiveSession, liveAttachUrl, LiveSessionError } from "./live-call.
|
|
|
132
139
|
import { LiveCallController, LiveCallSignedOutError } from "./live-call-controller.js";
|
|
133
140
|
import { narrateTool, toUtterances } from "./tts/speech-text.js";
|
|
134
141
|
import { turnStartLogLine } from "./turn-log.js";
|
|
142
|
+
import { makeCapContinuationSubscriber } from "./turn-continuation.js";
|
|
135
143
|
import { buildRecoveryText, buildTurnContext, engineIsFresh, NATIVELY_REPLAYING_DRIVER_KINDS, peerMessageText } from "./turn-context.js";
|
|
136
144
|
import { Handoffs, handedStateUsable, recordHanded, renderUnseen, sessionStart, unseenMessages, withUnseenMessages } from "./delta-context.js";
|
|
137
145
|
import { extractTurnImages } from "./turn-images.js";
|
|
138
146
|
import { threadTitlePrompt, titleConversationExcerpt } from "./thread-title.js";
|
|
139
147
|
import { TurnWatchdog } from "./turn-watchdog.js";
|
|
140
|
-
import { TurnResources
|
|
141
|
-
import { IdleReleasePolicy } from "./claim-idle.js";
|
|
148
|
+
import { TurnResources } from "./turn-resources.js";
|
|
142
149
|
import { ensureWorkspace, ensureTaskWorkspace, workspaceLocationsPrompt, supportsWorkspaceFiles, updateMemory, appendMemoryLog, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, TASK_WORKSPACES_DIR, readMemoryOnlyFromRegularFiles, workspaceDir, } from "./workspace.js";
|
|
143
150
|
import { listMemoryTopics, memoryDate, readMemoryFile, readMemoryTopic, writeMemoryFile, writeMemoryTopic } from "./workspace.js";
|
|
144
151
|
import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, journalMemoryWrite, } from "./memory-journal.js";
|
|
@@ -150,7 +157,7 @@ import { botSkillTemplateSchema, newBotDefaultsSchema, resolveBotCreationDefault
|
|
|
150
157
|
import { expandLearnTurnText, learnSource } from "./skill-learn.js";
|
|
151
158
|
import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
|
|
152
159
|
import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-folder.js";
|
|
153
|
-
import { buildSystemPrompt, userProfileSystemPrompt, cloudHomePrompt, computerPrompt, composioSystemPrompt, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, TEAM_MEMORY_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, resolveComputerPromptKind, } from "./system-prompt.js";
|
|
160
|
+
import { buildSystemPrompt, userProfileSystemPrompt, cloudHomePrompt, computerPrompt, composioSystemPrompt, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, TEAM_MEMORY_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, resolveComputerPromptKind, teamAvailabilityPart, } from "./system-prompt.js";
|
|
154
161
|
import { readCuaConnection, readCuaUnavailableReason, gatedLocalComputer } from "./local-computer.js";
|
|
155
162
|
import { discoverExistingPerBotLocalVms, discoverExistingPoolLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
|
|
156
163
|
import { localVmStopReason, recordLocalVmIdleStop } from "./local-vm-stop-reason.js";
|
|
@@ -165,7 +172,7 @@ import { CalendarCallManager } from "./calendar-calls.js";
|
|
|
165
172
|
import { BUILT_IN_BROWSER_SYSTEM_PROMPT } from "./browser-engine.js";
|
|
166
173
|
import { BrowserRuntime, browserRuntimeEnv } from "./browser-runtime.js";
|
|
167
174
|
import { BrowserLive } from "./browser-live.js";
|
|
168
|
-
import { agentBrowserFrame, agentBrowserIntegration, browserEngineEncryptionKey, prepareBrowserSessionState, clearBrowserSessionState, closeBrowserSession, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, browserEngineStatus, browserSessionId, describeBrowserEngine, } from "./browser-engine.js";
|
|
175
|
+
import { agentBrowserFrame, agentBrowserIntegration, browserEngineEncryptionKey, prepareBrowserSessionState, clearBrowserSessionState, closeBrowserSession, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, browserEngineStatus, browserSessionId, describeBrowserEngine, setBrowserViewport, } from "./browser-engine.js";
|
|
169
176
|
import { createScreenFrameSource } from "./screen-frame-source.js";
|
|
170
177
|
import { screenFrameHash, screenSurfaceForTool, screenTouchingTool, settledFrameIsNews } from "./screen-frame-gate.js";
|
|
171
178
|
import { asSchedule, RoutineRequestService } from "./routine-requests.js";
|
|
@@ -173,7 +180,6 @@ import { createOptionsCard } from "./options-card.js";
|
|
|
173
180
|
import { buildBotOverview, connectedAppsFacts } from "./bot-overview.js";
|
|
174
181
|
import { ProfileRequestService } from "./profile-requests.js";
|
|
175
182
|
import { ModelRequestService } from "./model-requests.js";
|
|
176
|
-
import { TighteningRequestService } from "./tightening-requests.js";
|
|
177
183
|
import { TeamSetupError, TeamSetupRequestService } from "./team-setup-requests.js";
|
|
178
184
|
import { profileRevision, profileSnapshot } from "./profile-revision.js";
|
|
179
185
|
import { flushAllProfileHistory, flushProfileHistory, readHistory, recordProfileChange } from "./profile-versions.js";
|
|
@@ -206,11 +212,11 @@ import { createTeamBackup, importTeamBackup } from "./team-backup.js";
|
|
|
206
212
|
import { MAX_TEAM_BACKUP_BYTES } from "../shared/team-backup.js";
|
|
207
213
|
import { shouldMountLocalComputer } from "./local-routing.js";
|
|
208
214
|
import { autoLocalVmAttachable } from "./container-computer.js";
|
|
209
|
-
import { startAutoVmClaim } from "./auto-vm-claims.js";
|
|
215
|
+
import { lazyClaimWaitMs, startAutoVmClaim } from "./auto-vm-claims.js";
|
|
210
216
|
import { computerFreeAfterText, computerParkedText, computerStoppedWaitingText, computerWaitingText } from "./computer-wait.js";
|
|
211
|
-
import { CloudOverflowConsent, CloudSeatLease, cloudOverflowAction, cloudOverflowConsentText, cloudOverflowOfferText, cloudSeatStartedText, cloudSeatStoppedText } from "./cloud-overflow.js";
|
|
212
217
|
import { modelContextWindow } from "./model-context-window.js";
|
|
213
|
-
import {
|
|
218
|
+
import { cloudPlaceRefusal, computerToolsRefusal, parseSurface, PlaceUnavailableError, placeUnavailable, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
|
|
219
|
+
import { cloudRefusal } from "../shared/place-view.js";
|
|
214
220
|
import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
|
|
215
221
|
import { createGracefulShutdown } from "./graceful-shutdown.js";
|
|
216
222
|
import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
|
|
@@ -243,6 +249,7 @@ import { localDesktopTarget, localVmViewerStatus, viewerTargetId } from "./deskt
|
|
|
243
249
|
import { createAntigravityLeftoverRoutes } from "./routes/antigravity-leftovers.js";
|
|
244
250
|
import { findAntigravityLeftovers, removeAntigravityLeftovers } from "./drivers/antigravity-temp.js";
|
|
245
251
|
import { createLiveRoutes } from "./routes/live.js";
|
|
252
|
+
import { withScopeHint } from "./connector-scope-hint.js";
|
|
246
253
|
const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799);
|
|
247
254
|
const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1);
|
|
248
255
|
// Behind a proxy or tunnel, the base URL senders should use (docs/self-hosting.md).
|
|
@@ -557,7 +564,7 @@ function cloudLendingTurn(capability) {
|
|
|
557
564
|
generation: capability.generation,
|
|
558
565
|
thread: store.messagesFor(capability.threadId),
|
|
559
566
|
starters: cloudThreadStarters(capability.threadId),
|
|
560
|
-
reportsFromOthers: store.messagesFor(capability.threadId).some(
|
|
567
|
+
reportsFromOthers: store.messagesFor(capability.threadId).some(cloudReportFromOthers),
|
|
561
568
|
ownerPerson: cloudProvenOwnerPerson,
|
|
562
569
|
cardAnswerer: cloudCardAnswerer,
|
|
563
570
|
routineRun: () => {
|
|
@@ -600,11 +607,13 @@ function cloudThreadStarters(threadId) {
|
|
|
600
607
|
function cloudLendingView(capability) {
|
|
601
608
|
return CLOUD_HOME !== null && cloudHomeLendingRefusal(cloudLendingTurn(capability)) === null;
|
|
602
609
|
}
|
|
603
|
-
/** On a Cloud home: a
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
function
|
|
610
|
+
/** On a Cloud home: a line that brings someone else's words into the
|
|
611
|
+
* conversation it lands in: a "post" webhook's payload, or a routine's
|
|
612
|
+
* report (its lifecycle card, carrying what the run said) of a routine the
|
|
613
|
+
* owner did not write, as it stands now. */
|
|
614
|
+
function cloudReportFromOthers(line) {
|
|
615
|
+
if (line.webhookPost)
|
|
616
|
+
return true;
|
|
608
617
|
if (line.kind !== "routine.run" || !line.routineRun)
|
|
609
618
|
return false;
|
|
610
619
|
const routine = routines?.listRoutines().find((candidate) => candidate.id === line.routineRun.routineId);
|
|
@@ -626,7 +635,7 @@ function cloudOwnerOnlyThread(threadId) {
|
|
|
626
635
|
const lastKey = last ? `${last.id}:${last.card?.answeredBy ? JSON.stringify(last.card.answeredBy) : ""}` : undefined;
|
|
627
636
|
if (cached && cached.length === thread.length && cached.last === lastKey)
|
|
628
637
|
return cached.owner;
|
|
629
|
-
const owner = ownerOnlyConversation(thread, cloudProvenOwnerPerson, cloudCardAnswerer) && !thread.some(
|
|
638
|
+
const owner = ownerOnlyConversation(thread, cloudProvenOwnerPerson, cloudCardAnswerer) && !thread.some(cloudReportFromOthers);
|
|
630
639
|
if (ownerOnlyCache.size > 5_000)
|
|
631
640
|
ownerOnlyCache.clear();
|
|
632
641
|
ownerOnlyCache.set(threadId, { length: thread.length, last: lastKey, owner });
|
|
@@ -862,7 +871,7 @@ function guestWorkThread(from, target, fromThreadId, brief) {
|
|
|
862
871
|
* to have written, or a conversation or room a revoked session opened. What
|
|
863
872
|
* the owner is told about one, and when its engine cannot be confined. */
|
|
864
873
|
const ROUTINE_FROM_BEFORE = "This routine was made before this update. Open it and save it once to run it with full access.";
|
|
865
|
-
const CONVERSATION_FROM_BEFORE = "This conversation is from before
|
|
874
|
+
const CONVERSATION_FROM_BEFORE = "This conversation is from before My Cloud was only yours, so this bot works in it without a shell. Start a new conversation to use it fully.";
|
|
866
875
|
function confinedWhy(threadId) {
|
|
867
876
|
const run = activeRoutineRunForThread(threadId);
|
|
868
877
|
const writer = run ? cloudRoutineAuthors?.writer(run.routineId) : undefined;
|
|
@@ -871,7 +880,7 @@ function confinedWhy(threadId) {
|
|
|
871
880
|
function guestEngineRefusal(threadId) {
|
|
872
881
|
const why = confinedWhy(threadId);
|
|
873
882
|
return why === ROUTINE_FROM_BEFORE ? `This bot's engine can't run it that way. ${why}`
|
|
874
|
-
: "This conversation is from before
|
|
883
|
+
: "This conversation is from before My Cloud was only yours, and this bot's engine can't work in it. Start a new conversation.";
|
|
875
884
|
}
|
|
876
885
|
/** On a Cloud home: a turn a guest drives, which runs in Ask whatever the
|
|
877
886
|
* bot's own level (no Full access, no Auto reviewer, no saved commands), with
|
|
@@ -1247,11 +1256,11 @@ function memberFrameContext(visible) {
|
|
|
1247
1256
|
webhookBot: (webhookId) => webhooks.list().find((webhook) => webhook.id === webhookId)?.botId,
|
|
1248
1257
|
freshBot: (botId) => {
|
|
1249
1258
|
const bot = store.bot(botId);
|
|
1250
|
-
return bot ?
|
|
1259
|
+
return bot ? publicBot(bot) : undefined;
|
|
1251
1260
|
},
|
|
1252
1261
|
freshGroup: (groupId) => {
|
|
1253
1262
|
const group = store.group(groupId);
|
|
1254
|
-
return group ?
|
|
1263
|
+
return group ? groupWithThread(group) : undefined;
|
|
1255
1264
|
},
|
|
1256
1265
|
};
|
|
1257
1266
|
}
|
|
@@ -1669,8 +1678,6 @@ function computerPlaceRefusal(kind) {
|
|
|
1669
1678
|
const managed = managedPolicy.computerRefusal(kind);
|
|
1670
1679
|
return managed ? { message: managed, code: "managed_policy" } : undefined;
|
|
1671
1680
|
}
|
|
1672
|
-
/** Cloud chosen with no Boat account: a Cloud home suggests the browser, not a Local VM. */
|
|
1673
|
-
const BOAT_NOT_CONFIGURED = boatNotConfiguredMessage(Boolean(CLOUD_HOME));
|
|
1674
1681
|
/** Why the organisation refuses this instance for bots, or undefined. */
|
|
1675
1682
|
function policyModelRefusal(instance) {
|
|
1676
1683
|
const engine = BUILT_IN_DRIVERS.find(driver => driver.driverKind === instance.driverKind)?.metadata.displayName;
|
|
@@ -1754,6 +1761,8 @@ utilityParentPort?.on("message", event => {
|
|
|
1754
1761
|
});
|
|
1755
1762
|
});
|
|
1756
1763
|
const MAX_ATTACHED_FILES_PER_TURN = 10;
|
|
1764
|
+
const MAX_ROOM_POSTS_PER_TURN = 3;
|
|
1765
|
+
const MAX_CREATED_BOTS_PER_TURN = 4;
|
|
1757
1766
|
// A capability lives for the exact provider-turn generation, including while
|
|
1758
1767
|
// that turn is parked on a human approval. The long ceiling is only an orphan
|
|
1759
1768
|
// backstop for an impossible-to-settle adapter; normal terminal paths revoke
|
|
@@ -1784,6 +1793,11 @@ function externalRuntimeMayCall(method, path) {
|
|
|
1784
1793
|
return path === "/api/internal/agents" || /^\/api\/internal\/delegations\/[\w-]+$/.test(path);
|
|
1785
1794
|
return method === "POST" && (path === "/api/internal/ask-bot" || path === "/api/internal/delegate-bot");
|
|
1786
1795
|
}
|
|
1796
|
+
/** An external runtime polls check/wait_delegation for what it hands off, so
|
|
1797
|
+
* the result must not also wake the bot's own engine on the pinned thread. */
|
|
1798
|
+
function externalCompletion(capability) {
|
|
1799
|
+
return capability.generation === EXTERNAL_RUNTIME_GENERATION ? { completionOwner: "external" } : {};
|
|
1800
|
+
}
|
|
1787
1801
|
function externalRuntimeCapability(header) {
|
|
1788
1802
|
const grant = authorizeExternalRuntime(EXTERNAL_RUNTIMES_FILE, header, id => store.bot(id));
|
|
1789
1803
|
if (!grant)
|
|
@@ -1810,15 +1824,52 @@ function beginInternalCapabilityGeneration(threadId, generation = randomUUID())
|
|
|
1810
1824
|
activeInternalGenerationByThread.set(threadId, generation);
|
|
1811
1825
|
return generation;
|
|
1812
1826
|
}
|
|
1813
|
-
|
|
1827
|
+
// The bearer an integration's proxy holds is part of the engine's launch
|
|
1828
|
+
// contract (Claude's process key, ACP's session inputs): a new one every turn
|
|
1829
|
+
// meant a relaunch, a resumed transcript and new MCP servers every turn. So
|
|
1830
|
+
// the bearer stays the same while the same bot works the same thread with the
|
|
1831
|
+
// same grants and approval level, and only the record behind it is per turn:
|
|
1832
|
+
// between turns, and after any revoke, nothing honours it. A changed grant,
|
|
1833
|
+
// Stop, a stall, a deleted bot or thread or a provider reload gives the next
|
|
1834
|
+
// turn a new bearer, so whatever still holds the old one is never honoured
|
|
1835
|
+
// again.
|
|
1836
|
+
/** threadId → "botId kind" → the bearer and the grants it was minted for. */
|
|
1837
|
+
const sessionCredentials = new Map();
|
|
1838
|
+
/** What a bearer stands for: everything its record grants (a turn's counters
|
|
1839
|
+
* all start at zero), plus the thread's approval level; not the turn. */
|
|
1840
|
+
function sessionGrants({ generation: _turn, ...grants }) {
|
|
1841
|
+
const thread = botForThread(grants.botId, grants.threadId);
|
|
1842
|
+
return JSON.stringify({ ...grants, approval: thread ? approvalModeFor(thread) : null });
|
|
1843
|
+
}
|
|
1844
|
+
/** The thread's bearer for this integration, new only when its grants changed. */
|
|
1845
|
+
function sessionBearer(capability) {
|
|
1846
|
+
const credentials = sessionCredentials.get(capability.threadId) ?? new Map();
|
|
1847
|
+
sessionCredentials.set(capability.threadId, credentials);
|
|
1848
|
+
const slot = `${capability.botId} ${capability.kind}`;
|
|
1849
|
+
const grants = sessionGrants(capability);
|
|
1850
|
+
const held = credentials.get(slot);
|
|
1851
|
+
if (held?.grants === grants)
|
|
1852
|
+
return held.token;
|
|
1853
|
+
const token = randomBytes(24).toString("hex");
|
|
1854
|
+
credentials.set(slot, { grants, token });
|
|
1855
|
+
return token;
|
|
1856
|
+
}
|
|
1857
|
+
function mintInternalCapability(capability, { perTurn = false } = {}) {
|
|
1814
1858
|
if (activeInternalGenerationByThread.get(capability.threadId) !== capability.generation) {
|
|
1815
1859
|
throw new Error("cannot mint an integration capability for an inactive turn");
|
|
1816
1860
|
}
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1861
|
+
// perTurn: a bridge into a machine the harness stops and starts between
|
|
1862
|
+
// turns (a Local VM, the VPS, a Boat). A warm engine never restarts a
|
|
1863
|
+
// server whose machine went away, so such a turn gets a new bearer and
|
|
1864
|
+
// starts its engine fresh.
|
|
1865
|
+
const token = perTurn ? randomBytes(24).toString("hex") : sessionBearer(capability);
|
|
1866
|
+
// Minted twice in one turn: keep that turn's record and its counters.
|
|
1867
|
+
if (internalCapabilities.get(token)?.generation !== capability.generation) {
|
|
1868
|
+
internalCapabilities.set(token, {
|
|
1869
|
+
...capability,
|
|
1870
|
+
orphanExpiresAt: Date.now() + INTERNAL_CAPABILITY_ORPHAN_MS,
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1822
1873
|
return token;
|
|
1823
1874
|
}
|
|
1824
1875
|
/** A turn that was not provably the owner's has ended: judge its bot's
|
|
@@ -1852,7 +1903,9 @@ function revokeInternalCapabilityGeneration(threadId, generation) {
|
|
|
1852
1903
|
}
|
|
1853
1904
|
internalGenerationByProviderTurn.deleteGeneration(threadId, generation);
|
|
1854
1905
|
}
|
|
1855
|
-
|
|
1906
|
+
/** Before a new turn on this thread mints: nothing an earlier turn here was
|
|
1907
|
+
* given stays honoured. The thread's bearers stay, so its engine stays warm. */
|
|
1908
|
+
function revokeEarlierTurnCapabilities(threadId) {
|
|
1856
1909
|
computerSelectionTurns.delete(threadId);
|
|
1857
1910
|
const generation = activeInternalGenerationByThread.get(threadId);
|
|
1858
1911
|
if (generation)
|
|
@@ -1865,9 +1918,16 @@ function revokeInternalCapabilitiesForThread(threadId) {
|
|
|
1865
1918
|
internalCapabilities.delete(token);
|
|
1866
1919
|
}
|
|
1867
1920
|
}
|
|
1921
|
+
/** Stop, a stall, a deleted bot or thread, a provider change: the thread's
|
|
1922
|
+
* bearers are retired too, so nothing that still holds one is honoured again. */
|
|
1923
|
+
function revokeInternalCapabilitiesForThread(threadId) {
|
|
1924
|
+
revokeEarlierTurnCapabilities(threadId);
|
|
1925
|
+
sessionCredentials.delete(threadId);
|
|
1926
|
+
}
|
|
1868
1927
|
function revokeAllInternalCapabilities() {
|
|
1869
1928
|
computerSelectionTurns.clear();
|
|
1870
1929
|
internalCapabilities.clear();
|
|
1930
|
+
sessionCredentials.clear();
|
|
1871
1931
|
// foreignTurns stays: a turn that is not provably the owner's can still
|
|
1872
1932
|
// write while its engine is torn down, and its own end (which revokes its
|
|
1873
1933
|
// generation by id) is what judges it (server/lending-memory.ts).
|
|
@@ -2031,6 +2091,64 @@ class ComputerWaitParked extends Error {
|
|
|
2031
2091
|
this.generation = generation;
|
|
2032
2092
|
}
|
|
2033
2093
|
}
|
|
2094
|
+
/** A failed place never sticks: an Auto-recorded pin to it (a
|
|
2095
|
+
* select_computer switch, or where an earlier Auto turn landed) is the
|
|
2096
|
+
* machine's memory, not a choice, so the next message runs on Auto. A
|
|
2097
|
+
* person's pin stays. */
|
|
2098
|
+
function clearFailedAutoPin(botId, threadId, place) {
|
|
2099
|
+
const pinned = store.taskByThread(botId, threadId);
|
|
2100
|
+
if (pinned?.surface === place && pinned.surfaceSource === "auto") {
|
|
2101
|
+
store.patchTask(botId, threadId, { surface: undefined, surfaceSource: undefined });
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
/** A place the turn was told to use failed to attach, read as one state of
|
|
2105
|
+
* shared/place-view.ts: a cloud computer's refusal by its code or words, any
|
|
2106
|
+
* other place by its own words. Its source decides the one way on. A
|
|
2107
|
+
* cancelled setup, a parked computer wait and an already-read place failure
|
|
2108
|
+
* pass through as they are. The one wrapper for a bot thread and a room
|
|
2109
|
+
* turn, at dispatch and at a cloud computer's start on its first call. */
|
|
2110
|
+
async function asPlaceFailure(error, place, source, bot, backend = "box") {
|
|
2111
|
+
if (error instanceof DirectTurnSetupCancelled || error instanceof ComputerWaitParked || error instanceof PlaceUnavailableError)
|
|
2112
|
+
return error;
|
|
2113
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
2114
|
+
const cause = failure.message.trim().replace(/[\s.]+$/, "");
|
|
2115
|
+
if (place !== "cloud" || backend === "vps") {
|
|
2116
|
+
return placeUnavailable(place, {
|
|
2117
|
+
state: "place-failed", params: { bot: bot.name, cause: `${cause.charAt(0).toUpperCase()}${cause.slice(1)}.` }, source,
|
|
2118
|
+
}, Boolean(CLOUD_HOME));
|
|
2119
|
+
}
|
|
2120
|
+
// A Boat refusal keeps the provider's own status and code (boat.ts
|
|
2121
|
+
// boatRefusal); a failed read of the account keeps its status.
|
|
2122
|
+
const { boatCode, boatStatus, status } = failure;
|
|
2123
|
+
const providerStatus = typeof boatStatus === "number" ? boatStatus : typeof status === "number" ? status : undefined;
|
|
2124
|
+
const read = cloudRefusal({
|
|
2125
|
+
message: failure.message,
|
|
2126
|
+
...(typeof boatCode === "string" ? { code: boatCode } : {}),
|
|
2127
|
+
...(providerStatus !== undefined ? { status: providerStatus } : {}),
|
|
2128
|
+
}, boat.boatAccount(cfg)?.included === true);
|
|
2129
|
+
const row = { state: read.state, params: { bot: bot.name, ...read.params }, source };
|
|
2130
|
+
if (row.state === "cc-at-once")
|
|
2131
|
+
row.params.holders = await cloudComputerHolders(bot.id);
|
|
2132
|
+
return placeUnavailable(place, row, Boolean(CLOUD_HOME));
|
|
2133
|
+
}
|
|
2134
|
+
/** The bots that hold this account's cloud computers, other than `botId`,
|
|
2135
|
+
* from this installation's own Boat list. Empty when it can't be read. */
|
|
2136
|
+
async function cloudComputerHolders(botId) {
|
|
2137
|
+
try {
|
|
2138
|
+
const listed = await boat.listManagedBoats(cfg, managedBoatOwners(), { adoptLegacy: false });
|
|
2139
|
+
return [...new Set(listed.instances
|
|
2140
|
+
.filter((instance) => instance.ownerBotId && instance.ownerBotId !== botId && instance.ownerName)
|
|
2141
|
+
.map((instance) => instance.ownerName))];
|
|
2142
|
+
}
|
|
2143
|
+
catch {
|
|
2144
|
+
return [];
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
/** Cloud chosen with no cloud computers here: on a Cloud home they are the
|
|
2148
|
+
* plan's, so they are only unavailable; anywhere else they need a Boat key. */
|
|
2149
|
+
function boatNotConfigured(bot, source) {
|
|
2150
|
+
return placeUnavailable("cloud", { state: CLOUD_HOME ? "cc-unavailable" : "cc-needs-key", params: { bot: bot.name }, source }, Boolean(CLOUD_HOME));
|
|
2151
|
+
}
|
|
2034
2152
|
const directTurnDispatchClaims = new Map();
|
|
2035
2153
|
const directTurnGenerationByThread = new Map();
|
|
2036
2154
|
// Only the latest direct request per thread is retained. A fresh user turn
|
|
@@ -2073,173 +2191,14 @@ const turnResources = new TurnResources();
|
|
|
2073
2191
|
const sharedComputerControl = new SharedComputerControl(turnResources, () => store.bots.some(bot => botComputerControlSnapshot(bot.id).held));
|
|
2074
2192
|
const turnResourceOwners = new Map();
|
|
2075
2193
|
const turnComputerResources = new Map();
|
|
2076
|
-
/** Screen-touching computer calls that started before their turn's computer
|
|
2077
|
-
* claim was bound (#1653 over the lazy attach of #1361): the start event
|
|
2078
|
-
* finds no claim to fence, so it waits here and bindTurnComputer carries it
|
|
2079
|
-
* into the fresh claim, keeping the quiet window from releasing a seat
|
|
2080
|
-
* under a call the desktop is already executing. */
|
|
2081
|
-
const pendingComputerCallStarts = new Map();
|
|
2082
2194
|
const teamComputerTurns = new Map();
|
|
2083
2195
|
const settlingResourceOwners = new Map();
|
|
2084
|
-
/** Idle release for computer claims (#1653), disabled until the feature
|
|
2085
|
-
* flag is on: a seat that stays screen-quiet for the policy's window is
|
|
2086
|
-
* released to waiting turns while its holder's turn still lives, and the
|
|
2087
|
-
* previous holder re-claims directly inside the reclaim window — it
|
|
2088
|
-
* yields once another turn holds the seat. The durable VM profile keeps
|
|
2089
|
-
* its login state, so that re-claim is cheap. Read per claim: a flag
|
|
2090
|
-
* change applies to new claims, never to a seat held under the old
|
|
2091
|
-
* setting. computer:phone is excluded: it is the #1663 exclusivity lock,
|
|
2092
|
-
* claimed once per turn with no activity signal to renew it. */
|
|
2093
|
-
const COMPUTER_CLAIM_IDLE_POLICY = new IdleReleasePolicy();
|
|
2094
|
-
function computerClaimIdlePolicy(resource) {
|
|
2095
|
-
return resource.startsWith("computer:") && resource !== "computer:phone" && computerClaimIdleReleaseEnabled(cfg) ? COMPUTER_CLAIM_IDLE_POLICY : undefined;
|
|
2096
|
-
}
|
|
2097
|
-
/** #1655: the consent ledger for local-wait cloud overflow, and the live
|
|
2098
|
-
* cloud seats it has started. One seat per bot — it is the bot's own Box,
|
|
2099
|
-
* shared by that bot's threads the way every other computer is. */
|
|
2100
|
-
const cloudOverflowConsent = new CloudOverflowConsent();
|
|
2101
|
-
const cloudSeatLeases = new Map();
|
|
2102
|
-
let cloudSeatSweepTimer;
|
|
2103
|
-
/** One stop attempt per seat at a time (#1655): the sweep ticks faster
|
|
2104
|
-
* than a Box sleeps, and a second stop call would race the first. */
|
|
2105
|
-
const cloudSeatStopsInFlight = new Set();
|
|
2106
|
-
/** The waits that may overflow: a turn queued for a local desktop, never
|
|
2107
|
-
* one already bound for a cloud or VPS seat. */
|
|
2108
|
-
function localComputerWait(resource) {
|
|
2109
|
-
return resource === "computer:host" || resource.startsWith("computer:vm:");
|
|
2110
|
-
}
|
|
2111
|
-
function cloudOverflowSituation(owner, resource, started) {
|
|
2112
|
-
if (!localComputerWait(resource))
|
|
2113
|
-
return null;
|
|
2114
|
-
if (!store.botByThread(owner.threadId))
|
|
2115
|
-
return null;
|
|
2116
|
-
const perSecondCostUsd = cloudOverflowPerSecondCostUsd(cfg);
|
|
2117
|
-
return {
|
|
2118
|
-
featureEnabled: cloudOverflowEnabled(cfg),
|
|
2119
|
-
// An organisation that refuses the Box kind has nothing to offer, and
|
|
2120
|
-
// the decision must fail closed before any card is written.
|
|
2121
|
-
cloudConfigured: boat.boatConfigured(cfg) && managedPolicy.computerRefusal("box") === undefined,
|
|
2122
|
-
perSecondCostUsd,
|
|
2123
|
-
// Consent answers the priced card (#1655): a grant counts only at the
|
|
2124
|
-
// rate its card showed, so a config change re-offers instead of
|
|
2125
|
-
// starting at a price nobody approved.
|
|
2126
|
-
consented: cloudOverflowConsent.consented(owner.threadId, cloudOverflowAllowlistedThreads(cfg), perSecondCostUsd ?? undefined),
|
|
2127
|
-
offered: cloudOverflowConsent.offered(owner.threadId, perSecondCostUsd ?? undefined),
|
|
2128
|
-
started,
|
|
2129
|
-
};
|
|
2130
|
-
}
|
|
2131
2196
|
function claimTurnResource(owner, resource) {
|
|
2132
|
-
|
|
2133
|
-
if (!turnResources.claim(resource, owner, idle ? { idle } : {}))
|
|
2197
|
+
if (!turnResources.claim(resource, owner))
|
|
2134
2198
|
return false;
|
|
2135
2199
|
turnResourceOwners.set(owner.threadId, owner);
|
|
2136
2200
|
return true;
|
|
2137
2201
|
}
|
|
2138
|
-
/** Start the waiting bot's cloud seat (#1655). True once handled — started
|
|
2139
|
-
* or terminally failed — so the wait never retries every poll; false when
|
|
2140
|
-
* another turn of this bot owns the Box lifecycle and a later poll may
|
|
2141
|
-
* still land. */
|
|
2142
|
-
async function startCloudSeat(owner) {
|
|
2143
|
-
const bot = store.botByThread(owner.threadId);
|
|
2144
|
-
if (!bot)
|
|
2145
|
-
return true;
|
|
2146
|
-
const perSecondCostUsd = cloudOverflowPerSecondCostUsd(cfg);
|
|
2147
|
-
if (!perSecondCostUsd)
|
|
2148
|
-
return true;
|
|
2149
|
-
const boxBotResource = "computer:box-bot:" + bot.id;
|
|
2150
|
-
if (!claimTurnResource(owner, boxBotResource))
|
|
2151
|
-
return false;
|
|
2152
|
-
try {
|
|
2153
|
-
broadcast({ kind: "computer", botId: bot.id, state: "waking" });
|
|
2154
|
-
const machine = await boat.readyBoat(cfg, bot.id);
|
|
2155
|
-
if (!machine)
|
|
2156
|
-
throw new Error("the cloud computer did not wake");
|
|
2157
|
-
// Consent can be revoked while the Box wakes (#1655): a seat nobody
|
|
2158
|
-
// consented to must not bill. It is released and put straight back to
|
|
2159
|
-
// sleep — and if even that fails, leased as already idle so the sweep
|
|
2160
|
-
// keeps retrying with backoff.
|
|
2161
|
-
if (!cloudOverflowConsent.consented(owner.threadId, cloudOverflowAllowlistedThreads(cfg), perSecondCostUsd)) {
|
|
2162
|
-
turnResources.releaseOne(boxBotResource, owner);
|
|
2163
|
-
const idleStopMs = cloudOverflowIdleStopMs(cfg);
|
|
2164
|
-
void boat.sleepBoat(cfg, bot.id).catch(() => {
|
|
2165
|
-
cloudSeatLeases.set(bot.id, new CloudSeatLease({ botId: bot.id, threadId: owner.threadId, generation: owner.generation, now: Date.now() - idleStopMs - 1, idleStopMs }));
|
|
2166
|
-
ensureCloudSeatSweep();
|
|
2167
|
-
});
|
|
2168
|
-
store.appendMessage(owner.threadId, {
|
|
2169
|
-
role: "bot", kind: "activity",
|
|
2170
|
-
tool: { name: "Cloud overflow consent was revoked while the cloud computer woke; it stops unbilled and the local wait continues.", ok: true },
|
|
2171
|
-
});
|
|
2172
|
-
return true;
|
|
2173
|
-
}
|
|
2174
|
-
cloudSeatLeases.set(bot.id, new CloudSeatLease({ botId: bot.id, threadId: owner.threadId, generation: owner.generation, idleStopMs: cloudOverflowIdleStopMs(cfg) }));
|
|
2175
|
-
ensureCloudSeatSweep();
|
|
2176
|
-
store.appendMessage(owner.threadId, {
|
|
2177
|
-
role: "bot", kind: "activity",
|
|
2178
|
-
tool: { name: cloudSeatStartedText({ perSecondCostUsd, idleStopMs: cloudOverflowIdleStopMs(cfg) }), ok: true },
|
|
2179
|
-
});
|
|
2180
|
-
return true;
|
|
2181
|
-
}
|
|
2182
|
-
catch (error) {
|
|
2183
|
-
turnResources.releaseOne(boxBotResource, owner);
|
|
2184
|
-
store.appendMessage(owner.threadId, {
|
|
2185
|
-
role: "bot", kind: "activity",
|
|
2186
|
-
tool: { name: "Cloud computer could not start (" + (error instanceof Error ? error.message : String(error)) + "); the local wait continues.", ok: false },
|
|
2187
|
-
});
|
|
2188
|
-
return true;
|
|
2189
|
-
}
|
|
2190
|
-
}
|
|
2191
|
-
/** A per-second-billed seat must stop on idleness even when nothing else
|
|
2192
|
-
* touches the server, so this slice carries the one timer — unref'd, it
|
|
2193
|
-
* can never hold the process open, and it dies with the last lease. */
|
|
2194
|
-
function ensureCloudSeatSweep() {
|
|
2195
|
-
if (cloudSeatSweepTimer)
|
|
2196
|
-
return;
|
|
2197
|
-
cloudSeatSweepTimer = setInterval(() => {
|
|
2198
|
-
sweepIdleCloudSeats();
|
|
2199
|
-
if (!cloudSeatLeases.size && cloudSeatSweepTimer) {
|
|
2200
|
-
clearInterval(cloudSeatSweepTimer);
|
|
2201
|
-
cloudSeatSweepTimer = undefined;
|
|
2202
|
-
}
|
|
2203
|
-
}, 15_000);
|
|
2204
|
-
cloudSeatSweepTimer.unref?.();
|
|
2205
|
-
}
|
|
2206
|
-
/** Stop every cloud seat idle past its window (#1655): the machine is
|
|
2207
|
-
* archived, billing pauses, and the transcript says which wait's seat it
|
|
2208
|
-
* was. Only real computer tool completions refresh a lease — the poller's
|
|
2209
|
-
* preview frames never reach the touch call. */
|
|
2210
|
-
/** Stop one running cloud seat (#1655): the Box sleeps, and only a
|
|
2211
|
-
* successful stop clears the lease and its Box claim — a failed stop
|
|
2212
|
-
* stays leased and retryable with backoff, because the seat still bills
|
|
2213
|
-
* until it sleeps. The claim releases for the lease's own generation,
|
|
2214
|
-
* never a newer turn's. */
|
|
2215
|
-
function stopCloudSeat(botId, lease, stoppedName, now = Date.now()) {
|
|
2216
|
-
if (cloudSeatStopsInFlight.has(botId))
|
|
2217
|
-
return;
|
|
2218
|
-
if (cloudSeatLeases.get(botId) !== lease)
|
|
2219
|
-
return;
|
|
2220
|
-
cloudSeatStopsInFlight.add(botId);
|
|
2221
|
-
void boat.sleepBoat(cfg, botId)
|
|
2222
|
-
.then(() => {
|
|
2223
|
-
if (cloudSeatLeases.get(botId) === lease)
|
|
2224
|
-
cloudSeatLeases.delete(botId);
|
|
2225
|
-
// The stopped seat's Box claim goes with it, so a still-waiting
|
|
2226
|
-
// turn may start a fresh seat for the same bot.
|
|
2227
|
-
turnResources.releaseOne(`computer:box-bot:${botId}`, { threadId: lease.threadId, generation: lease.generation });
|
|
2228
|
-
store.appendMessage(lease.threadId, { role: "bot", kind: "activity", tool: { name: stoppedName, ok: true } });
|
|
2229
|
-
})
|
|
2230
|
-
.catch(() => {
|
|
2231
|
-
lease.deferSleep(now);
|
|
2232
|
-
store.appendMessage(lease.threadId, { role: "bot", kind: "activity", tool: { name: "Cloud computer could not stop — the sweep retries with backoff; stop it from Settings if it stays awake. Billing continues until it sleeps.", ok: false } });
|
|
2233
|
-
})
|
|
2234
|
-
.finally(() => cloudSeatStopsInFlight.delete(botId));
|
|
2235
|
-
}
|
|
2236
|
-
function sweepIdleCloudSeats(now = Date.now()) {
|
|
2237
|
-
for (const [botId, lease] of cloudSeatLeases) {
|
|
2238
|
-
if (!lease.idleElapsed(now) || !lease.sleepDue(now))
|
|
2239
|
-
continue;
|
|
2240
|
-
stopCloudSeat(botId, lease, cloudSeatStoppedText(lease.idleFor(now)), now);
|
|
2241
|
-
}
|
|
2242
|
-
}
|
|
2243
2202
|
function releaseTurnResources(owner) {
|
|
2244
2203
|
if (!owner)
|
|
2245
2204
|
return;
|
|
@@ -2252,8 +2211,6 @@ function releaseTurnResources(owner) {
|
|
|
2252
2211
|
turnResourceOwners.delete(owner.threadId);
|
|
2253
2212
|
if (turnComputerResources.get(owner.threadId)?.owner.generation === owner.generation)
|
|
2254
2213
|
turnComputerResources.delete(owner.threadId);
|
|
2255
|
-
if (pendingComputerCallStarts.get(owner.threadId)?.generation === owner.generation)
|
|
2256
|
-
pendingComputerCallStarts.delete(owner.threadId);
|
|
2257
2214
|
const teamTurn = teamComputerTurns.get(owner.threadId);
|
|
2258
2215
|
if (teamTurn?.owner.generation === owner.generation) {
|
|
2259
2216
|
stopScreenPoller(teamTurn.botId, owner.threadId);
|
|
@@ -2283,10 +2240,6 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
|
2283
2240
|
// turn.wait_* events carry the same facts for the inspector log.
|
|
2284
2241
|
let waitedSince;
|
|
2285
2242
|
let waitEnded = false;
|
|
2286
|
-
// #1655: whether this wait already started (or terminally failed to
|
|
2287
|
-
// start) a cloud seat; the decision is re-evaluated every poll because
|
|
2288
|
-
// consent can arrive mid-wait.
|
|
2289
|
-
let overflowHandled = false;
|
|
2290
2243
|
const waitEventBase = () => {
|
|
2291
2244
|
const selection = store.botByThread(owner.threadId)?.modelSelection;
|
|
2292
2245
|
const instance = selection ? registry.get(selection.instanceId) : null;
|
|
@@ -2360,32 +2313,7 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
|
2360
2313
|
position,
|
|
2361
2314
|
...(holder ? { holder } : {}),
|
|
2362
2315
|
});
|
|
2363
|
-
// #1655: the wait is where an overflow choice gets made. The card
|
|
2364
|
-
// names the per-second cost before anything can start, and only a
|
|
2365
|
-
// turn genuinely waiting on a local desktop gets one. Fail closed:
|
|
2366
|
-
// feature off, cloud unusable, or no operator-set rate means no
|
|
2367
|
-
// card at all — Auto never offers, never starts.
|
|
2368
|
-
const overflow = cloudOverflowSituation(owner, resource, overflowHandled);
|
|
2369
|
-
const perSecondCostUsd = overflow?.perSecondCostUsd ?? null;
|
|
2370
|
-
if (overflow && perSecondCostUsd !== null && cloudOverflowAction(overflow).kind === "offer") {
|
|
2371
|
-
store.appendMessage(owner.threadId, {
|
|
2372
|
-
role: "bot", kind: "activity",
|
|
2373
|
-
tool: { name: cloudOverflowOfferText({ perSecondCostUsd, idleStopMs: cloudOverflowIdleStopMs(cfg) }), ok: true },
|
|
2374
|
-
});
|
|
2375
|
-
cloudOverflowConsent.markOffered(owner.threadId, perSecondCostUsd);
|
|
2376
|
-
}
|
|
2377
2316
|
}
|
|
2378
|
-
// #1655: consent can arrive mid-wait — the card's answer, or a
|
|
2379
|
-
// thread the operator allowlisted — and only then may the cloud
|
|
2380
|
-
// seat start, its cost chip landing beside the waiting one. The
|
|
2381
|
-
// local wait itself is untouched: it still ends by acquisition,
|
|
2382
|
-
// stop, or the ceiling (parking, once that stack lands).
|
|
2383
|
-
if (!overflowHandled) {
|
|
2384
|
-
const situation = cloudOverflowSituation(owner, resource, overflowHandled);
|
|
2385
|
-
if (situation && cloudOverflowAction(situation).kind === "start")
|
|
2386
|
-
overflowHandled = await startCloudSeat(owner);
|
|
2387
|
-
}
|
|
2388
|
-
sweepIdleCloudSeats();
|
|
2389
2317
|
if (Date.now() >= deadline) {
|
|
2390
2318
|
// ADR-2 (#1651): the ceiling parks the turn — settled, with a
|
|
2391
2319
|
// registered resume — instead of failing it. The owner mark lets the
|
|
@@ -2407,15 +2335,6 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
|
2407
2335
|
}
|
|
2408
2336
|
turnResourceOwners.set(owner.threadId, owner);
|
|
2409
2337
|
turnComputerResources.set(owner.threadId, { owner, resource });
|
|
2410
|
-
// A screen call that started before this bind (#1653) fenced nothing
|
|
2411
|
-
// because no claim existed yet: carry its pending start into the fresh
|
|
2412
|
-
// claim so the idle window still defers to the running call.
|
|
2413
|
-
const pendingStarts = pendingComputerCallStarts.get(owner.threadId);
|
|
2414
|
-
if (pendingStarts?.generation === owner.generation && pendingStarts.count > 0) {
|
|
2415
|
-
pendingComputerCallStarts.delete(owner.threadId);
|
|
2416
|
-
for (let i = 0; i < pendingStarts.count; i += 1)
|
|
2417
|
-
turnResources.beginComputerCall(resource, owner);
|
|
2418
|
-
}
|
|
2419
2338
|
}
|
|
2420
2339
|
function botForThread(botId, threadId) {
|
|
2421
2340
|
return directTurnBots.get(threadId) ?? store.projectBotForTask(botId, threadId) ?? store.bot(botId);
|
|
@@ -2461,15 +2380,9 @@ function canAdmitDirectTurn(botId, threadId) {
|
|
|
2461
2380
|
/** Routine and webhook dispatch shares startTurn's admission preconditions
|
|
2462
2381
|
* instead of waiting for whole-bot idleness: a free thread slot and no
|
|
2463
2382
|
* active group turn. A group turn blocks scheduled starts the same way it
|
|
2464
|
-
* blocks every other turn kind; it does not consume a capacity slot.
|
|
2465
|
-
*
|
|
2466
|
-
*
|
|
2467
|
-
* startTurn's cwd resolution: a free thread slot alone can miss a second
|
|
2468
|
-
* task of this bot landing in that one folder (#F-collide). Predicting that
|
|
2469
|
-
* collision here lets it defer through the same busy-target machinery as
|
|
2470
|
-
* capacity and group-turn contention, instead of reaching startTurn's own
|
|
2471
|
-
* workspace claim — which throws deep inside a detached dispatch that no
|
|
2472
|
-
* caller here awaits, so the run would otherwise fail permanently. */
|
|
2383
|
+
* blocks every other turn kind; it does not consume a capacity slot. The
|
|
2384
|
+
* bot's project folder is not a slot: a fresh run works there beside the
|
|
2385
|
+
* bot's other threads (see startTurn's cwd resolution). */
|
|
2473
2386
|
function unattendedDispatchState(botId) {
|
|
2474
2387
|
const bot = store.bot(botId);
|
|
2475
2388
|
const decision = admit("unattended", {}, {
|
|
@@ -2479,16 +2392,6 @@ function unattendedDispatchState(botId) {
|
|
|
2479
2392
|
});
|
|
2480
2393
|
if (decision.action === "refuse")
|
|
2481
2394
|
return decision.code === "missing" ? "missing" : "busy";
|
|
2482
|
-
if (bot?.cwd) {
|
|
2483
|
-
try {
|
|
2484
|
-
if (turnResources.blocker(workspaceResource(bot.cwd), { threadId: "", generation: "" }))
|
|
2485
|
-
return "busy";
|
|
2486
|
-
}
|
|
2487
|
-
catch {
|
|
2488
|
-
// An unresolvable folder is startTurn's own admission check to
|
|
2489
|
-
// report; this prediction only ever adds a defer, never a refusal.
|
|
2490
|
-
}
|
|
2491
|
-
}
|
|
2492
2395
|
return "ready";
|
|
2493
2396
|
}
|
|
2494
2397
|
function requestedTaskBot(botId, rawThreadId) {
|
|
@@ -2670,6 +2573,7 @@ const browserRuntime = new BrowserRuntime({
|
|
|
2670
2573
|
browserLive.closeForSession(session);
|
|
2671
2574
|
return closed;
|
|
2672
2575
|
},
|
|
2576
|
+
applyViewport: (spec) => setBrowserViewport(spec.command, spec.env),
|
|
2673
2577
|
});
|
|
2674
2578
|
const browserLive = new BrowserLive({ runtime: browserRuntime });
|
|
2675
2579
|
// Temporary profiles last for this server run, but are never saved to disk.
|
|
@@ -2839,8 +2743,12 @@ const routineRequestEnvelopeSchema = z.discriminatedUnion("action", [
|
|
|
2839
2743
|
]);
|
|
2840
2744
|
/** The loopback endpoint a bot's computer proxy polls before acting. With a
|
|
2841
2745
|
* Boat id, the same turn-scoped token also serves that Boat's tools at
|
|
2842
|
-
* /api/internal/computer/mcp, and is revoked when the turn ends.
|
|
2843
|
-
|
|
2746
|
+
* /api/internal/computer/mcp, and is revoked when the turn ends. This
|
|
2747
|
+
* computer runs as long as the app does, so its bridge keeps the thread's
|
|
2748
|
+
* bearer like every other integration; any other machine (a Local VM, the
|
|
2749
|
+
* VPS, a Boat) gets a new bearer each turn. */
|
|
2750
|
+
function controlIntegration(botId, threadId, generation, machine = {}) {
|
|
2751
|
+
const { localVmTarget, boxId } = machine === "this computer" ? {} : machine;
|
|
2844
2752
|
return {
|
|
2845
2753
|
url: `http://127.0.0.1:${PORT}/api/internal/computer-control?botId=${encodeURIComponent(botId)}`,
|
|
2846
2754
|
token: mintInternalCapability({
|
|
@@ -2850,12 +2758,12 @@ function controlIntegration(botId, threadId, generation, localVmTarget, boxId) {
|
|
|
2850
2758
|
depth: 0,
|
|
2851
2759
|
kind: "computer",
|
|
2852
2760
|
...(localVmTarget ? { localVmTarget } : {}),
|
|
2853
|
-
...(boxId ? { boxId } : {}),
|
|
2761
|
+
...(boxId !== undefined ? { boxId } : {}),
|
|
2854
2762
|
...(teamComputerTurns.get(threadId) ? { teamComputerId: teamComputerTurns.get(threadId).computerId } : {}),
|
|
2855
2763
|
skillAuthoring: false,
|
|
2856
2764
|
createdBots: 0,
|
|
2857
2765
|
openedThreads: 0,
|
|
2858
|
-
}),
|
|
2766
|
+
}, { perTurn: machine !== "this computer" }),
|
|
2859
2767
|
};
|
|
2860
2768
|
}
|
|
2861
2769
|
function askBotAndWait(targetBotId, message, depth, fromBotId, fromThreadId, targetThreadId) {
|
|
@@ -2913,17 +2821,89 @@ function askBotAndWait(targetBotId, message, depth, fromBotId, fromThreadId, tar
|
|
|
2913
2821
|
}).catch((err) => finish({ status: "error", text: `(couldn't start that bot: ${err instanceof Error ? err.message : String(err)})` }));
|
|
2914
2822
|
});
|
|
2915
2823
|
}
|
|
2916
|
-
// New bots honor setup's saved choice;
|
|
2917
|
-
// While enrolled, a Company model that can run beats
|
|
2918
|
-
// engine, and the organisation's policy is respected;
|
|
2919
|
-
|
|
2824
|
+
// New bots honor setup's saved choice; without one, an engine that can run a
|
|
2825
|
+
// turn now (Claude first). While enrolled, a Company model that can run beats
|
|
2826
|
+
// a signed-out personal engine, and the organisation's policy is respected;
|
|
2827
|
+
// otherwise inert. `null` leaves the saved choice out (moveOffComputerEngine).
|
|
2828
|
+
async function defaultSelection(saved = cfg.defaultModelSelection ?? null) {
|
|
2920
2829
|
if (hostedModels)
|
|
2921
|
-
return hostedModels.select(
|
|
2922
|
-
return selectDefaultModelSelection(await registry.describe(),
|
|
2830
|
+
return hostedModels.select(saved ?? undefined);
|
|
2831
|
+
return selectDefaultModelSelection(await registry.describe(), saved ?? undefined, {
|
|
2923
2832
|
company: (instanceId) => managedDesktop.owns(instanceId),
|
|
2924
2833
|
refusal: (instance) => policyModelRefusal(instance),
|
|
2925
2834
|
});
|
|
2926
2835
|
}
|
|
2836
|
+
/** The Computer engine was removed: it ran a whole turn on Boat's own agent.
|
|
2837
|
+
* Everything still set to it moves, once, to the engine a new bot gets
|
|
2838
|
+
* (defaultSelection, the one picker), or the one a new bot would get without
|
|
2839
|
+
* the saved choice when that cannot run: the saved default and new-bot
|
|
2840
|
+
* defaults, each bot and conversation (Store.retireInstances keeps each one
|
|
2841
|
+
* where it worked), and an automatic-recovery backup, which could never run
|
|
2842
|
+
* and is cleared. Each moved conversation says so in one plain line. When no
|
|
2843
|
+
* engine is available yet it runs again the next time the engines are read
|
|
2844
|
+
* (describeInstances): an install, a sign-in, a key or a Company engine. At
|
|
2845
|
+
* start it sees what a new bot made then would see; a policy that arrives
|
|
2846
|
+
* later refuses an engine for a moved bot the way it does for any bot. A
|
|
2847
|
+
* hosted workspace's reconcile has already moved every bot onto its own
|
|
2848
|
+
* models. */
|
|
2849
|
+
async function moveOffComputerEngine() {
|
|
2850
|
+
if (hostedModels)
|
|
2851
|
+
return;
|
|
2852
|
+
const removed = removedComputerInstanceIds(cfg.instances);
|
|
2853
|
+
const named = (selection) => Boolean(selection && removed.has(selection.instanceId));
|
|
2854
|
+
if (named(cfg.automaticRecovery?.backup)) {
|
|
2855
|
+
saveConfig({ automaticRecovery: { enabled: false } });
|
|
2856
|
+
cfg.automaticRecovery = { enabled: false };
|
|
2857
|
+
}
|
|
2858
|
+
const defaults = named(cfg.defaultModelSelection) || named(cfg.newBotDefaults?.profile.modelSelection);
|
|
2859
|
+
if (!defaults && !store.bots.some(bot => named(bot.modelSelection) || bot.fallback?.some(named) ||
|
|
2860
|
+
store.tasks(bot.id).some(task => named(task.modelSelection))))
|
|
2861
|
+
return;
|
|
2862
|
+
let replacement = named(cfg.defaultModelSelection) ? { instanceId: "", model: "" } : await defaultSelection();
|
|
2863
|
+
if (!replacement.instanceId)
|
|
2864
|
+
replacement = await defaultSelection(null);
|
|
2865
|
+
if (!replacement.instanceId) {
|
|
2866
|
+
console.warn("[engines] the Computer engine was removed and no other engine is available yet; its bots move when one is");
|
|
2867
|
+
return;
|
|
2868
|
+
}
|
|
2869
|
+
if (defaults) {
|
|
2870
|
+
// One write keeps both saved defaults on the replacement (saveConfig
|
|
2871
|
+
// syncs them), and the copies in memory follow at once, so a bot made
|
|
2872
|
+
// before the next start never lands on the removed engine.
|
|
2873
|
+
const newBotDefaults = cfg.newBotDefaults && newBotDefaultsSchema.parse({
|
|
2874
|
+
...cfg.newBotDefaults, profile: { ...cfg.newBotDefaults.profile, modelSelection: replacement },
|
|
2875
|
+
});
|
|
2876
|
+
saveConfig(newBotDefaults ? { newBotDefaults } : { defaultModelSelection: replacement });
|
|
2877
|
+
cfg.defaultModelSelection = replacement;
|
|
2878
|
+
if (newBotDefaults)
|
|
2879
|
+
cfg.newBotDefaults = newBotDefaults;
|
|
2880
|
+
}
|
|
2881
|
+
const instance = registry.get(replacement.instanceId);
|
|
2882
|
+
const engine = instance?.displayName ?? instance?.driverKind ?? replacement.instanceId;
|
|
2883
|
+
const moves = store.retireInstances(removed, replacement, {
|
|
2884
|
+
driverKind: instance?.driverKind,
|
|
2885
|
+
canWorkOnCloud: canWorkOnCloud(cloudEngine(instance)),
|
|
2886
|
+
keepCloud: (bot) => boat.boatConfigured(cfg) && bot.cloudBackend !== "vps" && !inheritedTeamComputer(bot),
|
|
2887
|
+
});
|
|
2888
|
+
writeComputerEngineMoveLines(moves, (move) => {
|
|
2889
|
+
const bot = store.bot(move.botId);
|
|
2890
|
+
if (bot)
|
|
2891
|
+
store.appendMessage(move.threadId, { role: "bot", kind: "activity", tool: { name: computerEngineMoveText(move, bot.name, engine), ok: true } });
|
|
2892
|
+
});
|
|
2893
|
+
}
|
|
2894
|
+
let computerEngineMoveRunning = null;
|
|
2895
|
+
/** The move, once at a time; it returns at once when nothing names the
|
|
2896
|
+
* removed engine any more. A failure is logged and never rejects, so it never
|
|
2897
|
+
* stops this server from listening. Bots whose save failed are unchanged and
|
|
2898
|
+
* move the next time the engines are read (describeInstances) or a turn
|
|
2899
|
+
* reaches one of them (startTurn), so a server no app reads the engines from
|
|
2900
|
+
* still moves them. */
|
|
2901
|
+
function retryComputerEngineMove() {
|
|
2902
|
+
computerEngineMoveRunning ??= moveOffComputerEngine()
|
|
2903
|
+
.catch((error) => console.warn(`[engines] moving bots off the removed Computer engine failed: ${error instanceof Error ? error.message : String(error)}`))
|
|
2904
|
+
.finally(() => { computerEngineMoveRunning = null; });
|
|
2905
|
+
return computerEngineMoveRunning;
|
|
2906
|
+
}
|
|
2927
2907
|
function checkedModelSelection(raw, current, requireAvailableModel = false) {
|
|
2928
2908
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
2929
2909
|
return { ok: false, status: 400, error: "modelSelection must be an object" };
|
|
@@ -3153,9 +3133,6 @@ function packageImportDeps(selection) {
|
|
|
3153
3133
|
return avatarUrl;
|
|
3154
3134
|
},
|
|
3155
3135
|
},
|
|
3156
|
-
broadcast: (event) => event.kind === "bot"
|
|
3157
|
-
? broadcast({ kind: "bot", bot: publicBot(event.bot) })
|
|
3158
|
-
: broadcast({ kind: "group", group: publicGroupState(event.group) }),
|
|
3159
3136
|
defaultSelection: () => selection,
|
|
3160
3137
|
presets: presetStore,
|
|
3161
3138
|
};
|
|
@@ -3230,6 +3207,8 @@ const store = new Store(() => bootSelection, (selection) => withNewBotEffort(sel
|
|
|
3230
3207
|
const teamComputers = new TeamComputers(join(DATA_DIR, "team-computers.json"), ENVIRONMENT_ID);
|
|
3231
3208
|
let followupsReady = false;
|
|
3232
3209
|
const sendSequencer = new SendSequencer();
|
|
3210
|
+
// Before the new-bot default is read: a saved default may name the engine.
|
|
3211
|
+
await retryComputerEngineMove();
|
|
3233
3212
|
bootSelection = await defaultSelection();
|
|
3234
3213
|
store.seedIfEmpty();
|
|
3235
3214
|
hostedModels?.reconcile(store);
|
|
@@ -3294,7 +3273,7 @@ const wireTask = (task) => {
|
|
|
3294
3273
|
return asleep ? { ...coordinated, snoozedUntil } : coordinated;
|
|
3295
3274
|
};
|
|
3296
3275
|
const wireBot = (bot) => {
|
|
3297
|
-
const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId,
|
|
3276
|
+
const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, assignedSkills: _assignedSkills, ...rest } = bot;
|
|
3298
3277
|
// An elevated selection is inert until the desktop confirms its exact
|
|
3299
3278
|
// private reply. Every ordinary client sees the effective Ask state during
|
|
3300
3279
|
// that two-phase window, never a grant that may still roll back.
|
|
@@ -3316,7 +3295,7 @@ function toolScopeForTurn(botId) {
|
|
|
3316
3295
|
/** The correlated private response carries the requested value so Electron
|
|
3317
3296
|
* can validate it before sending the confirmation that makes it effective. */
|
|
3318
3297
|
const wireTrustedApprovalBot = (bot) => {
|
|
3319
|
-
const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId,
|
|
3298
|
+
const { resumeCursors: _resumeCursors, tasks, approvalGrant: _approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, packageBase: _packageBase, assignedSkills: _assignedSkills, ...rest } = bot;
|
|
3320
3299
|
return { ...rest, approvalMode: approvalModeFor(rest), avatarUrl: rest.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
|
|
3321
3300
|
};
|
|
3322
3301
|
/** A settings-based preview, not a receipt of a dispatched turn. No
|
|
@@ -3339,7 +3318,6 @@ function previewSystemPrompt(bot) {
|
|
|
3339
3318
|
const computerPromptKind = resolveComputerPromptKind({
|
|
3340
3319
|
kind: previewComputer === "vm" ? "vm" : previewComputer === "cloud"
|
|
3341
3320
|
? bot.cloudBackend === "vps" ? "vps" : "box" : previewComputer === "local" ? "local" : null,
|
|
3342
|
-
driverKind: instance?.driverKind,
|
|
3343
3321
|
vmPrivate: localVmMode(cfg) === "per-bot",
|
|
3344
3322
|
});
|
|
3345
3323
|
const peers = reachablePeers(store.bots, bot);
|
|
@@ -3379,7 +3357,7 @@ function previewSystemPrompt(bot) {
|
|
|
3379
3357
|
computer: previewPlan.computer && previewPlan.computer !== "off" && computerPromptKind ? previewPlan.computer : null,
|
|
3380
3358
|
browser: previewPlan.computer === undefined ? false : previewPlan.browser,
|
|
3381
3359
|
}, { note: previewPlan.note, cloudHome: Boolean(CLOUD_HOME) }) },
|
|
3382
|
-
{ id: "cloud-home", label: "
|
|
3360
|
+
{ id: "cloud-home", label: "My Cloud", text: CLOUD_HOME ? cloudHomePrompt(agentsMounted && lendingEnabled()) : "" },
|
|
3383
3361
|
{ id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? composioSystemPrompt(bot.connectorTools) + describeConnectorScopes(bot.connectorScopes) : "" },
|
|
3384
3362
|
{ id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(engineMcpServers(bot))) : "" },
|
|
3385
3363
|
{ id: "browser", label: "Browser", text: previewPlan.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
|
|
@@ -3389,6 +3367,7 @@ function previewSystemPrompt(bot) {
|
|
|
3389
3367
|
{ id: "profile", label: "Profile changes", text: agentsMounted ? PROFILE_PROMPT : "" },
|
|
3390
3368
|
{ id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
|
|
3391
3369
|
{ id: "team-memory", label: "Team memory", text: teamMemory.systemPrompt(bot.section) + (agentsMounted ? TEAM_MEMORY_PROMPT : "") },
|
|
3370
|
+
teamAvailabilityPart(agentsMounted && coordination ? peers : []),
|
|
3392
3371
|
{ id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: agentsMounted, fileTools: Boolean(privateWorkspace), enabled: bot.memoryEnabled !== false }) },
|
|
3393
3372
|
{ id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id, skillsLibraryEnabled(cfg) ? bot.assignedSkills : undefined) : "" },
|
|
3394
3373
|
]);
|
|
@@ -3966,10 +3945,11 @@ function handleDesktopTrustedApprovalMessage(raw) {
|
|
|
3966
3945
|
* fetched immediately and a deleted/guessed attachment id cannot become a
|
|
3967
3946
|
* dangling profile reference. */
|
|
3968
3947
|
const storedAvatarExists = (avatarUrl) => attachmentExists(avatarUrl.slice("/api/attachments/".length));
|
|
3948
|
+
/** A bot with its open thread's newest page, for HTTP replies and a
|
|
3949
|
+
* member stream's first frame of it. */
|
|
3969
3950
|
const publicBot = (bot) => ({
|
|
3970
3951
|
...wireBot(bot),
|
|
3971
|
-
|
|
3972
|
-
activeLeafId: store.activeLeaf(bot.threadId),
|
|
3952
|
+
...messagePage(bot.threadId, DEFAULT_PAGE),
|
|
3973
3953
|
tasks: store.tasks(bot.id).map(wireTask),
|
|
3974
3954
|
});
|
|
3975
3955
|
const publicBotQueuedMessages = () => queuedSteerSnapshot((botId, threadId) => Boolean(store.taskByThread(botId, threadId)));
|
|
@@ -4291,7 +4271,9 @@ function outstandingAssignmentsPrompt(threadId) {
|
|
|
4291
4271
|
requestId: node.id,
|
|
4292
4272
|
bot: store.bot(node.botId)?.name ?? "Teammate",
|
|
4293
4273
|
assignment: node.text.slice(0, 1_000),
|
|
4294
|
-
status: node.status === "queued" ? "waiting for that teammate to be free"
|
|
4274
|
+
status: node.status === "queued" ? "waiting for that teammate to be free"
|
|
4275
|
+
: openPersonCard(node.threadId) ? "stopped on a card waiting for the person's approval or answer in that teammate's thread; tell the person to open it"
|
|
4276
|
+
: "working on it now",
|
|
4295
4277
|
}));
|
|
4296
4278
|
return ` Assignments you already sent are still outstanding, and nothing in this conversation cancelled them: ${JSON.stringify(listed)}. Do not resend them, do not poll or wait for them, and do not tell the user they were lost. Each result returns to this conversation on its own and resumes you then. To change or add to one, send the change to the same teammate with coordinate_bots; it runs after the current work. Answer the message above with that work still in flight.`;
|
|
4297
4279
|
}
|
|
@@ -4804,18 +4786,33 @@ function activeGroupTurnForBot(botId) {
|
|
|
4804
4786
|
}
|
|
4805
4787
|
return null;
|
|
4806
4788
|
}
|
|
4789
|
+
/** A room with its open thread's newest page, for HTTP replies and a
|
|
4790
|
+
* member stream's first frame of it. */
|
|
4807
4791
|
const groupWithThread = (group) => ({
|
|
4808
4792
|
...publicGroupState(group),
|
|
4809
|
-
|
|
4810
|
-
activeLeafId: store.activeLeaf(group.threadId),
|
|
4793
|
+
...messagePage(group.threadId, DEFAULT_PAGE),
|
|
4811
4794
|
...(group.dm ? {} : { tasks: store.groupTasks(group.id) }),
|
|
4812
4795
|
});
|
|
4813
4796
|
// The store tells us what it wrote; this is the ONE place that turns those
|
|
4814
4797
|
// into SSE frames. No mutation path can persist without emitting — the
|
|
4815
4798
|
// property holds by construction, not by every call site remembering to
|
|
4816
|
-
// broadcast.
|
|
4817
|
-
//
|
|
4818
|
-
//
|
|
4799
|
+
// broadcast.
|
|
4800
|
+
//
|
|
4801
|
+
// A bot or room frame carries its open thread's newest page only when that
|
|
4802
|
+
// thread is not the one it last went out with: a switch, a new open thread,
|
|
4803
|
+
// deleting the open one, a new bot or room. Other windows swap transcripts
|
|
4804
|
+
// on exactly those frames (one whose open thread was deleted keeps its
|
|
4805
|
+
// composer locked until a page arrives). Every other frame stays slim, and
|
|
4806
|
+
// clients keep the transcript they hold when a frame carries none.
|
|
4807
|
+
const sentThreads = new Map();
|
|
4808
|
+
for (const record of [...store.bots, ...store.groups])
|
|
4809
|
+
sentThreads.set(record.id, record.threadId);
|
|
4810
|
+
function pageIfThreadChanged(id, threadId) {
|
|
4811
|
+
if (sentThreads.get(id) === threadId)
|
|
4812
|
+
return {};
|
|
4813
|
+
sentThreads.set(id, threadId);
|
|
4814
|
+
return messagePage(threadId, DEFAULT_PAGE);
|
|
4815
|
+
}
|
|
4819
4816
|
store.onChange((change) => {
|
|
4820
4817
|
// Who owns which thread, and what each member may see, follow the fleet.
|
|
4821
4818
|
if (change.type !== "message" && change.type !== "message.patch" && change.type !== "thread" && change.type !== "sections")
|
|
@@ -4824,11 +4821,12 @@ store.onChange((change) => {
|
|
|
4824
4821
|
case "sections":
|
|
4825
4822
|
broadcast({ kind: "sections", sections: store.sections });
|
|
4826
4823
|
break;
|
|
4824
|
+
// Live frames never carry screenshot pixels; clients load them by URL.
|
|
4827
4825
|
case "message":
|
|
4828
|
-
broadcast({ kind: "message", threadId: change.threadId, message: change.message });
|
|
4826
|
+
broadcast({ kind: "message", threadId: change.threadId, message: slimMessage(change.message) });
|
|
4829
4827
|
break;
|
|
4830
4828
|
case "message.patch":
|
|
4831
|
-
broadcast({ kind: "message.patch", threadId: change.threadId, message: change.message });
|
|
4829
|
+
broadcast({ kind: "message.patch", threadId: change.threadId, message: slimMessage(change.message) });
|
|
4832
4830
|
break;
|
|
4833
4831
|
case "thread":
|
|
4834
4832
|
broadcast({ kind: "thread", threadId: change.threadId, activeLeafId: change.activeLeafId });
|
|
@@ -4848,7 +4846,7 @@ store.onChange((change) => {
|
|
|
4848
4846
|
case "bot": {
|
|
4849
4847
|
const bot = store.bot(change.botId);
|
|
4850
4848
|
if (bot)
|
|
4851
|
-
broadcast({ kind: "bot", bot: wireBot(bot) });
|
|
4849
|
+
broadcast({ kind: "bot", bot: { ...wireBot(bot), ...pageIfThreadChanged(bot.id, bot.threadId) } });
|
|
4852
4850
|
// A new audience narrows the rooms this bot is in, for good.
|
|
4853
4851
|
if (bot)
|
|
4854
4852
|
for (const group of store.groups.filter((candidate) => candidate.memberIds.includes(bot.id)))
|
|
@@ -4856,6 +4854,7 @@ store.onChange((change) => {
|
|
|
4856
4854
|
break;
|
|
4857
4855
|
}
|
|
4858
4856
|
case "bot.deleted":
|
|
4857
|
+
sentThreads.delete(change.botId);
|
|
4859
4858
|
try {
|
|
4860
4859
|
commandAllowlist.clear(change.botId);
|
|
4861
4860
|
}
|
|
@@ -4867,12 +4866,13 @@ store.onChange((change) => {
|
|
|
4867
4866
|
case "group": {
|
|
4868
4867
|
const group = store.group(change.groupId);
|
|
4869
4868
|
if (group)
|
|
4870
|
-
broadcast({ kind: "group", group: publicGroupState(group) });
|
|
4869
|
+
broadcast({ kind: "group", group: { ...publicGroupState(group), ...pageIfThreadChanged(group.id, group.threadId) } });
|
|
4871
4870
|
if (group)
|
|
4872
4871
|
settleRoomFloor(group);
|
|
4873
4872
|
break;
|
|
4874
4873
|
}
|
|
4875
4874
|
case "group.deleted":
|
|
4875
|
+
sentThreads.delete(change.groupId);
|
|
4876
4876
|
broadcast({ kind: "group.deleted", groupId: change.groupId });
|
|
4877
4877
|
break;
|
|
4878
4878
|
}
|
|
@@ -4897,12 +4897,13 @@ function pageSize(raw) {
|
|
|
4897
4897
|
return null;
|
|
4898
4898
|
return Math.min(size, MESSAGE_PAGE_MAX);
|
|
4899
4899
|
}
|
|
4900
|
-
/** A screen message without its pixels
|
|
4901
|
-
* `/api/threads/:threadId/messages/:id/image`
|
|
4900
|
+
/** A screen message without its pixels, for live frames and pages alike.
|
|
4901
|
+
* The client fetches those from `/api/threads/:threadId/messages/:id/image`
|
|
4902
|
+
* when it actually shows one; `mime` stays so it can name a download. */
|
|
4902
4903
|
function slimMessage(message) {
|
|
4903
4904
|
if (message.kind !== "screen" || !message.png)
|
|
4904
4905
|
return message;
|
|
4905
|
-
const { png: _png,
|
|
4906
|
+
const { png: _png, ...rest } = message;
|
|
4906
4907
|
return { ...rest, hasImage: true };
|
|
4907
4908
|
}
|
|
4908
4909
|
/** `limit === undefined` is the original, unpaginated shape. A bounded,
|
|
@@ -5309,7 +5310,7 @@ always, rememberCommand) {
|
|
|
5309
5310
|
}
|
|
5310
5311
|
/** A decision or answer on a provider/peer card, made on behalf of `auth`
|
|
5311
5312
|
* (a Live call answers as the person who started the call). Harness-native
|
|
5312
|
-
* proposals (skill, routine, profile, default model,
|
|
5313
|
+
* proposals (skill, routine, profile, default model, team setup)
|
|
5313
5314
|
* are not handled here; they are reviewed on screen (liveDecisionRefusal).
|
|
5314
5315
|
* Mirrors POST /api/threads/:id/respond. */
|
|
5315
5316
|
async function respondToCard(input) {
|
|
@@ -5468,10 +5469,7 @@ const watchdog = new TurnWatchdog({
|
|
|
5468
5469
|
repeats.settle(turn.threadId);
|
|
5469
5470
|
const bot = botForThread(turn.botId, turn.threadId);
|
|
5470
5471
|
const routineRun = activeRoutineRunForThread(turn.threadId);
|
|
5471
|
-
|
|
5472
|
-
? runningTurnInstance(bot, turn.threadId)
|
|
5473
|
-
: runningTurnEngines.get(turn.threadId) ?? null;
|
|
5474
|
-
void instance?.adapter.interruptTurn(turn.threadId).catch(() => { });
|
|
5472
|
+
void runningTurnInstance(bot, turn.threadId)?.adapter.interruptTurn(turn.threadId).catch(() => { });
|
|
5475
5473
|
const minutes = Math.round(TURN_STALL_MS / 60_000);
|
|
5476
5474
|
if (routineRun?.target === "bot") {
|
|
5477
5475
|
routines?.failThread(turn.threadId, `No activity for ${minutes} minutes — the routine was stopped`);
|
|
@@ -5481,7 +5479,7 @@ const watchdog = new TurnWatchdog({
|
|
|
5481
5479
|
store.appendMessage(turn.threadId, {
|
|
5482
5480
|
role: "bot",
|
|
5483
5481
|
kind: "activity",
|
|
5484
|
-
tool:
|
|
5482
|
+
tool: failedTurnTool(`no activity for ${minutes} minutes — the turn was stopped`),
|
|
5485
5483
|
});
|
|
5486
5484
|
// a routine's stall reports through its own failure path
|
|
5487
5485
|
if (bot && routineRun?.target !== "bot") {
|
|
@@ -5578,6 +5576,10 @@ bus.subscribe((event) => {
|
|
|
5578
5576
|
else if (event.type !== "session.exited")
|
|
5579
5577
|
watchdog.touch(event.threadId);
|
|
5580
5578
|
});
|
|
5579
|
+
// Automatic continuity: a turn that died on its budget or on tool errors gets
|
|
5580
|
+
// a `Continue:` task on the same bot, seeded with the persisted handoff — the
|
|
5581
|
+
// work moves forward without a person noticing and re-dispatching.
|
|
5582
|
+
bus.subscribe(makeCapContinuationSubscriber({ store, startTurn }));
|
|
5581
5583
|
// Memory journal turn boundary (server/memory-journal.ts). A bot's own
|
|
5582
5584
|
// file-tool writes to MEMORY.md and memory/ have no hook to tap, so the
|
|
5583
5585
|
// diff against the baseline taken at dispatch is made when the turn
|
|
@@ -5761,6 +5763,8 @@ bus.subscribe((event) => {
|
|
|
5761
5763
|
});
|
|
5762
5764
|
// Per-thread state the digest needs from before the turn: when it started,
|
|
5763
5765
|
// and the checkpoint taken at dispatch (hash + folder) so settle can diff.
|
|
5766
|
+
// `pin` is the dispatch that pinned that commit in the shadow repo, so a
|
|
5767
|
+
// sibling thread's snapshot in the same folder cannot reclaim it mid-turn.
|
|
5764
5768
|
const turnStartedAt = new Map();
|
|
5765
5769
|
const turnCheckpoints = new Map();
|
|
5766
5770
|
// The turn a thread is in, from the last event that named one. Not every
|
|
@@ -5768,9 +5772,18 @@ const turnCheckpoints = new Map();
|
|
|
5768
5772
|
// a digest can only count activity it can attribute — so an unstamped tool
|
|
5769
5773
|
// row is attributed to the thread's live turn instead of to nothing.
|
|
5770
5774
|
const liveTurnByThread = new Map();
|
|
5775
|
+
/** Forget a thread's pre-turn checkpoint and drop its pin, so the shadow
|
|
5776
|
+
* repo's next cleanup may reclaim that commit. */
|
|
5777
|
+
function forgetTurnCheckpoint(threadId) {
|
|
5778
|
+
const checkpoint = turnCheckpoints.get(threadId);
|
|
5779
|
+
if (!checkpoint)
|
|
5780
|
+
return;
|
|
5781
|
+
turnCheckpoints.delete(threadId);
|
|
5782
|
+
void checkpoints.release(checkpoint.botId, checkpoint.cwd, checkpoint.pin);
|
|
5783
|
+
}
|
|
5771
5784
|
function clearTurnDigestState(threadId) {
|
|
5772
5785
|
turnStartedAt.delete(threadId);
|
|
5773
|
-
|
|
5786
|
+
forgetTurnCheckpoint(threadId);
|
|
5774
5787
|
liveTurnByThread.delete(threadId);
|
|
5775
5788
|
memoryRowsByThread.delete(threadId);
|
|
5776
5789
|
}
|
|
@@ -5839,9 +5852,8 @@ function ingestEngineHook(capability, body) {
|
|
|
5839
5852
|
}
|
|
5840
5853
|
return { ok: true };
|
|
5841
5854
|
}
|
|
5842
|
-
/** Persist observed tools and memory immediately
|
|
5843
|
-
*
|
|
5844
|
-
* that same row only if its task and active branch still exist. */
|
|
5855
|
+
/** Persist observed tools and memory immediately, then enrich that same
|
|
5856
|
+
* row only if its task and active branch still exist. */
|
|
5845
5857
|
async function scheduleTurnDigest(input) {
|
|
5846
5858
|
const at = Date.now();
|
|
5847
5859
|
const startedAt = turnStartedAt.get(input.threadId);
|
|
@@ -5886,6 +5898,8 @@ async function scheduleTurnDigest(input) {
|
|
|
5886
5898
|
}
|
|
5887
5899
|
if (!checkpoint)
|
|
5888
5900
|
return;
|
|
5901
|
+
// A folder-level diff: when a sibling thread of this bot works in the
|
|
5902
|
+
// same folder, its edits since this turn's snapshot are listed here too.
|
|
5889
5903
|
const controller = new AbortController();
|
|
5890
5904
|
let timer;
|
|
5891
5905
|
const capture = async () => {
|
|
@@ -5909,6 +5923,12 @@ async function scheduleTurnDigest(input) {
|
|
|
5909
5923
|
catch (error) {
|
|
5910
5924
|
console.error(`digest: could not record turn ${input.turnId} on ${input.threadId}:`, error instanceof Error ? error.message : error);
|
|
5911
5925
|
}
|
|
5926
|
+
finally {
|
|
5927
|
+
// The pre-turn commit has had its last reader (the diff above is queued
|
|
5928
|
+
// ahead of this in the shadow repo); the next snapshot may reclaim it.
|
|
5929
|
+
if (checkpoint)
|
|
5930
|
+
void checkpoints.release(checkpoint.botId, checkpoint.cwd, checkpoint.pin);
|
|
5931
|
+
}
|
|
5912
5932
|
}
|
|
5913
5933
|
// Bots currently working with nobody at the keyboard — a webhook turn, or a
|
|
5914
5934
|
// turn a webhook-driven bot handed to a teammate. Auto mode is a decision
|
|
@@ -6044,9 +6064,6 @@ const vpsJoinRequests = new Map();
|
|
|
6044
6064
|
const orphanBoatLifecycleBusyIds = new Set();
|
|
6045
6065
|
const boatInventoryRequestsBusyIds = new Set();
|
|
6046
6066
|
const computerProviderConfigTransitions = new Set();
|
|
6047
|
-
// A restore mutates and cleans a project work tree. Claim the bot across the
|
|
6048
|
-
// entire async Git operation so a turn cannot start in that folder midway.
|
|
6049
|
-
const checkpointRestoreLeases = new Set();
|
|
6050
6067
|
/** The effective idle window, read from config so a Settings change applies
|
|
6051
6068
|
* to both new and already-armed timers (see the config route). */
|
|
6052
6069
|
const localVmIdleMs = () => localVmIdleTimeoutMinutes(cfg) * 60_000;
|
|
@@ -6059,7 +6076,7 @@ function inheritedTeamComputer(bot) {
|
|
|
6059
6076
|
return teamComputers.forBot(bot);
|
|
6060
6077
|
}
|
|
6061
6078
|
function teamComputerPrompt(computer) {
|
|
6062
|
-
return computer ? `Your team shares the
|
|
6079
|
+
return computer ? `Your team shares the cloud computer ${JSON.stringify(computer.name)}. Its desktop files and desktop browser logins are shared with other Auto bots in your team; only one turn may drive it at a time. The separate built-in Browser is not this desktop and does not automatically share its logins.` : "";
|
|
6063
6080
|
}
|
|
6064
6081
|
function botComputerControlKey(bot) {
|
|
6065
6082
|
const computer = inheritedTeamComputer(bot);
|
|
@@ -6070,21 +6087,26 @@ async function computerCallGate(internalCapability) {
|
|
|
6070
6087
|
const snapshot = botComputerControlSnapshot(botId, internalCapability.teamComputerId);
|
|
6071
6088
|
const slot = autoVmClaims.get(internalCapability.threadId);
|
|
6072
6089
|
const lazyClaim = slot && slot.owner.generation === internalCapability.generation ? slot : undefined;
|
|
6073
|
-
|
|
6074
|
-
|
|
6090
|
+
const waitMs = snapshot.held ? null : lazyClaimWaitMs(lazyClaim, LAZY_VM_CLAIM_GRACE_MS);
|
|
6091
|
+
if (lazyClaim && waitMs !== null) {
|
|
6092
|
+
// First screen tools/call on a lazily-attached computer (issue
|
|
6075
6093
|
// #1361): fire the exclusive claim — once — and give it a moment
|
|
6076
6094
|
// to land. A free, ready VM claims in the time of one container
|
|
6077
6095
|
// inspect, so this call then proceeds with an honest answer;
|
|
6078
6096
|
// only a claim still queued behind another holder answers held
|
|
6079
|
-
// below, and then the contention text is true.
|
|
6080
|
-
//
|
|
6097
|
+
// below, and then the contention text is true. A cloud computer's
|
|
6098
|
+
// claim creates or wakes it, so every call waits on that start
|
|
6099
|
+
// (graceMs) instead of being told someone else holds it. Keyed on
|
|
6100
|
+
// the slot, never on the thread's turn-computer entry: a bind this
|
|
6081
6101
|
// turn abandoned earlier (a VPS that turned out to be asleep)
|
|
6082
6102
|
// must not hide the unclaimed VM and let the call through.
|
|
6083
6103
|
startAutoVmClaim(autoVmClaims, internalCapability.threadId, internalCapability.generation);
|
|
6104
|
+
let timer;
|
|
6084
6105
|
await Promise.race([
|
|
6085
6106
|
lazyClaim.begin ?? Promise.resolve(),
|
|
6086
|
-
new Promise((resolve) => setTimeout(resolve,
|
|
6107
|
+
new Promise((resolve) => { timer = setTimeout(resolve, waitMs); }),
|
|
6087
6108
|
]);
|
|
6109
|
+
clearTimeout(timer);
|
|
6088
6110
|
}
|
|
6089
6111
|
if (!snapshot.held && lazyClaim?.failed === true) {
|
|
6090
6112
|
// A rejected lazy claim (gate finding F1, issue #1361): the
|
|
@@ -6104,17 +6126,14 @@ async function computerCallGate(internalCapability) {
|
|
|
6104
6126
|
}
|
|
6105
6127
|
if (!snapshot.held && lazyClaim?.lazy && lazyClaim.begin && !lazyClaim.claimed) {
|
|
6106
6128
|
// The claim fired and is still waiting on the exclusive bind:
|
|
6107
|
-
// another turn genuinely holds this desktop right now.
|
|
6129
|
+
// another turn genuinely holds this desktop right now. A slow claim
|
|
6130
|
+
// says what it is still doing instead.
|
|
6108
6131
|
return {
|
|
6109
6132
|
held: true, helpOpen: false,
|
|
6110
|
-
blockedReason: "Another thread is using this computer. This call was not performed. Pause computer work until that thread finishes, then take a fresh screenshot before acting.",
|
|
6133
|
+
blockedReason: lazyClaim.pendingReason ?? "Another thread is using this computer. This call was not performed. Pause computer work until that thread finishes, then take a fresh screenshot before acting.",
|
|
6111
6134
|
};
|
|
6112
6135
|
}
|
|
6113
6136
|
const computer = turnComputerResources.get(internalCapability.threadId);
|
|
6114
|
-
// The screen call arriving here re-claims an idle-released seat directly
|
|
6115
|
-
// (#1653): inside the reclaim window the previous holder picks its seat
|
|
6116
|
-
// back up without re-entering the wait, and the successful claim
|
|
6117
|
-
// restarts the quiet window. A seat another turn holds still refuses.
|
|
6118
6137
|
if (!snapshot.held && computer && computer.owner.generation === internalCapability.generation &&
|
|
6119
6138
|
!claimTurnResource(computer.owner, computer.resource)) {
|
|
6120
6139
|
return {
|
|
@@ -6137,11 +6156,6 @@ function teamComputerInUse(computer) {
|
|
|
6137
6156
|
[...teamComputerTurns.values()].some(turn => turn.computerId === computer.id) ||
|
|
6138
6157
|
store.bots.some(bot => computer.section !== null && sectionKey(bot.section) === computer.section && (botHasActiveTurn(bot.id) || Boolean(routines?.activeRunForBot(bot.id)) || computerControl.snapshot(bot.id).held));
|
|
6139
6158
|
}
|
|
6140
|
-
function assertTeamControlCanBeTaken(computerId) {
|
|
6141
|
-
if ([...teamComputerTurns.values()].some(turn => turn.computerId === computerId && turn.remoteAgent)) {
|
|
6142
|
-
throw Object.assign(new Error("Stop the Computer engine's active turn before taking control of this shared desktop"), { status: 409 });
|
|
6143
|
-
}
|
|
6144
|
-
}
|
|
6145
6159
|
function claimTeamComputerLifecycle(computer) {
|
|
6146
6160
|
if (computerProviderConfigTransitions.has("box"))
|
|
6147
6161
|
throw Object.assign(new Error(providerTransitionMessage("box")), { status: 409 });
|
|
@@ -6178,9 +6192,9 @@ async function teamComputersPayload() {
|
|
|
6178
6192
|
}
|
|
6179
6193
|
/** The same named Boat identity and whole-turn lease in chats and rooms.
|
|
6180
6194
|
* Assignment authorizes waking, never replacing a missing paid machine. */
|
|
6181
|
-
async function attachTeamBoat(computer, botId, owner, canMount
|
|
6195
|
+
async function attachTeamBoat(computer, botId, owner, canMount) {
|
|
6182
6196
|
if (!canMount)
|
|
6183
|
-
throw new Error("This model cannot use the team's
|
|
6197
|
+
throw new Error("This model cannot use the team's cloud computer; choose a model provider with computer tools or an explicit bot destination");
|
|
6184
6198
|
if (!boat.boatConfigured(cfg))
|
|
6185
6199
|
throw new Error("The team's Boat account is not configured; reconnect it in Settings");
|
|
6186
6200
|
const ownerId = teamComputerOwner(computer.id);
|
|
@@ -6189,42 +6203,51 @@ async function attachTeamBoat(computer, botId, owner, canMount, remoteAgent) {
|
|
|
6189
6203
|
if (computerControl.snapshot(ownerId).held)
|
|
6190
6204
|
throw new Error("Release human control of the team computer before starting another turn");
|
|
6191
6205
|
await bindTurnComputer(owner, `computer:box-bot:${ownerId}`, true);
|
|
6192
|
-
teamComputerTurns.set(owner.threadId, { owner, computerId: computer.id, botId
|
|
6206
|
+
teamComputerTurns.set(owner.threadId, { owner, computerId: computer.id, botId });
|
|
6193
6207
|
let machine = await boat.findBoat(cfg, ownerId);
|
|
6194
6208
|
if (!machine)
|
|
6195
|
-
throw new Error("The team's
|
|
6209
|
+
throw new Error("The team's cloud computer is missing; explicitly create or retry it from the Team map");
|
|
6196
6210
|
await bindTurnComputer(owner, `computer:box:${machine.id}`, true);
|
|
6197
|
-
const action = boat.boatTurnLifecycleAction(
|
|
6211
|
+
const action = boat.boatTurnLifecycleAction(typeof machine.state === "string" ? machine.state : null);
|
|
6198
6212
|
if (action === "wake")
|
|
6199
6213
|
machine = await boat.readyBoat(cfg, ownerId);
|
|
6200
|
-
if (!machine || boat.boatTurnLifecycleAction(
|
|
6214
|
+
if (!machine || boat.boatTurnLifecycleAction(typeof machine.state === "string" ? machine.state : null) !== "attach") {
|
|
6201
6215
|
throw new Error("The team computer is not ready; check it in the Team map");
|
|
6202
6216
|
}
|
|
6203
6217
|
if (turnResourceOwners.get(owner.threadId)?.generation !== owner.generation ||
|
|
6204
6218
|
!turnResources.owns(`computer:box:${machine.id}`, owner))
|
|
6205
6219
|
throw new Error("This computer turn ended while its machine was starting");
|
|
6206
6220
|
return {
|
|
6207
|
-
mount: cloudComputerMount(botId, owner, machine.id
|
|
6221
|
+
mount: cloudComputerMount(botId, owner, machine.id),
|
|
6208
6222
|
capture: () => boat.screenshotBoat(cfg, ownerId, machine.id),
|
|
6209
6223
|
};
|
|
6210
6224
|
}
|
|
6211
|
-
/**
|
|
6212
|
-
*
|
|
6213
|
-
|
|
6214
|
-
* computer), in the same slot a Local VM or VPS uses: one mechanism on a
|
|
6215
|
-
* desktop, a headless server and an OMB Cloud. The agent process holds only a
|
|
6216
|
-
* turn-scoped capability naming this Boat, never a Boat credential. */
|
|
6217
|
-
function cloudComputerMount(botId, owner, boxId, remoteAgent) {
|
|
6218
|
-
if (remoteAgent)
|
|
6219
|
-
return { computer: { kind: "box", boxId } };
|
|
6220
|
-
const control = controlIntegration(botId, owner.threadId, owner.generation, undefined, boxId);
|
|
6225
|
+
/** The engine fact the cloud-computer rule reads (shared/cloud-computer.ts),
|
|
6226
|
+
* and the model's name for a refusal to say. */
|
|
6227
|
+
function cloudEngine(instance, model) {
|
|
6221
6228
|
return {
|
|
6222
|
-
|
|
6223
|
-
|
|
6224
|
-
|
|
6225
|
-
|
|
6226
|
-
|
|
6227
|
-
|
|
6229
|
+
computerMcp: instance?.adapter.capabilities.computerMcp,
|
|
6230
|
+
name: model?.trim() || instance?.displayName || instance?.driverKind || "This model",
|
|
6231
|
+
};
|
|
6232
|
+
}
|
|
6233
|
+
/** How a turn's engine reaches a cloud computer. Every engine keeps its own
|
|
6234
|
+
* model and sign-in and gets the Boat as one more stdio computer server
|
|
6235
|
+
* (harness-mcp-proxy computer), in the same slot a Local VM or VPS uses: one
|
|
6236
|
+
* mechanism on a desktop, a headless server and an OMB Cloud. The agent
|
|
6237
|
+
* process holds only a turn-scoped capability naming this Boat, never a Boat
|
|
6238
|
+
* credential. */
|
|
6239
|
+
function cloudComputerMount(botId, owner, boxId) {
|
|
6240
|
+
return { localComputer: cloudComputerTools(botId, owner, boxId) };
|
|
6241
|
+
}
|
|
6242
|
+
/** The cloud computer's stdio tools for this turn. `boxId` null: the Boat is
|
|
6243
|
+
* the one the first computer call's claim creates or wakes (mountBotBoat). */
|
|
6244
|
+
function cloudComputerTools(botId, owner, boxId) {
|
|
6245
|
+
const control = controlIntegration(botId, owner.threadId, owner.generation, { boxId });
|
|
6246
|
+
return {
|
|
6247
|
+
command: process.execPath,
|
|
6248
|
+
args: [SPAWNED_PROXIES.harnessMcp, "computer"],
|
|
6249
|
+
env: { ...AGENTS_NODE_FLAG, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`, OMB_MCP_TOKEN: control.token },
|
|
6250
|
+
platform: "linux",
|
|
6228
6251
|
};
|
|
6229
6252
|
}
|
|
6230
6253
|
/** "Works on: This computer", strict. One mount for a bot thread and a group
|
|
@@ -6245,14 +6268,18 @@ async function mountHostComputer(owner, botId, providerSupportsLocal) {
|
|
|
6245
6268
|
: "CUA Driver is not ready for this computer — check permissions and restart OpenMausBot");
|
|
6246
6269
|
}
|
|
6247
6270
|
await bindTurnComputer(owner, "computer:host");
|
|
6248
|
-
return gatedLocalComputer(cua, controlIntegration(botId, owner.threadId, owner.generation));
|
|
6271
|
+
return gatedLocalComputer(cua, controlIntegration(botId, owner.threadId, owner.generation, "this computer"));
|
|
6249
6272
|
}
|
|
6250
6273
|
/** The bot's own VPS desktop, mounted into the local agent. The desktop lease
|
|
6251
6274
|
* is claimed on the first computer call, through the computer-control gate
|
|
6252
6275
|
* (the Local VM's seam, #1361), never at mount: turns that never touch the
|
|
6253
6276
|
* computer tools run side by side. `start` may provision or start the
|
|
6254
|
-
* container; without it the VPS is only inspected.
|
|
6255
|
-
*
|
|
6277
|
+
* container; without it the VPS is only inspected. Unlike the bot's cloud
|
|
6278
|
+
* computer (mountBotBoat), the start stays here, at dispatch: the VPS tools
|
|
6279
|
+
* are a bridge that execs into the running container as soon as the engine
|
|
6280
|
+
* launches it (runMcpBridge), and a self-hosted container uses no plan
|
|
6281
|
+
* hours. The caller has already recorded the thread with vpsThreadStarted
|
|
6282
|
+
* and ends it on a problem. */
|
|
6256
6283
|
async function mountBotVps(bot, owner, opts) {
|
|
6257
6284
|
const vpsResource = `computer:vps:${vpsSshAlias(cfg)}:${bot.id}`;
|
|
6258
6285
|
const remote = opts.start
|
|
@@ -6283,53 +6310,113 @@ async function mountBotVps(bot, owner, opts) {
|
|
|
6283
6310
|
},
|
|
6284
6311
|
};
|
|
6285
6312
|
}
|
|
6286
|
-
/**
|
|
6287
|
-
*
|
|
6288
|
-
*
|
|
6289
|
-
|
|
6290
|
-
|
|
6291
|
-
|
|
6292
|
-
|
|
6293
|
-
|
|
6294
|
-
|
|
6295
|
-
|
|
6296
|
-
|
|
6297
|
-
|
|
6298
|
-
|
|
6299
|
-
|
|
6300
|
-
|
|
6301
|
-
|
|
6302
|
-
|
|
6303
|
-
|
|
6304
|
-
|
|
6305
|
-
|
|
6306
|
-
|
|
6307
|
-
|
|
6308
|
-
|
|
6309
|
-
|
|
6310
|
-
|
|
6313
|
+
/** How long one computer call waits on a cloud computer that is being
|
|
6314
|
+
* created or woken before it is told to look again. Under the 60 seconds an
|
|
6315
|
+
* engine may give one tool call (Codex's default); a call that runs out finds
|
|
6316
|
+
* the start still going and waits again, inside the start's own budget.
|
|
6317
|
+
* OMB_CLOUD_COMPUTER_START_WAIT_MS shortens it for tests. */
|
|
6318
|
+
const CLOUD_COMPUTER_START_WAIT_MS = Math.max(1_000, Number(process.env.OMB_CLOUD_COMPUTER_START_WAIT_MS) || 45_000);
|
|
6319
|
+
/** How long the start itself may take once this turn has the computer to
|
|
6320
|
+
* itself: above a slow first start (about a minute; Boat's own ready wait is
|
|
6321
|
+
* 90 seconds). Past it the start has failed, and the turn says so and ends,
|
|
6322
|
+
* instead of every call answering "still starting" until the watchdog.
|
|
6323
|
+
* OMB_CLOUD_COMPUTER_START_BUDGET_MS shortens it for tests. */
|
|
6324
|
+
const CLOUD_COMPUTER_START_BUDGET_MS = Math.max(1_000, Number(process.env.OMB_CLOUD_COMPUTER_START_BUDGET_MS) || 150_000);
|
|
6325
|
+
const CLOUD_COMPUTER_STARTING = "The cloud computer is still starting. This call was not performed. Take a fresh screenshot in a moment.";
|
|
6326
|
+
const CLOUD_COMPUTER_DID_NOT_START = "the cloud computer didn't start in time";
|
|
6327
|
+
/** The bot's own Boat as a computer its engine uses, mounted like the VPS
|
|
6328
|
+
* (mountBotVps): the tools mount now, and the Boat is created or woken by
|
|
6329
|
+
* the turn's first computer call, through the computer-control gate (the
|
|
6330
|
+
* Local VM's seam, #1361), never at mount. A turn that never touches the
|
|
6331
|
+
* screen ("hi") makes no Boat call at all, so it costs no plan hours. The
|
|
6332
|
+
* claim is attachBotBoat itself, with its explicit-Cloud consent: the person
|
|
6333
|
+
* chose this place, and the bot is now using it. */
|
|
6334
|
+
function mountBotBoat(bot, owner, opts) {
|
|
6335
|
+
const slot = {
|
|
6336
|
+
owner,
|
|
6337
|
+
lazy: true,
|
|
6338
|
+
startsComputer: true,
|
|
6339
|
+
label: "the cloud computer",
|
|
6340
|
+
graceMs: CLOUD_COMPUTER_START_WAIT_MS,
|
|
6341
|
+
// Until this turn has the computer to itself another conversation does,
|
|
6342
|
+
// and a call is told so (the gate's contention text); from then on the
|
|
6343
|
+
// claim is starting it.
|
|
6344
|
+
onRejected: opts.onRejected,
|
|
6345
|
+
claim: async () => {
|
|
6346
|
+
let attached;
|
|
6347
|
+
try {
|
|
6348
|
+
attached = await attachBotBoat(bot, owner, {
|
|
6349
|
+
budgetMs: CLOUD_COMPUTER_START_BUDGET_MS,
|
|
6350
|
+
onSeated: () => { slot.pendingReason = CLOUD_COMPUTER_STARTING; },
|
|
6351
|
+
});
|
|
6352
|
+
if (!attached)
|
|
6353
|
+
throw new Error("the cloud computer could not be created or reached");
|
|
6354
|
+
}
|
|
6355
|
+
catch (error) {
|
|
6356
|
+
throw opts.source ? await asPlaceFailure(error, "cloud", opts.source, bot, "box") : error;
|
|
6357
|
+
}
|
|
6358
|
+
slot.boxId = attached.boxId;
|
|
6359
|
+
opts.onClaimed(attached.capture);
|
|
6360
|
+
},
|
|
6361
|
+
};
|
|
6362
|
+
autoVmClaims.set(owner.threadId, slot);
|
|
6363
|
+
return cloudComputerTools(bot.id, owner, null);
|
|
6364
|
+
}
|
|
6365
|
+
/** The Boat a lazily mounted cloud computer's claim landed on, for this
|
|
6366
|
+
* exact turn; none while the claim is still running or after it failed. */
|
|
6367
|
+
function claimedBoatId(capability) {
|
|
6368
|
+
const slot = autoVmClaims.get(capability.threadId);
|
|
6369
|
+
return slot?.owner.generation === capability.generation && slot.claimed ? slot.boxId : undefined;
|
|
6370
|
+
}
|
|
6371
|
+
/** The bot's own Boat, for a turn whose place is Cloud. That choice is the
|
|
6372
|
+
* consent boundary: it may create a missing machine and wake an archived one
|
|
6373
|
+
* (~8s, and it un-pauses billing). Auto never reaches here. Callers have
|
|
6374
|
+
* already checked that the engine can work on it (cloudPlaceRefusal).
|
|
6375
|
+
* Every engine reaches it through mountBotBoat, on its first computer call:
|
|
6376
|
+
* told once this turn has the Boat to itself, it has `budgetMs` to find,
|
|
6377
|
+
* create or wake it. */
|
|
6378
|
+
async function attachBotBoat(bot, owner, start) {
|
|
6379
|
+
// Claim before any network await so setup itself cannot race another turn.
|
|
6380
|
+
await bindTurnComputer(owner, `computer:box-bot:${bot.id}`, true);
|
|
6381
|
+
start.onSeated();
|
|
6382
|
+
const machine = await withinBudget(startBotBoat(bot), start.budgetMs, CLOUD_COMPUTER_DID_NOT_START);
|
|
6383
|
+
if (!machine)
|
|
6384
|
+
return null;
|
|
6385
|
+
await bindTurnComputer(owner, `computer:box:${machine.id}`);
|
|
6386
|
+
return {
|
|
6387
|
+
boxId: machine.id,
|
|
6388
|
+
capture: () => boat.screenshotBoat(cfg, bot.id, machine.id),
|
|
6389
|
+
};
|
|
6390
|
+
}
|
|
6391
|
+
/** `work`, or a rejection with `message` once `ms` pass first. The work is
|
|
6392
|
+
* not cancelled: it ends on its own requests' deadlines, and the race has
|
|
6393
|
+
* already handled a rejection it brings later. */
|
|
6394
|
+
function withinBudget(work, ms, message) {
|
|
6395
|
+
let timer;
|
|
6396
|
+
const expired = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); });
|
|
6397
|
+
return Promise.race([work, expired]).finally(() => clearTimeout(timer));
|
|
6398
|
+
}
|
|
6399
|
+
/** Find the bot's Boat, creating or waking it: the Boat once it can be
|
|
6400
|
+
* attached, or null. */
|
|
6401
|
+
async function startBotBoat(bot) {
|
|
6402
|
+
let b = await boat.findBoat(cfg, bot.id);
|
|
6403
|
+
const lifecycleOf = () => boat.boatTurnLifecycleAction(typeof b?.state === "string" ? b.state : null);
|
|
6404
|
+
let lifecycle = lifecycleOf();
|
|
6311
6405
|
if (lifecycle === "provision") {
|
|
6312
|
-
broadcast({ kind: "computer", botId: bot.id, state: "provisioning" });
|
|
6406
|
+
broadcast({ kind: "computer", botId: bot.id, state: "provisioning", place: "cloud" });
|
|
6313
6407
|
await boat.provisionBoat(cfg, bot.id, bot.name);
|
|
6314
6408
|
b = await boat.findBoat(cfg, bot.id);
|
|
6315
|
-
lifecycle = lifecycleOf(
|
|
6409
|
+
lifecycle = lifecycleOf();
|
|
6316
6410
|
}
|
|
6317
6411
|
// an archived boat answers every action with an error until it resumes —
|
|
6318
6412
|
// wake it here, once, instead of letting the agent discover it one failed
|
|
6319
6413
|
// tool call at a time.
|
|
6320
6414
|
if (lifecycle === "wake") {
|
|
6321
|
-
broadcast({ kind: "computer", botId: bot.id, state: "waking" });
|
|
6415
|
+
broadcast({ kind: "computer", botId: bot.id, state: "waking", place: "cloud" });
|
|
6322
6416
|
b = (await boat.readyBoat(cfg, bot.id)) ?? b;
|
|
6323
|
-
lifecycle = lifecycleOf(
|
|
6417
|
+
lifecycle = lifecycleOf();
|
|
6324
6418
|
}
|
|
6325
|
-
|
|
6326
|
-
return null;
|
|
6327
|
-
const machine = b;
|
|
6328
|
-
await bindTurnComputer(owner, `computer:box:${machine.id}`, opts.remoteAgent);
|
|
6329
|
-
return {
|
|
6330
|
-
capture: () => boat.screenshotBoat(cfg, bot.id, machine.id),
|
|
6331
|
-
mount: opts.canMount ? cloudComputerMount(bot.id, owner, machine.id, opts.remoteAgent) : null,
|
|
6332
|
-
};
|
|
6419
|
+
return b && lifecycle === "attach" ? b : null;
|
|
6333
6420
|
}
|
|
6334
6421
|
function managedBoatOwners() {
|
|
6335
6422
|
return [...store.bots.map((bot) => ({
|
|
@@ -6401,8 +6488,6 @@ function turnProvider(bot, runOn, threadId) {
|
|
|
6401
6488
|
const wants = turnSurfacePlan(bot, runOn, threadId).computer;
|
|
6402
6489
|
if (wants !== undefined && wants !== "cloud")
|
|
6403
6490
|
return null;
|
|
6404
|
-
if (registry.get(bot.modelSelection.instanceId)?.adapter.capabilities.remoteAgent === true)
|
|
6405
|
-
return "box";
|
|
6406
6491
|
return bot.cloudBackend === "vps" ? "vps" : wants === "cloud" ? "box" : null;
|
|
6407
6492
|
}
|
|
6408
6493
|
/** Preview requests carry the selected conversation, not whichever thread
|
|
@@ -6421,8 +6506,6 @@ async function computerPreviewSurface(bot, threadId) {
|
|
|
6421
6506
|
if (plan.computer !== undefined)
|
|
6422
6507
|
return plan.computer === "off" && plan.browser ? "browser" : plan.computer;
|
|
6423
6508
|
const instance = registry.get(bot.modelSelection.instanceId);
|
|
6424
|
-
if (instance?.adapter.capabilities.remoteAgent === true)
|
|
6425
|
-
return "cloud";
|
|
6426
6509
|
if (bot.cloudBackend === "vps") {
|
|
6427
6510
|
const remote = await vps.vpsComputerStatus(cfg, bot.id);
|
|
6428
6511
|
if (remote.ready)
|
|
@@ -6447,9 +6530,11 @@ async function computerPreviewSurface(bot, threadId) {
|
|
|
6447
6530
|
* A Cloud home lists only the places it has (cloud-home.ts), so a bot never
|
|
6448
6531
|
* tells the person to set up this computer or a Local VM there. */
|
|
6449
6532
|
async function selectableComputers(bot) {
|
|
6450
|
-
const
|
|
6533
|
+
const instance = registry.get(bot.modelSelection.instanceId);
|
|
6534
|
+
const caps = instance?.adapter.capabilities;
|
|
6451
6535
|
const off = bot.computer === "off";
|
|
6452
|
-
|
|
6536
|
+
// A desktop the bot's tools cannot reach is not offered, and not probed.
|
|
6537
|
+
const toolsLeaveOut = computerToolsRefusal(bot.toolScope, "works-on", bot.name)?.message;
|
|
6453
6538
|
const surfaces = ["cloud", "vm", "local", "browser"].filter(surface => !CLOUD_HOME || cloudHomeOffersPlace(surface));
|
|
6454
6539
|
return Promise.all(surfaces.map(async (surface) => {
|
|
6455
6540
|
let ready = false;
|
|
@@ -6463,24 +6548,31 @@ async function selectableComputers(bot) {
|
|
|
6463
6548
|
try {
|
|
6464
6549
|
if (off)
|
|
6465
6550
|
reason = "Computer access is Off in this bot's settings.";
|
|
6551
|
+
else if (toolsLeaveOut && surface !== "browser")
|
|
6552
|
+
reason = toolsLeaveOut;
|
|
6466
6553
|
else if (surface === "cloud") {
|
|
6467
6554
|
if (bot.cloudBackend === "vps") {
|
|
6468
|
-
const status =
|
|
6555
|
+
const status = canWorkOnCloud(cloudEngine(instance)) ? await vps.vpsComputerStatus(cfg, bot.id) : null;
|
|
6469
6556
|
ready = status?.ready === true;
|
|
6470
6557
|
canStart = Boolean(status?.daemonUp && status.managed && status.container === "stopped" &&
|
|
6471
6558
|
status.image && status.imageMatches && status.network === "private" && status.mounts === "none" && status.security === "hardened");
|
|
6472
6559
|
canCreate = Boolean(status?.configured && status.daemonUp && status.container === "missing");
|
|
6473
6560
|
reason = status?.problem ?? reason;
|
|
6474
6561
|
}
|
|
6475
|
-
else if (boat.boatConfigured(cfg) &&
|
|
6562
|
+
else if (boat.boatConfigured(cfg) && canWorkOnCloud(cloudEngine(instance))) {
|
|
6476
6563
|
const status = await boat.boatStatus(cfg, bot.id);
|
|
6477
|
-
const lifecycle = boat.boatTurnLifecycleAction(
|
|
6564
|
+
const lifecycle = boat.boatTurnLifecycleAction(status.box?.state ?? null);
|
|
6478
6565
|
ready = lifecycle === "attach";
|
|
6479
6566
|
canStart = lifecycle === "wake";
|
|
6480
6567
|
canCreate = lifecycle === "provision";
|
|
6481
6568
|
}
|
|
6569
|
+
else {
|
|
6570
|
+
// The words the person reads for the same place (shared/place-view.ts).
|
|
6571
|
+
reason = (cloudPlaceRefusal(cloudEngine(instance, bot.modelSelection.model), "works-on", bot.name)
|
|
6572
|
+
?? boatNotConfigured(bot, "works-on")).message;
|
|
6573
|
+
}
|
|
6482
6574
|
}
|
|
6483
|
-
else if (surface === "vm" &&
|
|
6575
|
+
else if (surface === "vm" && caps?.computerMcp) {
|
|
6484
6576
|
const target = localVmTargetForStatus(bot.id);
|
|
6485
6577
|
const status = await containerComputerStatus(undefined, undefined, target);
|
|
6486
6578
|
ready = status.ready;
|
|
@@ -6553,7 +6645,7 @@ function continueComputerSelection(threadId, generation, succeeded) {
|
|
|
6553
6645
|
const runningTurnEngines = new Map();
|
|
6554
6646
|
function runningTurnInstance(bot, threadId) {
|
|
6555
6647
|
// A turn always runs on the bot's own engine, wherever it works.
|
|
6556
|
-
return runningTurnEngines.get(threadId) ?? registry.get(bot.modelSelection.instanceId);
|
|
6648
|
+
return runningTurnEngines.get(threadId) ?? (bot ? registry.get(bot.modelSelection.instanceId) : null);
|
|
6557
6649
|
}
|
|
6558
6650
|
function providerTransitionForTurn(bot, runOn, threadId) {
|
|
6559
6651
|
const provider = turnProvider(bot, runOn, threadId);
|
|
@@ -6934,38 +7026,6 @@ bus.subscribe((event) => {
|
|
|
6934
7026
|
if (touches || /computer|screenshot|click|type_text|press_key|scroll|open_url|wait_for|browser_/i.test(toolName)) {
|
|
6935
7027
|
pokeScreenPoller(event.threadId, touches, surface);
|
|
6936
7028
|
}
|
|
6937
|
-
// A completed screen-touching computer tool is real screen
|
|
6938
|
-
// activity (#1653): it restarts the idle clock on that turn's
|
|
6939
|
-
// computer claim. The poller's own frames never arrive here, so
|
|
6940
|
-
// they cannot keep a quiet seat held.
|
|
6941
|
-
if (touches && surface === "computer") {
|
|
6942
|
-
const computer = turnComputerResources.get(event.threadId);
|
|
6943
|
-
if (computer) {
|
|
6944
|
-
// A quiet window cannot expire while this call executes.
|
|
6945
|
-
turnResources.endComputerCall(computer.resource, computer.owner);
|
|
6946
|
-
turnResources.activity(computer.resource, computer.owner);
|
|
6947
|
-
}
|
|
6948
|
-
else {
|
|
6949
|
-
// The call never forwarded onto a bound desktop, or the lazy
|
|
6950
|
-
// bind never landed: drop its pending start so it cannot
|
|
6951
|
-
// fence a claim taken later (#1653).
|
|
6952
|
-
const pending = pendingComputerCallStarts.get(event.threadId);
|
|
6953
|
-
if (pending) {
|
|
6954
|
-
if (pending.count > 1)
|
|
6955
|
-
pendingComputerCallStarts.set(event.threadId, { ...pending, count: pending.count - 1 });
|
|
6956
|
-
else
|
|
6957
|
-
pendingComputerCallStarts.delete(event.threadId);
|
|
6958
|
-
}
|
|
6959
|
-
}
|
|
6960
|
-
// Real cloud-screen work refreshes the #1655 idle stop for
|
|
6961
|
-
// that seat; the poller's own frames never arrive here, so
|
|
6962
|
-
// preview traffic cannot keep a paid machine awake.
|
|
6963
|
-
if (computer?.resource.startsWith("computer:box:")) {
|
|
6964
|
-
const seatBot = store.botByThread(event.threadId);
|
|
6965
|
-
if (seatBot)
|
|
6966
|
-
cloudSeatLeases.get(seatBot.id)?.touch();
|
|
6967
|
-
}
|
|
6968
|
-
}
|
|
6969
7029
|
}
|
|
6970
7030
|
}
|
|
6971
7031
|
break;
|
|
@@ -6988,26 +7048,6 @@ bus.subscribe((event) => {
|
|
|
6988
7048
|
});
|
|
6989
7049
|
if (event.itemId)
|
|
6990
7050
|
toolMessageByItem.set(`${event.threadId}:${event.itemId}`, message.id);
|
|
6991
|
-
// A screen-touching computer tool opening is an in-flight call on
|
|
6992
|
-
// that turn's claim (#1653): the seat cannot be idle-released out
|
|
6993
|
-
// from under I/O the desktop is still executing. Its completion —
|
|
6994
|
-
// or the turn settling — drops the fence.
|
|
6995
|
-
if (screenTouchingTool(name) && screenSurfaceForTool(name) === "computer") {
|
|
6996
|
-
const computer = turnComputerResources.get(event.threadId);
|
|
6997
|
-
if (computer) {
|
|
6998
|
-
turnResources.beginComputerCall(computer.resource, computer.owner);
|
|
6999
|
-
}
|
|
7000
|
-
else {
|
|
7001
|
-
// A lazy attach (#1361) may not have seated the claim yet: the
|
|
7002
|
-
// gate finishes the bind before the call forwards, so hold the
|
|
7003
|
-
// start here and let bindTurnComputer fence the fresh claim.
|
|
7004
|
-
const generation = activeInternalGenerationByThread.get(event.threadId);
|
|
7005
|
-
if (generation) {
|
|
7006
|
-
const pending = pendingComputerCallStarts.get(event.threadId);
|
|
7007
|
-
pendingComputerCallStarts.set(event.threadId, pending?.generation === generation ? { generation, count: pending.count + 1 } : { generation, count: 1 });
|
|
7008
|
-
}
|
|
7009
|
-
}
|
|
7010
|
-
}
|
|
7011
7051
|
}
|
|
7012
7052
|
break;
|
|
7013
7053
|
case "request.opened": {
|
|
@@ -7019,8 +7059,9 @@ bus.subscribe((event) => {
|
|
|
7019
7059
|
// A permission request here is one the provider left for a person: its
|
|
7020
7060
|
// own mode already ran (Ask, Edits, Auto's reviewer, Custom's config).
|
|
7021
7061
|
// Only the person's explicit Full access or exact saved command answers.
|
|
7022
|
-
// The app does not guess whether
|
|
7023
|
-
// always reaches the human — even Full
|
|
7062
|
+
// Approve for me allows a web search. The app does not guess whether
|
|
7063
|
+
// the action is safe. A QUESTION always reaches the human — even Full
|
|
7064
|
+
// access never invents an answer.
|
|
7024
7065
|
const asker = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
|
|
7025
7066
|
const unattended = permission && asker && event.requestId ? isUnattended(asker.id, event.threadId) : false;
|
|
7026
7067
|
// A turn a guest drives on a Cloud home is Ask, room turns included,
|
|
@@ -7082,9 +7123,9 @@ bus.subscribe((event) => {
|
|
|
7082
7123
|
if (outcome !== "unavailable")
|
|
7083
7124
|
pushMessage({
|
|
7084
7125
|
role: "bot", kind: "activity",
|
|
7085
|
-
tool:
|
|
7086
|
-
|
|
7087
|
-
|
|
7126
|
+
tool: outcome === "rejected"
|
|
7127
|
+
? { name: `The provider rejected this action despite ${verdict.source === "command-allowlist" ? "the saved command permission" : "Full access"}.`, ok: false }
|
|
7128
|
+
: failedTurnTool("could not deliver approval to the provider; retry the task after reconnecting."),
|
|
7088
7129
|
});
|
|
7089
7130
|
return;
|
|
7090
7131
|
}
|
|
@@ -7176,6 +7217,8 @@ bus.subscribe((event) => {
|
|
|
7176
7217
|
store.setTaskActivity(bot.id, event.threadId, "waiting-on-you");
|
|
7177
7218
|
else if (asker.busy)
|
|
7178
7219
|
store.setActivity(asker.id, "waiting-on-you");
|
|
7220
|
+
if (event.requestId)
|
|
7221
|
+
showWaitingOnPersonChip(event.threadId, event.requestId, asker, permission ? "approval" : "answer");
|
|
7179
7222
|
const notificationBot = (routineRun && routineSourceOwner(routineRun)?.bot) || asker;
|
|
7180
7223
|
notify(buildNotification(permission ? "approval" : "question", notificationBot, (routineRun && routineSourceThread(routineRun)) || event.threadId, event.summary));
|
|
7181
7224
|
}
|
|
@@ -7185,6 +7228,8 @@ bus.subscribe((event) => {
|
|
|
7185
7228
|
case "request.resolved": {
|
|
7186
7229
|
if (event.requestId)
|
|
7187
7230
|
pendingCommandRules.delete(`${event.threadId}:${event.requestId}`);
|
|
7231
|
+
if (event.requestId)
|
|
7232
|
+
settleWaitingOnPersonChips(event.threadId, event.requestId);
|
|
7188
7233
|
// answered (by whoever): the turn is working again, unless it settled
|
|
7189
7234
|
const waiting = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
|
|
7190
7235
|
if (bot && store.taskByThread(bot.id, event.threadId)?.activity === "waiting-on-you") {
|
|
@@ -7230,14 +7275,7 @@ bus.subscribe((event) => {
|
|
|
7230
7275
|
pushMessage({
|
|
7231
7276
|
role: "bot",
|
|
7232
7277
|
kind: "activity",
|
|
7233
|
-
tool:
|
|
7234
|
-
// a setup error carries its fix, which must not be cut mid-command
|
|
7235
|
-
name: `error: ${event.message.slice(0, event.setup ? 320 : 160)}`,
|
|
7236
|
-
ok: false,
|
|
7237
|
-
setup: event.setup,
|
|
7238
|
-
...(event.terminal ? { terminal: true } : {}),
|
|
7239
|
-
...(event.claudeUpdate ? { claudeUpdate: true } : {}),
|
|
7240
|
-
},
|
|
7278
|
+
tool: failedTurnTool(event.message, event),
|
|
7241
7279
|
});
|
|
7242
7280
|
// a setup error means the engine could not even start: the bot is
|
|
7243
7281
|
// dead until something changes, not merely idle. The next successful
|
|
@@ -7255,6 +7293,7 @@ bus.subscribe((event) => {
|
|
|
7255
7293
|
}
|
|
7256
7294
|
break;
|
|
7257
7295
|
case "turn.completed": {
|
|
7296
|
+
settleWaitingOnPersonChips(event.threadId);
|
|
7258
7297
|
// A peer-started turn settles as coordination, not as news. What keeps
|
|
7259
7298
|
// that classification from outliving its turn is the rewrite at
|
|
7260
7299
|
// dispatch, not this line — releasing it here too is hygiene, so a
|
|
@@ -7472,8 +7511,8 @@ bus.subscribe((event) => {
|
|
|
7472
7511
|
// still be in flight).
|
|
7473
7512
|
//
|
|
7474
7513
|
// Keep the thread busy until its bounded final capture releases
|
|
7475
|
-
// the screen
|
|
7476
|
-
//
|
|
7514
|
+
// the screen: an accepted follow-up must not take the desktop
|
|
7515
|
+
// before the END-state frame is captured.
|
|
7477
7516
|
const settleLeafId = store.activePath(event.threadId).at(-1)?.id;
|
|
7478
7517
|
let timeout;
|
|
7479
7518
|
const screenSettled = Promise.race([
|
|
@@ -7565,6 +7604,57 @@ bus.subscribe((event) => {
|
|
|
7565
7604
|
* channel that only ever shows requests is half a record. Mirror the
|
|
7566
7605
|
* reply on success; mirror a failed/stopped terminal chip otherwise. */
|
|
7567
7606
|
const delegationWatch = new Map();
|
|
7607
|
+
/** The card in this thread that is waiting on the person: an approval, a
|
|
7608
|
+
* question or a proposal to review, newest first. */
|
|
7609
|
+
function openPersonCard(threadId) {
|
|
7610
|
+
const messages = store.messagesFor(threadId);
|
|
7611
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
7612
|
+
const card = messages[i].kind === "options" ? messages[i].card : undefined;
|
|
7613
|
+
const kind = liveCardKind(card);
|
|
7614
|
+
if (kind)
|
|
7615
|
+
return { kind, tool: card?.tool };
|
|
7616
|
+
}
|
|
7617
|
+
return undefined;
|
|
7618
|
+
}
|
|
7619
|
+
/** The conversation waiting on work that runs in this thread: a Chief's
|
|
7620
|
+
* delegate_bot task or coordinate_bots assignment. */
|
|
7621
|
+
function delegatorOf(threadId) {
|
|
7622
|
+
const watch = delegationWatch.get(threadId);
|
|
7623
|
+
if (watch?.sourceThreadId)
|
|
7624
|
+
return { threadId: watch.sourceThreadId, botId: watch.sourceBotId };
|
|
7625
|
+
const parent = roomHandoffs.assignerOf(threadId);
|
|
7626
|
+
return parent ? { threadId: parent.threadId, botId: parent.botId } : undefined;
|
|
7627
|
+
}
|
|
7628
|
+
// MOCA-274: a delegated teammate's card used to live only in its own thread,
|
|
7629
|
+
// so the person talking to the Chief never saw that work was waiting on them.
|
|
7630
|
+
// A chip in the delegating conversation points at the card until it settles.
|
|
7631
|
+
const waitingOnPersonChips = new Map();
|
|
7632
|
+
function showWaitingOnPersonChip(threadId, requestId, asker, kind) {
|
|
7633
|
+
const delegator = delegatorOf(threadId);
|
|
7634
|
+
if (!delegator || delegator.threadId === threadId)
|
|
7635
|
+
return;
|
|
7636
|
+
const chip = store.appendMessage(delegator.threadId, { role: "bot", kind: "activity",
|
|
7637
|
+
from: { botId: asker.id, name: asker.name, color: asker.color },
|
|
7638
|
+
tool: { name: `Waiting on your ${kind} in @${asker.name}` },
|
|
7639
|
+
threadRef: { botId: asker.id, threadId, title: store.taskByThread(asker.id, threadId)?.title ?? asker.name },
|
|
7640
|
+
});
|
|
7641
|
+
waitingOnPersonChips.set(`${threadId}:${requestId}`, { threadId: delegator.threadId, messageId: chip.id, name: asker.name, kind });
|
|
7642
|
+
}
|
|
7643
|
+
/** Settle the chip for one answered card, or every chip for this thread once
|
|
7644
|
+
* its turn ends (a card nobody answered can no longer be answered). */
|
|
7645
|
+
function settleWaitingOnPersonChips(threadId, requestId) {
|
|
7646
|
+
for (const [key, chip] of waitingOnPersonChips) {
|
|
7647
|
+
if (requestId ? key !== `${threadId}:${requestId}` : !key.startsWith(`${threadId}:`))
|
|
7648
|
+
continue;
|
|
7649
|
+
waitingOnPersonChips.delete(key);
|
|
7650
|
+
const existing = store.messagesFor(chip.threadId).find((message) => message.id === chip.messageId);
|
|
7651
|
+
if (!existing?.tool)
|
|
7652
|
+
continue;
|
|
7653
|
+
store.patchMessage(chip.threadId, chip.messageId, {
|
|
7654
|
+
tool: { ...existing.tool, name: requestId ? `@${chip.name} got your ${chip.kind}` : `@${chip.name} is no longer waiting on your ${chip.kind}`, ok: true },
|
|
7655
|
+
});
|
|
7656
|
+
}
|
|
7657
|
+
}
|
|
7568
7658
|
// Peer wake: when a delegated reply lands, resume the source bot so it can
|
|
7569
7659
|
// fold the result in and answer the user instead of sitting idle. Mirrors
|
|
7570
7660
|
// the cardContinuation resume pattern used for connector/credential cards.
|
|
@@ -7608,10 +7698,7 @@ function dispatchDelegationWake(botId, threadId, targetName, failureReason, rout
|
|
|
7608
7698
|
store.appendMessage(threadId, {
|
|
7609
7699
|
role: "bot",
|
|
7610
7700
|
kind: "activity",
|
|
7611
|
-
tool: {
|
|
7612
|
-
name: `error: could not resume after delegation — ${message.slice(0, 120)}`,
|
|
7613
|
-
ok: false,
|
|
7614
|
-
},
|
|
7701
|
+
tool: failedTurnTool(`could not resume after delegation — ${message}`),
|
|
7615
7702
|
});
|
|
7616
7703
|
routines?.failThread(threadId, `Could not resume after delegation: ${message}`);
|
|
7617
7704
|
});
|
|
@@ -7713,7 +7800,7 @@ function reportIncident(input) {
|
|
|
7713
7800
|
store.appendMessage(incidents.threadId, {
|
|
7714
7801
|
role: "bot",
|
|
7715
7802
|
kind: "activity",
|
|
7716
|
-
tool:
|
|
7803
|
+
tool: failedTurnTool(`the incident could not reach ${chief.name} — ${why}`),
|
|
7717
7804
|
});
|
|
7718
7805
|
tellThePerson();
|
|
7719
7806
|
});
|
|
@@ -7998,12 +8085,15 @@ function finalizeDelegationWatch(threadId, ok, reply = "", failureName = "Delega
|
|
|
7998
8085
|
// with the reply only visible in the thread (the "delegated and went
|
|
7999
8086
|
// silent" gap). Failures wake it too — the user must hear the task did
|
|
8000
8087
|
// not finish. Idle-checked and burst-capped so a busy source or a
|
|
8001
|
-
// re-delegating loop cannot spin up runs.
|
|
8002
|
-
|
|
8003
|
-
|
|
8004
|
-
|
|
8005
|
-
|
|
8006
|
-
|
|
8088
|
+
// re-delegating loop cannot spin up runs. An external runtime polls
|
|
8089
|
+
// for the receipt instead; waking would answer it a second time.
|
|
8090
|
+
if (watched.completionOwner !== "external") {
|
|
8091
|
+
if (ok) {
|
|
8092
|
+
wakeDelegationSource(source, watched.sourceThreadId, targetName, undefined, watched.routineRunId);
|
|
8093
|
+
}
|
|
8094
|
+
else {
|
|
8095
|
+
wakeDelegationSource(source, watched.sourceThreadId, targetName, failureName || "the delegated turn did not finish", watched.routineRunId);
|
|
8096
|
+
}
|
|
8007
8097
|
}
|
|
8008
8098
|
}
|
|
8009
8099
|
}
|
|
@@ -8058,7 +8148,7 @@ bus.subscribe((event) => {
|
|
|
8058
8148
|
/** How a drained delegation becomes a real turn on the target. Shared by
|
|
8059
8149
|
* the settle-time drain and the boot-time drain of what a previous process
|
|
8060
8150
|
* left queued. */
|
|
8061
|
-
const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel, taskId, sourceBotId, openedThreadId, oneWay) => {
|
|
8151
|
+
const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel, taskId, sourceBotId, openedThreadId, oneWay, completionOwner) => {
|
|
8062
8152
|
// startTurn REJECTS on an ordinary condition — busy target, deleted bot,
|
|
8063
8153
|
// unavailable provider. Unhandled, that rejection is fatal to the
|
|
8064
8154
|
// harness (Node's default), which in the packaged app kills the server
|
|
@@ -8092,6 +8182,7 @@ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel,
|
|
|
8092
8182
|
routineRunId: activeRoutineRunForThread(sourceThreadId)?.id,
|
|
8093
8183
|
startedAtMs: Date.now(),
|
|
8094
8184
|
...(oneWay ? { oneWay: true } : {}),
|
|
8185
|
+
...(completionOwner ? { completionOwner } : {}),
|
|
8095
8186
|
});
|
|
8096
8187
|
}
|
|
8097
8188
|
let failureReported = false;
|
|
@@ -8112,7 +8203,7 @@ const runDelegatedTurn = (toBotId, rawText, commsDepth, sourceThreadId, channel,
|
|
|
8112
8203
|
store.appendMessage(sourceThreadId, {
|
|
8113
8204
|
role: "bot",
|
|
8114
8205
|
kind: "activity",
|
|
8115
|
-
tool:
|
|
8206
|
+
tool: failedTurnTool(`delegation to @${bot?.name ?? toBotId} could not start — ${why}`),
|
|
8116
8207
|
});
|
|
8117
8208
|
};
|
|
8118
8209
|
return startTurn(toBotId, text, {
|
|
@@ -8224,10 +8315,7 @@ function drainQueuedSends() {
|
|
|
8224
8315
|
}).catch((err) => {
|
|
8225
8316
|
store.appendMessage(threadId, {
|
|
8226
8317
|
role: "bot", kind: "activity",
|
|
8227
|
-
tool: {
|
|
8228
|
-
name: `error: queued message could not start — ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`,
|
|
8229
|
-
ok: false,
|
|
8230
|
-
},
|
|
8318
|
+
tool: failedTurnTool(`queued message could not start — ${err instanceof Error ? err.message : String(err)}`),
|
|
8231
8319
|
});
|
|
8232
8320
|
resolve();
|
|
8233
8321
|
// M2: only this group left the queue, and a turn that never
|
|
@@ -8284,10 +8372,7 @@ function drainAsideLane() {
|
|
|
8284
8372
|
}).catch((err) => {
|
|
8285
8373
|
store.appendMessage(threadId, {
|
|
8286
8374
|
role: "bot", kind: "activity",
|
|
8287
|
-
tool: {
|
|
8288
|
-
name: `error: queued aside could not start — ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`,
|
|
8289
|
-
ok: false,
|
|
8290
|
-
},
|
|
8375
|
+
tool: failedTurnTool(`queued aside could not start — ${err instanceof Error ? err.message : String(err)}`),
|
|
8291
8376
|
});
|
|
8292
8377
|
resolve();
|
|
8293
8378
|
}).catch(reject);
|
|
@@ -8579,7 +8664,7 @@ async function startOrQueueOpenedThread(botId, threadId, text, unattended) {
|
|
|
8579
8664
|
store.appendMessage(threadId, {
|
|
8580
8665
|
role: "bot",
|
|
8581
8666
|
kind: "activity",
|
|
8582
|
-
tool:
|
|
8667
|
+
tool: failedTurnTool(`this thread could not start — ${why}`),
|
|
8583
8668
|
});
|
|
8584
8669
|
return { state: "failed", error: why };
|
|
8585
8670
|
}
|
|
@@ -8592,10 +8677,9 @@ const screenPollers = new Map();
|
|
|
8592
8677
|
const SCREEN_POLL_MS = 6000;
|
|
8593
8678
|
const SCREEN_MIN_GAP_MS = 3000;
|
|
8594
8679
|
const SCREEN_SETTLE_TIMEOUT_MS = 10_000;
|
|
8595
|
-
/** `screenIsTheWork` starts the
|
|
8596
|
-
*
|
|
8597
|
-
*
|
|
8598
|
-
* shell-only turns are kept honest by the settle-time hash gate instead. */
|
|
8680
|
+
/** `screenIsTheWork` starts the poller already counting as screen usage: a
|
|
8681
|
+
* poller restarted mid-turn (a lazy claim swapping in its capture) carries
|
|
8682
|
+
* over whether the turn had touched its screen. */
|
|
8599
8683
|
function startScreenPoller(botId, threadId, captures, { screenIsTheWork = false } = {}) {
|
|
8600
8684
|
if (!captures.computer && !captures.browser)
|
|
8601
8685
|
return;
|
|
@@ -8687,10 +8771,9 @@ function shownScreenHash(threadId) {
|
|
|
8687
8771
|
* end state, not the previous action's. A turn that never touched the
|
|
8688
8772
|
* screen settles nothing — and skips the capture, which is one less
|
|
8689
8773
|
* command on the boat's single endpoint. A frame the reader can already see
|
|
8690
|
-
* settles nothing either
|
|
8691
|
-
*
|
|
8692
|
-
*
|
|
8693
|
-
* per-turn state survives the turn. */
|
|
8774
|
+
* settles nothing either, so a turn never ends on the same idle desktop it
|
|
8775
|
+
* already showed. Either way the poller is torn down here, so no per-turn
|
|
8776
|
+
* state survives the turn. */
|
|
8694
8777
|
async function finalScreenFrame(_botId, threadId) {
|
|
8695
8778
|
const entry = screenPollers.get(threadId);
|
|
8696
8779
|
const owner = turnResourceOwners.get(threadId);
|
|
@@ -8782,11 +8865,6 @@ async function startTurn(botId, text, opts) {
|
|
|
8782
8865
|
// A workspace at its monthly spend limit starts no turn of any kind: a
|
|
8783
8866
|
// person's message, a routine, a peer hop or a webhook all stop here.
|
|
8784
8867
|
assertWithinBudget(cfg, DATA_DIR);
|
|
8785
|
-
if (checkpointRestoreLeases.has(botId)) {
|
|
8786
|
-
throw Object.assign(new Error("this bot's project files are being restored — wait for the restore to finish"), {
|
|
8787
|
-
status: 409,
|
|
8788
|
-
});
|
|
8789
|
-
}
|
|
8790
8868
|
if (boatLifecycleBusyBots.has(botId)) {
|
|
8791
8869
|
throw Object.assign(new Error("this bot's cloud computer is being changed — wait for it to finish"), { status: 409 });
|
|
8792
8870
|
}
|
|
@@ -8803,10 +8881,10 @@ async function startTurn(botId, text, opts) {
|
|
|
8803
8881
|
// their results still return here (outstandingAssignmentsPrompt tells this
|
|
8804
8882
|
// turn which are still out). Stop, in this conversation, is the gesture
|
|
8805
8883
|
// that ends coordination — see interruptDirectThread.
|
|
8806
|
-
//
|
|
8807
|
-
// integrations. Completion and interrupt paths do the same;
|
|
8808
|
-
//
|
|
8809
|
-
|
|
8884
|
+
// Revoke anything a previous turn left behind before minting this turn's
|
|
8885
|
+
// integrations. Completion and interrupt paths do the same; a proxy the
|
|
8886
|
+
// warm engine kept is honoured again only through this turn's records.
|
|
8887
|
+
revokeEarlierTurnCapabilities(threadId);
|
|
8810
8888
|
// a webhook turn, or one inherited from a bot already running unattended
|
|
8811
8889
|
if (opts?.automationSource === "webhook" || opts?.unattended)
|
|
8812
8890
|
markUnattended(bot.id, threadId);
|
|
@@ -8823,6 +8901,10 @@ async function startTurn(botId, text, opts) {
|
|
|
8823
8901
|
const plan = turnSurfacePlan(bot, opts?.runOn, threadId);
|
|
8824
8902
|
const instance = registry.get(bot.modelSelection.instanceId);
|
|
8825
8903
|
if (!instance) {
|
|
8904
|
+
// Still on the removed Computer engine: its move failed or found no
|
|
8905
|
+
// engine. Try it again now, so the next message runs on the new engine.
|
|
8906
|
+
if (removedComputerInstanceIds(cfg.instances).has(bot.modelSelection.instanceId))
|
|
8907
|
+
void retryComputerEngineMove();
|
|
8826
8908
|
throw Object.assign(new Error(`provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`), { status: 409 });
|
|
8827
8909
|
}
|
|
8828
8910
|
const policyRefusal = policyModelRefusal(instance);
|
|
@@ -9116,7 +9198,11 @@ async function startTurn(botId, text, opts) {
|
|
|
9116
9198
|
const userTurnText = promptWithReply(skillAuthoring ? expandLearnTurnText(setupText) : setupText, opts?.replyTo, cfg.profile?.name?.trim() || "User");
|
|
9117
9199
|
// Decided again at dispatch when setup outlasted a soul edit (config).
|
|
9118
9200
|
const decideContext = (config) => {
|
|
9119
|
-
|
|
9201
|
+
// A Codex thread keeps the last effort it was sent and no turn can clear
|
|
9202
|
+
// one: back on the default, a thread that holds a level is rebuilt from
|
|
9203
|
+
// the transcript rather than resumed on that level.
|
|
9204
|
+
const effortHeld = !effort && typeof handed?.effort === "string";
|
|
9205
|
+
const handedStale = Boolean(handed && (!unseen || effortHeld || (externalUpdate && handed.config !== config)));
|
|
9120
9206
|
const { block: unseenBlock, placed } = unseen && !handedStale ? renderUnseen(unseen) : { block: "", placed: [] };
|
|
9121
9207
|
const { turnText: contextTurnText, resume } = buildTurnContext({
|
|
9122
9208
|
text: userTurnText,
|
|
@@ -9149,6 +9235,7 @@ async function startTurn(botId, text, opts) {
|
|
|
9149
9235
|
resumeCursor, sessionReset: !resume, recoveryText, recoveryIsReplay,
|
|
9150
9236
|
handoff: strictResume ? {
|
|
9151
9237
|
botId: bot.id, instanceId, config, resumeCursor: typeof resumeCursor === "string" ? resumeCursor : undefined,
|
|
9238
|
+
...(instance.driverKind === "codex" ? { effort: effort ?? null } : {}),
|
|
9152
9239
|
started: sessionStart(contextOrder, contextTurnText !== userTurnText ? windowIds : [], carried),
|
|
9153
9240
|
recovery: sessionStart(contextOrder, recoveryText !== undefined ? windowIds : [], carried),
|
|
9154
9241
|
resumed: sessionStart(contextOrder, [], [...placed, ...carried]),
|
|
@@ -9221,8 +9308,7 @@ async function startTurn(botId, text, opts) {
|
|
|
9221
9308
|
// because moving a live session would break resume.
|
|
9222
9309
|
// A cloud run keeps the bot's own engine on this host, with the cloud
|
|
9223
9310
|
// computer as one more tool, so it works in the same folder as any
|
|
9224
|
-
// other turn
|
|
9225
|
-
// workspace files).
|
|
9311
|
+
// other turn.
|
|
9226
9312
|
// On a Cloud home a conversation a guest opened is pinned to its own
|
|
9227
9313
|
// folder when it first runs, not the bot's project folder the owner's
|
|
9228
9314
|
// conversations share (what it leaves there reaches none of them). One
|
|
@@ -9231,14 +9317,16 @@ async function startTurn(botId, text, opts) {
|
|
|
9231
9317
|
const pinnedCwd = privateWorkspace
|
|
9232
9318
|
? store.pinTaskCwd(bot.id, threadId, privateWorkspace, { privateOnly: cloudGuestOpened(threadId) })
|
|
9233
9319
|
: null;
|
|
9320
|
+
// A bot's threads run side by side in one project folder, like several
|
|
9321
|
+
// Claude Code or Codex sessions open in one repo: the folder is never a
|
|
9322
|
+
// turn-long claim. What truly needs one writer queues on its own — the
|
|
9323
|
+
// per-turn snapshot per folder (server/checkpoints.ts), memory through
|
|
9324
|
+
// memory_update — and the engines key their sessions by id, not folder.
|
|
9234
9325
|
const cwd = pinnedCwd ?? undefined;
|
|
9235
|
-
if (cwd && !claimTurnResource(resourceOwner, workspaceResource(cwd))) {
|
|
9236
|
-
throw Object.assign(new Error("another thread is working in this project folder — wait for it to finish or choose a separate folder"), { status: 409, code: "workspace_busy" });
|
|
9237
|
-
}
|
|
9238
9326
|
// Checkpoint explicit project folders, where a bot can overwrite the
|
|
9239
9327
|
// user's work. Its private OpenMaus workspace is app-owned and changes
|
|
9240
9328
|
// on nearly every ordinary chat; snapshotting it would add hidden disk
|
|
9241
|
-
// and process overhead without a user project to
|
|
9329
|
+
// and process overhead without a user project to list changes for.
|
|
9242
9330
|
const checkpointCwd = cwd && cwd !== privateWorkspace ? cwd : undefined;
|
|
9243
9331
|
// dweb is opt-in: without an explicit daemon URL, do not advertise
|
|
9244
9332
|
// tools that would fail on every call or spawn an unnecessary proxy.
|
|
@@ -9261,11 +9349,6 @@ async function startTurn(botId, text, opts) {
|
|
|
9261
9349
|
// landed. A team computer or a cloud routine is not this conversation's
|
|
9262
9350
|
// choice, so those ignore the pin.
|
|
9263
9351
|
const dispatchTask = store.taskByThread(bot.id, threadId);
|
|
9264
|
-
if (plan.clearPin && dispatchTask)
|
|
9265
|
-
store.patchTask(bot.id, threadId, { surface: undefined });
|
|
9266
|
-
if (plan.computer !== undefined && plan.computer !== "cloud" && instance.adapter.capabilities.remoteAgent === true) {
|
|
9267
|
-
throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
|
|
9268
|
-
}
|
|
9269
9352
|
const wants = plan.computer;
|
|
9270
9353
|
// A place the organisation disallows, or a Cloud home never offers, is
|
|
9271
9354
|
// refused before anything is prepared; Auto below simply skips them.
|
|
@@ -9277,16 +9360,19 @@ async function startTurn(botId, text, opts) {
|
|
|
9277
9360
|
// Where an explicit place came from, so its failure names the one
|
|
9278
9361
|
// control that changes it (and a failed Auto-recorded pin is cleared).
|
|
9279
9362
|
// Auto and a team's shared computer are nobody's per-turn choice.
|
|
9280
|
-
const
|
|
9363
|
+
const namedPlace = teamComputer || wants === undefined || wants === "off" ? undefined : wants;
|
|
9364
|
+
const placeSource = !namedPlace ? undefined
|
|
9281
9365
|
: opts?.runOn === "cloud" ? "routine"
|
|
9282
9366
|
: plan.pinned ? (dispatchTask?.surfaceSource === "auto" ? "auto-pin" : "pin")
|
|
9283
9367
|
: "works-on";
|
|
9284
|
-
// An engine that cannot use the cloud computer
|
|
9285
|
-
//
|
|
9368
|
+
// An engine that cannot use the cloud computer, or a Tool selection
|
|
9369
|
+
// that leaves its tools out, is refused here, before anything is
|
|
9370
|
+
// created or woken — never handed to another engine.
|
|
9286
9371
|
if (wants === "cloud" && placeSource) {
|
|
9287
|
-
const unsupported =
|
|
9372
|
+
const unsupported = cloudPlaceRefusal(cloudEngine(instance, bot.modelSelection.model), placeSource, bot.name)
|
|
9373
|
+
?? computerToolsRefusal(toolScope, placeSource, bot.name);
|
|
9288
9374
|
if (unsupported)
|
|
9289
|
-
throw
|
|
9375
|
+
throw unsupported;
|
|
9290
9376
|
}
|
|
9291
9377
|
let previewCapture = null;
|
|
9292
9378
|
let browserCapture = null;
|
|
@@ -9412,7 +9498,7 @@ async function startTurn(botId, text, opts) {
|
|
|
9412
9498
|
// the newer turn. The gate's refusals stay fail-closed for any screen
|
|
9413
9499
|
// call racing this teardown; the turn-failed buzz and incident follow
|
|
9414
9500
|
// the dispatch-failure rules (person-started turns only).
|
|
9415
|
-
const surfaceLazyClaimRejection = (label) => (failure) => {
|
|
9501
|
+
const surfaceLazyClaimRejection = (label) => (failure, error) => {
|
|
9416
9502
|
if (activeInternalGenerationByThread.get(threadId) !== resourceOwner.generation || !threadBusy(bot.id, threadId))
|
|
9417
9503
|
return;
|
|
9418
9504
|
// The wait ceiling parked this turn (#1651): register the resume and
|
|
@@ -9431,11 +9517,16 @@ async function startTurn(botId, text, opts) {
|
|
|
9431
9517
|
void interruptDirectThread(bot.id, threadId, { preserveComputerResume: true }).catch(() => { });
|
|
9432
9518
|
return;
|
|
9433
9519
|
}
|
|
9434
|
-
|
|
9520
|
+
// A place that failed (mountBotBoat) is already read as its one line
|
|
9521
|
+
// and one way on, and never sticks as an Auto-recorded pin.
|
|
9522
|
+
const placeFailure = error instanceof PlaceUnavailableError ? error : undefined;
|
|
9523
|
+
if (placeFailure)
|
|
9524
|
+
clearFailedAutoPin(bot.id, threadId, placeFailure.place);
|
|
9525
|
+
const message = placeFailure?.message ?? `computer unavailable — ${label} could not be claimed for this turn (${failure})`;
|
|
9435
9526
|
store.appendMessage(threadId, {
|
|
9436
9527
|
role: "bot",
|
|
9437
9528
|
kind: "activity",
|
|
9438
|
-
tool: {
|
|
9529
|
+
tool: failedTurnTool(message, placeFailure ? { place: placeFailure.row } : {}),
|
|
9439
9530
|
});
|
|
9440
9531
|
if (opts?.automationSource === undefined && !opts?.commsDepth && !opts?.cardContinuation) {
|
|
9441
9532
|
notify(buildNotification("turn-failed", bot, threadId, redactSecretsInText(message), { avatarUrl: bot.avatarUrl }));
|
|
@@ -9444,8 +9535,26 @@ async function startTurn(botId, text, opts) {
|
|
|
9444
9535
|
// completion fold must not report this failure a second time.
|
|
9445
9536
|
resourceOwner.lazyClaimFailureReported = true;
|
|
9446
9537
|
}
|
|
9538
|
+
// A routine, a delegation or a resume reports this exactly as it
|
|
9539
|
+
// reports a place that failed at dispatch: with this cause, before
|
|
9540
|
+
// the interrupt settles the turn as merely "interrupted".
|
|
9541
|
+
opts?.onDispatchError?.(message);
|
|
9447
9542
|
void interruptDirectThread(bot.id, threadId).catch(() => { });
|
|
9448
9543
|
};
|
|
9544
|
+
/** A lazily claimed remote desktop (the VPS, the bot's Boat) streams
|
|
9545
|
+
* from the moment its claim lands. The dispatch-site poller start saw
|
|
9546
|
+
* no capture (the claim runs after dispatch), so restart the poller
|
|
9547
|
+
* with this one, keeping any browser capture and whether this turn
|
|
9548
|
+
* already touched its screen. Same still-running guard as dispatch: a
|
|
9549
|
+
* poller started after its own turn.completed would never stop. */
|
|
9550
|
+
const adoptClaimedCapture = (capture) => {
|
|
9551
|
+
previewCapture = capture;
|
|
9552
|
+
if (!threadBusy(bot.id, threadId))
|
|
9553
|
+
return;
|
|
9554
|
+
const touched = screenPollers.get(threadId)?.touched ?? false;
|
|
9555
|
+
stopScreenPoller(bot.id, threadId);
|
|
9556
|
+
startScreenPoller(bot.id, threadId, { computer: capture, ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: touched });
|
|
9557
|
+
};
|
|
9449
9558
|
// Explicit destinations are strict. In particular, Local VM must never
|
|
9450
9559
|
// fall through to host CUA and accidentally click on the user's Mac.
|
|
9451
9560
|
// The Local VM attach, shared by explicit "Local VM" and by Auto. Explicit
|
|
@@ -9454,7 +9563,7 @@ async function startTurn(botId, text, opts) {
|
|
|
9454
9563
|
// be recreated on demand after idling away — and never creates a first
|
|
9455
9564
|
// VM on its own; every failure there is a quiet "not this place".
|
|
9456
9565
|
const attachLocalVm = async (strict) => {
|
|
9457
|
-
if (!mountsComputerMcp
|
|
9566
|
+
if (!mountsComputerMcp) {
|
|
9458
9567
|
if (!strict)
|
|
9459
9568
|
return false;
|
|
9460
9569
|
throw new Error("this model cannot use the Local VM — choose Claude or an ACP model provider, or select another computer destination");
|
|
@@ -9510,7 +9619,7 @@ async function startTurn(botId, text, opts) {
|
|
|
9510
9619
|
// guard as dispatch: a poller started after its own
|
|
9511
9620
|
// turn.completed would never be torn down.
|
|
9512
9621
|
if (previewCapture && threadBusy(bot.id, threadId)) {
|
|
9513
|
-
const touched = screenPollers.get(threadId)?.touched ??
|
|
9622
|
+
const touched = screenPollers.get(threadId)?.touched ?? false;
|
|
9514
9623
|
stopScreenPoller(bot.id, threadId);
|
|
9515
9624
|
startScreenPoller(bot.id, threadId, { computer: previewCapture, ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: touched });
|
|
9516
9625
|
}
|
|
@@ -9557,7 +9666,7 @@ async function startTurn(botId, text, opts) {
|
|
|
9557
9666
|
// the person explicitly opted this bot into remote lifecycle actions.
|
|
9558
9667
|
// On Auto an engine that cannot use a computer simply skips it.
|
|
9559
9668
|
if ((wants === "cloud" || (wants === undefined && managedPolicy.computerAllowed("vps"))) && cloudBackend === "vps" &&
|
|
9560
|
-
|
|
9669
|
+
canWorkOnCloud(cloudEngine(instance))) {
|
|
9561
9670
|
// The VPS "computer" is the desktop inside this bot's managed
|
|
9562
9671
|
// container, and only screen work needs that desktop to itself.
|
|
9563
9672
|
// So the lease is claimed on the first computer call, through the
|
|
@@ -9574,12 +9683,7 @@ async function startTurn(botId, text, opts) {
|
|
|
9574
9683
|
// Local VM's lazy claim does.
|
|
9575
9684
|
onClaimed: (vpsCapture) => {
|
|
9576
9685
|
pinAutoSurface("cloud");
|
|
9577
|
-
|
|
9578
|
-
if (threadBusy(bot.id, threadId)) {
|
|
9579
|
-
const touched = screenPollers.get(threadId)?.touched ?? false;
|
|
9580
|
-
stopScreenPoller(bot.id, threadId);
|
|
9581
|
-
startScreenPoller(bot.id, threadId, { computer: vpsCapture, ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: touched });
|
|
9582
|
-
}
|
|
9686
|
+
adoptClaimedCapture(vpsCapture);
|
|
9583
9687
|
},
|
|
9584
9688
|
onRejected: surfaceLazyClaimRejection("the VPS computer"),
|
|
9585
9689
|
});
|
|
@@ -9595,54 +9699,56 @@ async function startTurn(botId, text, opts) {
|
|
|
9595
9699
|
autoVpsProblem = mounted.problem ?? "the VPS computer could not be reached";
|
|
9596
9700
|
}
|
|
9597
9701
|
}
|
|
9598
|
-
// Cloud is strict when selected. Every engine that can use it may also
|
|
9599
|
-
// reuse an already-ready Boat on Auto.
|
|
9600
9702
|
if (teamComputer) {
|
|
9601
|
-
const attached = await attachTeamBoat(teamComputer, bot.id, resourceOwner,
|
|
9703
|
+
const attached = await attachTeamBoat(teamComputer, bot.id, resourceOwner, canWorkOnCloud(cloudEngine(instance)));
|
|
9602
9704
|
Object.assign(integrations, attached.mount);
|
|
9603
9705
|
previewCapture = attached.capture;
|
|
9604
9706
|
computerKind = "box";
|
|
9605
9707
|
}
|
|
9606
|
-
|
|
9607
|
-
|
|
9608
|
-
|
|
9609
|
-
|
|
9610
|
-
|
|
9708
|
+
// Cloud is strict when selected. A turn reaches the Boat only when
|
|
9709
|
+
// this conversation is on it (Works on, a pin, a cloud routine, or
|
|
9710
|
+
// select_computer): an Auto turn never reads the Boat account, so it
|
|
9711
|
+
// adds no Boat call, cannot be stopped by a Boat being deleted, and
|
|
9712
|
+
// never records a place nobody chose. Even then the Boat is created
|
|
9713
|
+
// or woken only by the bot's first computer call (mountBotBoat): a
|
|
9714
|
+
// chat that never touches the screen uses no plan hours.
|
|
9715
|
+
if (!teamComputer && cloudBackend === "box" && boat.boatConfigured(cfg) && wants === "cloud") {
|
|
9716
|
+
integrations.localComputer = mountBotBoat(bot, resourceOwner, {
|
|
9717
|
+
// The row a failed attach always wrote: the place's one line and
|
|
9718
|
+
// its one way on, read where the place came from (an
|
|
9719
|
+
// Auto-recorded pin is cleared, as dispatch clears it).
|
|
9720
|
+
source: placeSource,
|
|
9721
|
+
onClaimed: adoptClaimedCapture,
|
|
9722
|
+
onRejected: surfaceLazyClaimRejection("the cloud computer"),
|
|
9611
9723
|
});
|
|
9612
|
-
|
|
9613
|
-
previewCapture = attached.capture;
|
|
9614
|
-
if (attached.mount) {
|
|
9615
|
-
Object.assign(integrations, attached.mount);
|
|
9616
|
-
computerKind = "box";
|
|
9617
|
-
}
|
|
9618
|
-
}
|
|
9724
|
+
computerKind = "box";
|
|
9619
9725
|
}
|
|
9620
9726
|
if (wants === "cloud" && cloudBackend === "box" && !boat.boatConfigured(cfg)) {
|
|
9621
|
-
throw
|
|
9727
|
+
throw boatNotConfigured(bot, placeSource ?? "works-on");
|
|
9622
9728
|
}
|
|
9623
9729
|
if (wants === "cloud" && cloudBackend === "box" && computerKind !== "box") {
|
|
9624
9730
|
throw new Error("the cloud computer could not be created or reached");
|
|
9625
9731
|
}
|
|
9626
9732
|
}
|
|
9627
9733
|
catch (error) {
|
|
9628
|
-
|
|
9629
|
-
|
|
9734
|
+
// Auto, Off and a team's shared computer name no place; they fail as
|
|
9735
|
+
// they are.
|
|
9736
|
+
if (!namedPlace || !placeSource)
|
|
9630
9737
|
throw error;
|
|
9631
|
-
throw
|
|
9738
|
+
throw await asPlaceFailure(error, namedPlace, placeSource, bot, cloudBackend);
|
|
9632
9739
|
}
|
|
9633
9740
|
// Auto-only host fallback. Electron owns cua-driver/TCC attribution;
|
|
9634
9741
|
// the harness only reads its already-running connection descriptor.
|
|
9635
9742
|
// Auto reaches a Local VM this bot already has before it ever touches the
|
|
9636
9743
|
// host's own desktop: on a headless server that VM is the only desktop
|
|
9637
9744
|
// there is, and a person who prepared one meant it to be used.
|
|
9638
|
-
if (wants === undefined && !integrations.
|
|
9745
|
+
if (wants === undefined && !integrations.localComputer && !computerPlaceRefusal("localVm") && await attachLocalVm(false))
|
|
9639
9746
|
computerKind = "vm";
|
|
9640
9747
|
// An unattended run on a bot with a VPS configured never lands on the
|
|
9641
9748
|
// host's own desktop instead: a scheduled job clicking on someone's
|
|
9642
9749
|
// laptop is worse than a scheduled job that fails and says why.
|
|
9643
9750
|
const unattendedVps = cloudBackend === "vps" && Boolean(opts?.automationSource);
|
|
9644
|
-
if (!integrations.
|
|
9645
|
-
!integrations.localComputer &&
|
|
9751
|
+
if (!integrations.localComputer &&
|
|
9646
9752
|
wants === undefined &&
|
|
9647
9753
|
!unattendedVps &&
|
|
9648
9754
|
!computerPlaceRefusal("thisComputer") &&
|
|
@@ -9671,13 +9777,12 @@ async function startTurn(botId, text, opts) {
|
|
|
9671
9777
|
pinAutoSurface("local");
|
|
9672
9778
|
},
|
|
9673
9779
|
});
|
|
9674
|
-
integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
|
|
9780
|
+
integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId, "this computer"));
|
|
9675
9781
|
computerKind = "local";
|
|
9676
9782
|
}
|
|
9677
9783
|
}
|
|
9678
9784
|
if (wants === undefined &&
|
|
9679
9785
|
cloudBackend === "vps" &&
|
|
9680
|
-
!integrations.computer &&
|
|
9681
9786
|
!integrations.localComputer &&
|
|
9682
9787
|
autoVpsProblem &&
|
|
9683
9788
|
!computerSelectionTurns.has(threadId)) {
|
|
@@ -9685,7 +9790,7 @@ async function startTurn(botId, text, opts) {
|
|
|
9685
9790
|
? "This scheduled run tried to start the VPS computer and could not reach it. Check the VPS connection in Settings → API keys."
|
|
9686
9791
|
: bot.autoStartVps
|
|
9687
9792
|
? "Check the VPS connection in Settings → API keys."
|
|
9688
|
-
: "Open Computer and enable Start VPS automatically, or choose Cloud to start it manually.";
|
|
9793
|
+
: "Open Computer and enable Start VPS automatically, or choose Cloud computer to start it manually.";
|
|
9689
9794
|
throw new Error(`${autoVpsProblem}. ${hint}`);
|
|
9690
9795
|
}
|
|
9691
9796
|
// Agent control tools include peer comms and the secure credential
|
|
@@ -9739,14 +9844,17 @@ async function startTurn(botId, text, opts) {
|
|
|
9739
9844
|
// snapshot() absorbs failures, so checkpointing may delay but never fail
|
|
9740
9845
|
// a turn.
|
|
9741
9846
|
if (checkpointCwd) {
|
|
9742
|
-
|
|
9847
|
+
// Pinned by this dispatch: a sibling thread's snapshot in the same
|
|
9848
|
+
// folder must not reclaim this turn's "before" state while it runs.
|
|
9849
|
+
const before = await checkpoints.snapshot(bot.id, checkpointCwd, `turn ${threadId.slice(0, 8)}`, undefined, { pin: dispatchClaimId });
|
|
9743
9850
|
if (!directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId)) {
|
|
9851
|
+
if (before)
|
|
9852
|
+
void checkpoints.release(bot.id, checkpointCwd, dispatchClaimId);
|
|
9744
9853
|
throw new DirectTurnSetupCancelled("turn stopped during checkpoint");
|
|
9745
9854
|
}
|
|
9855
|
+
forgetTurnCheckpoint(threadId);
|
|
9746
9856
|
if (before)
|
|
9747
|
-
turnCheckpoints.set(threadId, { cwd: checkpointCwd, hash: before });
|
|
9748
|
-
else
|
|
9749
|
-
turnCheckpoints.delete(threadId);
|
|
9857
|
+
turnCheckpoints.set(threadId, { botId: bot.id, cwd: checkpointCwd, hash: before, pin: dispatchClaimId });
|
|
9750
9858
|
}
|
|
9751
9859
|
if (!directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId)) {
|
|
9752
9860
|
throw new DirectTurnSetupCancelled("turn stopped before dispatch");
|
|
@@ -9805,8 +9913,8 @@ async function startTurn(botId, text, opts) {
|
|
|
9805
9913
|
// Pin on use (issue #1650): every computer that registers a claim
|
|
9806
9914
|
// slot — a ready Local VM, the VPS, the host fallback, a VM created
|
|
9807
9915
|
// for this turn — records its pin inside the claim itself, so a turn
|
|
9808
|
-
// that only mounted tools records nothing.
|
|
9809
|
-
//
|
|
9916
|
+
// that only mounted tools records nothing. The built-in browser (no
|
|
9917
|
+
// seat to claim) pins here.
|
|
9810
9918
|
if (autoPinAllowed()) {
|
|
9811
9919
|
const claimSlot = autoVmClaims.get(threadId);
|
|
9812
9920
|
const pinsAtClaim = claimSlot?.owner.generation === resourceOwner.generation;
|
|
@@ -9827,7 +9935,6 @@ async function startTurn(botId, text, opts) {
|
|
|
9827
9935
|
}
|
|
9828
9936
|
const computerPromptKind = resolveComputerPromptKind({
|
|
9829
9937
|
kind: computerKind,
|
|
9830
|
-
driverKind: instance.driverKind,
|
|
9831
9938
|
vmPrivate: localVmMode(cfg) === "per-bot",
|
|
9832
9939
|
});
|
|
9833
9940
|
const coordinationNode = opts?.coordination ? roomHandoffs.nodes.get(opts.coordination.id) : undefined;
|
|
@@ -9844,7 +9951,7 @@ async function startTurn(botId, text, opts) {
|
|
|
9844
9951
|
{ id: "computer", label: "Computer", text: computerPrompt(computerPromptKind) },
|
|
9845
9952
|
{ id: "team-computer", label: "Team computer", text: teamComputerPrompt(teamComputer) },
|
|
9846
9953
|
{ id: "plan", label: "Surface", text: surfacePrompt({ computer: mountedComputer, browser: Boolean(integrations.browser) }, { pinned: plan.pinned, note: plan.note, canSelect: computerSelectionTurns.has(threadId), cloudHome: Boolean(CLOUD_HOME) }) },
|
|
9847
|
-
{ id: "cloud-home", label: "
|
|
9954
|
+
{ id: "cloud-home", label: "My Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
|
|
9848
9955
|
// gated on the integration, not the key: the hint only goes to a
|
|
9849
9956
|
// bot whose driver actually mounted the tools
|
|
9850
9957
|
{ id: "composio", label: "Connected apps", text: integrations.composio ? composioSystemPrompt(liveBot?.connectorTools ?? bot.connectorTools) + describeConnectorScopes((liveBot ?? bot).connectorScopes) : "" },
|
|
@@ -9861,6 +9968,9 @@ async function startTurn(botId, text, opts) {
|
|
|
9861
9968
|
{ id: "learn", label: "Skill authoring", text: learnPrompt },
|
|
9862
9969
|
{ id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
|
|
9863
9970
|
{ id: "team-memory", label: "Team memory", text: teamMemory.systemPrompt(bot.section) + (integrations.agents ? TEAM_MEMORY_PROMPT : "") },
|
|
9971
|
+
// who on the team roster is busy right now; volatile, so a teammate
|
|
9972
|
+
// starting or finishing work never changes the stable half
|
|
9973
|
+
teamAvailabilityPart(coordinationPrompt ? sectionPeers : []),
|
|
9864
9974
|
// what the bot said lately in its other conversations, so a task
|
|
9865
9975
|
// never redoes — or forgets — what another one already did
|
|
9866
9976
|
{ id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(recentWorkSources(bot), bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId, ...recentWorkFilter() })) },
|
|
@@ -9974,7 +10084,7 @@ async function startTurn(botId, text, opts) {
|
|
|
9974
10084
|
// keep polling the boat forever, carrying dead per-turn state. busy
|
|
9975
10085
|
// is flipped false in the fold, so it is the honest "still running".
|
|
9976
10086
|
if ((previewCapture || browserCapture) && threadBusy(bot.id, threadId)) {
|
|
9977
|
-
startScreenPoller(bot.id, threadId, { ...(previewCapture ? { computer: previewCapture } : {}), ...(browserCapture ? { browser: browserCapture } : {}) }
|
|
10087
|
+
startScreenPoller(bot.id, threadId, { ...(previewCapture ? { computer: previewCapture } : {}), ...(browserCapture ? { browser: browserCapture } : {}) });
|
|
9978
10088
|
}
|
|
9979
10089
|
// An adapter may publish completion synchronously just before its
|
|
9980
10090
|
// dispatch promise resolves. The event could not use the turn-id map
|
|
@@ -10068,16 +10178,10 @@ async function startTurn(botId, text, opts) {
|
|
|
10068
10178
|
drainDelegationWakes();
|
|
10069
10179
|
return;
|
|
10070
10180
|
}
|
|
10071
|
-
// A failed place never sticks
|
|
10072
|
-
//
|
|
10073
|
-
|
|
10074
|
-
|
|
10075
|
-
if (e instanceof PlaceUnavailableError) {
|
|
10076
|
-
const pinned = store.taskByThread(bot.id, threadId);
|
|
10077
|
-
if (pinned?.surface === e.place && pinned.surfaceSource === "auto") {
|
|
10078
|
-
store.patchTask(bot.id, threadId, { surface: undefined, surfaceSource: undefined });
|
|
10079
|
-
}
|
|
10080
|
-
}
|
|
10181
|
+
// A failed place never sticks; a person's pin stays, and the message
|
|
10182
|
+
// names the composer.
|
|
10183
|
+
if (e instanceof PlaceUnavailableError)
|
|
10184
|
+
clearFailedAutoPin(bot.id, threadId, e.place);
|
|
10081
10185
|
let message = e instanceof Error ? e.message : String(e);
|
|
10082
10186
|
const current = store.projectBotForTask(bot.id, threadId);
|
|
10083
10187
|
const backups = cfg.automaticRecovery?.enabled
|
|
@@ -10132,7 +10236,7 @@ async function startTurn(botId, text, opts) {
|
|
|
10132
10236
|
role: "bot",
|
|
10133
10237
|
kind: "activity",
|
|
10134
10238
|
turnSucceeded: false,
|
|
10135
|
-
tool: {
|
|
10239
|
+
tool: failedTurnTool(message, e instanceof PlaceUnavailableError ? { place: e.row } : {}),
|
|
10136
10240
|
});
|
|
10137
10241
|
// Worth a buzz for the same reason a routine failure is, and the rule
|
|
10138
10242
|
// notify.ts encodes: the bot is not working, and the cause is usually
|
|
@@ -10393,16 +10497,22 @@ routines = new RoutineManager({
|
|
|
10393
10497
|
}
|
|
10394
10498
|
return groupIsWorking(group) || coordinator.busy ? "busy" : "ready";
|
|
10395
10499
|
},
|
|
10396
|
-
createTask: (botId, title, activate = false,
|
|
10500
|
+
createTask: (botId, title, activate = false, run) => {
|
|
10397
10501
|
const task = store.createTask(botId, title, activate);
|
|
10398
10502
|
// On a Cloud home a run's conversation is opened, like its results
|
|
10399
10503
|
// conversation, by whoever wrote the routine: a run of one that is
|
|
10400
10504
|
// nobody's is confined to a folder of its own, never the bot's project.
|
|
10401
|
-
|
|
10402
|
-
|
|
10403
|
-
|
|
10404
|
-
|
|
10405
|
-
|
|
10505
|
+
// A webhook's run is the owner's: only their own devices can create,
|
|
10506
|
+
// edit or rotate a webhook, so it works at the bot's own level, in its
|
|
10507
|
+
// folder, as on the desktop. Its payload still never reaches the lent
|
|
10508
|
+
// Mac: cloud-lending.ts refuses every webhook run by its trigger.
|
|
10509
|
+
if (task && CLOUD_HOME && run) {
|
|
10510
|
+
threadStarters.set(task.threadId, run.triggerSource === "webhook" ? CLOUD_OWNER_KEY : routineOpener(run.routineId));
|
|
10511
|
+
}
|
|
10512
|
+
// The store's frame announces it. A run's task that stays in the
|
|
10513
|
+
// background leaves the open thread alone, so that frame carries no
|
|
10514
|
+
// transcript: a whole thread on every scheduled run once passed the phone
|
|
10515
|
+
// sidecar's 4 MiB event ceiling and ended the stream (MOCA-179).
|
|
10406
10516
|
return task;
|
|
10407
10517
|
},
|
|
10408
10518
|
joinConversation: (run) => {
|
|
@@ -10457,11 +10567,8 @@ routines = new RoutineManager({
|
|
|
10457
10567
|
discardDelegations(commsBus, threadId);
|
|
10458
10568
|
cancelDirectTurnDispatch(botId, threadId);
|
|
10459
10569
|
revokeInternalCapabilitiesForThread(threadId);
|
|
10460
|
-
const instance = bot
|
|
10461
|
-
? runningTurnInstance(bot, threadId)
|
|
10462
|
-
: runningTurnEngines.get(threadId) ?? null;
|
|
10463
10570
|
try {
|
|
10464
|
-
await
|
|
10571
|
+
await runningTurnInstance(bot, threadId)?.adapter.interruptTurn(threadId);
|
|
10465
10572
|
}
|
|
10466
10573
|
finally {
|
|
10467
10574
|
closeOpenApprovals(threadId);
|
|
@@ -10617,25 +10724,25 @@ async function cloudRoutineReadiness(botId, threadId) {
|
|
|
10617
10724
|
if (!boat.boatConfigured(cfg)) {
|
|
10618
10725
|
return {
|
|
10619
10726
|
ready: false,
|
|
10620
|
-
reason:
|
|
10727
|
+
reason: `${boatNotConfigured(bot, "routine").message} For the bot's configured computer, including a self-hosted VPS, set run_on="maus" instead.`,
|
|
10621
10728
|
};
|
|
10622
10729
|
}
|
|
10623
10730
|
const instance = registry.get(bot.modelSelection.instanceId);
|
|
10624
10731
|
if (!instance)
|
|
10625
10732
|
return { ready: false, reason: "The target bot's model is unavailable. Choose another model in the bot's settings." };
|
|
10626
10733
|
// The same rule a cloud turn applies, before anything is provisioned.
|
|
10627
|
-
const unsupported =
|
|
10734
|
+
const unsupported = cloudPlaceRefusal(cloudEngine(instance, bot.modelSelection.model), "routine", bot.name);
|
|
10628
10735
|
if (unsupported)
|
|
10629
|
-
return { ready: false, reason:
|
|
10736
|
+
return { ready: false, reason: unsupported.message };
|
|
10630
10737
|
try {
|
|
10631
10738
|
if ((await instance.snapshot()).state !== "available") {
|
|
10632
|
-
return { ready: false, reason: "The target bot's model is not ready to use the
|
|
10739
|
+
return { ready: false, reason: "The target bot's model is not ready to use the cloud computer." };
|
|
10633
10740
|
}
|
|
10634
10741
|
const teamComputer = inheritedTeamComputer(bot);
|
|
10635
10742
|
const ownerId = teamComputer ? teamComputerOwner(teamComputer.id) : bot.id;
|
|
10636
10743
|
const machine = await boat.findBoat(cfg, ownerId);
|
|
10637
10744
|
if (teamComputer && !machine) {
|
|
10638
|
-
return { ready: false, reason: "The team's
|
|
10745
|
+
return { ready: false, reason: "The team's cloud computer is missing; explicitly create or retry it from the Team map." };
|
|
10639
10746
|
}
|
|
10640
10747
|
// Explicit Cloud may provision or wake the bot's own Boat at dispatch.
|
|
10641
10748
|
// This probe only checks its identity/account, without starting billing.
|
|
@@ -10998,52 +11105,6 @@ const modelRequests = new ModelRequestService({
|
|
|
10998
11105
|
validateModel: validateModelProposal,
|
|
10999
11106
|
driverCapabilities: driverCapabilitiesFor,
|
|
11000
11107
|
});
|
|
11001
|
-
const tighteningRequests = new TighteningRequestService({
|
|
11002
|
-
store,
|
|
11003
|
-
autoApply: fullAccessForSource,
|
|
11004
|
-
canPersist: proposalPersistence,
|
|
11005
|
-
// Same reach as a profile change: a Chief may tighten a section peer;
|
|
11006
|
-
// anyone else only itself. Re-checked at confirm.
|
|
11007
|
-
validateTarget: (proposerBotId, targetBotId) => {
|
|
11008
|
-
const proposer = store.bot(proposerBotId);
|
|
11009
|
-
const target = store.bot(targetBotId);
|
|
11010
|
-
if (!target)
|
|
11011
|
-
return "that bot no longer exists";
|
|
11012
|
-
if (!proposer?.chiefOfStaff)
|
|
11013
|
-
return "only a section's Chief of Staff can change another bot's permissions";
|
|
11014
|
-
if (!canReachPeer(proposer, target))
|
|
11015
|
-
return "that bot is not in a team this Chief is allowed to manage";
|
|
11016
|
-
return null;
|
|
11017
|
-
},
|
|
11018
|
-
// The card judges the effective mounts, so the snapshot resolves the same
|
|
11019
|
-
// way the engine does: through the config-aware custom-server filter.
|
|
11020
|
-
mountedMcpServers: (bot) => Object.keys(engineMcpServers(bot)),
|
|
11021
|
-
enabledSkills: (botId) => {
|
|
11022
|
-
const record = store.bot(botId);
|
|
11023
|
-
return (record ? listBotSkills(record) : listSkills(botId)).filter((skill) => skill.enabled).map((skill) => skill.name);
|
|
11024
|
-
},
|
|
11025
|
-
disableSkill: (botId, name) => {
|
|
11026
|
-
const record = store.bot(botId);
|
|
11027
|
-
// Private skills win collisions, including disabled/private copies.
|
|
11028
|
-
// Disabling one must not reveal a same-named shared skill instead.
|
|
11029
|
-
if (skillsLibraryEnabled(cfg) && !listSkills(botId).some((skill) => skill.name === name)
|
|
11030
|
-
&& record?.assignedSkills?.includes(name)) {
|
|
11031
|
-
try {
|
|
11032
|
-
const saved = store.patchBot(botId, { assignedSkills: record.assignedSkills.filter((assigned) => assigned !== name) });
|
|
11033
|
-
return saved ? { ok: true } : { ok: false, error: "that bot no longer exists" };
|
|
11034
|
-
}
|
|
11035
|
-
catch (error) {
|
|
11036
|
-
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
11037
|
-
}
|
|
11038
|
-
}
|
|
11039
|
-
const disabled = setSkillEnabled(botId, name, false);
|
|
11040
|
-
return typeof disabled === "object" && "error" in disabled ? { ok: false, error: disabled.error } : { ok: true };
|
|
11041
|
-
},
|
|
11042
|
-
// The owner's own routes stop a busy bot before touching its approval
|
|
11043
|
-
// level or MCP mounts; the card asks for the same, except an emergency
|
|
11044
|
-
// full/custom → ask downgrade, which applies first and stops after.
|
|
11045
|
-
targetBusy: (botId) => Boolean(store.bot(botId)?.busy || activeGroupTurnForBot(botId)),
|
|
11046
|
-
});
|
|
11047
11108
|
const teamSetupTeams = () => [...new Set(["", ...readSections(), ...store.bots.map((bot) => sectionKey(bot.section)), ...store.groups.map((group) => sectionKey(group.section))])];
|
|
11048
11109
|
const teamSetupRequests = new TeamSetupRequestService({
|
|
11049
11110
|
store, teams: teamSetupTeams, canAccessTeam, canPersist: proposalPersistence, maxBots: MAX_WORKSPACE_BOTS,
|
|
@@ -11355,46 +11416,6 @@ function resolveAndSendModel(res, args) {
|
|
|
11355
11416
|
json(res, 200, { ok: true, outcome: "rejected" });
|
|
11356
11417
|
return true;
|
|
11357
11418
|
}
|
|
11358
|
-
function resolveAndSendTightening(res, args) {
|
|
11359
|
-
const card = store.messagesFor(args.threadId).find((message) => message.card?.requestId === args.requestId && message.card.tighteningRequest)?.card;
|
|
11360
|
-
if (!card)
|
|
11361
|
-
return false;
|
|
11362
|
-
const result = tighteningRequests.resolve(args);
|
|
11363
|
-
if (!result.claimed)
|
|
11364
|
-
return false;
|
|
11365
|
-
if (result.state === "applied" || result.state === "denied") {
|
|
11366
|
-
appendDecision(DATA_DIR, {
|
|
11367
|
-
threadId: args.threadId, requestId: args.requestId, botId: args.botId, botName: args.botName,
|
|
11368
|
-
tool: "tighten_permissions", summary: card.subtitle,
|
|
11369
|
-
decision: result.state === "applied" ? "user-approved" : "user-denied", source: "user",
|
|
11370
|
-
});
|
|
11371
|
-
}
|
|
11372
|
-
if (result.state === "applied") {
|
|
11373
|
-
const target = store.bot(result.targetBotId);
|
|
11374
|
-
if (target)
|
|
11375
|
-
broadcast({ kind: "bot", bot: wireBot(target) });
|
|
11376
|
-
if (result.emergencyStop) {
|
|
11377
|
-
// Mirror the owner's own route: the fail-closed Ask is already
|
|
11378
|
-
// persisted, then the exact turn still holding the elevated
|
|
11379
|
-
// per-turn snapshot is stopped. The response never waits on it.
|
|
11380
|
-
void stopBotForEmergencyApprovalDowngrade(result.targetBotId).catch((error) => {
|
|
11381
|
-
console.warn(`[tightening] emergency stop failed for bot ${result.targetBotId}: ${error instanceof Error ? error.message : String(error)}. The fail-closed Ask is persisted, but the in-flight turn keeps its elevated access until it ends.`);
|
|
11382
|
-
});
|
|
11383
|
-
}
|
|
11384
|
-
json(res, 200, { ok: true, outcome: "allowed-once", tighteningFields: result.fields });
|
|
11385
|
-
return true;
|
|
11386
|
-
}
|
|
11387
|
-
if (result.state === "invalid") {
|
|
11388
|
-
json(res, result.status, { error: result.error });
|
|
11389
|
-
return true;
|
|
11390
|
-
}
|
|
11391
|
-
if (result.state === "already_settled") {
|
|
11392
|
-
json(res, 200, { ok: true, outcome: result.behavior === "allow" ? "allowed-once" : "rejected", alreadySettled: true });
|
|
11393
|
-
return true;
|
|
11394
|
-
}
|
|
11395
|
-
json(res, 200, { ok: true, outcome: "rejected" });
|
|
11396
|
-
return true;
|
|
11397
|
-
}
|
|
11398
11419
|
// Webhook definitions are independent from calendar schedules, but every
|
|
11399
11420
|
// delivery joins the same RoutineManager queue. That keeps unattended work
|
|
11400
11421
|
// ordered behind a busy MAUS and gives webhook runs the same durable receipts.
|
|
@@ -11414,7 +11435,7 @@ const webhooks = new WebhookManager({
|
|
|
11414
11435
|
post: (botId, threadId, text) => {
|
|
11415
11436
|
if (!store.bot(botId))
|
|
11416
11437
|
return;
|
|
11417
|
-
store.appendMessage(threadId, { role: "bot", kind: "text", text });
|
|
11438
|
+
store.appendMessage(threadId, { role: "bot", kind: "text", text, webhookPost: true });
|
|
11418
11439
|
},
|
|
11419
11440
|
// Mirrors resolveResultsThread's routines wiring a few hundred lines up
|
|
11420
11441
|
// in this same file: create-on-first-use, never activated (so it never
|
|
@@ -11589,7 +11610,7 @@ setupRetry = 0) {
|
|
|
11589
11610
|
onDispatchError?.(`${bot.name}'s approval level is still being confirmed — skipped this round`);
|
|
11590
11611
|
return true;
|
|
11591
11612
|
}
|
|
11592
|
-
|
|
11613
|
+
revokeEarlierTurnCapabilities(threadId);
|
|
11593
11614
|
spoken.add(botId);
|
|
11594
11615
|
// Must be the SAME resolver the readiness re-check uses below, or a Chief's
|
|
11595
11616
|
// delegated Full elevation makes the two disagree by construction: every
|
|
@@ -11617,7 +11638,7 @@ setupRetry = 0) {
|
|
|
11617
11638
|
role: "bot",
|
|
11618
11639
|
kind: "activity",
|
|
11619
11640
|
from: { botId: bot.id, name: bot.name, color: bot.color },
|
|
11620
|
-
tool:
|
|
11641
|
+
tool: failedTurnTool(message),
|
|
11621
11642
|
});
|
|
11622
11643
|
onDispatchError?.(message);
|
|
11623
11644
|
return true;
|
|
@@ -11652,6 +11673,10 @@ setupRetry = 0) {
|
|
|
11652
11673
|
noteForeignTurn(bot.id, threadId, internalGeneration);
|
|
11653
11674
|
const resourceOwner = { threadId, generation: internalGeneration };
|
|
11654
11675
|
turnResourceOwners.set(threadId, resourceOwner);
|
|
11676
|
+
// Set when this member's cloud computer, claimed by its first computer
|
|
11677
|
+
// call, could not start (or parked waiting for its seat): the turn ends on
|
|
11678
|
+
// it, with this cause, instead of as merely interrupted.
|
|
11679
|
+
let roomClaimFailure;
|
|
11655
11680
|
let roomVmTarget = null;
|
|
11656
11681
|
let retainRoomVmLease = false;
|
|
11657
11682
|
let roomSpeaker;
|
|
@@ -11749,7 +11774,7 @@ setupRetry = 0) {
|
|
|
11749
11774
|
role: "bot",
|
|
11750
11775
|
kind: "activity",
|
|
11751
11776
|
from: { botId: bot.id, name: bot.name, color: bot.color },
|
|
11752
|
-
tool:
|
|
11777
|
+
tool: failedTurnTool(message),
|
|
11753
11778
|
});
|
|
11754
11779
|
onDispatchError?.(message);
|
|
11755
11780
|
return true;
|
|
@@ -11898,17 +11923,13 @@ setupRetry = 0) {
|
|
|
11898
11923
|
const roomPlaceRefusal = roomKind && computerPlaceRefusal(roomKind);
|
|
11899
11924
|
if (roomPlaceRefusal)
|
|
11900
11925
|
throw Object.assign(new Error(roomPlaceRefusal.message), { code: roomPlaceRefusal.code });
|
|
11901
|
-
// The Computer engine runs on the Boat; this computer has no tools it can
|
|
11902
|
-
// reach, exactly as a bot thread refuses it.
|
|
11903
|
-
if (roomPlan.computer === "local" && instance.adapter.capabilities.remoteAgent === true) {
|
|
11904
|
-
throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
|
|
11905
|
-
}
|
|
11906
11926
|
// The same one rule as a bot thread, before anything is provisioned: an
|
|
11907
11927
|
// engine that cannot use the cloud computer is refused, never swapped.
|
|
11908
11928
|
if (!roomTeamComputer && roomPlan.computer === "cloud") {
|
|
11909
|
-
const unsupported =
|
|
11929
|
+
const unsupported = cloudPlaceRefusal(cloudEngine(instance, readyBot.modelSelection.model), "room", readyBot.name)
|
|
11930
|
+
?? computerToolsRefusal(readyBot.toolScope, "room", readyBot.name);
|
|
11910
11931
|
if (unsupported)
|
|
11911
|
-
throw
|
|
11932
|
+
throw unsupported;
|
|
11912
11933
|
}
|
|
11913
11934
|
// One place per room turn as well: a team computer reached on Auto means
|
|
11914
11935
|
// no separate built-in browser.
|
|
@@ -11928,12 +11949,12 @@ setupRetry = 0) {
|
|
|
11928
11949
|
return false;
|
|
11929
11950
|
}
|
|
11930
11951
|
if (roomTeamComputer) {
|
|
11931
|
-
const attached = await attachTeamBoat(roomTeamComputer, readyBot.id, resourceOwner,
|
|
11952
|
+
const attached = await attachTeamBoat(roomTeamComputer, readyBot.id, resourceOwner, canWorkOnCloud(cloudEngine(instance)));
|
|
11932
11953
|
if (isCancelled?.() || groupSpeakers.get(threadId) !== roomSpeaker ||
|
|
11933
11954
|
activeInternalGenerationByThread.get(threadId) !== internalGeneration)
|
|
11934
11955
|
return false;
|
|
11935
11956
|
Object.assign(integrations, attached.mount);
|
|
11936
|
-
startScreenPoller(readyBot.id, threadId, { computer: attached.capture }
|
|
11957
|
+
startScreenPoller(readyBot.id, threadId, { computer: attached.capture });
|
|
11937
11958
|
}
|
|
11938
11959
|
// The speaker's own explicit place, mounted exactly as its bot thread
|
|
11939
11960
|
// mounts it. A channel has no conversation pin and no Auto fallback here:
|
|
@@ -11941,6 +11962,51 @@ setupRetry = 0) {
|
|
|
11941
11962
|
const roomSetupIsCurrent = () => !isCancelled?.() &&
|
|
11942
11963
|
groupSpeakers.get(threadId) === roomSpeaker &&
|
|
11943
11964
|
activeInternalGenerationByThread.get(threadId) === internalGeneration;
|
|
11965
|
+
// A lazily claimed desktop (the VPS, the member's Boat) shows in the room
|
|
11966
|
+
// from the moment its first computer call claims it.
|
|
11967
|
+
const showClaimedScreen = (capture) => {
|
|
11968
|
+
if (roomSetupIsCurrent() && store.group(readyGroup.id)?.busyBotId === readyBot.id) {
|
|
11969
|
+
roomScreenBotId = readyBot.id;
|
|
11970
|
+
startScreenPoller(readyBot.id, threadId, { computer: capture });
|
|
11971
|
+
}
|
|
11972
|
+
};
|
|
11973
|
+
// The member's cloud computer could not start on its first computer call:
|
|
11974
|
+
// the same row, report and parking a failure at setup gets (the catch at
|
|
11975
|
+
// the end of this function), then the member's turn ends. Fenced to this
|
|
11976
|
+
// member's still-running turn.
|
|
11977
|
+
const failRoomClaim = (failure, error) => {
|
|
11978
|
+
if (activeInternalGenerationByThread.get(threadId) !== internalGeneration || groupSpeakers.get(threadId) !== roomSpeaker)
|
|
11979
|
+
return;
|
|
11980
|
+
if (resourceOwner.computerParkedOn) {
|
|
11981
|
+
registerComputerResume({
|
|
11982
|
+
botId: bot.id,
|
|
11983
|
+
threadId,
|
|
11984
|
+
resource: resourceOwner.computerParkedOn,
|
|
11985
|
+
generation: internalGeneration,
|
|
11986
|
+
afterMessageId: store.activePath(threadId).findLast((entry) => entry.role === "user")?.id,
|
|
11987
|
+
});
|
|
11988
|
+
roomClaimFailure = { parked: true };
|
|
11989
|
+
if (orchestration)
|
|
11990
|
+
orchestration.result.stopReason = `${bot.name} parked waiting for the computer`;
|
|
11991
|
+
}
|
|
11992
|
+
else {
|
|
11993
|
+
// Read as a room member's place (mountBotBoat): its one line, with no
|
|
11994
|
+
// retry of its own.
|
|
11995
|
+
const placeFailure = error instanceof PlaceUnavailableError ? error : undefined;
|
|
11996
|
+
const message = placeFailure?.message ?? failure;
|
|
11997
|
+
roomClaimFailure = { parked: false, message };
|
|
11998
|
+
store.appendMessage(threadId, {
|
|
11999
|
+
role: "bot", kind: "activity",
|
|
12000
|
+
from: { botId: bot.id, name: bot.name, color: bot.color },
|
|
12001
|
+
tool: failedTurnTool(message, placeFailure ? { place: placeFailure.row } : {}),
|
|
12002
|
+
});
|
|
12003
|
+
onDispatchError?.(message);
|
|
12004
|
+
// Claude settles the interrupt as exit_before_result: not a second
|
|
12005
|
+
// incident for the same failure.
|
|
12006
|
+
resourceOwner.lazyClaimFailureReported = true;
|
|
12007
|
+
}
|
|
12008
|
+
void instance.adapter.interruptTurn(threadId).catch(() => { });
|
|
12009
|
+
};
|
|
11944
12010
|
// An explicit place that cannot be attached fails the member's turn with
|
|
11945
12011
|
// its cause and the one setting that changes it.
|
|
11946
12012
|
try {
|
|
@@ -11954,15 +12020,7 @@ setupRetry = 0) {
|
|
|
11954
12020
|
if (turnProvider(readyBot) === "vps") {
|
|
11955
12021
|
vpsThreadStarted(readyBot.id, threadId);
|
|
11956
12022
|
roomVpsBotId = readyBot.id;
|
|
11957
|
-
const mounted = await mountBotVps(readyBot, resourceOwner, {
|
|
11958
|
-
start: true,
|
|
11959
|
-
onClaimed: (capture) => {
|
|
11960
|
-
if (roomSetupIsCurrent() && store.group(readyGroup.id)?.busyBotId === readyBot.id) {
|
|
11961
|
-
roomScreenBotId = readyBot.id;
|
|
11962
|
-
startScreenPoller(readyBot.id, threadId, { computer: capture });
|
|
11963
|
-
}
|
|
11964
|
-
},
|
|
11965
|
-
});
|
|
12023
|
+
const mounted = await mountBotVps(readyBot, resourceOwner, { start: true, onClaimed: showClaimedScreen });
|
|
11966
12024
|
if (!roomSetupIsCurrent())
|
|
11967
12025
|
return false;
|
|
11968
12026
|
if (!("integration" in mounted))
|
|
@@ -11972,25 +12030,18 @@ setupRetry = 0) {
|
|
|
11972
12030
|
}
|
|
11973
12031
|
else {
|
|
11974
12032
|
if (!boat.boatConfigured(cfg))
|
|
11975
|
-
throw
|
|
11976
|
-
|
|
11977
|
-
|
|
11978
|
-
|
|
11979
|
-
if (!roomSetupIsCurrent())
|
|
11980
|
-
return false;
|
|
11981
|
-
if (!attached?.mount)
|
|
11982
|
-
throw new Error("the cloud computer could not be created or reached");
|
|
11983
|
-
Object.assign(integrations, attached.mount);
|
|
11984
|
-
roomScreenBotId = readyBot.id;
|
|
11985
|
-
startScreenPoller(readyBot.id, threadId, { computer: attached.capture }, { screenIsTheWork: remoteAgent });
|
|
12033
|
+
throw boatNotConfigured(readyBot, "room");
|
|
12034
|
+
// As in a bot thread: created or woken by the member's first
|
|
12035
|
+
// computer call, never by the room message itself.
|
|
12036
|
+
integrations.localComputer = mountBotBoat(readyBot, resourceOwner, { source: "room", onClaimed: showClaimedScreen, onRejected: failRoomClaim });
|
|
11986
12037
|
roomComputerKind = "box";
|
|
11987
12038
|
}
|
|
11988
12039
|
}
|
|
11989
12040
|
// Room and Goal turns use the speaker's desktop, never the coordinator's.
|
|
11990
12041
|
// Claim the same lease as direct turns before asynchronous VM setup.
|
|
11991
12042
|
if (readyBot.computer === "vm") {
|
|
11992
|
-
if (instance.adapter.capabilities.computerMcp !== true
|
|
11993
|
-
throw
|
|
12043
|
+
if (instance.adapter.capabilities.computerMcp !== true) {
|
|
12044
|
+
throw placeUnavailable("vm", { state: "vm-cannot", params: { bot: readyBot.name, model: cloudEngine(instance, readyBot.modelSelection.model).name }, source: "room" }, Boolean(CLOUD_HOME));
|
|
11994
12045
|
}
|
|
11995
12046
|
// A distinct identity fences cleanup even in shared mode on the same room thread.
|
|
11996
12047
|
const target = { ...localVmTargetForThread(readyBot.id, threadId) };
|
|
@@ -12023,17 +12074,14 @@ setupRetry = 0) {
|
|
|
12023
12074
|
if (owner?.threadId !== threadId || owner.botId !== readyBot.id) {
|
|
12024
12075
|
throw new Error("the Local VM lease expired while preparing the turn");
|
|
12025
12076
|
}
|
|
12026
|
-
integrations.localComputer = containerComputerMcp(vm.runtime, controlIntegration(readyBot.id, threadId, internalGeneration, target), target);
|
|
12077
|
+
integrations.localComputer = containerComputerMcp(vm.runtime, controlIntegration(readyBot.id, threadId, internalGeneration, { localVmTarget: target }), target);
|
|
12027
12078
|
}
|
|
12028
12079
|
}
|
|
12029
12080
|
catch (error) {
|
|
12030
12081
|
const place = roomPlan.computer;
|
|
12031
12082
|
if (place !== "cloud" && place !== "vm" && place !== "local")
|
|
12032
12083
|
throw error;
|
|
12033
|
-
|
|
12034
|
-
throw error;
|
|
12035
|
-
// A room row is not cut, so the cause keeps all its words.
|
|
12036
|
-
throw placeUnavailable(place, "works-on", error instanceof Error ? error.message : String(error), Infinity);
|
|
12084
|
+
throw await asPlaceFailure(error, place, "room", readyBot, turnProvider(readyBot) === "vps" ? "vps" : "box");
|
|
12037
12085
|
}
|
|
12038
12086
|
const roster = readyGroup.memberIds
|
|
12039
12087
|
.map((id) => store.bot(id))
|
|
@@ -12044,8 +12092,9 @@ setupRetry = 0) {
|
|
|
12044
12092
|
// are who it cannot. The 1:1 prompt has carried a peer roster since #774,
|
|
12045
12093
|
// and a room turn had nothing — the only advice it gave ("mention them
|
|
12046
12094
|
// like @Name") sends the model after a teammate who will never see it.
|
|
12047
|
-
// Same reachability rule as list_bots, minus the room's own members
|
|
12048
|
-
|
|
12095
|
+
// Same reachability rule as list_bots, minus the room's own members, and
|
|
12096
|
+
// only on a turn that is not answering a room request.
|
|
12097
|
+
const outsideRoom = integrations.agents && orchestration && !orchestration.roomHandoffId
|
|
12049
12098
|
? reachablePeers(store.bots, bot).filter((peer) => !readyGroup.memberIds.includes(peer.id))
|
|
12050
12099
|
: [];
|
|
12051
12100
|
const system = [
|
|
@@ -12055,7 +12104,7 @@ setupRetry = 0) {
|
|
|
12055
12104
|
`Room members: ${roster}, and ${userName} (the human).`,
|
|
12056
12105
|
readyGroup.bulletin.trim() && `Room bulletin (shared instructions for everyone):\n${readyGroup.bulletin.trim()}`,
|
|
12057
12106
|
`Reply as yourself, briefly and conversationally. To bring a teammate in, mention them like @Name — they'll see the conversation and respond.`,
|
|
12058
|
-
outsideRoom.length > 0 &&
|
|
12107
|
+
outsideRoom.length > 0 && roomPeerRosterSystemPrompt(outsideRoom),
|
|
12059
12108
|
integrations.agents && (CREDENTIAL_PROMPT + (orchestration && !orchestration.roomHandoffId ? THREADS_PROMPT : "")).trim(),
|
|
12060
12109
|
integrations.agents && (!orchestration || orchestration.roomHandoffId) && "For actual OpenMausBot teamwork, discover IDs with list_room_targets and use coordinate_bots for advice or work in this or another room. Do not substitute native coding helpers for these named bots. Consult only when needed to make a decision; no discussion step is mandatory. Give concrete responsibilities, exact accessible paths and acceptance checks. End your turn after assigning; busy teammates queue and results automatically resume you. When they return, finish the requested verification and give the user one final answer. Native helper names are not evidence that an OpenMausBot teammate participated. Plain @mentions are only for conversational replies in this room.",
|
|
12061
12110
|
integrations.agents && ROUTINE_PROMPT.trim(),
|
|
@@ -12119,7 +12168,6 @@ setupRetry = 0) {
|
|
|
12119
12168
|
const recentLines = recentWork(recentWorkSources(bot), bot, { userName, currentThreadId: threadId, ...recentWorkFilter() });
|
|
12120
12169
|
const roomComputerPromptKind = resolveComputerPromptKind({
|
|
12121
12170
|
kind: roomTeamComputer || roomComputerKind === "box" ? "box" : roomVmTarget ? "vm" : roomComputerKind,
|
|
12122
|
-
driverKind: instance.driverKind,
|
|
12123
12171
|
vmPrivate: localVmMode(cfg) === "per-bot",
|
|
12124
12172
|
});
|
|
12125
12173
|
{
|
|
@@ -12140,9 +12188,10 @@ setupRetry = 0) {
|
|
|
12140
12188
|
{ id: "computer", label: "Computer", text: computerPrompt(roomComputerPromptKind) },
|
|
12141
12189
|
{ id: "team-computer", label: "Team computer", text: teamComputerPrompt(roomTeamComputer) },
|
|
12142
12190
|
{ id: "plan", label: "Surface", text: surfacePrompt({ computer: roomTeamComputer ? "cloud" : roomVmTarget ? "vm" : surfaceOfComputerKind(roomComputerKind), browser: Boolean(integrations.browser) }, { note: roomPlan.note, cloudHome: Boolean(CLOUD_HOME) }) },
|
|
12143
|
-
{ id: "cloud-home", label: "
|
|
12191
|
+
{ id: "cloud-home", label: "My Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
|
|
12144
12192
|
{ id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
|
|
12145
12193
|
{ id: "recall", label: "Recall", text: integrations.agents && bot.memoryEnabled !== false ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
|
|
12194
|
+
teamAvailabilityPart(outsideRoom),
|
|
12146
12195
|
{ id: "recent", label: "Recent work", text: recentWorkPrompt(recentLines) },
|
|
12147
12196
|
{ id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
|
|
12148
12197
|
{ id: "team-memory", label: "Team memory", text: teamMemory.systemPrompt(bot.section) + (integrations.agents ? TEAM_MEMORY_PROMPT : "") },
|
|
@@ -12232,13 +12281,10 @@ setupRetry = 0) {
|
|
|
12232
12281
|
if (e.type === "item.completed" && e.itemType === "assistant_text")
|
|
12233
12282
|
replyText += `\n${e.text}`;
|
|
12234
12283
|
else if (e.type === "turn.completed") {
|
|
12235
|
-
|
|
12236
|
-
|
|
12237
|
-
|
|
12238
|
-
|
|
12239
|
-
else {
|
|
12240
|
-
finish("settled");
|
|
12241
|
-
}
|
|
12284
|
+
const end = roomTurnEnd(e, Boolean(orchestration), roomClaimFailure);
|
|
12285
|
+
if (orchestration && end.stopReason !== undefined)
|
|
12286
|
+
orchestration.result.stopReason = end.stopReason;
|
|
12287
|
+
finish(end.outcome);
|
|
12242
12288
|
}
|
|
12243
12289
|
// Waiting on a person is not turn work: hold the ceiling while an
|
|
12244
12290
|
// approval or question card is open, so deciding slowly does not
|
|
@@ -12323,7 +12369,7 @@ setupRetry = 0) {
|
|
|
12323
12369
|
role: "bot",
|
|
12324
12370
|
kind: "activity",
|
|
12325
12371
|
from: { botId: bot.id, name: bot.name, color: bot.color },
|
|
12326
|
-
tool:
|
|
12372
|
+
tool: failedTurnTool(message),
|
|
12327
12373
|
});
|
|
12328
12374
|
onDispatchError?.(message);
|
|
12329
12375
|
watchdog.settle(threadId);
|
|
@@ -12427,7 +12473,7 @@ setupRetry = 0) {
|
|
|
12427
12473
|
drainSecretResumes();
|
|
12428
12474
|
drainTeamSetupResumes();
|
|
12429
12475
|
}
|
|
12430
|
-
if (outcome === "provider_failed") {
|
|
12476
|
+
if (outcome === "provider_failed" || outcome === "parked") {
|
|
12431
12477
|
if (skillAuthoring)
|
|
12432
12478
|
skillAuthoringClaim.claimed = false;
|
|
12433
12479
|
return false;
|
|
@@ -12516,7 +12562,7 @@ setupRetry = 0) {
|
|
|
12516
12562
|
store.appendMessage(threadId, {
|
|
12517
12563
|
role: "bot", kind: "activity",
|
|
12518
12564
|
from: { botId: bot.id, name: bot.name, color: bot.color },
|
|
12519
|
-
tool: {
|
|
12565
|
+
tool: failedTurnTool(message, error instanceof PlaceUnavailableError ? { place: error.row } : {}),
|
|
12520
12566
|
});
|
|
12521
12567
|
onDispatchError?.(message);
|
|
12522
12568
|
if (orchestration) {
|
|
@@ -13125,10 +13171,7 @@ function drainQueuedChannelSends() {
|
|
|
13125
13171
|
store.appendMessage(threadId, {
|
|
13126
13172
|
role: "bot",
|
|
13127
13173
|
kind: "activity",
|
|
13128
|
-
tool: {
|
|
13129
|
-
name: `error: queued channel message could not start — ${(error instanceof Error ? error.message : String(error)).slice(0, 120)}`,
|
|
13130
|
-
ok: false,
|
|
13131
|
-
},
|
|
13174
|
+
tool: failedTurnTool(`queued channel message could not start — ${error instanceof Error ? error.message : String(error)}`),
|
|
13132
13175
|
});
|
|
13133
13176
|
}
|
|
13134
13177
|
// A message with no eligible responder creates no operation. Continue
|
|
@@ -13335,9 +13378,9 @@ function proposalPersistence(botId, threadId) {
|
|
|
13335
13378
|
return { ok: true };
|
|
13336
13379
|
// Only cards on the visible branch can be acted on from the composer.
|
|
13337
13380
|
// Abandoned branches must not permanently consume the proposal quota.
|
|
13338
|
-
// Routine, profile, default-model
|
|
13381
|
+
// Routine, profile, default-model and team-setup proposals
|
|
13339
13382
|
// share one budget per bot per thread, so one thread cannot pile up 8 of each.
|
|
13340
|
-
const openRequests = store.activePath(threadId).filter((message) => (message.card?.routineRequest?.botId === botId || message.card?.profileRequest?.botId === botId || message.card?.modelRequest?.botId === botId || message.card?.
|
|
13383
|
+
const openRequests = store.activePath(threadId).filter((message) => (message.card?.routineRequest?.botId === botId || message.card?.profileRequest?.botId === botId || message.card?.modelRequest?.botId === botId || message.card?.teamSetupRequest?.botId === botId) &&
|
|
13341
13384
|
!message.card.answered &&
|
|
13342
13385
|
!message.card.dismissed && !message.card.expired).length;
|
|
13343
13386
|
return openRequests >= 8
|
|
@@ -14199,7 +14242,8 @@ function configStatus() {
|
|
|
14199
14242
|
// not a saved key
|
|
14200
14243
|
box: boat.describeBoatAccount(cfg),
|
|
14201
14244
|
vps: { configured: Boolean(vpsSshAlias(cfg)), sshAlias: vpsSshAlias(cfg) ?? "" },
|
|
14202
|
-
|
|
14245
|
+
// names only: every provider key stays write-only
|
|
14246
|
+
opencodeGo: { configured: Boolean(cfg.opencodeGo?.apiKey), providerKeys: Object.keys(openCodeProviderKeys(cfg)) },
|
|
14203
14247
|
// the chosen voice is a setting, not a secret; the key is reported the
|
|
14204
14248
|
// same configured-or-not way as every other credential
|
|
14205
14249
|
tts: tts.describeVoice(cfg),
|
|
@@ -14281,6 +14325,7 @@ function configForAccess(status, admin) {
|
|
|
14281
14325
|
edition: { edition: status.edition.edition, features: status.edition.features },
|
|
14282
14326
|
signIn: { admins: [], members: [] },
|
|
14283
14327
|
vps: { configured: status.vps.configured, sshAlias: "" },
|
|
14328
|
+
opencodeGo: { configured: status.opencodeGo.configured, providerKeys: [] },
|
|
14284
14329
|
profile: { name: status.profile.name, email: "" },
|
|
14285
14330
|
browserProfiles: status.browserProfiles.map((profile) => Object.fromEntries(Object.entries(profile).filter(([key]) => key !== "partitionId"))),
|
|
14286
14331
|
};
|
|
@@ -14332,6 +14377,10 @@ function persistMcpServers(next) {
|
|
|
14332
14377
|
}
|
|
14333
14378
|
async function describeInstances() {
|
|
14334
14379
|
const configs = providerConfigs();
|
|
14380
|
+
// Reading the engines is where this server learns one became available
|
|
14381
|
+
// (an install, a sign-in, a key or a Company engine), so a bot still on the
|
|
14382
|
+
// removed Computer engine moves now rather than at the next start.
|
|
14383
|
+
void retryComputerEngineMove();
|
|
14335
14384
|
return (await registry.describe()).map((instance) => {
|
|
14336
14385
|
const entry = configs[instance.instanceId];
|
|
14337
14386
|
const described = {
|
|
@@ -14508,7 +14557,7 @@ async function reloadProviders() {
|
|
|
14508
14557
|
if (store.taskByThread(botId, threadId)) {
|
|
14509
14558
|
store.appendMessage(threadId, {
|
|
14510
14559
|
role: "bot", kind: "activity",
|
|
14511
|
-
tool:
|
|
14560
|
+
tool: failedTurnTool("turn interrupted — provider settings changed"),
|
|
14512
14561
|
});
|
|
14513
14562
|
store.setTaskActivity(botId, threadId, "idle");
|
|
14514
14563
|
}
|
|
@@ -14611,7 +14660,7 @@ const workspaceBackupAccess = {
|
|
|
14611
14660
|
!routines?.isTicking && !calendarCalls?.isTicking &&
|
|
14612
14661
|
!localVmImageBusy && !localVmProvisionBusy && !localVmModeChangeBusy &&
|
|
14613
14662
|
!localVmLifecycleBusy.size && !boatLifecycleBusyBots.size && !vpsPreviewRequests.size && !orphanBoatLifecycleBusyIds.size &&
|
|
14614
|
-
!computerProviderConfigTransitions.size &&
|
|
14663
|
+
!computerProviderConfigTransitions.size &&
|
|
14615
14664
|
teamComputers.list().every(computer => !teamComputerInUse(computer)) &&
|
|
14616
14665
|
store.bots.every((bot) => !botHasActiveTurn(bot.id) && !routines?.activeRunForBot(bot.id) && !botComputerControlSnapshot(bot.id).held) &&
|
|
14617
14666
|
store.groups.every((group) => !groupIsWorking(group)),
|
|
@@ -14634,18 +14683,24 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
|
|
|
14634
14683
|
restored: workspaceRestore,
|
|
14635
14684
|
...workspaceBackupAccess,
|
|
14636
14685
|
});
|
|
14637
|
-
//
|
|
14638
|
-
//
|
|
14639
|
-
//
|
|
14640
|
-
|
|
14686
|
+
// Copy this computer here (server/cloud-move-http.ts, docs/copy-workspace.md):
|
|
14687
|
+
// every server sizes its own workspace for its desktop, and every server the
|
|
14688
|
+
// person owns receives one, a Cloud home included. A workspace shared with
|
|
14689
|
+
// other people never does (workspaceShared): a hosted organisation
|
|
14690
|
+
// workspace, or a server whose email sign-in lets someone besides its owner
|
|
14691
|
+
// in. After the restore commits, the server
|
|
14692
|
+
// exits with RESTART_EXIT_CODE and its launcher starts it again, so startup
|
|
14693
|
+
// installs it (server/restart.ts).
|
|
14694
|
+
let restartRequested = false;
|
|
14641
14695
|
const cloudMoveRoutes = createCloudMoveRoutes({
|
|
14642
14696
|
dataDir: DATA_DIR,
|
|
14643
14697
|
appVersion: serverVersion(),
|
|
14644
|
-
|
|
14698
|
+
environmentId: ENVIRONMENT_ID,
|
|
14699
|
+
sharedWorkspace: () => workspaceShared({ hosted: HOSTED_WORKSPACE, cloudHome: Boolean(CLOUD_HOME), signIn: signInAllowList() }),
|
|
14645
14700
|
readBody,
|
|
14646
14701
|
restored: workspaceRestore,
|
|
14647
14702
|
...workspaceBackupAccess,
|
|
14648
|
-
restart: () => {
|
|
14703
|
+
restart: () => { restartRequested = true; gracefulShutdown(); },
|
|
14649
14704
|
});
|
|
14650
14705
|
// Route modules (server/routes/README.md). `workspaceAccess` is assigned at
|
|
14651
14706
|
// boot, after this line, so the dependency reads it per request.
|
|
@@ -14754,7 +14809,7 @@ const liveCalls = new LiveCallController({
|
|
|
14754
14809
|
},
|
|
14755
14810
|
broadcast: (frame) => broadcast(frame, { adminOnly: true }),
|
|
14756
14811
|
settings: () => ({ key: cfg.live?.key ?? "", ...liveSettingsFor(cfg) }),
|
|
14757
|
-
createSession: ({ key, sdp, botId, threadId, voice }) => createLiveSession({ key, sdp, voice, bot: liveBotFor(botId), history: liveHistoryFor(threadId) }),
|
|
14812
|
+
createSession: ({ key, sdp, botId, threadId, voice }) => createLiveSession({ key, sdp, voice, bot: liveBotFor(botId), history: liveHistoryFor(threadId), cloudHome: Boolean(CLOUD_HOME) }),
|
|
14758
14813
|
// Node's WebSocket (undici) accepts headers in its second argument.
|
|
14759
14814
|
openSocket: (url, key) => new WebSocket(url, { headers: { authorization: `Bearer ${key}` } }),
|
|
14760
14815
|
attachUrl: (sessionId) => liveAttachUrl(sessionId),
|
|
@@ -15135,9 +15190,7 @@ const handleRequest = async (req, res) => {
|
|
|
15135
15190
|
// scheme the native companion scanners accept; it carries the
|
|
15136
15191
|
// credential encoding because those scanners cannot take a typed code.
|
|
15137
15192
|
const serverName = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED }).label;
|
|
15138
|
-
const invite = base
|
|
15139
|
-
? `openmausbot://pair?address=${encodeURIComponent(base)}&token=${encodeURIComponent(opened.credential)}&name=${encodeURIComponent(serverName)}`
|
|
15140
|
-
: null;
|
|
15193
|
+
const invite = base ? phonePairingLink({ address: base, token: opened.credential, name: serverName }) : null;
|
|
15141
15194
|
return json(res, 200, {
|
|
15142
15195
|
id: opened.id,
|
|
15143
15196
|
code,
|
|
@@ -15251,7 +15304,7 @@ const handleRequest = async (req, res) => {
|
|
|
15251
15304
|
// A Cloud home is one person's: only their own devices, which the
|
|
15252
15305
|
// Admin's pairing signs in with admin scope, may lend to it.
|
|
15253
15306
|
if (CLOUD_HOME && !auth.scopes.includes("admin"))
|
|
15254
|
-
return json(res, 403, { error: "Only your own computers can lend to
|
|
15307
|
+
return json(res, 403, { error: "Only your own computers can lend to My Cloud. On this computer, open My Cloud from Settings → OpenMausBot Cloud first." });
|
|
15255
15308
|
sharedComputers.register(registration, { session: auth.session.id, person: CLOUD_HOME ? CLOUD_HOME_LENDER : personKey(auth.session) }, secret);
|
|
15256
15309
|
return json(res, 200, { ok: true });
|
|
15257
15310
|
}
|
|
@@ -15401,6 +15454,9 @@ const handleRequest = async (req, res) => {
|
|
|
15401
15454
|
throw Object.assign(new Error("the internal turn capability has expired"), { status: 401 });
|
|
15402
15455
|
}
|
|
15403
15456
|
};
|
|
15457
|
+
const delegatedThisTurn = (taskId) => {
|
|
15458
|
+
(internalCapability.delegatedThisTurn ??= new Set()).add(taskId);
|
|
15459
|
+
};
|
|
15404
15460
|
// Where an entry came from, as the person will read it in MEMORY.md:
|
|
15405
15461
|
// the room or the thread title, never a bare id unless nothing else
|
|
15406
15462
|
// names the conversation.
|
|
@@ -15431,9 +15487,13 @@ const handleRequest = async (req, res) => {
|
|
|
15431
15487
|
return json(res, 409, { error: "The user request ended before its computer was selected." });
|
|
15432
15488
|
if (unoffered)
|
|
15433
15489
|
return json(res, 409, { error: unoffered, options });
|
|
15434
|
-
|
|
15435
|
-
|
|
15436
|
-
|
|
15490
|
+
// The computer this turn mounted is already selected, even before it
|
|
15491
|
+
// is up when its first call starts it (the bot's cloud computer):
|
|
15492
|
+
// switching to it would only restart the request.
|
|
15493
|
+
const claim = autoVmClaims.get(internalCapability.threadId);
|
|
15494
|
+
const startsOnFirstCall = claim?.owner.generation === internalCapability.generation && claim.startsComputer === true && !claim.failed;
|
|
15495
|
+
const picked = pickComputer(options, requested, current, startsOnFirstCall);
|
|
15496
|
+
const option = picked.option;
|
|
15437
15497
|
if (!option?.available)
|
|
15438
15498
|
return json(res, 409, { error: option?.reason ?? "No configured computer or browser is available. Open the Computer panel to set one up.", options });
|
|
15439
15499
|
if (source.selected) {
|
|
@@ -15441,8 +15501,11 @@ const handleRequest = async (req, res) => {
|
|
|
15441
15501
|
return json(res, 409, { error: "A computer switch is already pending. End this turn to continue there." });
|
|
15442
15502
|
}
|
|
15443
15503
|
else {
|
|
15444
|
-
if (
|
|
15445
|
-
return json(res, 200, { status: "ready", surface: current, message:
|
|
15504
|
+
if (picked.current) {
|
|
15505
|
+
return json(res, 200, { status: "ready", surface: current, message: option.ready
|
|
15506
|
+
? "This computer is already selected. Use its mounted tools."
|
|
15507
|
+
: "This computer is already selected. It starts on its first call; use its mounted tools." });
|
|
15508
|
+
}
|
|
15446
15509
|
source.selected = option.surface;
|
|
15447
15510
|
source.previousSurface = store.taskByThread(bot.id, bot.threadId)?.surface;
|
|
15448
15511
|
}
|
|
@@ -15544,17 +15607,19 @@ const handleRequest = async (req, res) => {
|
|
|
15544
15607
|
return json(res, 200, { result });
|
|
15545
15608
|
}
|
|
15546
15609
|
// The cloud computer's tools (harness-mcp-proxy computer): the turn's
|
|
15547
|
-
// capability names its Boat, and the Boat credential stays here.
|
|
15610
|
+
// capability names its Boat, and the Boat credential stays here. A
|
|
15611
|
+
// capability minted before the Boat existed (boxId null) acts on the
|
|
15612
|
+
// one its first call's claim created or woke (mountBotBoat).
|
|
15548
15613
|
if (method === "POST" && path === "/api/internal/computer/mcp") {
|
|
15549
15614
|
const body = await readInternalBody();
|
|
15550
|
-
|
|
15551
|
-
if (!boxId)
|
|
15615
|
+
if (internalCapability.boxId === undefined)
|
|
15552
15616
|
return json(res, 403, { error: "this turn has no cloud computer" });
|
|
15553
15617
|
const abort = new AbortController();
|
|
15554
15618
|
res.once("close", () => { if (!res.writableEnded)
|
|
15555
15619
|
abort.abort(); });
|
|
15556
15620
|
return json(res, 200, { result: await cloudComputerRpc(body, {
|
|
15557
|
-
cfg,
|
|
15621
|
+
cfg, signal: abort.signal,
|
|
15622
|
+
boxId: () => internalCapability.boxId ?? claimedBoatId(internalCapability),
|
|
15558
15623
|
gate: () => computerCallGate(internalCapability),
|
|
15559
15624
|
assertActive: requireActiveInternalCapability,
|
|
15560
15625
|
}) });
|
|
@@ -16057,39 +16122,6 @@ const handleRequest = async (req, res) => {
|
|
|
16057
16122
|
});
|
|
16058
16123
|
return json(res, 201, proposed);
|
|
16059
16124
|
}
|
|
16060
|
-
if (method === "POST" && path === "/api/internal/tightening-requests") {
|
|
16061
|
-
const parsed = z.object({
|
|
16062
|
-
fromBotId: z.string().min(1).max(128),
|
|
16063
|
-
fromThreadId: z.string().min(1).max(128),
|
|
16064
|
-
forBotId: z.string().max(128).optional(),
|
|
16065
|
-
intents: z.unknown(),
|
|
16066
|
-
reason: z.unknown(),
|
|
16067
|
-
}).strict().safeParse(await readInternalBody());
|
|
16068
|
-
if (!parsed.success)
|
|
16069
|
-
return json(res, 400, { error: "invalid tightening proposal" });
|
|
16070
|
-
const body = parsed.data;
|
|
16071
|
-
const from = store.bot(body.fromBotId);
|
|
16072
|
-
if (!from)
|
|
16073
|
-
return json(res, 403, { error: "unknown sender" });
|
|
16074
|
-
const owner = connectorThread(from.id, body.fromThreadId);
|
|
16075
|
-
if (!owner)
|
|
16076
|
-
return json(res, 403, { error: "source conversation does not belong to sender" });
|
|
16077
|
-
const targetBotId = body.forBotId?.trim() || from.id;
|
|
16078
|
-
const proposed = tighteningRequests.submit({
|
|
16079
|
-
botId: from.id,
|
|
16080
|
-
threadId: body.fromThreadId,
|
|
16081
|
-
targetBotId,
|
|
16082
|
-
intents: body.intents,
|
|
16083
|
-
reason: body.reason,
|
|
16084
|
-
from: owner.group ? { botId: from.id, name: from.name, color: from.color } : undefined,
|
|
16085
|
-
});
|
|
16086
|
-
appendDecision(DATA_DIR, {
|
|
16087
|
-
threadId: body.fromThreadId, requestId: proposed.requestId, botId: from.id, botName: from.name,
|
|
16088
|
-
tool: "tighten_permissions", summary: proposed.detail, decision: proposed.state === "applied" ? "auto-approved" : "card-shown",
|
|
16089
|
-
source: proposed.state === "applied" ? "full-access" : "tightening",
|
|
16090
|
-
});
|
|
16091
|
-
return json(res, 201, proposed);
|
|
16092
|
-
}
|
|
16093
16125
|
if (method === "GET" && path === "/api/internal/team-setup-catalog") {
|
|
16094
16126
|
let chief = store.bot(internalCapability.botId);
|
|
16095
16127
|
if (!chief.chiefOfStaff || chief.hidden)
|
|
@@ -16428,7 +16460,8 @@ const handleRequest = async (req, res) => {
|
|
|
16428
16460
|
const guestThread = guestWorkThread(from, target, fromThreadId, message);
|
|
16429
16461
|
if (guestThread === null)
|
|
16430
16462
|
return json(res, 500, { error: "couldn't open a conversation for that work" });
|
|
16431
|
-
const queued = queueDelegation(commsBus, from, { toBotId, message, reason: "asked while busy", depth, approvalAlreadyGranted, ...(guestThread ? { targetThreadId: guestThread } : {})
|
|
16463
|
+
const queued = queueDelegation(commsBus, from, { toBotId, message, reason: "asked while busy", depth, approvalAlreadyGranted, ...(guestThread ? { targetThreadId: guestThread } : {}),
|
|
16464
|
+
...externalCompletion(internalCapability) }, MAX_COMMS_DEPTH, fromThreadId);
|
|
16432
16465
|
if ((queued.result !== "ok" || !queued.id) && guestThread)
|
|
16433
16466
|
store.deleteTask(toBotId, guestThread);
|
|
16434
16467
|
if (queued.result !== "ok" || !queued.id)
|
|
@@ -16442,6 +16475,7 @@ const handleRequest = async (req, res) => {
|
|
|
16442
16475
|
if (internalCapability.generation === EXTERNAL_RUNTIME_GENERATION && !threadBusy(from.id, fromThreadId)) {
|
|
16443
16476
|
drainThreadDelegations(fromThreadId);
|
|
16444
16477
|
}
|
|
16478
|
+
delegatedThisTurn(queued.id);
|
|
16445
16479
|
return json(res, 200, { busy: true, taskId: queued.id, toBotName: target.name, receipt: peerDeliveryReceipt({
|
|
16446
16480
|
botId: toBotId, botName: target.name, outcome: "queued", taskId: queued.id,
|
|
16447
16481
|
detail: "the teammate was busy; the ask was queued as a delegation instead",
|
|
@@ -16595,12 +16629,14 @@ const handleRequest = async (req, res) => {
|
|
|
16595
16629
|
sourceThreadId: fromThreadId,
|
|
16596
16630
|
sourceBotId: currentFrom.id,
|
|
16597
16631
|
routineRunId: activeRoutineRunForThread(fromThreadId)?.id,
|
|
16632
|
+
...externalCompletion(internalCapability),
|
|
16598
16633
|
});
|
|
16599
16634
|
store.appendMessage(fromThreadId, {
|
|
16600
16635
|
role: "bot",
|
|
16601
16636
|
kind: "activity",
|
|
16602
16637
|
tool: { name: `@${currentTarget.name} is still working — ask converted to a delegation` },
|
|
16603
16638
|
});
|
|
16639
|
+
delegatedThisTurn(taskId);
|
|
16604
16640
|
return json(res, 200, { timeout: true, taskId, toBotName: currentTarget.name, waitedMs: ASK_BOT_TIMEOUT_MS,
|
|
16605
16641
|
receipt: peerDeliveryReceipt({ botId: toBotId, botName: currentTarget.name, outcome: "injected", taskId,
|
|
16606
16642
|
detail: "the teammate's turn started and is still running; only the synchronous wait ended" }) });
|
|
@@ -16639,6 +16675,9 @@ const handleRequest = async (req, res) => {
|
|
|
16639
16675
|
const delegationMatch = method === "GET" ? path.match(/^\/api\/internal\/delegations\/([\w-]{4,64})$/) : null;
|
|
16640
16676
|
if (delegationMatch) {
|
|
16641
16677
|
const taskId = delegationMatch[1];
|
|
16678
|
+
if (internalCapability.delegatedThisTurn?.has(taskId)) {
|
|
16679
|
+
return json(res, 409, { error: `Task ${taskId} was delegated during this turn. Finish your response now so the other bot can work; its result will be delivered to this conversation automatically. Do not check or wait for a newly delegated task until a later turn.` });
|
|
16680
|
+
}
|
|
16642
16681
|
const fromThreadId = internalCapability.threadId;
|
|
16643
16682
|
const from = internalSender;
|
|
16644
16683
|
if (!connectorThread(from.id, fromThreadId))
|
|
@@ -16680,7 +16719,10 @@ const handleRequest = async (req, res) => {
|
|
|
16680
16719
|
if (!sender || (target && !canReachPeer(sender, target))) {
|
|
16681
16720
|
return json(res, 403, { error: "Result withheld: team access changed while the teammate was working" });
|
|
16682
16721
|
}
|
|
16683
|
-
|
|
16722
|
+
// Nothing moves while the teammate's card waits on the person, so
|
|
16723
|
+
// say so now rather than holding the long-poll to its deadline.
|
|
16724
|
+
const awaitingPerson = running && runningEntry ? openPersonCard(runningEntry[0]) : undefined;
|
|
16725
|
+
if (Date.now() >= deadline || awaitingPerson) {
|
|
16684
16726
|
if (running && runningEntry) {
|
|
16685
16727
|
const recent = summarizeDelegatedActivity(store.messagesFor(runningEntry[0]), running.startedAtMs ?? Date.now());
|
|
16686
16728
|
return json(res, 200, {
|
|
@@ -16688,6 +16730,12 @@ const handleRequest = async (req, res) => {
|
|
|
16688
16730
|
toBotName: store.bot(toBotId)?.name ?? toBotId,
|
|
16689
16731
|
elapsedMs: Math.max(0, Date.now() - (running.startedAtMs ?? Date.now())),
|
|
16690
16732
|
recentActivity: recent,
|
|
16733
|
+
...(awaitingPerson ? {
|
|
16734
|
+
awaitingPerson: {
|
|
16735
|
+
...awaitingPerson,
|
|
16736
|
+
threadTitle: store.taskByThread(toBotId, runningEntry[0])?.title,
|
|
16737
|
+
},
|
|
16738
|
+
} : {}),
|
|
16691
16739
|
});
|
|
16692
16740
|
}
|
|
16693
16741
|
const queuedTarget = store.bot(toBotId);
|
|
@@ -16786,7 +16834,7 @@ const handleRequest = async (req, res) => {
|
|
|
16786
16834
|
const guestThread = guestWorkThread(from, target, fromThreadId, reason ?? message);
|
|
16787
16835
|
if (guestThread === null)
|
|
16788
16836
|
return json(res, 500, { error: "couldn't open a conversation for that work" });
|
|
16789
|
-
const queued = queueDelegation(commsBus, from, { toBotId, message, reason, depth, ...(guestThread ? { targetThreadId: guestThread } : {}) }, MAX_COMMS_DEPTH, fromThreadId);
|
|
16837
|
+
const queued = queueDelegation(commsBus, from, { toBotId, message, reason, depth, ...(guestThread ? { targetThreadId: guestThread } : {}), ...externalCompletion(internalCapability) }, MAX_COMMS_DEPTH, fromThreadId);
|
|
16790
16838
|
if ((queued.result !== "ok" || !queued.id) && guestThread)
|
|
16791
16839
|
store.deleteTask(toBotId, guestThread);
|
|
16792
16840
|
if (queued.result !== "ok" || !queued.id) {
|
|
@@ -16804,6 +16852,7 @@ const handleRequest = async (req, res) => {
|
|
|
16804
16852
|
detail: `${refusal} — nothing was queued`,
|
|
16805
16853
|
}) });
|
|
16806
16854
|
}
|
|
16855
|
+
delegatedThisTurn(queued.id);
|
|
16807
16856
|
const targetName = store.bot(toBotId)?.name ?? toBotId;
|
|
16808
16857
|
// The queue normally drains when the source thread's turn completes. A
|
|
16809
16858
|
// caller this server did not spawn (an external runtime) has no live
|
|
@@ -17032,6 +17081,12 @@ const handleRequest = async (req, res) => {
|
|
|
17032
17081
|
if (!groupId || !message) {
|
|
17033
17082
|
return json(res, 400, { error: "post_to_room needs group_id (from list_rooms) and message" });
|
|
17034
17083
|
}
|
|
17084
|
+
// One turn's worth of posts is a handful; past it the turn has
|
|
17085
|
+
// stopped reporting and started broadcasting. Counted per turn, on
|
|
17086
|
+
// top of the room's own budget below.
|
|
17087
|
+
if ((internalCapability.roomPosts ?? 0) >= MAX_ROOM_POSTS_PER_TURN) {
|
|
17088
|
+
return json(res, 429, { error: `You have already posted ${MAX_ROOM_POSTS_PER_TURN} times this turn, which is the limit. Do not retry — finish your turn and say anything further to the user directly.` });
|
|
17089
|
+
}
|
|
17035
17090
|
if (message.length > ROOM_POST_MAX_CHARS) {
|
|
17036
17091
|
return json(res, 400, {
|
|
17037
17092
|
error: `a room post is at most ${ROOM_POST_MAX_CHARS} characters — post the short version and keep the detail in your own reply`,
|
|
@@ -17160,6 +17215,7 @@ const handleRequest = async (req, res) => {
|
|
|
17160
17215
|
peerPost: unattended ? { unattended: true } : {},
|
|
17161
17216
|
...(attachedVoiceNote ? { attachments: stagedAttachments } : {}),
|
|
17162
17217
|
});
|
|
17218
|
+
internalCapability.roomPosts = (internalCapability.roomPosts ?? 0) + 1;
|
|
17163
17219
|
detachParkedAudio?.();
|
|
17164
17220
|
store.patchGroup(room.id, { unread: true });
|
|
17165
17221
|
// The same visibility contract the peer tools keep: whatever a bot
|
|
@@ -17203,7 +17259,7 @@ const handleRequest = async (req, res) => {
|
|
|
17203
17259
|
if (!fitsOnOneLine(title))
|
|
17204
17260
|
return json(res, 400, { error: "title must fit on one line" });
|
|
17205
17261
|
if (internalCapability.openedThreads >= MAX_THREADS_OPENED_PER_TURN) {
|
|
17206
|
-
return json(res, 429, { error: `
|
|
17262
|
+
return json(res, 429, { error: `You have already opened ${MAX_THREADS_OPENED_PER_TURN} threads this turn, which is the limit. Do not retry — finish your turn and tell the person which threads you still wanted to open, so they can open them or ask you again.` });
|
|
17207
17263
|
}
|
|
17208
17264
|
const toBotId = typeof body.toBotId === "string" && body.toBotId.trim() ? body.toBotId.trim() : from.id;
|
|
17209
17265
|
const target = store.bot(toBotId);
|
|
@@ -17302,6 +17358,7 @@ const handleRequest = async (req, res) => {
|
|
|
17302
17358
|
}
|
|
17303
17359
|
store.setTaskOpenedBy(target.id, task.threadId, { botId: from.id, name: from.name, delegationId: queued.id, at: task.openedBy?.at ?? Date.now() });
|
|
17304
17360
|
internalCapability.openedThreads += 1;
|
|
17361
|
+
delegatedThisTurn(queued.id);
|
|
17305
17362
|
// An honest forecast, not a promise: the handoff starts when this
|
|
17306
17363
|
// turn ends, and by then the target's slots are taken by whatever is
|
|
17307
17364
|
// running there plus the threads this turn already opened ahead.
|
|
@@ -17331,8 +17388,8 @@ const handleRequest = async (req, res) => {
|
|
|
17331
17388
|
if (!chief.chiefOfStaff) {
|
|
17332
17389
|
return json(res, 403, { error: "only a section's Chief of Staff can create operator bots" });
|
|
17333
17390
|
}
|
|
17334
|
-
if (internalCapability.createdBots >=
|
|
17335
|
-
return json(res, 429, { error:
|
|
17391
|
+
if (internalCapability.createdBots >= MAX_CREATED_BOTS_PER_TURN) {
|
|
17392
|
+
return json(res, 429, { error: `You can create at most ${MAX_CREATED_BOTS_PER_TURN} bots in one turn. Use the team you have before adding more.` });
|
|
17336
17393
|
}
|
|
17337
17394
|
if (store.bots.length >= MAX_WORKSPACE_BOTS) {
|
|
17338
17395
|
return json(res, 409, { error: `this workspace is limited to ${MAX_WORKSPACE_BOTS} bots` });
|
|
@@ -17381,8 +17438,9 @@ const handleRequest = async (req, res) => {
|
|
|
17381
17438
|
if (store.bot(chief.id) !== chief || chief.hidden || !chief.chiefOfStaff || !connectorThread(chief.id, fromThreadId)) {
|
|
17382
17439
|
return json(res, 403, { error: "only an active Chief of Staff can create operator bots" });
|
|
17383
17440
|
}
|
|
17384
|
-
if (internalCapability.createdBots >=
|
|
17385
|
-
return json(res, 429, { error:
|
|
17441
|
+
if (internalCapability.createdBots >= MAX_CREATED_BOTS_PER_TURN) {
|
|
17442
|
+
return json(res, 429, { error: `You can create at most ${MAX_CREATED_BOTS_PER_TURN} bots in one turn. Use the team you have before adding more.` });
|
|
17443
|
+
}
|
|
17386
17444
|
if (store.bots.length >= MAX_WORKSPACE_BOTS)
|
|
17387
17445
|
return json(res, 409, { error: `this workspace is limited to ${MAX_WORKSPACE_BOTS} bots` });
|
|
17388
17446
|
const duplicate = store.bots.find((candidate) => !candidate.hidden &&
|
|
@@ -17501,6 +17559,36 @@ const handleRequest = async (req, res) => {
|
|
|
17501
17559
|
const updated = updateChannel(room.id, patch);
|
|
17502
17560
|
return json(res, 200, { ok: true, memberIds: updated.memberIds, memberCount: updated.memberIds.length, message: `Updated room “${updated.name}”.` });
|
|
17503
17561
|
}
|
|
17562
|
+
if (method === "POST" && path === "/api/internal/mcp-servers") {
|
|
17563
|
+
// Same single-flight lock as POST /api/mcp/servers: two writers must
|
|
17564
|
+
// not interleave a read of cfg.mcpServers with a save.
|
|
17565
|
+
if (mcpConfigBusy)
|
|
17566
|
+
return json(res, 409, { error: "MCP servers are already being updated." });
|
|
17567
|
+
mcpConfigBusy = true;
|
|
17568
|
+
try {
|
|
17569
|
+
const body = await readInternalBody();
|
|
17570
|
+
const added = addDisabledMcpServer(cfg.mcpServers ?? {}, body);
|
|
17571
|
+
if (!added.ok)
|
|
17572
|
+
return json(res, added.status, { error: added.error });
|
|
17573
|
+
const refusal = mcpPolicyRefusal(added.name, added.server);
|
|
17574
|
+
if (refusal)
|
|
17575
|
+
return json(res, 403, { error: refusal, code: "managed_policy" });
|
|
17576
|
+
persistMcpServers(added.next);
|
|
17577
|
+
const server = added.server;
|
|
17578
|
+
const remote = isRemoteMcpServer(server);
|
|
17579
|
+
return json(res, 201, {
|
|
17580
|
+
name: added.name,
|
|
17581
|
+
enabled: false,
|
|
17582
|
+
transport: remote ? server.type : "command",
|
|
17583
|
+
target: remote ? server.url : server.command,
|
|
17584
|
+
envKeys: remote ? [] : Object.keys(server.env).sort(),
|
|
17585
|
+
headerKeys: remote ? Object.keys(server.headers).sort() : [],
|
|
17586
|
+
});
|
|
17587
|
+
}
|
|
17588
|
+
finally {
|
|
17589
|
+
mcpConfigBusy = false;
|
|
17590
|
+
}
|
|
17591
|
+
}
|
|
17504
17592
|
if (method === "POST" && path === "/api/internal/request-credential") {
|
|
17505
17593
|
const body = await readInternalBody();
|
|
17506
17594
|
const from = internalSender;
|
|
@@ -17726,8 +17814,15 @@ const handleRequest = async (req, res) => {
|
|
|
17726
17814
|
const policy = currentSender.outbound ?? DEFAULT_OUTBOUND_POLICY;
|
|
17727
17815
|
const threadId = internalCapability.threadId;
|
|
17728
17816
|
const { app } = describeTool(outboundTool);
|
|
17729
|
-
|
|
17817
|
+
// One entry per call, in the summary's order, so a phone can say
|
|
17818
|
+
// "Send to Linear? Create linear comment ×2" without parsing the
|
|
17819
|
+
// raw arguments out of the subtitle.
|
|
17820
|
+
const calls = outboundCalls.map((call) => {
|
|
17730
17821
|
const described = describeTool(call.slug);
|
|
17822
|
+
return { app: described.app, label: described.label };
|
|
17823
|
+
});
|
|
17824
|
+
const summary = outboundCalls.map((call, index) => {
|
|
17825
|
+
const described = calls[index];
|
|
17731
17826
|
const argsText = call.arguments === undefined ? "" : JSON.stringify(call.arguments);
|
|
17732
17827
|
return `${described.app ? `${described.app} · ` : ""}${described.label}${argsText ? `\n${argsText.slice(0, 400)}${argsText.length > 400 ? "… [arguments truncated]" : ""}` : ""}`;
|
|
17733
17828
|
}).join("\n\n");
|
|
@@ -17757,7 +17852,7 @@ const handleRequest = async (req, res) => {
|
|
|
17757
17852
|
tool: outboundTool,
|
|
17758
17853
|
held: HELD_NOTE["approval.held.outbound"],
|
|
17759
17854
|
heldCode: "approval.held.outbound",
|
|
17760
|
-
outboundRequest: { tool: outboundTool, app },
|
|
17855
|
+
outboundRequest: { tool: outboundTool, app, calls },
|
|
17761
17856
|
},
|
|
17762
17857
|
});
|
|
17763
17858
|
appendDecision(DATA_DIR, {
|
|
@@ -17852,7 +17947,8 @@ const handleRequest = async (req, res) => {
|
|
|
17852
17947
|
if (upstream.transportSessionId)
|
|
17853
17948
|
headers["mcp-session-id"] = upstream.transportSessionId;
|
|
17854
17949
|
res.writeHead(upstream.status, headers);
|
|
17855
|
-
|
|
17950
|
+
// A missing OAuth permission gets a note on what fixes it (MOCA-273).
|
|
17951
|
+
return res.end(Buffer.from(withScopeHint(upstream.bytes, upstream.contentType, connectorCalls.map((call) => call.slug))));
|
|
17856
17952
|
}
|
|
17857
17953
|
// ── computer control: proxies read the hold, bots plead for help ──
|
|
17858
17954
|
if (path === "/api/internal/computer-control") {
|
|
@@ -18353,12 +18449,10 @@ const handleRequest = async (req, res) => {
|
|
|
18353
18449
|
const limit = pageSize(url.searchParams.get("messages"));
|
|
18354
18450
|
if (limit === null)
|
|
18355
18451
|
return json(res, 400, { error: "messages must be a non-negative whole number" });
|
|
18356
|
-
// wireBot(), not publicBot():
|
|
18357
|
-
//
|
|
18358
|
-
//
|
|
18359
|
-
//
|
|
18360
|
-
// that messagePage() does not: wireBot() omits the key entirely for a
|
|
18361
|
-
// bot record carrying no tasks, where publicBot() always sent [].
|
|
18452
|
+
// wireBot(), not publicBot(): this snapshot answers with the page it
|
|
18453
|
+
// was asked for, not the default one. tasks stays explicit, as in
|
|
18454
|
+
// publicBot(): wireBot() omits the key entirely for a bot record
|
|
18455
|
+
// carrying no tasks, where clients expect [].
|
|
18362
18456
|
// A member on a workspace with a restricted bot gets only what they may
|
|
18363
18457
|
// see (bot-visibility.ts); for everyone else these filters keep all.
|
|
18364
18458
|
const shownBots = store.bots.filter((bot) => visible.bot(bot.id));
|
|
@@ -18382,47 +18476,6 @@ const handleRequest = async (req, res) => {
|
|
|
18382
18476
|
});
|
|
18383
18477
|
}
|
|
18384
18478
|
// scrollback: the page before a message the client already holds
|
|
18385
|
-
// #1655: the consent card's answer, per conversation. Explicit and
|
|
18386
|
-
// revocable; a thread allowlisted in config carries the same standing
|
|
18387
|
-
// consent without ever seeing a card.
|
|
18388
|
-
m = path.match(/^\/api\/threads\/([\w-]+)\/cloud-overflow$/);
|
|
18389
|
-
if (m && method === "POST") {
|
|
18390
|
-
const threadId = m[1];
|
|
18391
|
-
if (!store.botByThread(threadId))
|
|
18392
|
-
return json(res, 404, { error: "no such conversation" });
|
|
18393
|
-
const body = await readBody(req);
|
|
18394
|
-
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
18395
|
-
return json(res, 400, { error: "body must be a JSON object" });
|
|
18396
|
-
if (typeof body.consent !== "boolean")
|
|
18397
|
-
return json(res, 400, { error: "consent must be a boolean" });
|
|
18398
|
-
if (body.consent) {
|
|
18399
|
-
// Consent answers the priced card, never a guess: without a prior
|
|
18400
|
-
// offer the rate was never shown, so a seat must not start yet.
|
|
18401
|
-
// The grant binds to the rate the card showed (#1655): a config
|
|
18402
|
-
// change re-offers instead of starting at a new price silently.
|
|
18403
|
-
const offeredRate = cloudOverflowConsent.offeredRate(threadId);
|
|
18404
|
-
if (offeredRate === null)
|
|
18405
|
-
return json(res, 409, { error: "no cloud overflow offer" });
|
|
18406
|
-
cloudOverflowConsent.grant(threadId, offeredRate);
|
|
18407
|
-
}
|
|
18408
|
-
else {
|
|
18409
|
-
cloudOverflowConsent.revoke(threadId);
|
|
18410
|
-
// Revocation must reach the machine (#1655): the ledger alone
|
|
18411
|
-
// would leave this conversation's seat billing to its idle
|
|
18412
|
-
// timeout. A start still waking is caught by its own revoke
|
|
18413
|
-
// check once readyBox resolves.
|
|
18414
|
-
const seatBot = store.botByThread(threadId);
|
|
18415
|
-
const lease = seatBot ? cloudSeatLeases.get(seatBot.id) : undefined;
|
|
18416
|
-
if (seatBot && lease && lease.threadId === threadId) {
|
|
18417
|
-
stopCloudSeat(seatBot.id, lease, "Cloud computer stopped because overflow consent was revoked — billing pauses while it sleeps.");
|
|
18418
|
-
}
|
|
18419
|
-
}
|
|
18420
|
-
store.appendMessage(threadId, {
|
|
18421
|
-
role: "bot", kind: "activity",
|
|
18422
|
-
tool: { name: cloudOverflowConsentText(body.consent, cloudOverflowIdleStopMs(cfg)), ok: true },
|
|
18423
|
-
});
|
|
18424
|
-
return json(res, 200, { consented: body.consent });
|
|
18425
|
-
}
|
|
18426
18479
|
m = path.match(/^\/api\/threads\/([\w-]+)\/messages$/);
|
|
18427
18480
|
if (m && method === "GET") {
|
|
18428
18481
|
const threadId = m[1];
|
|
@@ -19172,8 +19225,11 @@ const handleRequest = async (req, res) => {
|
|
|
19172
19225
|
return json(res, 400, { error: "Import backups alongside your existing bots; project mode is only for templates" });
|
|
19173
19226
|
try {
|
|
19174
19227
|
const imported = importTeamBackup(store, routines, body, await defaultSelection(), { visibility: importVisibility });
|
|
19228
|
+
// A restore writes its transcripts without store events, after the
|
|
19229
|
+
// store's own frames announced each bot and room, so these frames
|
|
19230
|
+
// are what carries the restored pages to other clients.
|
|
19175
19231
|
const bots = imported.bots.map((bot) => publicBot(bot));
|
|
19176
|
-
const groups = imported.groups.map((group) => (
|
|
19232
|
+
const groups = imported.groups.map((group) => groupWithThread(group));
|
|
19177
19233
|
for (const bot of bots)
|
|
19178
19234
|
broadcast({ kind: "bot", bot });
|
|
19179
19235
|
for (const group of groups)
|
|
@@ -19323,9 +19379,7 @@ const handleRequest = async (req, res) => {
|
|
|
19323
19379
|
// Who opened it decides who may answer its cards on a shared workspace.
|
|
19324
19380
|
if (auth.kind === "session")
|
|
19325
19381
|
threadStarters.set(task.threadId, actorKey(auth));
|
|
19326
|
-
|
|
19327
|
-
broadcast({ kind: "group", group: fresh });
|
|
19328
|
-
return json(res, 201, { group: fresh, task });
|
|
19382
|
+
return json(res, 201, { group: groupWithThread(store.group(group.id)), task });
|
|
19329
19383
|
}
|
|
19330
19384
|
m = path.match(/^\/api\/groups\/([\w-]+)\/tasks\/([\w-]+)$/);
|
|
19331
19385
|
if (m && method === "POST") {
|
|
@@ -19350,15 +19404,6 @@ const handleRequest = async (req, res) => {
|
|
|
19350
19404
|
if (!switched)
|
|
19351
19405
|
return json(res, 404, { error: "no such channel task" });
|
|
19352
19406
|
const switchedSettings = { ...publicGroupState(switched), tasks: store.groupTasks(switched.id) };
|
|
19353
|
-
// The frame says the channel moved; it is not a transcript delivery.
|
|
19354
|
-
// A long room's whole history over SSE is the payload `?messages=`
|
|
19355
|
-
// exists to avoid, and it would also overwrite a bounded snapshot on
|
|
19356
|
-
// every other client. One default page is enough to render the switch;
|
|
19357
|
-
// anything earlier pages back through /api/threads/:id/messages.
|
|
19358
|
-
broadcast({
|
|
19359
|
-
kind: "group",
|
|
19360
|
-
group: { ...switchedSettings, ...messagePage(switched.threadId, DEFAULT_PAGE) },
|
|
19361
|
-
});
|
|
19362
19407
|
// "0" predates paging and means settings only — no `messages` key at
|
|
19363
19408
|
// all, which clients tell apart from an empty page. A positive page is
|
|
19364
19409
|
// the transcript a client can actually hold; omitting the parameter
|
|
@@ -19366,9 +19411,7 @@ const handleRequest = async (req, res) => {
|
|
|
19366
19411
|
// materialises it.
|
|
19367
19412
|
const responseGroup = requestedMessages === "0"
|
|
19368
19413
|
? switchedSettings
|
|
19369
|
-
: switchLimit
|
|
19370
|
-
? groupWithThread(switched)
|
|
19371
|
-
: { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
|
|
19414
|
+
: { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
|
|
19372
19415
|
return json(res, 200, { group: responseGroup });
|
|
19373
19416
|
}
|
|
19374
19417
|
if (m && method === "PATCH") {
|
|
@@ -19441,14 +19484,12 @@ const handleRequest = async (req, res) => {
|
|
|
19441
19484
|
lastReply.delete(m[2]);
|
|
19442
19485
|
cancelTeamSetupResumesForThread(m[2]);
|
|
19443
19486
|
const updated = store.deleteGroupTask(group.id, m[2]);
|
|
19444
|
-
if (updated)
|
|
19445
|
-
clearTurnDigestState(m[2]);
|
|
19446
19487
|
if (!updated)
|
|
19447
19488
|
return json(res, 400, { error: "a channel keeps at least one task" });
|
|
19489
|
+
clearTurnDigestState(m[2]);
|
|
19490
|
+
revokeInternalCapabilitiesForThread(m[2]);
|
|
19448
19491
|
rejectDeletedThreadSkillStages(stagedSkillCleanups);
|
|
19449
|
-
|
|
19450
|
-
broadcast({ kind: "group", group: fresh });
|
|
19451
|
-
return json(res, 200, { group: fresh });
|
|
19492
|
+
return json(res, 200, { group: groupWithThread(updated) });
|
|
19452
19493
|
}
|
|
19453
19494
|
m = path.match(/^\/api\/groups\/([\w-]+)$/);
|
|
19454
19495
|
if (m && method === "PATCH") {
|
|
@@ -21412,54 +21453,6 @@ const handleRequest = async (req, res) => {
|
|
|
21412
21453
|
broadcast({ kind: "bot", bot: visible });
|
|
21413
21454
|
return json(res, 200, { bot: visible });
|
|
21414
21455
|
}
|
|
21415
|
-
// ── workspace checkpoints: per-turn shadow-git snapshots ────────────
|
|
21416
|
-
// The list endpoint is the source of truth (turns store nothing), and
|
|
21417
|
-
// `enabled` tells the UI whether snapshots can happen here at all —
|
|
21418
|
-
// false for refused folders (home, Desktop…), a missing git, or a bot
|
|
21419
|
-
// whose checkpoints failed earlier this session.
|
|
21420
|
-
m = path.match(/^\/api\/bots\/([\w-]+)\/checkpoints$/);
|
|
21421
|
-
if (m && method === "GET") {
|
|
21422
|
-
if (!store.bot(m[1]))
|
|
21423
|
-
return json(res, 404, { error: "no such bot" });
|
|
21424
|
-
const cwd = url.searchParams.get("cwd") ?? "";
|
|
21425
|
-
if (!cwd.trim())
|
|
21426
|
-
return json(res, 400, { error: "cwd query parameter required" });
|
|
21427
|
-
return json(res, 200, {
|
|
21428
|
-
checkpoints: await checkpoints.listCheckpoints(m[1], cwd),
|
|
21429
|
-
enabled: await checkpoints.checkpointsEnabled(m[1], cwd),
|
|
21430
|
-
});
|
|
21431
|
-
}
|
|
21432
|
-
m = path.match(/^\/api\/bots\/([\w-]+)\/checkpoints\/restore$/);
|
|
21433
|
-
if (m && method === "POST") {
|
|
21434
|
-
const bot = store.bot(m[1]);
|
|
21435
|
-
if (!bot)
|
|
21436
|
-
return json(res, 404, { error: "no such bot" });
|
|
21437
|
-
const parsed = z
|
|
21438
|
-
.object({ cwd: z.string().min(1), hash: z.string().regex(/^[0-9a-f]{40}$/) })
|
|
21439
|
-
.safeParse(await readBody(req));
|
|
21440
|
-
if (!parsed.success) {
|
|
21441
|
-
return json(res, 400, { error: "cwd (absolute path) and hash (full 40-character checkpoint hash) required" });
|
|
21442
|
-
}
|
|
21443
|
-
// Claim synchronously with the busy check. startTurn checks the same
|
|
21444
|
-
// lease before reserving the bot, so no turn can enter during the
|
|
21445
|
-
// awaited Git operation.
|
|
21446
|
-
if (bot.busy)
|
|
21447
|
-
return json(res, 409, { error: "the bot is working — stop the turn before restoring files" });
|
|
21448
|
-
if (checkpointRestoreLeases.has(bot.id)) {
|
|
21449
|
-
return json(res, 409, { error: "this bot's project files are already being restored" });
|
|
21450
|
-
}
|
|
21451
|
-
checkpointRestoreLeases.add(bot.id);
|
|
21452
|
-
let result;
|
|
21453
|
-
try {
|
|
21454
|
-
result = await checkpoints.restore(bot.id, parsed.data.cwd, parsed.data.hash);
|
|
21455
|
-
}
|
|
21456
|
-
finally {
|
|
21457
|
-
checkpointRestoreLeases.delete(bot.id);
|
|
21458
|
-
}
|
|
21459
|
-
if (!result.ok)
|
|
21460
|
-
return json(res, 400, { error: result.error });
|
|
21461
|
-
return json(res, 200, { ok: true });
|
|
21462
|
-
}
|
|
21463
21456
|
// onboarding/ask cards persist their answered/dismissed state
|
|
21464
21457
|
m = path.match(/^\/api\/bots\/([\w-]+)\/cards\/([\w-]+)$/);
|
|
21465
21458
|
if (m && method === "PATCH") {
|
|
@@ -21863,14 +21856,6 @@ const handleRequest = async (req, res) => {
|
|
|
21863
21856
|
behavior,
|
|
21864
21857
|
}))
|
|
21865
21858
|
return;
|
|
21866
|
-
if (resolveAndSendTightening(res, {
|
|
21867
|
-
botId: bot.id,
|
|
21868
|
-
botName: bot.name,
|
|
21869
|
-
threadId: bot.threadId,
|
|
21870
|
-
requestId: String(body.requestId),
|
|
21871
|
-
behavior,
|
|
21872
|
-
}))
|
|
21873
|
-
return;
|
|
21874
21859
|
if (sendSkillResolution(res, resolveSkillRequest({
|
|
21875
21860
|
botId: bot.id,
|
|
21876
21861
|
botName: bot.name,
|
|
@@ -22011,21 +21996,6 @@ const handleRequest = async (req, res) => {
|
|
|
22011
21996
|
}))
|
|
22012
21997
|
return;
|
|
22013
21998
|
}
|
|
22014
|
-
const tighteningCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.tighteningRequest);
|
|
22015
|
-
if (tighteningCard?.card?.tighteningRequest) {
|
|
22016
|
-
const tighteningBotId = tighteningCard.from?.botId ?? store.botByThread(threadId)?.id;
|
|
22017
|
-
if (!tighteningBotId)
|
|
22018
|
-
return json(res, 400, { error: "this tightening request has no valid owner" });
|
|
22019
|
-
const tighteningOwner = store.bot(tighteningBotId);
|
|
22020
|
-
if (resolveAndSendTightening(res, {
|
|
22021
|
-
botId: tighteningBotId,
|
|
22022
|
-
botName: tighteningOwner?.name,
|
|
22023
|
-
threadId,
|
|
22024
|
-
requestId,
|
|
22025
|
-
behavior,
|
|
22026
|
-
}))
|
|
22027
|
-
return;
|
|
22028
|
-
}
|
|
22029
21999
|
// peer-approval intercept (see /api/bots/:id/respond above). A peer card
|
|
22030
22000
|
// belongs to the bus rather than to a speaker, so resolve it before we go
|
|
22031
22001
|
// looking for one — a room between turns has no speaker to find.
|
|
@@ -22148,15 +22118,8 @@ const handleRequest = async (req, res) => {
|
|
|
22148
22118
|
return json(res, 200, { ok: true });
|
|
22149
22119
|
}
|
|
22150
22120
|
// ── tasks: a bot's separate contexts ────────────────────────────────
|
|
22151
|
-
//
|
|
22152
|
-
//
|
|
22153
|
-
// the client showing the previous task's conversation.
|
|
22154
|
-
const botWithThread = (bot) => ({
|
|
22155
|
-
...wireBot(bot),
|
|
22156
|
-
messages: store.messagesFor(bot.threadId),
|
|
22157
|
-
activeLeafId: store.activeLeaf(bot.threadId),
|
|
22158
|
-
tasks: store.tasks(bot.id).map(wireTask),
|
|
22159
|
-
});
|
|
22121
|
+
// Replies carry the open thread's newest page: a create or delete can
|
|
22122
|
+
// change which transcript is live. The frames come from store.onChange.
|
|
22160
22123
|
// Folders organize one bot's threads; they never own settings,
|
|
22161
22124
|
// transcripts or working directories. The project wire names stay stable.
|
|
22162
22125
|
m = path.match(/^\/api\/bots\/([\w-]+)\/projects\/order$/);
|
|
@@ -22173,7 +22136,7 @@ const handleRequest = async (req, res) => {
|
|
|
22173
22136
|
const projects = store.reorderProjects(bot.id, body.projectIds);
|
|
22174
22137
|
if (!projects)
|
|
22175
22138
|
return json(res, 400, { error: "projectIds must include each of this bot's folders exactly once" });
|
|
22176
|
-
return json(res, 200, { projects, bot:
|
|
22139
|
+
return json(res, 200, { projects, bot: publicBot(bot) });
|
|
22177
22140
|
}
|
|
22178
22141
|
m = path.match(/^\/api\/bots\/([\w-]+)\/projects(?:\/([\w-]+))?$/);
|
|
22179
22142
|
if (m && ((method === "POST" && !m[2]) || (method === "PATCH" && m[2]) || (method === "DELETE" && m[2]))) {
|
|
@@ -22184,7 +22147,7 @@ const handleRequest = async (req, res) => {
|
|
|
22184
22147
|
return json(res, 404, { error: "no such folder" });
|
|
22185
22148
|
if (method === "DELETE") {
|
|
22186
22149
|
const updated = store.deleteProject(bot.id, m[2]);
|
|
22187
|
-
return json(res, 200, { bot:
|
|
22150
|
+
return json(res, 200, { bot: publicBot(updated) });
|
|
22188
22151
|
}
|
|
22189
22152
|
const body = await readBody(req);
|
|
22190
22153
|
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
@@ -22207,7 +22170,7 @@ const handleRequest = async (req, res) => {
|
|
|
22207
22170
|
const project = method === "POST"
|
|
22208
22171
|
? store.createProject(bot.id, patch.name, patch.emoji)
|
|
22209
22172
|
: store.patchProject(bot.id, m[2], patch);
|
|
22210
|
-
return json(res, method === "POST" ? 201 : 200, { project, bot:
|
|
22173
|
+
return json(res, method === "POST" ? 201 : 200, { project, bot: publicBot(bot) });
|
|
22211
22174
|
}
|
|
22212
22175
|
m = path.match(/^\/api\/bots\/([\w-]+)\/tasks$/);
|
|
22213
22176
|
if (m && method === "POST") {
|
|
@@ -22242,9 +22205,7 @@ const handleRequest = async (req, res) => {
|
|
|
22242
22205
|
// Who opened it decides who may answer its cards on a shared workspace.
|
|
22243
22206
|
if (auth.kind === "session")
|
|
22244
22207
|
threadStarters.set(task.threadId, actorKey(auth));
|
|
22245
|
-
|
|
22246
|
-
broadcast({ kind: "bot", bot: fresh });
|
|
22247
|
-
return json(res, 201, { bot: fresh, task: wireTask(task) });
|
|
22208
|
+
return json(res, 201, { bot: publicBot(store.bot(bot.id)), task: wireTask(task) });
|
|
22248
22209
|
}
|
|
22249
22210
|
m = path.match(/^\/api\/bots\/([\w-]+)\/tasks\/([\w-]+)$/);
|
|
22250
22211
|
if (m && method === "POST") {
|
|
@@ -22263,18 +22224,13 @@ const handleRequest = async (req, res) => {
|
|
|
22263
22224
|
if (!switched)
|
|
22264
22225
|
return json(res, 404, { error: "no such task" });
|
|
22265
22226
|
const switchedSettings = { ...wireBot(switched), tasks: store.tasks(switched.id).map(wireTask) };
|
|
22266
|
-
// Bounded for the same reason as the channel switch above.
|
|
22267
|
-
broadcast({
|
|
22268
|
-
kind: "bot",
|
|
22269
|
-
bot: { ...switchedSettings, ...messagePage(switched.threadId, DEFAULT_PAGE) },
|
|
22270
|
-
});
|
|
22271
22227
|
// "0" is settings only, a positive page is a bounded transcript, and no
|
|
22272
22228
|
// parameter is the whole thread — the one branch that materialises it.
|
|
22229
|
+
// Search results land through that branch (src/lib/focus-message.ts):
|
|
22230
|
+
// the hit can be older than one page.
|
|
22273
22231
|
const responseBot = requestedMessages === "0"
|
|
22274
22232
|
? switchedSettings
|
|
22275
|
-
: switchLimit
|
|
22276
|
-
? botWithThread(switched)
|
|
22277
|
-
: { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
|
|
22233
|
+
: { ...switchedSettings, ...messagePage(switched.threadId, switchLimit) };
|
|
22278
22234
|
return json(res, 200, { bot: responseBot });
|
|
22279
22235
|
}
|
|
22280
22236
|
if (m && method === "PATCH") {
|
|
@@ -22349,9 +22305,7 @@ const handleRequest = async (req, res) => {
|
|
|
22349
22305
|
const task = store.refreshTaskPermissions(profile.id, m[2]);
|
|
22350
22306
|
if (!task)
|
|
22351
22307
|
return json(res, 404, { error: "no such task" });
|
|
22352
|
-
|
|
22353
|
-
broadcast({ kind: "bot", bot: fresh });
|
|
22354
|
-
return json(res, 200, { task: wireTask(task), bot: fresh });
|
|
22308
|
+
return json(res, 200, { task: wireTask(task), bot: publicBot(store.bot(profile.id)) });
|
|
22355
22309
|
}
|
|
22356
22310
|
const patch = {};
|
|
22357
22311
|
if (body.projectId !== undefined) {
|
|
@@ -22457,9 +22411,7 @@ const handleRequest = async (req, res) => {
|
|
|
22457
22411
|
const task = patch.modelSelection
|
|
22458
22412
|
? store.switchTaskModel(m[1], m[2], patch.modelSelection, body.updateBotDefault === true, body.resetApprovalToAsk === true, { ...patch, ...hostedModels?.resetTask(current.modelSelection, patch.modelSelection) })
|
|
22459
22413
|
: store.patchTask(m[1], m[2], patch);
|
|
22460
|
-
|
|
22461
|
-
broadcast({ kind: "bot", bot: fresh });
|
|
22462
|
-
return json(res, 200, { task: wireTask(task), bot: fresh });
|
|
22414
|
+
return json(res, 200, { task: wireTask(task), bot: publicBot(store.bot(m[1])) });
|
|
22463
22415
|
}
|
|
22464
22416
|
if (m && method === "DELETE") {
|
|
22465
22417
|
const bot = store.bot(m[1]);
|
|
@@ -22482,12 +22434,11 @@ const handleRequest = async (req, res) => {
|
|
|
22482
22434
|
if (!updated)
|
|
22483
22435
|
return json(res, 404, { error: "no such task" });
|
|
22484
22436
|
clearTurnDigestState(m[2]);
|
|
22437
|
+
revokeInternalCapabilitiesForThread(m[2]);
|
|
22485
22438
|
handoffs.forget(m[2]);
|
|
22486
22439
|
settleDirectFollowup(directTurnGenerationByThread.get(m[2]));
|
|
22487
22440
|
rejectDeletedThreadSkillStages(stagedSkillCleanups);
|
|
22488
|
-
|
|
22489
|
-
broadcast({ kind: "bot", bot: fresh });
|
|
22490
|
-
return json(res, 200, { bot: fresh });
|
|
22441
|
+
return json(res, 200, { bot: publicBot(updated) });
|
|
22491
22442
|
}
|
|
22492
22443
|
// Regenerate title (#1858): name the thread again from its conversation
|
|
22493
22444
|
// as it is now. The person asked, so the result replaces any title, but
|
|
@@ -22577,8 +22528,6 @@ const handleRequest = async (req, res) => {
|
|
|
22577
22528
|
const key = teamComputerOwner(found.id);
|
|
22578
22529
|
if (parsed.data.action === "take" && boatLifecycleBusyBots.has(key))
|
|
22579
22530
|
return json(res, 409, { error: "Wait for this computer's action to finish before taking control" });
|
|
22580
|
-
if (parsed.data.action === "take")
|
|
22581
|
-
assertTeamControlCanBeTaken(found.id);
|
|
22582
22531
|
if (parsed.data.controlLeaseId) {
|
|
22583
22532
|
const result = parsed.data.action === "take"
|
|
22584
22533
|
? computerControl.acquireLease(key, parsed.data.controlLeaseId)
|
|
@@ -22617,7 +22566,7 @@ const handleRequest = async (req, res) => {
|
|
|
22617
22566
|
if (method === "POST" && !computerId) {
|
|
22618
22567
|
const parsed = teamComputerCreate.safeParse(body);
|
|
22619
22568
|
if (!parsed.success)
|
|
22620
|
-
return json(res, 400, { error: "Provide requestId (UUID), a name, and acknowledgeCost: true to create a paid
|
|
22569
|
+
return json(res, 400, { error: "Provide requestId (UUID), a name, and acknowledgeCost: true to create a paid cloud computer" });
|
|
22621
22570
|
if (!boat.boatConfigured(cfg))
|
|
22622
22571
|
return json(res, 409, { error: "Configure Boat in Settings before creating a cloud computer" });
|
|
22623
22572
|
const computer = teamComputers.create(parsed.data.name, parsed.data.requestId);
|
|
@@ -22637,7 +22586,7 @@ const handleRequest = async (req, res) => {
|
|
|
22637
22586
|
}
|
|
22638
22587
|
if (method === "POST" && found && action && action !== "control") {
|
|
22639
22588
|
if (action === "provision" && body?.acknowledgeCost !== true)
|
|
22640
|
-
return json(res, 400, { error: "Confirm
|
|
22589
|
+
return json(res, 400, { error: "Confirm cloud computer creation or wake costs with acknowledgeCost: true" });
|
|
22641
22590
|
// A ready-only join never wakes, provisions or steals an agent's turn.
|
|
22642
22591
|
// The caller takes a separate explicit human-control lease first.
|
|
22643
22592
|
if (action === "join") {
|
|
@@ -22959,7 +22908,12 @@ const handleRequest = async (req, res) => {
|
|
|
22959
22908
|
// The bots' browser engine: install it on this machine (agent-browser +
|
|
22960
22909
|
// a Chrome for Testing, a one-time download), or ask how that is going.
|
|
22961
22910
|
// One install at a time; the config frame's browserEngine tells the rest.
|
|
22911
|
+
// JSON only, so a form posted from another local page cannot start it.
|
|
22962
22912
|
if (method === "POST" && path === "/api/browser-engine/install") {
|
|
22913
|
+
const mediaType = String(req.headers["content-type"] ?? "").split(";", 1)[0].trim().toLowerCase();
|
|
22914
|
+
if (mediaType !== "application/json") {
|
|
22915
|
+
return json(res, 415, { error: "content-type must be application/json" });
|
|
22916
|
+
}
|
|
22963
22917
|
if (!browserEngineInstall) {
|
|
22964
22918
|
const status = browserEngineStatus();
|
|
22965
22919
|
if (status.kind === "unavailable" && !status.installable)
|
|
@@ -24112,6 +24066,15 @@ const handleRequest = async (req, res) => {
|
|
|
24112
24066
|
if (conflict)
|
|
24113
24067
|
return json(res, 409, { error: conflict });
|
|
24114
24068
|
}
|
|
24069
|
+
// A change names only the OpenCode provider keys it saves or removes;
|
|
24070
|
+
// the rest stay. Merged with no await before the save below, so it
|
|
24071
|
+
// starts from what is saved at that moment.
|
|
24072
|
+
if (patch.opencodeGo?.providerKeys) {
|
|
24073
|
+
const merged = mergeOpenCodeProviderKeys(cfg, patch.opencodeGo.providerKeys);
|
|
24074
|
+
if (!merged.ok)
|
|
24075
|
+
return json(res, 400, { error: merged.error });
|
|
24076
|
+
patch.opencodeGo = { ...patch.opencodeGo, providerKeys: merged.keys };
|
|
24077
|
+
}
|
|
24115
24078
|
const browserCleanupRequests = [];
|
|
24116
24079
|
if (profileControlConflict())
|
|
24117
24080
|
return json(res, 409, { error: "Release browser control before deleting its profile." });
|
|
@@ -24601,9 +24564,6 @@ const handleRequest = async (req, res) => {
|
|
|
24601
24564
|
if (!currentBot)
|
|
24602
24565
|
return json(res, 404, { error: "no such bot" });
|
|
24603
24566
|
const controlKey = botComputerControlKey(currentBot);
|
|
24604
|
-
const teamComputer = inheritedTeamComputer(currentBot);
|
|
24605
|
-
if (action === "take" && teamComputer)
|
|
24606
|
-
assertTeamControlCanBeTaken(teamComputer.id);
|
|
24607
24567
|
const leaseResult = body.controlLeaseId === undefined
|
|
24608
24568
|
? null
|
|
24609
24569
|
: controlLeaseIdSchema.safeParse(body.controlLeaseId);
|
|
@@ -24780,12 +24740,12 @@ const handleRequest = async (req, res) => {
|
|
|
24780
24740
|
}
|
|
24781
24741
|
if (bot.computer !== "cloud" && !threadPreview) {
|
|
24782
24742
|
return json(res, 409, {
|
|
24783
|
-
error: "Choose Cloud before changing or opening this
|
|
24743
|
+
error: "Choose Cloud computer before changing or opening this cloud computer. Auto only checks existing computer state.",
|
|
24784
24744
|
});
|
|
24785
24745
|
}
|
|
24786
24746
|
if (m[2] === "remove") {
|
|
24787
24747
|
// Boats sleep and wake; only the VPS backend has a container to remove.
|
|
24788
|
-
return json(res, 409, { error: "
|
|
24748
|
+
return json(res, 409, { error: "a cloud computer has no container to remove — put it to sleep instead" });
|
|
24789
24749
|
}
|
|
24790
24750
|
const releaseComputerLifecycle = claimBotComputerLifecycle(botId);
|
|
24791
24751
|
try {
|
|
@@ -25050,6 +25010,8 @@ const gracefulShutdown = createGracefulShutdown({
|
|
|
25050
25010
|
// the shutdown deadline), immediately before the process exits, so no new
|
|
25051
25011
|
// server can overlap with a still-mutating old one.
|
|
25052
25012
|
exit: (code) => {
|
|
25013
|
+
// Streamed text the bus is still merging reaches the log and clients.
|
|
25014
|
+
bus.flush();
|
|
25053
25015
|
try {
|
|
25054
25016
|
sessions.close();
|
|
25055
25017
|
}
|
|
@@ -25062,8 +25024,8 @@ const gracefulShutdown = createGracefulShutdown({
|
|
|
25062
25024
|
closeMessageDb();
|
|
25063
25025
|
mcpOAuth.dispose();
|
|
25064
25026
|
releaseDataDirLeaseAtExit();
|
|
25065
|
-
//
|
|
25066
|
-
process.exit(
|
|
25027
|
+
// Every launcher in this repo starts the server again on this code (server/restart.ts).
|
|
25028
|
+
process.exit(restartRequested && code === 0 ? RESTART_EXIT_CODE : code);
|
|
25067
25029
|
},
|
|
25068
25030
|
});
|
|
25069
25031
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|