openmausbot 0.1.89 → 0.1.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/dist/assets/{abnfDiagram-O67JEVCF-BwaMaZEm.js → abnfDiagram-O67JEVCF-DUyXd0kL.js} +1 -1
  2. package/dist/assets/{arc-Be-8GKxd.js → arc-q73qM-At.js} +1 -1
  3. package/dist/assets/{architectureDiagram-NJMV4G6O-Dp_U2Kv1.js → architectureDiagram-NJMV4G6O-D33O3Wur.js} +1 -1
  4. package/dist/assets/{blockDiagram-BEXU5L5S-DQjUjYUk.js → blockDiagram-BEXU5L5S-DLsJeoXC.js} +1 -1
  5. package/dist/assets/{c4Diagram-YGBWAQC7-Bd3aE4Kc.js → c4Diagram-YGBWAQC7-DnaljZnu.js} +1 -1
  6. package/dist/assets/channel-BCuyHjFV.js +1 -0
  7. package/dist/assets/{chunk-2Q5K7J3B-BijjTBeJ.js → chunk-2Q5K7J3B-Da0h2i79.js} +1 -1
  8. package/dist/assets/{chunk-3FUC2YCW-CMGdwvzW.js → chunk-3FUC2YCW-CfzHi54f.js} +2 -2
  9. package/dist/assets/{chunk-5VM5RSS4-0G09BfsZ.js → chunk-5VM5RSS4-LjE4d9Jf.js} +1 -1
  10. package/dist/assets/{chunk-GTNCS2PH-FOp3vSHe.js → chunk-GTNCS2PH-DY-UGmvc.js} +1 -1
  11. package/dist/assets/{chunk-GWA4HPMP-Dv-20VIn.js → chunk-GWA4HPMP-BqeGhyhP.js} +1 -1
  12. package/dist/assets/{chunk-JWPE2WC7-CRgrdFcz.js → chunk-JWPE2WC7-BYa9EBAg.js} +1 -1
  13. package/dist/assets/{chunk-NETBCI7D-3hP4f7QB.js → chunk-NETBCI7D-vTrDtWyb.js} +1 -1
  14. package/dist/assets/{chunk-WEXAMYUT-CG98IjlG.js → chunk-WEXAMYUT-ic5tXadM.js} +1 -1
  15. package/dist/assets/{chunk-XXDRQBXY-BrSTC9xG.js → chunk-XXDRQBXY-DvRCY-c6.js} +1 -1
  16. package/dist/assets/{classDiagram-v2-NBCMYWYE-rpQQQDFX.js → classDiagram-v2-NBCMYWYE-DVbmgz_L.js} +1 -1
  17. package/dist/assets/{cose-bilkent-JH36ORCC-G6RSafHA.js → cose-bilkent-JH36ORCC-vdUs2n57.js} +1 -1
  18. package/dist/assets/{cynefin-OW5HDTMX-CXjv-IM2.js → cynefin-OW5HDTMX-BvLGlV3U.js} +1 -1
  19. package/dist/assets/{cynefinDiagram-VND7K2PF-Dw07MR9f.js → cynefinDiagram-VND7K2PF-C4GcJQDt.js} +1 -1
  20. package/dist/assets/{dagre-6A5THRUB-BwPnlTg2.js → dagre-6A5THRUB-BMSaHpt_.js} +1 -1
  21. package/dist/assets/{diagram-22UHCM2B-Cm6xcMiz.js → diagram-22UHCM2B-D5b9UBhC.js} +1 -1
  22. package/dist/assets/{diagram-3UASUU5V-CDpogPbE.js → diagram-3UASUU5V-CIKLlB7Y.js} +1 -1
  23. package/dist/assets/{diagram-ATOU4E4O-BV8z3Pnc.js → diagram-ATOU4E4O-BzXb-7k7.js} +1 -1
  24. package/dist/assets/{diagram-CDSNMT55-CKCQhOOC.js → diagram-CDSNMT55-HsTvJ7uT.js} +1 -1
  25. package/dist/assets/{diagram-MLGK6HIB-FGvJg69C.js → diagram-MLGK6HIB-Db9ySNbQ.js} +1 -1
  26. package/dist/assets/{diagram-MPIPVDR6-CwATvgnS.js → diagram-MPIPVDR6-D6BKbpqY.js} +1 -1
  27. package/dist/assets/{ebnfDiagram-ZINNZB2B-CH_c3SSC.js → ebnfDiagram-ZINNZB2B-Bq067L0t.js} +1 -1
  28. package/dist/assets/{elk-276RUBZZ-3guZi71R.js → elk-276RUBZZ-DhaD7fNJ.js} +1 -1
  29. package/dist/assets/{erDiagram-OPXOYQCR-BMHphR2n.js → erDiagram-OPXOYQCR-fwIhfz36.js} +1 -1
  30. package/dist/assets/{flowDiagram-KWPJA3E3-Vm5DkW5x.js → flowDiagram-KWPJA3E3-DygIyu0V.js} +1 -1
  31. package/dist/assets/{ganttDiagram-FUAMR5RP-DO-1ct07.js → ganttDiagram-FUAMR5RP-DPYfSO96.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-X574FWY7-nxeksEIN.js → gitGraphDiagram-X574FWY7-DhkHeCle.js} +1 -1
  33. package/dist/assets/index-CRvo-DS2.css +1 -0
  34. package/dist/assets/index-CWYGlA63.js +588 -0
  35. package/dist/assets/{index-Oxk9x45i.js → index-G1PHKGyh.js} +1 -1
  36. package/dist/assets/{infoDiagram-VRGFBTTK-Dkcvr0p7.js → infoDiagram-VRGFBTTK-DstBLxME.js} +1 -1
  37. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BwVibkoO.js → ishikawaDiagram-OU5B5YK6-CrIsGgch.js} +1 -1
  38. package/dist/assets/{journeyDiagram-ZHPQQLJL-ByiBZqgG.js → journeyDiagram-ZHPQQLJL-CYypGDbB.js} +1 -1
  39. package/dist/assets/{kanban-definition-PNTS6WVX-qqjgXBF3.js → kanban-definition-PNTS6WVX-DGjPt8E_.js} +1 -1
  40. package/dist/assets/{linear-CtJnKe6A.js → linear-Dwc3Hrru.js} +1 -1
  41. package/dist/assets/{mermaid.core-BEiDklrY.js → mermaid.core-Dc4OePBL.js} +5 -5
  42. package/dist/assets/{mindmap-definition-NLK3R4M7-DfIYHia9.js → mindmap-definition-NLK3R4M7-DV-vepj-.js} +1 -1
  43. package/dist/assets/{pegDiagram-GJSIUBJH-CYyRqa1a.js → pegDiagram-GJSIUBJH-lx5Kj-n5.js} +1 -1
  44. package/dist/assets/{pieDiagram-5QR66LMP-hOToy_zH.js → pieDiagram-5QR66LMP-Bq7Vmm15.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-O4NWA36T-DKAsNN9I.js → quadrantDiagram-O4NWA36T-DeqeqOBj.js} +1 -1
  46. package/dist/assets/{railroadDiagram-XR7U4H2S-BSA-d8JU.js → railroadDiagram-XR7U4H2S-CXwRQzSC.js} +1 -1
  47. package/dist/assets/{requirementDiagram-PLB6GJNP-CSmlvb-p.js → requirementDiagram-PLB6GJNP-8tAeoB88.js} +1 -1
  48. package/dist/assets/{sankeyDiagram-IPEJSGJF-CD5F6fnG.js → sankeyDiagram-IPEJSGJF-BG5zjGWU.js} +1 -1
  49. package/dist/assets/{sequenceDiagram-PO4LG4MO-JUq5sx6d.js → sequenceDiagram-PO4LG4MO-DKF3iLcw.js} +1 -1
  50. package/dist/assets/{sizeCapture-INFHLROL-BjIS-Stk.js → sizeCapture-INFHLROL-B-y1qUgT.js} +1 -1
  51. package/dist/assets/{stateDiagram-v2-GCMORJYK-DklkUxOg.js → stateDiagram-v2-GCMORJYK-MkzPgxAc.js} +1 -1
  52. package/dist/assets/{swimlanes-2SLR337P-CsjIU6be.js → swimlanes-2SLR337P-DnbaOEwH.js} +1 -1
  53. package/dist/assets/swimlanesDiagram-TC7HE7FX-BZQ07DGE.js +8 -0
  54. package/dist/assets/{timeline-definition-EJHVYXUP-BkFqK7AB.js → timeline-definition-EJHVYXUP-Bb4xG_Bt.js} +1 -1
  55. package/dist/assets/{usecaseDiagram-POWQR4AR-70gJB1EU.js → usecaseDiagram-POWQR4AR-JgkR9pFf.js} +1 -1
  56. package/dist/assets/{vennDiagram-UO4OBE2U-83Tm62dN.js → vennDiagram-UO4OBE2U-CIPIrAYe.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-VNRHLVJA-tTmseoX9.js → wardleyDiagram-VNRHLVJA-BjCLuUvJ.js} +1 -1
  58. package/dist/assets/{xychartDiagram-PMCCYNJV-R1LBA55a.js → xychartDiagram-PMCCYNJV-DKgiaYxT.js} +1 -1
  59. package/dist/index.html +2 -2
  60. package/dist-server/cloud-home-start.js +14795 -0
  61. package/dist-server/container-mcp.js +52 -4
  62. package/dist-server/drivers/agents-proxy.js +20 -10
  63. package/dist-server/index.js +8985 -6461
  64. package/dist-server/openmausbot.js +327 -145
  65. package/dist-server/pair-cli.js +327 -145
  66. package/dist-server/permission-proxy.js +1 -1
  67. package/dist-server/server/admission.js +46 -0
  68. package/dist-server/server/{box-create-idempotency.js → boat-create-idempotency.js} +32 -31
  69. package/dist-server/server/{box-delete-journal.js → boat-delete-journal.js} +26 -25
  70. package/dist-server/server/{box.js → boat.js} +330 -328
  71. package/dist-server/server/bot-profile.js +7 -1
  72. package/dist-server/server/channel-queue.js +58 -18
  73. package/dist-server/server/checkpoints.js +98 -52
  74. package/dist-server/server/claim-idle.js +33 -0
  75. package/dist-server/server/cloud-backend.js +5 -5
  76. package/dist-server/server/cloud-home-start.js +103 -0
  77. package/dist-server/server/cloud-home.js +218 -0
  78. package/dist-server/server/cloud-overflow.js +167 -0
  79. package/dist-server/server/composio.js +5 -0
  80. package/dist-server/server/computer-observation.js +2 -2
  81. package/dist-server/server/config.js +115 -6
  82. package/dist-server/server/container-computer.js +13 -0
  83. package/dist-server/server/control-client.js +2 -2
  84. package/dist-server/server/delegations.js +11 -3
  85. package/dist-server/server/drivers/acp/core.js +5 -4
  86. package/dist-server/server/drivers/agents-call.js +5 -1
  87. package/dist-server/server/drivers/agents-catalog.js +9 -3
  88. package/dist-server/server/drivers/{boxagent.js → boatagent.js} +13 -12
  89. package/dist-server/server/drivers/builtIn.js +2 -2
  90. package/dist-server/server/drivers/{chat-box-tools.js → chat-boat-tools.js} +3 -3
  91. package/dist-server/server/drivers/chat-mcp-tools.js +2 -2
  92. package/dist-server/server/drivers/claude.js +177 -13
  93. package/dist-server/server/drivers/native.js +1 -1
  94. package/dist-server/server/drivers/openai-chat.js +15 -4
  95. package/dist-server/server/drivers/pi.js +3 -3
  96. package/dist-server/server/hosted-models.js +6 -1
  97. package/dist-server/server/index.js +1029 -479
  98. package/dist-server/server/local-vm-inventory.js +11 -1
  99. package/dist-server/server/local-vm-seat-pool.js +141 -0
  100. package/dist-server/server/managed-policy.js +4 -1
  101. package/dist-server/server/memory-capture.js +194 -0
  102. package/dist-server/server/memory-entries.js +76 -0
  103. package/dist-server/server/memory-organize.js +102 -0
  104. package/dist-server/server/memory-tidy.js +144 -0
  105. package/dist-server/server/memory-topics.js +169 -0
  106. package/dist-server/server/memory-upkeep.js +452 -0
  107. package/dist-server/server/message-db.js +49 -5
  108. package/dist-server/server/permission-proxy.js +3 -3
  109. package/dist-server/server/profile-learned.js +98 -0
  110. package/dist-server/server/profile-versions.js +45 -11
  111. package/dist-server/server/recall.js +172 -0
  112. package/dist-server/server/redact.js +1 -1
  113. package/dist-server/server/room-cwd.js +1 -1
  114. package/dist-server/server/room-handoffs.js +61 -6
  115. package/dist-server/server/routes/bot-memory.js +215 -0
  116. package/dist-server/server/routine-requests.js +27 -16
  117. package/dist-server/server/routines.js +10 -6
  118. package/dist-server/server/steer-queue.js +41 -10
  119. package/dist-server/server/store.js +25 -1
  120. package/dist-server/server/surface.js +1 -1
  121. package/dist-server/server/system-prompt.js +5 -5
  122. package/dist-server/server/team-computers.js +2 -2
  123. package/dist-server/server/tightening-requests.js +34 -4
  124. package/dist-server/server/tool-summary.js +2 -2
  125. package/dist-server/server/tts/elevenlabs.js +1 -1
  126. package/dist-server/server/turn-resources.js +87 -11
  127. package/dist-server/server/vps-computer.js +1 -1
  128. package/dist-server/server/workspace.js +57 -22
  129. package/dist-server/shared/ask-question.js +7 -5
  130. package/dist-server/shared/bot-avatar.js +19 -0
  131. package/dist-server/shared/credential-request.js +3 -3
  132. package/dist-server/shared/tool-surface.js +1 -1
  133. package/dist-server/src/lib/composer-attachments.js +9 -0
  134. package/dist-server/vps-container-mcp.js +52 -4
  135. package/dist-server/workspace-backup.worker.js +52 -4
  136. package/package.json +1 -1
  137. package/dist/assets/channel-DqY_gMLg.js +0 -1
  138. package/dist/assets/index-BIMdIbaw.css +0 -1
  139. package/dist/assets/index-BVPdbq2r.js +0 -583
  140. package/dist/assets/swimlanesDiagram-TC7HE7FX-Bk14M0_B.js +0 -8
@@ -44,20 +44,23 @@ import { avatarGenerationRequestSchema, avatarGenerationStateMatches, generateAv
44
44
  import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
45
45
  import { groupTurnCwd } from "./room-cwd.js";
46
46
  import { RoomTurnDeadline, RoomTurnStallRegistry, roomTurnTimeoutMessage } from "./room-turn-timeout.js";
47
- import * as box from "./box.js";
47
+ import * as boat from "./boat.js";
48
48
  import { TeamComputers, teamComputerAssignment, teamComputerCreate, teamComputerOwner } from "./team-computers.js";
49
49
  import { isEffortLevel } from "../shared/wire.js";
50
- import { boxCreateRecoverySnapshot, retireDeletedBoxCreate } from "./box-create-idempotency.js";
51
- import { boxDeletionSnapshot } from "./box-delete-journal.js";
52
- import { boxAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourceChangeError, } from "./cloud-backend.js";
50
+ import { boatCreateRecoverySnapshot, retireDeletedBoatCreate } from "./boat-create-idempotency.js";
51
+ import { boatDeletionSnapshot } from "./boat-delete-journal.js";
52
+ import { boatAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourceChangeError, } from "./cloud-backend.js";
53
53
  import * as composio from "./composio.js";
54
54
  import { connectorCallFromFrame, connectorRefusalText, connectorUnrecognizedText, evaluateConnectorTools } from "./connector-verdict.js";
55
55
  import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
56
+ import { buildRecall } from "./recall.js";
57
+ import { createMemoryUpkeep, upkeepEnabled } from "./memory-upkeep.js";
58
+ import { appendAboutMe, commitLearned, planLearned } from "./profile-learned.js";
56
59
  import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, livePeerRosterBlock, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
57
60
  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";
61
+ 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
62
  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";
63
+ 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, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, } from "./config.js";
61
64
  import { sweepThreadEventLogs } from "./thread-retention.js";
62
65
  import { ComputerControl } from "./computer-control.js";
63
66
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
@@ -90,16 +93,17 @@ import { chiefForBot, INCIDENTS_THREAD_TITLE, IncidentLedger, incidentChip, inci
90
93
  const SESSION_READ_MAX_CHARS = 8_000;
91
94
  import { attemptAsideInjection, drainAsideMessages, queueAsideMessage, restoreAsideMessages, } from "./aside-queue.js";
92
95
  import { promptWithReply, transcriptText } from "./replies.js";
93
- import { admit } from "./admission.js";
96
+ import { admit, DRAIN_COALESCE_MAX_ITEMS } from "./admission.js";
94
97
  import { _loadPending, buildDelegationFailurePrompt, buildDelegationRevivalPrompt, DELEGATION_TTL_MS, DelegationWakeBudget, discardDelegations, drainDelegations, expireStaleDelegations, findDelegationReceipt, pendingDelegationInfo, pendingDelegationSnapshot, pendingThreads, queueDelegation, recordDelegationReceipt, releaseDelegationsWaitingOn, summarizeDelegatedActivity } from "./delegations.js";
95
98
  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";
99
+ import { cancelChannelMessage, drainChannelMessages, holdChannelQueue, headChannelGroup, queuedChannelMessage, queueChannelMessage, restoreChannelMessages, restoreHeldChannelQueue, resolveHeldReplyTarget, settleHeldChannelQueueHead, } from "./channel-queue.js";
97
100
  import { acceptedSendMatch, parseSendId, sendFingerprint, SendSequencer, } from "./send-idempotency.js";
98
101
  import { EventBus } from "./harness/bus.js";
99
102
  import { ProviderRegistry } from "./harness/registry.js";
100
103
  import { ManagedDesktopProviders } from "./managed-desktop.js";
101
104
  import { computerKindForResource, ManagedDesktopPolicy } from "./managed-policy.js";
102
105
  import { hostedModelPolicy, HOSTED_MODEL_POLICY_HEADER, HOSTED_PROVIDER_SETTINGS_ERROR } from "./hosted-models.js";
106
+ import { CLOUD_IGNORED_KEYS, CLOUD_PAIRING_PATH, cloudHomeConfiguration, createCloudPairing, readSignedBody } from "./cloud-home.js";
103
107
  import { selectDefaultModelSelection, withNewBotEffort } from "./default-model-selection.js";
104
108
  import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, peerApprovalFailure, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
105
109
  import { peerDeliveryReceipt } from "./peer-delivery.js";
@@ -113,10 +117,10 @@ import { Handoffs, handedStateUsable, recordHanded, renderUnseen, sessionStart,
113
117
  import { extractTurnImages } from "./turn-images.js";
114
118
  import { TurnWatchdog } from "./turn-watchdog.js";
115
119
  import { TurnResources, workspaceResource } from "./turn-resources.js";
116
- import { ensureWorkspace, ensureTaskWorkspace, workspaceLocationsPrompt, supportsWorkspaceFiles, updateMemory, appendMemoryLog, isMemoryTopicName, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, workspaceDir, } from "./workspace.js";
117
- import { listMemoryTopics, readMemoryFile, readMemoryTopic, writeMemoryFile, writeMemoryTopic } from "./workspace.js";
118
- import { MEMORY_INDEX, MemoryStoreError, memoryCapacity, memoryOverview, openMemoryLocation, readMemoryDoc, } from "./memory-store.js";
119
- import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, flushMemoryJournal, journalMemoryDelete, journalMemoryWrite, readMemoryJournal, revertMemoryChange, } from "./memory-journal.js";
120
+ import { IdleReleasePolicy } from "./claim-idle.js";
121
+ import { ensureWorkspace, ensureTaskWorkspace, workspaceLocationsPrompt, supportsWorkspaceFiles, updateMemory, appendMemoryLog, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, workspaceDir, } from "./workspace.js";
122
+ import { listMemoryTopics, memoryDate, readMemoryFile, readMemoryTopic, writeMemoryFile, writeMemoryTopic } from "./workspace.js";
123
+ import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, journalMemoryWrite, } from "./memory-journal.js";
120
124
  import { readSectionContext, readSections, sectionContextKey, sectionContextLabel, sectionContextSystemPrompt, writeSectionContext, SECTION_CONTEXT_MAX_BYTES, } from "./section-context.js";
121
125
  import { applyStagedSkillWrite, applySkillWriteWithReceipt, getStagedSkillWrite, installOrgSkill, installSkill, parseSkillMd, scanSkillText, listSkills, listStagedSkillWrites, isSkillName, readSkillFile, rejectStagedSkillWrite, removeSkill, setSkillEnabled, skillPackageStamps, skillsSystemPrompt, stageSkillWrite, } from "./skills.js";
122
126
  import { fetchSkillFromSource } from "./skill-fetch.js";
@@ -127,9 +131,10 @@ import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup
127
131
  import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-folder.js";
128
132
  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";
129
133
  import { readCuaConnection, readCuaUnavailableReason, gatedLocalComputer } from "./local-computer.js";
130
- import { discoverExistingPerBotLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
134
+ import { discoverExistingPerBotLocalVms, discoverExistingPoolLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
131
135
  import { LocalVmIdleTimer } from "./local-vm-idle.js";
132
136
  import { LocalVmLeasePool } from "./local-vm-lease.js";
137
+ import { LocalVmSeatPool } from "./local-vm-seat-pool.js";
133
138
  import { RepeatDetector, callKey } from "./repeat-detector.js";
134
139
  import { redactSecretsInText } from "./redact.js";
135
140
  import * as vps from "./vps-computer.js";
@@ -174,6 +179,7 @@ import { shouldMountLocalComputer } from "./local-routing.js";
174
179
  import { autoLocalVmAttachable } from "./container-computer.js";
175
180
  import { startAutoVmClaim } from "./auto-vm-claims.js";
176
181
  import { computerFreeAfterText, computerStillBusyText, computerStoppedWaitingText, computerWaitingText } from "./computer-wait.js";
182
+ import { CloudOverflowConsent, CloudSeatLease, cloudOverflowAction, cloudOverflowConsentText, cloudOverflowOfferText, cloudSeatStartedText, cloudSeatStoppedText } from "./cloud-overflow.js";
177
183
  import { modelContextWindow } from "./model-context-window.js";
178
184
  import { parseSurface, resolveSurface, surfaceLabel, surfaceOfComputerKind, surfacePrompt } from "./surface.js";
179
185
  import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
@@ -201,6 +207,7 @@ import { json, onJsonBody, parsedBodyOf, readBody } from "./harness/http.js";
201
207
  import { ROUTES, dispatchRoutes } from "./routes/table.js";
202
208
  import { createHostedSlackRoutes } from "./routes/hosted-slack.js";
203
209
  import { createBotPresetRoutes } from "./routes/bot-presets.js";
210
+ import { createBotMemoryRoutes } from "./routes/bot-memory.js";
204
211
  const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799);
205
212
  const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1);
206
213
  // Behind a proxy or tunnel, the base URL senders should use (docs/self-hosting.md).
@@ -264,6 +271,21 @@ const sessions = new SessionRegistry({
264
271
  portalMembership: hostedWorkspaceConfiguration()?.portalMembership === true,
265
272
  });
266
273
  const sharedComputers = new SharedComputers(id => sessions.isLive(id));
274
+ // OMB Cloud Pro home machine (server/cloud-home.ts, docs/cloud-pro.md). A
275
+ // partial or invalid boot contract stops the server here, before it serves.
276
+ const CLOUD_HOME = cloudHomeConfiguration();
277
+ const cloudPairing = CLOUD_HOME ? createCloudPairing({ secret: CLOUD_HOME.bootstrapSecret, sessions }) : null;
278
+ if (CLOUD_HOME) {
279
+ // The signing secret is held in memory from here on, and a platform
280
+ // gateway's settings are dropped: no engine or tool this server starts
281
+ // inherits either. The person's own engines are the only way to a model.
282
+ delete process.env.OMB_CLOUD_BOOTSTRAP_SECRET;
283
+ for (const key of CLOUD_IGNORED_KEYS)
284
+ delete process.env[key];
285
+ console.log(`cloud home ${CLOUD_HOME.machineId}: bots run on the engines the person signs in to here`);
286
+ for (const warning of CLOUD_HOME.warnings)
287
+ console.warn(`cloud home: ${warning}`);
288
+ }
267
289
  // Who each thread is for, when a signed-in person can be named (server-private).
268
290
  const threadStarters = new ThreadStarters(join(DATA_DIR, "thread-starters.json"));
269
291
  const commandAllowlist = new CommandAllowlistStore(join(DATA_DIR, "command-allowlist.json"));
@@ -617,6 +639,56 @@ function recentWorkSources(bot) {
617
639
  taskByThread: (botId, threadId) => store.taskByThread(botId, threadId),
618
640
  };
619
641
  }
642
+ /** The automatic-recall block for a turn (server/recall.ts), or "". Notes
643
+ * always; the bot's other conversations only when `conversations` — a 1:1
644
+ * turn the person started — because a message from another bot, a webhook
645
+ * or a room must not be able to pull a private chat into its reply. The
646
+ * conversations are the ones session_search would search, minus this one. */
647
+ function autoRecallPrompt(bot, threadId, message, opts) {
648
+ if (!autoRecallEnabled(cfg))
649
+ return "";
650
+ const roomByThread = new Map();
651
+ if (opts.conversations) {
652
+ for (const group of store.groups) {
653
+ if (!group.memberIds.includes(bot.id) || !roomFeedsBot(group, bot))
654
+ continue;
655
+ roomByThread.set(group.threadId, group);
656
+ for (const task of group.tasks ?? [])
657
+ roomByThread.set(task.threadId, group);
658
+ }
659
+ }
660
+ const threadIds = opts.conversations
661
+ ? [...new Set([bot.threadId, ...(bot.tasks ?? []).map((task) => task.threadId), ...roomByThread.keys()])].filter((id) => id !== threadId)
662
+ : [];
663
+ try {
664
+ const recalled = buildRecall({
665
+ botId: bot.id,
666
+ message,
667
+ threadIds,
668
+ label: (id) => {
669
+ const room = roomByThread.get(id);
670
+ if (room)
671
+ return `room ${JSON.stringify(room.name)}`;
672
+ const task = store.taskByThread(bot.id, id);
673
+ if (task)
674
+ return `chat ${JSON.stringify(task.title)}`;
675
+ return id === bot.threadId ? "your main chat" : "an earlier chat";
676
+ },
677
+ author: (hit) => (hit.role === "user" ? hit.peer ?? opts.userName : hit.from ?? bot.name),
678
+ });
679
+ if (recalled)
680
+ console.log(`auto-recall: ${bot.name} (${bot.id}) got ${recalled.notes} note and ${recalled.conversations} conversation passage(s) in ${threadId}`);
681
+ return recalled?.text ?? "";
682
+ }
683
+ catch (err) {
684
+ console.warn(`auto-recall failed for ${bot.id}: ${err.message}`);
685
+ return "";
686
+ }
687
+ }
688
+ /** The recalled block in front of a turn's message, or the message alone. */
689
+ function withRecalled(recalled, text) {
690
+ return recalled ? `${recalled}\n\n${text}` : text;
691
+ }
620
692
  /** Bots in one room must be visible to the same people: a room is one
621
693
  * transcript, so otherwise a restricted bot's words would feed a teammate
622
694
  * everyone can see. Refuse a roster that adds such a bot (existing members
@@ -874,15 +946,17 @@ function operatorPrices() {
874
946
  * engine reported tokens but no price, and let the spend cap see it at once.
875
947
  * The first booking that takes the month past the warning threshold, or to
876
948
  * the cap, notifies admins (once per month each; server/spend.ts). */
877
- function bookTurnUsage(row) {
878
- const booked = { ...row, ...ledgerCost(row, operatorPrices()), at: new Date().toISOString() };
949
+ function bookTurnUsage(row, tokensReported = true, notificationThreadId = row.threadId) {
950
+ // Missing helper usage is unpriced, not an estimated zero-dollar call.
951
+ const cost = tokensReported || row.costUsd !== null ? ledgerCost(row, operatorPrices()) : { costUsd: null };
952
+ const booked = { ...row, ...cost, at: new Date().toISOString() };
879
953
  // The append lands asynchronously; the cap counts the row from memory
880
954
  // until it does, so the check below (and the next turn start) sees it.
881
955
  noteSpend(DATA_DIR, booked, appendUsage(DATA_DIR, booked));
882
956
  const state = spendState(cfg, DATA_DIR);
883
957
  const alert = takeSpendAlert(DATA_DIR, state);
884
958
  if (alert && state) {
885
- broadcast({ kind: "notify", notification: buildSpendNotification({ id: row.botId, name: row.botName }, row.threadId, spendAlertText(alert, state)) }, { adminOnly: true });
959
+ broadcast({ kind: "notify", notification: buildSpendNotification({ id: row.botId, name: row.botName }, notificationThreadId, spendAlertText(alert, state)) }, { adminOnly: true });
886
960
  }
887
961
  }
888
962
  /** A queued line from before triggers were stored with it: its sender, else
@@ -1352,12 +1426,164 @@ const turnResourceOwners = new Map();
1352
1426
  const turnComputerResources = new Map();
1353
1427
  const teamComputerTurns = new Map();
1354
1428
  const settlingResourceOwners = new Map();
1429
+ /** Idle release for computer claims (#1653), disabled until the feature
1430
+ * flag is on: a seat that stays screen-quiet for the policy's window is
1431
+ * released to waiting turns while its holder's turn still lives, and the
1432
+ * previous holder re-claims directly inside the reclaim window — it
1433
+ * yields once another turn holds the seat. The durable VM profile keeps
1434
+ * its login state, so that re-claim is cheap. Read per claim: a flag
1435
+ * change applies to new claims, never to a seat held under the old
1436
+ * setting. */
1437
+ const COMPUTER_CLAIM_IDLE_POLICY = new IdleReleasePolicy();
1438
+ function computerClaimIdlePolicy(resource) {
1439
+ return resource.startsWith("computer:") && computerClaimIdleReleaseEnabled(cfg) ? COMPUTER_CLAIM_IDLE_POLICY : undefined;
1440
+ }
1441
+ /** #1655: the consent ledger for local-wait cloud overflow, and the live
1442
+ * cloud seats it has started. One seat per bot — it is the bot's own Box,
1443
+ * shared by that bot's threads the way every other computer is. */
1444
+ const cloudOverflowConsent = new CloudOverflowConsent();
1445
+ const cloudSeatLeases = new Map();
1446
+ let cloudSeatSweepTimer;
1447
+ /** One stop attempt per seat at a time (#1655): the sweep ticks faster
1448
+ * than a Box sleeps, and a second stop call would race the first. */
1449
+ const cloudSeatStopsInFlight = new Set();
1450
+ /** The waits that may overflow: a turn queued for a local desktop, never
1451
+ * one already bound for a cloud or VPS seat. */
1452
+ function localComputerWait(resource) {
1453
+ return resource === "computer:host" || resource.startsWith("computer:vm:");
1454
+ }
1455
+ function cloudOverflowSituation(owner, resource, started) {
1456
+ if (!localComputerWait(resource))
1457
+ return null;
1458
+ if (!store.botByThread(owner.threadId))
1459
+ return null;
1460
+ const perSecondCostUsd = cloudOverflowPerSecondCostUsd(cfg);
1461
+ return {
1462
+ featureEnabled: cloudOverflowEnabled(cfg),
1463
+ // An organisation that refuses the Box kind has nothing to offer, and
1464
+ // the decision must fail closed before any card is written.
1465
+ cloudConfigured: boat.boatConfigured(cfg) && managedPolicy.computerRefusal("box") === undefined,
1466
+ perSecondCostUsd,
1467
+ // Consent answers the priced card (#1655): a grant counts only at the
1468
+ // rate its card showed, so a config change re-offers instead of
1469
+ // starting at a price nobody approved.
1470
+ consented: cloudOverflowConsent.consented(owner.threadId, cloudOverflowAllowlistedThreads(cfg), perSecondCostUsd ?? undefined),
1471
+ offered: cloudOverflowConsent.offered(owner.threadId, perSecondCostUsd ?? undefined),
1472
+ started,
1473
+ };
1474
+ }
1355
1475
  function claimTurnResource(owner, resource) {
1356
- if (!turnResources.claim(resource, owner))
1476
+ const idle = computerClaimIdlePolicy(resource);
1477
+ if (!turnResources.claim(resource, owner, idle ? { idle } : {}))
1357
1478
  return false;
1358
1479
  turnResourceOwners.set(owner.threadId, owner);
1359
1480
  return true;
1360
1481
  }
1482
+ /** Start the waiting bot's cloud seat (#1655). True once handled — started
1483
+ * or terminally failed — so the wait never retries every poll; false when
1484
+ * another turn of this bot owns the Box lifecycle and a later poll may
1485
+ * still land. */
1486
+ async function startCloudSeat(owner) {
1487
+ const bot = store.botByThread(owner.threadId);
1488
+ if (!bot)
1489
+ return true;
1490
+ const perSecondCostUsd = cloudOverflowPerSecondCostUsd(cfg);
1491
+ if (!perSecondCostUsd)
1492
+ return true;
1493
+ const boxBotResource = "computer:box-bot:" + bot.id;
1494
+ if (!claimTurnResource(owner, boxBotResource))
1495
+ return false;
1496
+ try {
1497
+ broadcast({ kind: "computer", botId: bot.id, state: "waking" });
1498
+ const machine = await boat.readyBoat(cfg, bot.id);
1499
+ if (!machine)
1500
+ throw new Error("the cloud computer did not wake");
1501
+ // Consent can be revoked while the Box wakes (#1655): a seat nobody
1502
+ // consented to must not bill. It is released and put straight back to
1503
+ // sleep — and if even that fails, leased as already idle so the sweep
1504
+ // keeps retrying with backoff.
1505
+ if (!cloudOverflowConsent.consented(owner.threadId, cloudOverflowAllowlistedThreads(cfg), perSecondCostUsd)) {
1506
+ turnResources.releaseOne(boxBotResource, owner);
1507
+ const idleStopMs = cloudOverflowIdleStopMs(cfg);
1508
+ void boat.sleepBoat(cfg, bot.id).catch(() => {
1509
+ cloudSeatLeases.set(bot.id, new CloudSeatLease({ botId: bot.id, threadId: owner.threadId, generation: owner.generation, now: Date.now() - idleStopMs - 1, idleStopMs }));
1510
+ ensureCloudSeatSweep();
1511
+ });
1512
+ store.appendMessage(owner.threadId, {
1513
+ role: "bot", kind: "activity",
1514
+ tool: { name: "Cloud overflow consent was revoked while the cloud computer woke; it stops unbilled and the local wait continues.", ok: true },
1515
+ });
1516
+ return true;
1517
+ }
1518
+ cloudSeatLeases.set(bot.id, new CloudSeatLease({ botId: bot.id, threadId: owner.threadId, generation: owner.generation, idleStopMs: cloudOverflowIdleStopMs(cfg) }));
1519
+ ensureCloudSeatSweep();
1520
+ store.appendMessage(owner.threadId, {
1521
+ role: "bot", kind: "activity",
1522
+ tool: { name: cloudSeatStartedText({ perSecondCostUsd, idleStopMs: cloudOverflowIdleStopMs(cfg) }), ok: true },
1523
+ });
1524
+ return true;
1525
+ }
1526
+ catch (error) {
1527
+ turnResources.releaseOne(boxBotResource, owner);
1528
+ store.appendMessage(owner.threadId, {
1529
+ role: "bot", kind: "activity",
1530
+ tool: { name: "Cloud computer could not start (" + (error instanceof Error ? error.message : String(error)) + "); the local wait continues.", ok: false },
1531
+ });
1532
+ return true;
1533
+ }
1534
+ }
1535
+ /** A per-second-billed seat must stop on idleness even when nothing else
1536
+ * touches the server, so this slice carries the one timer — unref'd, it
1537
+ * can never hold the process open, and it dies with the last lease. */
1538
+ function ensureCloudSeatSweep() {
1539
+ if (cloudSeatSweepTimer)
1540
+ return;
1541
+ cloudSeatSweepTimer = setInterval(() => {
1542
+ sweepIdleCloudSeats();
1543
+ if (!cloudSeatLeases.size && cloudSeatSweepTimer) {
1544
+ clearInterval(cloudSeatSweepTimer);
1545
+ cloudSeatSweepTimer = undefined;
1546
+ }
1547
+ }, 15_000);
1548
+ cloudSeatSweepTimer.unref?.();
1549
+ }
1550
+ /** Stop every cloud seat idle past its window (#1655): the machine is
1551
+ * archived, billing pauses, and the transcript says which wait's seat it
1552
+ * was. Only real computer tool completions refresh a lease — the poller's
1553
+ * preview frames never reach the touch call. */
1554
+ /** Stop one running cloud seat (#1655): the Box sleeps, and only a
1555
+ * successful stop clears the lease and its Box claim — a failed stop
1556
+ * stays leased and retryable with backoff, because the seat still bills
1557
+ * until it sleeps. The claim releases for the lease's own generation,
1558
+ * never a newer turn's. */
1559
+ function stopCloudSeat(botId, lease, stoppedName, now = Date.now()) {
1560
+ if (cloudSeatStopsInFlight.has(botId))
1561
+ return;
1562
+ if (cloudSeatLeases.get(botId) !== lease)
1563
+ return;
1564
+ cloudSeatStopsInFlight.add(botId);
1565
+ void boat.sleepBoat(cfg, botId)
1566
+ .then(() => {
1567
+ if (cloudSeatLeases.get(botId) === lease)
1568
+ cloudSeatLeases.delete(botId);
1569
+ // The stopped seat's Box claim goes with it, so a still-waiting
1570
+ // turn may start a fresh seat for the same bot.
1571
+ turnResources.releaseOne(`computer:box-bot:${botId}`, { threadId: lease.threadId, generation: lease.generation });
1572
+ store.appendMessage(lease.threadId, { role: "bot", kind: "activity", tool: { name: stoppedName, ok: true } });
1573
+ })
1574
+ .catch(() => {
1575
+ lease.deferSleep(now);
1576
+ 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 } });
1577
+ })
1578
+ .finally(() => cloudSeatStopsInFlight.delete(botId));
1579
+ }
1580
+ function sweepIdleCloudSeats(now = Date.now()) {
1581
+ for (const [botId, lease] of cloudSeatLeases) {
1582
+ if (!lease.idleElapsed(now) || !lease.sleepDue(now))
1583
+ continue;
1584
+ stopCloudSeat(botId, lease, cloudSeatStoppedText(lease.idleFor(now)), now);
1585
+ }
1586
+ }
1361
1587
  function releaseTurnResources(owner) {
1362
1588
  if (!owner)
1363
1589
  return;
@@ -1395,6 +1621,10 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
1395
1621
  // turn.wait_* events carry the same facts for the inspector log.
1396
1622
  let waitedSince;
1397
1623
  let waitEnded = false;
1624
+ // #1655: whether this wait already started (or terminally failed to
1625
+ // start) a cloud seat; the decision is re-evaluated every poll because
1626
+ // consent can arrive mid-wait.
1627
+ let overflowHandled = false;
1398
1628
  const waitEventBase = () => {
1399
1629
  const selection = store.botByThread(owner.threadId)?.modelSelection;
1400
1630
  const instance = selection ? registry.get(selection.instanceId) : null;
@@ -1460,7 +1690,32 @@ async function bindTurnComputer(owner, resource, exclusive = false) {
1460
1690
  resource,
1461
1691
  ...(holder ? { holder } : {}),
1462
1692
  });
1693
+ // #1655: the wait is where an overflow choice gets made. The card
1694
+ // names the per-second cost before anything can start, and only a
1695
+ // turn genuinely waiting on a local desktop gets one. Fail closed:
1696
+ // feature off, cloud unusable, or no operator-set rate means no
1697
+ // card at all — Auto never offers, never starts.
1698
+ const overflow = cloudOverflowSituation(owner, resource, overflowHandled);
1699
+ const perSecondCostUsd = overflow?.perSecondCostUsd ?? null;
1700
+ if (overflow && perSecondCostUsd !== null && cloudOverflowAction(overflow).kind === "offer") {
1701
+ store.appendMessage(owner.threadId, {
1702
+ role: "bot", kind: "activity",
1703
+ tool: { name: cloudOverflowOfferText({ perSecondCostUsd, idleStopMs: cloudOverflowIdleStopMs(cfg) }), ok: true },
1704
+ });
1705
+ cloudOverflowConsent.markOffered(owner.threadId, perSecondCostUsd);
1706
+ }
1707
+ }
1708
+ // #1655: consent can arrive mid-wait — the card's answer, or a
1709
+ // thread the operator allowlisted — and only then may the cloud
1710
+ // seat start, its cost chip landing beside the waiting one. The
1711
+ // local wait itself is untouched: it still ends by acquisition,
1712
+ // stop, or the ceiling (parking, once that stack lands).
1713
+ if (!overflowHandled) {
1714
+ const situation = cloudOverflowSituation(owner, resource, overflowHandled);
1715
+ if (situation && cloudOverflowAction(situation).kind === "start")
1716
+ overflowHandled = await startCloudSeat(owner);
1463
1717
  }
1718
+ sweepIdleCloudSeats();
1464
1719
  if (Date.now() >= deadline) {
1465
1720
  endWait("gave_up");
1466
1721
  throw new ComputerWaitGaveUp(computerStillBusyText(holder, GROUP_GOAL_WAIT_MAX_MS));
@@ -1857,8 +2112,9 @@ const routineRequestEnvelopeSchema = z.discriminatedUnion("action", [
1857
2112
  action: z.literal("update"),
1858
2113
  routineId: z.unknown(),
1859
2114
  changes: z.unknown(),
2115
+ forBotId: z.unknown().optional(),
1860
2116
  }).strict(),
1861
- ...["pause", "resume", "run_now", "delete"].map((action) => z.object({ ...routineRequestSourceSchema, action: z.literal(action), routineId: z.unknown() }).strict()),
2117
+ ...["pause", "resume", "run_now", "delete"].map((action) => z.object({ ...routineRequestSourceSchema, action: z.literal(action), routineId: z.unknown(), forBotId: z.unknown().optional() }).strict()),
1862
2118
  ]);
1863
2119
  /** The loopback endpoint a bot's computer proxy polls before acting. */
1864
2120
  function controlIntegration(botId, threadId, generation, localVmTarget) {
@@ -4380,6 +4636,89 @@ bus.subscribe((event) => {
4380
4636
  memoryRowsByThread.delete(event.threadId);
4381
4637
  }
4382
4638
  });
4639
+ // Memory upkeep (server/memory-upkeep.ts): background capture, About me
4640
+ // suggestions and the nightly tidy-up, for bots with the switch on.
4641
+ const memoryUpkeep = createMemoryUpkeep({
4642
+ bots: () => store.bots,
4643
+ bot: (id) => store.bot(id) ?? undefined,
4644
+ engine: (botId) => {
4645
+ const bot = store.bot(botId);
4646
+ const instance = bot ? registry.get(bot.modelSelection.instanceId) : undefined;
4647
+ // an engine the organisation disallows never receives memory text
4648
+ if (!bot || !instance || policyModelRefusal(instance))
4649
+ return null;
4650
+ const generate = instance.generateText?.bind(instance);
4651
+ return generate ? {
4652
+ generateText: async (prompt, options) => {
4653
+ // Check every capture, organize and contradiction call, including
4654
+ // consecutive calls within one upkeep pass.
4655
+ assertWithinBudget(cfg, DATA_DIR);
4656
+ const threadId = `memory-${randomUUID()}`;
4657
+ return generate(prompt, { ...options, onUsage: (usage) => bookTurnUsage({
4658
+ botId: bot.id,
4659
+ botName: bot.name,
4660
+ threadId,
4661
+ instanceId: instance.instanceId,
4662
+ driverKind: instance.driverKind,
4663
+ model: usage.model,
4664
+ input: usage.input ?? 0,
4665
+ output: usage.output ?? 0,
4666
+ ...(usage.cachedInput !== undefined ? { cachedInput: usage.cachedInput } : {}),
4667
+ costUsd: usage.costUsd ?? null,
4668
+ trigger: { kind: "bot", botId: bot.id },
4669
+ }, usage.input !== undefined && usage.output !== undefined, bot.threadId) });
4670
+ },
4671
+ } : {};
4672
+ },
4673
+ busy: (botId) => botHasActiveTurn(botId),
4674
+ addToAboutMe: (from, texts) => {
4675
+ const current = cfg.profile?.aboutMe ?? "";
4676
+ const facts = planLearned(from, texts, current, memoryDate());
4677
+ const next = appendAboutMe(current, facts.map((fact) => fact.line));
4678
+ // a full About me takes nothing more; the person trims it first
4679
+ if (!facts.length || next === null)
4680
+ return 0;
4681
+ saveConfig({ profile: { ...cfg.profile, aboutMe: next } });
4682
+ Object.assign(cfg, loadConfig());
4683
+ commitLearned(facts);
4684
+ broadcast({ kind: "config", ...configStatus() });
4685
+ return facts.length;
4686
+ },
4687
+ sourceLabel: (botId, threadId) => memorySourceLabel({ task: store.taskByThread(botId, threadId), threadId }),
4688
+ quietMs: () => captureQuietMs(cfg),
4689
+ tidyHour: () => tidyHour(cfg),
4690
+ log: (line) => console.log(line),
4691
+ });
4692
+ // A finished 1:1 turn of an upkeep bot waits for capture. Only the person's
4693
+ // own conversation: not a room, not a turn another bot or the harness
4694
+ // started (its "user" line is not the person), not a failed turn.
4695
+ bus.subscribe((event) => {
4696
+ if (shouldIgnoreProviderEvent(event))
4697
+ return;
4698
+ if (event.type !== "turn.completed" || !event.turnId || !event.ok)
4699
+ return;
4700
+ try {
4701
+ const bot = store.botByThread(event.threadId);
4702
+ if (!bot || !upkeepEnabled(bot) || store.groupByThread(event.threadId) || isInternalTurn(event.threadId)
4703
+ || isUnattended(bot.id, event.threadId) || routines?.runForThread(event.threadId))
4704
+ return;
4705
+ const path = store.activePath(event.threadId);
4706
+ const replyAt = path.findLastIndex((message) => message.role === "bot" && message.kind === "text" && message.turnId === event.turnId);
4707
+ if (replyAt < 0)
4708
+ return;
4709
+ const asked = path.slice(0, replyAt).findLast((message) => message.role === "user" && message.kind === "text");
4710
+ if (!asked || asked.peerAsk)
4711
+ return;
4712
+ memoryUpkeep.noteTurn(bot.id, event.threadId, {
4713
+ person: extractTurnImages(asked.text ?? "").text,
4714
+ bot: path[replyAt].text ?? "",
4715
+ owner: !asked.sender,
4716
+ });
4717
+ }
4718
+ catch (error) {
4719
+ console.warn(`memory upkeep: could not queue a turn for capture: ${error.message}`);
4720
+ }
4721
+ });
4383
4722
  // One line per finished turn in the bot's daily log (server/recent-work.ts):
4384
4723
  // what it said last, the tools it used, whether the turn failed. What a bot
4385
4724
  // did in one conversation was invisible from every other; the log is where
@@ -4550,12 +4889,7 @@ async function scheduleTurnDigest(input) {
4550
4889
  const controller = new AbortController();
4551
4890
  let timer;
4552
4891
  const capture = async () => {
4553
- const after = await checkpoints.snapshot(input.botId, checkpoint.cwd, `settle ${input.threadId.slice(0, 8)}`, controller.signal);
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);
4892
+ return checkpoints.diffWorkingTree(input.botId, checkpoint.cwd, checkpoint.hash, controller.signal);
4559
4893
  };
4560
4894
  let files;
4561
4895
  try {
@@ -4652,6 +4986,11 @@ let routines = null;
4652
4986
  let calendarCalls = null;
4653
4987
  const localVmOwnerBusy = (botId) => store.bot(botId)?.busy === true;
4654
4988
  const localVmLeases = new LocalVmLeasePool(30 * 60_000);
4989
+ // Pool mode (issue #1654) keeps the lease pool as the ownership fence and
4990
+ // uses this table only to decide WHICH seat a conversation addresses: a
4991
+ // TTL-bounded affinity returns each thread to the desktop holding its login
4992
+ // state, and hands the seat back once the conversation has been idle.
4993
+ const localVmSeatPool = new LocalVmSeatPool(() => localVmMaxInstances(cfg));
4655
4994
  const localVmLifecycleBusy = new Set();
4656
4995
  const localVmThreadTargets = new Map();
4657
4996
  const localVmActiveThreads = new Map();
@@ -4695,12 +5034,12 @@ function vpsThreadEnded(botId, threadId) {
4695
5034
  if (!threads.size)
4696
5035
  activeVpsThreads.delete(botId);
4697
5036
  }
4698
- const boxLifecycleBusyBots = new Set();
5037
+ const boatLifecycleBusyBots = new Set();
4699
5038
  // A refresh is a reader, not a lifecycle change. Keep its reservation until
4700
5039
  // the provider settles even if the HTTP client leaves, and share it on retry.
4701
5040
  const vpsPreviewRequests = new Map();
4702
- const orphanBoxLifecycleBusyIds = new Set();
4703
- const boxInventoryRequestsBusyIds = new Set();
5041
+ const orphanBoatLifecycleBusyIds = new Set();
5042
+ const boatInventoryRequestsBusyIds = new Set();
4704
5043
  const computerProviderConfigTransitions = new Set();
4705
5044
  // A restore mutates and cleans a project work tree. Claim the bot across the
4706
5045
  // entire async Git operation so a turn cannot start in that folder midway.
@@ -4715,7 +5054,7 @@ function inheritedTeamComputer(bot) {
4715
5054
  return teamComputers.forBot(bot);
4716
5055
  }
4717
5056
  function teamComputerPrompt(computer) {
4718
- return computer ? `Your team shares the Box 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.` : "";
5057
+ 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
5058
  }
4720
5059
  function botComputerControlKey(bot) {
4721
5060
  const computer = inheritedTeamComputer(bot);
@@ -4767,6 +5106,10 @@ async function computerCallGate(internalCapability) {
4767
5106
  };
4768
5107
  }
4769
5108
  const computer = turnComputerResources.get(internalCapability.threadId);
5109
+ // The screen call arriving here re-claims an idle-released seat directly
5110
+ // (#1653): inside the reclaim window the previous holder picks its seat
5111
+ // back up without re-entering the wait, and the successful claim
5112
+ // restarts the quiet window. A seat another turn holds still refuses.
4770
5113
  if (!snapshot.held && computer && computer.owner.generation === internalCapability.generation &&
4771
5114
  !claimTurnResource(computer.owner, computer.resource)) {
4772
5115
  return {
@@ -4807,13 +5150,13 @@ function assertTeamComputerChangeIdle(before, after) {
4807
5150
  if (previous?.id === next?.id)
4808
5151
  return;
4809
5152
  if (botHasActiveTurn(before.id) || routines?.activeRunForBot(before.id) || botComputerControlSnapshot(before.id).held ||
4810
- [previous, next].some(computer => computer && (teamComputerInUse(computer) || boxLifecycleBusyBots.has(teamComputerOwner(computer.id))))) {
5153
+ [previous, next].some(computer => computer && (teamComputerInUse(computer) || boatLifecycleBusyBots.has(teamComputerOwner(computer.id))))) {
4811
5154
  throw Object.assign(new Error("Stop the affected team's work and release computer control before changing its computer access"), { status: 409 });
4812
5155
  }
4813
5156
  }
4814
5157
  async function teamComputersPayload() {
4815
5158
  const entries = teamComputers.list();
4816
- const inventory = await box.listManagedBoxes(cfg, managedBoxOwners());
5159
+ const inventory = await boat.listManagedBoats(cfg, managedBoatOwners());
4817
5160
  return {
4818
5161
  configured: inventory.configured,
4819
5162
  ...(inventory.problem ? { problem: inventory.problem } : {}),
@@ -4822,34 +5165,34 @@ async function teamComputersPayload() {
4822
5165
  return {
4823
5166
  id: entry.id, name: entry.name, section: entry.section,
4824
5167
  held: computerControl.snapshot(teamComputerOwner(entry.id)).held,
4825
- state: boxLifecycleBusyBots.has(teamComputerOwner(entry.id)) ? "working" : machine?.state ?? (inventory.available ? "missing" : "unavailable"),
5168
+ state: boatLifecycleBusyBots.has(teamComputerOwner(entry.id)) ? "working" : machine?.state ?? (inventory.available ? "missing" : "unavailable"),
4826
5169
  ...(entry.problem || inventory.problem ? { problem: entry.problem || inventory.problem } : {}),
4827
5170
  };
4828
5171
  }),
4829
5172
  };
4830
5173
  }
4831
- /** The same named Box identity and whole-turn lease in chats and rooms.
5174
+ /** The same named Boat identity and whole-turn lease in chats and rooms.
4832
5175
  * Assignment authorizes waking, never replacing a missing paid machine. */
4833
- async function attachTeamBox(computer, botId, owner, canMount, remoteAgent) {
5176
+ async function attachTeamBoat(computer, botId, owner, canMount, remoteAgent) {
4834
5177
  if (!canMount)
4835
- throw new Error("This model engine cannot use the team's Box computer; choose an engine with computer tools or an explicit bot destination");
4836
- if (!box.boxConfigured(cfg))
4837
- throw new Error("The team's Box account is not configured; reconnect it in Settings");
5178
+ throw new Error("This model engine cannot use the team's Boat computer; choose an engine with computer tools or an explicit bot destination");
5179
+ if (!boat.boatConfigured(cfg))
5180
+ throw new Error("The team's Boat account is not configured; reconnect it in Settings");
4838
5181
  const ownerId = teamComputerOwner(computer.id);
4839
- if (boxLifecycleBusyBots.has(ownerId))
5182
+ if (boatLifecycleBusyBots.has(ownerId))
4840
5183
  throw new Error("The team computer is being changed; wait for it to finish");
4841
5184
  if (computerControl.snapshot(ownerId).held)
4842
5185
  throw new Error("Release human control of the team computer before starting another turn");
4843
5186
  await bindTurnComputer(owner, `computer:box-bot:${ownerId}`, true);
4844
5187
  teamComputerTurns.set(owner.threadId, { owner, computerId: computer.id, botId, remoteAgent });
4845
- let machine = await box.findBox(cfg, ownerId);
5188
+ let machine = await boat.findBoat(cfg, ownerId);
4846
5189
  if (!machine)
4847
- throw new Error("The team's Box computer is missing; explicitly create or retry it from the Team map");
5190
+ throw new Error("The team's Boat computer is missing; explicitly create or retry it from the Team map");
4848
5191
  await bindTurnComputer(owner, `computer:box:${machine.id}`, true);
4849
- const action = box.boxTurnLifecycleAction({ explicitCloud: true, canMount: true, state: typeof machine.state === "string" ? machine.state : null });
5192
+ const action = boat.boatTurnLifecycleAction({ explicitCloud: true, canMount: true, state: typeof machine.state === "string" ? machine.state : null });
4850
5193
  if (action === "wake")
4851
- machine = await box.readyBox(cfg, ownerId);
4852
- if (!machine || box.boxTurnLifecycleAction({ explicitCloud: true, canMount: true, state: typeof machine.state === "string" ? machine.state : null }) !== "attach") {
5194
+ machine = await boat.readyBoat(cfg, ownerId);
5195
+ if (!machine || boat.boatTurnLifecycleAction({ explicitCloud: true, canMount: true, state: typeof machine.state === "string" ? machine.state : null }) !== "attach") {
4853
5196
  throw new Error("The team computer is not ready; check it in the Team map");
4854
5197
  }
4855
5198
  if (turnResourceOwners.get(owner.threadId)?.generation !== owner.generation ||
@@ -4857,7 +5200,7 @@ async function attachTeamBox(computer, botId, owner, canMount, remoteAgent) {
4857
5200
  throw new Error("This computer turn ended while its machine was starting");
4858
5201
  return {
4859
5202
  integration: { kind: "box", boxId: machine.id, token: cfg.box.token, control: controlIntegration(botId, owner.threadId, owner.generation) },
4860
- capture: () => box.screenshotBox(cfg, ownerId, machine.id),
5203
+ capture: () => boat.screenshotBoat(cfg, ownerId, machine.id),
4861
5204
  };
4862
5205
  }
4863
5206
  /** "Works on: This computer", strict. One mount for a bot thread and a group
@@ -4916,17 +5259,17 @@ async function mountBotVps(bot, owner, opts) {
4916
5259
  },
4917
5260
  };
4918
5261
  }
4919
- /** The bot's own Box. Explicit Cloud is the consent boundary: it may create a
5262
+ /** The bot's own Boat. Explicit Cloud is the consent boundary: it may create a
4920
5263
  * missing machine and wake an archived one (~8s, and it un-pauses billing);
4921
5264
  * Auto only attaches a machine that is already running. */
4922
- async function attachBotBox(bot, owner, opts) {
4923
- // Explicit cloud turns can provision/wake the same bot's Box. Claim before
5265
+ async function attachBotBoat(bot, owner, opts) {
5266
+ // Explicit cloud turns can provision/wake the same bot's Boat. Claim before
4924
5267
  // any network await so setup itself cannot race another turn.
4925
5268
  if (opts.explicitCloud)
4926
5269
  await bindTurnComputer(owner, `computer:box-bot:${bot.id}`, true);
4927
5270
  let b;
4928
5271
  try {
4929
- b = await box.findBox(cfg, bot.id);
5272
+ b = await boat.findBoat(cfg, bot.id);
4930
5273
  }
4931
5274
  catch (error) {
4932
5275
  // Auto may fall through when an optional provider is offline, but a
@@ -4935,7 +5278,7 @@ async function attachBotBox(bot, owner, opts) {
4935
5278
  throw error;
4936
5279
  b = null;
4937
5280
  }
4938
- const lifecycleOf = (explicitCloud) => box.boxTurnLifecycleAction({
5281
+ const lifecycleOf = (explicitCloud) => boat.boatTurnLifecycleAction({
4939
5282
  explicitCloud,
4940
5283
  canMount: opts.canMount,
4941
5284
  state: typeof b?.state === "string" ? b.state : null,
@@ -4943,16 +5286,16 @@ async function attachBotBox(bot, owner, opts) {
4943
5286
  let lifecycle = lifecycleOf(opts.explicitCloud);
4944
5287
  if (lifecycle === "provision") {
4945
5288
  broadcast({ kind: "computer", botId: bot.id, state: "provisioning" });
4946
- await box.provisionBox(cfg, bot.id, bot.name);
4947
- b = await box.findBox(cfg, bot.id);
5289
+ await boat.provisionBoat(cfg, bot.id, bot.name);
5290
+ b = await boat.findBoat(cfg, bot.id);
4948
5291
  lifecycle = lifecycleOf(true);
4949
5292
  }
4950
- // an archived box answers every action with an error until it resumes —
5293
+ // an archived boat answers every action with an error until it resumes —
4951
5294
  // wake it here, once, instead of letting the agent discover it one failed
4952
5295
  // tool call at a time.
4953
5296
  if (lifecycle === "wake") {
4954
5297
  broadcast({ kind: "computer", botId: bot.id, state: "waking" });
4955
- b = (await box.readyBox(cfg, bot.id)) ?? b;
5298
+ b = (await boat.readyBoat(cfg, bot.id)) ?? b;
4956
5299
  lifecycle = lifecycleOf(true);
4957
5300
  }
4958
5301
  if (!b || lifecycle !== "attach")
@@ -4960,19 +5303,19 @@ async function attachBotBox(bot, owner, opts) {
4960
5303
  const machine = b;
4961
5304
  await bindTurnComputer(owner, `computer:box:${machine.id}`, opts.remoteAgent);
4962
5305
  return {
4963
- capture: () => box.screenshotBox(cfg, bot.id, machine.id),
5306
+ capture: () => boat.screenshotBoat(cfg, bot.id, machine.id),
4964
5307
  integration: opts.canMount
4965
5308
  ? { kind: "box", boxId: machine.id, token: cfg.box.token, control: controlIntegration(bot.id, owner.threadId, owner.generation) }
4966
5309
  : null,
4967
5310
  };
4968
5311
  }
4969
- function managedBoxOwners() {
5312
+ function managedBoatOwners() {
4970
5313
  return [...store.bots.map((bot) => ({
4971
5314
  botId: bot.id,
4972
5315
  name: bot.name,
4973
5316
  // A machine is not safe to mutate while any app-level work or human
4974
5317
  // control lease still names its owner. This is deliberately conservative
4975
- // across destination changes: an old Box may still contain valuable state.
5318
+ // across destination changes: an old Boat may still contain valuable state.
4976
5319
  inUse: bot.busy === true ||
4977
5320
  hasDirectDispatch(bot.id) ||
4978
5321
  activeGroupTurnForBot(bot.id) !== null ||
@@ -4991,7 +5334,7 @@ function botHasActiveTurn(botId) {
4991
5334
  }
4992
5335
  function providerTransitionMessage(provider) {
4993
5336
  return provider === "box"
4994
- ? "Box account settings are being updated — wait for them to finish"
5337
+ ? "Boat account settings are being updated — wait for them to finish"
4995
5338
  : "VPS connection settings are being updated — wait for them to finish";
4996
5339
  }
4997
5340
  /** Work which started first wins. This is intentionally conservative: a
@@ -5001,19 +5344,19 @@ function providerOperationConflict(provider) {
5001
5344
  if (provider === "vps" && activeVpsThreads.size > 0) {
5002
5345
  return "stop the active VPS turn before changing the SSH config alias";
5003
5346
  }
5004
- if (managedBoxOwners().some((owner) => owner.inUse)) {
5005
- return `stop active bot work and computer control before changing ${provider === "box" ? "the Box account" : "the VPS connection"}`;
5347
+ if (managedBoatOwners().some((owner) => owner.inUse)) {
5348
+ return `stop active bot work and computer control before changing ${provider === "box" ? "the Boat account" : "the VPS connection"}`;
5006
5349
  }
5007
- if (boxLifecycleBusyBots.size > 0 || vpsPreviewRequests.size > 0) {
5350
+ if (boatLifecycleBusyBots.size > 0 || vpsPreviewRequests.size > 0) {
5008
5351
  return "wait for cloud computer actions to finish before changing provider settings";
5009
5352
  }
5010
5353
  if (provider === "box") {
5011
- if (boxInventoryRequestsBusyIds.size > 0 || orphanBoxLifecycleBusyIds.size > 0) {
5012
- return "wait for cloud computer actions to finish before changing the Box account";
5354
+ if (boatInventoryRequestsBusyIds.size > 0 || orphanBoatLifecycleBusyIds.size > 0) {
5355
+ return "wait for cloud computer actions to finish before changing the Boat account";
5013
5356
  }
5014
- const deletingBoxIds = new Set(boxDeletionSnapshot().map((entry) => entry.boxId));
5015
- if (boxCreateRecoverySnapshot().some((entry) => !entry.resolved && (!entry.boxId || !deletingBoxIds.has(entry.boxId)))) {
5016
- return "finish reconciling pending cloud computer creation before changing the Box account";
5357
+ const deletingBoatIds = new Set(boatDeletionSnapshot().map((entry) => entry.boxId));
5358
+ if (boatCreateRecoverySnapshot().some((entry) => !entry.resolved && (!entry.boxId || !deletingBoatIds.has(entry.boxId)))) {
5359
+ return "finish reconciling pending cloud computer creation before changing the Boat account";
5017
5360
  }
5018
5361
  }
5019
5362
  else if (vps.vpsLifecycleBusy()) {
@@ -5023,10 +5366,10 @@ function providerOperationConflict(provider) {
5023
5366
  }
5024
5367
  function turnSurfacePlan(bot, runOn, threadId) {
5025
5368
  const instance = registry.get(bot.modelSelection.instanceId);
5026
- const forcedBox = runOn === "cloud" || Boolean(inheritedTeamComputer(bot));
5369
+ const forcedBoat = runOn === "cloud" || Boolean(inheritedTeamComputer(bot));
5027
5370
  return resolveSurface({
5028
- destination: forcedBox ? "cloud" : bot.computer,
5029
- pinnedSurface: forcedBox || !threadId ? null : store.taskByThread(bot.id, threadId)?.surface,
5371
+ destination: forcedBoat ? "cloud" : bot.computer,
5372
+ pinnedSurface: forcedBoat || !threadId ? null : store.taskByThread(bot.id, threadId)?.surface,
5030
5373
  browserOn: builtInBrowserEnabled(cfg) && bot.browser !== false && instance?.adapter.capabilities.browserMcp === true,
5031
5374
  });
5032
5375
  }
@@ -5040,14 +5383,14 @@ function turnProvider(bot, runOn, threadId) {
5040
5383
  return "box";
5041
5384
  return bot.cloudBackend === "vps" ? "vps" : wants === "cloud" ? "box" : null;
5042
5385
  }
5043
- /** A driver with a Box bridge keeps the selected model. Other engines use
5044
- * Box's native runner. Start and interrupt must resolve the same owner. */
5386
+ /** A driver with a Boat bridge keeps the selected model. Other engines use
5387
+ * Boat's native runner. Start and interrupt must resolve the same owner. */
5045
5388
  function turnInstance(bot, runOn, threadId) {
5046
5389
  const selected = registry.get(bot.modelSelection.instanceId);
5047
5390
  if (selected?.adapter.capabilities.cloudComputerMcp)
5048
5391
  return selected;
5049
- const onBox = turnProvider(bot, runOn, threadId) === "box";
5050
- return onBox
5392
+ const onBoat = turnProvider(bot, runOn, threadId) === "box";
5393
+ return onBoat
5051
5394
  ? registry.instances().find((candidate) => candidate.driverKind === "boxAgent") ?? null
5052
5395
  : registry.get(bot.modelSelection.instanceId);
5053
5396
  }
@@ -5074,7 +5417,7 @@ async function computerPreviewSurface(bot, threadId) {
5074
5417
  if (remote.ready)
5075
5418
  return "cloud";
5076
5419
  }
5077
- const target = localVmTargetForBot(bot.id);
5420
+ const target = localVmTargetForStatus(bot.id, threadId);
5078
5421
  if (instance?.adapter.capabilities.computerMcp && localVmSeen.has(target.key)) {
5079
5422
  const vm = await containerComputerStatus(undefined, undefined, target).catch(() => null);
5080
5423
  if (vm && autoLocalVmAttachable(vm))
@@ -5110,16 +5453,16 @@ async function selectableComputers(bot) {
5110
5453
  canCreate = Boolean(status?.configured && status.daemonUp && status.container === "missing");
5111
5454
  reason = status?.problem ?? reason;
5112
5455
  }
5113
- else if (box.boxConfigured(cfg) && (caps?.usesCloudComputer === true || registry.instances().some(instance => instance.driverKind === "boxAgent"))) {
5114
- const status = await box.boxStatus(cfg, bot.id);
5115
- const lifecycle = box.boxTurnLifecycleAction({ explicitCloud: true, canMount: true, state: status.box?.state ?? null });
5456
+ else if (boat.boatConfigured(cfg) && (caps?.usesCloudComputer === true || registry.instances().some(instance => instance.driverKind === "boxAgent"))) {
5457
+ const status = await boat.boatStatus(cfg, bot.id);
5458
+ const lifecycle = boat.boatTurnLifecycleAction({ explicitCloud: true, canMount: true, state: status.box?.state ?? null });
5116
5459
  ready = lifecycle === "attach";
5117
5460
  canStart = lifecycle === "wake";
5118
5461
  canCreate = lifecycle === "provision";
5119
5462
  }
5120
5463
  }
5121
5464
  else if (surface === "vm" && localEngine && caps?.computerMcp) {
5122
- const target = localVmTargetForBot(bot.id);
5465
+ const target = localVmTargetForStatus(bot.id);
5123
5466
  const status = await containerComputerStatus(undefined, undefined, target);
5124
5467
  ready = status.ready;
5125
5468
  canCreate = !ready && autoLocalVmAttachable(status);
@@ -5198,29 +5541,29 @@ function providerTransitionForTurn(bot, runOn, threadId) {
5198
5541
  ? providerTransitionMessage(provider)
5199
5542
  : null;
5200
5543
  }
5201
- function claimBoxInventoryRequest(boxId) {
5202
- if (boxInventoryRequestsBusyIds.has(boxId)) {
5544
+ function claimBoatInventoryRequest(boxId) {
5545
+ if (boatInventoryRequestsBusyIds.has(boxId)) {
5203
5546
  throw Object.assign(new Error("this cloud computer is being changed — wait for it to finish"), { status: 409 });
5204
5547
  }
5205
- boxInventoryRequestsBusyIds.add(boxId);
5206
- return () => boxInventoryRequestsBusyIds.delete(boxId);
5548
+ boatInventoryRequestsBusyIds.add(boxId);
5549
+ return () => boatInventoryRequestsBusyIds.delete(boxId);
5207
5550
  }
5208
- /** Claim the owning bot synchronously after Box revalidation and before the
5551
+ /** Claim the owning bot synchronously after Boat revalidation and before the
5209
5552
  * provider mutation. startTurn checks the same set before doing any work, so
5210
5553
  * a new turn and an irreversible lifecycle action cannot pass each other. */
5211
- function claimManagedBoxMutation(instance) {
5554
+ function claimManagedBoatMutation(instance) {
5212
5555
  const ownerBotId = instance.ownerBotId;
5213
5556
  const teamComputer = teamComputers.list().find(computer => teamComputerOwner(computer.id) === ownerBotId);
5214
5557
  if (teamComputer)
5215
5558
  return claimTeamComputerLifecycle(teamComputer);
5216
5559
  if (!ownerBotId) {
5217
- if (orphanBoxLifecycleBusyIds.has(instance.boxId)) {
5560
+ if (orphanBoatLifecycleBusyIds.has(instance.boxId)) {
5218
5561
  throw Object.assign(new Error("this cloud computer is being changed — wait for it to finish"), { status: 409 });
5219
5562
  }
5220
- orphanBoxLifecycleBusyIds.add(instance.boxId);
5221
- return () => orphanBoxLifecycleBusyIds.delete(instance.boxId);
5563
+ orphanBoatLifecycleBusyIds.add(instance.boxId);
5564
+ return () => orphanBoatLifecycleBusyIds.delete(instance.boxId);
5222
5565
  }
5223
- const owner = managedBoxOwners().find((candidate) => candidate.botId === ownerBotId);
5566
+ const owner = managedBoatOwners().find((candidate) => candidate.botId === ownerBotId);
5224
5567
  if (owner?.inUse) {
5225
5568
  throw Object.assign(new Error("this cloud computer is in use — stop its bot's work first"), { status: 409 });
5226
5569
  }
@@ -5231,20 +5574,20 @@ function claimManagedBoxMutation(instance) {
5231
5574
  * other instead of relying on a stale check made before a provider await.
5232
5575
  * Only opening an existing VPS viewer may coexist with its pending preview. */
5233
5576
  function claimBotComputerLifecycle(botId, allowPreview = false) {
5234
- if (boxLifecycleBusyBots.has(botId)) {
5577
+ if (boatLifecycleBusyBots.has(botId)) {
5235
5578
  throw Object.assign(new Error("this bot's cloud computer is being changed — wait for it to finish"), { status: 409 });
5236
5579
  }
5237
5580
  if (!allowPreview && vpsPreviewRequests.has(botId)) {
5238
5581
  throw Object.assign(new Error("a screen preview is still refreshing — wait before changing this computer"), { status: 409 });
5239
5582
  }
5240
- boxLifecycleBusyBots.add(botId);
5241
- return () => boxLifecycleBusyBots.delete(botId);
5583
+ boatLifecycleBusyBots.add(botId);
5584
+ return () => boatLifecycleBusyBots.delete(botId);
5242
5585
  }
5243
5586
  function claimManagedVpsMutation(containerName) {
5244
5587
  const owner = store.bots.find((candidate) => vps.vpsContainerName(candidate.id) === containerName);
5245
5588
  if (!owner)
5246
5589
  return () => { };
5247
- const ownerState = managedBoxOwners().find((candidate) => candidate.botId === owner.id);
5590
+ const ownerState = managedBoatOwners().find((candidate) => candidate.botId === owner.id);
5248
5591
  if (ownerState?.inUse) {
5249
5592
  throw Object.assign(new Error("this VPS computer is in use — stop its bot's work first"), { status: 409 });
5250
5593
  }
@@ -5253,6 +5596,30 @@ function claimManagedVpsMutation(containerName) {
5253
5596
  function localVmTargetForBot(botId) {
5254
5597
  return localVmMode(cfg) === "per-bot" ? perBotLocalVmTarget(botId) : SHARED_LOCAL_VM_TARGET;
5255
5598
  }
5599
+ function localVmPoolSeatHolder(seat) {
5600
+ return localVmLeases.forTarget(poolLocalVmTarget(seat).key).current(localVmOwnerBusy);
5601
+ }
5602
+ /** The Local VM a conversation's turn addresses. In pool mode each thread
5603
+ * draws a seat with TTL-bounded affinity so it reuses the desktop that
5604
+ * holds its login state; other modes are unchanged. */
5605
+ function localVmTargetForThread(botId, threadId) {
5606
+ if (localVmMode(cfg) !== "pool")
5607
+ return localVmTargetForBot(botId);
5608
+ return poolLocalVmTarget(localVmSeatPool.assign(threadId, localVmPoolSeatHolder));
5609
+ }
5610
+ /** Best-effort seat for read-only surfaces (Computer panel previews,
5611
+ * screenshots, status payloads). Records no affinity: a status probe must
5612
+ * not steer a later turn onto a desktop its claim would not choose. */
5613
+ function localVmTargetForStatus(botId, threadId) {
5614
+ if (localVmMode(cfg) !== "pool")
5615
+ return localVmTargetForBot(botId);
5616
+ // An in-flight turn keeps its claimed seat even after its affinity TTL
5617
+ // decays: prefer the live target so a preview cannot land on another seat.
5618
+ const activeTarget = threadId ? localVmThreadTargets.get(threadId) : undefined;
5619
+ if (activeTarget)
5620
+ return activeTarget;
5621
+ return poolLocalVmTarget((threadId ? localVmSeatPool.affinitySeat(threadId) : null) ?? 0);
5622
+ }
5256
5623
  function localVmLeaseFor(target) {
5257
5624
  return localVmLeases.forTarget(target.key);
5258
5625
  }
@@ -5286,6 +5653,19 @@ function releaseLocalVmThread(threadId) {
5286
5653
  const target = localVmThreadTargets.get(threadId);
5287
5654
  if (!target)
5288
5655
  return;
5656
+ // Renew affinity at settlement so a turn that consumed part of the TTL
5657
+ // still leaves the full window after it ends; failed claims never reach
5658
+ // here (no target), so only settled turns renew. A turn longer than the
5659
+ // TTL finds its entry expired — touch() alone would drop it — so renew
5660
+ // with the seat the turn actually ran on. A target recorded before a
5661
+ // mid-turn mode change is not a pool seat; touch still covers that.
5662
+ if (localVmMode(cfg) === "pool") {
5663
+ const pooled = /^pool:(\d+)$/.exec(target.key);
5664
+ if (pooled)
5665
+ localVmSeatPool.renew(threadId, Number(pooled[1]));
5666
+ else
5667
+ localVmSeatPool.touch(threadId);
5668
+ }
5289
5669
  localVmLeaseFor(target).release(threadId);
5290
5670
  if (localVmActiveThreads.get(target.key) === threadId)
5291
5671
  localVmActiveThreads.delete(target.key);
@@ -5296,6 +5676,27 @@ function releaseLocalVmThread(threadId) {
5296
5676
  // bot's current destination is intentionally ignored: moving a bot to Cloud,
5297
5677
  // Browser, This computer, Auto, or Off does not delete its old Local VM.
5298
5678
  void (async () => {
5679
+ if (localVmMode(cfg) === "pool") {
5680
+ // Same restore rule as per-bot: idle cleanup removes the container, not
5681
+ // its provisioned workspace, so every surviving seat stays Auto-eligible.
5682
+ const seats = localVmMaxInstances(cfg);
5683
+ for (let seat = 0; seat < seats; seat += 1) {
5684
+ const target = poolLocalVmTarget(seat);
5685
+ if (localVmWorkspaceExists(target))
5686
+ localVmSeen.add(target.key);
5687
+ }
5688
+ const runtime = await containerRuntimeStatus().catch(() => null);
5689
+ if (!runtime?.runtime || !runtime.daemonUp)
5690
+ return;
5691
+ const existing = await discoverExistingPoolLocalVms(seats, runtime.runtime).catch(() => []);
5692
+ const statuses = await Promise.all(existing.map((target) => containerComputerStatus(undefined, undefined, target).catch(() => null)));
5693
+ existing.forEach((target, index) => {
5694
+ noteLocalVmSeen(target, statuses[index]);
5695
+ if (shouldArmLocalVmIdle(statuses[index]))
5696
+ localVmIdleFor(target).touch();
5697
+ });
5698
+ return;
5699
+ }
5299
5700
  if (localVmMode(cfg) !== "per-bot") {
5300
5701
  const status = await containerComputerStatus(undefined, undefined, SHARED_LOCAL_VM_TARGET).catch(() => null);
5301
5702
  noteLocalVmSeen(SHARED_LOCAL_VM_TARGET, status);
@@ -5497,15 +5898,33 @@ bus.subscribe((event) => {
5497
5898
  }
5498
5899
  // the bot just acted ON ITS SCREEN — refresh the preview now. Only
5499
5900
  // computer tools can change the screen, and each capture competes
5500
- // with the agent for the box's command endpoint, so a bot grinding
5901
+ // with the agent for the boat's command endpoint, so a bot grinding
5501
5902
  // through file edits must not trigger one per tool. The refresh is
5502
5903
  // deliberately broad (a computer_exec may well have launched a
5503
5904
  // window); whether the turn has EARNED a settled screenshot is the
5504
5905
  // narrower question, and only the allow-list answers it.
5505
5906
  if (bot) {
5506
5907
  const touches = screenTouchingTool(toolName);
5908
+ const surface = screenSurfaceForTool(toolName);
5507
5909
  if (touches || /computer|screenshot|click|type_text|press_key|scroll|open_url|wait_for|browser_/i.test(toolName)) {
5508
- pokeScreenPoller(event.threadId, touches, screenSurfaceForTool(toolName));
5910
+ pokeScreenPoller(event.threadId, touches, surface);
5911
+ }
5912
+ // A completed screen-touching computer tool is real screen
5913
+ // activity (#1653): it restarts the idle clock on that turn's
5914
+ // computer claim. The poller's own frames never arrive here, so
5915
+ // they cannot keep a quiet seat held.
5916
+ if (touches && surface === "computer") {
5917
+ const computer = turnComputerResources.get(event.threadId);
5918
+ if (computer)
5919
+ turnResources.activity(computer.resource, computer.owner);
5920
+ // Real cloud-screen work refreshes the #1655 idle stop for
5921
+ // that seat; the poller's own frames never arrive here, so
5922
+ // preview traffic cannot keep a paid machine awake.
5923
+ if (computer?.resource.startsWith("computer:box:")) {
5924
+ const seatBot = store.botByThread(event.threadId);
5925
+ if (seatBot)
5926
+ cloudSeatLeases.get(seatBot.id)?.touch();
5927
+ }
5509
5928
  }
5510
5929
  }
5511
5930
  }
@@ -6718,6 +7137,9 @@ function drainQueuedSends() {
6718
7137
  },
6719
7138
  });
6720
7139
  resolve();
7140
+ // M2: only this group left the queue, and a turn that never
7141
+ // started publishes no completion to wake the groups behind it.
7142
+ queueMicrotask(drainQueuedSends);
6721
7143
  }).catch(reject);
6722
7144
  }),
6723
7145
  // Provider completion can precede its dispatch promise: keep the queue
@@ -6847,7 +7269,7 @@ async function startOrQueueOpenedThread(botId, threadId, text, unattended) {
6847
7269
  }
6848
7270
  }
6849
7271
  const screenPollers = new Map();
6850
- /** The preview shares the box's single command endpoint with the agent's
7272
+ /** The preview shares the boat's single command endpoint with the agent's
6851
7273
  * own actions, so every frame we take is latency stolen from the work the
6852
7274
  * user is waiting on. Hence: a slow interval, a floor between captures,
6853
7275
  * and never two in flight. */
@@ -6855,7 +7277,7 @@ const SCREEN_POLL_MS = 6000;
6855
7277
  const SCREEN_MIN_GAP_MS = 3000;
6856
7278
  const SCREEN_SETTLE_TIMEOUT_MS = 10_000;
6857
7279
  /** `screenIsTheWork` starts the turn already counting as screen usage: a
6858
- * boxAgent's whole session runs ON the box, so every tool it calls acts on
7280
+ * boxAgent's whole session runs ON the boat, so every tool it calls acts on
6859
7281
  * that screen even though none of them is named like a computer tool. Its
6860
7282
  * shell-only turns are kept honest by the settle-time hash gate instead. */
6861
7283
  function startScreenPoller(botId, threadId, captures, { screenIsTheWork = false } = {}) {
@@ -6948,7 +7370,7 @@ function shownScreenHash(threadId) {
6948
7370
  * in-flight poke first) so the settled screenshot shows the screen's actual
6949
7371
  * end state, not the previous action's. A turn that never touched the
6950
7372
  * screen settles nothing — and skips the capture, which is one less
6951
- * command on the box's single endpoint. A frame the reader can already see
7373
+ * command on the boat's single endpoint. A frame the reader can already see
6952
7374
  * settles nothing either: the boxAgent pre-touch counts every turn as
6953
7375
  * screen work, so without this its shell-only replies would all end in the
6954
7376
  * same idle desktop. Either way the poller is torn down here, so no
@@ -7051,7 +7473,7 @@ async function startTurn(botId, text, opts) {
7051
7473
  status: 409,
7052
7474
  });
7053
7475
  }
7054
- if (boxLifecycleBusyBots.has(botId)) {
7476
+ if (boatLifecycleBusyBots.has(botId)) {
7055
7477
  throw Object.assign(new Error("this bot's cloud computer is being changed — wait for it to finish"), { status: 409 });
7056
7478
  }
7057
7479
  if (threadBusy(botId, threadId))
@@ -7088,7 +7510,7 @@ async function startTurn(botId, text, opts) {
7088
7510
  const instance = turnInstance(bot, opts?.runOn, threadId);
7089
7511
  if (!instance) {
7090
7512
  throw Object.assign(new Error(turnProvider(bot, opts?.runOn, threadId) === "box"
7091
- ? "the Cloud VM runner is unavailable — configure Box in App Settings"
7513
+ ? "the Cloud VM runner is unavailable — configure Boat in App Settings"
7092
7514
  : `provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`), { status: 409 });
7093
7515
  }
7094
7516
  const policyRefusal = policyModelRefusal(instance);
@@ -7137,7 +7559,7 @@ async function startTurn(botId, text, opts) {
7137
7559
  const switchedEngine = instance.instanceId !== bot.modelSelection.instanceId;
7138
7560
  const useInstanceDefaults = switchedEngine || (opts?.runOn === "cloud" && !instance.adapter.capabilities.cloudComputerMcp);
7139
7561
  const model = useInstanceDefaults ? instance.models.default : bot.modelSelection.model;
7140
- // A native cloud runner borrows its instance defaults; a Box bridge keeps
7562
+ // A native cloud runner borrows its instance defaults; a Boat bridge keeps
7141
7563
  // the bot's selected model, effort and variant.
7142
7564
  const effort = useInstanceDefaults ? undefined : bot.modelSelection.effort;
7143
7565
  const variant = useInstanceDefaults ? undefined : bot.modelSelection.variant;
@@ -7192,7 +7614,7 @@ async function startTurn(botId, text, opts) {
7192
7614
  }
7193
7615
  const agentsMounted = (commsDepth < MAX_COMMS_DEPTH || Boolean(opts?.coordination)) && instance.adapter.capabilities.agentsMcp === true;
7194
7616
  // busy flips immediately so the composer locks; the dispatch itself runs
7195
- // in the background — box provisioning can take ~90s and must never
7617
+ // in the background — boat provisioning can take ~90s and must never
7196
7618
  // hang the HTTP request
7197
7619
  const dispatchClaimId = randomUUID();
7198
7620
  const resourceOwner = { threadId, generation: dispatchClaimId };
@@ -7460,7 +7882,7 @@ async function startTurn(botId, text, opts) {
7460
7882
  // the private bot workspace. A legacy task with an existing provider
7461
7883
  // session deliberately pins to null (the old home-folder behavior),
7462
7884
  // because moving a live session would break resume.
7463
- // A cloud run happens on the box, where a host folder means nothing:
7885
+ // A cloud run happens on the boat, where a host folder means nothing:
7464
7886
  // pin the task to the default so the header chip never shows the
7465
7887
  // bot's folder for a task that runs elsewhere.
7466
7888
  if (opts?.runOn === "cloud")
@@ -7482,7 +7904,7 @@ async function startTurn(botId, text, opts) {
7482
7904
  const dwebUrl = process.env.DWEB_URL?.trim();
7483
7905
  if (dwebUrl)
7484
7906
  integrations.dweb = { url: dwebUrl };
7485
- // Cloud routines always use Box/BoxAgent. The per-bot backend applies
7907
+ // Cloud routines always use Boat/BoatAgent. The per-bot backend applies
7486
7908
  // only to ordinary turns that mount a computer into the local agent.
7487
7909
  const teamComputer = inheritedTeamComputer(bot);
7488
7910
  const cloudBackend = teamComputer || opts?.runOn === "cloud" || bot.cloudBackend !== "vps" ? "box" : "vps";
@@ -7528,6 +7950,25 @@ async function startTurn(botId, text, opts) {
7528
7950
  }
7529
7951
  return containerComputerFrame(undefined, undefined, localVmTarget);
7530
7952
  };
7953
+ /** Pin the conversation at the moment its seat is actually claimed
7954
+ * (issue #1650): the same guards as the dispatch-time pin below, so
7955
+ * a claim landing later in the turn records exactly what a mount-time
7956
+ * pin would have — and a turn that never claims records nothing.
7957
+ * The guard reads live bot and task state because a claim can land
7958
+ * long after dispatch: a Works on change may have swept this
7959
+ * thread's auto pins, or a person may have pinned it meanwhile, and
7960
+ * a deferred claim must resurrect neither nor clobber either. */
7961
+ const autoPinAllowed = () => {
7962
+ const liveBot = store.bot(bot.id);
7963
+ const liveTask = store.taskByThread(bot.id, threadId);
7964
+ return liveBot?.computer === undefined && !teamComputer && opts?.runOn !== "cloud" && !plan.pinned &&
7965
+ Boolean(liveTask && liveTask.surfaceSource !== "user");
7966
+ };
7967
+ const pinAutoSurface = (surface) => {
7968
+ if (autoPinAllowed()) {
7969
+ store.patchTask(bot.id, threadId, { surface, surfaceSource: "auto" });
7970
+ }
7971
+ };
7531
7972
  /** The exclusive Local VM claim sequence, verbatim from the old inline
7532
7973
  * attach path, shared by dispatch (eager) and the first-screen-call
7533
7974
  * gate (issue #1361). Idempotent per turn: the resource claim and the
@@ -7537,7 +7978,7 @@ async function startTurn(botId, text, opts) {
7537
7978
  * desktop, not whatever localVmTargetForBot resolves to by the time
7538
7979
  * the first screen call arrives. */
7539
7980
  const claimAutoLocalVm = async (claimThreadId, pinnedTarget) => {
7540
- const localVmTarget = pinnedTarget ?? localVmTargetForBot(bot.id);
7981
+ const localVmTarget = pinnedTarget ?? localVmTargetForThread(bot.id, claimThreadId);
7541
7982
  await bindTurnComputer(resourceOwner, `computer:vm:${localVmTarget.key}`, true);
7542
7983
  if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) {
7543
7984
  throw new Error("this Local VM is being started, stopped, or replaced — wait for setup to finish");
@@ -7548,6 +7989,10 @@ async function startTurn(botId, text, opts) {
7548
7989
  if (!localVmLeaseFor(localVmTarget).claim(claimThreadId, bot.id, localVmOwnerBusy)) {
7549
7990
  throw new Error("this Local VM is already being used by another turn — wait for that turn to finish");
7550
7991
  }
7992
+ // Only a turn that wins its lease extends pool affinity; a thread
7993
+ // retrying behind a stranger lets the TTL lapse and migrates.
7994
+ if (localVmMode(cfg) === "pool")
7995
+ localVmSeatPool.touch(claimThreadId);
7551
7996
  localVmThreadTargets.set(claimThreadId, localVmTarget);
7552
7997
  localVmActiveThreads.set(localVmTarget.key, claimThreadId);
7553
7998
  localVmIdleFor(localVmTarget).touch();
@@ -7589,12 +8034,17 @@ async function startTurn(botId, text, opts) {
7589
8034
  dropLease();
7590
8035
  throw new Error("the Local VM lease expired while preparing the turn");
7591
8036
  }
7592
- // Same contract as the Box and VPS branches below: without this the
8037
+ // Same contract as the Boat and VPS branches below: without this the
7593
8038
  // poller never starts, so the Local VM publishes no `screen` events
7594
8039
  // and every client that only has the stream (the phone) waits
7595
8040
  // forever. The web panel hid the gap by polling the screenshot
7596
8041
  // route itself.
7597
8042
  previewCapture = localVmPreviewFor(localVmTarget, claimThreadId);
8043
+ // Pin on use: this runs for the dispatch-time claim of a VM created
8044
+ // for the turn and for the gate-fired claim of a lazily mounted one
8045
+ // alike — either way the conversation remembers the desktop this
8046
+ // turn actually took, not one it merely mounted (issue #1650).
8047
+ pinAutoSurface("vm");
7598
8048
  return { target: localVmTarget, runtime: localVm.runtime };
7599
8049
  };
7600
8050
  // Issue #1369: a lazy claim that rejects must not leave its turn busy
@@ -7636,7 +8086,16 @@ async function startTurn(botId, text, opts) {
7636
8086
  return false;
7637
8087
  throw new Error("this model engine cannot use the Local VM — choose Claude or an ACP engine, or select another computer destination");
7638
8088
  }
7639
- const localVmTarget = localVmTargetForBot(bot.id);
8089
+ if (!strict && localVmMode(cfg) === "pool") {
8090
+ // localVmTargetForThread records pool affinity, so a turn the
8091
+ // fast path is about to skip must not reserve or refresh a seat.
8092
+ // Gate on the side-effect-free candidate assign() would pick —
8093
+ // the automationSource bypass below stays authoritative.
8094
+ const poolCandidate = poolLocalVmTarget(localVmSeatPool.candidate(threadId, localVmPoolSeatHolder));
8095
+ if (!localVmSeen.has(poolCandidate.key) && !opts?.automationSource)
8096
+ return false;
8097
+ }
8098
+ const localVmTarget = localVmTargetForThread(bot.id, threadId);
7640
8099
  let lazyReadyVm = null;
7641
8100
  if (!strict) {
7642
8101
  // Nothing this process has ever seen for this target, and nobody is
@@ -7738,6 +8197,7 @@ async function startTurn(botId, text, opts) {
7738
8197
  // The claim restarts the poller with the capture, the way the
7739
8198
  // Local VM's lazy claim does.
7740
8199
  onClaimed: (vpsCapture) => {
8200
+ pinAutoSurface("cloud");
7741
8201
  previewCapture = vpsCapture;
7742
8202
  if (threadBusy(bot.id, threadId)) {
7743
8203
  const touched = screenPollers.get(threadId)?.touched ?? false;
@@ -7760,16 +8220,16 @@ async function startTurn(botId, text, opts) {
7760
8220
  }
7761
8221
  }
7762
8222
  }
7763
- // Cloud is strict when selected. Native Box and drivers with a Box
7764
- // bridge may also reuse an already-ready Box on Auto.
8223
+ // Cloud is strict when selected. Native Boat and drivers with a Boat
8224
+ // bridge may also reuse an already-ready Boat on Auto.
7765
8225
  if (teamComputer) {
7766
- const attached = await attachTeamBox(teamComputer, bot.id, resourceOwner, instance.adapter.capabilities.usesCloudComputer === true, instance.adapter.capabilities.remoteAgent === true);
8226
+ const attached = await attachTeamBoat(teamComputer, bot.id, resourceOwner, instance.adapter.capabilities.usesCloudComputer === true, instance.adapter.capabilities.remoteAgent === true);
7767
8227
  integrations.computer = attached.integration;
7768
8228
  previewCapture = attached.capture;
7769
8229
  computerKind = "box";
7770
8230
  }
7771
- if (!teamComputer && instance.adapter.capabilities.usesCloudComputer === true && (wants === "cloud" || wants === undefined) && cloudBackend === "box" && box.boxConfigured(cfg)) {
7772
- const attached = await attachBotBox(bot, resourceOwner, {
8231
+ if (!teamComputer && instance.adapter.capabilities.usesCloudComputer === true && (wants === "cloud" || wants === undefined) && cloudBackend === "box" && boat.boatConfigured(cfg)) {
8232
+ const attached = await attachBotBoat(bot, resourceOwner, {
7773
8233
  explicitCloud: wants === "cloud",
7774
8234
  canMount: instance.adapter.capabilities.usesCloudComputer === true,
7775
8235
  remoteAgent: instance.adapter.capabilities.remoteAgent === true,
@@ -7782,8 +8242,8 @@ async function startTurn(botId, text, opts) {
7782
8242
  }
7783
8243
  }
7784
8244
  }
7785
- if (wants === "cloud" && cloudBackend === "box" && !box.boxConfigured(cfg)) {
7786
- throw new Error("Cloud box is not configured — add a Box API key or choose Local VM");
8245
+ if (wants === "cloud" && cloudBackend === "box" && !boat.boatConfigured(cfg)) {
8246
+ throw new Error("Cloud Boat is not configured — add a Boat API key or choose Local VM");
7787
8247
  }
7788
8248
  if (wants === "cloud" && cloudBackend === "box" && !integrations.computer) {
7789
8249
  throw new Error("the cloud computer could not be created or reached");
@@ -7811,7 +8271,24 @@ async function startTurn(botId, text, opts) {
7811
8271
  })) {
7812
8272
  const cua = readCuaConnection();
7813
8273
  if (cua) {
7814
- await bindTurnComputer(resourceOwner, "computer:host");
8274
+ // Lazy host claim (issue #1650): the gated integration mounts
8275
+ // now, but the exclusive computer:host seat is taken only by the
8276
+ // first screen tools/call, through the computer-control gate —
8277
+ // the same seam as the Local VM (#1361) and the VPS. An Auto
8278
+ // turn that never touches the screen holds no desktop seat and
8279
+ // records no pin; a claim that finds the seat held waits behind
8280
+ // the holder like every other seat instead of failing on the
8281
+ // spot.
8282
+ autoVmClaims.set(threadId, {
8283
+ owner: resourceOwner,
8284
+ lazy: true,
8285
+ label: "this computer",
8286
+ onRejected: surfaceLazyClaimRejection("this computer"),
8287
+ claim: async () => {
8288
+ await bindTurnComputer(resourceOwner, "computer:host", true);
8289
+ pinAutoSurface("local");
8290
+ },
8291
+ });
7815
8292
  integrations.localComputer = gatedLocalComputer(cua, controlIntegration(bot.id, threadId, dispatchClaimId));
7816
8293
  computerKind = "local";
7817
8294
  }
@@ -7943,8 +8420,15 @@ async function startTurn(botId, text, opts) {
7943
8420
  // turns stay there and the composer can show it. Explicit settings are
7944
8421
  // not recorded: an auto pin yields to a later Works on change (which
7945
8422
  // sweeps it — store.clearAutoSurfacePins); a person's pin does not.
7946
- if (bot.computer === undefined && !teamComputer && opts?.runOn !== "cloud" && !plan.pinned) {
7947
- const used = mountedComputer ?? (integrations.browser ? "browser" : null);
8423
+ // Pin on use (issue #1650): every computer that registers a claim
8424
+ // slot — a ready Local VM, the VPS, the host fallback, a VM created
8425
+ // for this turn — records its pin inside the claim itself, so a turn
8426
+ // that only mounted tools records nothing. A Box attached during
8427
+ // dispatch and the built-in browser (no seat to claim) pin here.
8428
+ if (autoPinAllowed()) {
8429
+ const claimSlot = autoVmClaims.get(threadId);
8430
+ const pinsAtClaim = claimSlot?.owner.generation === resourceOwner.generation;
8431
+ const used = pinsAtClaim ? null : mountedComputer ?? (integrations.browser ? "browser" : null);
7948
8432
  if (used)
7949
8433
  store.patchTask(bot.id, threadId, { surface: used, surfaceSource: "auto" });
7950
8434
  }
@@ -8005,6 +8489,15 @@ async function startTurn(botId, text, opts) {
8005
8489
  { 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
8490
  ]);
8007
8491
  turnPromptBytes.set(threadId, { stable: Buffer.byteLength(prompt.stable), volatile: Buffer.byteLength(prompt.volatile) });
8492
+ // Automatic recall rides in front of THIS turn's message, never in the
8493
+ // system prompt: the volatile half is re-sent whole whenever any part of
8494
+ // it changes, and recall changes nearly every turn.
8495
+ const recalled = autoRecallPrompt(bot, threadId, resolvedImages.text, {
8496
+ // a routine run starts fresh by design, and a webhook is untrusted:
8497
+ // neither pulls earlier conversations in
8498
+ conversations: commsDepth === 0 && !coordinationNode && !opts?.automationSource && !opts?.unattended,
8499
+ userName: cfg.profile?.name?.trim() || "User",
8500
+ });
8008
8501
  runningTurnEngines.set(threadId, instance);
8009
8502
  // The prompt carries the soul as saved now. If it changed during setup,
8010
8503
  // decide again from what is actually sent — except on a continuation
@@ -8029,7 +8522,7 @@ async function startTurn(botId, text, opts) {
8029
8522
  threadId,
8030
8523
  botId: bot.id,
8031
8524
  startupRecovery: cfg.automaticRecovery?.enabled === true && !opts?.automaticRecoveryAttempted,
8032
- text: dispatchContext.turnText,
8525
+ text: withRecalled(recalled, dispatchContext.turnText),
8033
8526
  refreshSystemPrompt: true,
8034
8527
  images: turnImages,
8035
8528
  approvalMode: approvalModeForTurn(bot, commsDepth > 0),
@@ -8091,7 +8584,7 @@ async function startTurn(botId, text, opts) {
8091
8584
  }
8092
8585
  // a turn can settle before dispatch returns, and a poller started
8093
8586
  // after its own turn.completed would never be torn down — it would
8094
- // keep polling the box forever, carrying dead per-turn state. busy
8587
+ // keep polling the boat forever, carrying dead per-turn state. busy
8095
8588
  // is flipped false in the fold, so it is the honest "still running".
8096
8589
  if ((previewCapture || browserCapture) && threadBusy(bot.id, threadId)) {
8097
8590
  startScreenPoller(bot.id, threadId, { ...(previewCapture ? { computer: previewCapture } : {}), ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: instance.adapter.capabilities.remoteAgent === true });
@@ -8469,6 +8962,16 @@ routines = new RoutineManager({
8469
8962
  broadcast({ kind: "bot", bot: publicBot(bot) });
8470
8963
  return task;
8471
8964
  },
8965
+ joinConversation: (run) => {
8966
+ if (!routinesInConversationEnabled(cfg) || run.target !== "bot")
8967
+ return null;
8968
+ const source = routineSourceOwner(run);
8969
+ // Only this bot's own visible chat. A room, or a teammate's thread, keeps
8970
+ // the detached run so one bot cannot write its work into the wrong place.
8971
+ if (!source || source.group || source.bot.id !== run.botId)
8972
+ return null;
8973
+ return source.threadId;
8974
+ },
8472
8975
  createGoalTask: (groupId, title) => store.createGroupTask(groupId, title, false),
8473
8976
  isResultsThread: (botId, threadId) => {
8474
8977
  const bot = store.bot(botId);
@@ -8606,10 +9109,10 @@ async function cloudRoutineReadiness(botId, threadId) {
8606
9109
  const bot = threadId ? store.projectBotForTask(botId, threadId) : store.bot(botId);
8607
9110
  if (!bot || bot.hidden)
8608
9111
  return { ready: false, reason: "The routine's target bot no longer exists." };
8609
- if (!box.boxConfigured(cfg)) {
9112
+ if (!boat.boatConfigured(cfg)) {
8610
9113
  return {
8611
9114
  ready: false,
8612
- reason: 'The Box cloud computer needs a working Box API key. For the bot’s configured computer, including a self-hosted VPS, set run_on="maus" instead.',
9115
+ 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
9116
  };
8614
9117
  }
8615
9118
  const instance = turnInstance(bot, "cloud", threadId);
@@ -8617,15 +9120,15 @@ async function cloudRoutineReadiness(botId, threadId) {
8617
9120
  return { ready: false, reason: "The Cloud VM runner is unavailable. Restart OpenMausBot and try again." };
8618
9121
  try {
8619
9122
  if ((await instance.snapshot()).state !== "available") {
8620
- return { ready: false, reason: "The target bot's model engine is not ready to use the Box cloud computer." };
9123
+ return { ready: false, reason: "The target bot's model engine is not ready to use the Boat cloud computer." };
8621
9124
  }
8622
9125
  const teamComputer = inheritedTeamComputer(bot);
8623
9126
  const ownerId = teamComputer ? teamComputerOwner(teamComputer.id) : bot.id;
8624
- const machine = await box.findBox(cfg, ownerId);
9127
+ const machine = await boat.findBoat(cfg, ownerId);
8625
9128
  if (teamComputer && !machine) {
8626
- return { ready: false, reason: "The team's Box computer is missing; explicitly create or retry it from the Team map." };
9129
+ return { ready: false, reason: "The team's Boat computer is missing; explicitly create or retry it from the Team map." };
8627
9130
  }
8628
- // Explicit Cloud may provision or wake the bot's own Box at dispatch.
9131
+ // Explicit Cloud may provision or wake the bot's own Boat at dispatch.
8629
9132
  // This probe only checks its identity/account, without starting billing.
8630
9133
  return { ready: true };
8631
9134
  }
@@ -8648,9 +9151,9 @@ const routineRequests = new RoutineRequestService({
8648
9151
  const proposer = store.bot(proposerBotId);
8649
9152
  const targetBot = store.bot(target.botId);
8650
9153
  if (!targetBot)
8651
- return `@${target.name} no longer exists, so this routine cannot be scheduled for it`;
9154
+ return `@${target.name} no longer exists, so this routine request cannot be confirmed for it`;
8652
9155
  if (!proposer || !canReachPeer(proposer, targetBot)) {
8653
- return `@${target.name} is no longer in this section, so this routine cannot be scheduled for it`;
9156
+ return `@${target.name} is no longer in this section, so this routine request cannot be confirmed for it`;
8654
9157
  }
8655
9158
  return null;
8656
9159
  },
@@ -8667,7 +9170,7 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
8667
9170
  if (localVmModeChangeBusy) {
8668
9171
  return deletionResponse(409, { error: "Local VM settings are being updated — wait before deleting this bot" });
8669
9172
  }
8670
- if (boxLifecycleBusyBots.has(bot.id)) {
9173
+ if (boatLifecycleBusyBots.has(bot.id)) {
8671
9174
  return deletionResponse(409, { error: "wait for this bot's cloud computer action to finish before deleting the bot" });
8672
9175
  }
8673
9176
  const activeRoutine = routines.activeRunForBot(bot.id);
@@ -8682,17 +9185,17 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
8682
9185
  error: `stop this bot's work in channel ${activeGroup.group.name} before deleting the bot`,
8683
9186
  });
8684
9187
  }
8685
- // A direct turn that has already claimed the bot can provision a Box in
8686
- // its background setup. Do not let deletion race that work while a Box
9188
+ // A direct turn that has already claimed the bot can provision a Boat in
9189
+ // its background setup. Do not let deletion race that work while a Boat
8687
9190
  // account is configured; the person can stop the turn and retry.
8688
- if ((box.boxConfigured(cfg) || vpsSshAlias(cfg)) && (bot.busy || hasDirectDispatch(bot.id))) {
9191
+ if ((boat.boatConfigured(cfg) || vpsSshAlias(cfg)) && (bot.busy || hasDirectDispatch(bot.id))) {
8689
9192
  return deletionResponse(409, { error: "stop this bot's work before checking and deleting its cloud computer" });
8690
9193
  }
8691
- const botBoxRecovery = boxCreateRecoverySnapshot().filter((entry) => entry.botId === bot.id);
8692
- const botBoxDeletions = boxDeletionSnapshot().filter((entry) => entry.ownerBotId === bot.id);
8693
- if (botBoxRecovery.some((entry) => !entry.resolved)) {
9194
+ const botBoatRecovery = boatCreateRecoverySnapshot().filter((entry) => entry.botId === bot.id);
9195
+ const botBoatDeletions = boatDeletionSnapshot().filter((entry) => entry.ownerBotId === bot.id);
9196
+ if (botBoatRecovery.some((entry) => !entry.resolved)) {
8694
9197
  return deletionResponse(409, {
8695
- error: "finish reconciling this bot's pending cloud computer creation before deleting it — check boat.dev, then retry Box setup",
9198
+ error: "finish reconciling this bot's pending cloud computer creation before deleting it — check boat.dev, then retry Boat setup",
8696
9199
  });
8697
9200
  }
8698
9201
  // Bot deletion awaits VM/browser/provider cleanup. Claim the bot and
@@ -8741,25 +9244,25 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
8741
9244
  // Preflight every provider before deleting any resource. Cleanup can
8742
9245
  // still fail mid-flight across independent providers, but a missing
8743
9246
  // credential or offline daemon should not cause avoidable partial work.
8744
- const vpsInventory = await vps.listManagedVpsComputers(cfg, managedBoxOwners());
9247
+ const vpsInventory = await vps.listManagedVpsComputers(cfg, managedBoatOwners());
8745
9248
  if (vpsInventory.configured && !vpsInventory.available) {
8746
9249
  return deletionResponse(503, {
8747
9250
  error: `${vpsInventory.problem ?? "VPS computer inventory is unavailable"}. The bot was kept so its computer can be retried safely`,
8748
9251
  });
8749
9252
  }
8750
9253
  const ownedVpsComputers = vpsInventory.instances.filter((instance) => instance.ownerBotId === bot.id);
8751
- if ((botBoxRecovery.length > 0 || botBoxDeletions.length > 0) && !box.boxConfigured(cfg)) {
9254
+ if ((botBoatRecovery.length > 0 || botBoatDeletions.length > 0) && !boat.boatConfigured(cfg)) {
8752
9255
  return deletionResponse(409, {
8753
- error: "Reconnect the Box account that owns this bot's remembered cloud computer, then retry deletion",
9256
+ error: "Reconnect the Boat account that owns this bot's remembered cloud computer, then retry deletion",
8754
9257
  });
8755
9258
  }
8756
- const cloudInventory = await box.listManagedBoxes(cfg, managedBoxOwners());
9259
+ const cloudInventory = await boat.listManagedBoats(cfg, managedBoatOwners());
8757
9260
  if (cloudInventory.configured && !cloudInventory.available) {
8758
9261
  return deletionResponse(503, {
8759
9262
  error: `${cloudInventory.problem ?? "cloud computer inventory is unavailable"}. The bot was kept so its computer can be retried safely`,
8760
9263
  });
8761
9264
  }
8762
- const ownedBoxComputers = cloudInventory.instances.filter((instance) => instance.ownerBotId === bot.id);
9265
+ const ownedBoatComputers = cloudInventory.instances.filter((instance) => instance.ownerBotId === bot.id);
8763
9266
  // Revalidate a reviewed Chief-of-Staff request and establish the
8764
9267
  // browser cleanup intent before the first irreversible provider
8765
9268
  // mutation. A stale review or damaged journal therefore leaves every
@@ -8772,14 +9275,14 @@ async function deleteBotWithLifecycle(botId, revalidate = () => { }, setupReques
8772
9275
  // each exact, freshly revalidated identity before making its bot
8773
9276
  // owner disappear. Shared team computers use a different owner id
8774
9277
  // and are intentionally absent from these lists.
8775
- for (const instance of ownedBoxComputers) {
8776
- const removed = await box.deleteManagedBox(cfg, managedBoxOwners(), instance.boxId, instance.name);
9278
+ for (const instance of ownedBoatComputers) {
9279
+ const removed = await boat.deleteManagedBoat(cfg, managedBoatOwners(), instance.boxId, instance.name);
8777
9280
  if (removed.pending) {
8778
9281
  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
9282
  }
8780
9283
  }
8781
9284
  for (const instance of ownedVpsComputers) {
8782
- await vps.removeManagedVpsComputer(cfg, managedBoxOwners(), instance.name, instance.name);
9285
+ await vps.removeManagedVpsComputer(cfg, managedBoatOwners(), instance.name, instance.name);
8783
9286
  }
8784
9287
  if (localVmCleanup) {
8785
9288
  if (localVmCleanup.removeContainer) {
@@ -9351,7 +9854,10 @@ const roomHandoffTimer = setInterval(() => {
9351
9854
  }
9352
9855
  }, 250);
9353
9856
  roomHandoffTimer.unref();
9354
- const GROUP_CONTEXT_MESSAGES = 30;
9857
+ // The room-context window IS the drain's coalescing cap: a burst longer
9858
+ // than the window would append transcript lines the responder never reads,
9859
+ // so both bounds come from one constant (admission.ts).
9860
+ const GROUP_CONTEXT_MESSAGES = DRAIN_COALESCE_MAX_ITEMS;
9355
9861
  const MAX_GROUP_HOPS = 1;
9356
9862
  function teammateReportContext(requestId, readerBotId) {
9357
9863
  const node = roomHandoffs.nodes.get(requestId);
@@ -9361,9 +9867,10 @@ function teammateReportContext(requestId, readerBotId) {
9361
9867
  return "[Teammate result withheld or no longer retained]";
9362
9868
  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
9869
  }
9364
- function serializeRoomContext(threadId, userName, textOverride, readerBotId) {
9870
+ function serializeRoomContext(threadId, userName, textOverrides, readerBotId) {
9365
9871
  const messages = store.messagesFor(threadId);
9366
9872
  const messagesById = new Map(messages.map((message) => [message.id, message]));
9873
+ const overrides = new Map(textOverrides?.map((override) => [override.messageId, override.text]));
9367
9874
  return messages
9368
9875
  .filter((m) => (m.kind === "text" && m.text) || (m.kind === "digest" && m.digest) || m.roomRequest?.phase === "result")
9369
9876
  .slice(-GROUP_CONTEXT_MESSAGES)
@@ -9376,7 +9883,8 @@ function serializeRoomContext(threadId, userName, textOverride, readerBotId) {
9376
9883
  // turns. Resolve from the existing bounded store and recheck access.
9377
9884
  return teammateReportContext(m.roomRequest.id, readerBotId);
9378
9885
  }
9379
- const rendered = textOverride?.messageId === m.id ? { ...m, text: textOverride.text } : m;
9886
+ const overridden = overrides.get(m.id);
9887
+ const rendered = overridden !== undefined ? { ...m, text: overridden } : m;
9380
9888
  // a bot's name is quoted on the speaker line, so it gets one line; a
9381
9889
  // user line that came through the API says so, since the reader would
9382
9890
  // otherwise take it for the person typing
@@ -9545,14 +10053,39 @@ setupRetry = 0) {
9545
10053
  integrations.hooks = hooksIntegration(bot.id, threadId, internalGeneration);
9546
10054
  }
9547
10055
  const latestUser = [...store.activePath(threadId)].reverse().find((message) => message.role === "user" && message.kind === "text" && message.text);
9548
- const resolvedLatestImages = latestUser?.text && !cardContinuation
10056
+ const usesNativeImageInput = instance.adapter.capabilities.nativeImageInput === true;
10057
+ // M2: a drained room turn runs one sender's whole burst as ONE turn, so
10058
+ // every burst line must reach the responder the way its own turn would
10059
+ // have carried it — attachments extracted natively, their tags stripped
10060
+ // from the rendered context. Recover the burst by the queueIds recorded
10061
+ // on the operation, not by position: the last line alone is not the burst.
10062
+ const burstIds = new Set(operation?.queuedQueueIds ?? []);
10063
+ const burstOverrides = [];
10064
+ const burstImages = [];
10065
+ if (usesNativeImageInput && !cardContinuation) {
10066
+ for (const message of store.activePath(threadId)) {
10067
+ if (message.role !== "user" || message.kind !== "text" || !message.text)
10068
+ continue;
10069
+ if (!message.queueId || !burstIds.has(message.queueId))
10070
+ continue;
10071
+ const resolved = extractTurnImages(message.text);
10072
+ burstOverrides.push({ messageId: message.id, text: resolved.text });
10073
+ burstImages.push(...resolved.images);
10074
+ }
10075
+ }
10076
+ // the latest line is usually the burst's last item: extracting it again
10077
+ // would double-count its attachments and re-strip already-stripped words
10078
+ const latestInBurst = latestUser?.queueId !== undefined && burstIds.has(latestUser.queueId);
10079
+ const resolvedLatestImages = latestUser?.text && !cardContinuation && !latestInBurst
9549
10080
  ? extractTurnImages(latestUser.text)
9550
10081
  : { text: latestUser?.text ?? "", images: [] };
9551
- const usesNativeImageInput = instance.adapter.capabilities.nativeImageInput === true;
9552
10082
  const roomContext = serializeRoomContext(threadId, userName, usesNativeImageInput && latestUser
9553
- ? { messageId: latestUser.id, text: resolvedLatestImages.text }
10083
+ ? [
10084
+ ...burstOverrides,
10085
+ ...(latestInBurst ? [] : [{ messageId: latestUser.id, text: resolvedLatestImages.text }]),
10086
+ ]
9554
10087
  : undefined, bot.id);
9555
- const turnImages = usesNativeImageInput ? resolvedLatestImages.images : [];
10088
+ const turnImages = usesNativeImageInput ? [...burstImages, ...resolvedLatestImages.images] : [];
9556
10089
  const skills = availableSkills();
9557
10090
  const selectedSkills = mergeSkills(selectBundledSkills(roomContext, instance.adapter.capabilities.phoneMcp === true ? ["phoneMcp"] : [], skills), selectBundledSkills(latestUser?.text ?? "", skillAuthoring ? ["skillAuthoring"] : [], skills));
9558
10091
  if (selectedSkills.some((skill) => skill.manifest.requiredCapabilities.includes("phoneMcp"))) {
@@ -9640,7 +10173,7 @@ setupRetry = 0) {
9640
10173
  onDispatchError?.(message);
9641
10174
  return true;
9642
10175
  }
9643
- if (boxLifecycleBusyBots.has(readyBot.id)) {
10176
+ if (boatLifecycleBusyBots.has(readyBot.id)) {
9644
10177
  if (orchestration) {
9645
10178
  orchestration.result.outcome = "busy";
9646
10179
  return true;
@@ -9682,7 +10215,7 @@ setupRetry = 0) {
9682
10215
  orchestration?.onClaimed?.();
9683
10216
  // The stall completion handler only exists once the provider turn runs
9684
10217
  // (registered at dispatch below), but this watch is armed at claim. Latch
9685
- // a stall that fires during setup — browser, box, and VM mounts can all
10218
+ // a stall that fires during setup — browser, boat, and VM mounts can all
9686
10219
  // wedge — so the dispatch site completes the turn instead of launching a
9687
10220
  // provider turn the person was already told was stopped. The finally
9688
10221
  // below unregisters the latch on every other exit.
@@ -9691,7 +10224,7 @@ setupRetry = 0) {
9691
10224
  setupStalled = true;
9692
10225
  });
9693
10226
  // Watch from claim, not provider dispatch: room setup (connected-app
9694
- // discovery, browser, box, and VM mounts) can wedge before any provider
10227
+ // discovery, browser, boat, and VM mounts) can wedge before any provider
9695
10228
  // event exists. The exits between here and dispatch either throw into the
9696
10229
  // finally's guarded cleanup or return through the revalidation block,
9697
10230
  // both of which settle the watch.
@@ -9718,7 +10251,7 @@ setupRetry = 0) {
9718
10251
  const roomPlaceRefusal = roomKind && managedPolicy.computerRefusal(roomKind);
9719
10252
  if (roomPlaceRefusal)
9720
10253
  throw Object.assign(new Error(roomPlaceRefusal), { code: "managed_policy" });
9721
- // The Computer engine runs on the Box; this computer has no tools it can
10254
+ // The Computer engine runs on the Boat; this computer has no tools it can
9722
10255
  // reach, exactly as a bot thread refuses it.
9723
10256
  if (roomPlan.computer === "local" && instance.adapter.capabilities.remoteAgent === true) {
9724
10257
  throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
@@ -9741,7 +10274,7 @@ setupRetry = 0) {
9741
10274
  return false;
9742
10275
  }
9743
10276
  if (roomTeamComputer) {
9744
- const attached = await attachTeamBox(roomTeamComputer, readyBot.id, resourceOwner, instance.adapter.capabilities.usesCloudComputer === true, instance.adapter.capabilities.remoteAgent === true);
10277
+ const attached = await attachTeamBoat(roomTeamComputer, readyBot.id, resourceOwner, instance.adapter.capabilities.usesCloudComputer === true, instance.adapter.capabilities.remoteAgent === true);
9745
10278
  if (isCancelled?.() || groupSpeakers.get(threadId) !== roomSpeaker ||
9746
10279
  activeInternalGenerationByThread.get(threadId) !== internalGeneration)
9747
10280
  return false;
@@ -9784,10 +10317,10 @@ setupRetry = 0) {
9784
10317
  roomComputerKind = "vps";
9785
10318
  }
9786
10319
  else {
9787
- if (!box.boxConfigured(cfg))
9788
- throw new Error("Cloud box is not configured — add a Box API key or choose Local VM");
10320
+ if (!boat.boatConfigured(cfg))
10321
+ throw new Error("Cloud Boat is not configured — add a Boat API key or choose Local VM");
9789
10322
  const remoteAgent = instance.adapter.capabilities.remoteAgent === true;
9790
- const attached = await attachBotBox(readyBot, resourceOwner, { explicitCloud: true,
10323
+ const attached = await attachBotBoat(readyBot, resourceOwner, { explicitCloud: true,
9791
10324
  canMount: instance.adapter.capabilities.usesCloudComputer === true, remoteAgent });
9792
10325
  if (!roomSetupIsCurrent())
9793
10326
  return false;
@@ -9806,7 +10339,7 @@ setupRetry = 0) {
9806
10339
  throw new Error("this model engine cannot use the Local VM");
9807
10340
  }
9808
10341
  // A distinct identity fences cleanup even in shared mode on the same room thread.
9809
- const target = { ...localVmTargetForBot(readyBot.id) };
10342
+ const target = { ...localVmTargetForThread(readyBot.id, threadId) };
9810
10343
  await bindTurnComputer(resourceOwner, `computer:vm:${target.key}`, true);
9811
10344
  if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(target.key)) {
9812
10345
  throw new Error("this Local VM is being started, stopped, or replaced");
@@ -9814,6 +10347,8 @@ setupRetry = 0) {
9814
10347
  if (!localVmLeaseFor(target).claim(threadId, readyBot.id, localVmOwnerBusy)) {
9815
10348
  throw new Error("this Local VM is already being used by another turn");
9816
10349
  }
10350
+ if (localVmMode(cfg) === "pool")
10351
+ localVmSeatPool.touch(threadId);
9817
10352
  roomVmTarget = target;
9818
10353
  localVmThreadTargets.set(threadId, target);
9819
10354
  localVmActiveThreads.set(target.key, threadId);
@@ -10065,10 +10600,13 @@ setupRetry = 0) {
10065
10600
  providerDispatched = true;
10066
10601
  turnPromptBytes.set(threadId, { stable: Buffer.byteLength(roomSystem.stable), volatile: Buffer.byteLength(roomSystem.volatile) });
10067
10602
  runningTurnEngines.set(threadId, instance);
10603
+ // notes only in a room: a private chat reaches a room through the
10604
+ // explicit, disclosed session_search, never automatically
10605
+ const roomRecalled = cardContinuation ? "" : autoRecallPrompt(bot, threadId, resolvedLatestImages.text, { conversations: false, userName });
10068
10606
  guardTurnDispatch(instance.adapter.sendTurn({
10069
10607
  threadId,
10070
10608
  botId: readyBot.id,
10071
- text,
10609
+ text: withRecalled(roomRecalled, text),
10072
10610
  refreshSystemPrompt: true,
10073
10611
  images: turnImages,
10074
10612
  approvalMode: roomTurnApprovalMode(readyBot, orchestration),
@@ -10593,17 +11131,49 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
10593
11131
  if (options.goalCoordinatorBotId && (channelMode !== "goal" || !requestedGoalCoordinator)) {
10594
11132
  throw Object.assign(new Error("the selected goal coordinator is not an active room member"), { status: 409 });
10595
11133
  }
10596
- const message = store.appendMessage(threadId, {
10597
- role: "user",
10598
- kind: "text",
10599
- text,
10600
- replyToId: replyTo?.id,
10601
- sendId,
10602
- channelMode,
10603
- queueId,
10604
- via: options.via,
10605
- sender: options.sender,
10606
- });
11134
+ // A coalesced drain appends every queued line under its own queueId (the
11135
+ // chips and sendId receipts stay per-item); the LAST line is the turn's
11136
+ // user message, exactly like the 1:1 drain's userMessage.
11137
+ const queuedGroup = options.queuedGroup;
11138
+ const message = queuedGroup && queuedGroup.length > 0
11139
+ ? (() => {
11140
+ for (const item of queuedGroup.slice(0, -1)) {
11141
+ store.appendMessage(threadId, {
11142
+ role: "user",
11143
+ kind: "text",
11144
+ text: item.text,
11145
+ replyToId: item.replyToId,
11146
+ sendId: item.sendId,
11147
+ channelMode: item.mode,
11148
+ queueId: item.id,
11149
+ via: item.via,
11150
+ sender: item.sender,
11151
+ });
11152
+ }
11153
+ const last = queuedGroup[queuedGroup.length - 1];
11154
+ return store.appendMessage(threadId, {
11155
+ role: "user",
11156
+ kind: "text",
11157
+ text: last.text,
11158
+ replyToId: replyTo?.id,
11159
+ sendId: last.sendId,
11160
+ channelMode: last.mode,
11161
+ queueId: last.id,
11162
+ via: last.via,
11163
+ sender: last.sender,
11164
+ });
11165
+ })()
11166
+ : store.appendMessage(threadId, {
11167
+ role: "user",
11168
+ kind: "text",
11169
+ text,
11170
+ replyToId: replyTo?.id,
11171
+ sendId,
11172
+ channelMode,
11173
+ queueId,
11174
+ via: options.via,
11175
+ sender: options.sender,
11176
+ });
10607
11177
  // Admitted (see startTurn): the room's speakers are booked to this sender.
10608
11178
  if (options.trigger)
10609
11179
  turnTriggers.set(threadId, options.trigger);
@@ -10672,6 +11242,11 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
10672
11242
  .catch(() => undefined);
10673
11243
  }
10674
11244
  const operation = beginGroupTurnOperation(groupId, threadId, goalCoordinator ? [] : responders.map((responder) => responder.id));
11245
+ if (queuedGroup && queuedGroup.length > 0) {
11246
+ // member turns use these stable ids to carry every burst line's words
11247
+ // and attachments through the turn that swallowed the burst
11248
+ operation.queuedQueueIds = queuedGroup.map((item) => item.id);
11249
+ }
10675
11250
  if (goalCoordinator) {
10676
11251
  const runId = options.goalRunId?.trim() || `goal-${Date.now().toString(36)}-${randomUUID()}`;
10677
11252
  const startedAt = Date.now();
@@ -10756,19 +11331,32 @@ function drainQueuedChannelSends() {
10756
11331
  drainChannelMessages((groupId) => {
10757
11332
  const group = store.group(groupId);
10758
11333
  return group ? groupIsWorking(group) : false;
10759
- }, ({ groupId, threadId, text, replyToId, sendId, mode, id, via, sender, trigger }) => {
11334
+ }, ({ groupId, threadId, items }) => {
10760
11335
  const group = store.group(groupId);
10761
11336
  const ownsThread = group?.dm
10762
11337
  ? group.threadId === threadId
10763
11338
  : Boolean(group && store.groupTaskByThread(group.id, threadId));
10764
11339
  if (!group || !ownsThread)
10765
11340
  return;
11341
+ // The burst runs as ONE room turn: the joined text routes responders
11342
+ // and titles the task, while every queued line still lands under its
11343
+ // own queueId (chips and sendId receipts stay per-item).
11344
+ const head = items[0];
11345
+ const last = items[items.length - 1];
11346
+ const joined = items.map((item) => item.text).join("\n\n");
10766
11347
  try {
10767
- startGroupTurn(groupId, text, resolveReplyTarget(threadId, replyToId), sendId, mode, id, { via, threadId, sender, trigger: queuedTurnTrigger({ trigger, sender }) });
11348
+ startGroupTurn(groupId, joined, resolveReplyTarget(threadId, last.replyToId), last.sendId, last.mode, last.id, {
11349
+ threadId,
11350
+ sender: head.sender,
11351
+ trigger: queuedTurnTrigger({ trigger: head.trigger, sender: head.sender }),
11352
+ queuedGroup: items,
11353
+ });
10768
11354
  }
10769
11355
  catch (error) {
10770
- if (!store.messagesFor(threadId).some((message) => message.queueId === id && message.role === "user")) {
10771
- store.appendMessage(threadId, { role: "user", kind: "text", text, replyToId, sendId, channelMode: mode, queueId: id, via, sender });
11356
+ for (const item of items) {
11357
+ if (!store.messagesFor(threadId).some((message) => message.queueId === item.id && message.role === "user")) {
11358
+ 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 });
11359
+ }
10772
11360
  }
10773
11361
  store.appendMessage(threadId, {
10774
11362
  role: "bot",
@@ -11643,7 +12231,7 @@ async function localVmPayload(target) {
11643
12231
  * app itself deleted eight hours earlier. Someone who steps away overnight
11644
12232
  * comes back to an error on their first message.
11645
12233
  *
11646
- * The cloud branch below already does the opposite: an absent box is
12234
+ * The cloud branch below already does the opposite: an absent boat is
11647
12235
  * provisioned on first use behind a `provisioning` broadcast. This gives the
11648
12236
  * Local VM the same lifecycle for the same reason.
11649
12237
  *
@@ -11663,7 +12251,11 @@ async function readyLocalVmForTurn(botId, target, isCurrent = () => true) {
11663
12251
  // before the first await — the same synchronous-fence-then-count shape
11664
12252
  // the panel route uses — so two concurrent turns cannot both pass the
11665
12253
  // per-bot limit between count and create.
11666
- const ownsProvision = !localVmProvisionBusy;
12254
+ // Pool capacity is bounded by its seat indices. Each seat already has
12255
+ // its own lease/lifecycle fence, so independent cold seats can start
12256
+ // together without competing for the per-bot inventory's capacity gate.
12257
+ const pooled = target.key.startsWith("pool:");
12258
+ const ownsProvision = !pooled && !localVmProvisionBusy;
11667
12259
  if (ownsProvision)
11668
12260
  localVmProvisionBusy = true;
11669
12261
  let status;
@@ -11677,9 +12269,9 @@ async function readyLocalVmForTurn(botId, target, isCurrent = () => true) {
11677
12269
  // Another creation is already mid-flight and its container is not yet
11678
12270
  // visible to a count, so the safe answer is the inspected status —
11679
12271
  // exactly what the over-cap path below returns.
11680
- if (!ownsProvision)
12272
+ if (!pooled && !ownsProvision)
11681
12273
  return status;
11682
- if (target.key !== SHARED_LOCAL_VM_TARGET.key) {
12274
+ if (target.key.startsWith("bot:")) {
11683
12275
  const count = await existingPerBotLocalVmCount(status.runtime);
11684
12276
  if (!isCurrent() || count >= localVmMaxInstances(cfg))
11685
12277
  return status;
@@ -11802,6 +12394,7 @@ function configStatus() {
11802
12394
  features: {
11803
12395
  skillAuthoring: skillAuthoringEnabled(cfg),
11804
12396
  showToolCalls: showToolCallsEnabled(cfg),
12397
+ routinesInConversation: routinesInConversationEnabled(cfg),
11805
12398
  browser: builtInBrowserEnabled(cfg),
11806
12399
  // Maintainer-only escape hatch, not a Settings toggle: the desktop
11807
12400
  // shell and the Settings UI read it so they offer nothing this server
@@ -11810,6 +12403,7 @@ function configStatus() {
11810
12403
  // Plugins → MCP servers switch: Claude bots also see this machine's
11811
12404
  // own Claude Code MCP servers
11812
12405
  claudeUserMcp: claudeUserMcpEnabled(cfg),
12406
+ autoRecall: autoRecallEnabled(cfg),
11813
12407
  },
11814
12408
  // first-run progress — not a secret; the app decides whether to show
11815
12409
  // the welcome tour from this, never from browser storage
@@ -12120,26 +12714,6 @@ function serveStatic(res, path) {
12120
12714
  }
12121
12715
  }
12122
12716
  }
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
12717
  // Loopback-only enforcement: the harness runs on 127.0.0.1 but accepts
12144
12718
  // requests from any loopback connection and any web page that DNS-rebinds
12145
12719
  // onto it. Reject non-loopback Hosts outright (defeats rebinding) and
@@ -12170,13 +12744,13 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
12170
12744
  idle: () => !providerFleetReloading && !providerAuthSessions.active && !browserEngineInstall &&
12171
12745
  !routines?.isTicking && !calendarCalls?.isTicking &&
12172
12746
  !localVmImageBusy && !localVmProvisionBusy && !localVmModeChangeBusy &&
12173
- !localVmLifecycleBusy.size && !boxLifecycleBusyBots.size && !vpsPreviewRequests.size && !orphanBoxLifecycleBusyIds.size &&
12747
+ !localVmLifecycleBusy.size && !boatLifecycleBusyBots.size && !vpsPreviewRequests.size && !orphanBoatLifecycleBusyIds.size &&
12174
12748
  !computerProviderConfigTransitions.size && !checkpointRestoreLeases.size &&
12175
12749
  teamComputers.list().every(computer => !teamComputerInUse(computer)) &&
12176
12750
  store.bots.every((bot) => !botHasActiveTurn(bot.id) && !routines?.activeRunForBot(bot.id) && !botComputerControlSnapshot(bot.id).held) &&
12177
12751
  store.groups.every((group) => !groupIsWorking(group)),
12178
- pause: () => { routines?.stop(); calendarCalls?.stop(); watchdog.stop(); },
12179
- resume: () => { routines?.start(); calendarCalls?.start(); watchdog.start(); },
12752
+ pause: () => { routines?.stop(); calendarCalls?.stop(); watchdog.stop(); memoryUpkeep.pause(); },
12753
+ resume: () => { routines?.start(); calendarCalls?.start(); watchdog.start(); memoryUpkeep.resume(); },
12180
12754
  flush: async () => {
12181
12755
  await Promise.all([flushAllProfileHistory(), flushAllMemoryJournals(), flushUsageLedger(DATA_DIR), flushDecisionLog(DATA_DIR), flushAdminActivity(DATA_DIR)]);
12182
12756
  // With writers gated and work idle, release our WAL connection for the
@@ -12193,6 +12767,19 @@ ROUTES.push(createHostedSlackRoutes({ bot: (id) => store.bot(id), hostedReady: (
12193
12767
  // its file, so New bot cannot disagree with it. No organization: none.
12194
12768
  const orgInstallStatuses = () => orgLibrary?.installStatuses() ?? new Map();
12195
12769
  ROUTES.push(createBotPresetRoutes({ presets: presetStore, orgStatuses: orgInstallStatuses }));
12770
+ // The bot-memory panel's routes (MEMORY.md, memory/ topics, journal); the
12771
+ // store lookups — the 404 precheck and journal thread titles — stay explicit.
12772
+ ROUTES.push(createBotMemoryRoutes({
12773
+ bot: (id) => store.bot(id),
12774
+ taskByThread: (botId, threadId) => store.taskByThread(botId, threadId),
12775
+ upkeep: memoryUpkeep,
12776
+ aboutMe: () => cfg.profile?.aboutMe ?? "",
12777
+ saveAboutMe: (aboutMe) => {
12778
+ saveConfig({ profile: { ...cfg.profile, aboutMe } });
12779
+ Object.assign(cfg, loadConfig());
12780
+ broadcast({ kind: "config", ...configStatus() });
12781
+ },
12782
+ }));
12196
12783
  const toolResults = new ToolResults();
12197
12784
  const handleRequest = async (req, res) => {
12198
12785
  let url;
@@ -12297,6 +12884,26 @@ const handleRequest = async (req, res) => {
12297
12884
  res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, issued.token, { secure, maxAgeSeconds: cookieMaxAgeSeconds(issued.session) }));
12298
12885
  return json(res, 200, { session: issued.session, environment });
12299
12886
  }
12887
+ // The Admin's signed request for one pairing window on a Cloud home
12888
+ // machine. Public like /api/auth/pair, JSON only, and bad signatures
12889
+ // count against the same lockout. Never log its headers, body or code.
12890
+ if (cloudPairing && path === CLOUD_PAIRING_PATH) {
12891
+ res.setHeader("cache-control", "no-store");
12892
+ if (method !== "POST")
12893
+ return json(res, 405, { error: "method_not_allowed" });
12894
+ if (!/^application\/json\b/i.test(String(req.headers["content-type"] ?? ""))) {
12895
+ return json(res, 415, { error: "send the pairing request as JSON (content-type: application/json)" });
12896
+ }
12897
+ const source = requestSource(req);
12898
+ const header = (name) => { const value = req.headers[name]; return typeof value === "string" ? value : undefined; };
12899
+ const result = cloudPairing.handle({
12900
+ timestamp: header("x-omb-cloud-timestamp"), nonce: header("x-omb-cloud-nonce"), signature: header("x-omb-cloud-signature"),
12901
+ body: await readSignedBody(req), source,
12902
+ });
12903
+ if (result.status !== 200)
12904
+ console.warn(`cloud pairing refused from ${source}: ${String(result.body.error)}`);
12905
+ return json(res, result.status, result.body);
12906
+ }
12300
12907
  if (method === "POST" && path === "/api/auth/pair") {
12301
12908
  // JSON only: a cross-site HTML form cannot send this content type
12302
12909
  // without a preflight, so a stray unused code cannot be planted as a
@@ -12450,6 +13057,9 @@ const handleRequest = async (req, res) => {
12450
13057
  // a hosted team workspace: the web UI's first run skips the
12451
13058
  // desktop-only beats there. Absent everywhere else.
12452
13059
  ...(HOSTED_WORKSPACE ? { hosted: true } : {}),
13060
+ // an OMB Cloud home: the web UI's first run is its engine
13061
+ // sign-in (docs/cloud-pro.md). Absent everywhere else.
13062
+ ...(CLOUD_HOME ? { cloudHome: true } : {}),
12453
13063
  });
12454
13064
  }
12455
13065
  if (method === "POST" && path === "/api/auth/stream-ticket") {
@@ -12826,7 +13436,7 @@ const handleRequest = async (req, res) => {
12826
13436
  }
12827
13437
  if (method === "POST" && path === "/api/internal/memory") {
12828
13438
  const body = await readInternalBody();
12829
- const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText }, { source: memorySource() });
13439
+ const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText, ...(body.until !== undefined ? { until: body.until } : {}) }, { source: memorySource() });
12830
13440
  return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : result.code === "over-budget" ? 413 : 400, result);
12831
13441
  }
12832
13442
  if (method === "POST" && path === "/api/internal/memory/log") {
@@ -13150,11 +13760,12 @@ const handleRequest = async (req, res) => {
13150
13760
  const owner = connectorThread(from.id, fromThreadId);
13151
13761
  if (!owner)
13152
13762
  return json(res, 403, { error: "source conversation does not belong to sender" });
13153
- // "Make a routine for @B": resolve the target up front so the model
13154
- // gets a teaching error now, not a mis-bound routine later. Omitted
13155
- // (or the sender's own id) keeps the schedule-for-self path unchanged.
13763
+ // "Make a routine for @B" / "pause @B's routine": resolve the target
13764
+ // up front so the model gets a teaching error now, not a mis-bound
13765
+ // routine later. Omitted (or the sender's own id) keeps the
13766
+ // own-routine path unchanged.
13156
13767
  let forBot;
13157
- if (body.action === "create" && body.forBotId !== undefined) {
13768
+ if (body.forBotId !== undefined) {
13158
13769
  const parsedForBotId = z.string().max(128).safeParse(body.forBotId);
13159
13770
  const forBotId = parsedForBotId.success ? parsedForBotId.data.trim() : "";
13160
13771
  if (!forBotId) {
@@ -13178,8 +13789,8 @@ const handleRequest = async (req, res) => {
13178
13789
  const proposedInput = body.action === "create"
13179
13790
  ? { action: body.action, routine: body.routine, forBot }
13180
13791
  : body.action === "update"
13181
- ? { action: body.action, routineId: body.routineId, changes: body.changes }
13182
- : { action: body.action, routineId: body.routineId };
13792
+ ? { action: body.action, routineId: body.routineId, changes: body.changes, forBot }
13793
+ : { action: body.action, routineId: body.routineId, forBot };
13183
13794
  const proposed = await routineRequests.submit({
13184
13795
  botId: from.id,
13185
13796
  threadId: fromThreadId,
@@ -15348,6 +15959,47 @@ const handleRequest = async (req, res) => {
15348
15959
  });
15349
15960
  }
15350
15961
  // scrollback: the page before a message the client already holds
15962
+ // #1655: the consent card's answer, per conversation. Explicit and
15963
+ // revocable; a thread allowlisted in config carries the same standing
15964
+ // consent without ever seeing a card.
15965
+ m = path.match(/^\/api\/threads\/([\w-]+)\/cloud-overflow$/);
15966
+ if (m && method === "POST") {
15967
+ const threadId = m[1];
15968
+ if (!store.botByThread(threadId))
15969
+ return json(res, 404, { error: "no such conversation" });
15970
+ const body = await readBody(req);
15971
+ if (!body || typeof body !== "object" || Array.isArray(body))
15972
+ return json(res, 400, { error: "body must be a JSON object" });
15973
+ if (typeof body.consent !== "boolean")
15974
+ return json(res, 400, { error: "consent must be a boolean" });
15975
+ if (body.consent) {
15976
+ // Consent answers the priced card, never a guess: without a prior
15977
+ // offer the rate was never shown, so a seat must not start yet.
15978
+ // The grant binds to the rate the card showed (#1655): a config
15979
+ // change re-offers instead of starting at a new price silently.
15980
+ const offeredRate = cloudOverflowConsent.offeredRate(threadId);
15981
+ if (offeredRate === null)
15982
+ return json(res, 409, { error: "no cloud overflow offer" });
15983
+ cloudOverflowConsent.grant(threadId, offeredRate);
15984
+ }
15985
+ else {
15986
+ cloudOverflowConsent.revoke(threadId);
15987
+ // Revocation must reach the machine (#1655): the ledger alone
15988
+ // would leave this conversation's seat billing to its idle
15989
+ // timeout. A start still waking is caught by its own revoke
15990
+ // check once readyBox resolves.
15991
+ const seatBot = store.botByThread(threadId);
15992
+ const lease = seatBot ? cloudSeatLeases.get(seatBot.id) : undefined;
15993
+ if (seatBot && lease && lease.threadId === threadId) {
15994
+ stopCloudSeat(seatBot.id, lease, "Cloud computer stopped because overflow consent was revoked — billing pauses while it sleeps.");
15995
+ }
15996
+ }
15997
+ store.appendMessage(threadId, {
15998
+ role: "bot", kind: "activity",
15999
+ tool: { name: cloudOverflowConsentText(body.consent, cloudOverflowIdleStopMs(cfg)), ok: true },
16000
+ });
16001
+ return json(res, 200, { consented: body.consent });
16002
+ }
15351
16003
  m = path.match(/^\/api\/threads\/([\w-]+)\/messages$/);
15352
16004
  if (m && method === "GET") {
15353
16005
  const threadId = m[1];
@@ -16566,7 +17218,17 @@ const handleRequest = async (req, res) => {
16566
17218
  const held = holdChannelQueue(current.id, targetThreadId, m[2]);
16567
17219
  if (!held)
16568
17220
  return json(res, 404, { error: "no such queued message" });
16569
- const [head] = held.items;
17221
+ // M2: the head may be a coalesced group (one sender's contiguous
17222
+ // burst); Steer folds that whole group into the live turn as one.
17223
+ const headGroup = headChannelGroup(held.items);
17224
+ const [head] = headGroup;
17225
+ // A live steer has no image side channel — the same rule as the
17226
+ // 1:1 queue: attachment words wait for a real turn where central
17227
+ // admission can hand the images to the engine.
17228
+ if (headGroup.some((item) => extractTurnImages(item.text).images.length > 0)) {
17229
+ restoreHeldChannelQueue(held);
17230
+ return json(res, 200, { ok: true, queued: true, threadId: targetThreadId });
17231
+ }
16570
17232
  const decision = admit("room-steer", {
16571
17233
  speakerPresent: Boolean(speaker),
16572
17234
  engineCanSteer: Boolean(instance?.adapter.capabilities.queueing && instance.adapter.steer),
@@ -16583,11 +17245,31 @@ const handleRequest = async (req, res) => {
16583
17245
  // A reply target that cannot be resolved restores the held queue
16584
17246
  // before the request fails — the room's normal drain keeps the head.
16585
17247
  const replyTo = resolveHeldReplyTarget(held, resolveReplyTarget);
17248
+ // Each burst line keeps its own reply relationship: the fold renders
17249
+ // per-item reply context (the head through the helper above, the rest
17250
+ // resolved here) so a mixed burst does not inherit the head's quote
17251
+ // or drop its own. A target that cannot be resolved restores the held
17252
+ // queue before the request fails, mirroring the helper's contract.
17253
+ const speakerName = cfg.profile?.name?.trim() || "User";
17254
+ let foldedPrompt;
17255
+ try {
17256
+ foldedPrompt = headGroup
17257
+ .map((item, index) => promptWithReply(item.text, index === 0
17258
+ ? replyTo
17259
+ : item.replyToId
17260
+ ? resolveReplyTarget(targetThreadId, item.replyToId)
17261
+ : undefined, speakerName))
17262
+ .join("\n\n");
17263
+ }
17264
+ catch (error) {
17265
+ restoreHeldChannelQueue(held);
17266
+ throw error;
17267
+ }
16586
17268
  // steer was offered only when a live speaker instance could take it;
16587
17269
  // the check carries that invariant to the type system.
16588
17270
  const steered = instance?.adapter.steer
16589
17271
  ? await instance.adapter
16590
- .steer(targetThreadId, promptWithReply(head.text, replyTo, cfg.profile?.name?.trim() || "User"))
17272
+ .steer(targetThreadId, foldedPrompt)
16591
17273
  .catch(() => "indeterminate")
16592
17274
  : "refused";
16593
17275
  // The steer was awaited adapter work: re-read every ownership
@@ -16606,25 +17288,25 @@ const handleRequest = async (req, res) => {
16606
17288
  // speaker — and settle the head.
16607
17289
  const delivered = steered !== "refused";
16608
17290
  if (after && delivered && (steered === "indeterminate" || afterSpeakerBotId === speakerBotId)) {
16609
- const message = store.appendMessage(targetThreadId, {
17291
+ const messages = headGroup.map((item) => store.appendMessage(targetThreadId, {
16610
17292
  role: "user",
16611
17293
  kind: "text",
16612
- text: head.text,
16613
- replyToId: head.replyToId,
16614
- sendId: head.sendId,
16615
- channelMode: head.mode,
16616
- queueId: head.id,
16617
- via: head.via,
17294
+ text: item.text,
17295
+ replyToId: item.replyToId,
17296
+ sendId: item.sendId,
17297
+ channelMode: item.mode,
17298
+ queueId: item.id,
17299
+ via: item.via,
16618
17300
  steered: true,
16619
- sender: head.sender,
16620
- });
17301
+ sender: item.sender,
17302
+ }));
16621
17303
  settleHeldChannelQueueHead(held);
16622
17304
  return json(res, 200, {
16623
17305
  ok: true,
16624
17306
  steered: true,
16625
17307
  threadId: targetThreadId,
16626
- messages: [message],
16627
- queueIds: [head.id],
17308
+ messages,
17309
+ queueIds: headGroup.map((item) => item.id),
16628
17310
  });
16629
17311
  }
16630
17312
  if (steered === "indeterminate" && !after) {
@@ -17365,6 +18047,11 @@ const handleRequest = async (req, res) => {
17365
18047
  }
17366
18048
  patch.mcpServers = requestedMcpServers;
17367
18049
  }
18050
+ if (body.memoryUpkeep !== undefined) {
18051
+ if (typeof body.memoryUpkeep !== "boolean")
18052
+ return json(res, 400, { error: "memoryUpkeep must be true or false" });
18053
+ patch.memoryUpkeep = body.memoryUpkeep;
18054
+ }
17368
18055
  // per-bot gate on the app's built-in browser
17369
18056
  if (body.browser !== undefined) {
17370
18057
  if (typeof body.browser !== "boolean")
@@ -17702,6 +18389,8 @@ const handleRequest = async (req, res) => {
17702
18389
  }
17703
18390
  if (!bot)
17704
18391
  return json(res, 404, { error: "no such bot" });
18392
+ if (body.memoryUpkeep === false)
18393
+ memoryUpkeep.dropBot(bot.id);
17705
18394
  // A defined Works on is the newest explicit choice: this bot's
17706
18395
  // auto-recorded pins that now point elsewhere give way immediately, so
17707
18396
  // the next turn on each thread follows the new setting. Person-set pins
@@ -18024,154 +18713,6 @@ const handleRequest = async (req, res) => {
18024
18713
  broadcast({ kind: "bot", bot: visible });
18025
18714
  return json(res, 200, { bot: visible });
18026
18715
  }
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
18716
  // ── workspace checkpoints: per-turn shadow-git snapshots ────────────
18176
18717
  // The list endpoint is the source of truth (turns store nothing), and
18177
18718
  // `enabled` tells the UI whether snapshots can happen here at all —
@@ -19203,7 +19744,7 @@ const handleRequest = async (req, res) => {
19203
19744
  broadcast({ kind: "bot", bot: fresh });
19204
19745
  return json(res, 200, { bot: fresh });
19205
19746
  }
19206
- // Named team Boxes use real independent ownership, never a hidden bot or
19747
+ // Named team Boats use real independent ownership, never a hidden bot or
19207
19748
  // an arbitrary provider id. These new routes remain admin-only by default.
19208
19749
  if (path === "/api/team-computers" && method === "GET") {
19209
19750
  res.setHeader("cache-control", "private, no-store");
@@ -19240,7 +19781,7 @@ const handleRequest = async (req, res) => {
19240
19781
  if (!parsed.success)
19241
19782
  return json(res, 400, { error: "Choose take or release with a valid optional controlLeaseId" });
19242
19783
  const key = teamComputerOwner(found.id);
19243
- if (parsed.data.action === "take" && boxLifecycleBusyBots.has(key))
19784
+ if (parsed.data.action === "take" && boatLifecycleBusyBots.has(key))
19244
19785
  return json(res, 409, { error: "Wait for this computer's action to finish before taking control" });
19245
19786
  if (parsed.data.action === "take")
19246
19787
  assertTeamControlCanBeTaken(found.id);
@@ -19261,13 +19802,13 @@ const handleRequest = async (req, res) => {
19261
19802
  assertCurrentOwner();
19262
19803
  if (section !== null && section !== "" && !store.sections.includes(section))
19263
19804
  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 || boxLifecycleBusyBots.has(bot.id))))
19805
+ 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
19806
  throw Object.assign(new Error("Stop the target team's work and release computer control before assigning this computer"), { status: 409 });
19266
19807
  };
19267
19808
  checkAssignment();
19268
19809
  const release = claimTeamComputerLifecycle(found);
19269
19810
  try {
19270
- if (section !== null && !(await box.findBox(cfg, teamComputerOwner(found.id))))
19811
+ if (section !== null && !(await boat.findBoat(cfg, teamComputerOwner(found.id))))
19271
19812
  return json(res, 409, { error: "Create or retry this computer before assigning it to a team" });
19272
19813
  checkAssignment();
19273
19814
  if (teamComputerInUse(found))
@@ -19282,13 +19823,13 @@ const handleRequest = async (req, res) => {
19282
19823
  if (method === "POST" && !computerId) {
19283
19824
  const parsed = teamComputerCreate.safeParse(body);
19284
19825
  if (!parsed.success)
19285
- return json(res, 400, { error: "Provide requestId (UUID), a name, and acknowledgeCost: true to create a paid Box" });
19286
- if (!box.boxConfigured(cfg))
19287
- return json(res, 409, { error: "Configure Box in Settings before creating a cloud computer" });
19826
+ return json(res, 400, { error: "Provide requestId (UUID), a name, and acknowledgeCost: true to create a paid Boat" });
19827
+ if (!boat.boatConfigured(cfg))
19828
+ return json(res, 409, { error: "Configure Boat in Settings before creating a cloud computer" });
19288
19829
  const computer = teamComputers.create(parsed.data.name, parsed.data.requestId);
19289
19830
  const release = claimTeamComputerLifecycle(computer);
19290
19831
  try {
19291
- await box.provisionBox(cfg, teamComputerOwner(computer.id), computer.name);
19832
+ await boat.provisionBoat(cfg, teamComputerOwner(computer.id), computer.name);
19292
19833
  teamComputers.setProblem(computer.id);
19293
19834
  return json(res, 201, { id: computer.id });
19294
19835
  }
@@ -19302,18 +19843,18 @@ const handleRequest = async (req, res) => {
19302
19843
  }
19303
19844
  if (method === "POST" && found && action && action !== "control") {
19304
19845
  if (action === "provision" && body?.acknowledgeCost !== true)
19305
- return json(res, 400, { error: "Confirm Box creation or wake costs with acknowledgeCost: true" });
19846
+ return json(res, 400, { error: "Confirm Boat creation or wake costs with acknowledgeCost: true" });
19306
19847
  // A ready-only join never wakes, provisions or steals an agent's turn.
19307
19848
  // The caller takes a separate explicit human-control lease first.
19308
19849
  if (action === "join") {
19309
19850
  const key = teamComputerOwner(found.id);
19310
- if (boxLifecycleBusyBots.has(key))
19851
+ if (boatLifecycleBusyBots.has(key))
19311
19852
  return json(res, 409, { error: "Wait for this computer's action to finish" });
19312
19853
  if (!computerControl.snapshot(key).held)
19313
19854
  return json(res, 409, { error: "Take control before opening this shared desktop" });
19314
19855
  const release = claimBotComputerLifecycle(key);
19315
19856
  try {
19316
- return json(res, 200, await box.joinReadyBox(cfg, key));
19857
+ return json(res, 200, await boat.joinReadyBoat(cfg, key));
19317
19858
  }
19318
19859
  finally {
19319
19860
  release();
@@ -19322,8 +19863,8 @@ const handleRequest = async (req, res) => {
19322
19863
  const release = claimTeamComputerLifecycle(found);
19323
19864
  try {
19324
19865
  const result = action === "provision"
19325
- ? await box.provisionBox(cfg, teamComputerOwner(found.id), found.name)
19326
- : await box.sleepBox(cfg, teamComputerOwner(found.id));
19866
+ ? await boat.provisionBoat(cfg, teamComputerOwner(found.id), found.name)
19867
+ : await boat.sleepBoat(cfg, teamComputerOwner(found.id));
19327
19868
  teamComputers.setProblem(found.id);
19328
19869
  return json(res, 200, result);
19329
19870
  }
@@ -19337,12 +19878,13 @@ const handleRequest = async (req, res) => {
19337
19878
  }
19338
19879
  return json(res, 405, { error: "method not allowed" });
19339
19880
  }
19340
- // Account-wide Box inventory is a Settings surface, never a provisioning
19881
+ // Account-wide Boat inventory is a Settings surface, never a provisioning
19341
19882
  // path. Listing remains read-only; lifecycle changes require explicit
19342
19883
  // JSON actions and are revalidated against a fresh provider listing.
19884
+ // The REST path keeps its historical /boxes segment.
19343
19885
  if (method === "GET" && path === "/api/computers/boxes") {
19344
19886
  res.setHeader("cache-control", "private, no-store");
19345
- return json(res, 200, await box.listManagedBoxes(cfg, managedBoxOwners()));
19887
+ return json(res, 200, await boat.listManagedBoats(cfg, managedBoatOwners()));
19346
19888
  }
19347
19889
  m = path.match(/^\/api\/computers\/boxes\/([\w-]+)\/(sleep|delete)$/);
19348
19890
  if (m && method === "POST") {
@@ -19353,16 +19895,16 @@ const handleRequest = async (req, res) => {
19353
19895
  if (computerProviderConfigTransitions.has("box")) {
19354
19896
  return json(res, 409, { error: providerTransitionMessage("box") });
19355
19897
  }
19356
- const releaseInventoryRequest = claimBoxInventoryRequest(m[1]);
19898
+ const releaseInventoryRequest = claimBoatInventoryRequest(m[1]);
19357
19899
  try {
19358
- const owners = managedBoxOwners();
19900
+ const owners = managedBoatOwners();
19359
19901
  if (m[2] === "sleep") {
19360
- return json(res, 200, await box.sleepManagedBox(cfg, owners, m[1], claimManagedBoxMutation));
19902
+ return json(res, 200, await boat.sleepManagedBoat(cfg, owners, m[1], claimManagedBoatMutation));
19361
19903
  }
19362
19904
  if (typeof body?.confirmName !== "string" || body.confirmName.length > 100) {
19363
19905
  return json(res, 400, { error: "confirmName must be the cloud computer name shown in Settings" });
19364
19906
  }
19365
- return json(res, 202, await box.deleteManagedBox(cfg, owners, m[1], body.confirmName, claimManagedBoxMutation));
19907
+ return json(res, 202, await boat.deleteManagedBoat(cfg, owners, m[1], body.confirmName, claimManagedBoatMutation));
19366
19908
  }
19367
19909
  finally {
19368
19910
  releaseInventoryRequest();
@@ -19370,7 +19912,7 @@ const handleRequest = async (req, res) => {
19370
19912
  }
19371
19913
  if (method === "GET" && path === "/api/computers/vps") {
19372
19914
  res.setHeader("cache-control", "private, no-store");
19373
- return json(res, 200, await vps.listManagedVpsComputers(cfg, managedBoxOwners()));
19915
+ return json(res, 200, await vps.listManagedVpsComputers(cfg, managedBoatOwners()));
19374
19916
  }
19375
19917
  m = path.match(/^\/api\/computers\/vps\/([\w-]+)\/remove$/);
19376
19918
  if (m && method === "POST") {
@@ -19386,7 +19928,7 @@ const handleRequest = async (req, res) => {
19386
19928
  }
19387
19929
  const releaseComputerLifecycle = claimManagedVpsMutation(m[1]);
19388
19930
  try {
19389
- return json(res, 200, await vps.removeManagedVpsComputer(cfg, managedBoxOwners(), m[1], body.confirmName));
19931
+ return json(res, 200, await vps.removeManagedVpsComputer(cfg, managedBoatOwners(), m[1], body.confirmName));
19390
19932
  }
19391
19933
  finally {
19392
19934
  releaseComputerLifecycle();
@@ -19417,6 +19959,9 @@ const handleRequest = async (req, res) => {
19417
19959
  if (localVmMode(cfg) === "per-bot" && action === "run") {
19418
19960
  return json(res, 409, { error: "Per-bot mode creates each desktop from that bot's Computer panel" });
19419
19961
  }
19962
+ if (localVmMode(cfg) === "pool" && action === "run") {
19963
+ return json(res, 409, { error: "Pool mode creates its desktops automatically from conversation turns" });
19964
+ }
19420
19965
  const vmOwner = localVmLeaseFor(SHARED_LOCAL_VM_TARGET).current(localVmOwnerBusy);
19421
19966
  if (vmOwner && (action === "stop" || action === "remove" || action === "run")) {
19422
19967
  return json(res, 409, { error: "the Local VM is being used by a bot — stop that turn first" });
@@ -19464,7 +20009,7 @@ const handleRequest = async (req, res) => {
19464
20009
  const bot = computerPreviewBot(m[1], url);
19465
20010
  if (!bot)
19466
20011
  return json(res, 404, { error: "no such bot" });
19467
- return json(res, 200, await localVmPayload(localVmTargetForBot(bot.id)));
20012
+ return json(res, 200, await localVmPayload(localVmTargetForStatus(bot.id, bot.threadId)));
19468
20013
  }
19469
20014
  m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer\/(run|stop|remove)$/);
19470
20015
  if (m && method === "POST") {
@@ -19474,7 +20019,7 @@ const handleRequest = async (req, res) => {
19474
20019
  const bot = store.bot(m[1]);
19475
20020
  if (!bot)
19476
20021
  return json(res, 404, { error: "no such bot" });
19477
- if (boxLifecycleBusyBots.has(bot.id)) {
20022
+ if (boatLifecycleBusyBots.has(bot.id)) {
19478
20023
  return json(res, 409, { error: "this bot's computer is being changed or deleted — wait for it to finish" });
19479
20024
  }
19480
20025
  const action = z.enum(["run", "stop", "remove"]).parse(m[2]);
@@ -19542,7 +20087,7 @@ const handleRequest = async (req, res) => {
19542
20087
  if (url.searchParams.has("threadId") && await computerPreviewSurface(bot, bot.threadId) !== "vm") {
19543
20088
  return json(res, 409, { error: "This conversation is not using the Local VM" });
19544
20089
  }
19545
- const target = localVmTargetForBot(bot.id);
20090
+ const target = localVmTargetForStatus(bot.id, url.searchParams.has("threadId") ? bot.threadId : undefined);
19546
20091
  localVmIdleFor(target).touch();
19547
20092
  res.setHeader("cache-control", "private, no-store");
19548
20093
  return json(res, 200, {
@@ -20294,16 +20839,16 @@ const handleRequest = async (req, res) => {
20294
20839
  }
20295
20840
  if (patch.box?.token !== undefined)
20296
20841
  patch.box.token = patch.box.token.trim();
20297
- const currentBoxToken = cfg.box?.token?.trim() ?? "";
20298
- const nextBoxToken = patch.box?.token === undefined ? currentBoxToken : patch.box.token;
20299
- const changingBoxToken = patch.box?.token !== undefined && nextBoxToken !== currentBoxToken;
20842
+ const currentBoatToken = cfg.box?.token?.trim() ?? "";
20843
+ const nextBoatToken = patch.box?.token === undefined ? currentBoatToken : patch.box.token;
20844
+ const changingBoatToken = patch.box?.token !== undefined && nextBoatToken !== currentBoatToken;
20300
20845
  const currentVpsAlias = vpsSshAlias(cfg);
20301
20846
  const nextVpsAlias = patch.vps === undefined
20302
20847
  ? currentVpsAlias
20303
20848
  : vpsSshAlias({ ...cfg, vps: patch.vps });
20304
20849
  const changingVpsAlias = patch.vps !== undefined && nextVpsAlias !== currentVpsAlias;
20305
20850
  const transitioningProviders = [
20306
- ...(changingBoxToken ? ["box"] : []),
20851
+ ...(changingBoatToken ? ["box"] : []),
20307
20852
  ...(changingVpsAlias ? ["vps"] : []),
20308
20853
  ];
20309
20854
  providerConfigBusy = true;
@@ -20319,7 +20864,7 @@ const handleRequest = async (req, res) => {
20319
20864
  for (const provider of transitioningProviders)
20320
20865
  computerProviderConfigTransitions.add(provider);
20321
20866
  if (changingVpsAlias && currentVpsAlias) {
20322
- const inventory = await vps.listManagedVpsComputers({ vps: { sshAlias: currentVpsAlias } }, managedBoxOwners());
20867
+ const inventory = await vps.listManagedVpsComputers({ vps: { sshAlias: currentVpsAlias } }, managedBoatOwners());
20323
20868
  if (!inventory.available) {
20324
20869
  return json(res, 503, {
20325
20870
  error: `${inventory.problem ?? "VPS computer inventory is unavailable"}. Keep the current SSH config alias and retry`,
@@ -20329,24 +20874,24 @@ const handleRequest = async (req, res) => {
20329
20874
  if (resourceError)
20330
20875
  return json(res, 409, { error: resourceError });
20331
20876
  }
20332
- let boxRecovery = changingBoxToken ? boxCreateRecoverySnapshot() : [];
20333
- let boxDeletions = changingBoxToken ? boxDeletionSnapshot() : [];
20334
- if (changingBoxToken && boxDeletions.length > 0 && !nextBoxToken) {
20877
+ let boatRecovery = changingBoatToken ? boatCreateRecoverySnapshot() : [];
20878
+ let boatDeletions = changingBoatToken ? boatDeletionSnapshot() : [];
20879
+ if (changingBoatToken && boatDeletions.length > 0 && !nextBoatToken) {
20335
20880
  return json(res, 409, {
20336
- error: "finish or retry pending cloud computer deletion before removing the Box account",
20881
+ error: "finish or retry pending cloud computer deletion before removing the Boat account",
20337
20882
  });
20338
20883
  }
20339
20884
  let replacementProvedByDeletion = false;
20340
- if (changingBoxToken && boxDeletions.length > 0 && nextBoxToken) {
20885
+ if (changingBoatToken && boatDeletions.length > 0 && nextBoatToken) {
20341
20886
  try {
20342
- await box.verifyBoxDeletionCredential({ box: { token: nextBoxToken } });
20887
+ await boat.verifyBoatDeletionCredential({ box: { token: nextBoatToken } });
20343
20888
  replacementProvedByDeletion = true;
20344
20889
  // Verification can observe a completed operation and retire both
20345
20890
  // its deletion fence and matching create receipt. Never continue
20346
20891
  // with the pre-verification snapshots: they would demand access
20347
- // to a Box whose exact operation just proved it was deleted.
20348
- boxRecovery = boxCreateRecoverySnapshot();
20349
- boxDeletions = boxDeletionSnapshot();
20892
+ // to a Boat whose exact operation just proved it was deleted.
20893
+ boatRecovery = boatCreateRecoverySnapshot();
20894
+ boatDeletions = boatDeletionSnapshot();
20350
20895
  }
20351
20896
  catch (error) {
20352
20897
  return json(res, error?.status ?? 503, {
@@ -20354,28 +20899,28 @@ const handleRequest = async (req, res) => {
20354
20899
  });
20355
20900
  }
20356
20901
  }
20357
- let currentBoxInventory = null;
20358
- let replacingRejectedBoxToken = false;
20359
- let currentBoxResources = null;
20360
- const journalBoxResources = [];
20361
- const deletingBoxIds = new Set(boxDeletions.map((entry) => entry.boxId));
20362
- if (changingBoxToken && currentBoxToken) {
20363
- currentBoxInventory = await box.listManagedBoxes({ box: { token: currentBoxToken } }, managedBoxOwners());
20364
- if (!currentBoxInventory.available) {
20365
- replacingRejectedBoxToken = currentBoxInventory.credentialRejected === true && Boolean(nextBoxToken);
20366
- if (!replacementProvedByDeletion && !replacingRejectedBoxToken) {
20902
+ let currentBoatInventory = null;
20903
+ let replacingRejectedBoatToken = false;
20904
+ let currentBoatResources = null;
20905
+ const journalBoatResources = [];
20906
+ const deletingBoatIds = new Set(boatDeletions.map((entry) => entry.boxId));
20907
+ if (changingBoatToken && currentBoatToken) {
20908
+ currentBoatInventory = await boat.listManagedBoats({ box: { token: currentBoatToken } }, managedBoatOwners());
20909
+ if (!currentBoatInventory.available) {
20910
+ replacingRejectedBoatToken = currentBoatInventory.credentialRejected === true && Boolean(nextBoatToken);
20911
+ if (!replacementProvedByDeletion && !replacingRejectedBoatToken) {
20367
20912
  return json(res, 503, {
20368
- error: `${currentBoxInventory.problem ?? "cloud computer inventory is unavailable"}. Keep the current Box account and retry`,
20913
+ error: `${currentBoatInventory.problem ?? "cloud computer inventory is unavailable"}. Keep the current Boat account and retry`,
20369
20914
  });
20370
20915
  }
20371
20916
  // A rejected old key cannot authorize its own rotation. Below,
20372
- // validate the replacement and prove access to remembered Boxes;
20917
+ // validate the replacement and prove access to remembered Boats;
20373
20918
  // pending deletions retain their target-bound verification.
20374
- currentBoxInventory = null;
20919
+ currentBoatInventory = null;
20375
20920
  }
20376
- if (currentBoxInventory) {
20377
- const currentById = new Map(currentBoxInventory.instances.map((instance) => [instance.boxId, { boxId: instance.boxId, name: instance.name }]));
20378
- for (const recovery of boxRecovery) {
20921
+ if (currentBoatInventory) {
20922
+ const currentById = new Map(currentBoatInventory.instances.map((instance) => [instance.boxId, { boxId: instance.boxId, name: instance.name }]));
20923
+ for (const recovery of boatRecovery) {
20379
20924
  if (!recovery.boxId)
20380
20925
  continue;
20381
20926
  // A failed provisioning attempt may never have reached the
@@ -20383,29 +20928,29 @@ const handleRequest = async (req, res) => {
20383
20928
  // proved the exact deletion target, so its in-flight resource
20384
20929
  // is governed by that stronger target-bound receipt rather
20385
20930
  // than an OpenMausBot name check.
20386
- if (replacementProvedByDeletion && deletingBoxIds.has(recovery.boxId))
20931
+ if (replacementProvedByDeletion && deletingBoatIds.has(recovery.boxId))
20387
20932
  continue;
20388
- const inspected = await box.inspectBoxIdentity({ box: { token: currentBoxToken } }, recovery.boxId);
20933
+ const inspected = await boat.inspectBoatIdentity({ box: { token: currentBoatToken } }, recovery.boxId);
20389
20934
  if (!inspected.available) {
20390
20935
  return json(res, 503, {
20391
- error: `${inspected.problem ?? "a remembered cloud computer could not be verified"}. Keep the current Box account and retry`,
20936
+ error: `${inspected.problem ?? "a remembered cloud computer could not be verified"}. Keep the current Boat account and retry`,
20392
20937
  });
20393
20938
  }
20394
20939
  if (!inspected.identity) {
20395
20940
  // Reconcile exact stale receipts while the current credential is
20396
20941
  // still available. Leaving one behind would make a later token
20397
- // addition demand access to a Box the provider proved is gone.
20398
- retireDeletedBoxCreate(recovery.boxId);
20942
+ // addition demand access to a Boat the provider proved is gone.
20943
+ retireDeletedBoatCreate(recovery.boxId);
20399
20944
  continue;
20400
20945
  }
20401
20946
  const listed = currentById.get(inspected.identity.boxId);
20402
20947
  if (listed && listed.name !== inspected.identity.name) {
20403
- return json(res, 503, { error: "boat.dev returned conflicting cloud computer identities; keep the current Box account and retry" });
20948
+ return json(res, 503, { error: "boat.dev returned conflicting cloud computer identities; keep the current Boat account and retry" });
20404
20949
  }
20405
20950
  currentById.set(inspected.identity.boxId, inspected.identity);
20406
- journalBoxResources.push(inspected.identity);
20951
+ journalBoatResources.push(inspected.identity);
20407
20952
  }
20408
- currentBoxResources = [...currentById.values()];
20953
+ currentBoatResources = [...currentById.values()];
20409
20954
  }
20410
20955
  }
20411
20956
  if (changingLocalVmMode) {
@@ -20444,50 +20989,50 @@ const handleRequest = async (req, res) => {
20444
20989
  patch.composio = { ...patch.composio, apiKey: "", sessionId: "" };
20445
20990
  }
20446
20991
  }
20447
- // check a box token against the provider before storing it: a
20992
+ // check a boat token against the provider before storing it: a
20448
20993
  // rejected token used to save happily and only surface as a 401 in
20449
20994
  // another panel later, with nothing the user could act on
20450
- const newBoxToken = patch.box?.token;
20451
- if (newBoxToken?.trim()) {
20452
- const check = await box.verifyToken(newBoxToken);
20995
+ const newBoatToken = patch.box?.token;
20996
+ if (newBoatToken?.trim()) {
20997
+ const check = await boat.verifyToken(newBoatToken);
20453
20998
  if (!check.ok)
20454
20999
  return json(res, 400, { error: check.message });
20455
21000
  }
20456
- if (replacingRejectedBoxToken) {
21001
+ if (replacingRejectedBoatToken) {
20457
21002
  // An expired credential cannot authorize its own replacement. Probe
20458
21003
  // the new account without adopting or retiring any local identities.
20459
- const replacement = await box.listManagedBoxes({ box: { token: nextBoxToken } }, managedBoxOwners(), { adoptLegacy: false });
21004
+ const replacement = await boat.listManagedBoats({ box: { token: nextBoatToken } }, managedBoatOwners(), { adoptLegacy: false });
20460
21005
  if (!replacement.available)
20461
- return json(res, 503, { error: replacement.problem ?? "Could not verify the replacement Box key. Your saved key and computers are unchanged." });
21006
+ return json(res, 503, { error: replacement.problem ?? "Could not verify the replacement Boat key. Your saved key and computers are unchanged." });
20462
21007
  }
20463
- if (changingBoxToken && (!currentBoxToken || replacementProvedByDeletion || replacingRejectedBoxToken) && boxRecovery.length > 0) {
20464
- if (!nextBoxToken) {
20465
- return json(res, 409, { error: "restore the Box account that owns the remembered cloud computers before clearing it" });
21008
+ if (changingBoatToken && (!currentBoatToken || replacementProvedByDeletion || replacingRejectedBoatToken) && boatRecovery.length > 0) {
21009
+ if (!nextBoatToken) {
21010
+ return json(res, 409, { error: "restore the Boat account that owns the remembered cloud computers before clearing it" });
20466
21011
  }
20467
- for (const recovery of boxRecovery) {
21012
+ for (const recovery of boatRecovery) {
20468
21013
  if (!recovery.boxId) {
20469
- return json(res, 409, { error: "finish reconciling pending cloud computer creation before changing the Box account" });
21014
+ return json(res, 409, { error: "finish reconciling pending cloud computer creation before changing the Boat account" });
20470
21015
  }
20471
- if (replacementProvedByDeletion && deletingBoxIds.has(recovery.boxId))
21016
+ if (replacementProvedByDeletion && deletingBoatIds.has(recovery.boxId))
20472
21017
  continue;
20473
- const inspected = await box.inspectBoxIdentity({ box: { token: nextBoxToken } }, recovery.boxId);
21018
+ const inspected = await boat.inspectBoatIdentity({ box: { token: nextBoatToken } }, recovery.boxId);
20474
21019
  if (!inspected.available) {
20475
21020
  return json(res, 503, {
20476
- error: `${inspected.problem ?? "a remembered cloud computer could not be verified"}. Retry with the Box account that created it`,
21021
+ error: `${inspected.problem ?? "a remembered cloud computer could not be verified"}. Retry with the Boat account that created it`,
20477
21022
  });
20478
21023
  }
20479
- if (!inspected.identity || !(await box.boxNameMatchesBot(recovery.botId, inspected.identity.name))) {
20480
- return json(res, 409, { error: "that Box token cannot access the remembered cloud computers from this installation" });
21024
+ if (!inspected.identity || !(await boat.boatNameMatchesBot(recovery.botId, inspected.identity.name))) {
21025
+ return json(res, 409, { error: "that Boat token cannot access the remembered cloud computers from this installation" });
20481
21026
  }
20482
21027
  }
20483
21028
  }
20484
- if (changingBoxToken && currentBoxInventory && currentBoxResources) {
21029
+ if (changingBoatToken && currentBoatInventory && currentBoatResources) {
20485
21030
  let replacementResources = null;
20486
- if (nextBoxToken) {
20487
- const replacementInventory = await box.listManagedBoxes({ box: { token: nextBoxToken } }, managedBoxOwners(), { adoptLegacy: false });
21031
+ if (nextBoatToken) {
21032
+ const replacementInventory = await boat.listManagedBoats({ box: { token: nextBoatToken } }, managedBoatOwners(), { adoptLegacy: false });
20488
21033
  if (!replacementInventory.available) {
20489
21034
  return json(res, 503, {
20490
- error: `${replacementInventory.problem ?? "cloud computer inventory is unavailable"}. Keep the current Box account and retry`,
21035
+ error: `${replacementInventory.problem ?? "cloud computer inventory is unavailable"}. Keep the current Boat account and retry`,
20491
21036
  });
20492
21037
  }
20493
21038
  replacementResources = replacementInventory.instances.map((instance) => ({
@@ -20495,21 +21040,21 @@ const handleRequest = async (req, res) => {
20495
21040
  name: instance.name,
20496
21041
  }));
20497
21042
  const replacementById = new Map(replacementResources.map((instance) => [instance.boxId, { boxId: instance.boxId, name: instance.name }]));
20498
- for (const identity of journalBoxResources) {
20499
- const inspected = await box.inspectBoxIdentity({ box: { token: nextBoxToken } }, identity.boxId);
21043
+ for (const identity of journalBoatResources) {
21044
+ const inspected = await boat.inspectBoatIdentity({ box: { token: nextBoatToken } }, identity.boxId);
20500
21045
  if (!inspected.available) {
20501
21046
  return json(res, 503, {
20502
- error: `${inspected.problem ?? "a remembered cloud computer could not be verified"}. Keep the current Box account and retry`,
21047
+ error: `${inspected.problem ?? "a remembered cloud computer could not be verified"}. Keep the current Boat account and retry`,
20503
21048
  });
20504
21049
  }
20505
21050
  if (!inspected.identity || inspected.identity.name !== identity.name) {
20506
- return json(res, 409, { error: "the replacement Box token does not access the same cloud computers" });
21051
+ return json(res, 409, { error: "the replacement Boat token does not access the same cloud computers" });
20507
21052
  }
20508
21053
  replacementById.set(inspected.identity.boxId, inspected.identity);
20509
21054
  }
20510
21055
  replacementResources = [...replacementById.values()];
20511
21056
  }
20512
- const resourceError = boxAccountResourceChangeError(currentBoxResources, replacementResources);
21057
+ const resourceError = boatAccountResourceChangeError(currentBoatResources, replacementResources);
20513
21058
  if (resourceError)
20514
21059
  return json(res, 409, { error: resourceError });
20515
21060
  }
@@ -20777,6 +21322,9 @@ const handleRequest = async (req, res) => {
20777
21322
  return json(res, 200, {
20778
21323
  configured: composio.configured(cfg),
20779
21324
  mode: composio.connectionMode(cfg),
21325
+ // lets the panel say "add a key" on a fresh server instead of
21326
+ // reporting an outage that never happened
21327
+ setup: composio.connectorSetup(cfg),
20780
21328
  source,
20781
21329
  cards,
20782
21330
  ...(pagination ? { pagination } : {}),
@@ -20984,7 +21532,7 @@ const handleRequest = async (req, res) => {
20984
21532
  }
20985
21533
  return json(res, 405, { error: "method not allowed" });
20986
21534
  }
20987
- // ── the bot's cloud computer (Box) ──
21535
+ // ── the bot's cloud computer (Boat) ──
20988
21536
  m = path.match(/^\/api\/bots\/([\w-]+)\/computer$/);
20989
21537
  if (m && method === "GET") {
20990
21538
  const bot = computerPreviewBot(m[1], url);
@@ -20995,10 +21543,10 @@ const handleRequest = async (req, res) => {
20995
21543
  return json(res, 200, { surface, configured: false, backend: bot.cloudBackend === "vps" ? "vps" : "box" });
20996
21544
  const teamComputer = inheritedTeamComputer(bot);
20997
21545
  if (teamComputer)
20998
- return json(res, 200, { surface, backend: "box", teamComputer: { id: teamComputer.id, name: teamComputer.name }, ...(await box.boxStatus(cfg, teamComputerOwner(teamComputer.id))) });
21546
+ return json(res, 200, { surface, backend: "box", teamComputer: { id: teamComputer.id, name: teamComputer.name }, ...(await boat.boatStatus(cfg, teamComputerOwner(teamComputer.id))) });
20999
21547
  return bot.cloudBackend === "vps"
21000
21548
  ? json(res, 200, { surface, backend: "vps", ...(await vps.vpsComputerStatus(cfg, bot.id)) })
21001
- : json(res, 200, { surface, backend: "box", ...(await box.boxStatus(cfg, bot.id)) });
21549
+ : json(res, 200, { surface, backend: "box", ...(await boat.boatStatus(cfg, bot.id)) });
21002
21550
  }
21003
21551
  // Who is driving this bot's computer. GET is the panel's initial read;
21004
21552
  // POST take/release/dismiss-help are the person's three moves. The bot
@@ -21032,7 +21580,7 @@ const handleRequest = async (req, res) => {
21032
21580
  return json(res, 400, { error: "controlLeaseId is invalid" });
21033
21581
  }
21034
21582
  const controlLeaseId = leaseResult?.data;
21035
- if (action === "take" && (boxLifecycleBusyBots.has(bot.id) || boxLifecycleBusyBots.has(controlKey))) {
21583
+ if (action === "take" && (boatLifecycleBusyBots.has(bot.id) || boatLifecycleBusyBots.has(controlKey))) {
21036
21584
  return json(res, 409, { error: "this bot's cloud computer is being changed — wait before taking control" });
21037
21585
  }
21038
21586
  if (action === "take" && controlLeaseId) {
@@ -21082,7 +21630,7 @@ const handleRequest = async (req, res) => {
21082
21630
  // request (same reasoning as the Local VM lifecycle routes above): a
21083
21631
  // hostile page cannot submit it with a form, and its cross-origin JSON
21084
21632
  // request dies in the preflight this server never answers. Applied to
21085
- // both backends — the Box branch runs commands too.
21633
+ // both backends — the Boat branch runs commands too.
21086
21634
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
21087
21635
  return json(res, 415, { error: "content-type must be application/json" });
21088
21636
  }
@@ -21090,13 +21638,13 @@ const handleRequest = async (req, res) => {
21090
21638
  if (computerProviderConfigTransitions.has(remoteProvider)) {
21091
21639
  return json(res, 409, { error: providerTransitionMessage(remoteProvider) });
21092
21640
  }
21093
- if (boxLifecycleBusyBots.has(botId)) {
21641
+ if (boatLifecycleBusyBots.has(botId)) {
21094
21642
  return json(res, 409, { error: "this bot's cloud computer is being changed — wait for it to finish" });
21095
21643
  }
21096
21644
  const teamComputer = inheritedTeamComputer(bot);
21097
21645
  if (teamComputer) {
21098
21646
  const key = teamComputerOwner(teamComputer.id);
21099
- if (boxLifecycleBusyBots.has(key))
21647
+ if (boatLifecycleBusyBots.has(key))
21100
21648
  return json(res, 409, { error: "This team computer is being changed; wait for it to finish" });
21101
21649
  if (m[2] === "provision" || m[2] === "remove")
21102
21650
  return json(res, 409, { error: "Manage this shared computer from the Team map" });
@@ -21107,12 +21655,12 @@ const handleRequest = async (req, res) => {
21107
21655
  const release = m[2] === "sleep" ? claimTeamComputerLifecycle(teamComputer) : claimBotComputerLifecycle(key);
21108
21656
  try {
21109
21657
  if (m[2] === "join")
21110
- return json(res, 200, await box.joinReadyBox(cfg, key));
21658
+ return json(res, 200, await boat.joinReadyBoat(cfg, key));
21111
21659
  if (m[2] === "screenshot") {
21112
21660
  res.setHeader("cache-control", "private, no-store");
21113
- return json(res, 200, await box.screenshotBox(cfg, key));
21661
+ return json(res, 200, await boat.screenshotBoat(cfg, key));
21114
21662
  }
21115
- return json(res, 200, await box.sleepBox(cfg, key));
21663
+ return json(res, 200, await boat.sleepBoat(cfg, key));
21116
21664
  }
21117
21665
  finally {
21118
21666
  release();
@@ -21153,8 +21701,8 @@ const handleRequest = async (req, res) => {
21153
21701
  releaseComputerLifecycle();
21154
21702
  }
21155
21703
  }
21156
- const activeBoxTurn = botHasActiveTurn(botId);
21157
- if (["provision", "sleep"].includes(m[2]) && activeBoxTurn) {
21704
+ const activeBoatTurn = botHasActiveTurn(botId);
21705
+ if (["provision", "sleep"].includes(m[2]) && activeBoatTurn) {
21158
21706
  return json(res, 409, {
21159
21707
  error: CLOUD_COMPUTER_BUSY_ERROR,
21160
21708
  });
@@ -21162,39 +21710,39 @@ const handleRequest = async (req, res) => {
21162
21710
  // Input validity is independent of destination authorization. Preserve
21163
21711
  // the stable 400 contract for oversized commands without contacting the
21164
21712
  // provider; a valid Auto request still reaches the 409 gate below.
21165
- let boxCommand;
21713
+ let boatCommand;
21166
21714
  if (m[2] === "exec") {
21167
21715
  const body = await readBody(req);
21168
- boxCommand = String(body?.command ?? "");
21169
- if (boxCommand.length > box.MAX_REMOTE_COMMAND_LENGTH) {
21716
+ boatCommand = String(body?.command ?? "");
21717
+ if (boatCommand.length > boat.MAX_REMOTE_COMMAND_LENGTH) {
21170
21718
  return json(res, 400, {
21171
- error: `command is too long (maximum ${box.MAX_REMOTE_COMMAND_LENGTH} characters)`,
21719
+ error: `command is too long (maximum ${boat.MAX_REMOTE_COMMAND_LENGTH} characters)`,
21172
21720
  });
21173
21721
  }
21174
21722
  }
21175
21723
  if (bot.computer !== "cloud" && !threadPreview) {
21176
21724
  return json(res, 409, {
21177
- error: "Choose Cloud before changing or opening this Box. Auto only checks existing computer state.",
21725
+ error: "Choose Cloud before changing or opening this Boat. Auto only checks existing computer state.",
21178
21726
  });
21179
21727
  }
21180
21728
  if (m[2] === "remove") {
21181
- // Boxes sleep and wake; only the VPS backend has a container to remove.
21182
- return json(res, 409, { error: "the cloud Box backend has no container to remove — use sleep instead" });
21729
+ // Boats sleep and wake; only the VPS backend has a container to remove.
21730
+ return json(res, 409, { error: "the cloud Boat backend has no container to remove — use sleep instead" });
21183
21731
  }
21184
21732
  const releaseComputerLifecycle = claimBotComputerLifecycle(botId);
21185
21733
  try {
21186
21734
  switch (m[2]) {
21187
21735
  case "provision":
21188
- return json(res, 200, await box.provisionBox(cfg, botId, bot.name));
21736
+ return json(res, 200, await boat.provisionBoat(cfg, botId, bot.name));
21189
21737
  case "join":
21190
- return json(res, 200, await (activeBoxTurn || threadPreview ? box.joinReadyBox(cfg, botId) : box.joinBox(cfg, botId)));
21738
+ return json(res, 200, await (activeBoatTurn || threadPreview ? boat.joinReadyBoat(cfg, botId) : boat.joinBoat(cfg, botId)));
21191
21739
  case "sleep":
21192
- return json(res, 200, await box.sleepBox(cfg, botId));
21740
+ return json(res, 200, await boat.sleepBoat(cfg, botId));
21193
21741
  case "exec":
21194
- return json(res, 200, await box.execOnBox(cfg, botId, boxCommand ?? ""));
21742
+ return json(res, 200, await boat.execOnBoat(cfg, botId, boatCommand ?? ""));
21195
21743
  case "screenshot":
21196
21744
  res.setHeader("cache-control", "private, no-store");
21197
- return json(res, 200, await box.screenshotBox(cfg, botId));
21745
+ return json(res, 200, await boat.screenshotBoat(cfg, botId));
21198
21746
  }
21199
21747
  }
21200
21748
  finally {
@@ -21364,6 +21912,7 @@ server.listen(PORT, "127.0.0.1", () => {
21364
21912
  // ordinary chat. Start only once every registry is initialized and the
21365
21913
  // endpoint is listening; earlier dispatch can hit uninitialized bindings.
21366
21914
  routines.start();
21915
+ memoryUpkeep.start();
21367
21916
  const leftover = pendingThreads();
21368
21917
  if (leftover.length)
21369
21918
  console.log(`delegations: ${leftover.length} thread(s) with queued handoffs from a previous run — draining`);
@@ -21418,6 +21967,7 @@ const gracefulShutdown = createGracefulShutdown({
21418
21967
  watchdog.stop();
21419
21968
  routines?.stop();
21420
21969
  calendarCalls?.stop();
21970
+ memoryUpkeep.stop();
21421
21971
  webhookIngress?.server.close();
21422
21972
  tunnelListener?.close();
21423
21973
  },