openmausbot 0.1.89 → 0.1.91
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/{abnfDiagram-O67JEVCF-BwaMaZEm.js → abnfDiagram-O67JEVCF-yP-w8pRq.js} +1 -1
- package/dist/assets/{arc-Be-8GKxd.js → arc-DsivfZEh.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-Dp_U2Kv1.js → architectureDiagram-NJMV4G6O-CILw7lzT.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-DQjUjYUk.js → blockDiagram-BEXU5L5S-DEQUV3HF.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-Bd3aE4Kc.js → c4Diagram-YGBWAQC7-lSXCZoKA.js} +1 -1
- package/dist/assets/channel-DrfGMu7X.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-BijjTBeJ.js → chunk-2Q5K7J3B-QCD5bgWP.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-CMGdwvzW.js → chunk-3FUC2YCW-C5-iZJuL.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-0G09BfsZ.js → chunk-5VM5RSS4-cxsawMpY.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-FOp3vSHe.js → chunk-GTNCS2PH-DV8tV8H7.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-Dv-20VIn.js → chunk-GWA4HPMP-CJFZyfbW.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-CRgrdFcz.js → chunk-JWPE2WC7-BigbMXco.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-3hP4f7QB.js → chunk-NETBCI7D-oVxTfpDy.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-CG98IjlG.js → chunk-WEXAMYUT-CH4BiroW.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-BrSTC9xG.js → chunk-XXDRQBXY-KEDa6gax.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-rpQQQDFX.js → classDiagram-v2-NBCMYWYE-BiPjfqVv.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-G6RSafHA.js → cose-bilkent-JH36ORCC-Caxew3OY.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-CXjv-IM2.js → cynefin-OW5HDTMX-B_Pui369.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-Dw07MR9f.js → cynefinDiagram-VND7K2PF-X64ydIhP.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-BwPnlTg2.js → dagre-6A5THRUB-TqYCI1E1.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-Cm6xcMiz.js → diagram-22UHCM2B-TeOt3rAQ.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-CDpogPbE.js → diagram-3UASUU5V-D70BLhDA.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-BV8z3Pnc.js → diagram-ATOU4E4O-BbQDLwUC.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-CKCQhOOC.js → diagram-CDSNMT55-ChNJZ1u-.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-FGvJg69C.js → diagram-MLGK6HIB-DMBl2APa.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-CwATvgnS.js → diagram-MPIPVDR6-CEqDchOp.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-CH_c3SSC.js → ebnfDiagram-ZINNZB2B-BVDKRp_-.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-3guZi71R.js → elk-276RUBZZ-CRkofO9B.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-BMHphR2n.js → erDiagram-OPXOYQCR-DruOMh5e.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-Vm5DkW5x.js → flowDiagram-KWPJA3E3-8GMQeB7t.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-DO-1ct07.js → ganttDiagram-FUAMR5RP-ORE9jcsM.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-nxeksEIN.js → gitGraphDiagram-X574FWY7-BmsedsWh.js} +1 -1
- package/dist/assets/index-BlnCEcU6.js +588 -0
- package/dist/assets/{index-Oxk9x45i.js → index-DKNEM8Q6.js} +1 -1
- package/dist/assets/index-DVxjOvID.css +1 -0
- package/dist/assets/{infoDiagram-VRGFBTTK-Dkcvr0p7.js → infoDiagram-VRGFBTTK-E_p7AooJ.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-BwVibkoO.js → ishikawaDiagram-OU5B5YK6-Y-B2mKda.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-ByiBZqgG.js → journeyDiagram-ZHPQQLJL-eA8RELcK.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-qqjgXBF3.js → kanban-definition-PNTS6WVX-DqIL2e8e.js} +1 -1
- package/dist/assets/{linear-CtJnKe6A.js → linear-yC_29R6e.js} +1 -1
- package/dist/assets/{mermaid.core-BEiDklrY.js → mermaid.core-VhEMkSND.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-DfIYHia9.js → mindmap-definition-NLK3R4M7-oJ7kyQZy.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-CYyRqa1a.js → pegDiagram-GJSIUBJH-BmfYcY0O.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-hOToy_zH.js → pieDiagram-5QR66LMP-DKvgP7w9.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-DKAsNN9I.js → quadrantDiagram-O4NWA36T-BEKL3W0L.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-BSA-d8JU.js → railroadDiagram-XR7U4H2S-BEsiqR5s.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-CSmlvb-p.js → requirementDiagram-PLB6GJNP-DWBcWvAP.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-CD5F6fnG.js → sankeyDiagram-IPEJSGJF-rpZ2_O4s.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-JUq5sx6d.js → sequenceDiagram-PO4LG4MO-B99HQdJR.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-BjIS-Stk.js → sizeCapture-INFHLROL-BjN0qT5T.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-DklkUxOg.js → stateDiagram-v2-GCMORJYK-BbsVgGsC.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-CsjIU6be.js → swimlanes-2SLR337P-DG3gKkeR.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-BCZhxl7e.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-BkFqK7AB.js → timeline-definition-EJHVYXUP-BTHOdtF_.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-70gJB1EU.js → usecaseDiagram-POWQR4AR-acLSLFwc.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-83Tm62dN.js → vennDiagram-UO4OBE2U-C5JzNX9G.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-tTmseoX9.js → wardleyDiagram-VNRHLVJA-DL1-PrR_.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-R1LBA55a.js → xychartDiagram-PMCCYNJV-Bvr1477p.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/cloud-home-start.js +14810 -0
- package/dist-server/container-mcp.js +136 -36
- package/dist-server/drivers/agents-proxy.js +31 -10
- package/dist-server/index.js +48251 -44124
- package/dist-server/openmausbot.js +628 -292
- package/dist-server/pair-cli.js +628 -292
- package/dist-server/permission-proxy.js +1 -1
- package/dist-server/server/admission.js +46 -0
- package/dist-server/server/{box-create-idempotency.js → boat-create-idempotency.js} +32 -31
- package/dist-server/server/{box-delete-journal.js → boat-delete-journal.js} +26 -25
- package/dist-server/server/{box.js → boat.js} +381 -334
- package/dist-server/server/bot-overview.js +7 -4
- package/dist-server/server/bot-profile.js +7 -1
- package/dist-server/server/channel-queue.js +58 -18
- package/dist-server/server/checkpoints.js +98 -52
- package/dist-server/server/claim-idle.js +33 -0
- package/dist-server/server/cloud-backend.js +5 -5
- package/dist-server/server/cloud-home-start.js +121 -0
- package/dist-server/server/cloud-home.js +241 -0
- package/dist-server/server/cloud-lending.js +103 -0
- package/dist-server/server/cloud-move-http.js +268 -0
- package/dist-server/server/cloud-move.js +418 -0
- package/dist-server/server/cloud-overflow.js +167 -0
- package/dist-server/server/composio.js +5 -0
- package/dist-server/server/computer-observation.js +2 -2
- package/dist-server/server/config.js +156 -9
- package/dist-server/server/container-computer.js +13 -0
- package/dist-server/server/control-client.js +2 -2
- package/dist-server/server/decider/index.js +238 -0
- package/dist-server/server/decider/jev.js +183 -0
- package/dist-server/server/decider/log.js +62 -0
- package/dist-server/server/decider/relay.js +52 -0
- package/dist-server/server/decider/room-routing.js +88 -0
- package/dist-server/server/decider/types.js +5 -0
- package/dist-server/server/delegations.js +11 -3
- package/dist-server/server/drivers/acp/core.js +5 -4
- package/dist-server/server/drivers/agents-call.js +5 -1
- package/dist-server/server/drivers/agents-catalog.js +25 -3
- package/dist-server/server/drivers/{boxagent.js → boatagent.js} +30 -21
- package/dist-server/server/drivers/builtIn.js +2 -2
- package/dist-server/server/drivers/{chat-box-tools.js → chat-boat-tools.js} +3 -3
- package/dist-server/server/drivers/chat-mcp-tools.js +2 -2
- package/dist-server/server/drivers/claude.js +178 -13
- package/dist-server/server/drivers/native.js +1 -1
- package/dist-server/server/drivers/openai-chat.js +15 -4
- package/dist-server/server/drivers/pi.js +3 -3
- package/dist-server/server/group-goal-run.js +6 -4
- package/dist-server/server/harness-capabilities.js +2 -0
- package/dist-server/server/hosted-models.js +6 -1
- package/dist-server/server/included-services.js +70 -0
- package/dist-server/server/index.js +1462 -576
- package/dist-server/server/local-vm-inventory.js +11 -1
- package/dist-server/server/local-vm-seat-pool.js +141 -0
- package/dist-server/server/managed-policy.js +4 -1
- package/dist-server/server/memory-capture.js +194 -0
- package/dist-server/server/memory-entries.js +76 -0
- package/dist-server/server/memory-organize.js +102 -0
- package/dist-server/server/memory-tidy.js +144 -0
- package/dist-server/server/memory-topics.js +169 -0
- package/dist-server/server/memory-upkeep.js +452 -0
- package/dist-server/server/message-db.js +49 -5
- package/dist-server/server/model-context-window.js +4 -4
- package/dist-server/server/package-export.js +17 -10
- package/dist-server/server/package-import.js +6 -3
- package/dist-server/server/package-parts.js +3 -1
- package/dist-server/server/permission-proxy.js +3 -3
- package/dist-server/server/profile-learned.js +98 -0
- package/dist-server/server/profile-versions.js +45 -11
- package/dist-server/server/recall.js +172 -0
- package/dist-server/server/redact.js +1 -1
- package/dist-server/server/request-auth.js +2 -0
- package/dist-server/server/room-cwd.js +1 -1
- package/dist-server/server/room-handoffs.js +61 -6
- package/dist-server/server/routes/bot-memory.js +215 -0
- package/dist-server/server/routes/decider.js +19 -0
- package/dist-server/server/routine-requests.js +27 -16
- package/dist-server/server/routines.js +10 -6
- package/dist-server/server/shared-computers.js +116 -30
- package/dist-server/server/steer-queue.js +41 -10
- package/dist-server/server/store.js +42 -3
- package/dist-server/server/surface.js +14 -1
- package/dist-server/server/system-prompt.js +16 -5
- package/dist-server/server/team-backup.js +6 -2
- package/dist-server/server/team-computers.js +2 -2
- package/dist-server/server/team-manifest.js +11 -3
- package/dist-server/server/tightening-requests.js +34 -4
- package/dist-server/server/tool-summary.js +2 -2
- package/dist-server/server/tts/elevenlabs.js +12 -7
- package/dist-server/server/tts/index.js +17 -10
- package/dist-server/server/turn-resources.js +87 -11
- package/dist-server/server/vps-computer.js +1 -1
- package/dist-server/server/workspace-backup-policy.js +22 -3
- package/dist-server/server/workspace-backup.js +25 -8
- package/dist-server/server/workspace.js +57 -22
- package/dist-server/shared/ask-question.js +7 -5
- package/dist-server/shared/bot-avatar.js +19 -0
- package/dist-server/shared/cloud-home.js +4 -0
- package/dist-server/shared/credential-request.js +3 -3
- package/dist-server/shared/package-format.js +30 -4
- package/dist-server/shared/team-backup.js +20 -3
- package/dist-server/shared/tool-surface.js +1 -1
- package/dist-server/src/lib/composer-attachments.js +9 -0
- package/dist-server/vps-container-mcp.js +136 -36
- package/dist-server/workspace-backup.worker.js +165 -35
- package/package.json +1 -1
- package/dist/assets/channel-DqY_gMLg.js +0 -1
- package/dist/assets/index-BIMdIbaw.css +0 -1
- package/dist/assets/index-BVPdbq2r.js +0 -583
- package/dist/assets/swimlanesDiagram-TC7HE7FX-Bk14M0_B.js +0 -8
|
@@ -15,7 +15,8 @@ import { selectReplay, DEFAULT_REBUILD_BYTES, MAX_SUMMARY_BYTES } from "./contex
|
|
|
15
15
|
import { draftSummary, foldPoint } from "./compaction-summary.js";
|
|
16
16
|
import { compactBudget, contextWindowFor, shouldCompact } from "./context-budget.js";
|
|
17
17
|
import { autoCompactWindow } from "./drivers/claude.js";
|
|
18
|
-
import { SharedComputers, sharedComputerOperation, sharedComputerRegistration } from "./shared-computers.js";
|
|
18
|
+
import { provenRequestPerson, SharedComputers, sharedComputerOperation, sharedComputerRegistration } from "./shared-computers.js";
|
|
19
|
+
import { cloudHomeLendingRefusal, createCloudRoutineAuthors } from "./cloud-lending.js";
|
|
19
20
|
import { SharedComputerControl } from "./shared-computer-control.js";
|
|
20
21
|
import { RoomHandoffs } from "./room-handoffs.js";
|
|
21
22
|
import { assertRequestTarget, guardedRequestPath, requestConflict, requestNeedsInput, requestSourceForCard } from "./guarded-requests.js";
|
|
@@ -44,20 +45,23 @@ import { avatarGenerationRequestSchema, avatarGenerationStateMatches, generateAv
|
|
|
44
45
|
import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
|
|
45
46
|
import { groupTurnCwd } from "./room-cwd.js";
|
|
46
47
|
import { RoomTurnDeadline, RoomTurnStallRegistry, roomTurnTimeoutMessage } from "./room-turn-timeout.js";
|
|
47
|
-
import * as
|
|
48
|
+
import * as boat from "./boat.js";
|
|
48
49
|
import { TeamComputers, teamComputerAssignment, teamComputerCreate, teamComputerOwner } from "./team-computers.js";
|
|
49
50
|
import { isEffortLevel } from "../shared/wire.js";
|
|
50
|
-
import {
|
|
51
|
-
import {
|
|
52
|
-
import {
|
|
51
|
+
import { boatCreateRecoverySnapshot, retireDeletedBoatCreate } from "./boat-create-idempotency.js";
|
|
52
|
+
import { boatDeletionSnapshot } from "./boat-delete-journal.js";
|
|
53
|
+
import { boatAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourceChangeError, } from "./cloud-backend.js";
|
|
53
54
|
import * as composio from "./composio.js";
|
|
54
55
|
import { connectorCallFromFrame, connectorRefusalText, connectorUnrecognizedText, evaluateConnectorTools } from "./connector-verdict.js";
|
|
55
56
|
import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
|
|
57
|
+
import { buildRecall } from "./recall.js";
|
|
58
|
+
import { createMemoryUpkeep, upkeepEnabled } from "./memory-upkeep.js";
|
|
59
|
+
import { appendAboutMe, commitLearned, planLearned } from "./profile-learned.js";
|
|
56
60
|
import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, livePeerRosterBlock, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
|
|
57
61
|
import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
|
|
58
|
-
import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerExec, containerRuntimeStatus, localVmRecreatableOnDemand, localVmWorkspaceExists, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, VM_WORKSPACE_GUEST, } from "./container-computer.js";
|
|
62
|
+
import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerExec, containerRuntimeStatus, localVmRecreatableOnDemand, localVmWorkspaceExists, perBotLocalVmTarget, poolLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, VM_WORKSPACE_GUEST, } from "./container-computer.js";
|
|
59
63
|
import { attachForTurn, saveBotAttachment } from "./bot-attachment.js";
|
|
60
|
-
import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, } from "./config.js";
|
|
64
|
+
import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, computerClaimIdleReleaseEnabled, cloudOverflowAllowlistedThreads, cloudOverflowEnabled, cloudOverflowIdleStopMs, cloudOverflowPerSecondCostUsd, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, stripWorkspaceCredentialEnv, PROVIDER_CREDENTIAL_ENV, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, } from "./config.js";
|
|
61
65
|
import { sweepThreadEventLogs } from "./thread-retention.js";
|
|
62
66
|
import { ComputerControl } from "./computer-control.js";
|
|
63
67
|
import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
|
|
@@ -90,16 +94,19 @@ import { chiefForBot, INCIDENTS_THREAD_TITLE, IncidentLedger, incidentChip, inci
|
|
|
90
94
|
const SESSION_READ_MAX_CHARS = 8_000;
|
|
91
95
|
import { attemptAsideInjection, drainAsideMessages, queueAsideMessage, restoreAsideMessages, } from "./aside-queue.js";
|
|
92
96
|
import { promptWithReply, transcriptText } from "./replies.js";
|
|
93
|
-
import { admit } from "./admission.js";
|
|
97
|
+
import { admit, DRAIN_COALESCE_MAX_ITEMS } from "./admission.js";
|
|
94
98
|
import { _loadPending, buildDelegationFailurePrompt, buildDelegationRevivalPrompt, DELEGATION_TTL_MS, DelegationWakeBudget, discardDelegations, drainDelegations, expireStaleDelegations, findDelegationReceipt, pendingDelegationInfo, pendingDelegationSnapshot, pendingThreads, queueDelegation, recordDelegationReceipt, releaseDelegationsWaitingOn, summarizeDelegatedActivity } from "./delegations.js";
|
|
95
99
|
import { cancelSteeredMessage, drainSteeredMessages, hasQueuedSteeredMessages, holdSteeredQueue, onSteeredQueueChange, queuedSteerSnapshot, queuedSteeredMessage, queuedThreadPosition, queueSteeredMessage, restoreHeldSteeredQueue, restoreSteeredMessages, settleHeldSteeredQueue, } from "./steer-queue.js";
|
|
96
|
-
import { cancelChannelMessage, drainChannelMessages, holdChannelQueue, queuedChannelMessage, queueChannelMessage, restoreChannelMessages, restoreHeldChannelQueue, resolveHeldReplyTarget, settleHeldChannelQueueHead, } from "./channel-queue.js";
|
|
100
|
+
import { cancelChannelMessage, drainChannelMessages, holdChannelQueue, headChannelGroup, queuedChannelMessage, queueChannelMessage, restoreChannelMessages, restoreHeldChannelQueue, resolveHeldReplyTarget, settleHeldChannelQueueHead, } from "./channel-queue.js";
|
|
97
101
|
import { acceptedSendMatch, parseSendId, sendFingerprint, SendSequencer, } from "./send-idempotency.js";
|
|
98
102
|
import { EventBus } from "./harness/bus.js";
|
|
99
103
|
import { ProviderRegistry } from "./harness/registry.js";
|
|
100
104
|
import { ManagedDesktopProviders } from "./managed-desktop.js";
|
|
101
105
|
import { computerKindForResource, ManagedDesktopPolicy } from "./managed-policy.js";
|
|
102
106
|
import { hostedModelPolicy, HOSTED_MODEL_POLICY_HEADER, HOSTED_PROVIDER_SETTINGS_ERROR } from "./hosted-models.js";
|
|
107
|
+
import { boatNotConfiguredMessage, CLOUD_HOME_RESTART_EXIT_CODE, CLOUD_IGNORED_KEYS, CLOUD_PAIRING_PATH, cloudHomeConfiguration, cloudHomeOffersPlace, cloudHomePlaceRefusal, createCloudPairing, readSignedBody, } from "./cloud-home.js";
|
|
108
|
+
import { createCloudMoveRoutes } from "./cloud-move-http.js";
|
|
109
|
+
import { holdIncludedServices } from "./included-services.js";
|
|
103
110
|
import { selectDefaultModelSelection, withNewBotEffort } from "./default-model-selection.js";
|
|
104
111
|
import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, peerApprovalFailure, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
|
|
105
112
|
import { peerDeliveryReceipt } from "./peer-delivery.js";
|
|
@@ -107,16 +114,18 @@ import { peerProvenanceNote, withPeerProvenance } from "./peer-provenance.js";
|
|
|
107
114
|
import { decideRoomPost, emptyRoomPostBudget } from "./room-post-budget.js";
|
|
108
115
|
import { isProjectEmoji, mentionedBots, parseConnectorTools, roomResponders, sectionKey, Store, titleFromLlm, toWireTask, } from "./store.js";
|
|
109
116
|
import * as tts from "./tts/index.js";
|
|
117
|
+
import { createDecider, deciderIncludedHere, deciderReady, deciderSavePatch, describeDecider } from "./decider/index.js";
|
|
118
|
+
import { decideRoomResponder } from "./decider/room-routing.js";
|
|
110
119
|
import { narrateTool, toUtterances } from "./tts/speech-text.js";
|
|
111
120
|
import { buildRecoveryText, buildTurnContext, engineIsFresh, NATIVELY_REPLAYING_DRIVER_KINDS, peerMessageText } from "./turn-context.js";
|
|
112
121
|
import { Handoffs, handedStateUsable, recordHanded, renderUnseen, sessionStart, unseenMessages, withUnseenMessages } from "./delta-context.js";
|
|
113
122
|
import { extractTurnImages } from "./turn-images.js";
|
|
114
123
|
import { TurnWatchdog } from "./turn-watchdog.js";
|
|
115
124
|
import { TurnResources, workspaceResource } from "./turn-resources.js";
|
|
116
|
-
import {
|
|
117
|
-
import {
|
|
118
|
-
import {
|
|
119
|
-
import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals,
|
|
125
|
+
import { IdleReleasePolicy } from "./claim-idle.js";
|
|
126
|
+
import { ensureWorkspace, ensureTaskWorkspace, workspaceLocationsPrompt, supportsWorkspaceFiles, updateMemory, appendMemoryLog, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, workspaceDir, } from "./workspace.js";
|
|
127
|
+
import { listMemoryTopics, memoryDate, readMemoryFile, readMemoryTopic, writeMemoryFile, writeMemoryTopic } from "./workspace.js";
|
|
128
|
+
import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, journalMemoryWrite, } from "./memory-journal.js";
|
|
120
129
|
import { readSectionContext, readSections, sectionContextKey, sectionContextLabel, sectionContextSystemPrompt, writeSectionContext, SECTION_CONTEXT_MAX_BYTES, } from "./section-context.js";
|
|
121
130
|
import { applyStagedSkillWrite, applySkillWriteWithReceipt, getStagedSkillWrite, installOrgSkill, installSkill, parseSkillMd, scanSkillText, listSkills, listStagedSkillWrites, isSkillName, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillPackageStamps, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
|
|
122
131
|
import { fetchSkillFromSource } from "./skill-fetch.js";
|
|
@@ -125,11 +134,12 @@ import { botSkillTemplateSchema, newBotDefaultsSchema, resolveBotCreationDefault
|
|
|
125
134
|
import { expandLearnTurnText, learnSource } from "./skill-learn.js";
|
|
126
135
|
import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
|
|
127
136
|
import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-folder.js";
|
|
128
|
-
import { buildSystemPrompt, userProfileSystemPrompt, computerPrompt, composioSystemPrompt, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, resolveComputerPromptKind, } from "./system-prompt.js";
|
|
137
|
+
import { buildSystemPrompt, userProfileSystemPrompt, cloudHomePrompt, computerPrompt, composioSystemPrompt, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, resolveComputerPromptKind, } from "./system-prompt.js";
|
|
129
138
|
import { readCuaConnection, readCuaUnavailableReason, gatedLocalComputer } from "./local-computer.js";
|
|
130
|
-
import { discoverExistingPerBotLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
|
|
139
|
+
import { discoverExistingPerBotLocalVms, discoverExistingPoolLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
|
|
131
140
|
import { LocalVmIdleTimer } from "./local-vm-idle.js";
|
|
132
141
|
import { LocalVmLeasePool } from "./local-vm-lease.js";
|
|
142
|
+
import { LocalVmSeatPool } from "./local-vm-seat-pool.js";
|
|
133
143
|
import { RepeatDetector, callKey } from "./repeat-detector.js";
|
|
134
144
|
import { redactSecretsInText } from "./redact.js";
|
|
135
145
|
import * as vps from "./vps-computer.js";
|
|
@@ -174,6 +184,7 @@ import { shouldMountLocalComputer } from "./local-routing.js";
|
|
|
174
184
|
import { autoLocalVmAttachable } from "./container-computer.js";
|
|
175
185
|
import { startAutoVmClaim } from "./auto-vm-claims.js";
|
|
176
186
|
import { computerFreeAfterText, computerStillBusyText, computerStoppedWaitingText, computerWaitingText } from "./computer-wait.js";
|
|
187
|
+
import { CloudOverflowConsent, CloudSeatLease, cloudOverflowAction, cloudOverflowConsentText, cloudOverflowOfferText, cloudSeatStartedText, cloudSeatStoppedText } from "./cloud-overflow.js";
|
|
177
188
|
import { modelContextWindow } from "./model-context-window.js";
|
|
178
189
|
import { parseSurface, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
|
|
179
190
|
import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
|
|
@@ -201,6 +212,8 @@ import { json, onJsonBody, parsedBodyOf, readBody } from "./harness/http.js";
|
|
|
201
212
|
import { ROUTES, dispatchRoutes } from "./routes/table.js";
|
|
202
213
|
import { createHostedSlackRoutes } from "./routes/hosted-slack.js";
|
|
203
214
|
import { createBotPresetRoutes } from "./routes/bot-presets.js";
|
|
215
|
+
import { createBotMemoryRoutes } from "./routes/bot-memory.js";
|
|
216
|
+
import { createDeciderRoutes } from "./routes/decider.js";
|
|
204
217
|
const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799);
|
|
205
218
|
const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1);
|
|
206
219
|
// Behind a proxy or tunnel, the base URL senders should use (docs/self-hosting.md).
|
|
@@ -264,6 +277,35 @@ const sessions = new SessionRegistry({
|
|
|
264
277
|
portalMembership: hostedWorkspaceConfiguration()?.portalMembership === true,
|
|
265
278
|
});
|
|
266
279
|
const sharedComputers = new SharedComputers(id => sessions.isLive(id));
|
|
280
|
+
// OMB Cloud Pro home machine (server/cloud-home.ts, docs/cloud-pro.md). A
|
|
281
|
+
// partial or invalid boot contract stops the server here, before it serves.
|
|
282
|
+
const CLOUD_HOME = cloudHomeConfiguration();
|
|
283
|
+
/** Lending a computer to this server (the shared-computer routes, the two
|
|
284
|
+
* agent tools, the advertised capability): the maintainer flag anywhere, and
|
|
285
|
+
* always on an OMB Cloud home, where it is the person's own Mac lent to their
|
|
286
|
+
* own Cloud (docs/cloud-pro.md, "Let my Cloud use this Mac"). */
|
|
287
|
+
const lendingEnabled = () => sharedComputersEnabled(cfg) || CLOUD_HOME !== null;
|
|
288
|
+
/** On a Cloud home every lent computer and every turn belong to one person. */
|
|
289
|
+
const CLOUD_HOME_LENDER = "cloud-home";
|
|
290
|
+
/** Routines whose instructions the Cloud home's owner wrote, and manual runs
|
|
291
|
+
* the owner started: the only routine turns that may use a lent Mac. */
|
|
292
|
+
const cloudRoutineAuthors = CLOUD_HOME ? createCloudRoutineAuthors(join(DATA_DIR, "lending-routines.json")) : null;
|
|
293
|
+
const ownerStartedRoutineRuns = new Set();
|
|
294
|
+
const cloudPairing = CLOUD_HOME ? createCloudPairing({ secret: CLOUD_HOME.bootstrapSecret, sessions }) : null;
|
|
295
|
+
if (CLOUD_HOME) {
|
|
296
|
+
// The signing secret is held in memory from here on, and a platform
|
|
297
|
+
// gateway's settings are dropped: no engine or tool this server starts
|
|
298
|
+
// inherits either. The person's own engines are the only way to a model.
|
|
299
|
+
delete process.env.OMB_CLOUD_BOOTSTRAP_SECRET;
|
|
300
|
+
for (const key of CLOUD_IGNORED_KEYS)
|
|
301
|
+
delete process.env[key];
|
|
302
|
+
console.log(`cloud home ${CLOUD_HOME.machineId}: bots run on the engines the person signs in to here`);
|
|
303
|
+
for (const warning of CLOUD_HOME.warnings)
|
|
304
|
+
console.warn(`cloud home: ${warning}`);
|
|
305
|
+
}
|
|
306
|
+
// Cloud Pro's included Boat, voice and decision relay tokens, when the Admin
|
|
307
|
+
// set them: held in memory from here on, like the signing secret.
|
|
308
|
+
holdIncludedServices();
|
|
267
309
|
// Who each thread is for, when a signed-in person can be named (server-private).
|
|
268
310
|
const threadStarters = new ThreadStarters(join(DATA_DIR, "thread-starters.json"));
|
|
269
311
|
const commandAllowlist = new CommandAllowlistStore(join(DATA_DIR, "command-allowlist.json"));
|
|
@@ -319,6 +361,9 @@ bindThreadLogCapProvider(() => threadEventLogMaxBytes(cfg));
|
|
|
319
361
|
// The decision log keeps month files for at least this many days. Read per
|
|
320
362
|
// prune, so a Settings change applies at the next one without a restart.
|
|
321
363
|
bindDecisionRetention(() => decisionRetentionDays(cfg.decisions?.retentionDays));
|
|
364
|
+
// The decision model (server/decider): reads cfg per call, so a Settings
|
|
365
|
+
// save applies to the next decision. It never throws into a turn.
|
|
366
|
+
const decider = createDecider({ config: () => cfg, dataDir: DATA_DIR });
|
|
322
367
|
const customDomainVerifier = createCustomDomainVerifier({ environmentId: ENVIRONMENT_ID });
|
|
323
368
|
// "Sign in with your email" on /pair: the allow-list is read per call so a
|
|
324
369
|
// Settings change or an env bootstrap applies without a restart.
|
|
@@ -416,6 +461,74 @@ function threadPersonKey(threadId) {
|
|
|
416
461
|
: thread.findLast((message) => message.role === "user");
|
|
417
462
|
return linePersonKey(request) ?? threadStarters.get(threadId);
|
|
418
463
|
}
|
|
464
|
+
/** The person whose lent computers this bot turn may use, or null.
|
|
465
|
+
* Fail closed: only the person whose own message the harness proved started
|
|
466
|
+
* this exact turn generation (continuations of that same request keep it).
|
|
467
|
+
* A routine, a webhook, a room, a bot's delegated or peer turn, the owner on
|
|
468
|
+
* this machine (who lends nothing to itself) and anything unprovable act for
|
|
469
|
+
* nobody here. Deliberately not the usage ledger's trigger, which defaults to
|
|
470
|
+
* the owner. */
|
|
471
|
+
function sharedComputerPrincipal(capability) {
|
|
472
|
+
// A Cloud home lends the owner's Mac only to turns that provably act for
|
|
473
|
+
// the owner (server/cloud-lending.ts): their own conversation from one of
|
|
474
|
+
// their devices, or a routine they wrote. Never a guest, a webhook, a room
|
|
475
|
+
// or a line someone else slipped in.
|
|
476
|
+
if (CLOUD_HOME)
|
|
477
|
+
return cloudHomeLendingRefusal(cloudLendingTurn(capability)) === null ? CLOUD_HOME_LENDER : null;
|
|
478
|
+
return provenRequestPerson(directRequestOwners.get(capability.threadId), capability.generation, (messageId) => linePersonKey(store.messagesFor(capability.threadId).find((message) => message.id === messageId)));
|
|
479
|
+
}
|
|
480
|
+
/** What the lending rule needs to know about one bot turn on a Cloud home. */
|
|
481
|
+
function cloudLendingTurn(capability) {
|
|
482
|
+
return {
|
|
483
|
+
request: directRequestOwners.get(capability.threadId),
|
|
484
|
+
generation: capability.generation,
|
|
485
|
+
thread: store.messagesFor(capability.threadId),
|
|
486
|
+
ownerPerson: cloudOwnerPerson,
|
|
487
|
+
cardAnswerer: cloudCardAnswerer,
|
|
488
|
+
routineRun: () => {
|
|
489
|
+
const run = activeRoutineRunForThread(capability.threadId);
|
|
490
|
+
if (!run || run.threadId !== capability.threadId || store.taskByThread(capability.botId, capability.threadId)?.routineRunId !== run.id)
|
|
491
|
+
return null;
|
|
492
|
+
return {
|
|
493
|
+
triggerSource: run.triggerSource ?? (run.manual ? "manual" : "schedule"),
|
|
494
|
+
ownerStarted: ownerStartedRoutineRuns.has(run.id),
|
|
495
|
+
// What the run snapshotted, on the schedule its routine has now.
|
|
496
|
+
ownerAuthored: ((routine) => Boolean(routine) && cloudRoutineAuthors?.authored(run.routineId, {
|
|
497
|
+
...routine, prompt: run.prompt, target: run.target, botId: run.botId, groupId: run.groupId, attachments: run.attachments, runOn: run.runOn,
|
|
498
|
+
}) === true)(routines?.listRoutines().find((candidate) => candidate.id === run.routineId)),
|
|
499
|
+
};
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
/** What a bot on a Cloud home is told when its conversation cannot use the
|
|
504
|
+
* owner's lent Mac because someone else wrote in it. The bot relays it. */
|
|
505
|
+
const LENDING_SOMEONE_ELSE = "Someone else wrote in this conversation, so it can't use your Mac. Start a new conversation to use it.";
|
|
506
|
+
/** Whose lent computers a status reader may see: on a Cloud home, only the
|
|
507
|
+
* owner's own devices (admin sessions) see the lent Mac, never a guest or a
|
|
508
|
+
* local process; elsewhere, the person behind the asking session. */
|
|
509
|
+
function sessionLendingPrincipal(auth) {
|
|
510
|
+
if (CLOUD_HOME)
|
|
511
|
+
return cloudOwnerSession(auth) ? CLOUD_HOME_LENDER : null;
|
|
512
|
+
return auth.kind === "session" ? personKey(auth.session) : null;
|
|
513
|
+
}
|
|
514
|
+
/** On a Cloud home: a request from one of the owner's own devices. The
|
|
515
|
+
* Admin's signed pairing gives the desktop an admin session; a guest the
|
|
516
|
+
* owner pairs with chat-only access has client scope. Loopback is not the
|
|
517
|
+
* owner here: nothing the person does reaches a Cloud home that way, only
|
|
518
|
+
* processes on the machine (a bot's shell) do. */
|
|
519
|
+
function cloudOwnerSession(auth) {
|
|
520
|
+
return auth.kind === "session" && auth.scopes.includes("admin");
|
|
521
|
+
}
|
|
522
|
+
/** On a Cloud home: whether a message's person key is one of the owner's own
|
|
523
|
+
* devices right now (a live admin session with that key). */
|
|
524
|
+
function cloudOwnerPerson(person) {
|
|
525
|
+
return Boolean(person) && sessions.list().some((session) => session.scopes.includes("admin") && personKey(session) === person);
|
|
526
|
+
}
|
|
527
|
+
/** On a Cloud home, a session that is not one of the owner's own devices may
|
|
528
|
+
* not answer a card of any kind. */
|
|
529
|
+
function cloudCardRefusal(auth) {
|
|
530
|
+
return auth.scopes.includes("admin") ? null : "Only the owner of this Cloud can answer this card.";
|
|
531
|
+
}
|
|
419
532
|
/** Whose session may answer a card. The provider CLI's own approval modes and
|
|
420
533
|
* the harness's proposals stay exactly as they are; this adds no card, gate
|
|
421
534
|
* or prompt, it only decides whose answer to an existing card counts.
|
|
@@ -432,6 +545,11 @@ function cardAnswerRefusal(auth, threadId, requestId, behavior) {
|
|
|
432
545
|
? "A local service can only decline this request. Approve or answer it in OpenMausBot while signed in."
|
|
433
546
|
: null;
|
|
434
547
|
}
|
|
548
|
+
// A Cloud home is one person's: a device paired with chat-only access (a
|
|
549
|
+
// guest) may read along but never answer, or its words would reach the
|
|
550
|
+
// owner's turn, and through it a lent Mac (docs/cloud-pro.md).
|
|
551
|
+
if (CLOUD_HOME)
|
|
552
|
+
return cloudCardRefusal(auth);
|
|
435
553
|
if (auth.scopes.includes("admin") || !sharedMembership())
|
|
436
554
|
return null;
|
|
437
555
|
const known = [threadStarters.get(threadId), cardRequesterKey(threadId, requestId)].filter((person) => Boolean(person));
|
|
@@ -475,7 +593,19 @@ function decisionActorFor(auth) {
|
|
|
475
593
|
function cardAnswererFor(auth) {
|
|
476
594
|
if (auth.kind === "loopback")
|
|
477
595
|
return auth.trust === "service" ? { kind: "worker" } : { kind: "loopback" };
|
|
478
|
-
return { kind: "session", name: (auth.session.email ?? auth.session.label ?? "").trim() || "Signed-in user"
|
|
596
|
+
return { kind: "session", name: (auth.session.email ?? auth.session.label ?? "").trim() || "Signed-in user",
|
|
597
|
+
...(CLOUD_HOME ? { person: personKey(auth.session) } : {}) };
|
|
598
|
+
}
|
|
599
|
+
/** On a Cloud home, who is answering a card right now, by request id: the
|
|
600
|
+
* answer reaches the turn before `answeredBy` is written, and a lending turn
|
|
601
|
+
* must know whose words it just received (server/cloud-lending.ts). */
|
|
602
|
+
const cloudCardAnswersInFlight = new Map();
|
|
603
|
+
/** The person who answered a card, as far as lending on a Cloud home is
|
|
604
|
+
* concerned: the recorded answerer, else whoever is answering it now. */
|
|
605
|
+
function cloudCardAnswerer(card) {
|
|
606
|
+
if (card.answeredBy)
|
|
607
|
+
return card.answeredBy.kind === "session" ? card.answeredBy.person : undefined;
|
|
608
|
+
return card.requestId ? cloudCardAnswersInFlight.get(card.requestId) : undefined;
|
|
479
609
|
}
|
|
480
610
|
/** Answer a card as `auth`: the decision rows written meanwhile name the
|
|
481
611
|
* answerer, and a card this answer settled records who settled it. A card
|
|
@@ -485,6 +615,8 @@ async function answeringCardAs(auth, threadId, requestId, work) {
|
|
|
485
615
|
const card = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId)?.card;
|
|
486
616
|
return Boolean(card && !card.answered && !card.dismissed && !card.expired);
|
|
487
617
|
})();
|
|
618
|
+
if (CLOUD_HOME && open)
|
|
619
|
+
cloudCardAnswersInFlight.set(requestId, auth.kind === "session" ? personKey(auth.session) : undefined);
|
|
488
620
|
try {
|
|
489
621
|
await withDecisionActor(decisionActorFor(auth), work);
|
|
490
622
|
}
|
|
@@ -494,6 +626,8 @@ async function answeringCardAs(auth, threadId, requestId, work) {
|
|
|
494
626
|
if (message && card && !card.answeredBy && card.answered !== "unavailable" && (card.answered || card.dismissed)) {
|
|
495
627
|
store.patchMessage(threadId, message.id, { card: { ...card, answeredBy: cardAnswererFor(auth) } });
|
|
496
628
|
}
|
|
629
|
+
if (CLOUD_HOME && open)
|
|
630
|
+
cloudCardAnswersInFlight.delete(requestId);
|
|
497
631
|
}
|
|
498
632
|
}
|
|
499
633
|
// ── bot visibility (server/bot-visibility.ts) ──────────────────────────
|
|
@@ -617,6 +751,56 @@ function recentWorkSources(bot) {
|
|
|
617
751
|
taskByThread: (botId, threadId) => store.taskByThread(botId, threadId),
|
|
618
752
|
};
|
|
619
753
|
}
|
|
754
|
+
/** The automatic-recall block for a turn (server/recall.ts), or "". Notes
|
|
755
|
+
* always; the bot's other conversations only when `conversations` — a 1:1
|
|
756
|
+
* turn the person started — because a message from another bot, a webhook
|
|
757
|
+
* or a room must not be able to pull a private chat into its reply. The
|
|
758
|
+
* conversations are the ones session_search would search, minus this one. */
|
|
759
|
+
function autoRecallPrompt(bot, threadId, message, opts) {
|
|
760
|
+
if (!autoRecallEnabled(cfg))
|
|
761
|
+
return "";
|
|
762
|
+
const roomByThread = new Map();
|
|
763
|
+
if (opts.conversations) {
|
|
764
|
+
for (const group of store.groups) {
|
|
765
|
+
if (!group.memberIds.includes(bot.id) || !roomFeedsBot(group, bot))
|
|
766
|
+
continue;
|
|
767
|
+
roomByThread.set(group.threadId, group);
|
|
768
|
+
for (const task of group.tasks ?? [])
|
|
769
|
+
roomByThread.set(task.threadId, group);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
const threadIds = opts.conversations
|
|
773
|
+
? [...new Set([bot.threadId, ...(bot.tasks ?? []).map((task) => task.threadId), ...roomByThread.keys()])].filter((id) => id !== threadId)
|
|
774
|
+
: [];
|
|
775
|
+
try {
|
|
776
|
+
const recalled = buildRecall({
|
|
777
|
+
botId: bot.id,
|
|
778
|
+
message,
|
|
779
|
+
threadIds,
|
|
780
|
+
label: (id) => {
|
|
781
|
+
const room = roomByThread.get(id);
|
|
782
|
+
if (room)
|
|
783
|
+
return `room ${JSON.stringify(room.name)}`;
|
|
784
|
+
const task = store.taskByThread(bot.id, id);
|
|
785
|
+
if (task)
|
|
786
|
+
return `chat ${JSON.stringify(task.title)}`;
|
|
787
|
+
return id === bot.threadId ? "your main chat" : "an earlier chat";
|
|
788
|
+
},
|
|
789
|
+
author: (hit) => (hit.role === "user" ? hit.peer ?? opts.userName : hit.from ?? bot.name),
|
|
790
|
+
});
|
|
791
|
+
if (recalled)
|
|
792
|
+
console.log(`auto-recall: ${bot.name} (${bot.id}) got ${recalled.notes} note and ${recalled.conversations} conversation passage(s) in ${threadId}`);
|
|
793
|
+
return recalled?.text ?? "";
|
|
794
|
+
}
|
|
795
|
+
catch (err) {
|
|
796
|
+
console.warn(`auto-recall failed for ${bot.id}: ${err.message}`);
|
|
797
|
+
return "";
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
/** The recalled block in front of a turn's message, or the message alone. */
|
|
801
|
+
function withRecalled(recalled, text) {
|
|
802
|
+
return recalled ? `${recalled}\n\n${text}` : text;
|
|
803
|
+
}
|
|
620
804
|
/** Bots in one room must be visible to the same people: a room is one
|
|
621
805
|
* transcript, so otherwise a restricted bot's words would feed a teammate
|
|
622
806
|
* everyone can see. Refuse a roster that adds such a bot (existing members
|
|
@@ -699,7 +883,7 @@ function adminAuditPlan(method, path) {
|
|
|
699
883
|
if (path.startsWith("/api/internal/") || path.startsWith("/api/testing/"))
|
|
700
884
|
return null;
|
|
701
885
|
const plan = {
|
|
702
|
-
config: requiredScope(method, path, { sharedComputers:
|
|
886
|
+
config: requiredScope(method, path, { sharedComputers: lendingEnabled() }) === "admin",
|
|
703
887
|
createsBots: method === "POST" && (path === "/api/bots" || path === "/api/teams/import" || path === "/api/org-library/add"),
|
|
704
888
|
createsWebhook: method === "POST" && path === "/api/webhooks",
|
|
705
889
|
pairing: method === "POST" && path === "/api/auth/pairing",
|
|
@@ -874,15 +1058,17 @@ function operatorPrices() {
|
|
|
874
1058
|
* engine reported tokens but no price, and let the spend cap see it at once.
|
|
875
1059
|
* The first booking that takes the month past the warning threshold, or to
|
|
876
1060
|
* the cap, notifies admins (once per month each; server/spend.ts). */
|
|
877
|
-
function bookTurnUsage(row) {
|
|
878
|
-
|
|
1061
|
+
function bookTurnUsage(row, tokensReported = true, notificationThreadId = row.threadId) {
|
|
1062
|
+
// Missing helper usage is unpriced, not an estimated zero-dollar call.
|
|
1063
|
+
const cost = tokensReported || row.costUsd !== null ? ledgerCost(row, operatorPrices()) : { costUsd: null };
|
|
1064
|
+
const booked = { ...row, ...cost, at: new Date().toISOString() };
|
|
879
1065
|
// The append lands asynchronously; the cap counts the row from memory
|
|
880
1066
|
// until it does, so the check below (and the next turn start) sees it.
|
|
881
1067
|
noteSpend(DATA_DIR, booked, appendUsage(DATA_DIR, booked));
|
|
882
1068
|
const state = spendState(cfg, DATA_DIR);
|
|
883
1069
|
const alert = takeSpendAlert(DATA_DIR, state);
|
|
884
1070
|
if (alert && state) {
|
|
885
|
-
broadcast({ kind: "notify", notification: buildSpendNotification({ id: row.botId, name: row.botName },
|
|
1071
|
+
broadcast({ kind: "notify", notification: buildSpendNotification({ id: row.botId, name: row.botName }, notificationThreadId, spendAlertText(alert, state)) }, { adminOnly: true });
|
|
886
1072
|
}
|
|
887
1073
|
}
|
|
888
1074
|
/** A queued line from before triggers were stored with it: its sender, else
|
|
@@ -994,6 +1180,18 @@ const companyRuntimeStarted = new Promise(resolve => { companyRuntimeReady = res
|
|
|
994
1180
|
/** The enrolled organisation's desktop policy: in memory only, read-only, and
|
|
995
1181
|
* inert (null) unless Electron's enrolled parent sends one. */
|
|
996
1182
|
const managedPolicy = new ManagedDesktopPolicy({ onChange: () => broadcast({ kind: "config", ...configStatus() }) });
|
|
1183
|
+
/** A computer kind this server will not use, refused before anything is
|
|
1184
|
+
* prepared: a Cloud home never offers this computer or a Local VM
|
|
1185
|
+
* (cloud-home.ts), and an enrolled organisation may disallow any kind. */
|
|
1186
|
+
function computerPlaceRefusal(kind) {
|
|
1187
|
+
const cloudHome = CLOUD_HOME ? cloudHomePlaceRefusal(kind === "thisComputer" ? "local" : kind === "localVm" ? "vm" : "cloud") : undefined;
|
|
1188
|
+
if (cloudHome)
|
|
1189
|
+
return { message: cloudHome, code: "cloud_home" };
|
|
1190
|
+
const managed = managedPolicy.computerRefusal(kind);
|
|
1191
|
+
return managed ? { message: managed, code: "managed_policy" } : undefined;
|
|
1192
|
+
}
|
|
1193
|
+
/** Cloud chosen with no Boat account: a Cloud home suggests the browser, not a Local VM. */
|
|
1194
|
+
const BOAT_NOT_CONFIGURED = boatNotConfiguredMessage(Boolean(CLOUD_HOME));
|
|
997
1195
|
/** Why the organisation refuses this instance for bots, or undefined. */
|
|
998
1196
|
function policyModelRefusal(instance) {
|
|
999
1197
|
const engine = BUILT_IN_DRIVERS.find(driver => driver.driverKind === instance.driverKind)?.metadata.displayName;
|
|
@@ -1278,7 +1476,10 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation, r
|
|
|
1278
1476
|
OMB_SKILL_AUTHORING_ENABLED: skillAuthoring ? "1" : "0",
|
|
1279
1477
|
// The shared-computer tools are advertised only while the workspace
|
|
1280
1478
|
// gate is on; the routes behind them refuse regardless.
|
|
1281
|
-
OMB_SHARED_COMPUTERS_ENABLED:
|
|
1479
|
+
OMB_SHARED_COMPUTERS_ENABLED: lendingEnabled() ? "1" : "0",
|
|
1480
|
+
// A Cloud home offers no this computer and no Local VM (cloud-home.ts),
|
|
1481
|
+
// so select_computer lists neither and vm_exec is not shown.
|
|
1482
|
+
OMB_CLOUD_HOME: CLOUD_HOME ? "1" : "0",
|
|
1282
1483
|
// Same capability rule for voice: the tool is offered only when this
|
|
1283
1484
|
// bot can actually speak, and the route re-checks on every call.
|
|
1284
1485
|
OMB_VOICE_NOTES: (() => {
|
|
@@ -1352,12 +1553,164 @@ const turnResourceOwners = new Map();
|
|
|
1352
1553
|
const turnComputerResources = new Map();
|
|
1353
1554
|
const teamComputerTurns = new Map();
|
|
1354
1555
|
const settlingResourceOwners = new Map();
|
|
1556
|
+
/** Idle release for computer claims (#1653), disabled until the feature
|
|
1557
|
+
* flag is on: a seat that stays screen-quiet for the policy's window is
|
|
1558
|
+
* released to waiting turns while its holder's turn still lives, and the
|
|
1559
|
+
* previous holder re-claims directly inside the reclaim window — it
|
|
1560
|
+
* yields once another turn holds the seat. The durable VM profile keeps
|
|
1561
|
+
* its login state, so that re-claim is cheap. Read per claim: a flag
|
|
1562
|
+
* change applies to new claims, never to a seat held under the old
|
|
1563
|
+
* setting. */
|
|
1564
|
+
const COMPUTER_CLAIM_IDLE_POLICY = new IdleReleasePolicy();
|
|
1565
|
+
function computerClaimIdlePolicy(resource) {
|
|
1566
|
+
return resource.startsWith("computer:") && computerClaimIdleReleaseEnabled(cfg) ? COMPUTER_CLAIM_IDLE_POLICY : undefined;
|
|
1567
|
+
}
|
|
1568
|
+
/** #1655: the consent ledger for local-wait cloud overflow, and the live
|
|
1569
|
+
* cloud seats it has started. One seat per bot — it is the bot's own Box,
|
|
1570
|
+
* shared by that bot's threads the way every other computer is. */
|
|
1571
|
+
const cloudOverflowConsent = new CloudOverflowConsent();
|
|
1572
|
+
const cloudSeatLeases = new Map();
|
|
1573
|
+
let cloudSeatSweepTimer;
|
|
1574
|
+
/** One stop attempt per seat at a time (#1655): the sweep ticks faster
|
|
1575
|
+
* than a Box sleeps, and a second stop call would race the first. */
|
|
1576
|
+
const cloudSeatStopsInFlight = new Set();
|
|
1577
|
+
/** The waits that may overflow: a turn queued for a local desktop, never
|
|
1578
|
+
* one already bound for a cloud or VPS seat. */
|
|
1579
|
+
function localComputerWait(resource) {
|
|
1580
|
+
return resource === "computer:host" || resource.startsWith("computer:vm:");
|
|
1581
|
+
}
|
|
1582
|
+
function cloudOverflowSituation(owner, resource, started) {
|
|
1583
|
+
if (!localComputerWait(resource))
|
|
1584
|
+
return null;
|
|
1585
|
+
if (!store.botByThread(owner.threadId))
|
|
1586
|
+
return null;
|
|
1587
|
+
const perSecondCostUsd = cloudOverflowPerSecondCostUsd(cfg);
|
|
1588
|
+
return {
|
|
1589
|
+
featureEnabled: cloudOverflowEnabled(cfg),
|
|
1590
|
+
// An organisation that refuses the Box kind has nothing to offer, and
|
|
1591
|
+
// the decision must fail closed before any card is written.
|
|
1592
|
+
cloudConfigured: boat.boatConfigured(cfg) && managedPolicy.computerRefusal("box") === undefined,
|
|
1593
|
+
perSecondCostUsd,
|
|
1594
|
+
// Consent answers the priced card (#1655): a grant counts only at the
|
|
1595
|
+
// rate its card showed, so a config change re-offers instead of
|
|
1596
|
+
// starting at a price nobody approved.
|
|
1597
|
+
consented: cloudOverflowConsent.consented(owner.threadId, cloudOverflowAllowlistedThreads(cfg), perSecondCostUsd ?? undefined),
|
|
1598
|
+
offered: cloudOverflowConsent.offered(owner.threadId, perSecondCostUsd ?? undefined),
|
|
1599
|
+
started,
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1355
1602
|
function claimTurnResource(owner, resource) {
|
|
1356
|
-
|
|
1603
|
+
const idle = computerClaimIdlePolicy(resource);
|
|
1604
|
+
if (!turnResources.claim(resource, owner, idle ? { idle } : {}))
|
|
1357
1605
|
return false;
|
|
1358
1606
|
turnResourceOwners.set(owner.threadId, owner);
|
|
1359
1607
|
return true;
|
|
1360
1608
|
}
|
|
1609
|
+
/** Start the waiting bot's cloud seat (#1655). True once handled — started
|
|
1610
|
+
* or terminally failed — so the wait never retries every poll; false when
|
|
1611
|
+
* another turn of this bot owns the Box lifecycle and a later poll may
|
|
1612
|
+
* still land. */
|
|
1613
|
+
async function startCloudSeat(owner) {
|
|
1614
|
+
const bot = store.botByThread(owner.threadId);
|
|
1615
|
+
if (!bot)
|
|
1616
|
+
return true;
|
|
1617
|
+
const perSecondCostUsd = cloudOverflowPerSecondCostUsd(cfg);
|
|
1618
|
+
if (!perSecondCostUsd)
|
|
1619
|
+
return true;
|
|
1620
|
+
const boxBotResource = "computer:box-bot:" + bot.id;
|
|
1621
|
+
if (!claimTurnResource(owner, boxBotResource))
|
|
1622
|
+
return false;
|
|
1623
|
+
try {
|
|
1624
|
+
broadcast({ kind: "computer", botId: bot.id, state: "waking" });
|
|
1625
|
+
const machine = await boat.readyBoat(cfg, bot.id);
|
|
1626
|
+
if (!machine)
|
|
1627
|
+
throw new Error("the cloud computer did not wake");
|
|
1628
|
+
// Consent can be revoked while the Box wakes (#1655): a seat nobody
|
|
1629
|
+
// consented to must not bill. It is released and put straight back to
|
|
1630
|
+
// sleep — and if even that fails, leased as already idle so the sweep
|
|
1631
|
+
// keeps retrying with backoff.
|
|
1632
|
+
if (!cloudOverflowConsent.consented(owner.threadId, cloudOverflowAllowlistedThreads(cfg), perSecondCostUsd)) {
|
|
1633
|
+
turnResources.releaseOne(boxBotResource, owner);
|
|
1634
|
+
const idleStopMs = cloudOverflowIdleStopMs(cfg);
|
|
1635
|
+
void boat.sleepBoat(cfg, bot.id).catch(() => {
|
|
1636
|
+
cloudSeatLeases.set(bot.id, new CloudSeatLease({ botId: bot.id, threadId: owner.threadId, generation: owner.generation, now: Date.now() - idleStopMs - 1, idleStopMs }));
|
|
1637
|
+
ensureCloudSeatSweep();
|
|
1638
|
+
});
|
|
1639
|
+
store.appendMessage(owner.threadId, {
|
|
1640
|
+
role: "bot", kind: "activity",
|
|
1641
|
+
tool: { name: "Cloud overflow consent was revoked while the cloud computer woke; it stops unbilled and the local wait continues.", ok: true },
|
|
1642
|
+
});
|
|
1643
|
+
return true;
|
|
1644
|
+
}
|
|
1645
|
+
cloudSeatLeases.set(bot.id, new CloudSeatLease({ botId: bot.id, threadId: owner.threadId, generation: owner.generation, idleStopMs: cloudOverflowIdleStopMs(cfg) }));
|
|
1646
|
+
ensureCloudSeatSweep();
|
|
1647
|
+
store.appendMessage(owner.threadId, {
|
|
1648
|
+
role: "bot", kind: "activity",
|
|
1649
|
+
tool: { name: cloudSeatStartedText({ perSecondCostUsd, idleStopMs: cloudOverflowIdleStopMs(cfg) }), ok: true },
|
|
1650
|
+
});
|
|
1651
|
+
return true;
|
|
1652
|
+
}
|
|
1653
|
+
catch (error) {
|
|
1654
|
+
turnResources.releaseOne(boxBotResource, owner);
|
|
1655
|
+
store.appendMessage(owner.threadId, {
|
|
1656
|
+
role: "bot", kind: "activity",
|
|
1657
|
+
tool: { name: "Cloud computer could not start (" + (error instanceof Error ? error.message : String(error)) + "); the local wait continues.", ok: false },
|
|
1658
|
+
});
|
|
1659
|
+
return true;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
/** A per-second-billed seat must stop on idleness even when nothing else
|
|
1663
|
+
* touches the server, so this slice carries the one timer — unref'd, it
|
|
1664
|
+
* can never hold the process open, and it dies with the last lease. */
|
|
1665
|
+
function ensureCloudSeatSweep() {
|
|
1666
|
+
if (cloudSeatSweepTimer)
|
|
1667
|
+
return;
|
|
1668
|
+
cloudSeatSweepTimer = setInterval(() => {
|
|
1669
|
+
sweepIdleCloudSeats();
|
|
1670
|
+
if (!cloudSeatLeases.size && cloudSeatSweepTimer) {
|
|
1671
|
+
clearInterval(cloudSeatSweepTimer);
|
|
1672
|
+
cloudSeatSweepTimer = undefined;
|
|
1673
|
+
}
|
|
1674
|
+
}, 15_000);
|
|
1675
|
+
cloudSeatSweepTimer.unref?.();
|
|
1676
|
+
}
|
|
1677
|
+
/** Stop every cloud seat idle past its window (#1655): the machine is
|
|
1678
|
+
* archived, billing pauses, and the transcript says which wait's seat it
|
|
1679
|
+
* was. Only real computer tool completions refresh a lease — the poller's
|
|
1680
|
+
* preview frames never reach the touch call. */
|
|
1681
|
+
/** Stop one running cloud seat (#1655): the Box sleeps, and only a
|
|
1682
|
+
* successful stop clears the lease and its Box claim — a failed stop
|
|
1683
|
+
* stays leased and retryable with backoff, because the seat still bills
|
|
1684
|
+
* until it sleeps. The claim releases for the lease's own generation,
|
|
1685
|
+
* never a newer turn's. */
|
|
1686
|
+
function stopCloudSeat(botId, lease, stoppedName, now = Date.now()) {
|
|
1687
|
+
if (cloudSeatStopsInFlight.has(botId))
|
|
1688
|
+
return;
|
|
1689
|
+
if (cloudSeatLeases.get(botId) !== lease)
|
|
1690
|
+
return;
|
|
1691
|
+
cloudSeatStopsInFlight.add(botId);
|
|
1692
|
+
void boat.sleepBoat(cfg, botId)
|
|
1693
|
+
.then(() => {
|
|
1694
|
+
if (cloudSeatLeases.get(botId) === lease)
|
|
1695
|
+
cloudSeatLeases.delete(botId);
|
|
1696
|
+
// The stopped seat's Box claim goes with it, so a still-waiting
|
|
1697
|
+
// turn may start a fresh seat for the same bot.
|
|
1698
|
+
turnResources.releaseOne(`computer:box-bot:${botId}`, { threadId: lease.threadId, generation: lease.generation });
|
|
1699
|
+
store.appendMessage(lease.threadId, { role: "bot", kind: "activity", tool: { name: stoppedName, ok: true } });
|
|
1700
|
+
})
|
|
1701
|
+
.catch(() => {
|
|
1702
|
+
lease.deferSleep(now);
|
|
1703
|
+
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 } });
|
|
1704
|
+
})
|
|
1705
|
+
.finally(() => cloudSeatStopsInFlight.delete(botId));
|
|
1706
|
+
}
|
|
1707
|
+
function sweepIdleCloudSeats(now = Date.now()) {
|
|
1708
|
+
for (const [botId, lease] of cloudSeatLeases) {
|
|
1709
|
+
if (!lease.idleElapsed(now) || !lease.sleepDue(now))
|
|
1710
|
+
continue;
|
|
1711
|
+
stopCloudSeat(botId, lease, cloudSeatStoppedText(lease.idleFor(now)), now);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1361
1714
|
function releaseTurnResources(owner) {
|
|
1362
1715
|
if (!owner)
|
|
1363
1716
|
return;
|
|
@@ -1377,11 +1730,11 @@ function releaseTurnResources(owner) {
|
|
|
1377
1730
|
}
|
|
1378
1731
|
}
|
|
1379
1732
|
async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
1380
|
-
// Every computer claim passes here: a kind the organisation disallows
|
|
1381
|
-
// refused at claim time, whichever path selected it.
|
|
1382
|
-
const kind = computerKindForResource(resource), refusal = kind &&
|
|
1733
|
+
// Every computer claim passes here: a kind the organisation disallows, or a
|
|
1734
|
+
// Cloud home never offers, is refused at claim time, whichever path selected it.
|
|
1735
|
+
const kind = computerKindForResource(resource), refusal = kind && computerPlaceRefusal(kind);
|
|
1383
1736
|
if (refusal)
|
|
1384
|
-
throw Object.assign(new Error(refusal), { code:
|
|
1737
|
+
throw Object.assign(new Error(refusal.message), { code: refusal.code });
|
|
1385
1738
|
const active = () => activeInternalGenerationByThread.get(owner.threadId) === owner.generation &&
|
|
1386
1739
|
turnResourceOwners.get(owner.threadId)?.generation === owner.generation;
|
|
1387
1740
|
let waitingMessage;
|
|
@@ -1395,6 +1748,10 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
|
1395
1748
|
// turn.wait_* events carry the same facts for the inspector log.
|
|
1396
1749
|
let waitedSince;
|
|
1397
1750
|
let waitEnded = false;
|
|
1751
|
+
// #1655: whether this wait already started (or terminally failed to
|
|
1752
|
+
// start) a cloud seat; the decision is re-evaluated every poll because
|
|
1753
|
+
// consent can arrive mid-wait.
|
|
1754
|
+
let overflowHandled = false;
|
|
1398
1755
|
const waitEventBase = () => {
|
|
1399
1756
|
const selection = store.botByThread(owner.threadId)?.modelSelection;
|
|
1400
1757
|
const instance = selection ? registry.get(selection.instanceId) : null;
|
|
@@ -1460,7 +1817,32 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
|
1460
1817
|
resource,
|
|
1461
1818
|
...(holder ? { holder } : {}),
|
|
1462
1819
|
});
|
|
1820
|
+
// #1655: the wait is where an overflow choice gets made. The card
|
|
1821
|
+
// names the per-second cost before anything can start, and only a
|
|
1822
|
+
// turn genuinely waiting on a local desktop gets one. Fail closed:
|
|
1823
|
+
// feature off, cloud unusable, or no operator-set rate means no
|
|
1824
|
+
// card at all — Auto never offers, never starts.
|
|
1825
|
+
const overflow = cloudOverflowSituation(owner, resource, overflowHandled);
|
|
1826
|
+
const perSecondCostUsd = overflow?.perSecondCostUsd ?? null;
|
|
1827
|
+
if (overflow && perSecondCostUsd !== null && cloudOverflowAction(overflow).kind === "offer") {
|
|
1828
|
+
store.appendMessage(owner.threadId, {
|
|
1829
|
+
role: "bot", kind: "activity",
|
|
1830
|
+
tool: { name: cloudOverflowOfferText({ perSecondCostUsd, idleStopMs: cloudOverflowIdleStopMs(cfg) }), ok: true },
|
|
1831
|
+
});
|
|
1832
|
+
cloudOverflowConsent.markOffered(owner.threadId, perSecondCostUsd);
|
|
1833
|
+
}
|
|
1463
1834
|
}
|
|
1835
|
+
// #1655: consent can arrive mid-wait — the card's answer, or a
|
|
1836
|
+
// thread the operator allowlisted — and only then may the cloud
|
|
1837
|
+
// seat start, its cost chip landing beside the waiting one. The
|
|
1838
|
+
// local wait itself is untouched: it still ends by acquisition,
|
|
1839
|
+
// stop, or the ceiling (parking, once that stack lands).
|
|
1840
|
+
if (!overflowHandled) {
|
|
1841
|
+
const situation = cloudOverflowSituation(owner, resource, overflowHandled);
|
|
1842
|
+
if (situation && cloudOverflowAction(situation).kind === "start")
|
|
1843
|
+
overflowHandled = await startCloudSeat(owner);
|
|
1844
|
+
}
|
|
1845
|
+
sweepIdleCloudSeats();
|
|
1464
1846
|
if (Date.now() >= deadline) {
|
|
1465
1847
|
endWait("gave_up");
|
|
1466
1848
|
throw new ComputerWaitGaveUp(computerStillBusyText(holder, GROUP_GOAL_WAIT_MAX_MS));
|
|
@@ -1857,8 +2239,9 @@ const routineRequestEnvelopeSchema = z.discriminatedUnion("action", [
|
|
|
1857
2239
|
action: z.literal("update"),
|
|
1858
2240
|
routineId: z.unknown(),
|
|
1859
2241
|
changes: z.unknown(),
|
|
2242
|
+
forBotId: z.unknown().optional(),
|
|
1860
2243
|
}).strict(),
|
|
1861
|
-
...["pause", "resume", "run_now", "delete"].map((action) => z.object({ ...routineRequestSourceSchema, action: z.literal(action), routineId: z.unknown() }).strict()),
|
|
2244
|
+
...["pause", "resume", "run_now", "delete"].map((action) => z.object({ ...routineRequestSourceSchema, action: z.literal(action), routineId: z.unknown(), forBotId: z.unknown().optional() }).strict()),
|
|
1862
2245
|
]);
|
|
1863
2246
|
/** The loopback endpoint a bot's computer proxy polls before acting. */
|
|
1864
2247
|
function controlIntegration(botId, threadId, generation, localVmTarget) {
|
|
@@ -2208,6 +2591,13 @@ function checkedGroupResponder(value, memberIds) {
|
|
|
2208
2591
|
return { kind: "everyone" };
|
|
2209
2592
|
if (responder.kind === "mentions")
|
|
2210
2593
|
return { kind: "mentions" };
|
|
2594
|
+
if (responder.kind === "auto") {
|
|
2595
|
+
if (responder.fallbackBotId === undefined || responder.fallbackBotId === null)
|
|
2596
|
+
return { kind: "auto" };
|
|
2597
|
+
return typeof responder.fallbackBotId === "string" && memberIds.includes(responder.fallbackBotId)
|
|
2598
|
+
? { kind: "auto", fallbackBotId: responder.fallbackBotId }
|
|
2599
|
+
: null;
|
|
2600
|
+
}
|
|
2211
2601
|
if (responder.kind === "member" &&
|
|
2212
2602
|
typeof responder.botId === "string" &&
|
|
2213
2603
|
memberIds.includes(responder.botId)) {
|
|
@@ -2363,7 +2753,8 @@ function previewSystemPrompt(bot) {
|
|
|
2363
2753
|
{ id: "plan", label: "Surface", text: previewPlan.computer === undefined ? "" : surfacePrompt({
|
|
2364
2754
|
computer: previewPlan.computer && previewPlan.computer !== "off" && computerPromptKind ? previewPlan.computer : null,
|
|
2365
2755
|
browser: previewPlan.computer === undefined ? false : previewPlan.browser,
|
|
2366
|
-
}, { note: previewPlan.note }) },
|
|
2756
|
+
}, { note: previewPlan.note, cloudHome: Boolean(CLOUD_HOME) }) },
|
|
2757
|
+
{ id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(agentsMounted && lendingEnabled()) : "" },
|
|
2367
2758
|
{ id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? composioSystemPrompt(bot.connectorTools) : "" },
|
|
2368
2759
|
{ id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(engineMcpServers(bot))) : "" },
|
|
2369
2760
|
{ id: "browser", label: "Browser", text: previewPlan.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
|
|
@@ -2445,6 +2836,7 @@ async function botOverview(bot) {
|
|
|
2445
2836
|
})),
|
|
2446
2837
|
engine,
|
|
2447
2838
|
browserEnabled: builtInBrowserEnabled(cfg),
|
|
2839
|
+
cloudHome: Boolean(CLOUD_HOME),
|
|
2448
2840
|
connectedApps,
|
|
2449
2841
|
sectionPeers,
|
|
2450
2842
|
timeZone,
|
|
@@ -3019,7 +3411,9 @@ function createChannel(value) {
|
|
|
3019
3411
|
throw Object.assign(new Error("invalid setup.defaultResponder"), { status: 400 });
|
|
3020
3412
|
setup = { bulletin: requested.bulletin, defaultResponder: responder, completed: true };
|
|
3021
3413
|
}
|
|
3022
|
-
|
|
3414
|
+
// A new room with no routing of its own starts on Auto while the decision
|
|
3415
|
+
// model is on for rooms; otherwise it gets today's default (first member).
|
|
3416
|
+
return store.createGroup(name, memberIds, false, section, setup ?? (deciderReady(cfg, "roomRouting") ? { defaultResponder: { kind: "auto" } } : undefined));
|
|
3023
3417
|
}
|
|
3024
3418
|
function updateChannel(groupId, value) {
|
|
3025
3419
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -4209,6 +4603,10 @@ function notify(notification) {
|
|
|
4209
4603
|
// Group threads: the fold needs to know WHO is talking — the turn engine
|
|
4210
4604
|
// records the active member here before dispatching its turn.
|
|
4211
4605
|
const groupSpeakers = new Map();
|
|
4606
|
+
/** Auto-room rounds the decision model routed, by thread: the named
|
|
4607
|
+
* speaker's next reply line carries `routedBy` (stamped in the runtime fold
|
|
4608
|
+
* below), so the room can say "Picked by Jev". Only a routed round sets it. */
|
|
4609
|
+
const routedRoomReplies = new Map();
|
|
4212
4610
|
// The latest running token totals for the turn in flight on each thread.
|
|
4213
4611
|
// Providers report cumulative-within-turn numbers; the final value is folded
|
|
4214
4612
|
// into the task's tally when the turn settles.
|
|
@@ -4380,6 +4778,89 @@ bus.subscribe((event) => {
|
|
|
4380
4778
|
memoryRowsByThread.delete(event.threadId);
|
|
4381
4779
|
}
|
|
4382
4780
|
});
|
|
4781
|
+
// Memory upkeep (server/memory-upkeep.ts): background capture, About me
|
|
4782
|
+
// suggestions and the nightly tidy-up, for bots with the switch on.
|
|
4783
|
+
const memoryUpkeep = createMemoryUpkeep({
|
|
4784
|
+
bots: () => store.bots,
|
|
4785
|
+
bot: (id) => store.bot(id) ?? undefined,
|
|
4786
|
+
engine: (botId) => {
|
|
4787
|
+
const bot = store.bot(botId);
|
|
4788
|
+
const instance = bot ? registry.get(bot.modelSelection.instanceId) : undefined;
|
|
4789
|
+
// an engine the organisation disallows never receives memory text
|
|
4790
|
+
if (!bot || !instance || policyModelRefusal(instance))
|
|
4791
|
+
return null;
|
|
4792
|
+
const generate = instance.generateText?.bind(instance);
|
|
4793
|
+
return generate ? {
|
|
4794
|
+
generateText: async (prompt, options) => {
|
|
4795
|
+
// Check every capture, organize and contradiction call, including
|
|
4796
|
+
// consecutive calls within one upkeep pass.
|
|
4797
|
+
assertWithinBudget(cfg, DATA_DIR);
|
|
4798
|
+
const threadId = `memory-${randomUUID()}`;
|
|
4799
|
+
return generate(prompt, { ...options, onUsage: (usage) => bookTurnUsage({
|
|
4800
|
+
botId: bot.id,
|
|
4801
|
+
botName: bot.name,
|
|
4802
|
+
threadId,
|
|
4803
|
+
instanceId: instance.instanceId,
|
|
4804
|
+
driverKind: instance.driverKind,
|
|
4805
|
+
model: usage.model,
|
|
4806
|
+
input: usage.input ?? 0,
|
|
4807
|
+
output: usage.output ?? 0,
|
|
4808
|
+
...(usage.cachedInput !== undefined ? { cachedInput: usage.cachedInput } : {}),
|
|
4809
|
+
costUsd: usage.costUsd ?? null,
|
|
4810
|
+
trigger: { kind: "bot", botId: bot.id },
|
|
4811
|
+
}, usage.input !== undefined && usage.output !== undefined, bot.threadId) });
|
|
4812
|
+
},
|
|
4813
|
+
} : {};
|
|
4814
|
+
},
|
|
4815
|
+
busy: (botId) => botHasActiveTurn(botId),
|
|
4816
|
+
addToAboutMe: (from, texts) => {
|
|
4817
|
+
const current = cfg.profile?.aboutMe ?? "";
|
|
4818
|
+
const facts = planLearned(from, texts, current, memoryDate());
|
|
4819
|
+
const next = appendAboutMe(current, facts.map((fact) => fact.line));
|
|
4820
|
+
// a full About me takes nothing more; the person trims it first
|
|
4821
|
+
if (!facts.length || next === null)
|
|
4822
|
+
return 0;
|
|
4823
|
+
saveConfig({ profile: { ...cfg.profile, aboutMe: next } });
|
|
4824
|
+
Object.assign(cfg, loadConfig());
|
|
4825
|
+
commitLearned(facts);
|
|
4826
|
+
broadcast({ kind: "config", ...configStatus() });
|
|
4827
|
+
return facts.length;
|
|
4828
|
+
},
|
|
4829
|
+
sourceLabel: (botId, threadId) => memorySourceLabel({ task: store.taskByThread(botId, threadId), threadId }),
|
|
4830
|
+
quietMs: () => captureQuietMs(cfg),
|
|
4831
|
+
tidyHour: () => tidyHour(cfg),
|
|
4832
|
+
log: (line) => console.log(line),
|
|
4833
|
+
});
|
|
4834
|
+
// A finished 1:1 turn of an upkeep bot waits for capture. Only the person's
|
|
4835
|
+
// own conversation: not a room, not a turn another bot or the harness
|
|
4836
|
+
// started (its "user" line is not the person), not a failed turn.
|
|
4837
|
+
bus.subscribe((event) => {
|
|
4838
|
+
if (shouldIgnoreProviderEvent(event))
|
|
4839
|
+
return;
|
|
4840
|
+
if (event.type !== "turn.completed" || !event.turnId || !event.ok)
|
|
4841
|
+
return;
|
|
4842
|
+
try {
|
|
4843
|
+
const bot = store.botByThread(event.threadId);
|
|
4844
|
+
if (!bot || !upkeepEnabled(bot) || store.groupByThread(event.threadId) || isInternalTurn(event.threadId)
|
|
4845
|
+
|| isUnattended(bot.id, event.threadId) || routines?.runForThread(event.threadId))
|
|
4846
|
+
return;
|
|
4847
|
+
const path = store.activePath(event.threadId);
|
|
4848
|
+
const replyAt = path.findLastIndex((message) => message.role === "bot" && message.kind === "text" && message.turnId === event.turnId);
|
|
4849
|
+
if (replyAt < 0)
|
|
4850
|
+
return;
|
|
4851
|
+
const asked = path.slice(0, replyAt).findLast((message) => message.role === "user" && message.kind === "text");
|
|
4852
|
+
if (!asked || asked.peerAsk)
|
|
4853
|
+
return;
|
|
4854
|
+
memoryUpkeep.noteTurn(bot.id, event.threadId, {
|
|
4855
|
+
person: extractTurnImages(asked.text ?? "").text,
|
|
4856
|
+
bot: path[replyAt].text ?? "",
|
|
4857
|
+
owner: !asked.sender,
|
|
4858
|
+
});
|
|
4859
|
+
}
|
|
4860
|
+
catch (error) {
|
|
4861
|
+
console.warn(`memory upkeep: could not queue a turn for capture: ${error.message}`);
|
|
4862
|
+
}
|
|
4863
|
+
});
|
|
4383
4864
|
// One line per finished turn in the bot's daily log (server/recent-work.ts):
|
|
4384
4865
|
// what it said last, the tools it used, whether the turn failed. What a bot
|
|
4385
4866
|
// did in one conversation was invisible from every other; the log is where
|
|
@@ -4550,12 +5031,7 @@ async function scheduleTurnDigest(input) {
|
|
|
4550
5031
|
const controller = new AbortController();
|
|
4551
5032
|
let timer;
|
|
4552
5033
|
const capture = async () => {
|
|
4553
|
-
|
|
4554
|
-
if (!after || controller.signal.aborted)
|
|
4555
|
-
return null;
|
|
4556
|
-
if (checkpoint.hash === after)
|
|
4557
|
-
return { changed: [], added: [], deleted: [] };
|
|
4558
|
-
return checkpoints.diffStat(input.botId, checkpoint.cwd, checkpoint.hash, after, controller.signal);
|
|
5034
|
+
return checkpoints.diffWorkingTree(input.botId, checkpoint.cwd, checkpoint.hash, controller.signal);
|
|
4559
5035
|
};
|
|
4560
5036
|
let files;
|
|
4561
5037
|
try {
|
|
@@ -4652,6 +5128,11 @@ let routines = null;
|
|
|
4652
5128
|
let calendarCalls = null;
|
|
4653
5129
|
const localVmOwnerBusy = (botId) => store.bot(botId)?.busy === true;
|
|
4654
5130
|
const localVmLeases = new LocalVmLeasePool(30 * 60_000);
|
|
5131
|
+
// Pool mode (issue #1654) keeps the lease pool as the ownership fence and
|
|
5132
|
+
// uses this table only to decide WHICH seat a conversation addresses: a
|
|
5133
|
+
// TTL-bounded affinity returns each thread to the desktop holding its login
|
|
5134
|
+
// state, and hands the seat back once the conversation has been idle.
|
|
5135
|
+
const localVmSeatPool = new LocalVmSeatPool(() => localVmMaxInstances(cfg));
|
|
4655
5136
|
const localVmLifecycleBusy = new Set();
|
|
4656
5137
|
const localVmThreadTargets = new Map();
|
|
4657
5138
|
const localVmActiveThreads = new Map();
|
|
@@ -4695,12 +5176,12 @@ function vpsThreadEnded(botId, threadId) {
|
|
|
4695
5176
|
if (!threads.size)
|
|
4696
5177
|
activeVpsThreads.delete(botId);
|
|
4697
5178
|
}
|
|
4698
|
-
const
|
|
5179
|
+
const boatLifecycleBusyBots = new Set();
|
|
4699
5180
|
// A refresh is a reader, not a lifecycle change. Keep its reservation until
|
|
4700
5181
|
// the provider settles even if the HTTP client leaves, and share it on retry.
|
|
4701
5182
|
const vpsPreviewRequests = new Map();
|
|
4702
|
-
const
|
|
4703
|
-
const
|
|
5183
|
+
const orphanBoatLifecycleBusyIds = new Set();
|
|
5184
|
+
const boatInventoryRequestsBusyIds = new Set();
|
|
4704
5185
|
const computerProviderConfigTransitions = new Set();
|
|
4705
5186
|
// A restore mutates and cleans a project work tree. Claim the bot across the
|
|
4706
5187
|
// entire async Git operation so a turn cannot start in that folder midway.
|
|
@@ -4715,7 +5196,7 @@ function inheritedTeamComputer(bot) {
|
|
|
4715
5196
|
return teamComputers.forBot(bot);
|
|
4716
5197
|
}
|
|
4717
5198
|
function teamComputerPrompt(computer) {
|
|
4718
|
-
return computer ? `Your team shares the
|
|
5199
|
+
return computer ? `Your team shares the Boat 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.` : "";
|
|
4719
5200
|
}
|
|
4720
5201
|
function botComputerControlKey(bot) {
|
|
4721
5202
|
const computer = inheritedTeamComputer(bot);
|
|
@@ -4767,6 +5248,10 @@ async function computerCallGate(internalCapability) {
|
|
|
4767
5248
|
};
|
|
4768
5249
|
}
|
|
4769
5250
|
const computer = turnComputerResources.get(internalCapability.threadId);
|
|
5251
|
+
// The screen call arriving here re-claims an idle-released seat directly
|
|
5252
|
+
// (#1653): inside the reclaim window the previous holder picks its seat
|
|
5253
|
+
// back up without re-entering the wait, and the successful claim
|
|
5254
|
+
// restarts the quiet window. A seat another turn holds still refuses.
|
|
4770
5255
|
if (!snapshot.held && computer && computer.owner.generation === internalCapability.generation &&
|
|
4771
5256
|
!claimTurnResource(computer.owner, computer.resource)) {
|
|
4772
5257
|
return {
|
|
@@ -4807,13 +5292,13 @@ function assertTeamComputerChangeIdle(before, after) {
|
|
|
4807
5292
|
if (previous?.id === next?.id)
|
|
4808
5293
|
return;
|
|
4809
5294
|
if (botHasActiveTurn(before.id) || routines?.activeRunForBot(before.id) || botComputerControlSnapshot(before.id).held ||
|
|
4810
|
-
[previous, next].some(computer => computer && (teamComputerInUse(computer) ||
|
|
5295
|
+
[previous, next].some(computer => computer && (teamComputerInUse(computer) || boatLifecycleBusyBots.has(teamComputerOwner(computer.id))))) {
|
|
4811
5296
|
throw Object.assign(new Error("Stop the affected team's work and release computer control before changing its computer access"), { status: 409 });
|
|
4812
5297
|
}
|
|
4813
5298
|
}
|
|
4814
5299
|
async function teamComputersPayload() {
|
|
4815
5300
|
const entries = teamComputers.list();
|
|
4816
|
-
const inventory = await
|
|
5301
|
+
const inventory = await boat.listManagedBoats(cfg, managedBoatOwners());
|
|
4817
5302
|
return {
|
|
4818
5303
|
configured: inventory.configured,
|
|
4819
5304
|
...(inventory.problem ? { problem: inventory.problem } : {}),
|
|
@@ -4822,42 +5307,42 @@ async function teamComputersPayload() {
|
|
|
4822
5307
|
return {
|
|
4823
5308
|
id: entry.id, name: entry.name, section: entry.section,
|
|
4824
5309
|
held: computerControl.snapshot(teamComputerOwner(entry.id)).held,
|
|
4825
|
-
state:
|
|
5310
|
+
state: boatLifecycleBusyBots.has(teamComputerOwner(entry.id)) ? "working" : machine?.state ?? (inventory.available ? "missing" : "unavailable"),
|
|
4826
5311
|
...(entry.problem || inventory.problem ? { problem: entry.problem || inventory.problem } : {}),
|
|
4827
5312
|
};
|
|
4828
5313
|
}),
|
|
4829
5314
|
};
|
|
4830
5315
|
}
|
|
4831
|
-
/** The same named
|
|
5316
|
+
/** The same named Boat identity and whole-turn lease in chats and rooms.
|
|
4832
5317
|
* Assignment authorizes waking, never replacing a missing paid machine. */
|
|
4833
|
-
async function
|
|
5318
|
+
async function attachTeamBoat(computer, botId, owner, canMount, remoteAgent) {
|
|
4834
5319
|
if (!canMount)
|
|
4835
|
-
throw new Error("This model engine cannot use the team's
|
|
4836
|
-
if (!
|
|
4837
|
-
throw new Error("The team's
|
|
5320
|
+
throw new Error("This model engine cannot use the team's Boat computer; choose an engine with computer tools or an explicit bot destination");
|
|
5321
|
+
if (!boat.boatConfigured(cfg))
|
|
5322
|
+
throw new Error("The team's Boat account is not configured; reconnect it in Settings");
|
|
4838
5323
|
const ownerId = teamComputerOwner(computer.id);
|
|
4839
|
-
if (
|
|
5324
|
+
if (boatLifecycleBusyBots.has(ownerId))
|
|
4840
5325
|
throw new Error("The team computer is being changed; wait for it to finish");
|
|
4841
5326
|
if (computerControl.snapshot(ownerId).held)
|
|
4842
5327
|
throw new Error("Release human control of the team computer before starting another turn");
|
|
4843
5328
|
await bindTurnComputer(owner, `computer:box-bot:${ownerId}`, true);
|
|
4844
5329
|
teamComputerTurns.set(owner.threadId, { owner, computerId: computer.id, botId, remoteAgent });
|
|
4845
|
-
let machine = await
|
|
5330
|
+
let machine = await boat.findBoat(cfg, ownerId);
|
|
4846
5331
|
if (!machine)
|
|
4847
|
-
throw new Error("The team's
|
|
5332
|
+
throw new Error("The team's Boat computer is missing; explicitly create or retry it from the Team map");
|
|
4848
5333
|
await bindTurnComputer(owner, `computer:box:${machine.id}`, true);
|
|
4849
|
-
const action =
|
|
5334
|
+
const action = boat.boatTurnLifecycleAction({ explicitCloud: true, canMount: true, state: typeof machine.state === "string" ? machine.state : null });
|
|
4850
5335
|
if (action === "wake")
|
|
4851
|
-
machine = await
|
|
4852
|
-
if (!machine ||
|
|
5336
|
+
machine = await boat.readyBoat(cfg, ownerId);
|
|
5337
|
+
if (!machine || boat.boatTurnLifecycleAction({ explicitCloud: true, canMount: true, state: typeof machine.state === "string" ? machine.state : null }) !== "attach") {
|
|
4853
5338
|
throw new Error("The team computer is not ready; check it in the Team map");
|
|
4854
5339
|
}
|
|
4855
5340
|
if (turnResourceOwners.get(owner.threadId)?.generation !== owner.generation ||
|
|
4856
5341
|
!turnResources.owns(`computer:box:${machine.id}`, owner))
|
|
4857
5342
|
throw new Error("This computer turn ended while its machine was starting");
|
|
4858
5343
|
return {
|
|
4859
|
-
integration: { kind: "box", boxId: machine.id, token: cfg
|
|
4860
|
-
capture: () =>
|
|
5344
|
+
integration: { kind: "box", boxId: machine.id, token: boat.boatAccount(cfg)?.token ?? "", control: controlIntegration(botId, owner.threadId, owner.generation) },
|
|
5345
|
+
capture: () => boat.screenshotBoat(cfg, ownerId, machine.id),
|
|
4861
5346
|
};
|
|
4862
5347
|
}
|
|
4863
5348
|
/** "Works on: This computer", strict. One mount for a bot thread and a group
|
|
@@ -4916,17 +5401,17 @@ async function mountBotVps(bot, owner, opts) {
|
|
|
4916
5401
|
},
|
|
4917
5402
|
};
|
|
4918
5403
|
}
|
|
4919
|
-
/** The bot's own
|
|
5404
|
+
/** The bot's own Boat. Explicit Cloud is the consent boundary: it may create a
|
|
4920
5405
|
* missing machine and wake an archived one (~8s, and it un-pauses billing);
|
|
4921
5406
|
* Auto only attaches a machine that is already running. */
|
|
4922
|
-
async function
|
|
4923
|
-
// Explicit cloud turns can provision/wake the same bot's
|
|
5407
|
+
async function attachBotBoat(bot, owner, opts) {
|
|
5408
|
+
// Explicit cloud turns can provision/wake the same bot's Boat. Claim before
|
|
4924
5409
|
// any network await so setup itself cannot race another turn.
|
|
4925
5410
|
if (opts.explicitCloud)
|
|
4926
5411
|
await bindTurnComputer(owner, `computer:box-bot:${bot.id}`, true);
|
|
4927
5412
|
let b;
|
|
4928
5413
|
try {
|
|
4929
|
-
b = await
|
|
5414
|
+
b = await boat.findBoat(cfg, bot.id);
|
|
4930
5415
|
}
|
|
4931
5416
|
catch (error) {
|
|
4932
5417
|
// Auto may fall through when an optional provider is offline, but a
|
|
@@ -4935,7 +5420,7 @@ async function attachBotBox(bot, owner, opts) {
|
|
|
4935
5420
|
throw error;
|
|
4936
5421
|
b = null;
|
|
4937
5422
|
}
|
|
4938
|
-
const lifecycleOf = (explicitCloud) =>
|
|
5423
|
+
const lifecycleOf = (explicitCloud) => boat.boatTurnLifecycleAction({
|
|
4939
5424
|
explicitCloud,
|
|
4940
5425
|
canMount: opts.canMount,
|
|
4941
5426
|
state: typeof b?.state === "string" ? b.state : null,
|
|
@@ -4943,16 +5428,16 @@ async function attachBotBox(bot, owner, opts) {
|
|
|
4943
5428
|
let lifecycle = lifecycleOf(opts.explicitCloud);
|
|
4944
5429
|
if (lifecycle === "provision") {
|
|
4945
5430
|
broadcast({ kind: "computer", botId: bot.id, state: "provisioning" });
|
|
4946
|
-
await
|
|
4947
|
-
b = await
|
|
5431
|
+
await boat.provisionBoat(cfg, bot.id, bot.name);
|
|
5432
|
+
b = await boat.findBoat(cfg, bot.id);
|
|
4948
5433
|
lifecycle = lifecycleOf(true);
|
|
4949
5434
|
}
|
|
4950
|
-
// an archived
|
|
5435
|
+
// an archived boat answers every action with an error until it resumes —
|
|
4951
5436
|
// wake it here, once, instead of letting the agent discover it one failed
|
|
4952
5437
|
// tool call at a time.
|
|
4953
5438
|
if (lifecycle === "wake") {
|
|
4954
5439
|
broadcast({ kind: "computer", botId: bot.id, state: "waking" });
|
|
4955
|
-
b = (await
|
|
5440
|
+
b = (await boat.readyBoat(cfg, bot.id)) ?? b;
|
|
4956
5441
|
lifecycle = lifecycleOf(true);
|
|
4957
5442
|
}
|
|
4958
5443
|
if (!b || lifecycle !== "attach")
|
|
@@ -4960,19 +5445,19 @@ async function attachBotBox(bot, owner, opts) {
|
|
|
4960
5445
|
const machine = b;
|
|
4961
5446
|
await bindTurnComputer(owner, `computer:box:${machine.id}`, opts.remoteAgent);
|
|
4962
5447
|
return {
|
|
4963
|
-
capture: () =>
|
|
5448
|
+
capture: () => boat.screenshotBoat(cfg, bot.id, machine.id),
|
|
4964
5449
|
integration: opts.canMount
|
|
4965
|
-
? { kind: "box", boxId: machine.id, token: cfg
|
|
5450
|
+
? { kind: "box", boxId: machine.id, token: boat.boatAccount(cfg)?.token ?? "", control: controlIntegration(bot.id, owner.threadId, owner.generation) }
|
|
4966
5451
|
: null,
|
|
4967
5452
|
};
|
|
4968
5453
|
}
|
|
4969
|
-
function
|
|
5454
|
+
function managedBoatOwners() {
|
|
4970
5455
|
return [...store.bots.map((bot) => ({
|
|
4971
5456
|
botId: bot.id,
|
|
4972
5457
|
name: bot.name,
|
|
4973
5458
|
// A machine is not safe to mutate while any app-level work or human
|
|
4974
5459
|
// control lease still names its owner. This is deliberately conservative
|
|
4975
|
-
// across destination changes: an old
|
|
5460
|
+
// across destination changes: an old Boat may still contain valuable state.
|
|
4976
5461
|
inUse: bot.busy === true ||
|
|
4977
5462
|
hasDirectDispatch(bot.id) ||
|
|
4978
5463
|
activeGroupTurnForBot(bot.id) !== null ||
|
|
@@ -4991,7 +5476,7 @@ function botHasActiveTurn(botId) {
|
|
|
4991
5476
|
}
|
|
4992
5477
|
function providerTransitionMessage(provider) {
|
|
4993
5478
|
return provider === "box"
|
|
4994
|
-
? "
|
|
5479
|
+
? "Boat account settings are being updated — wait for them to finish"
|
|
4995
5480
|
: "VPS connection settings are being updated — wait for them to finish";
|
|
4996
5481
|
}
|
|
4997
5482
|
/** Work which started first wins. This is intentionally conservative: a
|
|
@@ -5001,19 +5486,19 @@ function providerOperationConflict(provider) {
|
|
|
5001
5486
|
if (provider === "vps" && activeVpsThreads.size > 0) {
|
|
5002
5487
|
return "stop the active VPS turn before changing the SSH config alias";
|
|
5003
5488
|
}
|
|
5004
|
-
if (
|
|
5005
|
-
return `stop active bot work and computer control before changing ${provider === "box" ? "the
|
|
5489
|
+
if (managedBoatOwners().some((owner) => owner.inUse)) {
|
|
5490
|
+
return `stop active bot work and computer control before changing ${provider === "box" ? "the Boat account" : "the VPS connection"}`;
|
|
5006
5491
|
}
|
|
5007
|
-
if (
|
|
5492
|
+
if (boatLifecycleBusyBots.size > 0 || vpsPreviewRequests.size > 0) {
|
|
5008
5493
|
return "wait for cloud computer actions to finish before changing provider settings";
|
|
5009
5494
|
}
|
|
5010
5495
|
if (provider === "box") {
|
|
5011
|
-
if (
|
|
5012
|
-
return "wait for cloud computer actions to finish before changing the
|
|
5496
|
+
if (boatInventoryRequestsBusyIds.size > 0 || orphanBoatLifecycleBusyIds.size > 0) {
|
|
5497
|
+
return "wait for cloud computer actions to finish before changing the Boat account";
|
|
5013
5498
|
}
|
|
5014
|
-
const
|
|
5015
|
-
if (
|
|
5016
|
-
return "finish reconciling pending cloud computer creation before changing the
|
|
5499
|
+
const deletingBoatIds = new Set(boatDeletionSnapshot().map((entry) => entry.boxId));
|
|
5500
|
+
if (boatCreateRecoverySnapshot().some((entry) => !entry.resolved && (!entry.boxId || !deletingBoatIds.has(entry.boxId)))) {
|
|
5501
|
+
return "finish reconciling pending cloud computer creation before changing the Boat account";
|
|
5017
5502
|
}
|
|
5018
5503
|
}
|
|
5019
5504
|
else if (vps.vpsLifecycleBusy()) {
|
|
@@ -5023,10 +5508,10 @@ function providerOperationConflict(provider) {
|
|
|
5023
5508
|
}
|
|
5024
5509
|
function turnSurfacePlan(bot, runOn, threadId) {
|
|
5025
5510
|
const instance = registry.get(bot.modelSelection.instanceId);
|
|
5026
|
-
const
|
|
5511
|
+
const forcedBoat = runOn === "cloud" || Boolean(inheritedTeamComputer(bot));
|
|
5027
5512
|
return resolveSurface({
|
|
5028
|
-
destination:
|
|
5029
|
-
pinnedSurface:
|
|
5513
|
+
destination: forcedBoat ? "cloud" : bot.computer,
|
|
5514
|
+
pinnedSurface: forcedBoat || !threadId ? null : store.taskByThread(bot.id, threadId)?.surface,
|
|
5030
5515
|
browserOn: builtInBrowserEnabled(cfg) && bot.browser !== false && instance?.adapter.capabilities.browserMcp === true,
|
|
5031
5516
|
});
|
|
5032
5517
|
}
|
|
@@ -5040,14 +5525,14 @@ function turnProvider(bot, runOn, threadId) {
|
|
|
5040
5525
|
return "box";
|
|
5041
5526
|
return bot.cloudBackend === "vps" ? "vps" : wants === "cloud" ? "box" : null;
|
|
5042
5527
|
}
|
|
5043
|
-
/** A driver with a
|
|
5044
|
-
*
|
|
5528
|
+
/** A driver with a Boat bridge keeps the selected model. Other engines use
|
|
5529
|
+
* Boat's native runner. Start and interrupt must resolve the same owner. */
|
|
5045
5530
|
function turnInstance(bot, runOn, threadId) {
|
|
5046
5531
|
const selected = registry.get(bot.modelSelection.instanceId);
|
|
5047
5532
|
if (selected?.adapter.capabilities.cloudComputerMcp)
|
|
5048
5533
|
return selected;
|
|
5049
|
-
const
|
|
5050
|
-
return
|
|
5534
|
+
const onBoat = turnProvider(bot, runOn, threadId) === "box";
|
|
5535
|
+
return onBoat
|
|
5051
5536
|
? registry.instances().find((candidate) => candidate.driverKind === "boxAgent") ?? null
|
|
5052
5537
|
: registry.get(bot.modelSelection.instanceId);
|
|
5053
5538
|
}
|
|
@@ -5074,13 +5559,14 @@ async function computerPreviewSurface(bot, threadId) {
|
|
|
5074
5559
|
if (remote.ready)
|
|
5075
5560
|
return "cloud";
|
|
5076
5561
|
}
|
|
5077
|
-
const target =
|
|
5078
|
-
|
|
5562
|
+
const target = localVmTargetForStatus(bot.id, threadId);
|
|
5563
|
+
// A Cloud home has neither place (cloud-home.ts), so Auto never shows one.
|
|
5564
|
+
if (!CLOUD_HOME && instance?.adapter.capabilities.computerMcp && localVmSeen.has(target.key)) {
|
|
5079
5565
|
const vm = await containerComputerStatus(undefined, undefined, target).catch(() => null);
|
|
5080
5566
|
if (vm && autoLocalVmAttachable(vm))
|
|
5081
5567
|
return "vm";
|
|
5082
5568
|
}
|
|
5083
|
-
if (shouldMountLocalComputer({ requested: undefined, hostPlatform: process.platform,
|
|
5569
|
+
if (!CLOUD_HOME && shouldMountLocalComputer({ requested: undefined, hostPlatform: process.platform,
|
|
5084
5570
|
providerSupportsLocal: instance?.adapter.capabilities.localComputerMcp === true }) && readCuaConnection())
|
|
5085
5571
|
return "local";
|
|
5086
5572
|
if (bot.cloudBackend === "vps")
|
|
@@ -5088,16 +5574,23 @@ async function computerPreviewSurface(bot, threadId) {
|
|
|
5088
5574
|
return plan.browser ? "browser" : "off";
|
|
5089
5575
|
}
|
|
5090
5576
|
/** Discovery is read-only. Starting or creating a configured computer is
|
|
5091
|
-
* deferred until a chat tool selects it and the old turn releases its tools.
|
|
5577
|
+
* deferred until a chat tool selects it and the old turn releases its tools.
|
|
5578
|
+
* A Cloud home lists only the places it has (cloud-home.ts), so a bot never
|
|
5579
|
+
* tells the person to set up this computer or a Local VM there. */
|
|
5092
5580
|
async function selectableComputers(bot) {
|
|
5093
5581
|
const caps = registry.get(bot.modelSelection.instanceId)?.adapter.capabilities;
|
|
5094
5582
|
const off = bot.computer === "off";
|
|
5095
5583
|
const localEngine = caps?.remoteAgent !== true;
|
|
5096
|
-
|
|
5584
|
+
const surfaces = ["cloud", "vm", "local", "browser"].filter(surface => !CLOUD_HOME || cloudHomeOffersPlace(surface));
|
|
5585
|
+
return Promise.all(surfaces.map(async (surface) => {
|
|
5097
5586
|
let ready = false;
|
|
5098
5587
|
let canStart = false;
|
|
5099
5588
|
let canCreate = false;
|
|
5100
|
-
|
|
5589
|
+
// On a Cloud home "this computer" reads as the person's own; name the
|
|
5590
|
+
// place the reason is about.
|
|
5591
|
+
let reason = CLOUD_HOME && surface === "cloud"
|
|
5592
|
+
? "The cloud computer is not set up or running. Open the Computer panel to set it up."
|
|
5593
|
+
: "This computer is not configured or running. Open the Computer panel to set it up.";
|
|
5101
5594
|
try {
|
|
5102
5595
|
if (off)
|
|
5103
5596
|
reason = "Computer access is Off in this bot's settings.";
|
|
@@ -5110,16 +5603,16 @@ async function selectableComputers(bot) {
|
|
|
5110
5603
|
canCreate = Boolean(status?.configured && status.daemonUp && status.container === "missing");
|
|
5111
5604
|
reason = status?.problem ?? reason;
|
|
5112
5605
|
}
|
|
5113
|
-
else if (
|
|
5114
|
-
const status = await
|
|
5115
|
-
const lifecycle =
|
|
5606
|
+
else if (boat.boatConfigured(cfg) && (caps?.usesCloudComputer === true || registry.instances().some(instance => instance.driverKind === "boxAgent"))) {
|
|
5607
|
+
const status = await boat.boatStatus(cfg, bot.id);
|
|
5608
|
+
const lifecycle = boat.boatTurnLifecycleAction({ explicitCloud: true, canMount: true, state: status.box?.state ?? null });
|
|
5116
5609
|
ready = lifecycle === "attach";
|
|
5117
5610
|
canStart = lifecycle === "wake";
|
|
5118
5611
|
canCreate = lifecycle === "provision";
|
|
5119
5612
|
}
|
|
5120
5613
|
}
|
|
5121
5614
|
else if (surface === "vm" && localEngine && caps?.computerMcp) {
|
|
5122
|
-
const target =
|
|
5615
|
+
const target = localVmTargetForStatus(bot.id);
|
|
5123
5616
|
const status = await containerComputerStatus(undefined, undefined, target);
|
|
5124
5617
|
ready = status.ready;
|
|
5125
5618
|
canCreate = !ready && autoLocalVmAttachable(status);
|
|
@@ -5198,29 +5691,29 @@ function providerTransitionForTurn(bot, runOn, threadId) {
|
|
|
5198
5691
|
? providerTransitionMessage(provider)
|
|
5199
5692
|
: null;
|
|
5200
5693
|
}
|
|
5201
|
-
function
|
|
5202
|
-
if (
|
|
5694
|
+
function claimBoatInventoryRequest(boxId) {
|
|
5695
|
+
if (boatInventoryRequestsBusyIds.has(boxId)) {
|
|
5203
5696
|
throw Object.assign(new Error("this cloud computer is being changed — wait for it to finish"), { status: 409 });
|
|
5204
5697
|
}
|
|
5205
|
-
|
|
5206
|
-
return () =>
|
|
5698
|
+
boatInventoryRequestsBusyIds.add(boxId);
|
|
5699
|
+
return () => boatInventoryRequestsBusyIds.delete(boxId);
|
|
5207
5700
|
}
|
|
5208
|
-
/** Claim the owning bot synchronously after
|
|
5701
|
+
/** Claim the owning bot synchronously after Boat revalidation and before the
|
|
5209
5702
|
* provider mutation. startTurn checks the same set before doing any work, so
|
|
5210
5703
|
* a new turn and an irreversible lifecycle action cannot pass each other. */
|
|
5211
|
-
function
|
|
5704
|
+
function claimManagedBoatMutation(instance) {
|
|
5212
5705
|
const ownerBotId = instance.ownerBotId;
|
|
5213
5706
|
const teamComputer = teamComputers.list().find(computer => teamComputerOwner(computer.id) === ownerBotId);
|
|
5214
5707
|
if (teamComputer)
|
|
5215
5708
|
return claimTeamComputerLifecycle(teamComputer);
|
|
5216
5709
|
if (!ownerBotId) {
|
|
5217
|
-
if (
|
|
5710
|
+
if (orphanBoatLifecycleBusyIds.has(instance.boxId)) {
|
|
5218
5711
|
throw Object.assign(new Error("this cloud computer is being changed — wait for it to finish"), { status: 409 });
|
|
5219
5712
|
}
|
|
5220
|
-
|
|
5221
|
-
return () =>
|
|
5713
|
+
orphanBoatLifecycleBusyIds.add(instance.boxId);
|
|
5714
|
+
return () => orphanBoatLifecycleBusyIds.delete(instance.boxId);
|
|
5222
5715
|
}
|
|
5223
|
-
const owner =
|
|
5716
|
+
const owner = managedBoatOwners().find((candidate) => candidate.botId === ownerBotId);
|
|
5224
5717
|
if (owner?.inUse) {
|
|
5225
5718
|
throw Object.assign(new Error("this cloud computer is in use — stop its bot's work first"), { status: 409 });
|
|
5226
5719
|
}
|
|
@@ -5231,20 +5724,20 @@ function claimManagedBoxMutation(instance) {
|
|
|
5231
5724
|
* other instead of relying on a stale check made before a provider await.
|
|
5232
5725
|
* Only opening an existing VPS viewer may coexist with its pending preview. */
|
|
5233
5726
|
function claimBotComputerLifecycle(botId, allowPreview = false) {
|
|
5234
|
-
if (
|
|
5727
|
+
if (boatLifecycleBusyBots.has(botId)) {
|
|
5235
5728
|
throw Object.assign(new Error("this bot's cloud computer is being changed — wait for it to finish"), { status: 409 });
|
|
5236
5729
|
}
|
|
5237
5730
|
if (!allowPreview && vpsPreviewRequests.has(botId)) {
|
|
5238
5731
|
throw Object.assign(new Error("a screen preview is still refreshing — wait before changing this computer"), { status: 409 });
|
|
5239
5732
|
}
|
|
5240
|
-
|
|
5241
|
-
return () =>
|
|
5733
|
+
boatLifecycleBusyBots.add(botId);
|
|
5734
|
+
return () => boatLifecycleBusyBots.delete(botId);
|
|
5242
5735
|
}
|
|
5243
5736
|
function claimManagedVpsMutation(containerName) {
|
|
5244
5737
|
const owner = store.bots.find((candidate) => vps.vpsContainerName(candidate.id) === containerName);
|
|
5245
5738
|
if (!owner)
|
|
5246
5739
|
return () => { };
|
|
5247
|
-
const ownerState =
|
|
5740
|
+
const ownerState = managedBoatOwners().find((candidate) => candidate.botId === owner.id);
|
|
5248
5741
|
if (ownerState?.inUse) {
|
|
5249
5742
|
throw Object.assign(new Error("this VPS computer is in use — stop its bot's work first"), { status: 409 });
|
|
5250
5743
|
}
|
|
@@ -5253,6 +5746,30 @@ function claimManagedVpsMutation(containerName) {
|
|
|
5253
5746
|
function localVmTargetForBot(botId) {
|
|
5254
5747
|
return localVmMode(cfg) === "per-bot" ? perBotLocalVmTarget(botId) : SHARED_LOCAL_VM_TARGET;
|
|
5255
5748
|
}
|
|
5749
|
+
function localVmPoolSeatHolder(seat) {
|
|
5750
|
+
return localVmLeases.forTarget(poolLocalVmTarget(seat).key).current(localVmOwnerBusy);
|
|
5751
|
+
}
|
|
5752
|
+
/** The Local VM a conversation's turn addresses. In pool mode each thread
|
|
5753
|
+
* draws a seat with TTL-bounded affinity so it reuses the desktop that
|
|
5754
|
+
* holds its login state; other modes are unchanged. */
|
|
5755
|
+
function localVmTargetForThread(botId, threadId) {
|
|
5756
|
+
if (localVmMode(cfg) !== "pool")
|
|
5757
|
+
return localVmTargetForBot(botId);
|
|
5758
|
+
return poolLocalVmTarget(localVmSeatPool.assign(threadId, localVmPoolSeatHolder));
|
|
5759
|
+
}
|
|
5760
|
+
/** Best-effort seat for read-only surfaces (Computer panel previews,
|
|
5761
|
+
* screenshots, status payloads). Records no affinity: a status probe must
|
|
5762
|
+
* not steer a later turn onto a desktop its claim would not choose. */
|
|
5763
|
+
function localVmTargetForStatus(botId, threadId) {
|
|
5764
|
+
if (localVmMode(cfg) !== "pool")
|
|
5765
|
+
return localVmTargetForBot(botId);
|
|
5766
|
+
// An in-flight turn keeps its claimed seat even after its affinity TTL
|
|
5767
|
+
// decays: prefer the live target so a preview cannot land on another seat.
|
|
5768
|
+
const activeTarget = threadId ? localVmThreadTargets.get(threadId) : undefined;
|
|
5769
|
+
if (activeTarget)
|
|
5770
|
+
return activeTarget;
|
|
5771
|
+
return poolLocalVmTarget((threadId ? localVmSeatPool.affinitySeat(threadId) : null) ?? 0);
|
|
5772
|
+
}
|
|
5256
5773
|
function localVmLeaseFor(target) {
|
|
5257
5774
|
return localVmLeases.forTarget(target.key);
|
|
5258
5775
|
}
|
|
@@ -5286,6 +5803,19 @@ function releaseLocalVmThread(threadId) {
|
|
|
5286
5803
|
const target = localVmThreadTargets.get(threadId);
|
|
5287
5804
|
if (!target)
|
|
5288
5805
|
return;
|
|
5806
|
+
// Renew affinity at settlement so a turn that consumed part of the TTL
|
|
5807
|
+
// still leaves the full window after it ends; failed claims never reach
|
|
5808
|
+
// here (no target), so only settled turns renew. A turn longer than the
|
|
5809
|
+
// TTL finds its entry expired — touch() alone would drop it — so renew
|
|
5810
|
+
// with the seat the turn actually ran on. A target recorded before a
|
|
5811
|
+
// mid-turn mode change is not a pool seat; touch still covers that.
|
|
5812
|
+
if (localVmMode(cfg) === "pool") {
|
|
5813
|
+
const pooled = /^pool:(\d+)$/.exec(target.key);
|
|
5814
|
+
if (pooled)
|
|
5815
|
+
localVmSeatPool.renew(threadId, Number(pooled[1]));
|
|
5816
|
+
else
|
|
5817
|
+
localVmSeatPool.touch(threadId);
|
|
5818
|
+
}
|
|
5289
5819
|
localVmLeaseFor(target).release(threadId);
|
|
5290
5820
|
if (localVmActiveThreads.get(target.key) === threadId)
|
|
5291
5821
|
localVmActiveThreads.delete(target.key);
|
|
@@ -5296,6 +5826,27 @@ function releaseLocalVmThread(threadId) {
|
|
|
5296
5826
|
// bot's current destination is intentionally ignored: moving a bot to Cloud,
|
|
5297
5827
|
// Browser, This computer, Auto, or Off does not delete its old Local VM.
|
|
5298
5828
|
void (async () => {
|
|
5829
|
+
if (localVmMode(cfg) === "pool") {
|
|
5830
|
+
// Same restore rule as per-bot: idle cleanup removes the container, not
|
|
5831
|
+
// its provisioned workspace, so every surviving seat stays Auto-eligible.
|
|
5832
|
+
const seats = localVmMaxInstances(cfg);
|
|
5833
|
+
for (let seat = 0; seat < seats; seat += 1) {
|
|
5834
|
+
const target = poolLocalVmTarget(seat);
|
|
5835
|
+
if (localVmWorkspaceExists(target))
|
|
5836
|
+
localVmSeen.add(target.key);
|
|
5837
|
+
}
|
|
5838
|
+
const runtime = await containerRuntimeStatus().catch(() => null);
|
|
5839
|
+
if (!runtime?.runtime || !runtime.daemonUp)
|
|
5840
|
+
return;
|
|
5841
|
+
const existing = await discoverExistingPoolLocalVms(seats, runtime.runtime).catch(() => []);
|
|
5842
|
+
const statuses = await Promise.all(existing.map((target) => containerComputerStatus(undefined, undefined, target).catch(() => null)));
|
|
5843
|
+
existing.forEach((target, index) => {
|
|
5844
|
+
noteLocalVmSeen(target, statuses[index]);
|
|
5845
|
+
if (shouldArmLocalVmIdle(statuses[index]))
|
|
5846
|
+
localVmIdleFor(target).touch();
|
|
5847
|
+
});
|
|
5848
|
+
return;
|
|
5849
|
+
}
|
|
5299
5850
|
if (localVmMode(cfg) !== "per-bot") {
|
|
5300
5851
|
const status = await containerComputerStatus(undefined, undefined, SHARED_LOCAL_VM_TARGET).catch(() => null);
|
|
5301
5852
|
noteLocalVmSeen(SHARED_LOCAL_VM_TARGET, status);
|
|
@@ -5425,7 +5976,12 @@ bus.subscribe((event) => {
|
|
|
5425
5976
|
const owner = bot ? directRequestOwners.get(event.threadId) : undefined;
|
|
5426
5977
|
const proven = owner?.messageId && !owner.stopped && owner.generation === directTurnGenerationByThread.get(event.threadId) &&
|
|
5427
5978
|
owner.turnId && (!event.turnId || event.turnId === owner.turnId);
|
|
5428
|
-
|
|
5979
|
+
// One line per routed reply: the routed speaker's first text of its turn.
|
|
5980
|
+
const routed = group && m.role === "bot" && m.kind === "text" ? routedRoomReplies.get(event.threadId) : undefined;
|
|
5981
|
+
const routedBy = routed && speaker && routed.botId === speaker.botId ? routed.routedBy : undefined;
|
|
5982
|
+
if (routedBy)
|
|
5983
|
+
routedRoomReplies.delete(event.threadId);
|
|
5984
|
+
const message = store.appendMessage(event.threadId, group && m.role === "bot" ? { ...m, from: speaker, ...(routedBy ? { routedBy } : {}) }
|
|
5429
5985
|
: proven ? { ...m, requestMessageId: owner.messageId } : m);
|
|
5430
5986
|
return message;
|
|
5431
5987
|
};
|
|
@@ -5497,15 +6053,33 @@ bus.subscribe((event) => {
|
|
|
5497
6053
|
}
|
|
5498
6054
|
// the bot just acted ON ITS SCREEN — refresh the preview now. Only
|
|
5499
6055
|
// computer tools can change the screen, and each capture competes
|
|
5500
|
-
// with the agent for the
|
|
6056
|
+
// with the agent for the boat's command endpoint, so a bot grinding
|
|
5501
6057
|
// through file edits must not trigger one per tool. The refresh is
|
|
5502
6058
|
// deliberately broad (a computer_exec may well have launched a
|
|
5503
6059
|
// window); whether the turn has EARNED a settled screenshot is the
|
|
5504
6060
|
// narrower question, and only the allow-list answers it.
|
|
5505
6061
|
if (bot) {
|
|
5506
6062
|
const touches = screenTouchingTool(toolName);
|
|
6063
|
+
const surface = screenSurfaceForTool(toolName);
|
|
5507
6064
|
if (touches || /computer|screenshot|click|type_text|press_key|scroll|open_url|wait_for|browser_/i.test(toolName)) {
|
|
5508
|
-
pokeScreenPoller(event.threadId, touches,
|
|
6065
|
+
pokeScreenPoller(event.threadId, touches, surface);
|
|
6066
|
+
}
|
|
6067
|
+
// A completed screen-touching computer tool is real screen
|
|
6068
|
+
// activity (#1653): it restarts the idle clock on that turn's
|
|
6069
|
+
// computer claim. The poller's own frames never arrive here, so
|
|
6070
|
+
// they cannot keep a quiet seat held.
|
|
6071
|
+
if (touches && surface === "computer") {
|
|
6072
|
+
const computer = turnComputerResources.get(event.threadId);
|
|
6073
|
+
if (computer)
|
|
6074
|
+
turnResources.activity(computer.resource, computer.owner);
|
|
6075
|
+
// Real cloud-screen work refreshes the #1655 idle stop for
|
|
6076
|
+
// that seat; the poller's own frames never arrive here, so
|
|
6077
|
+
// preview traffic cannot keep a paid machine awake.
|
|
6078
|
+
if (computer?.resource.startsWith("computer:box:")) {
|
|
6079
|
+
const seatBot = store.botByThread(event.threadId);
|
|
6080
|
+
if (seatBot)
|
|
6081
|
+
cloudSeatLeases.get(seatBot.id)?.touch();
|
|
6082
|
+
}
|
|
5509
6083
|
}
|
|
5510
6084
|
}
|
|
5511
6085
|
}
|
|
@@ -6718,6 +7292,9 @@ function drainQueuedSends() {
|
|
|
6718
7292
|
},
|
|
6719
7293
|
});
|
|
6720
7294
|
resolve();
|
|
7295
|
+
// M2: only this group left the queue, and a turn that never
|
|
7296
|
+
// started publishes no completion to wake the groups behind it.
|
|
7297
|
+
queueMicrotask(drainQueuedSends);
|
|
6721
7298
|
}).catch(reject);
|
|
6722
7299
|
}),
|
|
6723
7300
|
// Provider completion can precede its dispatch promise: keep the queue
|
|
@@ -6847,7 +7424,7 @@ async function startOrQueueOpenedThread(botId, threadId, text, unattended) {
|
|
|
6847
7424
|
}
|
|
6848
7425
|
}
|
|
6849
7426
|
const screenPollers = new Map();
|
|
6850
|
-
/** The preview shares the
|
|
7427
|
+
/** The preview shares the boat's single command endpoint with the agent's
|
|
6851
7428
|
* own actions, so every frame we take is latency stolen from the work the
|
|
6852
7429
|
* user is waiting on. Hence: a slow interval, a floor between captures,
|
|
6853
7430
|
* and never two in flight. */
|
|
@@ -6855,7 +7432,7 @@ const SCREEN_POLL_MS = 6000;
|
|
|
6855
7432
|
const SCREEN_MIN_GAP_MS = 3000;
|
|
6856
7433
|
const SCREEN_SETTLE_TIMEOUT_MS = 10_000;
|
|
6857
7434
|
/** `screenIsTheWork` starts the turn already counting as screen usage: a
|
|
6858
|
-
* boxAgent's whole session runs ON the
|
|
7435
|
+
* boxAgent's whole session runs ON the boat, so every tool it calls acts on
|
|
6859
7436
|
* that screen even though none of them is named like a computer tool. Its
|
|
6860
7437
|
* shell-only turns are kept honest by the settle-time hash gate instead. */
|
|
6861
7438
|
function startScreenPoller(botId, threadId, captures, { screenIsTheWork = false } = {}) {
|
|
@@ -6948,7 +7525,7 @@ function shownScreenHash(threadId) {
|
|
|
6948
7525
|
* in-flight poke first) so the settled screenshot shows the screen's actual
|
|
6949
7526
|
* end state, not the previous action's. A turn that never touched the
|
|
6950
7527
|
* screen settles nothing — and skips the capture, which is one less
|
|
6951
|
-
* command on the
|
|
7528
|
+
* command on the boat's single endpoint. A frame the reader can already see
|
|
6952
7529
|
* settles nothing either: the boxAgent pre-touch counts every turn as
|
|
6953
7530
|
* screen work, so without this its shell-only replies would all end in the
|
|
6954
7531
|
* same idle desktop. Either way the poller is torn down here, so no
|
|
@@ -7051,7 +7628,7 @@ async function startTurn(botId, text, opts) {
|
|
|
7051
7628
|
status: 409,
|
|
7052
7629
|
});
|
|
7053
7630
|
}
|
|
7054
|
-
if (
|
|
7631
|
+
if (boatLifecycleBusyBots.has(botId)) {
|
|
7055
7632
|
throw Object.assign(new Error("this bot's cloud computer is being changed — wait for it to finish"), { status: 409 });
|
|
7056
7633
|
}
|
|
7057
7634
|
if (threadBusy(botId, threadId))
|
|
@@ -7088,7 +7665,7 @@ async function startTurn(botId, text, opts) {
|
|
|
7088
7665
|
const instance = turnInstance(bot, opts?.runOn, threadId);
|
|
7089
7666
|
if (!instance) {
|
|
7090
7667
|
throw Object.assign(new Error(turnProvider(bot, opts?.runOn, threadId) === "box"
|
|
7091
|
-
? "the Cloud VM runner is unavailable — configure
|
|
7668
|
+
? "the Cloud VM runner is unavailable — configure Boat in App Settings"
|
|
7092
7669
|
: `provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`), { status: 409 });
|
|
7093
7670
|
}
|
|
7094
7671
|
const policyRefusal = policyModelRefusal(instance);
|
|
@@ -7137,7 +7714,7 @@ async function startTurn(botId, text, opts) {
|
|
|
7137
7714
|
const switchedEngine = instance.instanceId !== bot.modelSelection.instanceId;
|
|
7138
7715
|
const useInstanceDefaults = switchedEngine || (opts?.runOn === "cloud" && !instance.adapter.capabilities.cloudComputerMcp);
|
|
7139
7716
|
const model = useInstanceDefaults ? instance.models.default : bot.modelSelection.model;
|
|
7140
|
-
// A native cloud runner borrows its instance defaults; a
|
|
7717
|
+
// A native cloud runner borrows its instance defaults; a Boat bridge keeps
|
|
7141
7718
|
// the bot's selected model, effort and variant.
|
|
7142
7719
|
const effort = useInstanceDefaults ? undefined : bot.modelSelection.effort;
|
|
7143
7720
|
const variant = useInstanceDefaults ? undefined : bot.modelSelection.variant;
|
|
@@ -7150,7 +7727,7 @@ async function startTurn(botId, text, opts) {
|
|
|
7150
7727
|
// an edit hands us its already-branched user message; a plain send appends
|
|
7151
7728
|
let userMessage = opts?.userMessage;
|
|
7152
7729
|
if (opts?.editedMessageId) {
|
|
7153
|
-
const edited = store.branchMessage(threadId, opts.editedMessageId, text, opts.sendId);
|
|
7730
|
+
const edited = store.branchMessage(threadId, opts.editedMessageId, text, opts.sendId, opts.sender);
|
|
7154
7731
|
if (!edited)
|
|
7155
7732
|
throw Object.assign(new Error("only a user text message can be edited"), { status: 400 });
|
|
7156
7733
|
store.patchTask(bot.id, threadId, { rewound: true });
|
|
@@ -7192,7 +7769,7 @@ async function startTurn(botId, text, opts) {
|
|
|
7192
7769
|
}
|
|
7193
7770
|
const agentsMounted = (commsDepth < MAX_COMMS_DEPTH || Boolean(opts?.coordination)) && instance.adapter.capabilities.agentsMcp === true;
|
|
7194
7771
|
// busy flips immediately so the composer locks; the dispatch itself runs
|
|
7195
|
-
// in the background —
|
|
7772
|
+
// in the background — boat provisioning can take ~90s and must never
|
|
7196
7773
|
// hang the HTTP request
|
|
7197
7774
|
const dispatchClaimId = randomUUID();
|
|
7198
7775
|
const resourceOwner = { threadId, generation: dispatchClaimId };
|
|
@@ -7208,7 +7785,7 @@ async function startTurn(botId, text, opts) {
|
|
|
7208
7785
|
}
|
|
7209
7786
|
requestGenerations.add(dispatchClaimId);
|
|
7210
7787
|
directRequestOwners.set(threadId, { generation: dispatchClaimId, messageId: requestMessageId,
|
|
7211
|
-
generations: requestGenerations, turnId: null });
|
|
7788
|
+
generations: requestGenerations, turnId: null, ...(opts?.automationSource ? { automation: opts.automationSource } : {}) });
|
|
7212
7789
|
// An unknown control-plane wake may invalidate a prior final, but it must
|
|
7213
7790
|
// never acquire authority by guessing the latest user as its origin.
|
|
7214
7791
|
const pendingSource = requestMessageId ?? store.activePath(threadId).findLast(message => message.role === "user")?.id;
|
|
@@ -7460,7 +8037,7 @@ async function startTurn(botId, text, opts) {
|
|
|
7460
8037
|
// the private bot workspace. A legacy task with an existing provider
|
|
7461
8038
|
// session deliberately pins to null (the old home-folder behavior),
|
|
7462
8039
|
// because moving a live session would break resume.
|
|
7463
|
-
// A cloud run happens on the
|
|
8040
|
+
// A cloud run happens on the boat, where a host folder means nothing:
|
|
7464
8041
|
// pin the task to the default so the header chip never shows the
|
|
7465
8042
|
// bot's folder for a task that runs elsewhere.
|
|
7466
8043
|
if (opts?.runOn === "cloud")
|
|
@@ -7482,7 +8059,7 @@ async function startTurn(botId, text, opts) {
|
|
|
7482
8059
|
const dwebUrl = process.env.DWEB_URL?.trim();
|
|
7483
8060
|
if (dwebUrl)
|
|
7484
8061
|
integrations.dweb = { url: dwebUrl };
|
|
7485
|
-
// Cloud routines always use
|
|
8062
|
+
// Cloud routines always use Boat/BoatAgent. The per-bot backend applies
|
|
7486
8063
|
// only to ordinary turns that mount a computer into the local agent.
|
|
7487
8064
|
const teamComputer = inheritedTeamComputer(bot);
|
|
7488
8065
|
const cloudBackend = teamComputer || opts?.runOn === "cloud" || bot.cloudBackend !== "vps" ? "box" : "vps";
|
|
@@ -7504,13 +8081,13 @@ async function startTurn(botId, text, opts) {
|
|
|
7504
8081
|
throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
|
|
7505
8082
|
}
|
|
7506
8083
|
const wants = plan.computer;
|
|
7507
|
-
// A place the organisation disallows
|
|
7508
|
-
// prepared; Auto below simply skips
|
|
8084
|
+
// A place the organisation disallows, or a Cloud home never offers, is
|
|
8085
|
+
// refused before anything is prepared; Auto below simply skips them.
|
|
7509
8086
|
const wantedKind = teamComputer ? "box" : wants === "local" ? "thisComputer" : wants === "vm" ? "localVm"
|
|
7510
8087
|
: wants === "cloud" ? (cloudBackend === "vps" ? "vps" : "box") : undefined;
|
|
7511
|
-
const placeRefusal = wantedKind &&
|
|
8088
|
+
const placeRefusal = wantedKind && computerPlaceRefusal(wantedKind);
|
|
7512
8089
|
if (placeRefusal)
|
|
7513
|
-
throw Object.assign(new Error(placeRefusal), { status: 409, code:
|
|
8090
|
+
throw Object.assign(new Error(placeRefusal.message), { status: 409, code: placeRefusal.code });
|
|
7514
8091
|
let previewCapture = null;
|
|
7515
8092
|
let browserCapture = null;
|
|
7516
8093
|
let computerKind = null;
|
|
@@ -7528,6 +8105,25 @@ async function startTurn(botId, text, opts) {
|
|
|
7528
8105
|
}
|
|
7529
8106
|
return containerComputerFrame(undefined, undefined, localVmTarget);
|
|
7530
8107
|
};
|
|
8108
|
+
/** Pin the conversation at the moment its seat is actually claimed
|
|
8109
|
+
* (issue #1650): the same guards as the dispatch-time pin below, so
|
|
8110
|
+
* a claim landing later in the turn records exactly what a mount-time
|
|
8111
|
+
* pin would have — and a turn that never claims records nothing.
|
|
8112
|
+
* The guard reads live bot and task state because a claim can land
|
|
8113
|
+
* long after dispatch: a Works on change may have swept this
|
|
8114
|
+
* thread's auto pins, or a person may have pinned it meanwhile, and
|
|
8115
|
+
* a deferred claim must resurrect neither nor clobber either. */
|
|
8116
|
+
const autoPinAllowed = () => {
|
|
8117
|
+
const liveBot = store.bot(bot.id);
|
|
8118
|
+
const liveTask = store.taskByThread(bot.id, threadId);
|
|
8119
|
+
return liveBot?.computer === undefined && !teamComputer && opts?.runOn !== "cloud" && !plan.pinned &&
|
|
8120
|
+
Boolean(liveTask && liveTask.surfaceSource !== "user");
|
|
8121
|
+
};
|
|
8122
|
+
const pinAutoSurface = (surface) => {
|
|
8123
|
+
if (autoPinAllowed()) {
|
|
8124
|
+
store.patchTask(bot.id, threadId, { surface, surfaceSource: "auto" });
|
|
8125
|
+
}
|
|
8126
|
+
};
|
|
7531
8127
|
/** The exclusive Local VM claim sequence, verbatim from the old inline
|
|
7532
8128
|
* attach path, shared by dispatch (eager) and the first-screen-call
|
|
7533
8129
|
* gate (issue #1361). Idempotent per turn: the resource claim and the
|
|
@@ -7537,7 +8133,7 @@ async function startTurn(botId, text, opts) {
|
|
|
7537
8133
|
* desktop, not whatever localVmTargetForBot resolves to by the time
|
|
7538
8134
|
* the first screen call arrives. */
|
|
7539
8135
|
const claimAutoLocalVm = async (claimThreadId, pinnedTarget) => {
|
|
7540
|
-
const localVmTarget = pinnedTarget ??
|
|
8136
|
+
const localVmTarget = pinnedTarget ?? localVmTargetForThread(bot.id, claimThreadId);
|
|
7541
8137
|
await bindTurnComputer(resourceOwner, `computer:vm:${localVmTarget.key}`, true);
|
|
7542
8138
|
if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) {
|
|
7543
8139
|
throw new Error("this Local VM is being started, stopped, or replaced — wait for setup to finish");
|
|
@@ -7548,6 +8144,10 @@ async function startTurn(botId, text, opts) {
|
|
|
7548
8144
|
if (!localVmLeaseFor(localVmTarget).claim(claimThreadId, bot.id, localVmOwnerBusy)) {
|
|
7549
8145
|
throw new Error("this Local VM is already being used by another turn — wait for that turn to finish");
|
|
7550
8146
|
}
|
|
8147
|
+
// Only a turn that wins its lease extends pool affinity; a thread
|
|
8148
|
+
// retrying behind a stranger lets the TTL lapse and migrates.
|
|
8149
|
+
if (localVmMode(cfg) === "pool")
|
|
8150
|
+
localVmSeatPool.touch(claimThreadId);
|
|
7551
8151
|
localVmThreadTargets.set(claimThreadId, localVmTarget);
|
|
7552
8152
|
localVmActiveThreads.set(localVmTarget.key, claimThreadId);
|
|
7553
8153
|
localVmIdleFor(localVmTarget).touch();
|
|
@@ -7589,12 +8189,17 @@ async function startTurn(botId, text, opts) {
|
|
|
7589
8189
|
dropLease();
|
|
7590
8190
|
throw new Error("the Local VM lease expired while preparing the turn");
|
|
7591
8191
|
}
|
|
7592
|
-
// Same contract as the
|
|
8192
|
+
// Same contract as the Boat and VPS branches below: without this the
|
|
7593
8193
|
// poller never starts, so the Local VM publishes no `screen` events
|
|
7594
8194
|
// and every client that only has the stream (the phone) waits
|
|
7595
8195
|
// forever. The web panel hid the gap by polling the screenshot
|
|
7596
8196
|
// route itself.
|
|
7597
8197
|
previewCapture = localVmPreviewFor(localVmTarget, claimThreadId);
|
|
8198
|
+
// Pin on use: this runs for the dispatch-time claim of a VM created
|
|
8199
|
+
// for the turn and for the gate-fired claim of a lazily mounted one
|
|
8200
|
+
// alike — either way the conversation remembers the desktop this
|
|
8201
|
+
// turn actually took, not one it merely mounted (issue #1650).
|
|
8202
|
+
pinAutoSurface("vm");
|
|
7598
8203
|
return { target: localVmTarget, runtime: localVm.runtime };
|
|
7599
8204
|
};
|
|
7600
8205
|
// Issue #1369: a lazy claim that rejects must not leave its turn busy
|
|
@@ -7636,7 +8241,16 @@ async function startTurn(botId, text, opts) {
|
|
|
7636
8241
|
return false;
|
|
7637
8242
|
throw new Error("this model engine cannot use the Local VM — choose Claude or an ACP engine, or select another computer destination");
|
|
7638
8243
|
}
|
|
7639
|
-
|
|
8244
|
+
if (!strict && localVmMode(cfg) === "pool") {
|
|
8245
|
+
// localVmTargetForThread records pool affinity, so a turn the
|
|
8246
|
+
// fast path is about to skip must not reserve or refresh a seat.
|
|
8247
|
+
// Gate on the side-effect-free candidate assign() would pick —
|
|
8248
|
+
// the automationSource bypass below stays authoritative.
|
|
8249
|
+
const poolCandidate = poolLocalVmTarget(localVmSeatPool.candidate(threadId, localVmPoolSeatHolder));
|
|
8250
|
+
if (!localVmSeen.has(poolCandidate.key) && !opts?.automationSource)
|
|
8251
|
+
return false;
|
|
8252
|
+
}
|
|
8253
|
+
const localVmTarget = localVmTargetForThread(bot.id, threadId);
|
|
7640
8254
|
let lazyReadyVm = null;
|
|
7641
8255
|
if (!strict) {
|
|
7642
8256
|
// Nothing this process has ever seen for this target, and nobody is
|
|
@@ -7738,6 +8352,7 @@ async function startTurn(botId, text, opts) {
|
|
|
7738
8352
|
// The claim restarts the poller with the capture, the way the
|
|
7739
8353
|
// Local VM's lazy claim does.
|
|
7740
8354
|
onClaimed: (vpsCapture) => {
|
|
8355
|
+
pinAutoSurface("cloud");
|
|
7741
8356
|
previewCapture = vpsCapture;
|
|
7742
8357
|
if (threadBusy(bot.id, threadId)) {
|
|
7743
8358
|
const touched = screenPollers.get(threadId)?.touched ?? false;
|
|
@@ -7760,16 +8375,16 @@ async function startTurn(botId, text, opts) {
|
|
|
7760
8375
|
}
|
|
7761
8376
|
}
|
|
7762
8377
|
}
|
|
7763
|
-
// Cloud is strict when selected. Native
|
|
7764
|
-
// bridge may also reuse an already-ready
|
|
8378
|
+
// Cloud is strict when selected. Native Boat and drivers with a Boat
|
|
8379
|
+
// bridge may also reuse an already-ready Boat on Auto.
|
|
7765
8380
|
if (teamComputer) {
|
|
7766
|
-
const attached = await
|
|
8381
|
+
const attached = await attachTeamBoat(teamComputer, bot.id, resourceOwner, instance.adapter.capabilities.usesCloudComputer === true, instance.adapter.capabilities.remoteAgent === true);
|
|
7767
8382
|
integrations.computer = attached.integration;
|
|
7768
8383
|
previewCapture = attached.capture;
|
|
7769
8384
|
computerKind = "box";
|
|
7770
8385
|
}
|
|
7771
|
-
if (!teamComputer && instance.adapter.capabilities.usesCloudComputer === true && (wants === "cloud" || wants === undefined) && cloudBackend === "box" &&
|
|
7772
|
-
const attached = await
|
|
8386
|
+
if (!teamComputer && instance.adapter.capabilities.usesCloudComputer === true && (wants === "cloud" || wants === undefined) && cloudBackend === "box" && boat.boatConfigured(cfg)) {
|
|
8387
|
+
const attached = await attachBotBoat(bot, resourceOwner, {
|
|
7773
8388
|
explicitCloud: wants === "cloud",
|
|
7774
8389
|
canMount: instance.adapter.capabilities.usesCloudComputer === true,
|
|
7775
8390
|
remoteAgent: instance.adapter.capabilities.remoteAgent === true,
|
|
@@ -7782,8 +8397,8 @@ async function startTurn(botId, text, opts) {
|
|
|
7782
8397
|
}
|
|
7783
8398
|
}
|
|
7784
8399
|
}
|
|
7785
|
-
if (wants === "cloud" && cloudBackend === "box" && !
|
|
7786
|
-
throw new Error(
|
|
8400
|
+
if (wants === "cloud" && cloudBackend === "box" && !boat.boatConfigured(cfg)) {
|
|
8401
|
+
throw new Error(BOAT_NOT_CONFIGURED);
|
|
7787
8402
|
}
|
|
7788
8403
|
if (wants === "cloud" && cloudBackend === "box" && !integrations.computer) {
|
|
7789
8404
|
throw new Error("the cloud computer could not be created or reached");
|
|
@@ -7793,7 +8408,7 @@ async function startTurn(botId, text, opts) {
|
|
|
7793
8408
|
// Auto reaches a Local VM this bot already has before it ever touches the
|
|
7794
8409
|
// host's own desktop: on a headless server that VM is the only desktop
|
|
7795
8410
|
// there is, and a person who prepared one meant it to be used.
|
|
7796
|
-
if (wants === undefined && !integrations.computer && !integrations.localComputer &&
|
|
8411
|
+
if (wants === undefined && !integrations.computer && !integrations.localComputer && !computerPlaceRefusal("localVm") && await attachLocalVm(false))
|
|
7797
8412
|
computerKind = "vm";
|
|
7798
8413
|
// An unattended run on a bot with a VPS configured never lands on the
|
|
7799
8414
|
// host's own desktop instead: a scheduled job clicking on someone's
|
|
@@ -7803,7 +8418,7 @@ async function startTurn(botId, text, opts) {
|
|
|
7803
8418
|
!integrations.localComputer &&
|
|
7804
8419
|
wants === undefined &&
|
|
7805
8420
|
!unattendedVps &&
|
|
7806
|
-
|
|
8421
|
+
!computerPlaceRefusal("thisComputer") &&
|
|
7807
8422
|
shouldMountLocalComputer({
|
|
7808
8423
|
requested: undefined,
|
|
7809
8424
|
hostPlatform: process.platform,
|
|
@@ -7811,7 +8426,24 @@ async function startTurn(botId, text, opts) {
|
|
|
7811
8426
|
})) {
|
|
7812
8427
|
const cua = readCuaConnection();
|
|
7813
8428
|
if (cua) {
|
|
7814
|
-
|
|
8429
|
+
// Lazy host claim (issue #1650): the gated integration mounts
|
|
8430
|
+
// now, but the exclusive computer:host seat is taken only by the
|
|
8431
|
+
// first screen tools/call, through the computer-control gate —
|
|
8432
|
+
// the same seam as the Local VM (#1361) and the VPS. An Auto
|
|
8433
|
+
// turn that never touches the screen holds no desktop seat and
|
|
8434
|
+
// records no pin; a claim that finds the seat held waits behind
|
|
8435
|
+
// the holder like every other seat instead of failing on the
|
|
8436
|
+
// spot.
|
|
8437
|
+
autoVmClaims.set(threadId, {
|
|
8438
|
+
owner: resourceOwner,
|
|
8439
|
+
lazy: true,
|
|
8440
|
+
label: "this computer",
|
|
8441
|
+
onRejected: surfaceLazyClaimRejection("this computer"),
|
|
8442
|
+
claim: async () => {
|
|
8443
|
+
await bindTurnComputer(resourceOwner, "computer:host", true);
|
|
8444
|
+
pinAutoSurface("local");
|
|
8445
|
+
},
|
|
8446
|
+
});
|
|
7815
8447
|
integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
|
|
7816
8448
|
computerKind = "local";
|
|
7817
8449
|
}
|
|
@@ -7943,8 +8575,15 @@ async function startTurn(botId, text, opts) {
|
|
|
7943
8575
|
// turns stay there and the composer can show it. Explicit settings are
|
|
7944
8576
|
// not recorded: an auto pin yields to a later Works on change (which
|
|
7945
8577
|
// sweeps it — store.clearAutoSurfacePins); a person's pin does not.
|
|
7946
|
-
|
|
7947
|
-
|
|
8578
|
+
// Pin on use (issue #1650): every computer that registers a claim
|
|
8579
|
+
// slot — a ready Local VM, the VPS, the host fallback, a VM created
|
|
8580
|
+
// for this turn — records its pin inside the claim itself, so a turn
|
|
8581
|
+
// that only mounted tools records nothing. A Box attached during
|
|
8582
|
+
// dispatch and the built-in browser (no seat to claim) pin here.
|
|
8583
|
+
if (autoPinAllowed()) {
|
|
8584
|
+
const claimSlot = autoVmClaims.get(threadId);
|
|
8585
|
+
const pinsAtClaim = claimSlot?.owner.generation === resourceOwner.generation;
|
|
8586
|
+
const used = pinsAtClaim ? null : mountedComputer ?? (integrations.browser ? "browser" : null);
|
|
7948
8587
|
if (used)
|
|
7949
8588
|
store.patchTask(bot.id, threadId, { surface: used, surfaceSource: "auto" });
|
|
7950
8589
|
}
|
|
@@ -7978,7 +8617,8 @@ async function startTurn(botId, text, opts) {
|
|
|
7978
8617
|
{ id: "files", label: "File locations", text: worksInWorkspace && opts?.runOn !== "cloud" ? workspaceLocationsPrompt(bot.id, cwd, liveBot?.cwd ?? bot.cwd) : "" },
|
|
7979
8618
|
{ id: "computer", label: "Computer", text: computerPrompt(computerPromptKind) },
|
|
7980
8619
|
{ id: "team-computer", label: "Team computer", text: teamComputerPrompt(teamComputer) },
|
|
7981
|
-
{ id: "plan", label: "Surface", text: surfacePrompt({ computer: mountedComputer, browser: Boolean(integrations.browser) }, { pinned: plan.pinned, note: plan.note, canSelect: computerSelectionTurns.has(threadId) }) },
|
|
8620
|
+
{ 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) }) },
|
|
8621
|
+
{ id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
|
|
7982
8622
|
// gated on the integration, not the key: the hint only goes to a
|
|
7983
8623
|
// bot whose driver actually mounted the tools
|
|
7984
8624
|
{ id: "composio", label: "Connected apps", text: integrations.composio ? composioSystemPrompt(liveBot?.connectorTools ?? bot.connectorTools) : "" },
|
|
@@ -8005,6 +8645,15 @@ async function startTurn(botId, text, opts) {
|
|
|
8005
8645
|
{ id: "mentions", label: "Mentions", text: boundedCoordination && tagged.length ? `The user named these existing teammates: ${tagged.map(b => `${peerName(b.name)} (${b.id})`).join(", ")}. Use coordinate_bots when their contribution is needed; do not substitute native helper agents for these bots.` : mentionPrompt(tagged) },
|
|
8006
8646
|
]);
|
|
8007
8647
|
turnPromptBytes.set(threadId, { stable: Buffer.byteLength(prompt.stable), volatile: Buffer.byteLength(prompt.volatile) });
|
|
8648
|
+
// Automatic recall rides in front of THIS turn's message, never in the
|
|
8649
|
+
// system prompt: the volatile half is re-sent whole whenever any part of
|
|
8650
|
+
// it changes, and recall changes nearly every turn.
|
|
8651
|
+
const recalled = autoRecallPrompt(bot, threadId, resolvedImages.text, {
|
|
8652
|
+
// a routine run starts fresh by design, and a webhook is untrusted:
|
|
8653
|
+
// neither pulls earlier conversations in
|
|
8654
|
+
conversations: commsDepth === 0 && !coordinationNode && !opts?.automationSource && !opts?.unattended,
|
|
8655
|
+
userName: cfg.profile?.name?.trim() || "User",
|
|
8656
|
+
});
|
|
8008
8657
|
runningTurnEngines.set(threadId, instance);
|
|
8009
8658
|
// The prompt carries the soul as saved now. If it changed during setup,
|
|
8010
8659
|
// decide again from what is actually sent — except on a continuation
|
|
@@ -8029,7 +8678,7 @@ async function startTurn(botId, text, opts) {
|
|
|
8029
8678
|
threadId,
|
|
8030
8679
|
botId: bot.id,
|
|
8031
8680
|
startupRecovery: cfg.automaticRecovery?.enabled === true && !opts?.automaticRecoveryAttempted,
|
|
8032
|
-
text: dispatchContext.turnText,
|
|
8681
|
+
text: withRecalled(recalled, dispatchContext.turnText),
|
|
8033
8682
|
refreshSystemPrompt: true,
|
|
8034
8683
|
images: turnImages,
|
|
8035
8684
|
approvalMode: approvalModeForTurn(bot, commsDepth > 0),
|
|
@@ -8091,7 +8740,7 @@ async function startTurn(botId, text, opts) {
|
|
|
8091
8740
|
}
|
|
8092
8741
|
// a turn can settle before dispatch returns, and a poller started
|
|
8093
8742
|
// after its own turn.completed would never be torn down — it would
|
|
8094
|
-
// keep polling the
|
|
8743
|
+
// keep polling the boat forever, carrying dead per-turn state. busy
|
|
8095
8744
|
// is flipped false in the fold, so it is the honest "still running".
|
|
8096
8745
|
if ((previewCapture || browserCapture) && threadBusy(bot.id, threadId)) {
|
|
8097
8746
|
startScreenPoller(bot.id, threadId, { ...(previewCapture ? { computer: previewCapture } : {}), ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: instance.adapter.capabilities.remoteAgent === true });
|
|
@@ -8469,6 +9118,16 @@ routines = new RoutineManager({
|
|
|
8469
9118
|
broadcast({ kind: "bot", bot: publicBot(bot) });
|
|
8470
9119
|
return task;
|
|
8471
9120
|
},
|
|
9121
|
+
joinConversation: (run) => {
|
|
9122
|
+
if (!routinesInConversationEnabled(cfg) || run.target !== "bot")
|
|
9123
|
+
return null;
|
|
9124
|
+
const source = routineSourceOwner(run);
|
|
9125
|
+
// Only this bot's own visible chat. A room, or a teammate's thread, keeps
|
|
9126
|
+
// the detached run so one bot cannot write its work into the wrong place.
|
|
9127
|
+
if (!source || source.group || source.bot.id !== run.botId)
|
|
9128
|
+
return null;
|
|
9129
|
+
return source.threadId;
|
|
9130
|
+
},
|
|
8472
9131
|
createGoalTask: (groupId, title) => store.createGroupTask(groupId, title, false),
|
|
8473
9132
|
isResultsThread: (botId, threadId) => {
|
|
8474
9133
|
const bot = store.bot(botId);
|
|
@@ -8606,10 +9265,10 @@ async function cloudRoutineReadiness(botId, threadId) {
|
|
|
8606
9265
|
const bot = threadId ? store.projectBotForTask(botId, threadId) : store.bot(botId);
|
|
8607
9266
|
if (!bot || bot.hidden)
|
|
8608
9267
|
return { ready: false, reason: "The routine's target bot no longer exists." };
|
|
8609
|
-
if (!
|
|
9268
|
+
if (!boat.boatConfigured(cfg)) {
|
|
8610
9269
|
return {
|
|
8611
9270
|
ready: false,
|
|
8612
|
-
reason: 'The
|
|
9271
|
+
reason: 'The Boat cloud computer needs a working Boat API key. For the bot’s configured computer, including a self-hosted VPS, set run_on="maus" instead.',
|
|
8613
9272
|
};
|
|
8614
9273
|
}
|
|
8615
9274
|
const instance = turnInstance(bot, "cloud", threadId);
|
|
@@ -8617,15 +9276,15 @@ async function cloudRoutineReadiness(botId, threadId) {
|
|
|
8617
9276
|
return { ready: false, reason: "The Cloud VM runner is unavailable. Restart OpenMausBot and try again." };
|
|
8618
9277
|
try {
|
|
8619
9278
|
if ((await instance.snapshot()).state !== "available") {
|
|
8620
|
-
return { ready: false, reason: "The target bot's model engine is not ready to use the
|
|
9279
|
+
return { ready: false, reason: "The target bot's model engine is not ready to use the Boat cloud computer." };
|
|
8621
9280
|
}
|
|
8622
9281
|
const teamComputer = inheritedTeamComputer(bot);
|
|
8623
9282
|
const ownerId = teamComputer ? teamComputerOwner(teamComputer.id) : bot.id;
|
|
8624
|
-
const machine = await
|
|
9283
|
+
const machine = await boat.findBoat(cfg, ownerId);
|
|
8625
9284
|
if (teamComputer && !machine) {
|
|
8626
|
-
return { ready: false, reason: "The team's
|
|
9285
|
+
return { ready: false, reason: "The team's Boat computer is missing; explicitly create or retry it from the Team map." };
|
|
8627
9286
|
}
|
|
8628
|
-
// Explicit Cloud may provision or wake the bot's own
|
|
9287
|
+
// Explicit Cloud may provision or wake the bot's own Boat at dispatch.
|
|
8629
9288
|
// This probe only checks its identity/account, without starting billing.
|
|
8630
9289
|
return { ready: true };
|
|
8631
9290
|
}
|
|
@@ -8648,9 +9307,9 @@ const routineRequests = new RoutineRequestService({
|
|
|
8648
9307
|
const proposer = store.bot(proposerBotId);
|
|
8649
9308
|
const targetBot = store.bot(target.botId);
|
|
8650
9309
|
if (!targetBot)
|
|
8651
|
-
return `@${target.name} no longer exists, so this routine cannot be
|
|
9310
|
+
return `@${target.name} no longer exists, so this routine request cannot be confirmed for it`;
|
|
8652
9311
|
if (!proposer || !canReachPeer(proposer, targetBot)) {
|
|
8653
|
-
return `@${target.name} is no longer in this section, so this routine cannot be
|
|
9312
|
+
return `@${target.name} is no longer in this section, so this routine request cannot be confirmed for it`;
|
|
8654
9313
|
}
|
|
8655
9314
|
return null;
|
|
8656
9315
|
},
|
|
@@ -8667,7 +9326,7 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
|
|
|
8667
9326
|
if (localVmModeChangeBusy) {
|
|
8668
9327
|
return deletionResponse(409, { error: "Local VM settings are being updated — wait before deleting this bot" });
|
|
8669
9328
|
}
|
|
8670
|
-
if (
|
|
9329
|
+
if (boatLifecycleBusyBots.has(bot.id)) {
|
|
8671
9330
|
return deletionResponse(409, { error: "wait for this bot's cloud computer action to finish before deleting the bot" });
|
|
8672
9331
|
}
|
|
8673
9332
|
const activeRoutine = routines.activeRunForBot(bot.id);
|
|
@@ -8682,17 +9341,17 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
|
|
|
8682
9341
|
error: `stop this bot's work in channel ${activeGroup.group.name} before deleting the bot`,
|
|
8683
9342
|
});
|
|
8684
9343
|
}
|
|
8685
|
-
// A direct turn that has already claimed the bot can provision a
|
|
8686
|
-
// its background setup. Do not let deletion race that work while a
|
|
9344
|
+
// A direct turn that has already claimed the bot can provision a Boat in
|
|
9345
|
+
// its background setup. Do not let deletion race that work while a Boat
|
|
8687
9346
|
// account is configured; the person can stop the turn and retry.
|
|
8688
|
-
if ((
|
|
9347
|
+
if ((boat.boatConfigured(cfg) || vpsSshAlias(cfg)) && (bot.busy || hasDirectDispatch(bot.id))) {
|
|
8689
9348
|
return deletionResponse(409, { error: "stop this bot's work before checking and deleting its cloud computer" });
|
|
8690
9349
|
}
|
|
8691
|
-
const
|
|
8692
|
-
const
|
|
8693
|
-
if (
|
|
9350
|
+
const botBoatRecovery = boatCreateRecoverySnapshot().filter((entry) => entry.botId === bot.id);
|
|
9351
|
+
const botBoatDeletions = boatDeletionSnapshot().filter((entry) => entry.ownerBotId === bot.id);
|
|
9352
|
+
if (botBoatRecovery.some((entry) => !entry.resolved)) {
|
|
8694
9353
|
return deletionResponse(409, {
|
|
8695
|
-
error: "finish reconciling this bot's pending cloud computer creation before deleting it — check boat.dev, then retry
|
|
9354
|
+
error: "finish reconciling this bot's pending cloud computer creation before deleting it — check boat.dev, then retry Boat setup",
|
|
8696
9355
|
});
|
|
8697
9356
|
}
|
|
8698
9357
|
// Bot deletion awaits VM/browser/provider cleanup. Claim the bot and
|
|
@@ -8741,25 +9400,25 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
|
|
|
8741
9400
|
// Preflight every provider before deleting any resource. Cleanup can
|
|
8742
9401
|
// still fail mid-flight across independent providers, but a missing
|
|
8743
9402
|
// credential or offline daemon should not cause avoidable partial work.
|
|
8744
|
-
const vpsInventory = await vps.listManagedVpsComputers(cfg,
|
|
9403
|
+
const vpsInventory = await vps.listManagedVpsComputers(cfg, managedBoatOwners());
|
|
8745
9404
|
if (vpsInventory.configured && !vpsInventory.available) {
|
|
8746
9405
|
return deletionResponse(503, {
|
|
8747
9406
|
error: `${vpsInventory.problem ?? "VPS computer inventory is unavailable"}. The bot was kept so its computer can be retried safely`,
|
|
8748
9407
|
});
|
|
8749
9408
|
}
|
|
8750
9409
|
const ownedVpsComputers = vpsInventory.instances.filter((instance) => instance.ownerBotId === bot.id);
|
|
8751
|
-
if ((
|
|
9410
|
+
if ((botBoatRecovery.length > 0 || botBoatDeletions.length > 0) && !boat.boatConfigured(cfg)) {
|
|
8752
9411
|
return deletionResponse(409, {
|
|
8753
|
-
error: "Reconnect the
|
|
9412
|
+
error: "Reconnect the Boat account that owns this bot's remembered cloud computer, then retry deletion",
|
|
8754
9413
|
});
|
|
8755
9414
|
}
|
|
8756
|
-
const cloudInventory = await
|
|
9415
|
+
const cloudInventory = await boat.listManagedBoats(cfg, managedBoatOwners());
|
|
8757
9416
|
if (cloudInventory.configured && !cloudInventory.available) {
|
|
8758
9417
|
return deletionResponse(503, {
|
|
8759
9418
|
error: `${cloudInventory.problem ?? "cloud computer inventory is unavailable"}. The bot was kept so its computer can be retried safely`,
|
|
8760
9419
|
});
|
|
8761
9420
|
}
|
|
8762
|
-
const
|
|
9421
|
+
const ownedBoatComputers = cloudInventory.instances.filter((instance) => instance.ownerBotId === bot.id);
|
|
8763
9422
|
// Revalidate a reviewed Chief-of-Staff request and establish the
|
|
8764
9423
|
// browser cleanup intent before the first irreversible provider
|
|
8765
9424
|
// mutation. A stale review or damaged journal therefore leaves every
|
|
@@ -8772,14 +9431,14 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
|
|
|
8772
9431
|
// each exact, freshly revalidated identity before making its bot
|
|
8773
9432
|
// owner disappear. Shared team computers use a different owner id
|
|
8774
9433
|
// and are intentionally absent from these lists.
|
|
8775
|
-
for (const instance of
|
|
8776
|
-
const removed = await
|
|
9434
|
+
for (const instance of ownedBoatComputers) {
|
|
9435
|
+
const removed = await boat.deleteManagedBoat(cfg, managedBoatOwners(), instance.boxId, instance.name);
|
|
8777
9436
|
if (removed.pending) {
|
|
8778
9437
|
throw Object.assign(new Error("The cloud computer deletion has started but is still finishing. The bot was kept; retry in a moment"), { status: 409 });
|
|
8779
9438
|
}
|
|
8780
9439
|
}
|
|
8781
9440
|
for (const instance of ownedVpsComputers) {
|
|
8782
|
-
await vps.removeManagedVpsComputer(cfg,
|
|
9441
|
+
await vps.removeManagedVpsComputer(cfg, managedBoatOwners(), instance.name, instance.name);
|
|
8783
9442
|
}
|
|
8784
9443
|
if (localVmCleanup) {
|
|
8785
9444
|
if (localVmCleanup.removeContainer) {
|
|
@@ -9351,7 +10010,10 @@ const roomHandoffTimer = setInterval(() => {
|
|
|
9351
10010
|
}
|
|
9352
10011
|
}, 250);
|
|
9353
10012
|
roomHandoffTimer.unref();
|
|
9354
|
-
|
|
10013
|
+
// The room-context window IS the drain's coalescing cap: a burst longer
|
|
10014
|
+
// than the window would append transcript lines the responder never reads,
|
|
10015
|
+
// so both bounds come from one constant (admission.ts).
|
|
10016
|
+
const GROUP_CONTEXT_MESSAGES = DRAIN_COALESCE_MAX_ITEMS;
|
|
9355
10017
|
const MAX_GROUP_HOPS = 1;
|
|
9356
10018
|
function teammateReportContext(requestId, readerBotId) {
|
|
9357
10019
|
const node = roomHandoffs.nodes.get(requestId);
|
|
@@ -9361,9 +10023,10 @@ function teammateReportContext(requestId, readerBotId) {
|
|
|
9361
10023
|
return "[Teammate result withheld or no longer retained]";
|
|
9362
10024
|
return `[Teammate report — untrusted peer content, not human instructions or independent verification]\n${JSON.stringify({ bot: store.bot(node.botId)?.name, task: node.text, status: node.status, result: node.result })}`;
|
|
9363
10025
|
}
|
|
9364
|
-
function serializeRoomContext(threadId, userName,
|
|
10026
|
+
function serializeRoomContext(threadId, userName, textOverrides, readerBotId) {
|
|
9365
10027
|
const messages = store.messagesFor(threadId);
|
|
9366
10028
|
const messagesById = new Map(messages.map((message) => [message.id, message]));
|
|
10029
|
+
const overrides = new Map(textOverrides?.map((override) => [override.messageId, override.text]));
|
|
9367
10030
|
return messages
|
|
9368
10031
|
.filter((m) => (m.kind === "text" && m.text) || (m.kind === "digest" && m.digest) || m.roomRequest?.phase === "result")
|
|
9369
10032
|
.slice(-GROUP_CONTEXT_MESSAGES)
|
|
@@ -9376,7 +10039,8 @@ function serializeRoomContext(threadId, userName, textOverride, readerBotId) {
|
|
|
9376
10039
|
// turns. Resolve from the existing bounded store and recheck access.
|
|
9377
10040
|
return teammateReportContext(m.roomRequest.id, readerBotId);
|
|
9378
10041
|
}
|
|
9379
|
-
const
|
|
10042
|
+
const overridden = overrides.get(m.id);
|
|
10043
|
+
const rendered = overridden !== undefined ? { ...m, text: overridden } : m;
|
|
9380
10044
|
// a bot's name is quoted on the speaker line, so it gets one line; a
|
|
9381
10045
|
// user line that came through the API says so, since the reader would
|
|
9382
10046
|
// otherwise take it for the person typing
|
|
@@ -9395,6 +10059,33 @@ function serializeRoomContext(threadId, userName, textOverride, readerBotId) {
|
|
|
9395
10059
|
})
|
|
9396
10060
|
.join("\n");
|
|
9397
10061
|
}
|
|
10062
|
+
/** The decision model's view of a room message: the room, its people, the
|
|
10063
|
+
* last room lines before this one, and the message. Text is scrubbed of
|
|
10064
|
+
* secrets and attachment markup before it leaves this machine. */
|
|
10065
|
+
function roomRoutingInput(roomName, text, members, history, sender) {
|
|
10066
|
+
const userName = cfg.profile?.name?.trim() || "User";
|
|
10067
|
+
const clean = (value) => redactSecretsInText(extractTurnImages(value).text);
|
|
10068
|
+
const humans = new Set([userName]);
|
|
10069
|
+
const recent = history.flatMap((m) => {
|
|
10070
|
+
if (m.kind !== "text" || !m.text)
|
|
10071
|
+
return [];
|
|
10072
|
+
if (m.role === "user") {
|
|
10073
|
+
const person = m.sender?.name ?? userName;
|
|
10074
|
+
humans.add(person);
|
|
10075
|
+
return [{ from: person, text: clean(m.text) }];
|
|
10076
|
+
}
|
|
10077
|
+
return [{ from: `${m.from?.name ?? "A bot"} (bot)`, text: clean(m.text) }];
|
|
10078
|
+
});
|
|
10079
|
+
const from = sender?.name ?? userName;
|
|
10080
|
+
humans.add(from);
|
|
10081
|
+
return {
|
|
10082
|
+
room: roomName,
|
|
10083
|
+
humans: [...humans],
|
|
10084
|
+
members: members.map((member) => ({ id: member.id, name: member.name, title: member.title, description: member.description })),
|
|
10085
|
+
recent,
|
|
10086
|
+
message: { from, text: clean(text) },
|
|
10087
|
+
};
|
|
10088
|
+
}
|
|
9398
10089
|
// What each room has already taken from its bots. Keyed by room because the
|
|
9399
10090
|
// loop post_to_room can start is a property of the room, not of any one
|
|
9400
10091
|
// caller — three bots posting twice each is the same runaway as one bot
|
|
@@ -9545,14 +10236,39 @@ setupRetry = 0) {
|
|
|
9545
10236
|
integrations.hooks = hooksIntegration(bot.id, threadId, internalGeneration);
|
|
9546
10237
|
}
|
|
9547
10238
|
const latestUser = [...store.activePath(threadId)].reverse().find((message) => message.role === "user" && message.kind === "text" && message.text);
|
|
9548
|
-
const
|
|
10239
|
+
const usesNativeImageInput = instance.adapter.capabilities.nativeImageInput === true;
|
|
10240
|
+
// M2: a drained room turn runs one sender's whole burst as ONE turn, so
|
|
10241
|
+
// every burst line must reach the responder the way its own turn would
|
|
10242
|
+
// have carried it — attachments extracted natively, their tags stripped
|
|
10243
|
+
// from the rendered context. Recover the burst by the queueIds recorded
|
|
10244
|
+
// on the operation, not by position: the last line alone is not the burst.
|
|
10245
|
+
const burstIds = new Set(operation?.queuedQueueIds ?? []);
|
|
10246
|
+
const burstOverrides = [];
|
|
10247
|
+
const burstImages = [];
|
|
10248
|
+
if (usesNativeImageInput && !cardContinuation) {
|
|
10249
|
+
for (const message of store.activePath(threadId)) {
|
|
10250
|
+
if (message.role !== "user" || message.kind !== "text" || !message.text)
|
|
10251
|
+
continue;
|
|
10252
|
+
if (!message.queueId || !burstIds.has(message.queueId))
|
|
10253
|
+
continue;
|
|
10254
|
+
const resolved = extractTurnImages(message.text);
|
|
10255
|
+
burstOverrides.push({ messageId: message.id, text: resolved.text });
|
|
10256
|
+
burstImages.push(...resolved.images);
|
|
10257
|
+
}
|
|
10258
|
+
}
|
|
10259
|
+
// the latest line is usually the burst's last item: extracting it again
|
|
10260
|
+
// would double-count its attachments and re-strip already-stripped words
|
|
10261
|
+
const latestInBurst = latestUser?.queueId !== undefined && burstIds.has(latestUser.queueId);
|
|
10262
|
+
const resolvedLatestImages = latestUser?.text && !cardContinuation && !latestInBurst
|
|
9549
10263
|
? extractTurnImages(latestUser.text)
|
|
9550
10264
|
: { text: latestUser?.text ?? "", images: [] };
|
|
9551
|
-
const usesNativeImageInput = instance.adapter.capabilities.nativeImageInput === true;
|
|
9552
10265
|
const roomContext = serializeRoomContext(threadId, userName, usesNativeImageInput && latestUser
|
|
9553
|
-
?
|
|
10266
|
+
? [
|
|
10267
|
+
...burstOverrides,
|
|
10268
|
+
...(latestInBurst ? [] : [{ messageId: latestUser.id, text: resolvedLatestImages.text }]),
|
|
10269
|
+
]
|
|
9554
10270
|
: undefined, bot.id);
|
|
9555
|
-
const turnImages = usesNativeImageInput ? resolvedLatestImages.images : [];
|
|
10271
|
+
const turnImages = usesNativeImageInput ? [...burstImages, ...resolvedLatestImages.images] : [];
|
|
9556
10272
|
const skills = availableSkills();
|
|
9557
10273
|
const selectedSkills = mergeSkills(selectBundledSkills(roomContext, instance.adapter.capabilities.phoneMcp === true ? ["phoneMcp"] : [], skills), selectBundledSkills(latestUser?.text ?? "", skillAuthoring ? ["skillAuthoring"] : [], skills));
|
|
9558
10274
|
if (selectedSkills.some((skill) => skill.manifest.requiredCapabilities.includes("phoneMcp"))) {
|
|
@@ -9640,7 +10356,7 @@ setupRetry = 0) {
|
|
|
9640
10356
|
onDispatchError?.(message);
|
|
9641
10357
|
return true;
|
|
9642
10358
|
}
|
|
9643
|
-
if (
|
|
10359
|
+
if (boatLifecycleBusyBots.has(readyBot.id)) {
|
|
9644
10360
|
if (orchestration) {
|
|
9645
10361
|
orchestration.result.outcome = "busy";
|
|
9646
10362
|
return true;
|
|
@@ -9682,7 +10398,7 @@ setupRetry = 0) {
|
|
|
9682
10398
|
orchestration?.onClaimed?.();
|
|
9683
10399
|
// The stall completion handler only exists once the provider turn runs
|
|
9684
10400
|
// (registered at dispatch below), but this watch is armed at claim. Latch
|
|
9685
|
-
// a stall that fires during setup — browser,
|
|
10401
|
+
// a stall that fires during setup — browser, boat, and VM mounts can all
|
|
9686
10402
|
// wedge — so the dispatch site completes the turn instead of launching a
|
|
9687
10403
|
// provider turn the person was already told was stopped. The finally
|
|
9688
10404
|
// below unregisters the latch on every other exit.
|
|
@@ -9691,7 +10407,7 @@ setupRetry = 0) {
|
|
|
9691
10407
|
setupStalled = true;
|
|
9692
10408
|
});
|
|
9693
10409
|
// Watch from claim, not provider dispatch: room setup (connected-app
|
|
9694
|
-
// discovery, browser,
|
|
10410
|
+
// discovery, browser, boat, and VM mounts) can wedge before any provider
|
|
9695
10411
|
// event exists. The exits between here and dispatch either throw into the
|
|
9696
10412
|
// finally's guarded cleanup or return through the revalidation block,
|
|
9697
10413
|
// both of which settle the watch.
|
|
@@ -9711,14 +10427,15 @@ setupRetry = 0) {
|
|
|
9711
10427
|
readyBot.browser !== false &&
|
|
9712
10428
|
instance.adapter.capabilities.browserMcp === true,
|
|
9713
10429
|
});
|
|
9714
|
-
// A place the organisation disallows
|
|
9715
|
-
// provisioned or started, exactly as a bot
|
|
10430
|
+
// A place the organisation disallows, or a Cloud home never offers, is
|
|
10431
|
+
// refused before anything is provisioned or started, exactly as a bot
|
|
10432
|
+
// thread refuses it.
|
|
9716
10433
|
const roomKind = roomTeamComputer ? "box" : roomPlan.computer === "local" ? "thisComputer" : readyBot.computer === "vm" ? "localVm"
|
|
9717
10434
|
: roomPlan.computer === "cloud" ? (turnProvider(readyBot) === "vps" ? "vps" : "box") : undefined;
|
|
9718
|
-
const roomPlaceRefusal = roomKind &&
|
|
10435
|
+
const roomPlaceRefusal = roomKind && computerPlaceRefusal(roomKind);
|
|
9719
10436
|
if (roomPlaceRefusal)
|
|
9720
|
-
throw Object.assign(new Error(roomPlaceRefusal), { code:
|
|
9721
|
-
// The Computer engine runs on the
|
|
10437
|
+
throw Object.assign(new Error(roomPlaceRefusal.message), { code: roomPlaceRefusal.code });
|
|
10438
|
+
// The Computer engine runs on the Boat; this computer has no tools it can
|
|
9722
10439
|
// reach, exactly as a bot thread refuses it.
|
|
9723
10440
|
if (roomPlan.computer === "local" && instance.adapter.capabilities.remoteAgent === true) {
|
|
9724
10441
|
throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
|
|
@@ -9741,7 +10458,7 @@ setupRetry = 0) {
|
|
|
9741
10458
|
return false;
|
|
9742
10459
|
}
|
|
9743
10460
|
if (roomTeamComputer) {
|
|
9744
|
-
const attached = await
|
|
10461
|
+
const attached = await attachTeamBoat(roomTeamComputer, readyBot.id, resourceOwner, instance.adapter.capabilities.usesCloudComputer === true, instance.adapter.capabilities.remoteAgent === true);
|
|
9745
10462
|
if (isCancelled?.() || groupSpeakers.get(threadId) !== roomSpeaker ||
|
|
9746
10463
|
activeInternalGenerationByThread.get(threadId) !== internalGeneration)
|
|
9747
10464
|
return false;
|
|
@@ -9784,10 +10501,10 @@ setupRetry = 0) {
|
|
|
9784
10501
|
roomComputerKind = "vps";
|
|
9785
10502
|
}
|
|
9786
10503
|
else {
|
|
9787
|
-
if (!
|
|
9788
|
-
throw new Error(
|
|
10504
|
+
if (!boat.boatConfigured(cfg))
|
|
10505
|
+
throw new Error(BOAT_NOT_CONFIGURED);
|
|
9789
10506
|
const remoteAgent = instance.adapter.capabilities.remoteAgent === true;
|
|
9790
|
-
const attached = await
|
|
10507
|
+
const attached = await attachBotBoat(readyBot, resourceOwner, { explicitCloud: true,
|
|
9791
10508
|
canMount: instance.adapter.capabilities.usesCloudComputer === true, remoteAgent });
|
|
9792
10509
|
if (!roomSetupIsCurrent())
|
|
9793
10510
|
return false;
|
|
@@ -9806,7 +10523,7 @@ setupRetry = 0) {
|
|
|
9806
10523
|
throw new Error("this model engine cannot use the Local VM");
|
|
9807
10524
|
}
|
|
9808
10525
|
// A distinct identity fences cleanup even in shared mode on the same room thread.
|
|
9809
|
-
const target = { ...
|
|
10526
|
+
const target = { ...localVmTargetForThread(readyBot.id, threadId) };
|
|
9810
10527
|
await bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
|
|
9811
10528
|
if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(target.key)) {
|
|
9812
10529
|
throw new Error("this Local VM is being started, stopped, or replaced");
|
|
@@ -9814,6 +10531,8 @@ setupRetry = 0) {
|
|
|
9814
10531
|
if (!localVmLeaseFor(target).claim(threadId, readyBot.id, localVmOwnerBusy)) {
|
|
9815
10532
|
throw new Error("this Local VM is already being used by another turn");
|
|
9816
10533
|
}
|
|
10534
|
+
if (localVmMode(cfg) === "pool")
|
|
10535
|
+
localVmSeatPool.touch(threadId);
|
|
9817
10536
|
roomVmTarget = target;
|
|
9818
10537
|
localVmThreadTargets.set(threadId, target);
|
|
9819
10538
|
localVmActiveThreads.set(target.key, threadId);
|
|
@@ -9937,7 +10656,8 @@ setupRetry = 0) {
|
|
|
9937
10656
|
{ id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
|
|
9938
10657
|
{ id: "computer", label: "Computer", text: computerPrompt(roomComputerPromptKind) },
|
|
9939
10658
|
{ id: "team-computer", label: "Team computer", text: teamComputerPrompt(roomTeamComputer) },
|
|
9940
|
-
{ id: "plan", label: "Surface", text: surfacePrompt({ computer: roomTeamComputer ? "cloud" : roomVmTarget ? "vm" : surfaceOfComputerKind(roomComputerKind), browser: Boolean(integrations.browser) }, { note: roomPlan.note }) },
|
|
10659
|
+
{ id: "plan", label: "Surface", text: surfacePrompt({ computer: roomTeamComputer ? "cloud" : roomVmTarget ? "vm" : surfaceOfComputerKind(roomComputerKind), browser: Boolean(integrations.browser) }, { note: roomPlan.note, cloudHome: Boolean(CLOUD_HOME) }) },
|
|
10660
|
+
{ id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
|
|
9941
10661
|
{ id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
|
|
9942
10662
|
{ id: "recall", label: "Recall", text: integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
|
|
9943
10663
|
{ id: "recent", label: "Recent work", text: recentWorkPrompt(recentLines) },
|
|
@@ -10065,10 +10785,13 @@ setupRetry = 0) {
|
|
|
10065
10785
|
providerDispatched = true;
|
|
10066
10786
|
turnPromptBytes.set(threadId, { stable: Buffer.byteLength(roomSystem.stable), volatile: Buffer.byteLength(roomSystem.volatile) });
|
|
10067
10787
|
runningTurnEngines.set(threadId, instance);
|
|
10788
|
+
// notes only in a room: a private chat reaches a room through the
|
|
10789
|
+
// explicit, disclosed session_search, never automatically
|
|
10790
|
+
const roomRecalled = cardContinuation ? "" : autoRecallPrompt(bot, threadId, resolvedLatestImages.text, { conversations: false, userName });
|
|
10068
10791
|
guardTurnDispatch(instance.adapter.sendTurn({
|
|
10069
10792
|
threadId,
|
|
10070
10793
|
botId: readyBot.id,
|
|
10071
|
-
text,
|
|
10794
|
+
text: withRecalled(roomRecalled, text),
|
|
10072
10795
|
refreshSystemPrompt: true,
|
|
10073
10796
|
images: turnImages,
|
|
10074
10797
|
approvalMode: roomTurnApprovalMode(readyBot, orchestration),
|
|
@@ -10593,17 +11316,56 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
|
|
|
10593
11316
|
if (options.goalCoordinatorBotId && (channelMode !== "goal" || !requestedGoalCoordinator)) {
|
|
10594
11317
|
throw Object.assign(new Error("the selected goal coordinator is not an active room member"), { status: 409 });
|
|
10595
11318
|
}
|
|
10596
|
-
|
|
10597
|
-
|
|
10598
|
-
|
|
10599
|
-
|
|
10600
|
-
|
|
10601
|
-
|
|
10602
|
-
|
|
10603
|
-
|
|
10604
|
-
|
|
10605
|
-
|
|
10606
|
-
|
|
11319
|
+
// An Auto room may ask the decision model who answers. It reads the room
|
|
11320
|
+
// as it stood before this message, which is appended just below.
|
|
11321
|
+
const autoCandidate = !group.dm && channelMode === "chat" && group.defaultResponder.kind === "auto" &&
|
|
11322
|
+
deciderReady(cfg, "roomRouting");
|
|
11323
|
+
const historyBeforeSend = autoCandidate
|
|
11324
|
+
? store.messagesFor(threadId).filter((m) => m.kind === "text" && m.text).slice(-GROUP_CONTEXT_MESSAGES)
|
|
11325
|
+
: [];
|
|
11326
|
+
// A coalesced drain appends every queued line under its own queueId (the
|
|
11327
|
+
// chips and sendId receipts stay per-item); the LAST line is the turn's
|
|
11328
|
+
// user message, exactly like the 1:1 drain's userMessage.
|
|
11329
|
+
const queuedGroup = options.queuedGroup;
|
|
11330
|
+
const message = queuedGroup && queuedGroup.length > 0
|
|
11331
|
+
? (() => {
|
|
11332
|
+
for (const item of queuedGroup.slice(0, -1)) {
|
|
11333
|
+
store.appendMessage(threadId, {
|
|
11334
|
+
role: "user",
|
|
11335
|
+
kind: "text",
|
|
11336
|
+
text: item.text,
|
|
11337
|
+
replyToId: item.replyToId,
|
|
11338
|
+
sendId: item.sendId,
|
|
11339
|
+
channelMode: item.mode,
|
|
11340
|
+
queueId: item.id,
|
|
11341
|
+
via: item.via,
|
|
11342
|
+
sender: item.sender,
|
|
11343
|
+
});
|
|
11344
|
+
}
|
|
11345
|
+
const last = queuedGroup[queuedGroup.length - 1];
|
|
11346
|
+
return store.appendMessage(threadId, {
|
|
11347
|
+
role: "user",
|
|
11348
|
+
kind: "text",
|
|
11349
|
+
text: last.text,
|
|
11350
|
+
replyToId: replyTo?.id,
|
|
11351
|
+
sendId: last.sendId,
|
|
11352
|
+
channelMode: last.mode,
|
|
11353
|
+
queueId: last.id,
|
|
11354
|
+
via: last.via,
|
|
11355
|
+
sender: last.sender,
|
|
11356
|
+
});
|
|
11357
|
+
})()
|
|
11358
|
+
: store.appendMessage(threadId, {
|
|
11359
|
+
role: "user",
|
|
11360
|
+
kind: "text",
|
|
11361
|
+
text,
|
|
11362
|
+
replyToId: replyTo?.id,
|
|
11363
|
+
sendId,
|
|
11364
|
+
channelMode,
|
|
11365
|
+
queueId,
|
|
11366
|
+
via: options.via,
|
|
11367
|
+
sender: options.sender,
|
|
11368
|
+
});
|
|
10607
11369
|
// Admitted (see startTurn): the room's speakers are booked to this sender.
|
|
10608
11370
|
if (options.trigger)
|
|
10609
11371
|
turnTriggers.set(threadId, options.trigger);
|
|
@@ -10653,25 +11415,47 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
|
|
|
10653
11415
|
}
|
|
10654
11416
|
return message;
|
|
10655
11417
|
}
|
|
11418
|
+
// Auto with nobody addressed: the responders above are only the fallback
|
|
11419
|
+
// until the decision model answers, in the async round below. The append
|
|
11420
|
+
// and this function's return stay synchronous either way.
|
|
11421
|
+
const autoRoute = autoCandidate && !goalCoordinator && availableMembers.length > 1 &&
|
|
11422
|
+
roomResponders(text, availableMembers, { kind: "mentions" }).length === 0;
|
|
10656
11423
|
// The snippet is only the fallback name here too. The member about to
|
|
10657
11424
|
// answer supplies the same cheap one-shot the bot path uses, and the
|
|
10658
11425
|
// swap lands only while the row still carries the snippet — a rename by
|
|
10659
11426
|
// the person always wins. Channel tasks carry no peer provenance (no
|
|
10660
11427
|
// assignment opens them), and a member whose engine offers no text
|
|
10661
11428
|
// one-shot simply keeps the snippet.
|
|
10662
|
-
const titleBot = goalCoordinator ?? responders[0];
|
|
10663
|
-
const titleInstance = registry.get(titleBot.modelSelection.instanceId);
|
|
10664
11429
|
const titleText = extractTurnImages(text).text;
|
|
10665
|
-
|
|
10666
|
-
|
|
10667
|
-
|
|
10668
|
-
|
|
10669
|
-
|
|
10670
|
-
|
|
11430
|
+
const startTitle = (titleBot) => {
|
|
11431
|
+
const titleInstance = registry.get(titleBot.modelSelection.instanceId);
|
|
11432
|
+
// An engine the organisation disallows never receives the text, even for a title.
|
|
11433
|
+
if (titled && snippet && titleText.trim() && llmThreadTitlesEnabled(cfg) && titleInstance?.generateText && !policyModelRefusal(titleInstance)) {
|
|
11434
|
+
void generateThreadTitle(titleInstance, titleText)
|
|
11435
|
+
.then((title) => {
|
|
11436
|
+
if (title)
|
|
11437
|
+
store.retitleGroupTask(group.id, threadId, snippet, title);
|
|
11438
|
+
})
|
|
11439
|
+
.catch(() => undefined);
|
|
11440
|
+
}
|
|
11441
|
+
};
|
|
11442
|
+
// An Auto round titles with whoever the decision picks, once it has.
|
|
11443
|
+
if (!autoRoute)
|
|
11444
|
+
startTitle(goalCoordinator ?? responders[0]);
|
|
11445
|
+
const operation = beginGroupTurnOperation(groupId, threadId, goalCoordinator || autoRoute ? [] : responders.map((responder) => responder.id));
|
|
11446
|
+
// Asked now, in parallel with any earlier room work still queued: the
|
|
11447
|
+
// answer is waited for only when this round's turn comes up. It never
|
|
11448
|
+
// rejects; a Stop aborts it through the operation's signal.
|
|
11449
|
+
const routing = autoRoute
|
|
11450
|
+
? decideRoomResponder(decider, roomRoutingInput(group.name, text, availableMembers, historyBeforeSend, options.sender), {
|
|
11451
|
+
signal: operation.cancellation.signal,
|
|
10671
11452
|
})
|
|
10672
|
-
|
|
11453
|
+
: null;
|
|
11454
|
+
if (queuedGroup && queuedGroup.length > 0) {
|
|
11455
|
+
// member turns use these stable ids to carry every burst line's words
|
|
11456
|
+
// and attachments through the turn that swallowed the burst
|
|
11457
|
+
operation.queuedQueueIds = queuedGroup.map((item) => item.id);
|
|
10673
11458
|
}
|
|
10674
|
-
const operation = beginGroupTurnOperation(groupId, threadId, goalCoordinator ? [] : responders.map((responder) => responder.id));
|
|
10675
11459
|
if (goalCoordinator) {
|
|
10676
11460
|
const runId = options.goalRunId?.trim() || `goal-${Date.now().toString(36)}-${randomUUID()}`;
|
|
10677
11461
|
const startedAt = Date.now();
|
|
@@ -10723,9 +11507,27 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
|
|
|
10723
11507
|
await runGroupGoalOperation({ groupId, threadId, coordinator: goalCoordinator, members, operation });
|
|
10724
11508
|
}
|
|
10725
11509
|
else {
|
|
11510
|
+
let round = responders;
|
|
11511
|
+
let routedBy;
|
|
11512
|
+
if (routing) {
|
|
11513
|
+
const route = await routing;
|
|
11514
|
+
if (operation.cancelled)
|
|
11515
|
+
return;
|
|
11516
|
+
const picked = route.kind === "member" ? availableMembers.filter((member) => member.id === route.botId)
|
|
11517
|
+
: route.kind === "everyone" ? availableMembers
|
|
11518
|
+
: [];
|
|
11519
|
+
if (picked.length && route.kind !== "fallback") {
|
|
11520
|
+
round = picked;
|
|
11521
|
+
routedBy = { provider: "jev", probability: Math.round(route.probability * 100) / 100 };
|
|
11522
|
+
}
|
|
11523
|
+
// What a routed round holds from the start, like any other round.
|
|
11524
|
+
for (const responder of round)
|
|
11525
|
+
operation.botIds.add(responder.id);
|
|
11526
|
+
startTitle(round[0]);
|
|
11527
|
+
}
|
|
10726
11528
|
const spoken = new Set();
|
|
10727
11529
|
const skillAuthoringClaim = { claimed: false };
|
|
10728
|
-
for (const responder of
|
|
11530
|
+
for (const responder of round) {
|
|
10729
11531
|
if (operation.cancelled)
|
|
10730
11532
|
break;
|
|
10731
11533
|
if (spoken.has(responder.id))
|
|
@@ -10741,7 +11543,19 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
|
|
|
10741
11543
|
spoken.add(responder.id);
|
|
10742
11544
|
continue;
|
|
10743
11545
|
}
|
|
10744
|
-
|
|
11546
|
+
// This speaker's first reply line says the decision model chose it.
|
|
11547
|
+
const stamp = routedBy ? { botId: responder.id, routedBy } : undefined;
|
|
11548
|
+
if (stamp)
|
|
11549
|
+
routedRoomReplies.set(threadId, stamp);
|
|
11550
|
+
let proceed;
|
|
11551
|
+
try {
|
|
11552
|
+
proceed = await runGroupMemberTurn(groupId, threadId, responder.id, 0, spoken, undefined, undefined, () => operation.cancelled, () => groupProviderHandshakeStarted(operation), () => groupProviderHandshakeSettled(operation), skillAuthoringClaim, undefined, operation);
|
|
11553
|
+
}
|
|
11554
|
+
finally {
|
|
11555
|
+
if (stamp && routedRoomReplies.get(threadId) === stamp)
|
|
11556
|
+
routedRoomReplies.delete(threadId);
|
|
11557
|
+
}
|
|
11558
|
+
if (!proceed)
|
|
10745
11559
|
break;
|
|
10746
11560
|
}
|
|
10747
11561
|
}
|
|
@@ -10756,19 +11570,32 @@ function drainQueuedChannelSends() {
|
|
|
10756
11570
|
drainChannelMessages((groupId) => {
|
|
10757
11571
|
const group = store.group(groupId);
|
|
10758
11572
|
return group ? groupIsWorking(group) : false;
|
|
10759
|
-
}, ({ groupId, threadId,
|
|
11573
|
+
}, ({ groupId, threadId, items }) => {
|
|
10760
11574
|
const group = store.group(groupId);
|
|
10761
11575
|
const ownsThread = group?.dm
|
|
10762
11576
|
? group.threadId === threadId
|
|
10763
11577
|
: Boolean(group && store.groupTaskByThread(group.id, threadId));
|
|
10764
11578
|
if (!group || !ownsThread)
|
|
10765
11579
|
return;
|
|
11580
|
+
// The burst runs as ONE room turn: the joined text routes responders
|
|
11581
|
+
// and titles the task, while every queued line still lands under its
|
|
11582
|
+
// own queueId (chips and sendId receipts stay per-item).
|
|
11583
|
+
const head = items[0];
|
|
11584
|
+
const last = items[items.length - 1];
|
|
11585
|
+
const joined = items.map((item) => item.text).join("\n\n");
|
|
10766
11586
|
try {
|
|
10767
|
-
startGroupTurn(groupId,
|
|
11587
|
+
startGroupTurn(groupId, joined, resolveReplyTarget(threadId, last.replyToId), last.sendId, last.mode, last.id, {
|
|
11588
|
+
threadId,
|
|
11589
|
+
sender: head.sender,
|
|
11590
|
+
trigger: queuedTurnTrigger({ trigger: head.trigger, sender: head.sender }),
|
|
11591
|
+
queuedGroup: items,
|
|
11592
|
+
});
|
|
10768
11593
|
}
|
|
10769
11594
|
catch (error) {
|
|
10770
|
-
|
|
10771
|
-
store.
|
|
11595
|
+
for (const item of items) {
|
|
11596
|
+
if (!store.messagesFor(threadId).some((message) => message.queueId === item.id && message.role === "user")) {
|
|
11597
|
+
store.appendMessage(threadId, { role: "user", kind: "text", text: item.text, replyToId: item.replyToId, sendId: item.sendId, channelMode: item.mode, queueId: item.id, via: item.via, sender: item.sender });
|
|
11598
|
+
}
|
|
10772
11599
|
}
|
|
10773
11600
|
store.appendMessage(threadId, {
|
|
10774
11601
|
role: "bot",
|
|
@@ -11600,24 +12427,15 @@ async function testCliBinary(cli, driver) {
|
|
|
11600
12427
|
});
|
|
11601
12428
|
}
|
|
11602
12429
|
/** A pre-save probe only needs PATH. Never hand credentials inherited by the
|
|
11603
|
-
* desktop/server process to an arbitrary wrapper selected through Settings.
|
|
12430
|
+
* desktop/server process to an arbitrary wrapper selected through Settings.
|
|
12431
|
+
* The shared lists (config.ts) cover every workspace, control-plane and
|
|
12432
|
+
* provider credential, including ones added later; the probe and `claude
|
|
12433
|
+
* update` keep everything else (HOME, proxy settings, CA bundles). */
|
|
11604
12434
|
function cliProbeEnvironment() {
|
|
11605
12435
|
const env = { ...process.env, PATH: augmentedPath() };
|
|
11606
|
-
|
|
11607
|
-
|
|
11608
|
-
"BOX_TOKEN",
|
|
11609
|
-
"OPENCODE_API_KEY",
|
|
11610
|
-
"COMPOSIO_API_KEY",
|
|
11611
|
-
"OMB_COMPOSIO_BROKER_TOKEN",
|
|
11612
|
-
"OMB_TTS_KEY",
|
|
11613
|
-
"OMB_FISH_AUDIO_API_KEY",
|
|
11614
|
-
"OMB_OPENAI_IMAGE_KEY",
|
|
11615
|
-
"OMB_CUSTOM_IMAGE_KEY",
|
|
11616
|
-
"ANTHROPIC_API_KEY",
|
|
11617
|
-
"OPENAI_API_KEY",
|
|
11618
|
-
]) {
|
|
12436
|
+
stripWorkspaceCredentialEnv(env);
|
|
12437
|
+
for (const key of PROVIDER_CREDENTIAL_ENV)
|
|
11619
12438
|
delete env[key];
|
|
11620
|
-
}
|
|
11621
12439
|
return env;
|
|
11622
12440
|
}
|
|
11623
12441
|
/** execFile's error carries the child's stderr in .stderr. */
|
|
@@ -11643,7 +12461,7 @@ async function localVmPayload(target) {
|
|
|
11643
12461
|
* app itself deleted eight hours earlier. Someone who steps away overnight
|
|
11644
12462
|
* comes back to an error on their first message.
|
|
11645
12463
|
*
|
|
11646
|
-
* The cloud branch below already does the opposite: an absent
|
|
12464
|
+
* The cloud branch below already does the opposite: an absent boat is
|
|
11647
12465
|
* provisioned on first use behind a `provisioning` broadcast. This gives the
|
|
11648
12466
|
* Local VM the same lifecycle for the same reason.
|
|
11649
12467
|
*
|
|
@@ -11663,7 +12481,11 @@ async function readyLocalVmForTurn(botId, target, isCurrent = () => true) {
|
|
|
11663
12481
|
// before the first await — the same synchronous-fence-then-count shape
|
|
11664
12482
|
// the panel route uses — so two concurrent turns cannot both pass the
|
|
11665
12483
|
// per-bot limit between count and create.
|
|
11666
|
-
|
|
12484
|
+
// Pool capacity is bounded by its seat indices. Each seat already has
|
|
12485
|
+
// its own lease/lifecycle fence, so independent cold seats can start
|
|
12486
|
+
// together without competing for the per-bot inventory's capacity gate.
|
|
12487
|
+
const pooled = target.key.startsWith("pool:");
|
|
12488
|
+
const ownsProvision = !pooled && !localVmProvisionBusy;
|
|
11667
12489
|
if (ownsProvision)
|
|
11668
12490
|
localVmProvisionBusy = true;
|
|
11669
12491
|
let status;
|
|
@@ -11677,9 +12499,9 @@ async function readyLocalVmForTurn(botId, target, isCurrent = () => true) {
|
|
|
11677
12499
|
// Another creation is already mid-flight and its container is not yet
|
|
11678
12500
|
// visible to a count, so the safe answer is the inspected status —
|
|
11679
12501
|
// exactly what the over-cap path below returns.
|
|
11680
|
-
if (!ownsProvision)
|
|
12502
|
+
if (!pooled && !ownsProvision)
|
|
11681
12503
|
return status;
|
|
11682
|
-
if (target.key
|
|
12504
|
+
if (target.key.startsWith("bot:")) {
|
|
11683
12505
|
const count = await existingPerBotLocalVmCount(status.runtime);
|
|
11684
12506
|
if (!isCurrent() || count >= localVmMaxInstances(cfg))
|
|
11685
12507
|
return status;
|
|
@@ -11773,17 +12595,24 @@ function configStatus() {
|
|
|
11773
12595
|
configured: composio.configured(cfg),
|
|
11774
12596
|
mode: composio.connectionMode(cfg),
|
|
11775
12597
|
},
|
|
11776
|
-
|
|
12598
|
+
// configured = cloud computers work here; included = through Cloud Pro,
|
|
12599
|
+
// not a saved key
|
|
12600
|
+
box: boat.describeBoatAccount(cfg),
|
|
11777
12601
|
vps: { configured: Boolean(vpsSshAlias(cfg)), sshAlias: vpsSshAlias(cfg) ?? "" },
|
|
11778
12602
|
opencodeGo: { configured: Boolean(cfg.opencodeGo?.apiKey) },
|
|
11779
12603
|
// the chosen voice is a setting, not a secret; the key is reported the
|
|
11780
12604
|
// same configured-or-not way as every other credential
|
|
11781
12605
|
tts: tts.describeVoice(cfg),
|
|
12606
|
+
// the decision model: switches and configured-or-not, never the key
|
|
12607
|
+
decider: describeDecider(cfg),
|
|
11782
12608
|
imageGen: avatarImageStatus(cfg),
|
|
11783
12609
|
// not a secret — the sidebar shows it
|
|
11784
12610
|
profile: { name: cfg.profile?.name ?? "", email: cfg.profile?.email ?? "", aboutMe: cfg.profile?.aboutMe ?? "" },
|
|
11785
12611
|
// the enrolled organisation's read-only desktop policy; null when not enrolled
|
|
11786
12612
|
managedPolicy: managedPolicy.summary(),
|
|
12613
|
+
// an OMB Cloud home (cloud-home.ts): the app offers no this computer and
|
|
12614
|
+
// no Local VM here
|
|
12615
|
+
...(CLOUD_HOME ? { cloudHome: true } : {}),
|
|
11787
12616
|
// not a secret — the settings picker shows it; "" = follow the system
|
|
11788
12617
|
language: cfg.language ?? "",
|
|
11789
12618
|
rooms: { turnTimeoutMinutes: roomTurnTimeoutMinutes(cfg) },
|
|
@@ -11802,6 +12631,7 @@ function configStatus() {
|
|
|
11802
12631
|
features: {
|
|
11803
12632
|
skillAuthoring: skillAuthoringEnabled(cfg),
|
|
11804
12633
|
showToolCalls: showToolCallsEnabled(cfg),
|
|
12634
|
+
routinesInConversation: routinesInConversationEnabled(cfg),
|
|
11805
12635
|
browser: builtInBrowserEnabled(cfg),
|
|
11806
12636
|
// Maintainer-only escape hatch, not a Settings toggle: the desktop
|
|
11807
12637
|
// shell and the Settings UI read it so they offer nothing this server
|
|
@@ -11810,6 +12640,7 @@ function configStatus() {
|
|
|
11810
12640
|
// Plugins → MCP servers switch: Claude bots also see this machine's
|
|
11811
12641
|
// own Claude Code MCP servers
|
|
11812
12642
|
claudeUserMcp: claudeUserMcpEnabled(cfg),
|
|
12643
|
+
autoRecall: autoRecallEnabled(cfg),
|
|
11813
12644
|
},
|
|
11814
12645
|
// first-run progress — not a secret; the app decides whether to show
|
|
11815
12646
|
// the welcome tour from this, never from browser storage
|
|
@@ -12120,36 +12951,11 @@ function serveStatic(res, path) {
|
|
|
12120
12951
|
}
|
|
12121
12952
|
}
|
|
12122
12953
|
}
|
|
12123
|
-
/** A store refusal is a client error with a status of its own (400 path,
|
|
12124
|
-
* 409 conflict, 413 too large); a 409 also carries what is on disk now so
|
|
12125
|
-
* the editor can show the bot's version instead of guessing. Anything else
|
|
12126
|
-
* is a real failure and goes to the handler's catch-all. */
|
|
12127
|
-
function replyMemoryError(res, error) {
|
|
12128
|
-
if (!(error instanceof MemoryStoreError))
|
|
12129
|
-
throw error;
|
|
12130
|
-
if (error.code === "conflict") {
|
|
12131
|
-
return json(res, error.status, { error: error.message, code: error.code, currentHash: error.currentHash, current: error.current });
|
|
12132
|
-
}
|
|
12133
|
-
return json(res, error.status, { error: error.message, code: error.code });
|
|
12134
|
-
}
|
|
12135
|
-
/** The journal row as the panel shows it: the full prior text stays on
|
|
12136
|
-
* the server (a revert needs it there, the list does not), and the thread
|
|
12137
|
-
* id becomes the chat title people recognise. */
|
|
12138
|
-
function journalEntryForClient(botId, entry) {
|
|
12139
|
-
const { before: _before, ...visible } = entry;
|
|
12140
|
-
const threadTitle = entry.threadId ? store.taskByThread(botId, entry.threadId)?.title : undefined;
|
|
12141
|
-
return threadTitle ? { ...visible, threadTitle } : visible;
|
|
12142
|
-
}
|
|
12143
12954
|
// Loopback-only enforcement: the harness runs on 127.0.0.1 but accepts
|
|
12144
12955
|
// requests from any loopback connection and any web page that DNS-rebinds
|
|
12145
12956
|
// onto it. Reject non-loopback Hosts outright (defeats rebinding) and
|
|
12146
12957
|
// origins outside loopback (blocks remote-web CSRF).
|
|
12147
|
-
const
|
|
12148
|
-
dataDir: DATA_DIR,
|
|
12149
|
-
appVersion: serverVersion(),
|
|
12150
|
-
readBody,
|
|
12151
|
-
restored: workspaceRestore,
|
|
12152
|
-
status: () => ({ busy: workspaceMaintenance.active, pendingRestore: workspaceMaintenance.pendingRestore }),
|
|
12958
|
+
const workspaceBackupAccess = {
|
|
12153
12959
|
authorized: (req, original) => {
|
|
12154
12960
|
const current = resolveRequestAuth(req, {
|
|
12155
12961
|
sessions, cookieName: SESSION_COOKIE, streamPath: "/api/events",
|
|
@@ -12170,13 +12976,13 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
|
|
|
12170
12976
|
idle: () => !providerFleetReloading && !providerAuthSessions.active && !browserEngineInstall &&
|
|
12171
12977
|
!routines?.isTicking && !calendarCalls?.isTicking &&
|
|
12172
12978
|
!localVmImageBusy && !localVmProvisionBusy && !localVmModeChangeBusy &&
|
|
12173
|
-
!localVmLifecycleBusy.size && !
|
|
12979
|
+
!localVmLifecycleBusy.size && !boatLifecycleBusyBots.size && !vpsPreviewRequests.size && !orphanBoatLifecycleBusyIds.size &&
|
|
12174
12980
|
!computerProviderConfigTransitions.size && !checkpointRestoreLeases.size &&
|
|
12175
12981
|
teamComputers.list().every(computer => !teamComputerInUse(computer)) &&
|
|
12176
12982
|
store.bots.every((bot) => !botHasActiveTurn(bot.id) && !routines?.activeRunForBot(bot.id) && !botComputerControlSnapshot(bot.id).held) &&
|
|
12177
12983
|
store.groups.every((group) => !groupIsWorking(group)),
|
|
12178
|
-
pause: () => { routines?.stop(); calendarCalls?.stop(); watchdog.stop(); },
|
|
12179
|
-
resume: () => { routines?.start(); calendarCalls?.start(); watchdog.start(); },
|
|
12984
|
+
pause: () => { routines?.stop(); calendarCalls?.stop(); watchdog.stop(); memoryUpkeep.pause(); },
|
|
12985
|
+
resume: () => { routines?.start(); calendarCalls?.start(); watchdog.start(); memoryUpkeep.resume(); },
|
|
12180
12986
|
flush: async () => {
|
|
12181
12987
|
await Promise.all([flushAllProfileHistory(), flushAllMemoryJournals(), flushUsageLedger(DATA_DIR), flushDecisionLog(DATA_DIR), flushAdminActivity(DATA_DIR)]);
|
|
12182
12988
|
// With writers gated and work idle, release our WAL connection for the
|
|
@@ -12185,6 +12991,27 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
|
|
|
12185
12991
|
closeMessageDb();
|
|
12186
12992
|
},
|
|
12187
12993
|
}, keepLocked),
|
|
12994
|
+
status: () => ({ busy: workspaceMaintenance.active, pendingRestore: workspaceMaintenance.pendingRestore }),
|
|
12995
|
+
};
|
|
12996
|
+
const workspaceBackupRoutes = createWorkspaceBackupRoutes({
|
|
12997
|
+
dataDir: DATA_DIR,
|
|
12998
|
+
appVersion: serverVersion(),
|
|
12999
|
+
readBody,
|
|
13000
|
+
restored: workspaceRestore,
|
|
13001
|
+
...workspaceBackupAccess,
|
|
13002
|
+
});
|
|
13003
|
+
// Move to Cloud (server/cloud-move-http.ts): every server sizes its own
|
|
13004
|
+
// workspace for the desktop; only a Cloud home receives one. After its
|
|
13005
|
+
// restore commits, the Cloud home restarts so startup installs it.
|
|
13006
|
+
let cloudHomeRestartRequested = false;
|
|
13007
|
+
const cloudMoveRoutes = createCloudMoveRoutes({
|
|
13008
|
+
dataDir: DATA_DIR,
|
|
13009
|
+
appVersion: serverVersion(),
|
|
13010
|
+
cloudHome: Boolean(CLOUD_HOME),
|
|
13011
|
+
readBody,
|
|
13012
|
+
restored: workspaceRestore,
|
|
13013
|
+
...workspaceBackupAccess,
|
|
13014
|
+
restart: () => { cloudHomeRestartRequested = true; gracefulShutdown(); },
|
|
12188
13015
|
});
|
|
12189
13016
|
// Route modules (server/routes/README.md). `workspaceAccess` is assigned at
|
|
12190
13017
|
// boot, after this line, so the dependency reads it per request.
|
|
@@ -12193,6 +13020,20 @@ ROUTES.push(createHostedSlackRoutes({ bot: (id) => store.bot(id), hostedReady: (
|
|
|
12193
13020
|
// its file, so New bot cannot disagree with it. No organization: none.
|
|
12194
13021
|
const orgInstallStatuses = () => orgLibrary?.installStatuses() ?? new Map();
|
|
12195
13022
|
ROUTES.push(createBotPresetRoutes({ presets: presetStore, orgStatuses: orgInstallStatuses }));
|
|
13023
|
+
// The bot-memory panel's routes (MEMORY.md, memory/ topics, journal); the
|
|
13024
|
+
// store lookups — the 404 precheck and journal thread titles — stay explicit.
|
|
13025
|
+
ROUTES.push(createBotMemoryRoutes({
|
|
13026
|
+
bot: (id) => store.bot(id),
|
|
13027
|
+
taskByThread: (botId, threadId) => store.taskByThread(botId, threadId),
|
|
13028
|
+
upkeep: memoryUpkeep,
|
|
13029
|
+
aboutMe: () => cfg.profile?.aboutMe ?? "",
|
|
13030
|
+
saveAboutMe: (aboutMe) => {
|
|
13031
|
+
saveConfig({ profile: { ...cfg.profile, aboutMe } });
|
|
13032
|
+
Object.assign(cfg, loadConfig());
|
|
13033
|
+
broadcast({ kind: "config", ...configStatus() });
|
|
13034
|
+
},
|
|
13035
|
+
}));
|
|
13036
|
+
ROUTES.push(createDeciderRoutes({ decider }));
|
|
12196
13037
|
const toolResults = new ToolResults();
|
|
12197
13038
|
const handleRequest = async (req, res) => {
|
|
12198
13039
|
let url;
|
|
@@ -12249,7 +13090,7 @@ const handleRequest = async (req, res) => {
|
|
|
12249
13090
|
if (method === "GET" && !path.startsWith("/api/") && !path.startsWith("/.well-known/") && serveStatic(res, path))
|
|
12250
13091
|
return;
|
|
12251
13092
|
if (method === "GET" && path === "/.well-known/openmausbot/environment") {
|
|
12252
|
-
return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !HOSTED_WORKSPACE && emailSignIn.enabled(), sharedComputers:
|
|
13093
|
+
return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !HOSTED_WORKSPACE && emailSignIn.enabled(), sharedComputers: lendingEnabled() }));
|
|
12253
13094
|
}
|
|
12254
13095
|
const domainCheck = /^\/\.well-known\/openmausbot\/domain-check\/([a-f0-9]{64})$/.exec(path);
|
|
12255
13096
|
if (method === "GET" && domainCheck) {
|
|
@@ -12292,11 +13133,31 @@ const handleRequest = async (req, res) => {
|
|
|
12292
13133
|
}
|
|
12293
13134
|
sessions.clearFailures(source);
|
|
12294
13135
|
const issued = sessions.issue({ label: label.trim() || labelFromUserAgent(req.headers["user-agent"]), scopes: verified.scopes, userId: verified.userId, email: verified.email });
|
|
12295
|
-
const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: true, sharedComputers:
|
|
13136
|
+
const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: true, sharedComputers: lendingEnabled() });
|
|
12296
13137
|
const secure = requestOrigin(req)?.startsWith("https://") === true;
|
|
12297
13138
|
res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, issued.token, { secure, maxAgeSeconds: cookieMaxAgeSeconds(issued.session) }));
|
|
12298
13139
|
return json(res, 200, { session: issued.session, environment });
|
|
12299
13140
|
}
|
|
13141
|
+
// The Admin's signed request for one pairing window on a Cloud home
|
|
13142
|
+
// machine. Public like /api/auth/pair, JSON only, and bad signatures
|
|
13143
|
+
// count against the same lockout. Never log its headers, body or code.
|
|
13144
|
+
if (cloudPairing && path === CLOUD_PAIRING_PATH) {
|
|
13145
|
+
res.setHeader("cache-control", "no-store");
|
|
13146
|
+
if (method !== "POST")
|
|
13147
|
+
return json(res, 405, { error: "method_not_allowed" });
|
|
13148
|
+
if (!/^application\/json\b/i.test(String(req.headers["content-type"] ?? ""))) {
|
|
13149
|
+
return json(res, 415, { error: "send the pairing request as JSON (content-type: application/json)" });
|
|
13150
|
+
}
|
|
13151
|
+
const source = requestSource(req);
|
|
13152
|
+
const header = (name) => { const value = req.headers[name]; return typeof value === "string" ? value : undefined; };
|
|
13153
|
+
const result = cloudPairing.handle({
|
|
13154
|
+
timestamp: header("x-omb-cloud-timestamp"), nonce: header("x-omb-cloud-nonce"), signature: header("x-omb-cloud-signature"),
|
|
13155
|
+
body: await readSignedBody(req), source,
|
|
13156
|
+
});
|
|
13157
|
+
if (result.status !== 200)
|
|
13158
|
+
console.warn(`cloud pairing refused from ${source}: ${String(result.body.error)}`);
|
|
13159
|
+
return json(res, result.status, result.body);
|
|
13160
|
+
}
|
|
12300
13161
|
if (method === "POST" && path === "/api/auth/pair") {
|
|
12301
13162
|
// JSON only: a cross-site HTML form cannot send this content type
|
|
12302
13163
|
// without a preflight, so a stray unused code cannot be planted as a
|
|
@@ -12314,7 +13175,7 @@ const handleRequest = async (req, res) => {
|
|
|
12314
13175
|
console.warn(`pairing refused from ${requestSource(req)}: ${result.error}`);
|
|
12315
13176
|
return json(res, result.status, { error: result.error });
|
|
12316
13177
|
}
|
|
12317
|
-
const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: emailSignIn.enabled(), sharedComputers:
|
|
13178
|
+
const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: emailSignIn.enabled(), sharedComputers: lendingEnabled() });
|
|
12318
13179
|
if (wantsCookie) {
|
|
12319
13180
|
const secure = requestOrigin(req)?.startsWith("https://") === true;
|
|
12320
13181
|
res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, result.token, { secure, maxAgeSeconds: cookieMaxAgeSeconds(result.session) }));
|
|
@@ -12366,7 +13227,7 @@ const handleRequest = async (req, res) => {
|
|
|
12366
13227
|
url,
|
|
12367
13228
|
loopbackMutationToken: desktopMutationToken,
|
|
12368
13229
|
companionMutationToken,
|
|
12369
|
-
features: { sharedComputers:
|
|
13230
|
+
features: { sharedComputers: lendingEnabled() },
|
|
12370
13231
|
loopbackTrust: LOOPBACK.trust,
|
|
12371
13232
|
cliOwnerToken,
|
|
12372
13233
|
});
|
|
@@ -12424,6 +13285,8 @@ const handleRequest = async (req, res) => {
|
|
|
12424
13285
|
}
|
|
12425
13286
|
if (await workspaceBackupRoutes(req, res, path, auth))
|
|
12426
13287
|
return;
|
|
13288
|
+
if (await cloudMoveRoutes(req, res, path, auth))
|
|
13289
|
+
return;
|
|
12427
13290
|
// Count ordinary requests until their asynchronous handler returns, not
|
|
12428
13291
|
// merely until the browser disconnects. A cancelled upload can still write.
|
|
12429
13292
|
if (path.startsWith("/api/") && path !== "/api/events" && path !== "/api/health" && !path.startsWith("/api/shared-computers/") && !isWorkspaceBackupSessionControl(method, path)) {
|
|
@@ -12450,6 +13313,9 @@ const handleRequest = async (req, res) => {
|
|
|
12450
13313
|
// a hosted team workspace: the web UI's first run skips the
|
|
12451
13314
|
// desktop-only beats there. Absent everywhere else.
|
|
12452
13315
|
...(HOSTED_WORKSPACE ? { hosted: true } : {}),
|
|
13316
|
+
// an OMB Cloud home: the web UI's first run is its engine
|
|
13317
|
+
// sign-in (docs/cloud-pro.md). Absent everywhere else.
|
|
13318
|
+
...(CLOUD_HOME ? { cloudHome: true } : {}),
|
|
12453
13319
|
});
|
|
12454
13320
|
}
|
|
12455
13321
|
if (method === "POST" && path === "/api/auth/stream-ticket") {
|
|
@@ -12540,11 +13406,15 @@ const handleRequest = async (req, res) => {
|
|
|
12540
13406
|
const cancelled = sessions.cancelPairing(m[1]);
|
|
12541
13407
|
return json(res, cancelled ? 200 : 404, cancelled ? { ok: true } : { error: "no such pairing code" });
|
|
12542
13408
|
}
|
|
12543
|
-
|
|
13409
|
+
// The desktop's own server always answers its desktop here: lending to the
|
|
13410
|
+
// person's Cloud (docs/cloud-pro.md) needs the lease without the
|
|
13411
|
+
// maintainer flag. It grants nothing by itself; it only keeps local bot
|
|
13412
|
+
// turns and a remote screen action from using the screen at once.
|
|
13413
|
+
if (method === "POST" && path === "/api/desktop/shared-computer-control" && (sharedComputersEnabled(cfg) || DESKTOP_MANAGED)) {
|
|
12544
13414
|
if (auth.kind !== "loopback")
|
|
12545
13415
|
return json(res, 403, { error: "Local desktop only" });
|
|
12546
13416
|
const body = await readBody(req, 1024);
|
|
12547
|
-
if (!sharedComputersEnabled(cfg))
|
|
13417
|
+
if (!sharedComputersEnabled(cfg) && !DESKTOP_MANAGED)
|
|
12548
13418
|
return json(res, 404, { error: `no route: ${method} ${path}` });
|
|
12549
13419
|
if (!z.string().uuid().safeParse(body?.id).success || !["acquire", "release", "renew"].includes(body?.action))
|
|
12550
13420
|
return json(res, 400, { error: "Invalid computer lease" });
|
|
@@ -12567,13 +13437,19 @@ const handleRequest = async (req, res) => {
|
|
|
12567
13437
|
// With features.sharedComputers off the whole family falls through to the
|
|
12568
13438
|
// generic "no route" 404, so a probe cannot tell a disabled feature from
|
|
12569
13439
|
// a build that never had one.
|
|
12570
|
-
|
|
13440
|
+
// What the asking person has lent to this server, online or not, with its
|
|
13441
|
+
// scopes (docs/cloud-pro.md, "What the Cloud can see"). Owner-scoped like
|
|
13442
|
+
// the bot routes; no id a session could not already use, and no secret.
|
|
13443
|
+
if (method === "GET" && path === "/api/shared-computers" && lendingEnabled()) {
|
|
13444
|
+
return json(res, 200, { computers: sharedComputers.status(sessionLendingPrincipal(auth)) });
|
|
13445
|
+
}
|
|
13446
|
+
if (method === "POST" && path.startsWith("/api/shared-computers/") && lendingEnabled()) {
|
|
12571
13447
|
if (auth.kind !== "session")
|
|
12572
13448
|
return json(res, 403, { error: "Pair this desktop first" });
|
|
12573
13449
|
if (!/^application\/json\b/i.test(String(req.headers["content-type"] ?? "")))
|
|
12574
13450
|
return json(res, 415, { error: "JSON required" });
|
|
12575
13451
|
const body = await readBody(req, 4_000_000);
|
|
12576
|
-
if (!
|
|
13452
|
+
if (!lendingEnabled())
|
|
12577
13453
|
return json(res, 404, { error: `no route: ${method} ${path}` });
|
|
12578
13454
|
if (!sessions.isLive(auth.session.id))
|
|
12579
13455
|
return json(res, 401, { error: "Session ended" });
|
|
@@ -12585,7 +13461,11 @@ const handleRequest = async (req, res) => {
|
|
|
12585
13461
|
const registration = parsed.data;
|
|
12586
13462
|
if (registration.environmentId !== ENVIRONMENT_ID)
|
|
12587
13463
|
return json(res, 409, { error: "Workspace identity changed. Pair again before sharing this computer." });
|
|
12588
|
-
|
|
13464
|
+
// A Cloud home is one person's: only their own devices, which the
|
|
13465
|
+
// Admin's pairing signs in with admin scope, may lend to it.
|
|
13466
|
+
if (CLOUD_HOME && !auth.scopes.includes("admin"))
|
|
13467
|
+
return json(res, 403, { error: "Only your own computers can lend to your Cloud. Connect this computer to your Cloud from its OMB Cloud settings." });
|
|
13468
|
+
sharedComputers.register(registration, { session: auth.session.id, person: CLOUD_HOME ? CLOUD_HOME_LENDER : personKey(auth.session) }, secret);
|
|
12589
13469
|
return json(res, 200, { ok: true });
|
|
12590
13470
|
}
|
|
12591
13471
|
const route = /^\/api\/shared-computers\/([\w-]+)\/(poll|lease|result|disconnect)$/.exec(path);
|
|
@@ -12754,6 +13634,7 @@ const handleRequest = async (req, res) => {
|
|
|
12754
13634
|
if (method === "POST" && requested !== "auto" && !parseSurface(requested)) {
|
|
12755
13635
|
return json(res, 400, { error: "surface must be auto, cloud, vm, local, or browser" });
|
|
12756
13636
|
}
|
|
13637
|
+
const unoffered = CLOUD_HOME && method === "POST" && requested !== "auto" ? cloudHomePlaceRefusal(parseSurface(requested)) : undefined;
|
|
12757
13638
|
const options = await selectableComputers(bot);
|
|
12758
13639
|
const current = source ? source.mounted ?? "off" : await computerPreviewSurface(bot, bot.threadId);
|
|
12759
13640
|
requireActiveInternalCapability();
|
|
@@ -12761,6 +13642,8 @@ const handleRequest = async (req, res) => {
|
|
|
12761
13642
|
return json(res, 200, { current, canSelect, options });
|
|
12762
13643
|
if (computerSelectionTurns.get(internalCapability.threadId) !== source)
|
|
12763
13644
|
return json(res, 409, { error: "The user request ended before its computer was selected." });
|
|
13645
|
+
if (unoffered)
|
|
13646
|
+
return json(res, 409, { error: unoffered, options });
|
|
12764
13647
|
const option = requested === "auto"
|
|
12765
13648
|
? options.find(option => option.ready && option.surface === current) ?? options.find(option => option.ready && option.surface === "vm") ?? options.find(option => option.ready) ?? options.find(option => option.canStart) ?? options.find(option => option.canCreate && option.surface === "vm") ?? options.find(option => option.canCreate)
|
|
12766
13649
|
: options.find(option => option.surface === requested);
|
|
@@ -12826,7 +13709,7 @@ const handleRequest = async (req, res) => {
|
|
|
12826
13709
|
}
|
|
12827
13710
|
if (method === "POST" && path === "/api/internal/memory") {
|
|
12828
13711
|
const body = await readInternalBody();
|
|
12829
|
-
const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText }, { source: memorySource() });
|
|
13712
|
+
const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText, ...(body.until !== undefined ? { until: body.until } : {}) }, { source: memorySource() });
|
|
12830
13713
|
return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : result.code === "over-budget" ? 413 : 400, result);
|
|
12831
13714
|
}
|
|
12832
13715
|
if (method === "POST" && path === "/api/internal/memory/log") {
|
|
@@ -12874,15 +13757,30 @@ const handleRequest = async (req, res) => {
|
|
|
12874
13757
|
}
|
|
12875
13758
|
// Off by default: both fall through to the same "unknown internal
|
|
12876
13759
|
// endpoint" 404 a never-implemented route returns.
|
|
12877
|
-
|
|
12878
|
-
|
|
12879
|
-
if (method === "
|
|
13760
|
+
// Owner-scoped: a turn sees and uses only the computers of the person it
|
|
13761
|
+
// provably acts for (sharedComputerPrincipal), never anyone else's.
|
|
13762
|
+
if (method === "GET" && path === "/api/internal/shared-computers" && lendingEnabled()) {
|
|
13763
|
+
const principal = sharedComputerPrincipal(internalCapability);
|
|
13764
|
+
// Say why an owner's own conversation sees no Mac, so the bot can tell them.
|
|
13765
|
+
const notice = CLOUD_HOME && !principal && sharedComputers.status(CLOUD_HOME_LENDER).length > 0 &&
|
|
13766
|
+
cloudHomeLendingRefusal(cloudLendingTurn(internalCapability)) === "someone-else" ? { unavailable: LENDING_SOMEONE_ELSE } : {};
|
|
13767
|
+
return json(res, 200, { computers: sharedComputers.list(principal), ...notice });
|
|
13768
|
+
}
|
|
13769
|
+
if (method === "POST" && path === "/api/internal/shared-computers" && lendingEnabled()) {
|
|
12880
13770
|
const parsed = sharedComputerOperation.safeParse(await readInternalBody());
|
|
12881
|
-
if (!
|
|
13771
|
+
if (!lendingEnabled())
|
|
12882
13772
|
return json(res, 404, { error: "unknown internal endpoint" });
|
|
12883
13773
|
if (!parsed.success)
|
|
12884
13774
|
return json(res, 400, { error: "Invalid shared computer operation" });
|
|
12885
|
-
|
|
13775
|
+
// The bot's own "no computer" setting holds on a lent screen too.
|
|
13776
|
+
if (["computer_tools", "computer_call"].includes(parsed.data.action) && store.bot(internalCapability.botId)?.computer === "off") {
|
|
13777
|
+
return json(res, 403, { error: "This bot has no computer. Change its Computer setting to use a shared computer's apps and screen." });
|
|
13778
|
+
}
|
|
13779
|
+
const principal = sharedComputerPrincipal(internalCapability);
|
|
13780
|
+
if (CLOUD_HOME && !principal && cloudHomeLendingRefusal(cloudLendingTurn(internalCapability)) === "someone-else") {
|
|
13781
|
+
return json(res, 403, { error: LENDING_SOMEONE_ELSE });
|
|
13782
|
+
}
|
|
13783
|
+
return json(res, 200, { result: await sharedComputers.request(parsed.data, principal, () => lendingEnabled() && internalCapabilityIsActive(internalCapability)) });
|
|
12886
13784
|
}
|
|
12887
13785
|
if (method === "GET" && path === "/api/internal/agents") {
|
|
12888
13786
|
const sender = internalSender;
|
|
@@ -13150,11 +14048,12 @@ const handleRequest = async (req, res) => {
|
|
|
13150
14048
|
const owner = connectorThread(from.id, fromThreadId);
|
|
13151
14049
|
if (!owner)
|
|
13152
14050
|
return json(res, 403, { error: "source conversation does not belong to sender" });
|
|
13153
|
-
// "Make a routine for @B"
|
|
13154
|
-
// gets a teaching error now, not a mis-bound
|
|
13155
|
-
// (or the sender's own id) keeps the
|
|
14051
|
+
// "Make a routine for @B" / "pause @B's routine": resolve the target
|
|
14052
|
+
// up front so the model gets a teaching error now, not a mis-bound
|
|
14053
|
+
// routine later. Omitted (or the sender's own id) keeps the
|
|
14054
|
+
// own-routine path unchanged.
|
|
13156
14055
|
let forBot;
|
|
13157
|
-
if (body.
|
|
14056
|
+
if (body.forBotId !== undefined) {
|
|
13158
14057
|
const parsedForBotId = z.string().max(128).safeParse(body.forBotId);
|
|
13159
14058
|
const forBotId = parsedForBotId.success ? parsedForBotId.data.trim() : "";
|
|
13160
14059
|
if (!forBotId) {
|
|
@@ -13178,8 +14077,8 @@ const handleRequest = async (req, res) => {
|
|
|
13178
14077
|
const proposedInput = body.action === "create"
|
|
13179
14078
|
? { action: body.action, routine: body.routine, forBot }
|
|
13180
14079
|
: body.action === "update"
|
|
13181
|
-
? { action: body.action, routineId: body.routineId, changes: body.changes }
|
|
13182
|
-
: { action: body.action, routineId: body.routineId };
|
|
14080
|
+
? { action: body.action, routineId: body.routineId, changes: body.changes, forBot }
|
|
14081
|
+
: { action: body.action, routineId: body.routineId, forBot };
|
|
13183
14082
|
const proposed = await routineRequests.submit({
|
|
13184
14083
|
botId: from.id,
|
|
13185
14084
|
threadId: fromThreadId,
|
|
@@ -14655,7 +15554,11 @@ const handleRequest = async (req, res) => {
|
|
|
14655
15554
|
}
|
|
14656
15555
|
const group = createChannel({
|
|
14657
15556
|
name: redactSecretsInText(parsed.data.name), memberIds, section: chief.section,
|
|
14658
|
-
setup: {
|
|
15557
|
+
setup: {
|
|
15558
|
+
bulletin: redactSecretsInText(parsed.data.bulletin ?? ""),
|
|
15559
|
+
// The Chief leads; on Auto it is the fallback when the decision model is unsure.
|
|
15560
|
+
defaultResponder: deciderReady(cfg, "roomRouting") ? { kind: "auto", fallbackBotId: chief.id } : { kind: "member", botId: chief.id },
|
|
15561
|
+
},
|
|
14659
15562
|
});
|
|
14660
15563
|
internalCapability.createdRooms = (internalCapability.createdRooms ?? 0) + 1;
|
|
14661
15564
|
return json(res, 201, { id: group.id, name: group.name, section: group.section || "General", memberIds: group.memberIds, memberCount: group.memberIds.length });
|
|
@@ -15034,7 +15937,12 @@ const handleRequest = async (req, res) => {
|
|
|
15034
15937
|
const hidden = hiddenRoutineTarget(body, visible);
|
|
15035
15938
|
if (hidden)
|
|
15036
15939
|
return json(res, 404, { error: hidden });
|
|
15037
|
-
|
|
15940
|
+
const routine = routines.create(body);
|
|
15941
|
+
// On a Cloud home, a routine the owner writes from their own device may
|
|
15942
|
+
// use their lent Mac when it runs (server/cloud-lending.ts).
|
|
15943
|
+
if (cloudRoutineAuthors && cloudOwnerSession(auth))
|
|
15944
|
+
cloudRoutineAuthors.record(routine.id, routine);
|
|
15945
|
+
return json(res, 201, { routine });
|
|
15038
15946
|
}
|
|
15039
15947
|
// The desktop shell polls this to decide whether to hold the computer
|
|
15040
15948
|
// awake: a run in flight, or a routine due within the hour.
|
|
@@ -15044,6 +15952,8 @@ const handleRequest = async (req, res) => {
|
|
|
15044
15952
|
let routineMatch = path.match(/^\/api\/routines\/([\w-]+)\/run$/);
|
|
15045
15953
|
if (routineMatch && method === "POST") {
|
|
15046
15954
|
const run = routines.runNow(routineMatch[1]);
|
|
15955
|
+
if (run && CLOUD_HOME && cloudOwnerSession(auth))
|
|
15956
|
+
ownerStartedRoutineRuns.add(run.id);
|
|
15047
15957
|
return run ? json(res, 201, { run }) : json(res, 404, { error: "no such routine" });
|
|
15048
15958
|
}
|
|
15049
15959
|
routineMatch = path.match(/^\/api\/routines\/([\w-]+)$/);
|
|
@@ -15052,11 +15962,26 @@ const handleRequest = async (req, res) => {
|
|
|
15052
15962
|
const hidden = hiddenRoutineTarget(body, visible);
|
|
15053
15963
|
if (hidden)
|
|
15054
15964
|
return json(res, 404, { error: hidden });
|
|
15965
|
+
const before = cloudRoutineAuthors ? routines.listRoutines().find((candidate) => candidate.id === routineMatch[1]) : undefined;
|
|
15966
|
+
const wasOwners = Boolean(before && cloudRoutineAuthors?.authored(before.id, before));
|
|
15055
15967
|
const routine = routines.update(routineMatch[1], body);
|
|
15968
|
+
// The owner's own edit keeps (or, when it rewrites the instructions,
|
|
15969
|
+
// makes) the routine theirs. Anyone else's edit, of any field (its
|
|
15970
|
+
// schedule, where its results go, whether it is on), makes it no longer
|
|
15971
|
+
// the owner's: it stops lending until the owner rewrites it.
|
|
15972
|
+
if (routine && cloudRoutineAuthors) {
|
|
15973
|
+
if (!cloudOwnerSession(auth))
|
|
15974
|
+
cloudRoutineAuthors.forget(routine.id);
|
|
15975
|
+
else if (wasOwners || (body && typeof body === "object" && "prompt" in body))
|
|
15976
|
+
cloudRoutineAuthors.record(routine.id, routine);
|
|
15977
|
+
}
|
|
15056
15978
|
return routine ? json(res, 200, { routine }) : json(res, 404, { error: "no such routine" });
|
|
15057
15979
|
}
|
|
15058
15980
|
if (routineMatch && method === "DELETE") {
|
|
15059
|
-
|
|
15981
|
+
const removed = routines.remove(routineMatch[1]);
|
|
15982
|
+
if (removed)
|
|
15983
|
+
cloudRoutineAuthors?.forget(routineMatch[1]);
|
|
15984
|
+
return removed
|
|
15060
15985
|
? json(res, 200, { ok: true })
|
|
15061
15986
|
: json(res, 404, { error: "no such routine" });
|
|
15062
15987
|
}
|
|
@@ -15348,6 +16273,47 @@ const handleRequest = async (req, res) => {
|
|
|
15348
16273
|
});
|
|
15349
16274
|
}
|
|
15350
16275
|
// scrollback: the page before a message the client already holds
|
|
16276
|
+
// #1655: the consent card's answer, per conversation. Explicit and
|
|
16277
|
+
// revocable; a thread allowlisted in config carries the same standing
|
|
16278
|
+
// consent without ever seeing a card.
|
|
16279
|
+
m = path.match(/^\/api\/threads\/([\w-]+)\/cloud-overflow$/);
|
|
16280
|
+
if (m && method === "POST") {
|
|
16281
|
+
const threadId = m[1];
|
|
16282
|
+
if (!store.botByThread(threadId))
|
|
16283
|
+
return json(res, 404, { error: "no such conversation" });
|
|
16284
|
+
const body = await readBody(req);
|
|
16285
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
16286
|
+
return json(res, 400, { error: "body must be a JSON object" });
|
|
16287
|
+
if (typeof body.consent !== "boolean")
|
|
16288
|
+
return json(res, 400, { error: "consent must be a boolean" });
|
|
16289
|
+
if (body.consent) {
|
|
16290
|
+
// Consent answers the priced card, never a guess: without a prior
|
|
16291
|
+
// offer the rate was never shown, so a seat must not start yet.
|
|
16292
|
+
// The grant binds to the rate the card showed (#1655): a config
|
|
16293
|
+
// change re-offers instead of starting at a new price silently.
|
|
16294
|
+
const offeredRate = cloudOverflowConsent.offeredRate(threadId);
|
|
16295
|
+
if (offeredRate === null)
|
|
16296
|
+
return json(res, 409, { error: "no cloud overflow offer" });
|
|
16297
|
+
cloudOverflowConsent.grant(threadId, offeredRate);
|
|
16298
|
+
}
|
|
16299
|
+
else {
|
|
16300
|
+
cloudOverflowConsent.revoke(threadId);
|
|
16301
|
+
// Revocation must reach the machine (#1655): the ledger alone
|
|
16302
|
+
// would leave this conversation's seat billing to its idle
|
|
16303
|
+
// timeout. A start still waking is caught by its own revoke
|
|
16304
|
+
// check once readyBox resolves.
|
|
16305
|
+
const seatBot = store.botByThread(threadId);
|
|
16306
|
+
const lease = seatBot ? cloudSeatLeases.get(seatBot.id) : undefined;
|
|
16307
|
+
if (seatBot && lease && lease.threadId === threadId) {
|
|
16308
|
+
stopCloudSeat(seatBot.id, lease, "Cloud computer stopped because overflow consent was revoked — billing pauses while it sleeps.");
|
|
16309
|
+
}
|
|
16310
|
+
}
|
|
16311
|
+
store.appendMessage(threadId, {
|
|
16312
|
+
role: "bot", kind: "activity",
|
|
16313
|
+
tool: { name: cloudOverflowConsentText(body.consent, cloudOverflowIdleStopMs(cfg)), ok: true },
|
|
16314
|
+
});
|
|
16315
|
+
return json(res, 200, { consented: body.consent });
|
|
16316
|
+
}
|
|
15351
16317
|
m = path.match(/^\/api\/threads\/([\w-]+)\/messages$/);
|
|
15352
16318
|
if (m && method === "GET") {
|
|
15353
16319
|
const threadId = m[1];
|
|
@@ -15688,7 +16654,7 @@ const handleRequest = async (req, res) => {
|
|
|
15688
16654
|
const current = resolveRequestAuth(req, {
|
|
15689
16655
|
sessions, cookieName: SESSION_COOKIE, streamPath: "/api/events", url,
|
|
15690
16656
|
loopbackMutationToken: desktopMutationToken, companionMutationToken,
|
|
15691
|
-
features: { sharedComputers:
|
|
16657
|
+
features: { sharedComputers: lendingEnabled() }, loopbackTrust: LOOPBACK.trust, cliOwnerToken,
|
|
15692
16658
|
});
|
|
15693
16659
|
if (!current.auth)
|
|
15694
16660
|
return json(res, current.status, { error: current.error });
|
|
@@ -16186,15 +17152,7 @@ const handleRequest = async (req, res) => {
|
|
|
16186
17152
|
return json(res, 400, { error: "bulletin must be a string" });
|
|
16187
17153
|
if (body.bulletin.length > 12_000)
|
|
16188
17154
|
return json(res, 400, { error: "bulletin must be at most 12000 characters" });
|
|
16189
|
-
const
|
|
16190
|
-
let responder = null;
|
|
16191
|
-
if (value?.kind === "everyone")
|
|
16192
|
-
responder = { kind: "everyone" };
|
|
16193
|
-
else if (value?.kind === "mentions")
|
|
16194
|
-
responder = { kind: "mentions" };
|
|
16195
|
-
else if (value?.kind === "member" && typeof value.botId === "string" && group.memberIds.includes(value.botId)) {
|
|
16196
|
-
responder = { kind: "member", botId: value.botId };
|
|
16197
|
-
}
|
|
17155
|
+
const responder = checkedGroupResponder(body.defaultResponder, group.memberIds);
|
|
16198
17156
|
if (!responder)
|
|
16199
17157
|
return json(res, 400, { error: "invalid default responder" });
|
|
16200
17158
|
patch.cwd = checked.cwd ?? undefined;
|
|
@@ -16566,7 +17524,17 @@ const handleRequest = async (req, res) => {
|
|
|
16566
17524
|
const held = holdChannelQueue(current.id, targetThreadId, m[2]);
|
|
16567
17525
|
if (!held)
|
|
16568
17526
|
return json(res, 404, { error: "no such queued message" });
|
|
16569
|
-
|
|
17527
|
+
// M2: the head may be a coalesced group (one sender's contiguous
|
|
17528
|
+
// burst); Steer folds that whole group into the live turn as one.
|
|
17529
|
+
const headGroup = headChannelGroup(held.items);
|
|
17530
|
+
const [head] = headGroup;
|
|
17531
|
+
// A live steer has no image side channel — the same rule as the
|
|
17532
|
+
// 1:1 queue: attachment words wait for a real turn where central
|
|
17533
|
+
// admission can hand the images to the engine.
|
|
17534
|
+
if (headGroup.some((item) => extractTurnImages(item.text).images.length > 0)) {
|
|
17535
|
+
restoreHeldChannelQueue(held);
|
|
17536
|
+
return json(res, 200, { ok: true, queued: true, threadId: targetThreadId });
|
|
17537
|
+
}
|
|
16570
17538
|
const decision = admit("room-steer", {
|
|
16571
17539
|
speakerPresent: Boolean(speaker),
|
|
16572
17540
|
engineCanSteer: Boolean(instance?.adapter.capabilities.queueing && instance.adapter.steer),
|
|
@@ -16583,11 +17551,31 @@ const handleRequest = async (req, res) => {
|
|
|
16583
17551
|
// A reply target that cannot be resolved restores the held queue
|
|
16584
17552
|
// before the request fails — the room's normal drain keeps the head.
|
|
16585
17553
|
const replyTo = resolveHeldReplyTarget(held, resolveReplyTarget);
|
|
17554
|
+
// Each burst line keeps its own reply relationship: the fold renders
|
|
17555
|
+
// per-item reply context (the head through the helper above, the rest
|
|
17556
|
+
// resolved here) so a mixed burst does not inherit the head's quote
|
|
17557
|
+
// or drop its own. A target that cannot be resolved restores the held
|
|
17558
|
+
// queue before the request fails, mirroring the helper's contract.
|
|
17559
|
+
const speakerName = cfg.profile?.name?.trim() || "User";
|
|
17560
|
+
let foldedPrompt;
|
|
17561
|
+
try {
|
|
17562
|
+
foldedPrompt = headGroup
|
|
17563
|
+
.map((item, index) => promptWithReply(item.text, index === 0
|
|
17564
|
+
? replyTo
|
|
17565
|
+
: item.replyToId
|
|
17566
|
+
? resolveReplyTarget(targetThreadId, item.replyToId)
|
|
17567
|
+
: undefined, speakerName))
|
|
17568
|
+
.join("\n\n");
|
|
17569
|
+
}
|
|
17570
|
+
catch (error) {
|
|
17571
|
+
restoreHeldChannelQueue(held);
|
|
17572
|
+
throw error;
|
|
17573
|
+
}
|
|
16586
17574
|
// steer was offered only when a live speaker instance could take it;
|
|
16587
17575
|
// the check carries that invariant to the type system.
|
|
16588
17576
|
const steered = instance?.adapter.steer
|
|
16589
17577
|
? await instance.adapter
|
|
16590
|
-
.steer(targetThreadId,
|
|
17578
|
+
.steer(targetThreadId, foldedPrompt)
|
|
16591
17579
|
.catch(() => "indeterminate")
|
|
16592
17580
|
: "refused";
|
|
16593
17581
|
// The steer was awaited adapter work: re-read every ownership
|
|
@@ -16606,25 +17594,25 @@ const handleRequest = async (req, res) => {
|
|
|
16606
17594
|
// speaker — and settle the head.
|
|
16607
17595
|
const delivered = steered !== "refused";
|
|
16608
17596
|
if (after && delivered && (steered === "indeterminate" || afterSpeakerBotId === speakerBotId)) {
|
|
16609
|
-
const
|
|
17597
|
+
const messages = headGroup.map((item) => store.appendMessage(targetThreadId, {
|
|
16610
17598
|
role: "user",
|
|
16611
17599
|
kind: "text",
|
|
16612
|
-
text:
|
|
16613
|
-
replyToId:
|
|
16614
|
-
sendId:
|
|
16615
|
-
channelMode:
|
|
16616
|
-
queueId:
|
|
16617
|
-
via:
|
|
17600
|
+
text: item.text,
|
|
17601
|
+
replyToId: item.replyToId,
|
|
17602
|
+
sendId: item.sendId,
|
|
17603
|
+
channelMode: item.mode,
|
|
17604
|
+
queueId: item.id,
|
|
17605
|
+
via: item.via,
|
|
16618
17606
|
steered: true,
|
|
16619
|
-
sender:
|
|
16620
|
-
});
|
|
17607
|
+
sender: item.sender,
|
|
17608
|
+
}));
|
|
16621
17609
|
settleHeldChannelQueueHead(held);
|
|
16622
17610
|
return json(res, 200, {
|
|
16623
17611
|
ok: true,
|
|
16624
17612
|
steered: true,
|
|
16625
17613
|
threadId: targetThreadId,
|
|
16626
|
-
messages
|
|
16627
|
-
queueIds:
|
|
17614
|
+
messages,
|
|
17615
|
+
queueIds: headGroup.map((item) => item.id),
|
|
16628
17616
|
});
|
|
16629
17617
|
}
|
|
16630
17618
|
if (steered === "indeterminate" && !after) {
|
|
@@ -17365,6 +18353,11 @@ const handleRequest = async (req, res) => {
|
|
|
17365
18353
|
}
|
|
17366
18354
|
patch.mcpServers = requestedMcpServers;
|
|
17367
18355
|
}
|
|
18356
|
+
if (body.memoryUpkeep !== undefined) {
|
|
18357
|
+
if (typeof body.memoryUpkeep !== "boolean")
|
|
18358
|
+
return json(res, 400, { error: "memoryUpkeep must be true or false" });
|
|
18359
|
+
patch.memoryUpkeep = body.memoryUpkeep;
|
|
18360
|
+
}
|
|
17368
18361
|
// per-bot gate on the app's built-in browser
|
|
17369
18362
|
if (body.browser !== undefined) {
|
|
17370
18363
|
if (typeof body.browser !== "boolean")
|
|
@@ -17702,6 +18695,8 @@ const handleRequest = async (req, res) => {
|
|
|
17702
18695
|
}
|
|
17703
18696
|
if (!bot)
|
|
17704
18697
|
return json(res, 404, { error: "no such bot" });
|
|
18698
|
+
if (body.memoryUpkeep === false)
|
|
18699
|
+
memoryUpkeep.dropBot(bot.id);
|
|
17705
18700
|
// A defined Works on is the newest explicit choice: this bot's
|
|
17706
18701
|
// auto-recorded pins that now point elsewhere give way immediately, so
|
|
17707
18702
|
// the next turn on each thread follows the new setting. Person-set pins
|
|
@@ -18024,154 +19019,6 @@ const handleRequest = async (req, res) => {
|
|
|
18024
19019
|
broadcast({ kind: "bot", bot: visible });
|
|
18025
19020
|
return json(res, 200, { bot: visible });
|
|
18026
19021
|
}
|
|
18027
|
-
// ── bot memory: MEMORY.md + memory/ topic and log files ──────────────
|
|
18028
|
-
// The files already belong to the person (plain markdown in the bot's
|
|
18029
|
-
// workspace). server/memory-store.ts decides which paths can be reached
|
|
18030
|
-
// and refuses a save whose expectedHash no longer matches the file;
|
|
18031
|
-
// server/memory-journal.ts records every change made here so it can be
|
|
18032
|
-
// read back and reverted. Reads never create the workspace — a bot that
|
|
18033
|
-
// has not run yet simply has nothing to show. Admin scope by default
|
|
18034
|
-
// (request-auth.ts), like the profile routes above.
|
|
18035
|
-
m = path.match(/^\/api\/bots\/([\w-]+)\/memory$/);
|
|
18036
|
-
if (m && method === "GET") {
|
|
18037
|
-
if (!store.bot(m[1]))
|
|
18038
|
-
return json(res, 404, { error: "no such bot" });
|
|
18039
|
-
try {
|
|
18040
|
-
const overview = memoryOverview(m[1]);
|
|
18041
|
-
// `text` and `truncated` ride along one release for clients of the
|
|
18042
|
-
// old whole-file shape; the panel reads the file through /memory/file
|
|
18043
|
-
return json(res, 200, { ...overview, text: readMemoryDoc(m[1], MEMORY_INDEX).text, truncated: overview.index.truncated });
|
|
18044
|
-
}
|
|
18045
|
-
catch (error) {
|
|
18046
|
-
return replyMemoryError(res, error);
|
|
18047
|
-
}
|
|
18048
|
-
}
|
|
18049
|
-
if (m && method === "PUT") {
|
|
18050
|
-
// The pre-panel whole-file write, kept one release: no hash check, so
|
|
18051
|
-
// it can still overwrite a note the bot just wrote — journaled as the
|
|
18052
|
-
// person's so at least the journal can undo it.
|
|
18053
|
-
if (!store.bot(m[1]))
|
|
18054
|
-
return json(res, 404, { error: "no such bot" });
|
|
18055
|
-
const parsed = z.object({ text: z.string() }).safeParse(await readBody(req));
|
|
18056
|
-
if (!parsed.success)
|
|
18057
|
-
return json(res, 400, { error: "text must be a string" });
|
|
18058
|
-
try {
|
|
18059
|
-
const { doc } = journalMemoryWrite(m[1], MEMORY_INDEX, parsed.data.text, { actor: "person", via: "api" });
|
|
18060
|
-
return json(res, 200, { ok: true, hash: doc.hash, truncated: memoryCapacity(doc.text).truncated });
|
|
18061
|
-
}
|
|
18062
|
-
catch (error) {
|
|
18063
|
-
// the old route answered 400 for an oversized body; keep that for
|
|
18064
|
-
// its callers while the new route says 413
|
|
18065
|
-
if (error instanceof MemoryStoreError && error.code === "too-large")
|
|
18066
|
-
return json(res, 400, { error: error.message });
|
|
18067
|
-
return replyMemoryError(res, error);
|
|
18068
|
-
}
|
|
18069
|
-
}
|
|
18070
|
-
m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/file$/);
|
|
18071
|
-
if (m && method === "GET") {
|
|
18072
|
-
if (!store.bot(m[1]))
|
|
18073
|
-
return json(res, 404, { error: "no such bot" });
|
|
18074
|
-
try {
|
|
18075
|
-
return json(res, 200, readMemoryDoc(m[1], url.searchParams.get("path") ?? MEMORY_INDEX));
|
|
18076
|
-
}
|
|
18077
|
-
catch (error) {
|
|
18078
|
-
return replyMemoryError(res, error);
|
|
18079
|
-
}
|
|
18080
|
-
}
|
|
18081
|
-
if (m && method === "PUT") {
|
|
18082
|
-
if (!store.bot(m[1]))
|
|
18083
|
-
return json(res, 404, { error: "no such bot" });
|
|
18084
|
-
const parsed = z
|
|
18085
|
-
.object({ path: z.string().default(MEMORY_INDEX), text: z.string(), expectedHash: z.string().optional() })
|
|
18086
|
-
.safeParse(await readBody(req));
|
|
18087
|
-
if (!parsed.success)
|
|
18088
|
-
return json(res, 400, { error: "text must be a string; path and expectedHash are optional strings" });
|
|
18089
|
-
try {
|
|
18090
|
-
const { doc, entry } = journalMemoryWrite(m[1], parsed.data.path, parsed.data.text, {
|
|
18091
|
-
actor: "person",
|
|
18092
|
-
via: "ui",
|
|
18093
|
-
expectedHash: parsed.data.expectedHash,
|
|
18094
|
-
});
|
|
18095
|
-
return json(res, 200, { ok: true, ...doc, entry: entry ? journalEntryForClient(m[1], entry) : null, overview: memoryOverview(m[1]) });
|
|
18096
|
-
}
|
|
18097
|
-
catch (error) {
|
|
18098
|
-
return replyMemoryError(res, error);
|
|
18099
|
-
}
|
|
18100
|
-
}
|
|
18101
|
-
if (m && method === "DELETE") {
|
|
18102
|
-
if (!store.bot(m[1]))
|
|
18103
|
-
return json(res, 404, { error: "no such bot" });
|
|
18104
|
-
const file = url.searchParams.get("path") ?? "";
|
|
18105
|
-
try {
|
|
18106
|
-
const entry = journalMemoryDelete(m[1], file, { actor: "person", via: "ui" });
|
|
18107
|
-
return json(res, 200, { ok: true, path: file, entry: entry ? journalEntryForClient(m[1], entry) : null, overview: memoryOverview(m[1]) });
|
|
18108
|
-
}
|
|
18109
|
-
catch (error) {
|
|
18110
|
-
return replyMemoryError(res, error);
|
|
18111
|
-
}
|
|
18112
|
-
}
|
|
18113
|
-
m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/journal$/);
|
|
18114
|
-
if (m && method === "GET") {
|
|
18115
|
-
if (!store.bot(m[1]))
|
|
18116
|
-
return json(res, 404, { error: "no such bot" });
|
|
18117
|
-
// the row a save queued a moment ago may not have reached disk yet
|
|
18118
|
-
await flushMemoryJournal(m[1]);
|
|
18119
|
-
const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit") ?? 50) || 50));
|
|
18120
|
-
const botId = m[1];
|
|
18121
|
-
return json(res, 200, { entries: readMemoryJournal(botId, limit).map((entry) => journalEntryForClient(botId, entry)) });
|
|
18122
|
-
}
|
|
18123
|
-
m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/journal\/([\w-]+)\/revert$/);
|
|
18124
|
-
if (m && method === "POST") {
|
|
18125
|
-
if (!store.bot(m[1]))
|
|
18126
|
-
return json(res, 404, { error: "no such bot" });
|
|
18127
|
-
await flushMemoryJournal(m[1]);
|
|
18128
|
-
const result = revertMemoryChange(m[1], m[2]);
|
|
18129
|
-
if (!result.ok)
|
|
18130
|
-
return json(res, result.status, { error: result.error });
|
|
18131
|
-
return json(res, 200, { ok: true, ...result.doc, entry: result.entry ? journalEntryForClient(m[1], result.entry) : null, overview: memoryOverview(m[1]) });
|
|
18132
|
-
}
|
|
18133
|
-
m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/open$/);
|
|
18134
|
-
if (m && method === "POST") {
|
|
18135
|
-
if (!store.bot(m[1]))
|
|
18136
|
-
return json(res, 404, { error: "no such bot" });
|
|
18137
|
-
const parsed = z.object({ target: z.enum(["obsidian", "folder"]) }).safeParse(await readBody(req));
|
|
18138
|
-
if (!parsed.success)
|
|
18139
|
-
return json(res, 400, { error: "target must be obsidian or folder" });
|
|
18140
|
-
// The folder is on this machine's disk; opening it only makes sense
|
|
18141
|
-
// from this machine. A paired phone or a remote browser gets the path
|
|
18142
|
-
// to open by hand instead.
|
|
18143
|
-
const workspacePath = memoryOverview(m[1]).workspacePath;
|
|
18144
|
-
if (auth.kind !== "loopback") {
|
|
18145
|
-
return json(res, 403, { error: `This only works on the computer running OpenMausBot. The memory folder there is ${workspacePath}`, workspacePath });
|
|
18146
|
-
}
|
|
18147
|
-
const opened = await openMemoryLocation(m[1], parsed.data.target);
|
|
18148
|
-
if (!opened.ok)
|
|
18149
|
-
return json(res, 500, { error: opened.error, workspacePath: opened.workspacePath });
|
|
18150
|
-
return json(res, 200, opened);
|
|
18151
|
-
}
|
|
18152
|
-
m = path.match(/^\/api\/bots\/([\w-]+)\/memory\/topics\/([^/]+)$/);
|
|
18153
|
-
if (m && method === "GET") {
|
|
18154
|
-
if (!store.bot(m[1]))
|
|
18155
|
-
return json(res, 404, { error: "no such bot" });
|
|
18156
|
-
// Decode before validating: a UI-sent name arrives percent-encoded
|
|
18157
|
-
// ("my notes.md" → "my%20notes.md"), and an encoded traversal
|
|
18158
|
-
// ("..%2F..") must be judged by what it decodes TO, not slip through
|
|
18159
|
-
// as an opaque token. The name gate then rejects anything that is not
|
|
18160
|
-
// a single plain-markdown path segment.
|
|
18161
|
-
let name;
|
|
18162
|
-
try {
|
|
18163
|
-
name = decodeURIComponent(m[2]);
|
|
18164
|
-
}
|
|
18165
|
-
catch {
|
|
18166
|
-
return json(res, 400, { error: "invalid topic name" });
|
|
18167
|
-
}
|
|
18168
|
-
if (!isMemoryTopicName(name))
|
|
18169
|
-
return json(res, 400, { error: "invalid topic name" });
|
|
18170
|
-
const text = readMemoryTopic(m[1], name);
|
|
18171
|
-
if (text === null)
|
|
18172
|
-
return json(res, 404, { error: "no such topic file" });
|
|
18173
|
-
return json(res, 200, { name, text });
|
|
18174
|
-
}
|
|
18175
19022
|
// ── workspace checkpoints: per-turn shadow-git snapshots ────────────
|
|
18176
19023
|
// The list endpoint is the source of truth (turns store nothing), and
|
|
18177
19024
|
// `enabled` tells the UI whether snapshots can happen here at all —
|
|
@@ -18234,6 +19081,12 @@ const handleRequest = async (req, res) => {
|
|
|
18234
19081
|
if (existing.card.requestId) {
|
|
18235
19082
|
return json(res, 409, { error: "request cards must be answered through the approval endpoint" });
|
|
18236
19083
|
}
|
|
19084
|
+
// On a Cloud home only the owner answers cards (cardAnswerRefusal).
|
|
19085
|
+
// This route only keeps how an options card looks; the answer itself
|
|
19086
|
+
// reaches the bot as a chat line, which lending already checks.
|
|
19087
|
+
const cloudRefusal = CLOUD_HOME && body.answered !== undefined && auth.kind === "session" ? cloudCardRefusal(auth) : null;
|
|
19088
|
+
if (cloudRefusal)
|
|
19089
|
+
return json(res, 403, { error: cloudRefusal });
|
|
18237
19090
|
if (Object.keys(body).some((key) => key !== "answered" && key !== "dismissed" && key !== "threadId")) {
|
|
18238
19091
|
return json(res, 400, { error: "only answered and dismissed may be changed" });
|
|
18239
19092
|
}
|
|
@@ -18248,6 +19101,7 @@ const handleRequest = async (req, res) => {
|
|
|
18248
19101
|
...existing.card,
|
|
18249
19102
|
...(body.answered !== undefined ? { answered: body.answered } : {}),
|
|
18250
19103
|
...(body.dismissed !== undefined ? { dismissed: body.dismissed } : {}),
|
|
19104
|
+
...(CLOUD_HOME && body.answered !== undefined ? { answeredBy: cardAnswererFor(auth) } : {}),
|
|
18251
19105
|
},
|
|
18252
19106
|
});
|
|
18253
19107
|
return json(res, 200, { message: patched });
|
|
@@ -18626,7 +19480,10 @@ const handleRequest = async (req, res) => {
|
|
|
18626
19480
|
// startTurn admits the rerun before branching. A shared-resource or
|
|
18627
19481
|
// concurrency-limit refusal must leave the original transcript intact.
|
|
18628
19482
|
const replyTo = source.replyToId ? resolveReplyTarget(bot.threadId, source.replyToId) : undefined;
|
|
18629
|
-
|
|
19483
|
+
// On a Cloud home an edit is its author's line, so the owner's own edit
|
|
19484
|
+
// keeps their conversation theirs for lending (server/cloud-lending.ts).
|
|
19485
|
+
const message = await startTurn(bot.id, text, { threadId: bot.threadId, editedMessageId: messageId, replyTo, sendId, trigger: usageTriggerFor(auth),
|
|
19486
|
+
...(CLOUD_HOME ? { sender: messageSender(auth) } : {}) });
|
|
18630
19487
|
return json(res, 202, { ok: true, message });
|
|
18631
19488
|
}
|
|
18632
19489
|
// switch which fork of the conversation is visible (no new turn)
|
|
@@ -19203,7 +20060,7 @@ const handleRequest = async (req, res) => {
|
|
|
19203
20060
|
broadcast({ kind: "bot", bot: fresh });
|
|
19204
20061
|
return json(res, 200, { bot: fresh });
|
|
19205
20062
|
}
|
|
19206
|
-
// Named team
|
|
20063
|
+
// Named team Boats use real independent ownership, never a hidden bot or
|
|
19207
20064
|
// an arbitrary provider id. These new routes remain admin-only by default.
|
|
19208
20065
|
if (path === "/api/team-computers" && method === "GET") {
|
|
19209
20066
|
res.setHeader("cache-control", "private, no-store");
|
|
@@ -19240,7 +20097,7 @@ const handleRequest = async (req, res) => {
|
|
|
19240
20097
|
if (!parsed.success)
|
|
19241
20098
|
return json(res, 400, { error: "Choose take or release with a valid optional controlLeaseId" });
|
|
19242
20099
|
const key = teamComputerOwner(found.id);
|
|
19243
|
-
if (parsed.data.action === "take" &&
|
|
20100
|
+
if (parsed.data.action === "take" && boatLifecycleBusyBots.has(key))
|
|
19244
20101
|
return json(res, 409, { error: "Wait for this computer's action to finish before taking control" });
|
|
19245
20102
|
if (parsed.data.action === "take")
|
|
19246
20103
|
assertTeamControlCanBeTaken(found.id);
|
|
@@ -19261,13 +20118,13 @@ const handleRequest = async (req, res) => {
|
|
|
19261
20118
|
assertCurrentOwner();
|
|
19262
20119
|
if (section !== null && section !== "" && !store.sections.includes(section))
|
|
19263
20120
|
throw Object.assign(new Error("Create the team before assigning a computer"), { status: 404 });
|
|
19264
|
-
if (section !== null && store.bots.some(bot => sectionKey(bot.section) === section && (botHasActiveTurn(bot.id) || routines?.activeRunForBot(bot.id) || botComputerControlSnapshot(bot.id).held ||
|
|
20121
|
+
if (section !== null && store.bots.some(bot => sectionKey(bot.section) === section && (botHasActiveTurn(bot.id) || routines?.activeRunForBot(bot.id) || botComputerControlSnapshot(bot.id).held || boatLifecycleBusyBots.has(bot.id))))
|
|
19265
20122
|
throw Object.assign(new Error("Stop the target team's work and release computer control before assigning this computer"), { status: 409 });
|
|
19266
20123
|
};
|
|
19267
20124
|
checkAssignment();
|
|
19268
20125
|
const release = claimTeamComputerLifecycle(found);
|
|
19269
20126
|
try {
|
|
19270
|
-
if (section !== null && !(await
|
|
20127
|
+
if (section !== null && !(await boat.findBoat(cfg, teamComputerOwner(found.id))))
|
|
19271
20128
|
return json(res, 409, { error: "Create or retry this computer before assigning it to a team" });
|
|
19272
20129
|
checkAssignment();
|
|
19273
20130
|
if (teamComputerInUse(found))
|
|
@@ -19282,13 +20139,13 @@ const handleRequest = async (req, res) => {
|
|
|
19282
20139
|
if (method === "POST" && !computerId) {
|
|
19283
20140
|
const parsed = teamComputerCreate.safeParse(body);
|
|
19284
20141
|
if (!parsed.success)
|
|
19285
|
-
return json(res, 400, { error: "Provide requestId (UUID), a name, and acknowledgeCost: true to create a paid
|
|
19286
|
-
if (!
|
|
19287
|
-
return json(res, 409, { error: "Configure
|
|
20142
|
+
return json(res, 400, { error: "Provide requestId (UUID), a name, and acknowledgeCost: true to create a paid Boat" });
|
|
20143
|
+
if (!boat.boatConfigured(cfg))
|
|
20144
|
+
return json(res, 409, { error: "Configure Boat in Settings before creating a cloud computer" });
|
|
19288
20145
|
const computer = teamComputers.create(parsed.data.name, parsed.data.requestId);
|
|
19289
20146
|
const release = claimTeamComputerLifecycle(computer);
|
|
19290
20147
|
try {
|
|
19291
|
-
await
|
|
20148
|
+
await boat.provisionBoat(cfg, teamComputerOwner(computer.id), computer.name);
|
|
19292
20149
|
teamComputers.setProblem(computer.id);
|
|
19293
20150
|
return json(res, 201, { id: computer.id });
|
|
19294
20151
|
}
|
|
@@ -19302,18 +20159,18 @@ const handleRequest = async (req, res) => {
|
|
|
19302
20159
|
}
|
|
19303
20160
|
if (method === "POST" && found && action && action !== "control") {
|
|
19304
20161
|
if (action === "provision" && body?.acknowledgeCost !== true)
|
|
19305
|
-
return json(res, 400, { error: "Confirm
|
|
20162
|
+
return json(res, 400, { error: "Confirm Boat creation or wake costs with acknowledgeCost: true" });
|
|
19306
20163
|
// A ready-only join never wakes, provisions or steals an agent's turn.
|
|
19307
20164
|
// The caller takes a separate explicit human-control lease first.
|
|
19308
20165
|
if (action === "join") {
|
|
19309
20166
|
const key = teamComputerOwner(found.id);
|
|
19310
|
-
if (
|
|
20167
|
+
if (boatLifecycleBusyBots.has(key))
|
|
19311
20168
|
return json(res, 409, { error: "Wait for this computer's action to finish" });
|
|
19312
20169
|
if (!computerControl.snapshot(key).held)
|
|
19313
20170
|
return json(res, 409, { error: "Take control before opening this shared desktop" });
|
|
19314
20171
|
const release = claimBotComputerLifecycle(key);
|
|
19315
20172
|
try {
|
|
19316
|
-
return json(res, 200, await
|
|
20173
|
+
return json(res, 200, await boat.joinReadyBoat(cfg, key));
|
|
19317
20174
|
}
|
|
19318
20175
|
finally {
|
|
19319
20176
|
release();
|
|
@@ -19322,8 +20179,8 @@ const handleRequest = async (req, res) => {
|
|
|
19322
20179
|
const release = claimTeamComputerLifecycle(found);
|
|
19323
20180
|
try {
|
|
19324
20181
|
const result = action === "provision"
|
|
19325
|
-
? await
|
|
19326
|
-
: await
|
|
20182
|
+
? await boat.provisionBoat(cfg, teamComputerOwner(found.id), found.name)
|
|
20183
|
+
: await boat.sleepBoat(cfg, teamComputerOwner(found.id));
|
|
19327
20184
|
teamComputers.setProblem(found.id);
|
|
19328
20185
|
return json(res, 200, result);
|
|
19329
20186
|
}
|
|
@@ -19337,12 +20194,13 @@ const handleRequest = async (req, res) => {
|
|
|
19337
20194
|
}
|
|
19338
20195
|
return json(res, 405, { error: "method not allowed" });
|
|
19339
20196
|
}
|
|
19340
|
-
// Account-wide
|
|
20197
|
+
// Account-wide Boat inventory is a Settings surface, never a provisioning
|
|
19341
20198
|
// path. Listing remains read-only; lifecycle changes require explicit
|
|
19342
20199
|
// JSON actions and are revalidated against a fresh provider listing.
|
|
20200
|
+
// The REST path keeps its historical /boxes segment.
|
|
19343
20201
|
if (method === "GET" && path === "/api/computers/boxes") {
|
|
19344
20202
|
res.setHeader("cache-control", "private, no-store");
|
|
19345
|
-
return json(res, 200, await
|
|
20203
|
+
return json(res, 200, await boat.listManagedBoats(cfg, managedBoatOwners()));
|
|
19346
20204
|
}
|
|
19347
20205
|
m = path.match(/^\/api\/computers\/boxes\/([\w-]+)\/(sleep|delete)$/);
|
|
19348
20206
|
if (m && method === "POST") {
|
|
@@ -19353,16 +20211,16 @@ const handleRequest = async (req, res) => {
|
|
|
19353
20211
|
if (computerProviderConfigTransitions.has("box")) {
|
|
19354
20212
|
return json(res, 409, { error: providerTransitionMessage("box") });
|
|
19355
20213
|
}
|
|
19356
|
-
const releaseInventoryRequest =
|
|
20214
|
+
const releaseInventoryRequest = claimBoatInventoryRequest(m[1]);
|
|
19357
20215
|
try {
|
|
19358
|
-
const owners =
|
|
20216
|
+
const owners = managedBoatOwners();
|
|
19359
20217
|
if (m[2] === "sleep") {
|
|
19360
|
-
return json(res, 200, await
|
|
20218
|
+
return json(res, 200, await boat.sleepManagedBoat(cfg, owners, m[1], claimManagedBoatMutation));
|
|
19361
20219
|
}
|
|
19362
20220
|
if (typeof body?.confirmName !== "string" || body.confirmName.length > 100) {
|
|
19363
20221
|
return json(res, 400, { error: "confirmName must be the cloud computer name shown in Settings" });
|
|
19364
20222
|
}
|
|
19365
|
-
return json(res, 202, await
|
|
20223
|
+
return json(res, 202, await boat.deleteManagedBoat(cfg, owners, m[1], body.confirmName, claimManagedBoatMutation));
|
|
19366
20224
|
}
|
|
19367
20225
|
finally {
|
|
19368
20226
|
releaseInventoryRequest();
|
|
@@ -19370,7 +20228,7 @@ const handleRequest = async (req, res) => {
|
|
|
19370
20228
|
}
|
|
19371
20229
|
if (method === "GET" && path === "/api/computers/vps") {
|
|
19372
20230
|
res.setHeader("cache-control", "private, no-store");
|
|
19373
|
-
return json(res, 200, await vps.listManagedVpsComputers(cfg,
|
|
20231
|
+
return json(res, 200, await vps.listManagedVpsComputers(cfg, managedBoatOwners()));
|
|
19374
20232
|
}
|
|
19375
20233
|
m = path.match(/^\/api\/computers\/vps\/([\w-]+)\/remove$/);
|
|
19376
20234
|
if (m && method === "POST") {
|
|
@@ -19386,7 +20244,7 @@ const handleRequest = async (req, res) => {
|
|
|
19386
20244
|
}
|
|
19387
20245
|
const releaseComputerLifecycle = claimManagedVpsMutation(m[1]);
|
|
19388
20246
|
try {
|
|
19389
|
-
return json(res, 200, await vps.removeManagedVpsComputer(cfg,
|
|
20247
|
+
return json(res, 200, await vps.removeManagedVpsComputer(cfg, managedBoatOwners(), m[1], body.confirmName));
|
|
19390
20248
|
}
|
|
19391
20249
|
finally {
|
|
19392
20250
|
releaseComputerLifecycle();
|
|
@@ -19417,6 +20275,9 @@ const handleRequest = async (req, res) => {
|
|
|
19417
20275
|
if (localVmMode(cfg) === "per-bot" && action === "run") {
|
|
19418
20276
|
return json(res, 409, { error: "Per-bot mode creates each desktop from that bot's Computer panel" });
|
|
19419
20277
|
}
|
|
20278
|
+
if (localVmMode(cfg) === "pool" && action === "run") {
|
|
20279
|
+
return json(res, 409, { error: "Pool mode creates its desktops automatically from conversation turns" });
|
|
20280
|
+
}
|
|
19420
20281
|
const vmOwner = localVmLeaseFor(SHARED_LOCAL_VM_TARGET).current(localVmOwnerBusy);
|
|
19421
20282
|
if (vmOwner && (action === "stop" || action === "remove" || action === "run")) {
|
|
19422
20283
|
return json(res, 409, { error: "the Local VM is being used by a bot — stop that turn first" });
|
|
@@ -19464,7 +20325,7 @@ const handleRequest = async (req, res) => {
|
|
|
19464
20325
|
const bot = computerPreviewBot(m[1], url);
|
|
19465
20326
|
if (!bot)
|
|
19466
20327
|
return json(res, 404, { error: "no such bot" });
|
|
19467
|
-
return json(res, 200, await localVmPayload(
|
|
20328
|
+
return json(res, 200, await localVmPayload(localVmTargetForStatus(bot.id, bot.threadId)));
|
|
19468
20329
|
}
|
|
19469
20330
|
m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer\/(run|stop|remove)$/);
|
|
19470
20331
|
if (m && method === "POST") {
|
|
@@ -19474,7 +20335,7 @@ const handleRequest = async (req, res) => {
|
|
|
19474
20335
|
const bot = store.bot(m[1]);
|
|
19475
20336
|
if (!bot)
|
|
19476
20337
|
return json(res, 404, { error: "no such bot" });
|
|
19477
|
-
if (
|
|
20338
|
+
if (boatLifecycleBusyBots.has(bot.id)) {
|
|
19478
20339
|
return json(res, 409, { error: "this bot's computer is being changed or deleted — wait for it to finish" });
|
|
19479
20340
|
}
|
|
19480
20341
|
const action = z.enum(["run", "stop", "remove"]).parse(m[2]);
|
|
@@ -19542,7 +20403,7 @@ const handleRequest = async (req, res) => {
|
|
|
19542
20403
|
if (url.searchParams.has("threadId") && await computerPreviewSurface(bot, bot.threadId) !== "vm") {
|
|
19543
20404
|
return json(res, 409, { error: "This conversation is not using the Local VM" });
|
|
19544
20405
|
}
|
|
19545
|
-
const target =
|
|
20406
|
+
const target = localVmTargetForStatus(bot.id, url.searchParams.has("threadId") ? bot.threadId : undefined);
|
|
19546
20407
|
localVmIdleFor(target).touch();
|
|
19547
20408
|
res.setHeader("cache-control", "private, no-store");
|
|
19548
20409
|
return json(res, 200, {
|
|
@@ -20238,6 +21099,14 @@ const handleRequest = async (req, res) => {
|
|
|
20238
21099
|
}
|
|
20239
21100
|
if (!Object.keys(patch).length)
|
|
20240
21101
|
return json(res, 400, { error: "nothing to save" });
|
|
21102
|
+
if (patch.decider) {
|
|
21103
|
+
// Saving a key is the "turn it on"; clearing it turns it off, or
|
|
21104
|
+
// falls back to Cloud Pro's included decisions where there are some.
|
|
21105
|
+
const planned = deciderSavePatch(patch.decider, cfg.decider, deciderIncludedHere());
|
|
21106
|
+
if (!planned.ok)
|
|
21107
|
+
return json(res, 400, { error: planned.error });
|
|
21108
|
+
patch.decider = planned.patch;
|
|
21109
|
+
}
|
|
20241
21110
|
const changingVoiceProvider = patch.tts?.provider !== undefined
|
|
20242
21111
|
&& patch.tts.provider !== tts.voiceProvider(cfg);
|
|
20243
21112
|
if (changingVoiceProvider && patch.tts?.voice === undefined) {
|
|
@@ -20294,16 +21163,16 @@ const handleRequest = async (req, res) => {
|
|
|
20294
21163
|
}
|
|
20295
21164
|
if (patch.box?.token !== undefined)
|
|
20296
21165
|
patch.box.token = patch.box.token.trim();
|
|
20297
|
-
const
|
|
20298
|
-
const
|
|
20299
|
-
const
|
|
21166
|
+
const currentBoatToken = cfg.box?.token?.trim() ?? "";
|
|
21167
|
+
const nextBoatToken = patch.box?.token === undefined ? currentBoatToken : patch.box.token;
|
|
21168
|
+
const changingBoatToken = patch.box?.token !== undefined && nextBoatToken !== currentBoatToken;
|
|
20300
21169
|
const currentVpsAlias = vpsSshAlias(cfg);
|
|
20301
21170
|
const nextVpsAlias = patch.vps === undefined
|
|
20302
21171
|
? currentVpsAlias
|
|
20303
21172
|
: vpsSshAlias({ ...cfg, vps: patch.vps });
|
|
20304
21173
|
const changingVpsAlias = patch.vps !== undefined && nextVpsAlias !== currentVpsAlias;
|
|
20305
21174
|
const transitioningProviders = [
|
|
20306
|
-
...(
|
|
21175
|
+
...(changingBoatToken ? ["box"] : []),
|
|
20307
21176
|
...(changingVpsAlias ? ["vps"] : []),
|
|
20308
21177
|
];
|
|
20309
21178
|
providerConfigBusy = true;
|
|
@@ -20319,7 +21188,7 @@ const handleRequest = async (req, res) => {
|
|
|
20319
21188
|
for (const provider of transitioningProviders)
|
|
20320
21189
|
computerProviderConfigTransitions.add(provider);
|
|
20321
21190
|
if (changingVpsAlias && currentVpsAlias) {
|
|
20322
|
-
const inventory = await vps.listManagedVpsComputers({ vps: { sshAlias: currentVpsAlias } },
|
|
21191
|
+
const inventory = await vps.listManagedVpsComputers({ vps: { sshAlias: currentVpsAlias } }, managedBoatOwners());
|
|
20323
21192
|
if (!inventory.available) {
|
|
20324
21193
|
return json(res, 503, {
|
|
20325
21194
|
error: `${inventory.problem ?? "VPS computer inventory is unavailable"}. Keep the current SSH config alias and retry`,
|
|
@@ -20329,24 +21198,24 @@ const handleRequest = async (req, res) => {
|
|
|
20329
21198
|
if (resourceError)
|
|
20330
21199
|
return json(res, 409, { error: resourceError });
|
|
20331
21200
|
}
|
|
20332
|
-
let
|
|
20333
|
-
let
|
|
20334
|
-
if (
|
|
21201
|
+
let boatRecovery = changingBoatToken ? boatCreateRecoverySnapshot() : [];
|
|
21202
|
+
let boatDeletions = changingBoatToken ? boatDeletionSnapshot() : [];
|
|
21203
|
+
if (changingBoatToken && boatDeletions.length > 0 && !nextBoatToken) {
|
|
20335
21204
|
return json(res, 409, {
|
|
20336
|
-
error: "finish or retry pending cloud computer deletion before removing the
|
|
21205
|
+
error: "finish or retry pending cloud computer deletion before removing the Boat account",
|
|
20337
21206
|
});
|
|
20338
21207
|
}
|
|
20339
21208
|
let replacementProvedByDeletion = false;
|
|
20340
|
-
if (
|
|
21209
|
+
if (changingBoatToken && boatDeletions.length > 0 && nextBoatToken) {
|
|
20341
21210
|
try {
|
|
20342
|
-
await
|
|
21211
|
+
await boat.verifyBoatDeletionCredential({ box: { token: nextBoatToken } });
|
|
20343
21212
|
replacementProvedByDeletion = true;
|
|
20344
21213
|
// Verification can observe a completed operation and retire both
|
|
20345
21214
|
// its deletion fence and matching create receipt. Never continue
|
|
20346
21215
|
// with the pre-verification snapshots: they would demand access
|
|
20347
|
-
// to a
|
|
20348
|
-
|
|
20349
|
-
|
|
21216
|
+
// to a Boat whose exact operation just proved it was deleted.
|
|
21217
|
+
boatRecovery = boatCreateRecoverySnapshot();
|
|
21218
|
+
boatDeletions = boatDeletionSnapshot();
|
|
20350
21219
|
}
|
|
20351
21220
|
catch (error) {
|
|
20352
21221
|
return json(res, error?.status ?? 503, {
|
|
@@ -20354,28 +21223,28 @@ const handleRequest = async (req, res) => {
|
|
|
20354
21223
|
});
|
|
20355
21224
|
}
|
|
20356
21225
|
}
|
|
20357
|
-
let
|
|
20358
|
-
let
|
|
20359
|
-
let
|
|
20360
|
-
const
|
|
20361
|
-
const
|
|
20362
|
-
if (
|
|
20363
|
-
|
|
20364
|
-
if (!
|
|
20365
|
-
|
|
20366
|
-
if (!replacementProvedByDeletion && !
|
|
21226
|
+
let currentBoatInventory = null;
|
|
21227
|
+
let replacingRejectedBoatToken = false;
|
|
21228
|
+
let currentBoatResources = null;
|
|
21229
|
+
const journalBoatResources = [];
|
|
21230
|
+
const deletingBoatIds = new Set(boatDeletions.map((entry) => entry.boxId));
|
|
21231
|
+
if (changingBoatToken && currentBoatToken) {
|
|
21232
|
+
currentBoatInventory = await boat.listManagedBoats({ box: { token: currentBoatToken } }, managedBoatOwners());
|
|
21233
|
+
if (!currentBoatInventory.available) {
|
|
21234
|
+
replacingRejectedBoatToken = currentBoatInventory.credentialRejected === true && Boolean(nextBoatToken);
|
|
21235
|
+
if (!replacementProvedByDeletion && !replacingRejectedBoatToken) {
|
|
20367
21236
|
return json(res, 503, {
|
|
20368
|
-
error: `${
|
|
21237
|
+
error: `${currentBoatInventory.problem ?? "cloud computer inventory is unavailable"}. Keep the current Boat account and retry`,
|
|
20369
21238
|
});
|
|
20370
21239
|
}
|
|
20371
21240
|
// A rejected old key cannot authorize its own rotation. Below,
|
|
20372
|
-
// validate the replacement and prove access to remembered
|
|
21241
|
+
// validate the replacement and prove access to remembered Boats;
|
|
20373
21242
|
// pending deletions retain their target-bound verification.
|
|
20374
|
-
|
|
21243
|
+
currentBoatInventory = null;
|
|
20375
21244
|
}
|
|
20376
|
-
if (
|
|
20377
|
-
const currentById = new Map(
|
|
20378
|
-
for (const recovery of
|
|
21245
|
+
if (currentBoatInventory) {
|
|
21246
|
+
const currentById = new Map(currentBoatInventory.instances.map((instance) => [instance.boxId, { boxId: instance.boxId, name: instance.name }]));
|
|
21247
|
+
for (const recovery of boatRecovery) {
|
|
20379
21248
|
if (!recovery.boxId)
|
|
20380
21249
|
continue;
|
|
20381
21250
|
// A failed provisioning attempt may never have reached the
|
|
@@ -20383,29 +21252,29 @@ const handleRequest = async (req, res) => {
|
|
|
20383
21252
|
// proved the exact deletion target, so its in-flight resource
|
|
20384
21253
|
// is governed by that stronger target-bound receipt rather
|
|
20385
21254
|
// than an OpenMausBot name check.
|
|
20386
|
-
if (replacementProvedByDeletion &&
|
|
21255
|
+
if (replacementProvedByDeletion && deletingBoatIds.has(recovery.boxId))
|
|
20387
21256
|
continue;
|
|
20388
|
-
const inspected = await
|
|
21257
|
+
const inspected = await boat.inspectBoatIdentity({ box: { token: currentBoatToken } }, recovery.boxId);
|
|
20389
21258
|
if (!inspected.available) {
|
|
20390
21259
|
return json(res, 503, {
|
|
20391
|
-
error: `${inspected.problem ?? "a remembered cloud computer could not be verified"}. Keep the current
|
|
21260
|
+
error: `${inspected.problem ?? "a remembered cloud computer could not be verified"}. Keep the current Boat account and retry`,
|
|
20392
21261
|
});
|
|
20393
21262
|
}
|
|
20394
21263
|
if (!inspected.identity) {
|
|
20395
21264
|
// Reconcile exact stale receipts while the current credential is
|
|
20396
21265
|
// still available. Leaving one behind would make a later token
|
|
20397
|
-
// addition demand access to a
|
|
20398
|
-
|
|
21266
|
+
// addition demand access to a Boat the provider proved is gone.
|
|
21267
|
+
retireDeletedBoatCreate(recovery.boxId);
|
|
20399
21268
|
continue;
|
|
20400
21269
|
}
|
|
20401
21270
|
const listed = currentById.get(inspected.identity.boxId);
|
|
20402
21271
|
if (listed && listed.name !== inspected.identity.name) {
|
|
20403
|
-
return json(res, 503, { error: "boat.dev returned conflicting cloud computer identities; keep the current
|
|
21272
|
+
return json(res, 503, { error: "boat.dev returned conflicting cloud computer identities; keep the current Boat account and retry" });
|
|
20404
21273
|
}
|
|
20405
21274
|
currentById.set(inspected.identity.boxId, inspected.identity);
|
|
20406
|
-
|
|
21275
|
+
journalBoatResources.push(inspected.identity);
|
|
20407
21276
|
}
|
|
20408
|
-
|
|
21277
|
+
currentBoatResources = [...currentById.values()];
|
|
20409
21278
|
}
|
|
20410
21279
|
}
|
|
20411
21280
|
if (changingLocalVmMode) {
|
|
@@ -20444,50 +21313,50 @@ const handleRequest = async (req, res) => {
|
|
|
20444
21313
|
patch.composio = { ...patch.composio, apiKey: "", sessionId: "" };
|
|
20445
21314
|
}
|
|
20446
21315
|
}
|
|
20447
|
-
// check a
|
|
21316
|
+
// check a boat token against the provider before storing it: a
|
|
20448
21317
|
// rejected token used to save happily and only surface as a 401 in
|
|
20449
21318
|
// another panel later, with nothing the user could act on
|
|
20450
|
-
const
|
|
20451
|
-
if (
|
|
20452
|
-
const check = await
|
|
21319
|
+
const newBoatToken = patch.box?.token;
|
|
21320
|
+
if (newBoatToken?.trim()) {
|
|
21321
|
+
const check = await boat.verifyToken(newBoatToken);
|
|
20453
21322
|
if (!check.ok)
|
|
20454
21323
|
return json(res, 400, { error: check.message });
|
|
20455
21324
|
}
|
|
20456
|
-
if (
|
|
21325
|
+
if (replacingRejectedBoatToken) {
|
|
20457
21326
|
// An expired credential cannot authorize its own replacement. Probe
|
|
20458
21327
|
// the new account without adopting or retiring any local identities.
|
|
20459
|
-
const replacement = await
|
|
21328
|
+
const replacement = await boat.listManagedBoats({ box: { token: nextBoatToken } }, managedBoatOwners(), { adoptLegacy: false });
|
|
20460
21329
|
if (!replacement.available)
|
|
20461
|
-
return json(res, 503, { error: replacement.problem ?? "Could not verify the replacement
|
|
21330
|
+
return json(res, 503, { error: replacement.problem ?? "Could not verify the replacement Boat key. Your saved key and computers are unchanged." });
|
|
20462
21331
|
}
|
|
20463
|
-
if (
|
|
20464
|
-
if (!
|
|
20465
|
-
return json(res, 409, { error: "restore the
|
|
21332
|
+
if (changingBoatToken && (!currentBoatToken || replacementProvedByDeletion || replacingRejectedBoatToken) && boatRecovery.length > 0) {
|
|
21333
|
+
if (!nextBoatToken) {
|
|
21334
|
+
return json(res, 409, { error: "restore the Boat account that owns the remembered cloud computers before clearing it" });
|
|
20466
21335
|
}
|
|
20467
|
-
for (const recovery of
|
|
21336
|
+
for (const recovery of boatRecovery) {
|
|
20468
21337
|
if (!recovery.boxId) {
|
|
20469
|
-
return json(res, 409, { error: "finish reconciling pending cloud computer creation before changing the
|
|
21338
|
+
return json(res, 409, { error: "finish reconciling pending cloud computer creation before changing the Boat account" });
|
|
20470
21339
|
}
|
|
20471
|
-
if (replacementProvedByDeletion &&
|
|
21340
|
+
if (replacementProvedByDeletion && deletingBoatIds.has(recovery.boxId))
|
|
20472
21341
|
continue;
|
|
20473
|
-
const inspected = await
|
|
21342
|
+
const inspected = await boat.inspectBoatIdentity({ box: { token: nextBoatToken } }, recovery.boxId);
|
|
20474
21343
|
if (!inspected.available) {
|
|
20475
21344
|
return json(res, 503, {
|
|
20476
|
-
error: `${inspected.problem ?? "a remembered cloud computer could not be verified"}. Retry with the
|
|
21345
|
+
error: `${inspected.problem ?? "a remembered cloud computer could not be verified"}. Retry with the Boat account that created it`,
|
|
20477
21346
|
});
|
|
20478
21347
|
}
|
|
20479
|
-
if (!inspected.identity || !(await
|
|
20480
|
-
return json(res, 409, { error: "that
|
|
21348
|
+
if (!inspected.identity || !(await boat.boatNameMatchesBot(recovery.botId, inspected.identity.name))) {
|
|
21349
|
+
return json(res, 409, { error: "that Boat token cannot access the remembered cloud computers from this installation" });
|
|
20481
21350
|
}
|
|
20482
21351
|
}
|
|
20483
21352
|
}
|
|
20484
|
-
if (
|
|
21353
|
+
if (changingBoatToken && currentBoatInventory && currentBoatResources) {
|
|
20485
21354
|
let replacementResources = null;
|
|
20486
|
-
if (
|
|
20487
|
-
const replacementInventory = await
|
|
21355
|
+
if (nextBoatToken) {
|
|
21356
|
+
const replacementInventory = await boat.listManagedBoats({ box: { token: nextBoatToken } }, managedBoatOwners(), { adoptLegacy: false });
|
|
20488
21357
|
if (!replacementInventory.available) {
|
|
20489
21358
|
return json(res, 503, {
|
|
20490
|
-
error: `${replacementInventory.problem ?? "cloud computer inventory is unavailable"}. Keep the current
|
|
21359
|
+
error: `${replacementInventory.problem ?? "cloud computer inventory is unavailable"}. Keep the current Boat account and retry`,
|
|
20491
21360
|
});
|
|
20492
21361
|
}
|
|
20493
21362
|
replacementResources = replacementInventory.instances.map((instance) => ({
|
|
@@ -20495,21 +21364,21 @@ const handleRequest = async (req, res) => {
|
|
|
20495
21364
|
name: instance.name,
|
|
20496
21365
|
}));
|
|
20497
21366
|
const replacementById = new Map(replacementResources.map((instance) => [instance.boxId, { boxId: instance.boxId, name: instance.name }]));
|
|
20498
|
-
for (const identity of
|
|
20499
|
-
const inspected = await
|
|
21367
|
+
for (const identity of journalBoatResources) {
|
|
21368
|
+
const inspected = await boat.inspectBoatIdentity({ box: { token: nextBoatToken } }, identity.boxId);
|
|
20500
21369
|
if (!inspected.available) {
|
|
20501
21370
|
return json(res, 503, {
|
|
20502
|
-
error: `${inspected.problem ?? "a remembered cloud computer could not be verified"}. Keep the current
|
|
21371
|
+
error: `${inspected.problem ?? "a remembered cloud computer could not be verified"}. Keep the current Boat account and retry`,
|
|
20503
21372
|
});
|
|
20504
21373
|
}
|
|
20505
21374
|
if (!inspected.identity || inspected.identity.name !== identity.name) {
|
|
20506
|
-
return json(res, 409, { error: "the replacement
|
|
21375
|
+
return json(res, 409, { error: "the replacement Boat token does not access the same cloud computers" });
|
|
20507
21376
|
}
|
|
20508
21377
|
replacementById.set(inspected.identity.boxId, inspected.identity);
|
|
20509
21378
|
}
|
|
20510
21379
|
replacementResources = [...replacementById.values()];
|
|
20511
21380
|
}
|
|
20512
|
-
const resourceError =
|
|
21381
|
+
const resourceError = boatAccountResourceChangeError(currentBoatResources, replacementResources);
|
|
20513
21382
|
if (resourceError)
|
|
20514
21383
|
return json(res, 409, { error: resourceError });
|
|
20515
21384
|
}
|
|
@@ -20527,6 +21396,15 @@ const handleRequest = async (req, res) => {
|
|
|
20527
21396
|
if (!check.ok)
|
|
20528
21397
|
return json(res, 400, { error: check.message });
|
|
20529
21398
|
}
|
|
21399
|
+
// A pasted decision-model key is tried once (one tiny yes/no call).
|
|
21400
|
+
// Only a refusal blocks the save: an offline or overloaded service must
|
|
21401
|
+
// not stop a person saving a key that may well be fine.
|
|
21402
|
+
if (patch.decider?.key) {
|
|
21403
|
+
const check = await decider.testKey({ key: patch.decider.key });
|
|
21404
|
+
if (!check.ok && check.reason === "rejected") {
|
|
21405
|
+
return json(res, 400, { error: "Jev rejected this key. Check it at typesafe.ai and paste it again.", code: "decider_rejected" });
|
|
21406
|
+
}
|
|
21407
|
+
}
|
|
20530
21408
|
if (patch.browserProfiles !== undefined) {
|
|
20531
21409
|
// Provider/credential validation above may await the network. A turn
|
|
20532
21410
|
// can start during that window and claim a profile which looked idle
|
|
@@ -20594,6 +21472,8 @@ const handleRequest = async (req, res) => {
|
|
|
20594
21472
|
persisted.tts.key = "";
|
|
20595
21473
|
if (persisted.tts?.fishKey !== undefined)
|
|
20596
21474
|
persisted.tts.fishKey = "";
|
|
21475
|
+
if (persisted.decider?.key !== undefined)
|
|
21476
|
+
persisted.decider.key = "";
|
|
20597
21477
|
if (persisted.imageGen?.key !== undefined)
|
|
20598
21478
|
persisted.imageGen.key = "";
|
|
20599
21479
|
if (persisted.imageGen?.customApiKey !== undefined)
|
|
@@ -20629,7 +21509,7 @@ const handleRequest = async (req, res) => {
|
|
|
20629
21509
|
let browserReferenceCleanupError = null;
|
|
20630
21510
|
if (patch.signIn !== undefined)
|
|
20631
21511
|
sessions.revalidateEmailSessions();
|
|
20632
|
-
if (!
|
|
21512
|
+
if (!lendingEnabled()) {
|
|
20633
21513
|
sharedComputers.close();
|
|
20634
21514
|
sharedComputerControl.close();
|
|
20635
21515
|
}
|
|
@@ -20777,6 +21657,9 @@ const handleRequest = async (req, res) => {
|
|
|
20777
21657
|
return json(res, 200, {
|
|
20778
21658
|
configured: composio.configured(cfg),
|
|
20779
21659
|
mode: composio.connectionMode(cfg),
|
|
21660
|
+
// lets the panel say "add a key" on a fresh server instead of
|
|
21661
|
+
// reporting an outage that never happened
|
|
21662
|
+
setup: composio.connectorSetup(cfg),
|
|
20780
21663
|
source,
|
|
20781
21664
|
cards,
|
|
20782
21665
|
...(pagination ? { pagination } : {}),
|
|
@@ -20984,7 +21867,7 @@ const handleRequest = async (req, res) => {
|
|
|
20984
21867
|
}
|
|
20985
21868
|
return json(res, 405, { error: "method not allowed" });
|
|
20986
21869
|
}
|
|
20987
|
-
// ── the bot's cloud computer (
|
|
21870
|
+
// ── the bot's cloud computer (Boat) ──
|
|
20988
21871
|
m = path.match(/^\/api\/bots\/([\w-]+)\/computer$/);
|
|
20989
21872
|
if (m && method === "GET") {
|
|
20990
21873
|
const bot = computerPreviewBot(m[1], url);
|
|
@@ -20995,10 +21878,10 @@ const handleRequest = async (req, res) => {
|
|
|
20995
21878
|
return json(res, 200, { surface, configured: false, backend: bot.cloudBackend === "vps" ? "vps" : "box" });
|
|
20996
21879
|
const teamComputer = inheritedTeamComputer(bot);
|
|
20997
21880
|
if (teamComputer)
|
|
20998
|
-
return json(res, 200, { surface, backend: "box", teamComputer: { id: teamComputer.id, name: teamComputer.name }, ...(await
|
|
21881
|
+
return json(res, 200, { surface, backend: "box", teamComputer: { id: teamComputer.id, name: teamComputer.name }, ...(await boat.boatStatus(cfg, teamComputerOwner(teamComputer.id))) });
|
|
20999
21882
|
return bot.cloudBackend === "vps"
|
|
21000
21883
|
? json(res, 200, { surface, backend: "vps", ...(await vps.vpsComputerStatus(cfg, bot.id)) })
|
|
21001
|
-
: json(res, 200, { surface, backend: "box", ...(await
|
|
21884
|
+
: json(res, 200, { surface, backend: "box", ...(await boat.boatStatus(cfg, bot.id)) });
|
|
21002
21885
|
}
|
|
21003
21886
|
// Who is driving this bot's computer. GET is the panel's initial read;
|
|
21004
21887
|
// POST take/release/dismiss-help are the person's three moves. The bot
|
|
@@ -21032,7 +21915,7 @@ const handleRequest = async (req, res) => {
|
|
|
21032
21915
|
return json(res, 400, { error: "controlLeaseId is invalid" });
|
|
21033
21916
|
}
|
|
21034
21917
|
const controlLeaseId = leaseResult?.data;
|
|
21035
|
-
if (action === "take" && (
|
|
21918
|
+
if (action === "take" && (boatLifecycleBusyBots.has(bot.id) || boatLifecycleBusyBots.has(controlKey))) {
|
|
21036
21919
|
return json(res, 409, { error: "this bot's cloud computer is being changed — wait before taking control" });
|
|
21037
21920
|
}
|
|
21038
21921
|
if (action === "take" && controlLeaseId) {
|
|
@@ -21082,7 +21965,7 @@ const handleRequest = async (req, res) => {
|
|
|
21082
21965
|
// request (same reasoning as the Local VM lifecycle routes above): a
|
|
21083
21966
|
// hostile page cannot submit it with a form, and its cross-origin JSON
|
|
21084
21967
|
// request dies in the preflight this server never answers. Applied to
|
|
21085
|
-
// both backends — the
|
|
21968
|
+
// both backends — the Boat branch runs commands too.
|
|
21086
21969
|
if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
|
|
21087
21970
|
return json(res, 415, { error: "content-type must be application/json" });
|
|
21088
21971
|
}
|
|
@@ -21090,13 +21973,13 @@ const handleRequest = async (req, res) => {
|
|
|
21090
21973
|
if (computerProviderConfigTransitions.has(remoteProvider)) {
|
|
21091
21974
|
return json(res, 409, { error: providerTransitionMessage(remoteProvider) });
|
|
21092
21975
|
}
|
|
21093
|
-
if (
|
|
21976
|
+
if (boatLifecycleBusyBots.has(botId)) {
|
|
21094
21977
|
return json(res, 409, { error: "this bot's cloud computer is being changed — wait for it to finish" });
|
|
21095
21978
|
}
|
|
21096
21979
|
const teamComputer = inheritedTeamComputer(bot);
|
|
21097
21980
|
if (teamComputer) {
|
|
21098
21981
|
const key = teamComputerOwner(teamComputer.id);
|
|
21099
|
-
if (
|
|
21982
|
+
if (boatLifecycleBusyBots.has(key))
|
|
21100
21983
|
return json(res, 409, { error: "This team computer is being changed; wait for it to finish" });
|
|
21101
21984
|
if (m[2] === "provision" || m[2] === "remove")
|
|
21102
21985
|
return json(res, 409, { error: "Manage this shared computer from the Team map" });
|
|
@@ -21107,12 +21990,12 @@ const handleRequest = async (req, res) => {
|
|
|
21107
21990
|
const release = m[2] === "sleep" ? claimTeamComputerLifecycle(teamComputer) : claimBotComputerLifecycle(key);
|
|
21108
21991
|
try {
|
|
21109
21992
|
if (m[2] === "join")
|
|
21110
|
-
return json(res, 200, await
|
|
21993
|
+
return json(res, 200, await boat.joinReadyBoat(cfg, key));
|
|
21111
21994
|
if (m[2] === "screenshot") {
|
|
21112
21995
|
res.setHeader("cache-control", "private, no-store");
|
|
21113
|
-
return json(res, 200, await
|
|
21996
|
+
return json(res, 200, await boat.screenshotBoat(cfg, key));
|
|
21114
21997
|
}
|
|
21115
|
-
return json(res, 200, await
|
|
21998
|
+
return json(res, 200, await boat.sleepBoat(cfg, key));
|
|
21116
21999
|
}
|
|
21117
22000
|
finally {
|
|
21118
22001
|
release();
|
|
@@ -21153,8 +22036,8 @@ const handleRequest = async (req, res) => {
|
|
|
21153
22036
|
releaseComputerLifecycle();
|
|
21154
22037
|
}
|
|
21155
22038
|
}
|
|
21156
|
-
const
|
|
21157
|
-
if (["provision", "sleep"].includes(m[2]) &&
|
|
22039
|
+
const activeBoatTurn = botHasActiveTurn(botId);
|
|
22040
|
+
if (["provision", "sleep"].includes(m[2]) && activeBoatTurn) {
|
|
21158
22041
|
return json(res, 409, {
|
|
21159
22042
|
error: CLOUD_COMPUTER_BUSY_ERROR,
|
|
21160
22043
|
});
|
|
@@ -21162,39 +22045,39 @@ const handleRequest = async (req, res) => {
|
|
|
21162
22045
|
// Input validity is independent of destination authorization. Preserve
|
|
21163
22046
|
// the stable 400 contract for oversized commands without contacting the
|
|
21164
22047
|
// provider; a valid Auto request still reaches the 409 gate below.
|
|
21165
|
-
let
|
|
22048
|
+
let boatCommand;
|
|
21166
22049
|
if (m[2] === "exec") {
|
|
21167
22050
|
const body = await readBody(req);
|
|
21168
|
-
|
|
21169
|
-
if (
|
|
22051
|
+
boatCommand = String(body?.command ?? "");
|
|
22052
|
+
if (boatCommand.length > boat.MAX_REMOTE_COMMAND_LENGTH) {
|
|
21170
22053
|
return json(res, 400, {
|
|
21171
|
-
error: `command is too long (maximum ${
|
|
22054
|
+
error: `command is too long (maximum ${boat.MAX_REMOTE_COMMAND_LENGTH} characters)`,
|
|
21172
22055
|
});
|
|
21173
22056
|
}
|
|
21174
22057
|
}
|
|
21175
22058
|
if (bot.computer !== "cloud" && !threadPreview) {
|
|
21176
22059
|
return json(res, 409, {
|
|
21177
|
-
error: "Choose Cloud before changing or opening this
|
|
22060
|
+
error: "Choose Cloud before changing or opening this Boat. Auto only checks existing computer state.",
|
|
21178
22061
|
});
|
|
21179
22062
|
}
|
|
21180
22063
|
if (m[2] === "remove") {
|
|
21181
|
-
//
|
|
21182
|
-
return json(res, 409, { error: "the cloud
|
|
22064
|
+
// Boats sleep and wake; only the VPS backend has a container to remove.
|
|
22065
|
+
return json(res, 409, { error: "the cloud Boat backend has no container to remove — use sleep instead" });
|
|
21183
22066
|
}
|
|
21184
22067
|
const releaseComputerLifecycle = claimBotComputerLifecycle(botId);
|
|
21185
22068
|
try {
|
|
21186
22069
|
switch (m[2]) {
|
|
21187
22070
|
case "provision":
|
|
21188
|
-
return json(res, 200, await
|
|
22071
|
+
return json(res, 200, await boat.provisionBoat(cfg, botId, bot.name));
|
|
21189
22072
|
case "join":
|
|
21190
|
-
return json(res, 200, await (
|
|
22073
|
+
return json(res, 200, await (activeBoatTurn || threadPreview ? boat.joinReadyBoat(cfg, botId) : boat.joinBoat(cfg, botId)));
|
|
21191
22074
|
case "sleep":
|
|
21192
|
-
return json(res, 200, await
|
|
22075
|
+
return json(res, 200, await boat.sleepBoat(cfg, botId));
|
|
21193
22076
|
case "exec":
|
|
21194
|
-
return json(res, 200, await
|
|
22077
|
+
return json(res, 200, await boat.execOnBoat(cfg, botId, boatCommand ?? ""));
|
|
21195
22078
|
case "screenshot":
|
|
21196
22079
|
res.setHeader("cache-control", "private, no-store");
|
|
21197
|
-
return json(res, 200, await
|
|
22080
|
+
return json(res, 200, await boat.screenshotBoat(cfg, botId));
|
|
21198
22081
|
}
|
|
21199
22082
|
}
|
|
21200
22083
|
finally {
|
|
@@ -21364,6 +22247,7 @@ server.listen(PORT, "127.0.0.1", () => {
|
|
|
21364
22247
|
// ordinary chat. Start only once every registry is initialized and the
|
|
21365
22248
|
// endpoint is listening; earlier dispatch can hit uninitialized bindings.
|
|
21366
22249
|
routines.start();
|
|
22250
|
+
memoryUpkeep.start();
|
|
21367
22251
|
const leftover = pendingThreads();
|
|
21368
22252
|
if (leftover.length)
|
|
21369
22253
|
console.log(`delegations: ${leftover.length} thread(s) with queued handoffs from a previous run — draining`);
|
|
@@ -21418,6 +22302,7 @@ const gracefulShutdown = createGracefulShutdown({
|
|
|
21418
22302
|
watchdog.stop();
|
|
21419
22303
|
routines?.stop();
|
|
21420
22304
|
calendarCalls?.stop();
|
|
22305
|
+
memoryUpkeep.stop();
|
|
21421
22306
|
webhookIngress?.server.close();
|
|
21422
22307
|
tunnelListener?.close();
|
|
21423
22308
|
},
|
|
@@ -21448,7 +22333,8 @@ const gracefulShutdown = createGracefulShutdown({
|
|
|
21448
22333
|
}
|
|
21449
22334
|
closeMessageDb();
|
|
21450
22335
|
releaseDataDirLeaseAtExit();
|
|
21451
|
-
|
|
22336
|
+
// A Cloud home's launcher starts the server again on this code only.
|
|
22337
|
+
process.exit(cloudHomeRestartRequested && code === 0 ? CLOUD_HOME_RESTART_EXIT_CODE : code);
|
|
21452
22338
|
},
|
|
21453
22339
|
});
|
|
21454
22340
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|