openmausbot 0.1.91 → 0.1.93

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 (164) hide show
  1. package/dist/assets/DesktopViewer-CKNR3SEW.js +5 -0
  2. package/dist/assets/{abnfDiagram-O67JEVCF-yP-w8pRq.js → abnfDiagram-O67JEVCF-Ckrm1I1X.js} +1 -1
  3. package/dist/assets/{arc-DsivfZEh.js → arc-BBdBuCgB.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CILw7lzT.js → architectureDiagram-NJMV4G6O-CDdA3Hjs.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-DEQUV3HF.js → blockDiagram-BEXU5L5S-AUrsx1YR.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-lSXCZoKA.js → c4Diagram-YGBWAQC7-BEqQ0Unh.js} +1 -1
  7. package/dist/assets/channel-BU94VpxE.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-QCD5bgWP.js → chunk-2Q5K7J3B-CEEuvd4C.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-C5-iZJuL.js → chunk-3FUC2YCW-DyANCiHl.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-cxsawMpY.js → chunk-5VM5RSS4-Dx_Slpw9.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-DV8tV8H7.js → chunk-GTNCS2PH-Dl7h7iiD.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-CJFZyfbW.js → chunk-GWA4HPMP-BLx07SPb.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-BigbMXco.js → chunk-JWPE2WC7-BMxDj3n7.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-oVxTfpDy.js → chunk-NETBCI7D-CN66PT4H.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CH4BiroW.js → chunk-WEXAMYUT-6HwhsDKw.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-KEDa6gax.js → chunk-XXDRQBXY-gWnLt7SN.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-BiPjfqVv.js → classDiagram-v2-NBCMYWYE-CMS5l2ea.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-Caxew3OY.js → cose-bilkent-JH36ORCC-CV7sQwIG.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-B_Pui369.js → cynefin-OW5HDTMX-CSyl-7vl.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-X64ydIhP.js → cynefinDiagram-VND7K2PF-BCLKL6az.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-TqYCI1E1.js → dagre-6A5THRUB-DiaZs6S1.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-TeOt3rAQ.js → diagram-22UHCM2B-BeTtmt3I.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-D70BLhDA.js → diagram-3UASUU5V-CsdBG-QQ.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-BbQDLwUC.js → diagram-ATOU4E4O-BczvdbR1.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-ChNJZ1u-.js → diagram-CDSNMT55-DHTTTMhi.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-DMBl2APa.js → diagram-MLGK6HIB-RTjHyd8z.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-CEqDchOp.js → diagram-MPIPVDR6-BDenHEH_.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-BVDKRp_-.js → ebnfDiagram-ZINNZB2B-BSiRSbtL.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-CRkofO9B.js → elk-276RUBZZ-BCAElss-.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-DruOMh5e.js → erDiagram-OPXOYQCR-C4_1ZPJe.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-8GMQeB7t.js → flowDiagram-KWPJA3E3-Bcb4Z75S.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-ORE9jcsM.js → ganttDiagram-FUAMR5RP-BiPOzxzb.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-BmsedsWh.js → gitGraphDiagram-X574FWY7-B1wtF3ZS.js} +1 -1
  34. package/dist/assets/index-Biji-bfL.css +1 -0
  35. package/dist/assets/{index-DKNEM8Q6.js → index-BkYwilkG.js} +1 -1
  36. package/dist/assets/index-DZlhoES0.js +597 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-E_p7AooJ.js → infoDiagram-VRGFBTTK-A19No71U.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-Y-B2mKda.js → ishikawaDiagram-OU5B5YK6-BQsqJ_cV.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-eA8RELcK.js → journeyDiagram-ZHPQQLJL-DAO0pVan.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-DqIL2e8e.js → kanban-definition-PNTS6WVX-NepmKEe2.js} +1 -1
  41. package/dist/assets/{linear-yC_29R6e.js → linear-bq1PTDTW.js} +1 -1
  42. package/dist/assets/{mermaid.core-VhEMkSND.js → mermaid.core-s4LTbQle.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-oJ7kyQZy.js → mindmap-definition-NLK3R4M7-LtdbgHFH.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-BmfYcY0O.js → pegDiagram-GJSIUBJH-tdbaCxiK.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-DKvgP7w9.js → pieDiagram-5QR66LMP-DvkbpW8l.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-BEKL3W0L.js → quadrantDiagram-O4NWA36T-Do4KaH-E.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-BEsiqR5s.js → railroadDiagram-XR7U4H2S-BQWULOAs.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-DWBcWvAP.js → requirementDiagram-PLB6GJNP-w9kei1FU.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-rpZ2_O4s.js → sankeyDiagram-IPEJSGJF-D2gRy0e8.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-B99HQdJR.js → sequenceDiagram-PO4LG4MO-a2sO5_Eo.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-BjN0qT5T.js → sizeCapture-INFHLROL-CiPJmFEB.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-BbsVgGsC.js → stateDiagram-v2-GCMORJYK-DAkUUlL7.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DG3gKkeR.js → swimlanes-2SLR337P-DANf2R8Y.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-CdHxG8aM.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BTHOdtF_.js → timeline-definition-EJHVYXUP-BKpQmzrT.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-acLSLFwc.js → usecaseDiagram-POWQR4AR-BtI7qLN-.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-C5JzNX9G.js → vennDiagram-UO4OBE2U-gXJXrAG_.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DL1-PrR_.js → wardleyDiagram-VNRHLVJA-GU8Po5-n.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-Bvr1477p.js → xychartDiagram-PMCCYNJV-BF4J30g6.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist/novnc-NOTICE.txt +118 -0
  62. package/dist-server/cloud-home-start.js +112 -14
  63. package/dist-server/companion/src/routes.js +24 -1
  64. package/dist-server/container-mcp.js +201 -16
  65. package/dist-server/drivers/agents-proxy.js +7 -4
  66. package/dist-server/enterprise/server/index.js +5 -1
  67. package/dist-server/index.js +10698 -4737
  68. package/dist-server/local-computer-proxy.js +133 -8
  69. package/dist-server/mcp-gate.js +16 -6
  70. package/dist-server/model-catalog/models-dev.snapshot.json +7299 -0
  71. package/dist-server/openmausbot.js +4656 -1258
  72. package/dist-server/pair-cli.js +4656 -1258
  73. package/dist-server/server/boat.js +5 -5
  74. package/dist-server/server/browser-engine.js +10 -1
  75. package/dist-server/server/browser-live.js +46 -2
  76. package/dist-server/server/browser-runtime.js +74 -8
  77. package/dist-server/server/browser-tool-shape.js +6 -4
  78. package/dist-server/server/chief-of-staff.js +1 -1
  79. package/dist-server/server/cloud-home-start.js +118 -26
  80. package/dist-server/server/cloud-home.js +39 -3
  81. package/dist-server/server/cloud-lending.js +74 -10
  82. package/dist-server/server/cloud-owner.js +196 -0
  83. package/dist-server/server/cloud-secrets-boot.js +6 -0
  84. package/dist-server/server/cloud-secrets.js +65 -0
  85. package/dist-server/server/computer-control.js +6 -0
  86. package/dist-server/server/config.js +187 -14
  87. package/dist-server/server/container-computer.js +46 -34
  88. package/dist-server/server/desktop-viewer-targets.js +26 -0
  89. package/dist-server/server/digest.js +12 -2
  90. package/dist-server/server/drivers/acp/core.js +339 -33
  91. package/dist-server/server/drivers/acp/cursor.js +13 -1
  92. package/dist-server/server/drivers/acp/opencode-go.js +614 -85
  93. package/dist-server/server/drivers/acp/quiet-status.js +257 -0
  94. package/dist-server/server/drivers/acp/qwen.js +18 -0
  95. package/dist-server/server/drivers/agents-catalog.js +6 -6
  96. package/dist-server/server/drivers/antigravity-acp.js +69 -11
  97. package/dist-server/server/drivers/antigravity-temp.js +360 -0
  98. package/dist-server/server/drivers/antigravity.js +21 -0
  99. package/dist-server/server/drivers/builtIn.js +2 -0
  100. package/dist-server/server/drivers/cerebras.js +108 -0
  101. package/dist-server/server/drivers/chat-mcp-tools.js +20 -3
  102. package/dist-server/server/drivers/chatgpt-plan-auth.js +481 -0
  103. package/dist-server/server/drivers/claude.js +329 -37
  104. package/dist-server/server/drivers/codex.js +228 -18
  105. package/dist-server/server/drivers/grok.js +2 -2
  106. package/dist-server/server/drivers/mistral.js +4 -3
  107. package/dist-server/server/drivers/openai-chat-protocol.js +4 -3
  108. package/dist-server/server/drivers/openai-chat.js +66 -4
  109. package/dist-server/server/drivers/openai-compat.js +73 -23
  110. package/dist-server/server/drivers/prompt-split.js +2 -0
  111. package/dist-server/server/engine-install.js +46 -12
  112. package/dist-server/server/env-path.js +21 -5
  113. package/dist-server/server/harness/registry.js +2 -2
  114. package/dist-server/server/index.js +1545 -238
  115. package/dist-server/server/lending-memory.js +479 -0
  116. package/dist-server/server/local-vm-idle.js +24 -6
  117. package/dist-server/server/local-vm-stop-reason.js +26 -0
  118. package/dist-server/server/managed-desktop.js +3 -0
  119. package/dist-server/server/mcp-bridge.js +5 -3
  120. package/dist-server/server/mcp-http.js +6 -3
  121. package/dist-server/server/mcp-oauth-discovery.js +133 -0
  122. package/dist-server/server/mcp-oauth-store.js +70 -0
  123. package/dist-server/server/mcp-oauth.js +550 -0
  124. package/dist-server/server/mcp-probe.js +14 -1
  125. package/dist-server/server/mcp-registry.js +43 -0
  126. package/dist-server/server/memory-upkeep.js +94 -84
  127. package/dist-server/server/model-catalog/catalog.js +374 -0
  128. package/dist-server/server/model-catalog/presets.js +176 -0
  129. package/dist-server/server/model-catalog/trim.js +219 -0
  130. package/dist-server/server/provider-key-check.js +5 -2
  131. package/dist-server/server/recent-work.js +1 -1
  132. package/dist-server/server/request-auth.js +23 -0
  133. package/dist-server/server/routes/antigravity-leftovers.js +45 -0
  134. package/dist-server/server/routes/bot-memory.js +31 -6
  135. package/dist-server/server/routes/desktop-viewer.js +238 -0
  136. package/dist-server/server/routine-requests.js +1 -1
  137. package/dist-server/server/routines.js +6 -3
  138. package/dist-server/server/sessions.js +56 -9
  139. package/dist-server/server/skills.js +25 -10
  140. package/dist-server/server/store.js +116 -122
  141. package/dist-server/server/thread-events.js +2 -0
  142. package/dist-server/server/thread-starters.js +33 -0
  143. package/dist-server/server/thread-title.js +46 -0
  144. package/dist-server/server/tts/fish.js +4 -5
  145. package/dist-server/server/tts/index.js +5 -3
  146. package/dist-server/server/vps-computer.js +80 -13
  147. package/dist-server/server/webhooks.js +18 -1
  148. package/dist-server/server/workspace-backup-policy.js +3 -0
  149. package/dist-server/server/workspace-backup.js +25 -0
  150. package/dist-server/server/workspace.js +66 -16
  151. package/dist-server/shared/approval-mode.js +1 -1
  152. package/dist-server/shared/redact.js +1 -0
  153. package/dist-server/shared/tool-name.js +22 -0
  154. package/dist-server/shared/workspace-backup-client.js +1 -0
  155. package/dist-server/src/lib/citations.js +177 -0
  156. package/dist-server/src/lib/composer-attachments.js +7 -1
  157. package/dist-server/vps-container-mcp.js +202 -16
  158. package/dist-server/workspace-backup.worker.js +87 -9
  159. package/enterprise/server/index.js +5 -1
  160. package/package.json +1 -1
  161. package/dist/assets/channel-DrfGMu7X.js +0 -1
  162. package/dist/assets/index-BlnCEcU6.js +0 -588
  163. package/dist/assets/index-DVxjOvID.css +0 -1
  164. package/dist/assets/swimlanesDiagram-TC7HE7FX-BCZhxl7e.js +0 -8
@@ -1,6 +1,9 @@
1
1
  // OpenMausBot server — the harness host. Clients hold no transports
2
2
  // (upstream rule): the React app dispatches typed commands over HTTP and
3
3
  // folds one SSE event stream; every provider process runs here.
4
+ // First, before any module that could start a process: a Cloud home's
5
+ // secrets off the launcher's pipe (cloud-secrets-boot.ts).
6
+ import { BOOT_CLOUD_SECRETS } from "./cloud-secrets-boot.js";
4
7
  import { AsyncLocalStorage } from "node:async_hooks";
5
8
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
6
9
  import { existsSync, readFileSync, realpathSync, rmSync, mkdirSync } from "node:fs";
@@ -16,7 +19,8 @@ import { draftSummary, foldPoint } from "./compaction-summary.js";
16
19
  import { compactBudget, contextWindowFor, shouldCompact } from "./context-budget.js";
17
20
  import { autoCompactWindow } from "./drivers/claude.js";
18
21
  import { provenRequestPerson, SharedComputers, sharedComputerOperation, sharedComputerRegistration } from "./shared-computers.js";
19
- import { cloudHomeLendingRefusal, createCloudRoutineAuthors } from "./cloud-lending.js";
22
+ import { cloudHomeLendingRefusal, createCloudRoutineAuthors, ownerOnlyConversation } from "./cloud-lending.js";
23
+ import { botMemoryFiles, createLendingMemory } from "./lending-memory.js";
20
24
  import { SharedComputerControl } from "./shared-computer-control.js";
21
25
  import { RoomHandoffs } from "./room-handoffs.js";
22
26
  import { assertRequestTarget, guardedRequestPath, requestConflict, requestNeedsInput, requestSourceForCard } from "./guarded-requests.js";
@@ -59,9 +63,9 @@ import { createMemoryUpkeep, upkeepEnabled } from "./memory-upkeep.js";
59
63
  import { appendAboutMe, commitLearned, planLearned } from "./profile-learned.js";
60
64
  import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, livePeerRosterBlock, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
61
65
  import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
62
- import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerExec, containerRuntimeStatus, localVmRecreatableOnDemand, localVmWorkspaceExists, perBotLocalVmTarget, poolLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, VM_WORKSPACE_GUEST, } from "./container-computer.js";
66
+ import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerExec, containerRuntimeStatus, localVmWakeAction, localVmWorkspaceExists, perBotLocalVmTarget, poolLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, VM_WORKSPACE_GUEST, } from "./container-computer.js";
63
67
  import { attachForTurn, saveBotAttachment } from "./bot-attachment.js";
64
- import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, computerClaimIdleReleaseEnabled, cloudOverflowAllowlistedThreads, cloudOverflowEnabled, cloudOverflowIdleStopMs, cloudOverflowPerSecondCostUsd, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, stripWorkspaceCredentialEnv, PROVIDER_CREDENTIAL_ENV, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, } from "./config.js";
68
+ import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmIdleTimeoutMinutes, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, computerClaimIdleReleaseEnabled, cloudOverflowAllowlistedThreads, cloudOverflowEnabled, cloudOverflowIdleStopMs, cloudOverflowPerSecondCostUsd, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, stripWorkspaceCredentialEnv, PROVIDER_CREDENTIAL_ENV, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, CLAUDE_API_INSTANCE, } from "./config.js";
65
69
  import { sweepThreadEventLogs } from "./thread-retention.js";
66
70
  import { ComputerControl } from "./computer-control.js";
67
71
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
@@ -80,10 +84,12 @@ import { isModelVariant, TurnNotStartedError, newId, } from "./contracts.js";
80
84
  import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.js";
81
85
  import { recoveryCapabilityError } from "./automatic-recovery.js";
82
86
  import { decodeGeneratedImage } from "./generated-image.js";
83
- import { MAX_MCP_SERVERS, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
87
+ import { MAX_MCP_SERVERS, isRemoteMcpServer, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
88
+ import { McpOAuthError, McpSignInError, McpOAuthManager, mcpOAuthRedirectUri, withMcpSignIn, withoutPendingSignIn } from "./mcp-oauth.js";
84
89
  import { probeMcpServer } from "./mcp-probe.js";
85
90
  import { GROUP_GOAL_MAX_TURNS, groupGoalAssignmentKey, groupGoalCompletionTurnId, groupGoalCoordinatorInstructions, groupGoalWorkerInstructions, parseGroupGoalDecision, resolveGroupGoalMember, selectGroupGoalCoordinator, } from "./group-goal-run.js";
86
91
  import { BUILT_IN_DRIVERS } from "./drivers/builtIn.js";
92
+ import { openCodeProviderKeysAllowed, setOpenCodeProviderKeyPolicy } from "./drivers/acp/opencode-go.js";
87
93
  import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from "./comms-visibility.js";
88
94
  import { readMessageText, recallMessages, recentMessages, searchMessagesAsync, closeMessageSearch, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups, threadsReferencing } from "./message-db.js";
89
95
  import { briefCrossingLabel, claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
@@ -104,7 +110,8 @@ import { ProviderRegistry } from "./harness/registry.js";
104
110
  import { ManagedDesktopProviders } from "./managed-desktop.js";
105
111
  import { computerKindForResource, ManagedDesktopPolicy } from "./managed-policy.js";
106
112
  import { hostedModelPolicy, HOSTED_MODEL_POLICY_HEADER, HOSTED_PROVIDER_SETTINGS_ERROR } from "./hosted-models.js";
107
- import { boatNotConfiguredMessage, CLOUD_HOME_RESTART_EXIT_CODE, CLOUD_IGNORED_KEYS, CLOUD_PAIRING_PATH, cloudHomeConfiguration, cloudHomeOffersPlace, cloudHomePlaceRefusal, createCloudPairing, readSignedBody, } from "./cloud-home.js";
113
+ import { boatNotConfiguredMessage, CLOUD_HOME_RESTART_EXIT_CODE, CLOUD_HOME_SECRET_KEYS, CLOUD_IGNORED_KEYS, CLOUD_PAIRING_PATH, cloudHomeConfiguration, cloudHomeOffersPlace, cloudHomePlaceRefusal, createCloudPairing, firstCloudTurnPatch, readSignedBody, } from "./cloud-home.js";
114
+ import { CLOUD_PERSONAL_REFUSAL, settleCloudOwnership } from "./cloud-owner.js";
108
115
  import { createCloudMoveRoutes } from "./cloud-move-http.js";
109
116
  import { holdIncludedServices } from "./included-services.js";
110
117
  import { selectDefaultModelSelection, withNewBotEffort } from "./default-model-selection.js";
@@ -120,10 +127,11 @@ import { narrateTool, toUtterances } from "./tts/speech-text.js";
120
127
  import { buildRecoveryText, buildTurnContext, engineIsFresh, NATIVELY_REPLAYING_DRIVER_KINDS, peerMessageText } from "./turn-context.js";
121
128
  import { Handoffs, handedStateUsable, recordHanded, renderUnseen, sessionStart, unseenMessages, withUnseenMessages } from "./delta-context.js";
122
129
  import { extractTurnImages } from "./turn-images.js";
130
+ import { threadTitlePrompt, titleConversationExcerpt } from "./thread-title.js";
123
131
  import { TurnWatchdog } from "./turn-watchdog.js";
124
132
  import { TurnResources, workspaceResource } from "./turn-resources.js";
125
133
  import { IdleReleasePolicy } from "./claim-idle.js";
126
- import { ensureWorkspace, ensureTaskWorkspace, workspaceLocationsPrompt, supportsWorkspaceFiles, updateMemory, appendMemoryLog, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, workspaceDir, } from "./workspace.js";
134
+ import { ensureWorkspace, ensureTaskWorkspace, workspaceLocationsPrompt, supportsWorkspaceFiles, updateMemory, appendMemoryLog, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, TASK_WORKSPACES_DIR, readMemoryOnlyFromRegularFiles, workspaceDir, } from "./workspace.js";
127
135
  import { listMemoryTopics, memoryDate, readMemoryFile, readMemoryTopic, writeMemoryFile, writeMemoryTopic } from "./workspace.js";
128
136
  import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, journalMemoryWrite, } from "./memory-journal.js";
129
137
  import { readSectionContext, readSections, sectionContextKey, sectionContextLabel, sectionContextSystemPrompt, writeSectionContext, SECTION_CONTEXT_MAX_BYTES, } from "./section-context.js";
@@ -137,6 +145,7 @@ import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-
137
145
  import { buildSystemPrompt, userProfileSystemPrompt, cloudHomePrompt, computerPrompt, composioSystemPrompt, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, resolveComputerPromptKind, } from "./system-prompt.js";
138
146
  import { readCuaConnection, readCuaUnavailableReason, gatedLocalComputer } from "./local-computer.js";
139
147
  import { discoverExistingPerBotLocalVms, discoverExistingPoolLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
148
+ import { localVmStopReason, recordLocalVmIdleStop } from "./local-vm-stop-reason.js";
140
149
  import { LocalVmIdleTimer } from "./local-vm-idle.js";
141
150
  import { LocalVmLeasePool } from "./local-vm-lease.js";
142
151
  import { LocalVmSeatPool } from "./local-vm-seat-pool.js";
@@ -146,12 +155,12 @@ import * as vps from "./vps-computer.js";
146
155
  import { RoutineManager } from "./routines.js";
147
156
  import { CalendarCallManager } from "./calendar-calls.js";
148
157
  import { BUILT_IN_BROWSER_SYSTEM_PROMPT } from "./browser-engine.js";
149
- import { BrowserRuntime } from "./browser-runtime.js";
158
+ import { BrowserRuntime, browserRuntimeEnv } from "./browser-runtime.js";
150
159
  import { BrowserLive } from "./browser-live.js";
151
- import { agentBrowserFrame, agentBrowserIntegration, browserEngineEncryptionKey, prepareBrowserSessionState, clearBrowserSessionState, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, browserEngineStatus, browserSessionId, describeBrowserEngine, } from "./browser-engine.js";
160
+ import { agentBrowserFrame, agentBrowserIntegration, browserEngineEncryptionKey, prepareBrowserSessionState, clearBrowserSessionState, closeBrowserSession, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, browserEngineStatus, browserSessionId, describeBrowserEngine, } from "./browser-engine.js";
152
161
  import { createScreenFrameSource } from "./screen-frame-source.js";
153
162
  import { screenFrameHash, screenSurfaceForTool, screenTouchingTool, settledFrameIsNews } from "./screen-frame-gate.js";
154
- import { RoutineRequestService } from "./routine-requests.js";
163
+ import { asSchedule, RoutineRequestService } from "./routine-requests.js";
155
164
  import { createOptionsCard } from "./options-card.js";
156
165
  import { buildBotOverview, connectedAppsFacts } from "./bot-overview.js";
157
166
  import { ProfileRequestService } from "./profile-requests.js";
@@ -214,6 +223,10 @@ import { createHostedSlackRoutes } from "./routes/hosted-slack.js";
214
223
  import { createBotPresetRoutes } from "./routes/bot-presets.js";
215
224
  import { createBotMemoryRoutes } from "./routes/bot-memory.js";
216
225
  import { createDeciderRoutes } from "./routes/decider.js";
226
+ import { createDesktopViewer, desktopViewerUrl } from "./routes/desktop-viewer.js";
227
+ import { localDesktopTarget, localVmViewerStatus, viewerTargetId } from "./desktop-viewer-targets.js";
228
+ import { createAntigravityLeftoverRoutes } from "./routes/antigravity-leftovers.js";
229
+ import { findAntigravityLeftovers, removeAntigravityLeftovers } from "./drivers/antigravity-temp.js";
217
230
  const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799);
218
231
  const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1);
219
232
  // Behind a proxy or tunnel, the base URL senders should use (docs/self-hosting.md).
@@ -223,6 +236,7 @@ const MIME = {
223
236
  ".html": "text/html",
224
237
  ".js": "text/javascript",
225
238
  ".css": "text/css",
239
+ ".txt": "text/plain; charset=utf-8",
226
240
  ".svg": "image/svg+xml",
227
241
  ".png": "image/png",
228
242
  ".ico": "image/x-icon",
@@ -252,8 +266,12 @@ process.once("exit", releaseDataDirLeaseAtExit);
252
266
  // Restore before constructing any long-lived config, Store, session or provider
253
267
  // objects. Replacing files underneath a live Store would overwrite restored data.
254
268
  let workspaceRestore = { restored: false };
269
+ /** A restore applied at this very start (not the last one on record). */
270
+ let restoredAtStart;
255
271
  if (existsSync(join(DATA_DIR, ".backups"))) {
256
272
  workspaceRestore = applyPendingWorkspaceRestore(DATA_DIR);
273
+ if (workspaceRestore.restored)
274
+ restoredAtStart = workspaceRestore.id;
257
275
  if (!workspaceRestore.restored && !workspaceRestore.rolledBack) {
258
276
  workspaceRestore = readLastWorkspaceRestore(DATA_DIR) ?? workspaceRestore;
259
277
  }
@@ -279,7 +297,16 @@ const sessions = new SessionRegistry({
279
297
  const sharedComputers = new SharedComputers(id => sessions.isLive(id));
280
298
  // OMB Cloud Pro home machine (server/cloud-home.ts, docs/cloud-pro.md). A
281
299
  // partial or invalid boot contract stops the server here, before it serves.
282
- const CLOUD_HOME = cloudHomeConfiguration();
300
+ // Its secrets come over the launcher's pipe, never this process's
301
+ // environment (cloud-home-start.ts); a server started another way (tests,
302
+ // development) still reads them from its environment.
303
+ const CLOUD_SECRETS = BOOT_CLOUD_SECRETS;
304
+ const CLOUD_ENV = { ...process.env, ...CLOUD_SECRETS };
305
+ const CLOUD_HOME = cloudHomeConfiguration(CLOUD_ENV);
306
+ // A Cloud home is personal: only the owner's own devices, each with admin
307
+ // scope, connect (server/cloud-owner.ts; the stored rest is revoked below).
308
+ if (CLOUD_HOME)
309
+ sessions.requireAdmin(CLOUD_PERSONAL_REFUSAL);
283
310
  /** Lending a computer to this server (the shared-computer routes, the two
284
311
  * agent tools, the advertised capability): the maintainer flag anywhere, and
285
312
  * always on an OMB Cloud home, where it is the person's own Mac lent to their
@@ -291,6 +318,29 @@ const CLOUD_HOME_LENDER = "cloud-home";
291
318
  * the owner started: the only routine turns that may use a lent Mac. */
292
319
  const cloudRoutineAuthors = CLOUD_HOME ? createCloudRoutineAuthors(join(DATA_DIR, "lending-routines.json")) : null;
293
320
  const ownerStartedRoutineRuns = new Set();
321
+ /** Turns running now that are not provably the owner's, by generation. */
322
+ const foreignTurns = new Map();
323
+ /** Whether each bot's memory is still the owner's (server/lending-memory.ts):
324
+ * its memory files, and the instruction files its engine reads in the
325
+ * folders its conversations work in. */
326
+ const lendingMemory = CLOUD_HOME ? createLendingMemory({
327
+ file: join(DATA_DIR, "lending-memory.json"),
328
+ files: (botId) => {
329
+ const bot = store.bot(botId);
330
+ // The workspace as a turn dispatch leaves it: a bot's first turn seeds
331
+ // MEMORY.md, and that seed is not a change anyone made. Only the folders
332
+ // of conversations the owner opened: what a guest's own conversation
333
+ // writes in its own private folder reaches no turn of the owner's.
334
+ // (A bot's own conversations are never a room's: its opener is enough.)
335
+ const ownerOpened = (threadId) => { const starter = threadStarters.get(threadId); return starter === undefined || cloudOwnerPerson(starter); };
336
+ return botMemoryFiles(bot ? { id: bot.id, cwd: bot.cwd, tasks: store.tasks(bot.id).filter((task) => ownerOpened(task.threadId)) } : undefined, { workspace: ensureWorkspace, taskWorkspaces: TASK_WORKSPACES_DIR });
337
+ },
338
+ knownBots: () => store.bots.map((bot) => bot.id),
339
+ log: (line) => console.warn(line),
340
+ }) : null;
341
+ // On a Cloud home memory is never read through a link (server/workspace.ts).
342
+ if (CLOUD_HOME)
343
+ readMemoryOnlyFromRegularFiles();
294
344
  const cloudPairing = CLOUD_HOME ? createCloudPairing({ secret: CLOUD_HOME.bootstrapSecret, sessions }) : null;
295
345
  if (CLOUD_HOME) {
296
346
  // The signing secret is held in memory from here on, and a platform
@@ -305,7 +355,12 @@ if (CLOUD_HOME) {
305
355
  }
306
356
  // Cloud Pro's included Boat, voice and decision relay tokens, when the Admin
307
357
  // set them: held in memory from here on, like the signing secret.
308
- holdIncludedServices();
358
+ holdIncludedServices(CLOUD_ENV);
359
+ for (const key of CLOUD_HOME_SECRET_KEYS)
360
+ delete process.env[key];
361
+ if (CLOUD_HOME && Object.keys(CLOUD_SECRETS).length === 0) {
362
+ console.warn("cloud home: its secrets came in this process's environment, which anything running as the same user can read in /proc; start it with cloud-home-start");
363
+ }
309
364
  // Who each thread is for, when a signed-in person can be named (server-private).
310
365
  const threadStarters = new ThreadStarters(join(DATA_DIR, "thread-starters.json"));
311
366
  const commandAllowlist = new CommandAllowlistStore(join(DATA_DIR, "command-allowlist.json"));
@@ -318,7 +373,7 @@ const sharedWorkspaceFullAccessEnabled = () => SHARED_WORKSPACE_FULL_ACCESS && B
318
373
  // Who a loopback request without a session is (server/request-auth.ts
319
374
  // LoopbackTrust): the owner on a desktop or a one-person server; a service on
320
375
  // a shared workspace, where every bot's shell is a loopback caller too.
321
- const LOOPBACK = resolveLoopbackTrust({ desktopManaged: DESKTOP_MANAGED, hostedWorkspace: HOSTED_WORKSPACE });
376
+ const LOOPBACK = resolveLoopbackTrust({ desktopManaged: DESKTOP_MANAGED, hostedWorkspace: HOSTED_WORKSPACE, cloudHome: Boolean(CLOUD_HOME) });
322
377
  // `openmausbot serve` on a service-trust server hands the server it starts a
323
378
  // per-launch secret on stdin, then closes it (server/cli.ts). It opens only
324
379
  // the pairing route, for that CLI. Never an environment variable: every
@@ -416,7 +471,7 @@ function messageSender(auth) {
416
471
  if (auth.kind !== "session")
417
472
  return undefined;
418
473
  const name = (auth.session.email ?? auth.session.label ?? "").trim();
419
- return name ? { name, id: personKey(auth.session) } : undefined;
474
+ return name ? { name, id: actorKey(auth) } : undefined;
420
475
  }
421
476
  /** An opaque, stable key for the person behind a session: their account
422
477
  * email when they signed in with one (a new device is still them), else the
@@ -474,7 +529,7 @@ function sharedComputerPrincipal(capability) {
474
529
  // their devices, or a routine they wrote. Never a guest, a webhook, a room
475
530
  // or a line someone else slipped in.
476
531
  if (CLOUD_HOME)
477
- return cloudHomeLendingRefusal(cloudLendingTurn(capability)) === null ? CLOUD_HOME_LENDER : null;
532
+ return cloudLendingRefusal(capability) === null ? CLOUD_HOME_LENDER : null;
478
533
  return provenRequestPerson(directRequestOwners.get(capability.threadId), capability.generation, (messageId) => linePersonKey(store.messagesFor(capability.threadId).find((message) => message.id === messageId)));
479
534
  }
480
535
  /** What the lending rule needs to know about one bot turn on a Cloud home. */
@@ -483,7 +538,9 @@ function cloudLendingTurn(capability) {
483
538
  request: directRequestOwners.get(capability.threadId),
484
539
  generation: capability.generation,
485
540
  thread: store.messagesFor(capability.threadId),
486
- ownerPerson: cloudOwnerPerson,
541
+ starters: cloudThreadStarters(capability.threadId),
542
+ reportsFromOthers: store.messagesFor(capability.threadId).some(cloudOthersRoutineReport),
543
+ ownerPerson: cloudProvenOwnerPerson,
487
544
  cardAnswerer: cloudCardAnswerer,
488
545
  routineRun: () => {
489
546
  const run = activeRoutineRunForThread(capability.threadId);
@@ -493,9 +550,7 @@ function cloudLendingTurn(capability) {
493
550
  triggerSource: run.triggerSource ?? (run.manual ? "manual" : "schedule"),
494
551
  ownerStarted: ownerStartedRoutineRuns.has(run.id),
495
552
  // What the run snapshotted, on the schedule its routine has now.
496
- ownerAuthored: ((routine) => Boolean(routine) && cloudRoutineAuthors?.authored(run.routineId, {
497
- ...routine, prompt: run.prompt, target: run.target, botId: run.botId, groupId: run.groupId, attachments: run.attachments, runOn: run.runOn,
498
- }) === true)(routines?.listRoutines().find((candidate) => candidate.id === run.routineId)),
553
+ ownerAuthored: cloudRoutineRunIsOwners(run, capability.botId, capability.threadId),
499
554
  };
500
555
  },
501
556
  };
@@ -503,6 +558,82 @@ function cloudLendingTurn(capability) {
503
558
  /** What a bot on a Cloud home is told when its conversation cannot use the
504
559
  * owner's lent Mac because someone else wrote in it. The bot relays it. */
505
560
  const LENDING_SOMEONE_ELSE = "Someone else wrote in this conversation, so it can't use your Mac. Start a new conversation to use it.";
561
+ /** …or because its memory was changed where the owner did not write. */
562
+ const LENDING_MEMORY_CHANGED = "This bot's memory was changed in a conversation you didn't write. Review it in Memory to use your Mac again.";
563
+ /** Why this bot turn may not use the owner's lent Mac on a Cloud home, or
564
+ * null: the conversation's provenance (server/cloud-lending.ts), then the
565
+ * bot's memory, which every one of its turns loads (server/lending-memory.ts). */
566
+ function cloudLendingRefusal(capability) {
567
+ const refusal = cloudHomeLendingRefusal(cloudLendingTurn(capability));
568
+ if (refusal)
569
+ return refusal;
570
+ // Checked on every Mac action: one snapshot per tick is enough to look.
571
+ return lendingMemory?.reconcile(capability.botId, foreignTurnRunning(capability.botId), { cached: true }).changedBySomeoneElse ? "memory-changed" : null;
572
+ }
573
+ /** Who opened a conversation, and the room it is in: on a Cloud home a
574
+ * person who can name, title and steer it (see cloudThreadRefusal). */
575
+ function cloudThreadStarters(threadId) {
576
+ const room = store.groupByThread(threadId);
577
+ return [threadStarters.get(threadId), ...(room && room.threadId !== threadId ? [threadStarters.get(room.threadId)] : [])];
578
+ }
579
+ /** On a Cloud home: a turn that may use the owner's lent Mac, as far as its
580
+ * conversation goes. Such a turn reads only the owner's own conversations
581
+ * (session_search, session_read, list_threads). */
582
+ function cloudLendingView(capability) {
583
+ return CLOUD_HOME !== null && cloudHomeLendingRefusal(cloudLendingTurn(capability)) === null;
584
+ }
585
+ /** On a Cloud home: a routine's report (its lifecycle card, carrying what
586
+ * the run said) of a routine the owner did not write, as it stands now. It
587
+ * brings someone else's instructions' results into the conversation it
588
+ * reports to. */
589
+ function cloudOthersRoutineReport(line) {
590
+ if (line.kind !== "routine.run" || !line.routineRun)
591
+ return false;
592
+ const routine = routines?.listRoutines().find((candidate) => candidate.id === line.routineRun.routineId);
593
+ return !routine || cloudRoutineAuthors?.authored(routine.id, routine) !== true;
594
+ }
595
+ /** On a Cloud home: a conversation of this bot that only its owner opened
596
+ * and wrote in (server/cloud-lending.ts ownerOnlyConversation), the only kind
597
+ * that may feed the bot's memory, daily log, recall or recent-work brief, or
598
+ * be quoted there by its title. Cached per thread until it gets another
599
+ * message, and dropped whenever a session is revoked. */
600
+ const ownerOnlyCache = new Map();
601
+ function cloudOwnerOnlyThread(threadId) {
602
+ if (cloudThreadStarters(threadId).some((person) => person !== undefined && !cloudProvenOwnerPerson(person)))
603
+ return false;
604
+ const thread = store.messagesFor(threadId);
605
+ const last = thread.at(-1);
606
+ const cached = ownerOnlyCache.get(threadId);
607
+ // A card answered later changes the answer too: key on its answerer as well.
608
+ const lastKey = last ? `${last.id}:${last.card?.answeredBy ? JSON.stringify(last.card.answeredBy) : ""}` : undefined;
609
+ if (cached && cached.length === thread.length && cached.last === lastKey)
610
+ return cached.owner;
611
+ const owner = ownerOnlyConversation(thread, cloudProvenOwnerPerson, cloudCardAnswerer) && !thread.some(cloudOthersRoutineReport);
612
+ if (ownerOnlyCache.size > 5_000)
613
+ ownerOnlyCache.clear();
614
+ ownerOnlyCache.set(threadId, { length: thread.length, last: lastKey, owner });
615
+ return owner;
616
+ }
617
+ /** On a Cloud home: a turn that is not provably the owner's starts for this
618
+ * bot (its memory is "pending" until the turn's generation ends:
619
+ * server/lending-memory.ts; see revokeInternalCapabilityGeneration). */
620
+ function noteForeignTurn(botId, threadId, generation) {
621
+ if (!lendingMemory)
622
+ return;
623
+ foreignTurns.set(generation, { botId, threadId });
624
+ // The record is updated in memory before it is saved: a failed save still
625
+ // leaves the bot pending, and must not fail the turn that is starting
626
+ // (whose end is what clears this entry).
627
+ try {
628
+ lendingMemory.noteForeignTurn(botId);
629
+ }
630
+ catch (error) {
631
+ console.warn(`lending: could not record a turn someone else started: ${error.message}`);
632
+ }
633
+ }
634
+ function foreignTurnRunning(botId) {
635
+ return [...foreignTurns.values()].some((turn) => turn.botId === botId);
636
+ }
506
637
  /** Whose lent computers a status reader may see: on a Cloud home, only the
507
638
  * owner's own devices (admin sessions) see the lent Mac, never a guest or a
508
639
  * local process; elsewhere, the person behind the asking session. */
@@ -511,6 +642,239 @@ function sessionLendingPrincipal(auth) {
511
642
  return cloudOwnerSession(auth) ? CLOUD_HOME_LENDER : null;
512
643
  return auth.kind === "session" ? personKey(auth.session) : null;
513
644
  }
645
+ /** Profile fields only the owner may change on a Cloud home: what rides the
646
+ * bot's prompt, and whether it notifies the owner. */
647
+ const CLOUD_OWNER_PROFILE_FIELDS = ["name", "title", "description", "soul", "notifications"];
648
+ /** On a Cloud home: why this request may not act on a conversation, or
649
+ * null. The owner's conversations feed the owner's turns (and a lent Mac),
650
+ * so only one of the owner's own devices may rename them, change how they
651
+ * run, rewrite or delete them; anyone else only a conversation they started
652
+ * themselves (a guest's own session). Sends are checked by
653
+ * cloudGuestSendRefusal instead. Elsewhere always null. */
654
+ function cloudThreadRefusal(auth, threadId) {
655
+ if (!CLOUD_HOME || cloudOwnerSession(auth))
656
+ return null;
657
+ if (auth.kind === "session" && threadStarters.get(threadId) === actorKey(auth))
658
+ return null;
659
+ return "On this Cloud you can only change conversations you started.";
660
+ }
661
+ /** On a Cloud home: why a paired guest may not send into a conversation, or
662
+ * null. A guest writes only in conversations it started, never in the
663
+ * owner's (a line steered into the owner's running turn would reach it
664
+ * before anything could reclassify that turn). Local services (loopback)
665
+ * keep their own conversations; their lines make a conversation someone
666
+ * else's all the same (see noteSomeoneElsesLine). */
667
+ function cloudGuestSendRefusal(auth, threadId) {
668
+ if (!CLOUD_HOME || auth.kind !== "session" || cloudOwnerSession(auth))
669
+ return null;
670
+ return threadStarters.get(threadId) === actorKey(auth)
671
+ ? null : "On this Cloud you can only write in conversations you started. Start a new conversation with this bot.";
672
+ }
673
+ /** On a Cloud home: the one key every line, card answer, conversation and
674
+ * routine of the owner's own devices carries, whichever device and however
675
+ * often it was paired again, so re-pairing never demotes what the owner
676
+ * wrote. Only an admin session is given it: a guest keeps its own key. */
677
+ const CLOUD_OWNER_KEY = CLOUD_HOME ? `p_${createHash("sha256").update(`cloud-owner:${CLOUD_HOME.machineId}`).digest("base64url").slice(0, 22)}` : null;
678
+ /** …and the key of nobody at all: what a conversation made for work no
679
+ * person can be named for (a routine with no recorded writer) opens as. */
680
+ const CLOUD_NOBODY_KEY = `p_${createHash("sha256").update("cloud-nobody").digest("base64url").slice(0, 22)}`;
681
+ /** A Cloud home is personal (server/cloud-owner.ts): the keys its owner's
682
+ * devices carried before they shared one, settled at boot once the routines
683
+ * are loaded (settleCloudOwnership, below). `adopted` decides who opened a
684
+ * conversation, its level and its folder; only `proven` feeds lending and
685
+ * memory. Nothing is either until then. */
686
+ let cloudEarlierOwners = { adopted: new Set(), proven: new Set() };
687
+ /** Who a session acts as, for lines, openers and answers: on a Cloud home
688
+ * one of the owner's own devices is the owner; anyone else is themselves. */
689
+ function actorKey(auth) {
690
+ return CLOUD_OWNER_KEY && auth.scopes.includes("admin") ? CLOUD_OWNER_KEY : personKey(auth.session);
691
+ }
692
+ /** On a Cloud home: whoever is writing a routine in this request (routine
693
+ * creation and edits can open its results conversation). */
694
+ let routineWriterInFlight;
695
+ function withRoutineWriter(writer, work) {
696
+ const previous = routineWriterInFlight;
697
+ routineWriterInFlight = writer;
698
+ try {
699
+ return work();
700
+ }
701
+ finally {
702
+ routineWriterInFlight = previous;
703
+ }
704
+ }
705
+ /** On a Cloud home: a routine the owner approved (its card, or a proposal
706
+ * applied at once in their own Full-access conversation) is theirs as it
707
+ * stands: their key as its writer, their fingerprint on it. */
708
+ function cloudOwnersRoutine(routineId) {
709
+ const routine = routineId ? routines?.listRoutines().find((candidate) => candidate.id === routineId) : undefined;
710
+ if (!routine || !cloudRoutineAuthors || !CLOUD_OWNER_KEY)
711
+ return;
712
+ cloudRoutineAuthors.record(routine.id, routine);
713
+ cloudRoutineAuthors.wrote(routine.id, CLOUD_OWNER_KEY);
714
+ }
715
+ /** On a Cloud home: a routine that is the owner's as it stands: their
716
+ * fingerprint matches what it runs, or they are its writer and no
717
+ * fingerprint of theirs was ever recorded (a template, a restore). */
718
+ function cloudRoutineIsOwners(routineId) {
719
+ const routine = routineId ? routines?.listRoutines().find((candidate) => candidate.id === routineId) : undefined;
720
+ if (!routine || !cloudRoutineAuthors || !CLOUD_OWNER_KEY)
721
+ return false;
722
+ return cloudRoutineAuthors.authored(routine.id, routine) ||
723
+ (cloudRoutineAuthors.writer(routine.id) === CLOUD_OWNER_KEY && !cloudRoutineAuthors.recorded(routine.id));
724
+ }
725
+ /** On a Cloud home: a routine run that acts for the owner. Only in the run's
726
+ * own thread, and only while the owner's fingerprint matches what the run
727
+ * snapshotted, the same test as lending the Mac. A routine that is the
728
+ * owner's but not yet cleared for the Mac (no fingerprint: a v0.1.91
729
+ * approval, a template, a restore) can't clear itself or others this way. */
730
+ function cloudRoutineRunIsOwners(run, botId, threadId) {
731
+ if (!run || run.threadId !== threadId || store.taskByThread(botId, threadId)?.routineRunId !== run.id || !cloudRoutineAuthors)
732
+ return false;
733
+ const routine = routines?.listRoutines().find((candidate) => candidate.id === run.routineId);
734
+ return Boolean(routine) && cloudRoutineAuthors.authored(run.routineId, {
735
+ ...routine, prompt: run.prompt, target: run.target, botId: run.botId, groupId: run.groupId, attachments: run.attachments, runOn: run.runOn,
736
+ }) === true;
737
+ }
738
+ /** After the owner approved a routine request (its card, or at once in their
739
+ * own conversation): a routine it created is theirs; one it changed, paused
740
+ * or resumed stays theirs only if it already was (`wasOwners`, taken before
741
+ * the change). Approving a harmless change never makes anyone else's routine
742
+ * the owner's. */
743
+ function cloudOwnerApplied(action, routineId, wasOwners) {
744
+ if (action === "create" || ((action === "update" || action === "pause" || action === "resume") && wasOwners))
745
+ cloudOwnersRoutine(routineId);
746
+ }
747
+ /** What the owner saw on a card that proposed a routine, and what a routine
748
+ * runs, in the same terms (everything its fingerprint covers except an
749
+ * interval's anchor, which only moves when it runs). */
750
+ function approvalShape(shape) {
751
+ const schedule = shape.schedule && typeof shape.schedule === "object" ? { ...shape.schedule } : null;
752
+ if (schedule)
753
+ delete schedule.anchorAt;
754
+ return JSON.stringify([shape.prompt ?? "", shape.botId ?? "", shape.runOn ?? "maus", schedule, shape.target ?? "bot", shape.groupId ?? null,
755
+ (shape.attachments ?? []).map((attachment) => [attachment.id, attachment.path])]);
756
+ }
757
+ /** Who opens a routine's results conversation on a Cloud home: the writer
758
+ * of this request, else the owner for a routine that is theirs (they wrote
759
+ * it as it stands, or they are its writer: cloud-owner.ts), else its last
760
+ * writer, else nobody. */
761
+ function routineOpener(routineId) {
762
+ if (routineWriterInFlight)
763
+ return routineWriterInFlight;
764
+ const routine = routines?.listRoutines().find((candidate) => candidate.id === routineId);
765
+ if (routine && CLOUD_OWNER_KEY && cloudRoutineAuthors?.authored(routineId, routine))
766
+ return CLOUD_OWNER_KEY;
767
+ const writer = cloudRoutineAuthors?.writer(routineId);
768
+ if (writer && CLOUD_OWNER_KEY && writer === CLOUD_OWNER_KEY)
769
+ return CLOUD_OWNER_KEY;
770
+ return writer && !cloudOwnerPerson(writer) ? writer : CLOUD_NOBODY_KEY;
771
+ }
772
+ /** On a Cloud home: a conversation someone other than the owner opened (a
773
+ * guest, a guest's routine, or its room; on a personal Cloud, one a revoked
774
+ * session opened: nobody's). Its turns run in Ask, confined, in a private
775
+ * folder of their own (pinned at its first turn, or after it was unpinned at
776
+ * boot: see pinTaskCwd), and nothing in it reaches the owner's turns. */
777
+ function cloudGuestOpened(threadId) {
778
+ return Boolean(CLOUD_HOME) && cloudThreadStarters(threadId).some((person) => person !== undefined && !cloudOwnerPerson(person));
779
+ }
780
+ /** On a Cloud home: turns running now that a guest drives through another
781
+ * conversation (a teammate's handoff started from a guest's turn), by
782
+ * thread, with their generation. They are guest-driven like the guest's
783
+ * own conversation while they run (cloudGuestDriven). */
784
+ const guestDrivenTurns = new Map();
785
+ /** On a Cloud home: whether a handoff's chain started in (or passed
786
+ * through) a guest-driven conversation: work a guest's turn hands a
787
+ * teammate is the guest's too, however deep. */
788
+ function cloudGuestDrivenHandoff(nodeId) {
789
+ if (!CLOUD_HOME || !nodeId)
790
+ return false;
791
+ let node = roomHandoffs.nodes.get(nodeId);
792
+ for (let steps = 0; node && steps < 32; steps++) {
793
+ // Every conversation the work came through, up to the one it started in.
794
+ if (node.id !== nodeId && cloudGuestDriven(node.threadId))
795
+ return true;
796
+ if (node.id === node.rootId)
797
+ break;
798
+ node = roomHandoffs.nodes.get(node.parentId ?? node.rootId);
799
+ }
800
+ return false;
801
+ }
802
+ /** Who a conversation a bot opens from another is for: on a Cloud home,
803
+ * never the owner when a guest drives the source (it opens as the guest's,
804
+ * or nobody's), so it is confined like the source. */
805
+ function openerFrom(fromThreadId) {
806
+ const person = threadPersonKey(fromThreadId);
807
+ if (!CLOUD_HOME || !cloudGuestDriven(fromThreadId))
808
+ return person;
809
+ return person && !cloudOwnerPerson(person) ? person : CLOUD_NOBODY_KEY;
810
+ }
811
+ /** The model a thread a bot opens from one of its own threads starts on:
812
+ * that thread's, not the bot default the person may have moved away from
813
+ * (#1950). Only the bot's own thread counts — a room or a teammate's thread
814
+ * is not one of its tasks, and another bot's model belongs to another engine.
815
+ * Approval stays the bot's, except that a level the inherited engine would
816
+ * have to confirm on a model switch starts the new thread in Ask. */
817
+ function parentThreadModel(botId, parentThreadId) {
818
+ // The stored profile, never a per-thread projection: the comparison is
819
+ // against the defaults the new thread would otherwise get.
820
+ const bot = store.bot(botId);
821
+ const modelSelection = bot && store.taskByThread(bot.id, parentThreadId)?.modelSelection;
822
+ if (!bot || !modelSelection)
823
+ return {};
824
+ const needsAsk = modelSwitchNeedsAsk(approvalModeFor(bot), registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, registry.cliTarget(modelSelection.instanceId)?.driverKind);
825
+ return { modelSelection, ...(needsAsk ? { approvalMode: "ask" } : {}) };
826
+ }
827
+ /** On a Cloud home: the conversation that work a guest-driven turn hands a
828
+ * teammate (delegate_bot, ask_bot) runs in. It is a fresh one of the guest's
829
+ * own on the teammate, never the owner's conversation with it, so the work,
830
+ * and anything that later continues there, stays confined. Undefined when
831
+ * the source is not guest-driven, null when it could not be made. */
832
+ function guestWorkThread(from, target, fromThreadId, brief) {
833
+ if (!CLOUD_HOME || !cloudGuestDriven(fromThreadId))
834
+ return undefined;
835
+ const title = brief.replace(/\s+/g, " ").trim().slice(0, 60) || `From ${from.name}`;
836
+ const task = store.createTask(target.id, title, false, undefined, { botId: from.id, name: from.name, at: Date.now() });
837
+ if (!task)
838
+ return null;
839
+ threadStarters.set(task.threadId, openerFrom(fromThreadId));
840
+ return task.threadId;
841
+ }
842
+ /** On a personal Cloud home the only turns confined like a guest's
843
+ * (cloudGuestDriven) are in what came before it: a routine nobody is proven
844
+ * to have written, or a conversation or room a revoked session opened. What
845
+ * the owner is told about one, and when its engine cannot be confined. */
846
+ const ROUTINE_FROM_BEFORE = "This routine was made before this update. Open it and save it once to run it with full access.";
847
+ const CONVERSATION_FROM_BEFORE = "This conversation is from before your Cloud was only yours, so this bot works in it without a shell. Start a new conversation to use it fully.";
848
+ function confinedWhy(threadId) {
849
+ const run = activeRoutineRunForThread(threadId);
850
+ const writer = run ? cloudRoutineAuthors?.writer(run.routineId) : undefined;
851
+ return run && writer && !cloudOwnerPerson(writer) ? ROUTINE_FROM_BEFORE : CONVERSATION_FROM_BEFORE;
852
+ }
853
+ function guestEngineRefusal(threadId) {
854
+ const why = confinedWhy(threadId);
855
+ return why === ROUTINE_FROM_BEFORE ? `This bot's engine can't run it that way. ${why}`
856
+ : "This conversation is from before your Cloud was only yours, and this bot's engine can't work in it. Start a new conversation.";
857
+ }
858
+ /** On a Cloud home: a turn a guest drives, which runs in Ask whatever the
859
+ * bot's own level (no Full access, no Auto reviewer, no saved commands), with
860
+ * no shell (guestEngineRefusal): a conversation a guest opened, a run of a
861
+ * routine a guest wrote, work a guest's turn handed on (guestDrivenTurns), or
862
+ * a room whose latest line from a person is a guest's. */
863
+ function cloudGuestDriven(threadId) {
864
+ if (!CLOUD_HOME)
865
+ return false;
866
+ if (guestDrivenTurns.has(threadId) || cloudGuestOpened(threadId))
867
+ return true;
868
+ // A run of a routine a guest wrote (or last edited) is the guest's.
869
+ const run = activeRoutineRunForThread(threadId);
870
+ const writer = run ? cloudRoutineAuthors?.writer(run.routineId) : undefined;
871
+ if (writer && !cloudOwnerPerson(writer))
872
+ return true;
873
+ if (!store.groupByThread(threadId))
874
+ return false;
875
+ const person = store.messagesFor(threadId).findLast((line) => line.role === "user" && !line.peerAsk && line.sender?.id)?.sender?.id;
876
+ return person !== undefined && !cloudOwnerPerson(person);
877
+ }
514
878
  /** On a Cloud home: a request from one of the owner's own devices. The
515
879
  * Admin's signed pairing gives the desktop an admin session; a guest the
516
880
  * owner pairs with chat-only access has client scope. Loopback is not the
@@ -519,10 +883,31 @@ function sessionLendingPrincipal(auth) {
519
883
  function cloudOwnerSession(auth) {
520
884
  return auth.kind === "session" && auth.scopes.includes("admin");
521
885
  }
522
- /** On a Cloud home: whether a message's person key is one of the owner's own
523
- * devices right now (a live admin session with that key). */
886
+ /** On a Cloud home: whether a person key is the owner's, for who opened a
887
+ * conversation, who drives a turn, its approval level and its folder: the
888
+ * owner's one key, a device of theirs still paired, or a key from before
889
+ * they shared one (cloud-owner.ts, adopted). */
524
890
  function cloudOwnerPerson(person) {
525
- return Boolean(person) && sessions.list().some((session) => session.scopes.includes("admin") && personKey(session) === person);
891
+ if (!person)
892
+ return false;
893
+ if (person === CLOUD_OWNER_KEY || cloudEarlierOwners.adopted.has(person))
894
+ return true;
895
+ return cloudOwnerDevice(person);
896
+ }
897
+ /** …and whether it is proven the owner's, the only kind whose words may
898
+ * reach the lent Mac, memory, recall and the recent-work brief: a key from
899
+ * before counts only with proof it was the owner's (cloud-owner.ts, proven),
900
+ * never just because nobody revoked it. */
901
+ function cloudProvenOwnerPerson(person) {
902
+ if (!person)
903
+ return false;
904
+ if (person === CLOUD_OWNER_KEY || cloudEarlierOwners.proven.has(person))
905
+ return true;
906
+ return cloudOwnerDevice(person);
907
+ }
908
+ /** Written before the owner had one key: a device of theirs still paired. */
909
+ function cloudOwnerDevice(person) {
910
+ return sessions.list().some((session) => session.scopes.includes("admin") && personKey(session) === person);
526
911
  }
527
912
  /** On a Cloud home, a session that is not one of the owner's own devices may
528
913
  * not answer a card of any kind. */
@@ -594,7 +979,7 @@ function cardAnswererFor(auth) {
594
979
  if (auth.kind === "loopback")
595
980
  return auth.trust === "service" ? { kind: "worker" } : { kind: "loopback" };
596
981
  return { kind: "session", name: (auth.session.email ?? auth.session.label ?? "").trim() || "Signed-in user",
597
- ...(CLOUD_HOME ? { person: personKey(auth.session) } : {}) };
982
+ ...(CLOUD_HOME ? { person: actorKey(auth) } : {}) };
598
983
  }
599
984
  /** On a Cloud home, who is answering a card right now, by request id: the
600
985
  * answer reaches the turn before `answeredBy` is written, and a lending turn
@@ -616,7 +1001,7 @@ async function answeringCardAs(auth, threadId, requestId, work) {
616
1001
  return Boolean(card && !card.answered && !card.dismissed && !card.expired);
617
1002
  })();
618
1003
  if (CLOUD_HOME && open)
619
- cloudCardAnswersInFlight.set(requestId, auth.kind === "session" ? personKey(auth.session) : undefined);
1004
+ cloudCardAnswersInFlight.set(requestId, auth.kind === "session" ? actorKey(auth) : undefined);
620
1005
  try {
621
1006
  await withDecisionActor(decisionActorFor(auth), work);
622
1007
  }
@@ -743,6 +1128,11 @@ function settleRoomFloor(group) {
743
1128
  return;
744
1129
  store.patchGroup(group.id, { audienceFloor: next === "everyone" ? undefined : next });
745
1130
  }
1131
+ /** On a Cloud home the recent-work brief draws only on conversations the
1132
+ * owner alone wrote in (see cloudOwnerOnlyThread). */
1133
+ function recentWorkFilter() {
1134
+ return CLOUD_HOME ? { include: cloudOwnerOnlyThread } : {};
1135
+ }
746
1136
  /** The conversations a bot's recent-work brief may draw on: its own, and the
747
1137
  * rooms roomFeedsBot allows. */
748
1138
  function recentWorkSources(bot) {
@@ -757,7 +1147,7 @@ function recentWorkSources(bot) {
757
1147
  * or a room must not be able to pull a private chat into its reply. The
758
1148
  * conversations are the ones session_search would search, minus this one. */
759
1149
  function autoRecallPrompt(bot, threadId, message, opts) {
760
- if (!autoRecallEnabled(cfg))
1150
+ if (bot.memoryEnabled === false || !autoRecallEnabled(cfg))
761
1151
  return "";
762
1152
  const roomByThread = new Map();
763
1153
  if (opts.conversations) {
@@ -770,7 +1160,10 @@ function autoRecallPrompt(bot, threadId, message, opts) {
770
1160
  }
771
1161
  }
772
1162
  const threadIds = opts.conversations
773
- ? [...new Set([bot.threadId, ...(bot.tasks ?? []).map((task) => task.threadId), ...roomByThread.keys()])].filter((id) => id !== threadId)
1163
+ ? [...new Set([bot.threadId, ...(bot.tasks ?? []).map((task) => task.threadId), ...roomByThread.keys()])]
1164
+ // On a Cloud home a conversation anyone else wrote in never feeds
1165
+ // another: its words would reach the owner's turns (and a lent Mac).
1166
+ .filter((id) => id !== threadId && (!CLOUD_HOME || cloudOwnerOnlyThread(id)))
774
1167
  : [];
775
1168
  try {
776
1169
  const recalled = buildRecall({
@@ -902,11 +1295,13 @@ function adminAuditPlan(method, path) {
902
1295
  m = /^\/api\/groups\/([\w-]+)$/.exec(path);
903
1296
  if (m && method === "PATCH")
904
1297
  plan.roomId = m[1];
905
- m = /^\/api\/instances\/([\w.-]+)\/(install|auth\/complete|auth\/sign-out|claude-update)$/.exec(path);
1298
+ m = /^\/api\/instances\/([\w.-]+)\/(install|auth\/complete|auth\/sign-out|claude-update|leftover-files\/remove)$/.exec(path);
906
1299
  if (m && method === "POST")
907
1300
  plan.engineAction = { id: m[1], action: m[2].replace("/", "-") };
908
1301
  if (method === "POST" && path === "/api/instances/claude-accounts")
909
1302
  plan.engineAction = { id: "claude", action: "account-add" };
1303
+ if (method === "POST" && path === "/api/instances/chatgpt-accounts")
1304
+ plan.engineAction = { id: "chatgpt", action: "account-add" };
910
1305
  return plan.config || plan.botId || plan.createsBots || plan.deletesBot || plan.createsWebhook || plan.webhookId || plan.sessionId || plan.pairing || plan.engineAction || plan.roomId ? plan : null;
911
1306
  }
912
1307
  const WEBHOOK_AUDIT_FIELDS = ["name", "prompt", "botId", "runOn", "enabled", "eventTypes"];
@@ -1081,6 +1476,46 @@ function queuedTurnTrigger(item) {
1081
1476
  return item.sender ? { kind: "user", label: item.sender.name } : { kind: "owner" };
1082
1477
  }
1083
1478
  const providerAuthSessions = new ProviderAuthSessions();
1479
+ /** OAuth sign-in for URL servers; tokens live in their own owner-only file. */
1480
+ const mcpOAuth = new McpOAuthManager({
1481
+ file: join(DATA_DIR, "mcp-oauth.json"),
1482
+ isOwnerLive: (owner) => owner === "loopback" || sessions.isLive(owner),
1483
+ // Read the registered app when needed; never copy its secret into the token store.
1484
+ clients: (name, url) => {
1485
+ const raw = cfg.mcpServers?.[name];
1486
+ const parsed = raw === undefined ? null : parseStoredMcpServer(name, raw);
1487
+ return parsed?.ok && isRemoteMcpServer(parsed.server) && parsed.server.url === url ? parsed.server.oauth : undefined;
1488
+ },
1489
+ });
1490
+ // OpenCode reads provider keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, …) from
1491
+ // its environment, as it does in a terminal, but only where that environment
1492
+ // is the person's own shell (openCodeProviderKeysAllowed). Wired before the
1493
+ // first catalog probe below. Company enrollment arrives later from the
1494
+ // desktop parent and is wired in once it can (openCodeOrganisationManaged).
1495
+ let openCodeOrganisationManaged = () => false;
1496
+ const openCodeKeysAllowed = () => openCodeProviderKeysAllowed({
1497
+ cloudHome: Boolean(CLOUD_HOME),
1498
+ hostedWorkspace: HOSTED_WORKSPACE,
1499
+ organisationManaged: openCodeOrganisationManaged(),
1500
+ sharedSignIn: sharedSignIn(signInAllowList()),
1501
+ });
1502
+ setOpenCodeProviderKeyPolicy(openCodeKeysAllowed);
1503
+ let openCodeKeysLastAllowed = openCodeKeysAllowed();
1504
+ /** OpenCode lists a provider's models only while it may read that
1505
+ * provider's key. When enrollment or the sign-in list changes the answer,
1506
+ * list them again, so the picker never offers models its turns lack. */
1507
+ function resyncOpenCodeProviderKeys() {
1508
+ const allowed = openCodeKeysAllowed();
1509
+ if (allowed === openCodeKeysLastAllowed)
1510
+ return;
1511
+ openCodeKeysLastAllowed = allowed;
1512
+ for (const instance of registry.instances()) {
1513
+ if (instance.driverKind !== "opencodeGo")
1514
+ continue;
1515
+ void registry.refreshModels(instance.instanceId)
1516
+ .then(() => broadcast({ kind: "config", ...configStatus() }), () => { });
1517
+ }
1518
+ }
1084
1519
  await registry.load(providerConfigs(), decorateHostedProvider);
1085
1520
  const bundledSkills = loadBundledSkills();
1086
1521
  const availableSkills = () => mergeSkills(bundledSkills, loadUserSkills(join(DATA_DIR, "skills")));
@@ -1179,7 +1614,10 @@ let companyRuntimeReady;
1179
1614
  const companyRuntimeStarted = new Promise(resolve => { companyRuntimeReady = resolve; });
1180
1615
  /** The enrolled organisation's desktop policy: in memory only, read-only, and
1181
1616
  * inert (null) unless Electron's enrolled parent sends one. */
1182
- const managedPolicy = new ManagedDesktopPolicy({ onChange: () => broadcast({ kind: "config", ...configStatus() }) });
1617
+ const managedPolicy = new ManagedDesktopPolicy({ onChange: () => {
1618
+ broadcast({ kind: "config", ...configStatus() });
1619
+ resyncOpenCodeProviderKeys();
1620
+ } });
1183
1621
  /** A computer kind this server will not use, refused before anything is
1184
1622
  * prepared: a Cloud home never offers this computer or a Local VM
1185
1623
  * (cloud-home.ts), and an enrolled organisation may disallow any kind. */
@@ -1203,7 +1641,10 @@ const managedDesktop = new ManagedDesktopProviders({
1203
1641
  beforeReplace: stopCompanyInstances,
1204
1642
  // Ids became stable across re-enrolment; carry old references forward once.
1205
1643
  migrate: aliases => { store.renameInstances(new Map(aliases.map(({ from, to }) => [from, to]))); },
1206
- onAvailability: () => broadcast({ kind: "config", ...configStatus() }),
1644
+ onAvailability: () => {
1645
+ broadcast({ kind: "config", ...configStatus() });
1646
+ resyncOpenCodeProviderKeys();
1647
+ },
1207
1648
  afterReplace: ids => {
1208
1649
  for (const id of providerInstancesChanging)
1209
1650
  if (managedDesktop.owns(id))
@@ -1212,8 +1653,11 @@ const managedDesktop = new ManagedDesktopProviders({
1212
1653
  // Existing renderer config events refresh /api/instances as well, so
1213
1654
  // Company grants and revocations appear without reloading the window.
1214
1655
  broadcast({ kind: "config", ...configStatus() });
1656
+ resyncOpenCodeProviderKeys();
1215
1657
  },
1216
1658
  });
1659
+ // An organisation-managed desktop's environment is not the person's shell.
1660
+ openCodeOrganisationManaged = () => managedPolicy.current() !== null || managedDesktop.enrolled();
1217
1661
  utilityParentPort?.on("message", event => {
1218
1662
  const message = event.data;
1219
1663
  if (message?.type !== "openmausbot:managed-desktop-identity")
@@ -1336,7 +1780,27 @@ function mintInternalCapability(capability) {
1336
1780
  });
1337
1781
  return token;
1338
1782
  }
1783
+ /** A turn that was not provably the owner's has ended: judge its bot's
1784
+ * memory now, so a change it made is caught and an unchanged one is cleared
1785
+ * (server/lending-memory.ts). `generation` undefined: every such turn on the
1786
+ * thread (a teardown that settles threads, not generations). */
1787
+ function endForeignTurns(threadId, generation) {
1788
+ for (const [id, foreign] of foreignTurns) {
1789
+ if (foreign.threadId !== threadId || (generation !== undefined && id !== generation))
1790
+ continue;
1791
+ foreignTurns.delete(id);
1792
+ try {
1793
+ lendingMemory?.reconcile(foreign.botId, foreignTurnRunning(foreign.botId));
1794
+ }
1795
+ catch (error) {
1796
+ console.warn(`lending: could not check a bot's memory: ${error.message}`);
1797
+ }
1798
+ }
1799
+ }
1339
1800
  function revokeInternalCapabilityGeneration(threadId, generation) {
1801
+ endForeignTurns(threadId, generation);
1802
+ if (guestDrivenTurns.get(threadId) === generation)
1803
+ guestDrivenTurns.delete(threadId);
1340
1804
  for (const [token, capability] of internalCapabilities) {
1341
1805
  if (capability.threadId === threadId && capability.generation === generation) {
1342
1806
  internalCapabilities.delete(token);
@@ -1363,6 +1827,9 @@ function revokeInternalCapabilitiesForThread(threadId) {
1363
1827
  function revokeAllInternalCapabilities() {
1364
1828
  computerSelectionTurns.clear();
1365
1829
  internalCapabilities.clear();
1830
+ // foreignTurns stays: a turn that is not provably the owner's can still
1831
+ // write while its engine is torn down, and its own end (which revokes its
1832
+ // generation by id) is what judges it (server/lending-memory.ts).
1366
1833
  activeInternalGenerationByThread.clear();
1367
1834
  internalGenerationByProviderTurn.clear();
1368
1835
  }
@@ -1474,6 +1941,7 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation, r
1474
1941
  OMB_ROOM_TURN: roomCoordination ? "1" : "0",
1475
1942
  OMB_OWN_THREAD_CREATION: ownThreadCreation ? "1" : "0",
1476
1943
  OMB_SKILL_AUTHORING_ENABLED: skillAuthoring ? "1" : "0",
1944
+ OMB_MEMORY_ENABLED: store.bot(botId)?.memoryEnabled === false ? "0" : "1",
1477
1945
  // The shared-computer tools are advertised only while the workspace
1478
1946
  // gate is on; the routes behind them refuse regardless.
1479
1947
  OMB_SHARED_COMPUTERS_ENABLED: lendingEnabled() ? "1" : "0",
@@ -1902,7 +2370,15 @@ function canAdmitDirectTurn(botId, threadId) {
1902
2370
  /** Routine and webhook dispatch shares startTurn's admission preconditions
1903
2371
  * instead of waiting for whole-bot idleness: a free thread slot and no
1904
2372
  * active group turn. A group turn blocks scheduled starts the same way it
1905
- * blocks every other turn kind; it does not consume a capacity slot. */
2373
+ * blocks every other turn kind; it does not consume a capacity slot.
2374
+ *
2375
+ * A fresh dispatch also pins to the bot's own project folder, exactly like
2376
+ * startTurn's cwd resolution: a free thread slot alone can miss a second
2377
+ * task of this bot landing in that one folder (#F-collide). Predicting that
2378
+ * collision here lets it defer through the same busy-target machinery as
2379
+ * capacity and group-turn contention, instead of reaching startTurn's own
2380
+ * workspace claim — which throws deep inside a detached dispatch that no
2381
+ * caller here awaits, so the run would otherwise fail permanently. */
1906
2382
  function unattendedDispatchState(botId) {
1907
2383
  const bot = store.bot(botId);
1908
2384
  const decision = admit("unattended", {}, {
@@ -1910,7 +2386,19 @@ function unattendedDispatchState(botId) {
1910
2386
  atCapacity: Boolean(bot) && botAtThreadCapacity(botId),
1911
2387
  groupTurn: Boolean(bot) && Boolean(activeGroupTurnForBot(botId)),
1912
2388
  });
1913
- return decision.action !== "refuse" ? "ready" : decision.code === "missing" ? "missing" : "busy";
2389
+ if (decision.action === "refuse")
2390
+ return decision.code === "missing" ? "missing" : "busy";
2391
+ if (bot?.cwd) {
2392
+ try {
2393
+ if (turnResources.blocker(workspaceResource(bot.cwd), { threadId: "", generation: "" }))
2394
+ return "busy";
2395
+ }
2396
+ catch {
2397
+ // An unresolvable folder is startTurn's own admission check to
2398
+ // report; this prediction only ever adds a defer, never a refusal.
2399
+ }
2400
+ }
2401
+ return "ready";
1914
2402
  }
1915
2403
  function requestedTaskBot(botId, rawThreadId) {
1916
2404
  const profile = store.bot(botId);
@@ -2076,7 +2564,16 @@ function cancelDirectTurnDispatch(botId, expectedThreadId) {
2076
2564
  /** The bot's browser for this turn: agent-browser, one isolated session per
2077
2565
  * browser profile or per bot (docs/plans/browser-engine.md). Null, with the
2078
2566
  * reason logged once, when the engine is not on this machine. */
2079
- const browserRuntime = new BrowserRuntime();
2567
+ const browserRuntime = new BrowserRuntime({
2568
+ // The bot's restart_browser tool. An open Browser panel watched the old
2569
+ // browser; drop its stream the way the panel's own Restart button does.
2570
+ closeBrowser: async (session, spec) => {
2571
+ const closed = await closeBrowserSession(spec.command, browserRuntimeEnv({ ...spec.env, AGENT_BROWSER_SESSION: session }));
2572
+ if (closed)
2573
+ browserLive.closeForSession(session);
2574
+ return closed;
2575
+ },
2576
+ });
2080
2577
  const browserLive = new BrowserLive({ runtime: browserRuntime });
2081
2578
  // Temporary profiles last for this server run, but are never saved to disk.
2082
2579
  // The viewer and the agent must address the SAME temporary browser.
@@ -2763,7 +3260,7 @@ function previewSystemPrompt(bot) {
2763
3260
  { id: "routine", label: "Routines", text: agentsMounted ? ROUTINE_PROMPT : "" },
2764
3261
  { id: "profile", label: "Profile changes", text: agentsMounted ? PROFILE_PROMPT : "" },
2765
3262
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
2766
- { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: agentsMounted, fileTools: Boolean(privateWorkspace) }) },
3263
+ { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: agentsMounted, fileTools: Boolean(privateWorkspace), enabled: bot.memoryEnabled !== false }) },
2767
3264
  { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id) : "" },
2768
3265
  ]);
2769
3266
  const totalBytes = built.sections.reduce((n, s) => n + s.bytes, 0);
@@ -2851,7 +3348,12 @@ async function botOverview(bot) {
2851
3348
  * with one deliberate exception: a Chief of Staff with Full access makes the
2852
3349
  * threads it delegates Full too (delegatedFullAccess), so the grant the
2853
3350
  * person gave the Chief covers the work the Chief hands out. */
2854
- const approvalModeForTurn = (bot, peerInitiated = false) => {
3351
+ const approvalModeForTurn = (bot, peerInitiated = false, threadId = bot.threadId) => {
3352
+ // On a Cloud home a turn a guest drives runs in Ask, whatever the bot's
3353
+ // own level. Judged by the conversation the turn runs in (a room's, for a
3354
+ // room turn), never by whichever of the bot's conversations is active.
3355
+ if (cloudGuestDriven(threadId))
3356
+ return "ask";
2855
3357
  const mode = approvalModeForOrigin(approvalModeFor(bot), { peerInitiated });
2856
3358
  if (!supportsApprovalMode(bot.modelSelection, mode)) {
2857
3359
  return "ask";
@@ -2866,7 +3368,7 @@ function fullAccessForSource(botId, threadId) {
2866
3368
  return false;
2867
3369
  const bot = store.projectBotForTask(botId, threadId) ?? owner.bot;
2868
3370
  // Origin changes Custom to Auto, never Full; no live-turn state is needed.
2869
- return approvalModeForTurn(bot) === "full";
3371
+ return approvalModeForTurn(bot, false, threadId) === "full";
2870
3372
  }
2871
3373
  function peerReviewRequired(bot, threadId) {
2872
3374
  return Boolean(bot.approvePeerComms && !fullAccessForSource(bot.id, threadId));
@@ -2890,8 +3392,7 @@ function delegatedFullAccess(from, fromThreadId, target) {
2890
3392
  }
2891
3393
  /** Make a delegated thread Full and say so in it once, so the level the
2892
3394
  * chip shows and the level the turns run at agree, and the person can see
2893
- * where the access came from. Idempotent: a pair conversation is reused
2894
- * across delegations and must not collect a chip per request. */
3395
+ * where the access came from. */
2895
3396
  function grantDelegatedFullAccess(from, target, threadId) {
2896
3397
  if (store.taskByThread(target.id, threadId)?.approvalMode === "full")
2897
3398
  return;
@@ -2905,13 +3406,16 @@ function grantDelegatedFullAccess(from, target, threadId) {
2905
3406
  /** A room member's level for one turn. Work a Full-access Chief hands out
2906
3407
  * in a room runs Full for that turn: the room thread is shared, so the
2907
3408
  * level is not stored on it — it rides the handoff. */
2908
- function roomTurnApprovalMode(bot, orchestration) {
3409
+ function roomTurnApprovalMode(bot, threadId, orchestration) {
3410
+ // On a Cloud home a room turn a guest drives runs in Ask (cloudGuestDriven).
3411
+ if (cloudGuestDriven(threadId))
3412
+ return "ask";
2909
3413
  const handoff = orchestration?.roomHandoffId ? roomHandoffs.nodes.get(orchestration.roomHandoffId) : undefined;
2910
3414
  const source = handoff?.parentId ? roomHandoffs.nodes.get(handoff.parentId) : undefined;
2911
3415
  const from = source ? store.bot(source.botId) : undefined;
2912
3416
  if (from && source && delegatedFullAccess(from, source.threadId, bot))
2913
3417
  return "full";
2914
- return approvalModeForTurn(bot, Boolean(orchestration?.roomHandoffId));
3418
+ return approvalModeForTurn(bot, Boolean(orchestration?.roomHandoffId), threadId);
2915
3419
  }
2916
3420
  /** Privileged approval-mode transitions are deliberately absent from the
2917
3421
  * loopback HTTP authority model: a bot with shell access can curl that
@@ -2963,6 +3467,9 @@ function handleDesktopTrustedApprovalMessage(raw) {
2963
3467
  if (bot.approvalGrant.allThreads && mode === "full") {
2964
3468
  store.setAllThreadApprovalMode(botId, mode);
2965
3469
  }
3470
+ else if (bot.approvalGrant.refreshPermissions && bot.approvalGrant.threadId) {
3471
+ store.refreshTaskPermissions(botId, bot.approvalGrant.threadId);
3472
+ }
2966
3473
  else if (bot.approvalGrant.threadId) {
2967
3474
  store.patchTask(botId, bot.approvalGrant.threadId, { approvalMode: mode, autoApprove: false });
2968
3475
  }
@@ -3168,6 +3675,39 @@ function handleDesktopTrustedApprovalMessage(raw) {
3168
3675
  respond({ ok: false, error: "Invalid thread approval scope" });
3169
3676
  return true;
3170
3677
  }
3678
+ if (message.refreshPermissions !== undefined && message.refreshPermissions !== true) {
3679
+ respond({ ok: false, error: "Invalid permission refresh" });
3680
+ return true;
3681
+ }
3682
+ if (message.refreshPermissions === true) {
3683
+ const target = typeof threadId === "string" ? store.projectBotForTask(botId, threadId) : null;
3684
+ const savedMode = approvalModeFor({ ...existing, approvalGrant: undefined });
3685
+ if (message.threadOnly !== true || !target || message.allThreads === true || message.modelSelection !== undefined || message.updateBotDefault !== undefined || savedMode !== mode) {
3686
+ respond({ ok: false, error: "Choose this bot's approval level in bot settings before refreshing a thread" });
3687
+ return true;
3688
+ }
3689
+ if (existing.approvalGrant || threadBusy(botId, target.threadId)) {
3690
+ respond({ ok: false, error: "Stop this thread and finish its pending approval change first" });
3691
+ return true;
3692
+ }
3693
+ if (!supportsApprovalMode(target.modelSelection, mode)) {
3694
+ respond({ ok: false, error: "This thread's provider does not support that approval level" });
3695
+ return true;
3696
+ }
3697
+ if (mode === "auto" && target.computer === "local" && approvalModeFor(target) !== "auto" && message.acknowledgeLocalAuto !== true) {
3698
+ respond({ ok: false, error: "Auto mode on this computer requires confirming the warning" });
3699
+ return true;
3700
+ }
3701
+ if (mode === "full" || mode === "custom") {
3702
+ store.patchBot(botId, { approvalGrant: { requestId, mode, phase: "prepared", threadId: target.threadId, threadOnly: true, refreshPermissions: true } });
3703
+ }
3704
+ else if (!store.refreshTaskPermissions(botId, target.threadId)) {
3705
+ respond({ ok: false, error: "That thread is no longer available" });
3706
+ return true;
3707
+ }
3708
+ respond({ ok: true, bot: wireTrustedApprovalBot(store.bot(botId)) });
3709
+ return true;
3710
+ }
3171
3711
  if (message.threadOnly === true) {
3172
3712
  const target = typeof threadId === "string" ? store.projectBotForTask(botId, threadId) : null;
3173
3713
  if (!target || message.modelSelection !== undefined || message.updateBotDefault !== undefined) {
@@ -3668,6 +4208,10 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
3668
4208
  : child.status === "completed" ? `${bot?.name ?? "Teammate"} replied${child.groupId ? ` · ${store.group(child.groupId)?.name ?? "Room"}` : ""}`
3669
4209
  : `${bot?.name ?? "Teammate"} — ${child.status}: ${child.result.slice(0, 180)}`,
3670
4210
  ok: child.status === "completed" && !problem,
4211
+ // The chip label stays short; the report itself rides along so a
4212
+ // phone can show what the teammate said without opening its thread.
4213
+ ...(child.status === "completed" && !problem && child.result.trim()
4214
+ ? { output: redactSecretsInText(child.result.trim()).slice(0, 2_000) } : {}),
3671
4215
  },
3672
4216
  ...(child.groupId ? { comm: { groupId: child.groupId, threadId: child.threadId, withBotId: child.botId,
3673
4217
  withName: bot?.name ?? "Teammate", withColor: bot?.color ?? "blue" } }
@@ -3681,14 +4225,9 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
3681
4225
  if (source)
3682
4226
  markTaskContextExternallyUpdated(source, parent.threadId);
3683
4227
  }
3684
- // A work thread exists only because the pair conversation was busy with
3685
- // another job. Its result is now in the sender's conversation, so it
3686
- // closes itself exactly as close_thread would — folded out of the
3687
- // sidebar, never deleted, and open again the moment anyone speaks
3688
- // there. A finished job tidies up after itself; a failed or withheld
3689
- // one stays in the sidebar where the person can see it. The pair
3690
- // conversation is the standing line between two bots and never
3691
- // auto-closes.
4228
+ // A successful direct work thread closes after its result is reported:
4229
+ // folded out of the sidebar, never deleted, and reopened if addressed.
4230
+ // Failed or withheld work stays visible.
3692
4231
  const childTask = store.taskByThread(child.botId, child.threadId);
3693
4232
  if (!child.groupId && child.status === "completed" && !problem && !childTask?.closedBy
3694
4233
  && childTask?.openedBy?.kind === "work" && childTask.openedBy.botId === parent.botId) {
@@ -4337,8 +4876,47 @@ function audienceChanged() {
4337
4876
  }
4338
4877
  }
4339
4878
  }
4879
+ // Cloud boot can revoke sessions before routes are registered. Create the
4880
+ // viewer manager before installing any revocation callbacks.
4881
+ const desktopViewer = createDesktopViewer({
4882
+ target: (id) => {
4883
+ if (id.startsWith("vps/")) {
4884
+ const botId = id.slice(4);
4885
+ if (store.bot(botId)?.cloudBackend !== "vps")
4886
+ return;
4887
+ return { key: id, resolve: async () => {
4888
+ const connection = vps.vpsDesktopConnection(botId);
4889
+ if (!connection)
4890
+ throw Object.assign(new Error("VPS viewer is not open"), { status: 409 });
4891
+ return connection;
4892
+ } };
4893
+ }
4894
+ const targets = [SHARED_LOCAL_VM_TARGET, ...store.bots.map(bot => perBotLocalVmTarget(bot.id)),
4895
+ ...Array.from({ length: localVmMaxInstances(cfg) }, (_, seat) => poolLocalVmTarget(seat))];
4896
+ const target = targets.find(target => viewerTargetId(target) === id);
4897
+ return target && localDesktopTarget(target, {
4898
+ // A viewer can repair a sick CUA driver; skip desktop health probes.
4899
+ status: target => containerComputerStatus(undefined, undefined, target, { probeDesktop: false }),
4900
+ touch: target => localVmIdleFor(target).touch(),
4901
+ });
4902
+ },
4903
+ live: auth => auth.kind === "loopback" || sessions.isLive(auth.session.id),
4904
+ // A phone paired with this server directly drives a bot's Local VM through
4905
+ // this proxy under its control lease (see the local-computer join route).
4906
+ // The lease must hold the bot's computer, and that computer must be the
4907
+ // desktop being viewed: a lease on one bot opens no other bot's VM.
4908
+ lease: (id, botId, controlLeaseId, threadId) => {
4909
+ const bot = store.bot(botId);
4910
+ if (!bot || viewerTargetId(localVmTargetForStatus(bot.id, threadId)) !== id)
4911
+ return;
4912
+ const key = botComputerControlKey(bot);
4913
+ const holds = () => computerControl.ownsLease(key, controlLeaseId);
4914
+ return holds() ? holds : undefined;
4915
+ },
4916
+ });
4340
4917
  function closeSessionStreams(sessionId) {
4341
4918
  browserLive.closeForOwner(sessionId);
4919
+ desktopViewer.closeForOwner(sessionId);
4342
4920
  for (const client of sseClients) {
4343
4921
  if (client.sessionId !== sessionId)
4344
4922
  continue;
@@ -4353,8 +4931,35 @@ function closeSessionStreams(sessionId) {
4353
4931
  }
4354
4932
  sessions.onSessionRevoked((sessionId) => {
4355
4933
  providerAuthSessions.revokeOwner(sessionId);
4934
+ mcpOAuth.revokeOwner(sessionId);
4356
4935
  closeSessionStreams(sessionId);
4936
+ // A revoked owner device no longer vouches for its lines (cloudOwnerPerson).
4937
+ ownerOnlyCache.clear();
4357
4938
  });
4939
+ /** On a Cloud home: a line someone else adds to a conversation while one of
4940
+ * the owner's turns runs in it (steered into it, or handed in) makes that
4941
+ * turn someone else's from then on. The lent Mac is refused at its next use
4942
+ * anyway (cloudLendingRefusal reads the whole conversation each time); this
4943
+ * makes whatever the bot writes to its memory from now on count as theirs
4944
+ * too (server/lending-memory.ts). */
4945
+ function noteSomeoneElsesLine(threadId) {
4946
+ const generation = activeInternalGenerationByThread.get(threadId);
4947
+ if (!generation || foreignTurns.has(generation))
4948
+ return;
4949
+ const bot = store.botByThread(threadId);
4950
+ if (bot && cloudHomeLendingRefusal(cloudLendingTurn({ botId: bot.id, threadId, generation })) !== null) {
4951
+ noteForeignTurn(bot.id, threadId, generation);
4952
+ }
4953
+ }
4954
+ if (lendingMemory) {
4955
+ store.onChange((change) => {
4956
+ if (change.type === "message" && change.message.role === "user")
4957
+ noteSomeoneElsesLine(change.threadId);
4958
+ // A deleted bot's record goes with it (and nothing recreates its workspace).
4959
+ else if (change.type === "bot.deleted")
4960
+ lendingMemory.forget(change.botId);
4961
+ });
4962
+ }
4358
4963
  /** Every frame is numbered, and the last few hundred are kept, so a client
4359
4964
  * whose connection dropped can ask for what it missed instead of
4360
4965
  * re-downloading every transcript. The desktop reconnects in milliseconds
@@ -4456,7 +5061,7 @@ const askMessageByRequest = new Map(); // threadId:requestId -> messageId
4456
5061
  const pendingCommandRules = new Map();
4457
5062
  function commandRememberRefusal(auth, threadId, requestId, behavior) {
4458
5063
  if (!canManageCommandAllowlist(auth))
4459
- return "Only the workspace owner or an admin can save bot-wide command permissions.";
5064
+ return "Only the owner or an admin can save bot-wide command permissions.";
4460
5065
  if (behavior !== "allow" || !pendingCommandRules.has(`${threadId}:${requestId}`)) {
4461
5066
  return "This live request does not contain a command that can be saved. Allow it once instead.";
4462
5067
  }
@@ -4643,10 +5248,9 @@ const watchdog = new TurnWatchdog({
4643
5248
  onStall: (turn) => {
4644
5249
  const stalledResourceOwner = turnResourceOwners.get(turn.threadId);
4645
5250
  const stalledGeneration = directTurnGenerationByThread.get(turn.threadId);
5251
+ const stalledProviderTurn = directRequestOwners.get(turn.threadId);
4646
5252
  cancelDirectTurnDispatch(turn.botId, turn.threadId);
4647
- // Room targets carry an invocation identity; only those claims belong
4648
- // to the room grace cleanup added here.
4649
- const stalledVmTarget = groupSpeakers.has(turn.threadId) ? localVmThreadTargets.get(turn.threadId) : undefined;
5253
+ const stalledVmTarget = localVmThreadTargets.get(turn.threadId);
4650
5254
  revokeInternalCapabilitiesForThread(turn.threadId);
4651
5255
  repeats.settle(turn.threadId);
4652
5256
  const bot = botForThread(turn.botId, turn.threadId);
@@ -4684,6 +5288,9 @@ const watchdog = new TurnWatchdog({
4684
5288
  return;
4685
5289
  if (stalledResourceOwner && turnResourceOwners.get(turn.threadId)?.generation !== stalledResourceOwner.generation)
4686
5290
  return;
5291
+ if (stalledGeneration && stalledProviderTurn?.generation === stalledGeneration && stalledProviderTurn.turnId) {
5292
+ retireProviderTurn(stalledProviderTurn.turnId);
5293
+ }
4687
5294
  // A goal coordinator can stall before sendTurn reveals its provider
4688
5295
  // turn id. Reusing the room during that ambiguous pre-id window would
4689
5296
  // make old and replacement events indistinguishable. Keep ownership
@@ -4698,7 +5305,8 @@ const watchdog = new TurnWatchdog({
4698
5305
  }
4699
5306
  const group = store.groupByThread(turn.threadId);
4700
5307
  const speaker = groupSpeakers.get(turn.threadId);
4701
- if (group && group.busyBotId === turn.botId && speaker?.botId === turn.botId) {
5308
+ const stalledRoomSpeaker = group && group.busyBotId === turn.botId && speaker?.botId === turn.botId;
5309
+ if (stalledRoomSpeaker) {
4702
5310
  groupSpeakers.delete(turn.threadId);
4703
5311
  store.patchGroup(group.id, { busyBotId: null, unread: true });
4704
5312
  }
@@ -4706,7 +5314,7 @@ const watchdog = new TurnWatchdog({
4706
5314
  // computer claim. The generation checks above protect replacements.
4707
5315
  releaseTurnResources(stalledResourceOwner);
4708
5316
  const currentBot = store.bot(turn.botId);
4709
- if (currentBot?.busy) {
5317
+ if (currentBot?.busy && (threadBusy(currentBot.id, turn.threadId) || stalledRoomSpeaker)) {
4710
5318
  stopScreenPoller(currentBot.id, turn.threadId);
4711
5319
  vpsThreadEnded(currentBot.id, turn.threadId);
4712
5320
  if (store.taskByThread(currentBot.id, turn.threadId))
@@ -4827,10 +5435,34 @@ const memoryUpkeep = createMemoryUpkeep({
4827
5435
  return facts.length;
4828
5436
  },
4829
5437
  sourceLabel: (botId, threadId) => memorySourceLabel({ task: store.taskByThread(botId, threadId), threadId }),
5438
+ // On a Cloud home upkeep only works on the owner's conversations (see the
5439
+ // capture hook below), so its writes are the owner's (lending-memory.ts).
5440
+ ...(lendingMemory ? { writing: (botId, write) => lendingMemory.trustedWrite(botId, write) } : {}),
4830
5441
  quietMs: () => captureQuietMs(cfg),
4831
5442
  tidyHour: () => tidyHour(cfg),
4832
5443
  log: (line) => console.log(line),
4833
5444
  });
5445
+ // OMB Cloud home: the first bot turn that finishes here ticks the setup
5446
+ // checklist's "try something" step (firstCloudTurnPatch). The server writes
5447
+ // it, not an admin, so it is kept out of any admin's recorded changes.
5448
+ bus.subscribe((event) => {
5449
+ if (!CLOUD_HOME || event.type !== "turn.completed" || shouldIgnoreProviderEvent(event))
5450
+ return;
5451
+ const patch = firstCloudTurnPatch({
5452
+ cloudHome: true, recorded: cfg.onboarding?.firstTurnAt, ok: event.ok,
5453
+ known: Boolean(store.botByThread(event.threadId) ?? store.groupByThread(event.threadId)),
5454
+ });
5455
+ if (!patch)
5456
+ return;
5457
+ try {
5458
+ adminActorScope.exit(() => saveConfig(patch));
5459
+ Object.assign(cfg, loadConfig());
5460
+ broadcast({ kind: "config", ...configStatus() });
5461
+ }
5462
+ catch (error) {
5463
+ console.warn(`cloud home: could not record the first finished turn (${error instanceof Error ? error.message : "unknown"})`);
5464
+ }
5465
+ });
4834
5466
  // A finished 1:1 turn of an upkeep bot waits for capture. Only the person's
4835
5467
  // own conversation: not a room, not a turn another bot or the harness
4836
5468
  // started (its "user" line is not the person), not a failed turn.
@@ -4844,6 +5476,10 @@ bus.subscribe((event) => {
4844
5476
  if (!bot || !upkeepEnabled(bot) || store.groupByThread(event.threadId) || isInternalTurn(event.threadId)
4845
5477
  || isUnattended(bot.id, event.threadId) || routines?.runForThread(event.threadId))
4846
5478
  return;
5479
+ // On a Cloud home the bot's memory reaches its owner's turns (and a lent
5480
+ // Mac): nothing is captured from a conversation anyone else wrote in.
5481
+ if (CLOUD_HOME && !cloudOwnerOnlyThread(event.threadId))
5482
+ return;
4847
5483
  const path = store.activePath(event.threadId);
4848
5484
  const replyAt = path.findLastIndex((message) => message.role === "bot" && message.kind === "text" && message.turnId === event.turnId);
4849
5485
  if (replyAt < 0)
@@ -4876,7 +5512,7 @@ bus.subscribe((event) => {
4876
5512
  const messages = store.messagesFor(event.threadId);
4877
5513
  const reply = messages.findLast((message) => message.role === "bot" && message.kind === "text" && message.turnId === event.turnId);
4878
5514
  const bot = store.botByThread(event.threadId) ?? (reply?.from ? store.bot(reply.from.botId) : undefined);
4879
- if (!bot)
5515
+ if (!bot || bot.memoryEnabled === false)
4880
5516
  return;
4881
5517
  const tools = messages
4882
5518
  .filter((message) => message.kind === "activity" && message.turnId === event.turnId && message.tool?.name)
@@ -4889,13 +5525,21 @@ bus.subscribe((event) => {
4889
5525
  const room = store.groupByThread(event.threadId);
4890
5526
  if (room && !roomFeedsBot(room, bot))
4891
5527
  return;
4892
- appendMemoryLog(bot.id, line, {
5528
+ // On a Cloud home the log feeds recall in the owner's turns: only the
5529
+ // owner's own conversations leave a line.
5530
+ if (CLOUD_HOME && (room || !cloudOwnerOnlyThread(event.threadId)))
5531
+ return;
5532
+ const writeLine = () => appendMemoryLog(bot.id, line, {
4893
5533
  source: memorySourceLabel({
4894
5534
  room,
4895
5535
  task: store.taskByThread(bot.id, event.threadId),
4896
5536
  threadId: event.threadId,
4897
5537
  }),
4898
5538
  });
5539
+ if (lendingMemory)
5540
+ lendingMemory.trustedWrite(bot.id, writeLine);
5541
+ else
5542
+ writeLine();
4899
5543
  }
4900
5544
  catch {
4901
5545
  // a missing note never fails a turn
@@ -5180,13 +5824,18 @@ const boatLifecycleBusyBots = new Set();
5180
5824
  // A refresh is a reader, not a lifecycle change. Keep its reservation until
5181
5825
  // the provider settles even if the HTTP client leaves, and share it on retry.
5182
5826
  const vpsPreviewRequests = new Map();
5827
+ // Joins in flight per bot. A second tab or a quick reconnect waits for the
5828
+ // pending join, then reuses its tunnel, instead of being refused by its lane.
5829
+ const vpsJoinRequests = new Map();
5183
5830
  const orphanBoatLifecycleBusyIds = new Set();
5184
5831
  const boatInventoryRequestsBusyIds = new Set();
5185
5832
  const computerProviderConfigTransitions = new Set();
5186
5833
  // A restore mutates and cleans a project work tree. Claim the bot across the
5187
5834
  // entire async Git operation so a turn cannot start in that folder midway.
5188
5835
  const checkpointRestoreLeases = new Set();
5189
- const LOCAL_VM_IDLE_MS = 8 * 60 * 60_000;
5836
+ /** The effective idle window, read from config so a Settings change applies
5837
+ * to both new and already-armed timers (see the config route). */
5838
+ const localVmIdleMs = () => localVmIdleTimeoutMinutes(cfg) * 60_000;
5190
5839
  /** How long a turn waits for Cua Driver after starting the container itself.
5191
5840
  * A cold XFCE desktop needs some seconds; past this the turn reports the
5192
5841
  * status it has rather than hanging on a container that will not come up. */
@@ -5317,7 +5966,7 @@ async function teamComputersPayload() {
5317
5966
  * Assignment authorizes waking, never replacing a missing paid machine. */
5318
5967
  async function attachTeamBoat(computer, botId, owner, canMount, remoteAgent) {
5319
5968
  if (!canMount)
5320
- throw new Error("This model engine cannot use the team's Boat computer; choose an engine with computer tools or an explicit bot destination");
5969
+ throw new Error("This model cannot use the team's Boat computer; choose a model provider with computer tools or an explicit bot destination");
5321
5970
  if (!boat.boatConfigured(cfg))
5322
5971
  throw new Error("The team's Boat account is not configured; reconnect it in Settings");
5323
5972
  const ownerId = teamComputerOwner(computer.id);
@@ -5353,7 +6002,7 @@ async function mountHostComputer(owner, botId, providerSupportsLocal) {
5353
6002
  // ACP engine" while already on one has nowhere to go.
5354
6003
  throw new Error(providerSupportsLocal
5355
6004
  ? `local computer control is not available on ${process.platform} — select another destination`
5356
- : "this model engine cannot control this computer — choose Claude or an ACP engine, or select another destination");
6005
+ : "this model cannot control this computer — choose Claude or an ACP model provider, or select another destination");
5357
6006
  }
5358
6007
  const cua = readCuaConnection();
5359
6008
  if (!cua) {
@@ -5626,7 +6275,7 @@ async function selectableComputers(bot) {
5626
6275
  }
5627
6276
  else if (surface === "browser") {
5628
6277
  ready = caps?.browserMcp === true && builtInBrowserEnabled(cfg) && bot.browser !== false && browserEngineStatus().kind === "ready";
5629
- reason = "The built-in browser is disabled, not installed, or unsupported by this model engine.";
6278
+ reason = "The built-in browser is disabled, not installed, or unsupported by this model.";
5630
6279
  }
5631
6280
  }
5632
6281
  catch (error) {
@@ -5777,15 +6426,15 @@ function localVmIdleFor(target) {
5777
6426
  let idle = localVmIdles.get(target.key);
5778
6427
  if (idle)
5779
6428
  return idle;
5780
- idle = new LocalVmIdleTimer(LOCAL_VM_IDLE_MS, () => localVmImageBusy || localVmLifecycleBusy.has(target.key) || localVmActiveThreads.has(target.key), async () => {
6429
+ idle = new LocalVmIdleTimer(localVmIdleMs(), () => localVmImageBusy || localVmLifecycleBusy.has(target.key) || localVmActiveThreads.has(target.key), async () => {
5781
6430
  localVmLifecycleBusy.add(target.key);
5782
6431
  try {
5783
6432
  const status = await containerComputerStatus(undefined, undefined, target);
5784
- // The desktop leaves a stale X lock after stop, so idle cleanup
5785
- // removes only the disposable container. Its target-specific durable
5786
- // workspace and the shared prepared image remain.
5787
- if (status.container === "running") {
5788
- await containerComputerAction("remove", undefined, undefined, target);
6433
+ // The pinned VNC startup clears stale X locks. Keep the container's
6434
+ // home and installed software so inactivity is a reversible stop.
6435
+ if (status.container === "running" && status.managed) {
6436
+ const stopped = await containerComputerAction("stop", undefined, undefined, target);
6437
+ recordLocalVmIdleStop(target.key, stopped.stopped_at);
5789
6438
  }
5790
6439
  }
5791
6440
  finally {
@@ -5827,7 +6476,7 @@ function releaseLocalVmThread(threadId) {
5827
6476
  // Browser, This computer, Auto, or Off does not delete its old Local VM.
5828
6477
  void (async () => {
5829
6478
  if (localVmMode(cfg) === "pool") {
5830
- // Same restore rule as per-bot: idle cleanup removes the container, not
6479
+ // Same restore rule as per-bot: idle shutdown preserves the container and
5831
6480
  // its provisioned workspace, so every surviving seat stays Auto-eligible.
5832
6481
  const seats = localVmMaxInstances(cfg);
5833
6482
  for (let seat = 0; seat < seats; seat += 1) {
@@ -6118,10 +6767,13 @@ bus.subscribe((event) => {
6118
6767
  // always reaches the human — even Full access never invents an answer.
6119
6768
  const asker = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
6120
6769
  const unattended = permission && asker && event.requestId ? isUnattended(asker.id, event.threadId) : false;
6121
- const effectiveApprovalMode = asker ? approvalModeForTurn(asker, isInternalTurn(event.threadId)) : "ask";
6770
+ // A turn a guest drives on a Cloud home is Ask, room turns included,
6771
+ // and no command the owner saved answers for it.
6772
+ const guestDriven = cloudGuestDriven(event.threadId);
6773
+ const effectiveApprovalMode = asker && !guestDriven ? approvalModeForTurn(asker, isInternalTurn(event.threadId), event.threadId) : "ask";
6122
6774
  // Native shell descriptors are distinct from computer/MCP permissions;
6123
6775
  // choosing a local desktop must not disable an exact shell grant.
6124
- const command = permission && asker && event.requestId && !event.requiresExplicitApproval && event.command
6776
+ const command = permission && asker && event.requestId && !event.requiresExplicitApproval && event.command && !guestDriven
6125
6777
  ? commandAllowlistCandidate({ ...event.command, providerInstanceId: event.providerInstanceId ?? asker.modelSelection.instanceId })
6126
6778
  : null;
6127
6779
  const verdict = permission && asker && event.requestId
@@ -6226,7 +6878,8 @@ bus.subscribe((event) => {
6226
6878
  : undefined,
6227
6879
  commandAllowlist: command ?? undefined,
6228
6880
  // Provider-owned session grants remain separate from exact commands.
6229
- allowSession: permission && event.allowSession && !event.requiresExplicitApproval ? true : undefined,
6881
+ // Never on a guest's turn: an "always" for it would outlive it.
6882
+ allowSession: permission && event.allowSession && !event.requiresExplicitApproval && !guestDriven ? true : undefined,
6230
6883
  // The text stays for cards saved before heldCode existed, and for
6231
6884
  // clients that do not know the key yet.
6232
6885
  held: approvalHeldReason(heldContext),
@@ -6304,12 +6957,19 @@ bus.subscribe((event) => {
6304
6957
  tool: { name: `retrying — attempt ${event.attempt + 1}/${RETRY_MAX_ATTEMPTS} in ${Math.round(event.delayMs / 1000)}s — ${event.reason}`, ok: true },
6305
6958
  });
6306
6959
  break;
6960
+ case "runtime.notice":
6961
+ // informational: the turn carries on. The prefix keeps it a status row
6962
+ // the person always sees (src/lib/activity-runs.ts), never a tool step
6963
+ // hidden with Tool calls off.
6964
+ pushMessage({ role: "bot", kind: "activity", tool: { name: `notice: ${event.message.slice(0, 240)}`, ok: true } });
6965
+ break;
6307
6966
  case "runtime.error":
6308
6967
  pushMessage({
6309
6968
  role: "bot",
6310
6969
  kind: "activity",
6311
6970
  tool: {
6312
- name: `error: ${event.message.slice(0, 160)}`,
6971
+ // a setup error carries its fix, which must not be cut mid-command
6972
+ name: `error: ${event.message.slice(0, event.setup ? 320 : 160)}`,
6313
6973
  ok: false,
6314
6974
  setup: event.setup,
6315
6975
  ...(event.terminal ? { terminal: true } : {}),
@@ -7554,14 +8214,10 @@ async function finalScreenFrame(_botId, threadId) {
7554
8214
  * (generateText — Haiku on Claude, the chat completion endpoint's text
7555
8215
  * path on OpenAI-compatible engines). Null whenever that call cannot run,
7556
8216
  * runs long, or answers with something that is not a plain short title;
7557
- * the caller keeps the snippet it already applied. */
7558
- async function generateThreadTitle(provider, text) {
7559
- const prompt = [
7560
- "Name the conversation that begins with the message below.",
7561
- "Reply with only a short title: 3 to 6 words, plain text, no quotes, no trailing period.",
7562
- "Message:",
7563
- text.trim().slice(0, 1_500),
7564
- ].join("\n");
8217
+ * the caller keeps the snippet it already applied. "conversation" names an
8218
+ * existing thread from titleConversationExcerpt (Regenerate title). */
8219
+ async function generateThreadTitle(provider, text, source = "first-message") {
8220
+ const prompt = threadTitlePrompt(text, source);
7565
8221
  const expiry = new AbortController();
7566
8222
  let timer;
7567
8223
  try {
@@ -7585,6 +8241,8 @@ async function generateThreadTitle(provider, text) {
7585
8241
  clearTimeout(timer);
7586
8242
  }
7587
8243
  }
8244
+ /** Threads whose Regenerate title one-shot is still running: one at a time. */
8245
+ const titleRegenerations = new Set();
7588
8246
  // ── turn dispatch (upstream ProviderCommandReactor, miniature) ──────────
7589
8247
  async function startTurn(botId, text, opts) {
7590
8248
  workspaceMaintenance.assertAvailable();
@@ -7671,6 +8329,15 @@ async function startTurn(botId, text, opts) {
7671
8329
  const policyRefusal = policyModelRefusal(instance);
7672
8330
  if (policyRefusal)
7673
8331
  throw Object.assign(new Error(policyRefusal), { status: 409, code: "managed_policy" });
8332
+ // On a Cloud home a guest's turn never gets a shell or reads outside its
8333
+ // own folder (docs/cloud-pro.md): an engine that cannot run it that way is
8334
+ // refused, in one plain line, before anything is recorded.
8335
+ // (The thread is idle here: a mark a turn that never started left is stale.)
8336
+ guestDrivenTurns.delete(threadId);
8337
+ const guestConfined = cloudGuestDriven(threadId) || cloudGuestDrivenHandoff(opts?.coordination?.id);
8338
+ if (guestConfined && instance.adapter.capabilities.guestTurns !== "confined") {
8339
+ throw Object.assign(new Error(guestEngineRefusal(threadId)), { status: 409, code: "guest_engine" });
8340
+ }
7674
8341
  // Resolve only transport tags from this newly submitted text. The original
7675
8342
  // string remains the durable message. Native-image providers get a
7676
8343
  // path-free prompt and bounded inputs instead of needing a Read tool;
@@ -7691,11 +8358,9 @@ async function startTurn(botId, text, opts) {
7691
8358
  const titled = store.titleTaskFromFirstMessage(bot.id, resolvedImages.text, threadId);
7692
8359
  // The snippet is only the fallback name. A cheap one-shot may trade it
7693
8360
  // for a title a person would have typed, but never on a peer-opened
7694
- // row: adoption recognises those by the exact title their assignment
7695
- // gave them (openingRequestTitle), and a generated one would break the
7696
- // comparison it renames under. Everywhere else, the swap happens only
7697
- // while the row still carries the snippet — a rename by the person or
7698
- // by adoption has already broken that equality by then.
8361
+ // row: its title belongs to the assigned work. Everywhere else, the
8362
+ // swap happens only while the row still carries the snippet — a rename
8363
+ // by a person has already broken that equality by then.
7699
8364
  const snippet = titled?.title;
7700
8365
  if (titled && snippet && !titled.openedBy?.botId && llmThreadTitlesEnabled(cfg) && instance.generateText) {
7701
8366
  void generateThreadTitle(instance, resolvedImages.text)
@@ -7772,6 +8437,9 @@ async function startTurn(botId, text, opts) {
7772
8437
  // in the background — boat provisioning can take ~90s and must never
7773
8438
  // hang the HTTP request
7774
8439
  const dispatchClaimId = randomUUID();
8440
+ // Guest-driven for as long as it runs, wherever it runs (cloudGuestDriven).
8441
+ if (guestConfined)
8442
+ guestDrivenTurns.set(threadId, dispatchClaimId);
7775
8443
  const resourceOwner = { threadId, generation: dispatchClaimId };
7776
8444
  turnResourceOwners.set(threadId, resourceOwner);
7777
8445
  directTurnGenerationByThread.set(threadId, dispatchClaimId);
@@ -7786,6 +8454,10 @@ async function startTurn(botId, text, opts) {
7786
8454
  requestGenerations.add(dispatchClaimId);
7787
8455
  directRequestOwners.set(threadId, { generation: dispatchClaimId, messageId: requestMessageId,
7788
8456
  generations: requestGenerations, turnId: null, ...(opts?.automationSource ? { automation: opts.automationSource } : {}) });
8457
+ // On a Cloud home, a turn that is not provably the owner's leaves this
8458
+ // bot's memory "pending" until it ends (server/lending-memory.ts).
8459
+ if (CLOUD_HOME && cloudHomeLendingRefusal(cloudLendingTurn({ botId, threadId, generation: dispatchClaimId })) !== null)
8460
+ noteForeignTurn(botId, threadId, dispatchClaimId);
7789
8461
  // An unknown control-plane wake may invalidate a prior final, but it must
7790
8462
  // never acquire authority by guessing the latest user as its origin.
7791
8463
  const pendingSource = requestMessageId ?? store.activePath(threadId).findLast(message => message.role === "user")?.id;
@@ -7911,9 +8583,9 @@ async function startTurn(botId, text, opts) {
7911
8583
  const externalUpdate = Boolean(opts?.coordination?.resumed || unseen?.some((m) => m.keep));
7912
8584
  // What a resumed session keeps from its launch: the standing instructions
7913
8585
  // (tools, servers and — for Claude — the model are passed on every launch),
7914
- // plus whatever this engine can only set when a session starts. Codex's
7915
- // thread/resume sends no model selection, and an effort it is not sent stays
7916
- // at the thread's last value, so both belong to the session there. An
8586
+ // plus settings that a live turn cannot change. Codex now reasserts its
8587
+ // model/provider on resume, but an in-flight turn and an omitted effort
8588
+ // retain the prior selection, so both still belong to the session here. An
7917
8589
  // external update that finds any of it changed since the session started
7918
8590
  // gets the fresh session and replay it always got, rather than a resume.
7919
8591
  const persistentConfig = [bot.name, bot.title, bot.description, sectionContextSystemPrompt(bot.section),
@@ -8014,7 +8686,7 @@ async function startTurn(botId, text, opts) {
8014
8686
  // composio — only to a driver that can mount them. Their tools are
8015
8687
  // never pre-allowed, so every call rides the normal permission flow.
8016
8688
  if (instance.adapter.capabilities.customMcp === true) {
8017
- const custom = engineMcpServers(bot);
8689
+ const custom = await withMcpSignIn(engineMcpServers(bot), mcpOAuth);
8018
8690
  if (Object.keys(custom).length)
8019
8691
  integrations.custom = custom;
8020
8692
  }
@@ -8026,7 +8698,8 @@ async function startTurn(botId, text, opts) {
8026
8698
  if (worksInWorkspace) {
8027
8699
  ensureWorkspace(bot.id);
8028
8700
  // baseline for the journal's turn-boundary diff (see the bus hook)
8029
- beginMemoryTurn(bot.id, threadId);
8701
+ if (bot.memoryEnabled !== false)
8702
+ beginMemoryTurn(bot.id, threadId);
8030
8703
  }
8031
8704
  const privateWorkspace = worksInWorkspace ? ensureTaskWorkspace(bot.id, threadId) : undefined;
8032
8705
  const skillInstructions = renderSkillInstructions(selectedSkills, {
@@ -8042,8 +8715,13 @@ async function startTurn(botId, text, opts) {
8042
8715
  // bot's folder for a task that runs elsewhere.
8043
8716
  if (opts?.runOn === "cloud")
8044
8717
  store.pinTaskCwd(bot.id, threadId, undefined, { none: true });
8718
+ // On a Cloud home a conversation a guest opened is pinned to its own
8719
+ // folder when it first runs, not the bot's project folder the owner's
8720
+ // conversations share (what it leaves there reaches none of them). One
8721
+ // that ran before keeps its pin, unless the session that opened it was
8722
+ // revoked: then it was unpinned at boot (server/cloud-owner.ts).
8045
8723
  const pinnedCwd = privateWorkspace && opts?.runOn !== "cloud"
8046
- ? store.pinTaskCwd(bot.id, threadId, privateWorkspace)
8724
+ ? store.pinTaskCwd(bot.id, threadId, privateWorkspace, { privateOnly: cloudGuestOpened(threadId) })
8047
8725
  : null;
8048
8726
  const cwd = pinnedCwd ?? undefined;
8049
8727
  if (cwd && !claimTurnResource(resourceOwner, workspaceResource(cwd))) {
@@ -8133,7 +8811,7 @@ async function startTurn(botId, text, opts) {
8133
8811
  * desktop, not whatever localVmTargetForBot resolves to by the time
8134
8812
  * the first screen call arrives. */
8135
8813
  const claimAutoLocalVm = async (claimThreadId, pinnedTarget) => {
8136
- const localVmTarget = pinnedTarget ?? localVmTargetForThread(bot.id, claimThreadId);
8814
+ const localVmTarget = pinnedTarget ?? localVmThreadTargets.get(claimThreadId) ?? { ...localVmTargetForThread(bot.id, claimThreadId) };
8137
8815
  await bindTurnComputer(resourceOwner, `computer:vm:${localVmTarget.key}`, true);
8138
8816
  if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) {
8139
8817
  throw new Error("this Local VM is being started, stopped, or replaced — wait for setup to finish");
@@ -8157,6 +8835,8 @@ async function startTurn(botId, text, opts) {
8157
8835
  // without this the exclusive lease would sit held for the rest of a
8158
8836
  // turn that never got the VM — the very serialisation #1361 removes.
8159
8837
  const dropLease = () => {
8838
+ if (localVmThreadTargets.get(claimThreadId) !== localVmTarget)
8839
+ return;
8160
8840
  localVmLeaseFor(localVmTarget).release(claimThreadId);
8161
8841
  if (localVmActiveThreads.get(localVmTarget.key) === claimThreadId)
8162
8842
  localVmActiveThreads.delete(localVmTarget.key);
@@ -8239,7 +8919,7 @@ async function startTurn(botId, text, opts) {
8239
8919
  if (!mountsComputerMcp || instance.adapter.capabilities.remoteAgent === true) {
8240
8920
  if (!strict)
8241
8921
  return false;
8242
- throw new Error("this model engine cannot use the Local VM — choose Claude or an ACP engine, or select another computer destination");
8922
+ throw new Error("this model cannot use the Local VM — choose Claude or an ACP model provider, or select another computer destination");
8243
8923
  }
8244
8924
  if (!strict && localVmMode(cfg) === "pool") {
8245
8925
  // localVmTargetForThread records pool affinity, so a turn the
@@ -8250,7 +8930,7 @@ async function startTurn(botId, text, opts) {
8250
8930
  if (!localVmSeen.has(poolCandidate.key) && !opts?.automationSource)
8251
8931
  return false;
8252
8932
  }
8253
- const localVmTarget = localVmTargetForThread(bot.id, threadId);
8933
+ const localVmTarget = { ...localVmTargetForThread(bot.id, threadId) };
8254
8934
  let lazyReadyVm = null;
8255
8935
  if (!strict) {
8256
8936
  // Nothing this process has ever seen for this target, and nobody is
@@ -8315,7 +8995,8 @@ async function startTurn(botId, text, opts) {
8315
8995
  catch (error) {
8316
8996
  if (strict)
8317
8997
  throw error;
8318
- releaseLocalVmThread(threadId);
8998
+ if (directTurnGenerationByThread.get(threadId) === dispatchClaimId)
8999
+ releaseLocalVmThread(threadId);
8319
9000
  return false;
8320
9001
  }
8321
9002
  };
@@ -8455,9 +9136,9 @@ async function startTurn(botId, text, opts) {
8455
9136
  autoVpsProblem &&
8456
9137
  !computerSelectionTurns.has(threadId)) {
8457
9138
  const hint = opts?.automationSource
8458
- ? "This scheduled run tried to start the VPS computer and could not reach it. Check the VPS connection in App Settings → Connections."
9139
+ ? "This scheduled run tried to start the VPS computer and could not reach it. Check the VPS connection in Settings → API keys."
8459
9140
  : bot.autoStartVps
8460
- ? "Check the VPS connection in App Settings → Connections."
9141
+ ? "Check the VPS connection in Settings → API keys."
8461
9142
  : "Open Computer and enable Start VPS automatically, or choose Cloud to start it manually.";
8462
9143
  throw new Error(`${autoVpsProblem}. ${hint}`);
8463
9144
  }
@@ -8503,7 +9184,7 @@ async function startTurn(botId, text, opts) {
8503
9184
  const credentialPrompt = integrations.agents ? CREDENTIAL_PROMPT + (boundedCoordination ? "" : THREADS_PROMPT) : "";
8504
9185
  const routinePrompt = integrations.agents ? ROUTINE_PROMPT : "";
8505
9186
  const profilePrompt = integrations.agents ? PROFILE_PROMPT : "";
8506
- const recallPrompt = integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "";
9187
+ const recallPrompt = integrations.agents && bot.memoryEnabled !== false ? SESSION_SEARCH_SYSTEM_PROMPT : "";
8507
9188
  const learnPrompt = skillAuthoring ? LEARN_PROMPT : "";
8508
9189
  // (activeVpsThreads was already claimed above, before the provision or
8509
9190
  // reuse await, so the backend guards saw this turn the whole time.)
@@ -8636,8 +9317,8 @@ async function startTurn(botId, text, opts) {
8636
9317
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
8637
9318
  // what the bot said lately in its other conversations, so a task
8638
9319
  // never redoes — or forgets — what another one already did
8639
- { id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(recentWorkSources(bot), bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId })) },
8640
- { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace }) },
9320
+ { id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(recentWorkSources(bot), bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId, ...recentWorkFilter() })) },
9321
+ { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace, enabled: bot.memoryEnabled !== false }) },
8641
9322
  { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id) : "" },
8642
9323
  { id: "skill-instructions", label: "Skill instructions", text: skillInstructions },
8643
9324
  { id: "playbooks", label: "Playbooks", text: packagePlaybooks },
@@ -8681,7 +9362,8 @@ async function startTurn(botId, text, opts) {
8681
9362
  text: withRecalled(recalled, dispatchContext.turnText),
8682
9363
  refreshSystemPrompt: true,
8683
9364
  images: turnImages,
8684
- approvalMode: approvalModeForTurn(bot, commsDepth > 0),
9365
+ approvalMode: approvalModeForTurn(bot, commsDepth > 0, threadId),
9366
+ ...(guestConfined ? { guestConfined: true, confinedWhy: confinedWhy(threadId) } : {}),
8685
9367
  model,
8686
9368
  effort,
8687
9369
  variant,
@@ -8972,7 +9654,10 @@ function routineRunFallbackText(card) {
8972
9654
  : card.status === "queued" && card.deferredAt != null
8973
9655
  ? "deferred: target busy"
8974
9656
  : card.status);
8975
- return `Routine “${card.routineName}” ${state}`;
9657
+ // Clients without the card (older phones) draw only this line, so it
9658
+ // carries what the run said, not just that it finished.
9659
+ const detail = card.summary ?? card.error;
9660
+ return `Routine “${card.routineName}” ${state}${detail ? `\n\n${detail}` : ""}`;
8976
9661
  }
8977
9662
  /** Upsert one durable lifecycle card per run. Replaying the same transition,
8978
9663
  * including restart recovery, patches the existing run id instead of adding
@@ -9017,9 +9702,14 @@ function syncRoutineRunToSource(run) {
9017
9702
  const execution = store.taskByThread(run.botId, run.threadId);
9018
9703
  const freshExecution = execution && run.status === "running" && !execution.routineRunId &&
9019
9704
  store.messagesFor(run.threadId).length === 0;
9020
- if (execution && (freshExecution || (execution.routineRunId === run.id && execution.unread))) {
9705
+ if (execution && freshExecution) {
9021
9706
  store.patchTask(run.botId, run.threadId, { routineRunId: run.id, unread: false });
9022
9707
  }
9708
+ else if (execution?.routineRunId === run.id && execution.unread) {
9709
+ // Already this run. Clear the dot without rewriting the tag, so a
9710
+ // promoted thread (tag cleared, transcript present) stays visible.
9711
+ store.patchTask(run.botId, run.threadId, { unread: false });
9712
+ }
9023
9713
  }
9024
9714
  // Merely queueing/running is ambient progress. Attention and terminal
9025
9715
  // states become unread in the conversation where the user asked for them.
@@ -9111,8 +9801,13 @@ routines = new RoutineManager({
9111
9801
  }
9112
9802
  return groupIsWorking(group) || coordinator.busy ? "busy" : "ready";
9113
9803
  },
9114
- createTask: (botId, title, activate = false) => {
9804
+ createTask: (botId, title, activate = false, routineId) => {
9115
9805
  const task = store.createTask(botId, title, activate);
9806
+ // On a Cloud home a run's conversation is opened, like its results
9807
+ // conversation, by whoever wrote the routine: a run of one that is
9808
+ // nobody's is confined to a folder of its own, never the bot's project.
9809
+ if (task && CLOUD_HOME && routineId)
9810
+ threadStarters.set(task.threadId, routineOpener(routineId));
9116
9811
  const bot = store.bot(botId);
9117
9812
  if (task && bot)
9118
9813
  broadcast({ kind: "bot", bot: publicBot(bot) });
@@ -9140,7 +9835,12 @@ routines = new RoutineManager({
9140
9835
  // explicit resultsThreadId must be a visible task owned by the running bot.
9141
9836
  if (!forceNew && routineSourceOwner(routine))
9142
9837
  return routine.resultsThreadId;
9143
- return store.createTask(routine.botId, `${routine.name} · Results`, false)?.threadId;
9838
+ const threadId = store.createTask(routine.botId, `${routine.name} · Results`, false)?.threadId;
9839
+ // On a Cloud home the results conversation is opened by whoever wrote the
9840
+ // routine: it carries their name for it (cloudGuestOpened).
9841
+ if (threadId && CLOUD_HOME)
9842
+ threadStarters.set(threadId, routineOpener(routine.id));
9843
+ return threadId;
9144
9844
  },
9145
9845
  discardResultsThread: (botId, threadId) => {
9146
9846
  const task = store.taskByThread(botId, threadId);
@@ -9202,6 +9902,63 @@ routines = new RoutineManager({
9202
9902
  notify(buildNotification("routine-deferred", notificationBot, routineSourceThread(run) ?? bot.threadId, detail));
9203
9903
  },
9204
9904
  });
9905
+ // A Cloud home is personal (server/cloud-owner.ts): every stored session
9906
+ // without admin scope is revoked and what it opened or wrote is nobody's;
9907
+ // once (and after a restore) the earlier keys are settled. Before anything
9908
+ // is served, and before any routine can run.
9909
+ if (CLOUD_HOME && CLOUD_OWNER_KEY && cloudRoutineAuthors) {
9910
+ const authors = cloudRoutineAuthors;
9911
+ const routineById = (id) => routines?.listRoutines().find((routine) => routine.id === id);
9912
+ cloudEarlierOwners = settleCloudOwnership({
9913
+ file: join(DATA_DIR, "cloud-owner.json"),
9914
+ machineId: CLOUD_HOME.machineId,
9915
+ ownerKey: CLOUD_OWNER_KEY,
9916
+ nobodyKey: CLOUD_NOBODY_KEY,
9917
+ sessions,
9918
+ personKey,
9919
+ starters: threadStarters,
9920
+ writers: authors,
9921
+ routines: {
9922
+ ids: () => (routines?.listRoutines() ?? []).map((routine) => routine.id),
9923
+ writer: (id) => authors.writer(id),
9924
+ fingerprinted: (id) => { const routine = routineById(id); return Boolean(routine) && authors.authored(id, routine); },
9925
+ resultsOpener: (id) => { const results = routineById(id)?.resultsThreadId; return results ? threadStarters.get(results) : undefined; },
9926
+ shape: (id) => { const routine = routineById(id); return routine ? approvalShape(routine) : undefined; },
9927
+ name: (id, person) => authors.wrote(id, person),
9928
+ pause: (ids) => { for (const id of ids)
9929
+ routines?.update(id, { enabled: false }); },
9930
+ },
9931
+ unpin: (threadIds) => { for (const threadId of threadIds)
9932
+ store.unpinTaskCwd(threadId); },
9933
+ lines: () => {
9934
+ const senders = new Set(), answerers = new Set();
9935
+ const approvals = [];
9936
+ const threads = new Set([...store.bots.flatMap((bot) => store.tasks(bot.id).map((task) => task.threadId)), ...store.groups.map((group) => group.threadId)]);
9937
+ for (const threadId of threads) {
9938
+ for (const line of store.messagesFor(threadId)) {
9939
+ const sender = linePersonKey(line);
9940
+ if (sender)
9941
+ senders.add(sender);
9942
+ const answerer = line.card?.answeredBy?.kind === "session" ? line.card.answeredBy.person : undefined;
9943
+ if (answerer)
9944
+ answerers.add(answerer);
9945
+ // A routine a bot proposed, created when this person allowed its card.
9946
+ const request = line.card?.routineRequest;
9947
+ if (answerer && line.card?.answered === "allow" && request?.operation.action === "create" && request.resultId) {
9948
+ const definition = request.operation.routine;
9949
+ approvals.push([request.resultId, answerer, approvalShape({ prompt: definition.instructions, botId: request.operation.forBot?.botId ?? request.botId,
9950
+ runOn: definition.runOn, schedule: asSchedule(definition.schedule, 0) })]);
9951
+ }
9952
+ }
9953
+ }
9954
+ return { senders, answerers, approvals };
9955
+ },
9956
+ ...(restoredAtStart ? { restoredNow: restoredAtStart } : {}),
9957
+ ...(workspaceRestore.restored && workspaceRestore.id ? { lastRestore: workspaceRestore.id } : {}),
9958
+ log: (line) => console.log(line),
9959
+ });
9960
+ ownerOnlyCache.clear();
9961
+ }
9205
9962
  orgLibrary = new OrgLibrary({
9206
9963
  dataDir: DATA_DIR,
9207
9964
  store,
@@ -9276,7 +10033,7 @@ async function cloudRoutineReadiness(botId, threadId) {
9276
10033
  return { ready: false, reason: "The Cloud VM runner is unavailable. Restart OpenMausBot and try again." };
9277
10034
  try {
9278
10035
  if ((await instance.snapshot()).state !== "available") {
9279
- return { ready: false, reason: "The target bot's model engine is not ready to use the Boat cloud computer." };
10036
+ return { ready: false, reason: "The target bot's model is not ready to use the Boat cloud computer." };
9280
10037
  }
9281
10038
  const teamComputer = inheritedTeamComputer(bot);
9282
10039
  const ownerId = teamComputer ? teamComputerOwner(teamComputer.id) : bot.id;
@@ -9712,7 +10469,7 @@ async function resolveAndSendTeamSetup(res, args, ownerReview) {
9712
10469
  if (!card)
9713
10470
  return false;
9714
10471
  if (args.behavior === "allow" && !ownerReview) {
9715
- json(res, 403, { error: "Approve team setup or deletion from the desktop app or a paired owner device. In a local browser, wait until every bot is idle." });
10472
+ json(res, 403, { error: "Approve team setup or deletion from the desktop app or a paired Full-access device. In a local browser, wait until every bot is idle." });
9716
10473
  return true;
9717
10474
  }
9718
10475
  if (args.behavior === "allow" && !card.answered && !card.dismissed && !card.expired) {
@@ -9829,9 +10586,15 @@ function sendRoutineResolution(res, result) {
9829
10586
  });
9830
10587
  return true;
9831
10588
  }
9832
- function resolveAndSendRoutine(res, args) {
10589
+ function resolveAndSendRoutine(res, args, ownersAnswer = false) {
9833
10590
  const card = store.messagesFor(args.threadId).find((message) => message.card?.requestId === args.requestId && message.card.routineRequest)?.card;
10591
+ // On a Cloud home a routine the owner approved on its card is theirs; one
10592
+ // it only changed stays whosever it was (cloudOwnerApplied).
10593
+ const operation = card?.routineRequest?.operation;
10594
+ const wasOwners = operation && operation.action !== "create" ? cloudRoutineIsOwners(operation.routineId) : false;
9834
10595
  const result = routineRequests.resolve(args);
10596
+ if (ownersAnswer && result.claimed && result.state === "applied")
10597
+ cloudOwnerApplied(result.action, result.resultId, wasOwners);
9835
10598
  if (result.claimed &&
9836
10599
  (result.state === "applied" || result.state === "denied")) {
9837
10600
  appendDecision(DATA_DIR, {
@@ -9967,12 +10730,25 @@ const webhooks = new WebhookManager({
9967
10730
  findRun: (webhookId, deliveryId) => routines.webhookRunReceipt(webhookId, deliveryId),
9968
10731
  cancelQueued: (webhookId, message) => routines.cancelQueuedWebhook(webhookId, message),
9969
10732
  pendingRuns: (webhookId) => routines.activeWebhookRunCount(webhookId),
9970
- // delivery:"post" webhooks land in the bot's main chat as the bot's own message.
9971
- post: (botId, text) => {
9972
- const bot = store.bot(botId);
9973
- if (!bot)
10733
+ // delivery:"post" webhooks land in a dedicated "Updates" task, never
10734
+ // bot.threadId (the bot's currently-selected task) -- see
10735
+ // resolvePostThread below. Fixes
10736
+ // https://github.com/milind-soni/OpenMausBot/issues/2071: a post used to
10737
+ // land wherever the owner (or another automation) had last switched
10738
+ // that bot's selection, including a live conversation.
10739
+ post: (botId, threadId, text) => {
10740
+ if (!store.bot(botId))
9974
10741
  return;
9975
- store.appendMessage(bot.threadId, { role: "bot", kind: "text", text });
10742
+ store.appendMessage(threadId, { role: "bot", kind: "text", text });
10743
+ },
10744
+ // Mirrors resolveResultsThread's routines wiring a few hundred lines up
10745
+ // in this same file: create-on-first-use, never activated (so it never
10746
+ // steals the bot's live selection the way POST /api/bots/:id/tasks
10747
+ // does), reused forever after via trigger.resultsThreadId.
10748
+ resolvePostThread: (trigger, forceNew) => {
10749
+ if (!forceNew && trigger.resultsThreadId)
10750
+ return trigger.resultsThreadId;
10751
+ return store.createTask(trigger.botId, "Updates", false)?.threadId;
9976
10752
  },
9977
10753
  });
9978
10754
  let webhookIngress = null;
@@ -10144,7 +10920,7 @@ setupRetry = 0) {
10144
10920
  // delegated Full elevation makes the two disagree by construction: every
10145
10921
  // such room turn then reads as "settings changed", retries once, and
10146
10922
  // settles as busy without ever dispatching.
10147
- const preparedApprovalMode = roomTurnApprovalMode(bot, orchestration);
10923
+ const preparedApprovalMode = roomTurnApprovalMode(bot, threadId, orchestration);
10148
10924
  const preparedSelection = { ...bot.modelSelection };
10149
10925
  const preparedComposio = bot.composio;
10150
10926
  const instance = turnInstance(bot);
@@ -10154,8 +10930,14 @@ setupRetry = 0) {
10154
10930
  return true;
10155
10931
  }
10156
10932
  const roomPolicyRefusal = instance ? policyModelRefusal(instance) : undefined;
10157
- if (!instance || roomPolicyRefusal) {
10158
- const message = roomPolicyRefusal ?? `${bot.name}'s model is unavailable`;
10933
+ // A room turn a guest drives on a Cloud home is confined like a guest's
10934
+ // own conversation, or refused on an engine that cannot be.
10935
+ if (!groupSpeakers.has(threadId))
10936
+ guestDrivenTurns.delete(threadId);
10937
+ const roomGuestConfined = cloudGuestDriven(threadId) || cloudGuestDrivenHandoff(orchestration?.roomHandoffId);
10938
+ const roomGuestRefusal = roomGuestConfined && instance?.adapter.capabilities.guestTurns !== "confined" ? guestEngineRefusal(threadId) : undefined;
10939
+ if (!instance || roomPolicyRefusal || roomGuestRefusal) {
10940
+ const message = roomPolicyRefusal ?? roomGuestRefusal ?? `${bot.name}'s model is unavailable`;
10159
10941
  store.appendMessage(threadId, {
10160
10942
  role: "bot",
10161
10943
  kind: "activity",
@@ -10188,6 +10970,11 @@ setupRetry = 0) {
10188
10970
  return true;
10189
10971
  }
10190
10972
  const internalGeneration = beginInternalCapabilityGeneration(threadId);
10973
+ if (roomGuestConfined)
10974
+ guestDrivenTurns.set(threadId, internalGeneration);
10975
+ // A room turn is never provably the owner's alone (server/lending-memory.ts).
10976
+ if (CLOUD_HOME)
10977
+ noteForeignTurn(bot.id, threadId, internalGeneration);
10191
10978
  const resourceOwner = { threadId, generation: internalGeneration };
10192
10979
  turnResourceOwners.set(threadId, resourceOwner);
10193
10980
  let roomVmTarget = null;
@@ -10294,7 +11081,7 @@ setupRetry = 0) {
10294
11081
  }
10295
11082
  // user-configured MCP servers: same gating as the 1:1 site above.
10296
11083
  if (instance.adapter.capabilities.customMcp === true) {
10297
- const custom = engineMcpServers(bot);
11084
+ const custom = await withMcpSignIn(engineMcpServers(bot), mcpOAuth);
10298
11085
  if (Object.keys(custom).length)
10299
11086
  integrations.custom = custom;
10300
11087
  }
@@ -10316,7 +11103,7 @@ setupRetry = 0) {
10316
11103
  if (!readyGroup || !stillOwnsThread || !readyGroup.memberIds.includes(readyBot.id))
10317
11104
  return false;
10318
11105
  const setupChanged = turnInstance(readyBot) !== instance ||
10319
- roomTurnApprovalMode(readyBot, orchestration) !== preparedApprovalMode ||
11106
+ roomTurnApprovalMode(readyBot, threadId, orchestration) !== preparedApprovalMode ||
10320
11107
  readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
10321
11108
  readyBot.modelSelection.model !== preparedSelection.model ||
10322
11109
  readyBot.modelSelection.effort !== preparedSelection.effort ||
@@ -10520,7 +11307,7 @@ setupRetry = 0) {
10520
11307
  // Claim the same lease as direct turns before asynchronous VM setup.
10521
11308
  if (readyBot.computer === "vm") {
10522
11309
  if (instance.adapter.capabilities.computerMcp !== true || instance.adapter.capabilities.remoteAgent === true) {
10523
- throw new Error("this model engine cannot use the Local VM");
11310
+ throw new Error("this model cannot use the Local VM");
10524
11311
  }
10525
11312
  // A distinct identity fences cleanup even in shared mode on the same room thread.
10526
11313
  const target = { ...localVmTargetForThread(readyBot.id, threadId) };
@@ -10613,7 +11400,7 @@ setupRetry = 0) {
10613
11400
  const worksInWorkspace = supportsWorkspaceFiles(instance.driverKind);
10614
11401
  const workspace = worksInWorkspace ? ensureWorkspace(bot.id) : undefined;
10615
11402
  // a room member's memory writes are journaled the same as a 1:1 turn's
10616
- if (workspace)
11403
+ if (workspace && bot.memoryEnabled !== false)
10617
11404
  beginMemoryTurn(bot.id, threadId);
10618
11405
  // The room's folder pins here — on the first turn that actually
10619
11406
  // dispatches, not at PATCH time — so a folder set on a never-used room
@@ -10621,19 +11408,22 @@ setupRetry = 0) {
10621
11408
  // has its folder moved underneath it. Off-host members skip the folder
10622
11409
  // but must not decide the pin: the room's desk is a property of the
10623
11410
  // room, not of whichever member happened to speak first.
10624
- const cwd = groupTurnCwd(workspace, () => store.pinGroupCwd(readyGroup.id, threadId));
11411
+ // A room a guest opened on a Cloud home works in a folder of its own too.
11412
+ const cwd = workspace && cloudGuestOpened(threadId)
11413
+ ? ensureTaskWorkspace(bot.id, threadId)
11414
+ : groupTurnCwd(workspace, () => store.pinGroupCwd(readyGroup.id, threadId));
10625
11415
  {
10626
11416
  const drift = checkSoulDrift(bot.id, bot.soul ?? "", bot.soulHash ?? "");
10627
11417
  if (drift.drift !== Boolean(bot.soulDrift))
10628
11418
  store.patchBot(bot.id, { soulDrift: drift.drift });
10629
11419
  }
10630
- const roomMemory = memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace });
11420
+ const roomMemory = memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace, enabled: bot.memoryEnabled !== false });
10631
11421
  // The same brief a 1:1 turn gets: what this member said lately in its
10632
11422
  // other conversations, so a standup is answered from what happened. A
10633
11423
  // brief can carry a private chat into the room; as with session_search
10634
11424
  // (#754) the room is told, once per source thread, rather than the
10635
11425
  // crossing being blocked.
10636
- const recentLines = recentWork(recentWorkSources(bot), bot, { userName, currentThreadId: threadId });
11426
+ const recentLines = recentWork(recentWorkSources(bot), bot, { userName, currentThreadId: threadId, ...recentWorkFilter() });
10637
11427
  const roomComputerPromptKind = resolveComputerPromptKind({
10638
11428
  kind: roomTeamComputer || roomComputerKind === "box" ? "box" : roomVmTarget ? "vm" : roomComputerKind,
10639
11429
  driverKind: instance.driverKind, cloudComputerMcp: instance.adapter.capabilities.cloudComputerMcp,
@@ -10659,7 +11449,7 @@ setupRetry = 0) {
10659
11449
  { id: "plan", label: "Surface", text: surfacePrompt({ computer: roomTeamComputer ? "cloud" : roomVmTarget ? "vm" : surfaceOfComputerKind(roomComputerKind), browser: Boolean(integrations.browser) }, { note: roomPlan.note, cloudHome: Boolean(CLOUD_HOME) }) },
10660
11450
  { id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
10661
11451
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
10662
- { id: "recall", label: "Recall", text: integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
11452
+ { id: "recall", label: "Recall", text: integrations.agents && bot.memoryEnabled !== false ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
10663
11453
  { id: "recent", label: "Recent work", text: recentWorkPrompt(recentLines) },
10664
11454
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
10665
11455
  // the room path has always put a newline before memory and trimmed
@@ -10794,7 +11584,8 @@ setupRetry = 0) {
10794
11584
  text: withRecalled(roomRecalled, text),
10795
11585
  refreshSystemPrompt: true,
10796
11586
  images: turnImages,
10797
- approvalMode: roomTurnApprovalMode(readyBot, orchestration),
11587
+ approvalMode: roomTurnApprovalMode(readyBot, threadId, orchestration),
11588
+ ...(roomGuestConfined ? { guestConfined: true, confinedWhy: confinedWhy(threadId) } : {}),
10798
11589
  system: roomSystem.text,
10799
11590
  systemStable: roomSystem.stable,
10800
11591
  systemVolatile: roomSystem.volatile,
@@ -12443,38 +13234,19 @@ function stderrOf(err) {
12443
13234
  const s = err.stderr;
12444
13235
  return typeof s === "string" ? s : Buffer.isBuffer(s) ? s.toString("utf8") : "";
12445
13236
  }
12446
- async function localVmPayload(target) {
13237
+ async function localVmPayload(target, auth) {
12447
13238
  const status = await containerComputerStatus(undefined, undefined, target);
12448
13239
  return {
12449
- ...status,
13240
+ ...localVmViewerStatus(status, auth),
13241
+ stop_reason: localVmStopReason(target.key, status),
12450
13242
  commands: setupCommands(status.runtime, process.platform, target),
12451
- idle_timeout_ms: LOCAL_VM_IDLE_MS,
13243
+ idle_timeout_ms: localVmIdleMs(),
12452
13244
  mode: localVmMode(cfg),
12453
13245
  max_instances: localVmMaxInstances(cfg),
12454
13246
  };
12455
13247
  }
12456
- /** The Local VM a turn is about to use, recreated if the idle timer took it.
12457
- *
12458
- * `LocalVmIdleTimer` REMOVES an unused Local VM rather than pausing it. The
12459
- * turn then failed with "Create the Local VM (App Settings → Local VM)" —
12460
- * which reads like a fault the person must repair by hand, for a container the
12461
- * app itself deleted eight hours earlier. Someone who steps away overnight
12462
- * comes back to an error on their first message.
12463
- *
12464
- * The cloud branch below already does the opposite: an absent boat is
12465
- * provisioned on first use behind a `provisioning` broadcast. This gives the
12466
- * Local VM the same lifecycle for the same reason.
12467
- *
12468
- * Only `missing` is recovered, and only when a fresh `run` is all it takes.
12469
- * Every other problem still surfaces: no runtime installed, no image pulled,
12470
- * `create_supported` false, or an existing container that is stale, unmanaged
12471
- * or unsafe. Those need a decision — install podman, download 1.4 GB, replace
12472
- * a container someone else made — and a stopped container is deliberately not
12473
- * resumed here, because `localVmProblem` says this desktop image cannot safely
12474
- * resume and asks for a recreate rather than a start. Per-bot mode keeps its
12475
- * instance cap; creating past it would quietly do what the lifecycle route
12476
- * refuses.
12477
- */
13248
+ /** Wake an idle desktop, or recreate a missing one from an already prepared
13249
+ * image. Incompatible or unsafe containers still require an explicit decision. */
12478
13250
  async function readyLocalVmForTurn(botId, target, isCurrent = () => true) {
12479
13251
  localVmLifecycleBusy.add(target.key);
12480
13252
  // Fence this target, and the cross-target capacity decision for creates,
@@ -12494,21 +13266,22 @@ async function readyLocalVmForTurn(botId, target, isCurrent = () => true) {
12494
13266
  noteLocalVmSeen(target, status);
12495
13267
  if (!isCurrent())
12496
13268
  return status;
12497
- if (status.ready || !localVmRecreatableOnDemand(status))
13269
+ const action = localVmWakeAction(status);
13270
+ if (status.ready || !action || !status.runtime)
12498
13271
  return status;
12499
13272
  // Another creation is already mid-flight and its container is not yet
12500
13273
  // visible to a count, so the safe answer is the inspected status —
12501
13274
  // exactly what the over-cap path below returns.
12502
- if (!pooled && !ownsProvision)
13275
+ if (action === "run" && !pooled && !ownsProvision)
12503
13276
  return status;
12504
- if (target.key.startsWith("bot:")) {
13277
+ if (action === "run" && target.key.startsWith("bot:")) {
12505
13278
  const count = await existingPerBotLocalVmCount(status.runtime);
12506
13279
  if (!isCurrent() || count >= localVmMaxInstances(cfg))
12507
13280
  return status;
12508
13281
  }
12509
13282
  broadcast({ kind: "computer", botId, state: "provisioning" });
12510
13283
  try {
12511
- status = await containerComputerAction("run", undefined, undefined, target);
13284
+ status = await containerComputerAction(action, undefined, undefined, target);
12512
13285
  }
12513
13286
  catch {
12514
13287
  // Keep the inspected status: its `problem` names the real obstacle,
@@ -12576,7 +13349,10 @@ function configStatus() {
12576
13349
  return {
12577
13350
  xai: { configured: Boolean(cfg.xai?.key) },
12578
13351
  mistral: { configured: Boolean(cfg.mistral?.key) },
12579
- anthropic: { configured: Boolean(cfg.anthropic?.key) },
13352
+ cerebras: { configured: Boolean(cfg.cerebras?.key) },
13353
+ anthropic: { configured: Boolean(cfg.anthropic?.key), everyClaudeBot: cfg.anthropic?.everyClaudeBot !== false },
13354
+ openai: { configured: Boolean(cfg.openai?.key) },
13355
+ openrouter: { configured: Boolean(cfg.openrouter?.key) },
12580
13356
  // a fleet agent on this server means Settings → Workspaces has something to drive
12581
13357
  fleet: { available: fleetAvailable(fleetSocketPath()) },
12582
13358
  // what this build is entitled to, so Settings shows only what works here
@@ -12627,6 +13403,7 @@ function configStatus() {
12627
13403
  localVm: {
12628
13404
  mode: localVmMode(cfg),
12629
13405
  maxInstances: localVmMaxInstances(cfg),
13406
+ idleTimeoutMinutes: localVmIdleTimeoutMinutes(cfg),
12630
13407
  },
12631
13408
  features: {
12632
13409
  skillAuthoring: skillAuthoringEnabled(cfg),
@@ -12641,6 +13418,8 @@ function configStatus() {
12641
13418
  // own Claude Code MCP servers
12642
13419
  claudeUserMcp: claudeUserMcpEnabled(cfg),
12643
13420
  autoRecall: autoRecallEnabled(cfg),
13421
+ // hand-edited config.json only; the thread menu offers Regenerate title from it
13422
+ llmThreadTitles: llmThreadTitlesEnabled(cfg),
12644
13423
  },
12645
13424
  // first-run progress — not a secret; the app decides whether to show
12646
13425
  // the welcome tour from this, never from browser storage
@@ -12649,6 +13428,7 @@ function configStatus() {
12649
13428
  version: cfg.onboarding?.version ?? 0,
12650
13429
  reelSeen: cfg.onboarding?.reelSeen === true,
12651
13430
  hintsSeen: cfg.onboarding?.hintsSeen ?? [],
13431
+ ...(cfg.onboarding?.firstTurnAt ? { firstTurnAt: cfg.onboarding.firstTurnAt } : {}),
12652
13432
  },
12653
13433
  // Which browser this server can give bots: the desktop app's surface,
12654
13434
  // the agent-browser engine, or nothing yet (with the reason).
@@ -12682,7 +13462,14 @@ function mcpServerResponse() {
12682
13462
  // While enrolled with custom servers off, say which servers stay configured
12683
13463
  // but never reach bots, and why. Nothing here is written to config.json.
12684
13464
  const policy = managedPolicy.current();
12685
- const servers = listMcpServers(cfg.mcpServers).map(server => managedPolicy.mcpAllowed(server.name, "url" in server ? server.url : undefined) ? server : { ...server, managedBy: policy.organizationName });
13465
+ const servers = listMcpServers(cfg.mcpServers).map((server) => {
13466
+ if (!("url" in server))
13467
+ return server;
13468
+ const auth = mcpOAuth.authState(server.name, server.url);
13469
+ // the redirect URI to register with a pre-registered sign-in app
13470
+ const listed = server.oauth ? { ...server, oauth: { ...server.oauth, redirectUri: mcpOAuthRedirectUri(server.url) } } : server;
13471
+ return auth === "none" ? listed : { ...listed, auth };
13472
+ }).map(server => managedPolicy.mcpAllowed(server.name, "url" in server ? server.url : undefined) ? server : { ...server, managedBy: policy.organizationName });
12686
13473
  return { servers, ...(policy && !policy.mcp.allowCustom ? { managed: { organizationName: policy.organizationName, allowlist: policy.mcp.allowlist } } : {}) };
12687
13474
  }
12688
13475
  /** Adding or editing a server the organisation has not approved is refused. */
@@ -12697,7 +13484,7 @@ function mcpServerBody(body) {
12697
13484
  if (!body || typeof body !== "object")
12698
13485
  return out;
12699
13486
  const record = body;
12700
- for (const key of ["command", "args", "env", "type", "url", "headers", "enabled"]) {
13487
+ for (const key of ["command", "args", "env", "type", "url", "headers", "oauth", "enabled"]) {
12701
13488
  if (record[key] !== undefined)
12702
13489
  out[key] = record[key];
12703
13490
  }
@@ -12706,7 +13493,7 @@ function mcpServerBody(body) {
12706
13493
  /** Configured MCP servers that may reach this bot's engine. While enrolled,
12707
13494
  * the organisation's allow-list filters them; config.json is never changed. */
12708
13495
  function engineMcpServers(bot) {
12709
- return managedPolicy.filterMcp(customMcpServers(cfg, bot.mcpServers));
13496
+ return withoutPendingSignIn(managedPolicy.filterMcp(customMcpServers(cfg, bot.mcpServers)), mcpOAuth);
12710
13497
  }
12711
13498
  function persistMcpServers(next) {
12712
13499
  saveConfig({ mcpServers: next });
@@ -12720,7 +13507,11 @@ async function describeInstances() {
12720
13507
  const configs = providerConfigs();
12721
13508
  return (await registry.describe()).map((instance) => {
12722
13509
  const entry = configs[instance.instanceId];
12723
- const described = entry?.icon ? { ...instance, icon: entry.icon } : instance;
13510
+ const described = {
13511
+ ...instance,
13512
+ ...(entry?.icon ? { icon: entry.icon } : {}),
13513
+ ...(entry?.access ? { access: entry.access } : {}),
13514
+ };
12724
13515
  if (hostedModels)
12725
13516
  return { ...described, readOnly: true,
12726
13517
  install: undefined, authentication: undefined, cli: undefined, cliCandidates: [],
@@ -12732,8 +13523,16 @@ async function describeInstances() {
12732
13523
  ...described, ...policy, readOnly: true, managed: managedDesktop.info(instance.instanceId),
12733
13524
  install: undefined, authentication: undefined, cli: undefined, cliCandidates: [],
12734
13525
  };
13526
+ // Windows keeps what Antigravity's runtime unpacked whenever it is
13527
+ // stopped by force; its Settings card offers to clear that.
13528
+ if (instance.driverKind === "antigravityAgent" && process.platform === "win32")
13529
+ return { ...described, ...policy, freeUpSpace: true };
12735
13530
  if (entry?.driver !== "claudeAgent")
12736
13531
  return { ...described, ...policy };
13532
+ // Claude on the workspace API key has no account to sign in to.
13533
+ if (instance.instanceId === CLAUDE_API_INSTANCE) {
13534
+ return { ...described, ...policy, authentication: undefined, install: { ...instance.install, signInCommand: undefined } };
13535
+ }
12737
13536
  try {
12738
13537
  const claudeAccount = claudeAccountInfo(instance.instanceId, entry, instance.cli ?? instance.cliDefault ?? "claude");
12739
13538
  return { ...described, ...policy, claudeAccount, install: { ...instance.install, signInCommand: claudeAccount.signInCommand } };
@@ -12872,6 +13671,7 @@ async function reloadProviders() {
12872
13671
  stopScreenPoller(botId, threadId);
12873
13672
  releaseLocalVmThread(threadId);
12874
13673
  releaseTurnResources(owner);
13674
+ endForeignTurns(threadId);
12875
13675
  vpsThreadEnded(botId, threadId);
12876
13676
  watchdog.settle(threadId);
12877
13677
  closeOpenApprovals(threadId);
@@ -12891,6 +13691,7 @@ async function reloadProviders() {
12891
13691
  for (const [threadId, speaker] of rooms) {
12892
13692
  releaseLocalVmThread(threadId);
12893
13693
  releaseTurnResources(turnResourceOwners.get(threadId));
13694
+ endForeignTurns(threadId);
12894
13695
  watchdog.settle(threadId);
12895
13696
  closeOpenApprovals(threadId);
12896
13697
  if (groupSpeakers.get(threadId) !== speaker)
@@ -12922,6 +13723,10 @@ let mcpProbesInFlight = 0;
12922
13723
  // install, and running two self-updates against it would race its files.
12923
13724
  const claudeUpdatesInFlight = new Set();
12924
13725
  // ── HTTP plumbing ─────────────────────────────────────────────────────
13726
+ /** The web UI's pages: never framed by another page (a hidden frame could
13727
+ * otherwise act on a pair or sign-in page unseen), and they send no Referer.
13728
+ * Nothing frames the web UI: the desktop app shows it in its own window. */
13729
+ const PAGE_HEADERS = { "content-security-policy": "frame-ancestors 'none'", "x-frame-options": "DENY", "referrer-policy": "no-referrer" };
12925
13730
  /** The built UI, when this process serves it (OMB_STATIC_DIR: set by the
12926
13731
  * desktop app and by the container image). Public by design: it is the same
12927
13732
  * bundle anyone can download, holds no secrets, and a remote browser must be
@@ -12934,7 +13739,8 @@ function serveStatic(res, path) {
12934
13739
  const file = join(STATIC_DIR, safe);
12935
13740
  try {
12936
13741
  const data = readFileSync(file);
12937
- res.writeHead(200, { "content-type": MIME[extname(file)] ?? "application/octet-stream" });
13742
+ const type = MIME[extname(file)] ?? "application/octet-stream";
13743
+ res.writeHead(200, { "content-type": type, ...(type === "text/html" ? PAGE_HEADERS : {}) });
12938
13744
  res.end(data);
12939
13745
  return true;
12940
13746
  }
@@ -12942,7 +13748,7 @@ function serveStatic(res, path) {
12942
13748
  // SPA fallback
12943
13749
  try {
12944
13750
  const data = readFileSync(join(STATIC_DIR, "index.html"));
12945
- res.writeHead(200, { "content-type": "text/html" });
13751
+ res.writeHead(200, { "content-type": "text/html", ...PAGE_HEADERS });
12946
13752
  res.end(data);
12947
13753
  return true;
12948
13754
  }
@@ -13026,6 +13832,23 @@ ROUTES.push(createBotMemoryRoutes({
13026
13832
  bot: (id) => store.bot(id),
13027
13833
  taskByThread: (botId, threadId) => store.taskByThread(botId, threadId),
13028
13834
  upkeep: memoryUpkeep,
13835
+ ...(lendingMemory ? {
13836
+ lendingReview: {
13837
+ needed: (botId) => {
13838
+ if (!lendingMemory.reconcile(botId, foreignTurnRunning(botId), { cached: true }).changedBySomeoneElse)
13839
+ return null;
13840
+ const { token, changed } = lendingMemory.reviewInfo(botId);
13841
+ return { token, changed };
13842
+ },
13843
+ accept: (botId, token) => lendingMemory.review(botId, foreignTurnRunning(botId), token).ok,
13844
+ // Like Move to Cloud: one of the owner's own devices, never loopback
13845
+ // (on a Cloud home that is a bot's shell, not the owner).
13846
+ owner: cloudOwnerSession,
13847
+ },
13848
+ // The owner's own Memory edits are theirs even while someone else's
13849
+ // conversation runs; anyone else's are judged like a bot's own write.
13850
+ ownersWrite: (auth, botId, write) => cloudOwnerSession(auth) ? lendingMemory.trustedWrite(botId, write) : write(),
13851
+ } : {}),
13029
13852
  aboutMe: () => cfg.profile?.aboutMe ?? "",
13030
13853
  saveAboutMe: (aboutMe) => {
13031
13854
  saveConfig({ profile: { ...cfg.profile, aboutMe } });
@@ -13034,6 +13857,13 @@ ROUTES.push(createBotMemoryRoutes({
13034
13857
  },
13035
13858
  }));
13036
13859
  ROUTES.push(createDeciderRoutes({ decider }));
13860
+ ROUTES.push(createAntigravityLeftoverRoutes({
13861
+ hosted: Boolean(hostedModels),
13862
+ isAntigravity: (instanceId) => registry.get(instanceId)?.driverKind === "antigravityAgent",
13863
+ find: () => findAntigravityLeftovers(),
13864
+ remove: () => removeAntigravityLeftovers(),
13865
+ }));
13866
+ ROUTES.push(desktopViewer.route);
13037
13867
  const toolResults = new ToolResults();
13038
13868
  const handleRequest = async (req, res) => {
13039
13869
  let url;
@@ -13090,7 +13920,7 @@ const handleRequest = async (req, res) => {
13090
13920
  if (method === "GET" && !path.startsWith("/api/") && !path.startsWith("/.well-known/") && serveStatic(res, path))
13091
13921
  return;
13092
13922
  if (method === "GET" && path === "/.well-known/openmausbot/environment") {
13093
- return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !HOSTED_WORKSPACE && emailSignIn.enabled(), sharedComputers: lendingEnabled() }));
13923
+ return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !HOSTED_WORKSPACE && !CLOUD_HOME && emailSignIn.enabled(), sharedComputers: lendingEnabled() }));
13094
13924
  }
13095
13925
  const domainCheck = /^\/\.well-known\/openmausbot\/domain-check\/([a-f0-9]{64})$/.exec(path);
13096
13926
  if (method === "GET" && domainCheck) {
@@ -13100,7 +13930,11 @@ const handleRequest = async (req, res) => {
13100
13930
  }
13101
13931
  // Sign in with an emailed code (server/account-signin.ts). Public like
13102
13932
  // /api/auth/pair, JSON-only for the same reason, and counted against the
13103
- // same per-source lockout so a code cannot be guessed.
13933
+ // same per-source lockout so a code cannot be guessed. Never on a Cloud
13934
+ // home: it is personal, and the owner's devices sign in through the Admin.
13935
+ if (CLOUD_HOME && method === "POST" && (path === "/api/auth/email/start" || path === "/api/auth/email/verify")) {
13936
+ return json(res, 403, { error: CLOUD_PERSONAL_REFUSAL, code: "cloud_personal" });
13937
+ }
13104
13938
  if (method === "POST" && (path === "/api/auth/email/start" || path === "/api/auth/email/verify")) {
13105
13939
  if (!/^application\/json\b/i.test(String(req.headers["content-type"] ?? ""))) {
13106
13940
  return json(res, 415, { error: "send the sign-in request as JSON (content-type: application/json)" });
@@ -13170,13 +14004,27 @@ const handleRequest = async (req, res) => {
13170
14004
  const wantsCookie = body?.cookie === true;
13171
14005
  const label = typeof body?.label === "string" ? body.label : "";
13172
14006
  const attemptId = typeof body?.attemptId === "string" ? body.attemptId : undefined;
13173
- const result = sessions.exchange({ code, label, attemptId, source: requestSource(req), fallbackLabel: labelFromUserAgent(req.headers["user-agent"]) });
14007
+ // A browser sign-in (the OMB Cloud page's "Use in your browser", docs/cloud-pro.md)
14008
+ // redeems only a window opened for one, and only into this browser's cookie.
14009
+ const browser = body?.browser === true;
14010
+ if (browser && body?.preview === true) {
14011
+ // Whose Cloud it signs in to, shown before the person continues. Redeems nothing.
14012
+ const preview = sessions.previewBrowserSignIn(code);
14013
+ return preview ? json(res, 200, preview) : json(res, 401, { error: "this sign-in link has expired or was already used" });
14014
+ }
14015
+ if (browser && !wantsCookie)
14016
+ return json(res, 400, { error: "a browser sign-in sets this browser's cookie; send cookie: true" });
14017
+ const result = sessions.exchange({ code, label, attemptId, browser, source: requestSource(req), fallbackLabel: labelFromUserAgent(req.headers["user-agent"]) });
13174
14018
  if (!result.ok) {
13175
14019
  console.warn(`pairing refused from ${requestSource(req)}: ${result.error}`);
13176
14020
  return json(res, result.status, { error: result.error });
13177
14021
  }
13178
- const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: emailSignIn.enabled(), sharedComputers: lendingEnabled() });
14022
+ const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !CLOUD_HOME && emailSignIn.enabled(), sharedComputers: lendingEnabled() });
13179
14023
  if (wantsCookie) {
14024
+ // A browser sign-in replaces this browser's own session here, if it had one, rather than leaving it behind.
14025
+ const previous = browser ? sessions.authenticate(parseCookies(req.headers.cookie).get(SESSION_COOKIE)) : null;
14026
+ if (previous && previous.id !== result.session.id)
14027
+ sessions.revoke(previous.id);
13180
14028
  const secure = requestOrigin(req)?.startsWith("https://") === true;
13181
14029
  res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, result.token, { secure, maxAgeSeconds: cookieMaxAgeSeconds(result.session) }));
13182
14030
  return json(res, 200, { session: result.session, environment });
@@ -13310,12 +14158,18 @@ const handleRequest = async (req, res) => {
13310
14158
  environmentId: ENVIRONMENT_ID,
13311
14159
  // the account behind the session, when it came from a sign-in
13312
14160
  ...(auth.session.email ? { email: auth.session.email } : {}),
14161
+ // whose OMB Cloud a browser sign-in signed in to
14162
+ ...(auth.session.owner ? { owner: auth.session.owner } : {}),
13313
14163
  // a hosted team workspace: the web UI's first run skips the
13314
14164
  // desktop-only beats there. Absent everywhere else.
13315
14165
  ...(HOSTED_WORKSPACE ? { hosted: true } : {}),
13316
14166
  // an OMB Cloud home: the web UI's first run is its engine
13317
14167
  // sign-in (docs/cloud-pro.md). Absent everywhere else.
13318
14168
  ...(CLOUD_HOME ? { cloudHome: true } : {}),
14169
+ // a device that is not one of the owner's own on a Cloud home
14170
+ // writes only in conversations it opened: the composer offers
14171
+ // a new conversation everywhere else.
14172
+ ...(CLOUD_HOME && !cloudOwnerSession(auth) ? { cloudGuest: true, openedThreads: threadStarters.threadsOf(actorKey(auth), 2_000) } : {}),
13319
14173
  });
13320
14174
  }
13321
14175
  if (method === "POST" && path === "/api/auth/stream-ticket") {
@@ -13339,7 +14193,16 @@ const handleRequest = async (req, res) => {
13339
14193
  }
13340
14194
  const requested = body?.scopes;
13341
14195
  const scopes = Array.isArray(requested) ? requested.filter((v) => v === "admin" || v === "client") : undefined;
13342
- const opened = sessions.openPairing({ label: typeof body?.label === "string" ? body.label : undefined, scopes });
14196
+ let opened;
14197
+ try {
14198
+ opened = sessions.openPairing({ label: typeof body?.label === "string" ? body.label : undefined, scopes });
14199
+ }
14200
+ catch (error) {
14201
+ // A Cloud home is personal: a device paired to it has admin scope (sessions.requireAdmin).
14202
+ if (error.code === "cloud_personal")
14203
+ return json(res, 403, { error: error.message, code: "cloud_personal" });
14204
+ throw error;
14205
+ }
13343
14206
  const origin = requestOrigin(req);
13344
14207
  const base = publicUrl() ?? (auth.kind === "session" && origin ? origin : null);
13345
14208
  const code = formatPairingCode(opened.code);
@@ -13702,19 +14565,31 @@ const handleRequest = async (req, res) => {
13702
14565
  // Notes written from a room fewer people can see than this bot would
13703
14566
  // carry that room's words to everyone who can see the bot.
13704
14567
  if ((path === "/api/internal/memory" || path === "/api/internal/memory/log") && method === "POST") {
14568
+ // On a Cloud home a bot's memory loads into its owner's turns, and
14569
+ // through them can reach a lent Mac: only a conversation that is
14570
+ // provably the owner's may write it.
14571
+ if (CLOUD_HOME && cloudHomeLendingRefusal(cloudLendingTurn(internalCapability)) !== null) {
14572
+ return json(res, 403, { error: "Notes can only be saved from a conversation that only the owner of this Cloud has written in." });
14573
+ }
14574
+ if (internalSender.memoryEnabled === false) {
14575
+ return json(res, 403, { error: "Memory is disabled for this bot." });
14576
+ }
13705
14577
  const room = store.groupByThread(internalCapability.threadId);
13706
14578
  if (room && !roomFeedsBot(room, internalSender)) {
13707
14579
  return json(res, 403, { error: "This room is visible to fewer people than you are, so notes from it can't go into your memory. Keep them in the room." });
13708
14580
  }
13709
14581
  }
14582
+ // On a Cloud home only the owner's conversations get here (above): the
14583
+ // write is theirs (server/lending-memory.ts).
14584
+ const ownersWrite = (write) => lendingMemory ? lendingMemory.trustedWrite(internalSender.id, write) : write();
13710
14585
  if (method === "POST" && path === "/api/internal/memory") {
13711
14586
  const body = await readInternalBody();
13712
- const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText, ...(body.until !== undefined ? { until: body.until } : {}) }, { source: memorySource() });
14587
+ const result = ownersWrite(() => updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText, ...(body.until !== undefined ? { until: body.until } : {}) }, { source: memorySource() }));
13713
14588
  return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : result.code === "over-budget" ? 413 : 400, result);
13714
14589
  }
13715
14590
  if (method === "POST" && path === "/api/internal/memory/log") {
13716
14591
  const body = await readInternalBody();
13717
- const result = appendMemoryLog(internalSender.id, body.text, { source: memorySource() });
14592
+ const result = ownersWrite(() => appendMemoryLog(internalSender.id, body.text, { source: memorySource() }));
13718
14593
  return json(res, result.ok ? 200 : 400, result);
13719
14594
  }
13720
14595
  if (method === "POST" && path === "/api/internal/browser/mcp") {
@@ -13762,8 +14637,8 @@ const handleRequest = async (req, res) => {
13762
14637
  if (method === "GET" && path === "/api/internal/shared-computers" && lendingEnabled()) {
13763
14638
  const principal = sharedComputerPrincipal(internalCapability);
13764
14639
  // Say why an owner's own conversation sees no Mac, so the bot can tell them.
13765
- const notice = CLOUD_HOME && !principal && sharedComputers.status(CLOUD_HOME_LENDER).length > 0 &&
13766
- cloudHomeLendingRefusal(cloudLendingTurn(internalCapability)) === "someone-else" ? { unavailable: LENDING_SOMEONE_ELSE } : {};
14640
+ const why = CLOUD_HOME && !principal && sharedComputers.status(CLOUD_HOME_LENDER).length > 0 ? cloudLendingRefusal(internalCapability) : null;
14641
+ const notice = why === "someone-else" ? { unavailable: LENDING_SOMEONE_ELSE } : why === "memory-changed" ? { unavailable: LENDING_MEMORY_CHANGED } : {};
13767
14642
  return json(res, 200, { computers: sharedComputers.list(principal), ...notice });
13768
14643
  }
13769
14644
  if (method === "POST" && path === "/api/internal/shared-computers" && lendingEnabled()) {
@@ -13777,8 +14652,9 @@ const handleRequest = async (req, res) => {
13777
14652
  return json(res, 403, { error: "This bot has no computer. Change its Computer setting to use a shared computer's apps and screen." });
13778
14653
  }
13779
14654
  const principal = sharedComputerPrincipal(internalCapability);
13780
- if (CLOUD_HOME && !principal && cloudHomeLendingRefusal(cloudLendingTurn(internalCapability)) === "someone-else") {
13781
- return json(res, 403, { error: LENDING_SOMEONE_ELSE });
14655
+ const why = CLOUD_HOME && !principal ? cloudLendingRefusal(internalCapability) : null;
14656
+ if (why === "someone-else" || why === "memory-changed") {
14657
+ return json(res, 403, { error: why === "someone-else" ? LENDING_SOMEONE_ELSE : LENDING_MEMORY_CHANGED });
13782
14658
  }
13783
14659
  return json(res, 200, { result: await sharedComputers.request(parsed.data, principal, () => lendingEnabled() && internalCapabilityIsActive(internalCapability)) });
13784
14660
  }
@@ -13837,7 +14713,13 @@ const handleRequest = async (req, res) => {
13837
14713
  return "closed";
13838
14714
  return "idle";
13839
14715
  };
14716
+ // On a Cloud home a turn that may use the lent Mac sees only the
14717
+ // conversations the owner alone opened and wrote in: a title is words
14718
+ // too (cloudLendingView).
14719
+ const shown = cloudLendingView(internalCapability) ? cloudOwnerOnlyThread : () => true;
13840
14720
  for (const task of store.tasks(from.id)) {
14721
+ if (!shown(task.threadId))
14722
+ continue;
13841
14723
  rows.push({
13842
14724
  threadId: task.threadId, botId: from.id, botName: from.name, title: task.title,
13843
14725
  state: stateOf(from, task), unread: task.unread === true, openedAt: task.openedBy?.at ?? task.createdAt,
@@ -13846,7 +14728,7 @@ const handleRequest = async (req, res) => {
13846
14728
  }
13847
14729
  for (const peer of reachablePeers(store.bots, from)) {
13848
14730
  for (const task of store.tasks(peer.id)) {
13849
- if (task.openedBy?.botId !== from.id)
14731
+ if (task.openedBy?.botId !== from.id || !shown(task.threadId))
13850
14732
  continue;
13851
14733
  rows.push({
13852
14734
  threadId: task.threadId, botId: peer.id, botName: peer.name, title: task.title,
@@ -14079,6 +14961,12 @@ const handleRequest = async (req, res) => {
14079
14961
  : body.action === "update"
14080
14962
  ? { action: body.action, routineId: body.routineId, changes: body.changes, forBot }
14081
14963
  : { action: body.action, routineId: body.routineId, forBot };
14964
+ // Whether the routine a change is for is the owner's, and whether this
14965
+ // turn acts for the owner (their own conversation, or a run of a
14966
+ // routine that is theirs), both before the change.
14967
+ const wasOwners = proposedInput.action !== "create" && typeof proposedInput.routineId === "string" ? cloudRoutineIsOwners(proposedInput.routineId) : false;
14968
+ const run = activeRoutineRunForThread(fromThreadId);
14969
+ const ownersTurn = cloudOwnerOnlyThread(fromThreadId) || cloudRoutineRunIsOwners(run, from.id, fromThreadId);
14082
14970
  const proposed = await routineRequests.submit({
14083
14971
  botId: from.id,
14084
14972
  threadId: fromThreadId,
@@ -14087,6 +14975,17 @@ const handleRequest = async (req, res) => {
14087
14975
  canCommit: () => internalCapabilityIsActive(internalCapability),
14088
14976
  });
14089
14977
  const proposedCard = store.messagesFor(fromThreadId).find((message) => message.id === proposed.messageId)?.card;
14978
+ // Applied at once in a Full-access conversation. In the owner's own (one
14979
+ // only they provably wrote in) it is theirs, as if they had approved
14980
+ // the card. From anywhere else, a routine it created or changed is no
14981
+ // longer the owner's, like anyone else's edit (the routine PATCH).
14982
+ const applied = CLOUD_HOME && proposed.state === "applied" && "result" in proposed ? proposed.result : undefined;
14983
+ if (applied?.resultId && ownersTurn)
14984
+ cloudOwnerApplied(applied.action, applied.resultId, wasOwners);
14985
+ else if (applied?.resultId && (applied.action === "create" || applied.action === "update")) {
14986
+ cloudRoutineAuthors?.forget(applied.resultId);
14987
+ cloudRoutineAuthors?.wrote(applied.resultId, CLOUD_NOBODY_KEY);
14988
+ }
14090
14989
  appendDecision(DATA_DIR, {
14091
14990
  threadId: fromThreadId,
14092
14991
  requestId: proposed.requestId,
@@ -14305,7 +15204,7 @@ const handleRequest = async (req, res) => {
14305
15204
  if (scope !== "all" && scope !== "conversations" && scope !== "memory") {
14306
15205
  return json(res, 400, { error: "scope must be all, conversations, or memory" });
14307
15206
  }
14308
- const memoryHits = scope === "conversations" || !q ? [] : searchMemoryFiles(from.id, q, limit);
15207
+ const memoryHits = scope === "conversations" || !q || from.memoryEnabled === false ? [] : searchMemoryFiles(from.id, q, limit);
14309
15208
  if (scope === "memory")
14310
15209
  return json(res, 200, { hits: [], memoryHits });
14311
15210
  // Own threads: the bot's main chat and tasks, and the rooms it is a
@@ -14321,7 +15220,10 @@ const handleRequest = async (req, res) => {
14321
15220
  for (const task of group.tasks ?? [])
14322
15221
  roomByThread.set(task.threadId, group);
14323
15222
  }
14324
- const ownThreads = [...new Set([from.threadId, ...(from.tasks ?? []).map((task) => task.threadId), ...roomByThread.keys()])];
15223
+ const ownThreads = [...new Set([from.threadId, ...(from.tasks ?? []).map((task) => task.threadId), ...roomByThread.keys()])]
15224
+ // On a Cloud home a turn that may use the lent Mac searches only the
15225
+ // conversations the owner alone opened and wrote in.
15226
+ .filter(cloudLendingView(internalCapability) ? cloudOwnerOnlyThread : () => true);
14325
15227
  // A room is the only place a recall can be a disclosure, and only a
14326
15228
  // private chat is one: in a 1:1 the user already owns every thread
14327
15229
  // the bot can reach, and a room's lines were said in the open.
@@ -14360,7 +15262,8 @@ const handleRequest = async (req, res) => {
14360
15262
  const messageId = String(url.searchParams.get("messageId") ?? "").trim();
14361
15263
  if (!threadId || !messageId)
14362
15264
  return json(res, 400, { error: "threadId and messageId are required" });
14363
- const own = threadId === from.threadId || Boolean(store.taskByThread(from.id, threadId));
15265
+ const own = (threadId === from.threadId || Boolean(store.taskByThread(from.id, threadId))) &&
15266
+ (!cloudLendingView(internalCapability) || cloudOwnerOnlyThread(threadId));
14364
15267
  const message = own ? readMessageText(threadId, messageId) : null;
14365
15268
  if (!message)
14366
15269
  return json(res, 404, { error: "no such message in your conversations" });
@@ -14530,7 +15433,12 @@ const handleRequest = async (req, res) => {
14530
15433
  // can check next turn. If the ledger refuses (cap/depth), fall back
14531
15434
  // to the plain busy bounce rather than dropping the refusal reason.
14532
15435
  const queueBusyFallback = (approvalAlreadyGranted = false) => {
14533
- const queued = queueDelegation(commsBus, from, { toBotId, message, reason: "asked while busy", depth, approvalAlreadyGranted }, MAX_COMMS_DEPTH, fromThreadId);
15436
+ const guestThread = guestWorkThread(from, target, fromThreadId, message);
15437
+ if (guestThread === null)
15438
+ return json(res, 500, { error: "couldn't open a conversation for that work" });
15439
+ const queued = queueDelegation(commsBus, from, { toBotId, message, reason: "asked while busy", depth, approvalAlreadyGranted, ...(guestThread ? { targetThreadId: guestThread } : {}) }, MAX_COMMS_DEPTH, fromThreadId);
15440
+ if ((queued.result !== "ok" || !queued.id) && guestThread)
15441
+ store.deleteTask(toBotId, guestThread);
14534
15442
  if (queued.result !== "ok" || !queued.id)
14535
15443
  return json(res, 200, { busy: true, receipt: peerDeliveryReceipt({
14536
15444
  botId: toBotId, botName: target.name, outcome: "failed",
@@ -14558,6 +15466,10 @@ const handleRequest = async (req, res) => {
14558
15466
  const tryAsideDelivery = async () => {
14559
15467
  if (peerReviewRequired(from, fromThreadId))
14560
15468
  return null;
15469
+ // A guest's request never folds into a turn running in the
15470
+ // owner's conversation; it waits for a confined one of its own.
15471
+ if (cloudGuestDriven(fromThreadId))
15472
+ return null;
14561
15473
  // Busy elsewhere (room turn, sibling thread): the thread this ask
14562
15474
  // targets is not the running one, so there is nothing to fold
14563
15475
  // into here — the queue lane keeps it.
@@ -14664,7 +15576,10 @@ const handleRequest = async (req, res) => {
14664
15576
  delivery: "ask_bot",
14665
15577
  unattended: isUnattended(currentFrom.id, fromThreadId),
14666
15578
  });
14667
- const targetThreadId = currentTarget.threadId;
15579
+ const guestThread = guestWorkThread(currentFrom, currentTarget, fromThreadId, message);
15580
+ if (guestThread === null)
15581
+ return json(res, 500, { error: "couldn't open a conversation for that work" });
15582
+ const targetThreadId = guestThread ?? currentTarget.threadId;
14668
15583
  const outcome = await askBotAndWait(toBotId, prefixed, depth, fromBotId, fromThreadId, targetThreadId);
14669
15584
  requireActiveInternalCapability();
14670
15585
  const replySender = store.bot(fromBotId);
@@ -14876,7 +15791,12 @@ const handleRequest = async (req, res) => {
14876
15791
  if (!connectorThread(from.id, fromThreadId)) {
14877
15792
  return json(res, 403, { error: "source thread does not belong to sender" });
14878
15793
  }
14879
- const queued = queueDelegation(commsBus, from, { toBotId, message, reason, depth }, MAX_COMMS_DEPTH, fromThreadId);
15794
+ const guestThread = guestWorkThread(from, target, fromThreadId, reason ?? message);
15795
+ if (guestThread === null)
15796
+ return json(res, 500, { error: "couldn't open a conversation for that work" });
15797
+ const queued = queueDelegation(commsBus, from, { toBotId, message, reason, depth, ...(guestThread ? { targetThreadId: guestThread } : {}) }, MAX_COMMS_DEPTH, fromThreadId);
15798
+ if ((queued.result !== "ok" || !queued.id) && guestThread)
15799
+ store.deleteTask(toBotId, guestThread);
14880
15800
  if (queued.result !== "ok" || !queued.id) {
14881
15801
  // the agent reads this string — a bare enum ("too_deep") tells it
14882
15802
  // nothing about what to do instead
@@ -14944,7 +15864,7 @@ const handleRequest = async (req, res) => {
14944
15864
  })).filter(g => g.members.length);
14945
15865
  return json(res, 200, { currentRoom: source ? { id: source.id, name: source.name, workingFolder: source.cwd || null } : null,
14946
15866
  bots: reachablePeers(store.bots, internalSender).map(bot => ({ id: bot.id, name: bot.name, title: bot.title, section: bot.section, busy: bot.busy })),
14947
- rooms, note: "Without group_id: use this room when in a room, otherwise your standing conversation with that teammate — every assignment you send it continues the same thread, so write as if it remembers the last one. Each bot uses its own environment and permissions. Files are not transferred: pass absolute paths only when accessible to the recipient, otherwise pass the content." });
15867
+ rooms, note: "Without group_id: use this room when in a room, otherwise each distinct assignment starts a fresh thread for that teammate. Give a self-contained brief. Each bot uses its own environment and permissions. Files are not transferred: pass absolute paths only when accessible to the recipient, otherwise pass the content." });
14948
15868
  }
14949
15869
  if (method === "POST" && path === "/api/internal/coordinate-bots") {
14950
15870
  const parsed = z.object({
@@ -15021,39 +15941,21 @@ const handleRequest = async (req, res) => {
15021
15941
  try {
15022
15942
  requireActiveInternalCapability();
15023
15943
  if (!destination) {
15024
- // One durable conversation per pair of bots, resolved from
15025
- // the recipient's own threads — never from this turn, the
15026
- // request key, or the thread the person has selected there.
15027
- const resolved = store.resolvePairConversation(internalSender, target.botId, {
15028
- label: parsed.data.label,
15029
- // "Still working" exactly as close_thread reads it: a
15030
- // running turn, a queued one, or coordinated work already
15031
- // addressed at that thread.
15032
- working: threadId => threadBusy(target.botId, threadId)
15033
- || queuedThreadPosition(target.botId, threadId) !== null
15034
- || roomHandoffs.activeDirect(threadId),
15035
- });
15036
- if (!resolved)
15944
+ const task = store.createTask(target.botId, `@${internalSender.name}${parsed.data.label ? ` · ${parsed.data.label}` : " · work"}`, false, undefined, { botId: internalSender.id, name: internalSender.name, kind: "work", at: Date.now() });
15945
+ if (!task)
15037
15946
  throw new Error("The recipient no longer exists");
15038
- target.threadId = resolved.task.threadId;
15039
- if (resolved.created)
15040
- createdThread = resolved.task.threadId;
15041
- // A work thread carries one assignment: it is for the person
15042
- // this coordination serves. The durable pair conversation is
15043
- // shared by every assignment between two bots, so it names nobody.
15044
- if (resolved.created && resolved.task.openedBy?.kind === "work")
15045
- threadStarters.set(resolved.task.threadId, threadPersonKey(address.threadId));
15947
+ target.threadId = createdThread = task.threadId;
15046
15948
  if (delegatedFullAccess(internalSender, internalCapability.threadId, store.bot(target.botId))) {
15047
15949
  grantDelegatedFullAccess(internalSender, store.bot(target.botId), target.threadId);
15048
15950
  }
15049
15951
  }
15050
15952
  const { node, duplicate } = roomHandoffs.enqueue(address, internalCapability.generation, internalCapability.roomHandoffId, target, parsed.data.requestKey + ":" + target.botId, parsed.data.message, approvalGranted, parsed.data.rework, [...store.messagesFor(address.threadId)].reverse().find(m => m.role === "user" && m.kind === "text")?.text ?? "", destination ? parsed.data.requestKey : undefined);
15051
- // A re-dispatched request_key is answered by the request it
15052
- // already made, so a thread resolved for the retry (the pair
15053
- // conversation was busy with that very request) goes back
15054
- // before anyone sees a row that leads nowhere.
15055
- if (duplicate && createdThread && createdThread !== node.threadId)
15953
+ // A retry keeps its original task; discard the speculative row.
15954
+ if (duplicate && createdThread && createdThread !== node.threadId) {
15056
15955
  store.deleteTask(target.botId, createdThread);
15956
+ }
15957
+ if (!duplicate && createdThread)
15958
+ threadStarters.set(createdThread, openerFrom(address.threadId));
15057
15959
  createdThread = undefined; // The durable coordinator now owns this task.
15058
15960
  accepted.push({ requestId: node.id, botId: node.botId, duplicate, status: node.status });
15059
15961
  // A duplicate request_key lands on the node enqueue already
@@ -15093,8 +15995,9 @@ const handleRequest = async (req, res) => {
15093
15995
  }
15094
15996
  }
15095
15997
  catch (error) {
15096
- if (createdThread)
15998
+ if (createdThread) {
15097
15999
  store.deleteTask(target.botId, createdThread);
16000
+ }
15098
16001
  const said = error instanceof Error ? error.message : String(error);
15099
16002
  errors.push({ botId: target.botId, error: said });
15100
16003
  receipts.push(peerDeliveryReceipt({
@@ -15337,10 +16240,11 @@ const handleRequest = async (req, res) => {
15337
16240
  }
15338
16241
  const sourceTitle = owner.group ? owner.group.name : (store.taskByThread(from.id, fromThreadId)?.title ?? "");
15339
16242
  if (target.id === from.id) {
15340
- const task = store.createTask(from.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() });
16243
+ const inherited = parentThreadModel(from.id, fromThreadId);
16244
+ const task = store.createTask(from.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() }, inherited.approvalMode, inherited.modelSelection);
15341
16245
  if (!task)
15342
16246
  return json(res, 500, { error: "couldn't create that thread" });
15343
- threadStarters.set(task.threadId, threadPersonKey(fromThreadId));
16247
+ threadStarters.set(task.threadId, openerFrom(fromThreadId));
15344
16248
  internalCapability.openedThreads += 1;
15345
16249
  const chip = {
15346
16250
  role: "bot",
@@ -15383,7 +16287,7 @@ const handleRequest = async (req, res) => {
15383
16287
  if (!task)
15384
16288
  return json(res, 500, { error: "couldn't create that thread" });
15385
16289
  // The work is still for the person whose request the opener is on.
15386
- threadStarters.set(task.threadId, threadPersonKey(fromThreadId));
16290
+ threadStarters.set(task.threadId, openerFrom(fromThreadId));
15387
16291
  if (delegatedFullAccess(from, fromThreadId, target))
15388
16292
  grantDelegatedFullAccess(from, target, task.threadId);
15389
16293
  const queued = queueDelegation(commsBus, from, { toBotId: target.id, message, depth, targetThreadId: task.threadId }, MAX_COMMS_DEPTH, fromThreadId);
@@ -15932,16 +16836,25 @@ const handleRequest = async (req, res) => {
15932
16836
  .filter((run) => routineVisible(run, visible)),
15933
16837
  });
15934
16838
  }
16839
+ // On a Cloud home a routine reports only where its writer may write: a
16840
+ // guest's into a conversation the guest started, never the owner's.
16841
+ const routineResultsRefusal = (body) => body && typeof body === "object" && typeof body.resultsThreadId === "string"
16842
+ ? cloudThreadRefusal(auth, body.resultsThreadId) : null;
15935
16843
  if (path === "/api/routines" && method === "POST") {
15936
16844
  const body = await readBody(req);
15937
16845
  const hidden = hiddenRoutineTarget(body, visible);
15938
16846
  if (hidden)
15939
16847
  return json(res, 404, { error: hidden });
15940
- const routine = routines.create(body);
16848
+ const notYours = routineResultsRefusal(body);
16849
+ if (notYours)
16850
+ return json(res, 403, { error: notYours });
16851
+ const writer = auth.kind === "session" ? actorKey(auth) : CLOUD_NOBODY_KEY;
16852
+ const routine = withRoutineWriter(writer, () => routines.create(body));
15941
16853
  // On a Cloud home, a routine the owner writes from their own device may
15942
16854
  // use their lent Mac when it runs (server/cloud-lending.ts).
15943
16855
  if (cloudRoutineAuthors && cloudOwnerSession(auth))
15944
16856
  cloudRoutineAuthors.record(routine.id, routine);
16857
+ cloudRoutineAuthors?.wrote(routine.id, writer);
15945
16858
  return json(res, 201, { routine });
15946
16859
  }
15947
16860
  // The desktop shell polls this to decide whether to hold the computer
@@ -15962,9 +16875,27 @@ const handleRequest = async (req, res) => {
15962
16875
  const hidden = hiddenRoutineTarget(body, visible);
15963
16876
  if (hidden)
15964
16877
  return json(res, 404, { error: hidden });
16878
+ const notYours = routineResultsRefusal(body);
16879
+ if (notYours)
16880
+ return json(res, 403, { error: notYours });
15965
16881
  const before = cloudRoutineAuthors ? routines.listRoutines().find((candidate) => candidate.id === routineMatch[1]) : undefined;
15966
16882
  const wasOwners = Boolean(before && cloudRoutineAuthors?.authored(before.id, before));
15967
- const routine = routines.update(routineMatch[1], body);
16883
+ const previous = cloudRoutineAuthors?.writer(routineMatch[1]);
16884
+ // Who this edit leaves as the routine's writer: the owner when the
16885
+ // routine stays (or becomes) theirs (their fingerprint, or already
16886
+ // theirs as its writer: a template, a restore), a guest when a guest
16887
+ // edits it, else whoever wrote it before.
16888
+ // An owner writer is proof only for a routine that never had the
16889
+ // owner's fingerprint (a template, a restore, one the settlement named):
16890
+ // a fingerprint that no longer matches means someone else changed what
16891
+ // it runs, and only the owner rewriting its instructions renews it.
16892
+ const ownerWriterOnly = CLOUD_OWNER_KEY !== null && previous === CLOUD_OWNER_KEY && !cloudRoutineAuthors?.recorded(routineMatch[1]);
16893
+ const ownerAuthors = cloudOwnerSession(auth) && (wasOwners || ownerWriterOnly ||
16894
+ (body && typeof body === "object" && "prompt" in body));
16895
+ const writer = ownerAuthors && CLOUD_OWNER_KEY ? CLOUD_OWNER_KEY
16896
+ : auth.kind === "session" && !cloudOwnerSession(auth) ? actorKey(auth)
16897
+ : previous && !cloudOwnerPerson(previous) ? previous : CLOUD_NOBODY_KEY;
16898
+ const routine = withRoutineWriter(writer, () => routines.update(routineMatch[1], body));
15968
16899
  // The owner's own edit keeps (or, when it rewrites the instructions,
15969
16900
  // makes) the routine theirs. Anyone else's edit, of any field (its
15970
16901
  // schedule, where its results go, whether it is on), makes it no longer
@@ -15972,15 +16903,16 @@ const handleRequest = async (req, res) => {
15972
16903
  if (routine && cloudRoutineAuthors) {
15973
16904
  if (!cloudOwnerSession(auth))
15974
16905
  cloudRoutineAuthors.forget(routine.id);
15975
- else if (wasOwners || (body && typeof body === "object" && "prompt" in body))
16906
+ else if (ownerAuthors)
15976
16907
  cloudRoutineAuthors.record(routine.id, routine);
16908
+ cloudRoutineAuthors.wrote(routine.id, writer);
15977
16909
  }
15978
16910
  return routine ? json(res, 200, { routine }) : json(res, 404, { error: "no such routine" });
15979
16911
  }
15980
16912
  if (routineMatch && method === "DELETE") {
15981
16913
  const removed = routines.remove(routineMatch[1]);
15982
16914
  if (removed)
15983
- cloudRoutineAuthors?.forget(routineMatch[1]);
16915
+ cloudRoutineAuthors?.remove(routineMatch[1]);
15984
16916
  return removed
15985
16917
  ? json(res, 200, { ok: true })
15986
16918
  : json(res, 404, { error: "no such routine" });
@@ -16742,6 +17674,9 @@ const handleRequest = async (req, res) => {
16742
17674
  if (hiddenMember)
16743
17675
  return json(res, 400, { error: `unknown channel member: ${String(hiddenMember)}` });
16744
17676
  const group = createChannel(body);
17677
+ // On a Cloud home whoever opens a room names it (cloudThreadRefusal).
17678
+ if (CLOUD_HOME && auth.kind === "session")
17679
+ threadStarters.set(group.threadId, actorKey(auth));
16745
17680
  return json(res, 201, { group: { ...publicGroupState(group), messages: [] } });
16746
17681
  }
16747
17682
  if (method === "POST" && path === "/api/teams/export") {
@@ -17209,7 +18144,7 @@ const handleRequest = async (req, res) => {
17209
18144
  return json(res, 500, { error: "couldn't create that task" });
17210
18145
  // Who opened it decides who may answer its cards on a shared workspace.
17211
18146
  if (auth.kind === "session")
17212
- threadStarters.set(task.threadId, personKey(auth.session));
18147
+ threadStarters.set(task.threadId, actorKey(auth));
17213
18148
  const fresh = groupWithThread(store.group(group.id));
17214
18149
  broadcast({ kind: "group", group: fresh });
17215
18150
  return json(res, 201, { group: fresh, task });
@@ -17286,6 +18221,9 @@ const handleRequest = async (req, res) => {
17286
18221
  // without the old handler turning a missing title into "Untitled".
17287
18222
  // That echo is not a rename. A real rename stays blocked while working.
17288
18223
  const titleChange = renaming && body.title !== existing.title;
18224
+ const notYours = titleChange ? cloudThreadRefusal(auth, m[2]) : null;
18225
+ if (notYours)
18226
+ return json(res, 403, { error: notYours });
17289
18227
  if (channelTaskBlocked(group) && !(pinning && !titleChange)) {
17290
18228
  return json(res, 409, { error: "this channel is working or waiting on you — finish that turn first" });
17291
18229
  }
@@ -17318,6 +18256,9 @@ const handleRequest = async (req, res) => {
17318
18256
  }
17319
18257
  if (!store.groupTaskByThread(group.id, m[2]))
17320
18258
  return json(res, 404, { error: "no such channel task" });
18259
+ const notYours = cloudThreadRefusal(auth, m[2]);
18260
+ if (notYours)
18261
+ return json(res, 403, { error: notYours });
17321
18262
  const stagedSkillCleanups = stagedSkillCleanupsForThread(m[2]);
17322
18263
  lastReply.delete(m[2]);
17323
18264
  cancelTeamSetupResumesForThread(m[2]);
@@ -17339,6 +18280,14 @@ const handleRequest = async (req, res) => {
17339
18280
  if (field)
17340
18281
  return json(res, 403, { error: `forbidden: this session may rename or mark a room, not change "${field}" (needs the admin scope)` });
17341
18282
  }
18283
+ // On a Cloud home a room's name and bulletin are its opener's (or the owner's).
18284
+ const renamed = store.group(m[1]);
18285
+ if (renamed && body && typeof body === "object" &&
18286
+ ((body.name !== undefined && body.name !== renamed.name) || (body.bulletin !== undefined && body.bulletin !== (renamed.bulletin ?? "")))) {
18287
+ const notYours = cloudThreadRefusal(auth, renamed.threadId);
18288
+ if (notYours)
18289
+ return json(res, 403, { error: notYours });
18290
+ }
17342
18291
  const group = updateChannel(m[1], body);
17343
18292
  return json(res, 200, { group: publicGroupState(group) });
17344
18293
  }
@@ -17963,9 +18912,14 @@ const handleRequest = async (req, res) => {
17963
18912
  throw Object.assign(new Error(enabled.error), { status: 422 });
17964
18913
  }
17965
18914
  }
18915
+ // On a Cloud home only the owner's devices create bots: its routines
18916
+ // (and the conversations they report into) are theirs.
18917
+ const ownersRoutines = CLOUD_OWNER_KEY && auth.kind === "session" && cloudOwnerSession(auth) ? CLOUD_OWNER_KEY : null;
17966
18918
  for (const routine of template.routines) {
17967
18919
  const created = routines.create({ ...routine, botId: bot.id, enabled: false });
17968
18920
  createdRoutines.push({ id: created.id, enabled: routine.enabled !== false });
18921
+ if (ownersRoutines)
18922
+ cloudRoutineAuthors?.wrote(created.id, ownersRoutines);
17969
18923
  }
17970
18924
  }
17971
18925
  catch (error) {
@@ -18051,6 +19005,17 @@ const handleRequest = async (req, res) => {
18051
19005
  return json(res, 400, { error: "avatarUrl must reference an existing stored image" });
18052
19006
  }
18053
19007
  const existingBot = store.bot(m[1]);
19008
+ // On a Cloud home a bot's name, title, description and standing
19009
+ // instructions ride every one of the owner's turns (and a lent Mac):
19010
+ // only the owner's own devices change them. Anyone else keeps the
19011
+ // picture and the voice. Resending an unchanged value is not a change.
19012
+ if (CLOUD_HOME && existingBot && !cloudOwnerSession(auth)) {
19013
+ const changed = CLOUD_OWNER_PROFILE_FIELDS.find((field) => parsed.patch[field] !== undefined && (field === "notifications"
19014
+ ? (parsed.patch.notifications !== false) !== (existingBot.notifications !== false)
19015
+ : parsed.patch[field] !== (existingBot[field] ?? "")));
19016
+ if (changed)
19017
+ return json(res, 403, { error: "On this Cloud only its owner can change a bot's name, title, description, instructions or notifications." });
19018
+ }
18054
19019
  const beforeProfile = existingBot ? profileSnapshot(existingBot) : undefined;
18055
19020
  const bot = store.patchBotProfile(m[1], parsed.patch);
18056
19021
  if (!bot)
@@ -18118,7 +19083,7 @@ const handleRequest = async (req, res) => {
18118
19083
  m = path.match(/^\/api\/bots\/([\w-]+)\/command-allowlist(?:\/([\w-]+))?$/);
18119
19084
  if (m && ["GET", "POST", "DELETE"].includes(method)) {
18120
19085
  if (!canManageCommandAllowlist(auth))
18121
- return json(res, 403, { error: "Only the workspace owner or an admin can manage command permissions." });
19086
+ return json(res, 403, { error: "Only the owner or an admin can manage command permissions." });
18122
19087
  const bot = requestedTaskBot(m[1], url.searchParams.get("threadId") ?? undefined);
18123
19088
  if (method === "GET" && !m[2])
18124
19089
  return json(res, 200, commandAllowlistResponse(bot));
@@ -18304,6 +19269,15 @@ const handleRequest = async (req, res) => {
18304
19269
  return json(res, 400, { error: "voiceNotes must be true or false" });
18305
19270
  patch.voiceNotes = body.voiceNotes;
18306
19271
  }
19272
+ if (body.memoryEnabled !== undefined) {
19273
+ if (typeof body.memoryEnabled !== "boolean")
19274
+ return json(res, 400, { error: "memoryEnabled must be true or false" });
19275
+ if (existingBot && (existingBot.busy || activeGroupTurnForBot(existingBot.id)) &&
19276
+ body.memoryEnabled !== (existingBot.memoryEnabled !== false)) {
19277
+ return json(res, 409, { error: "stop this bot's turn before changing its memory setting" });
19278
+ }
19279
+ patch.memoryEnabled = body.memoryEnabled;
19280
+ }
18307
19281
  // which of those apps' tools this bot may call (connector grants 1/5:
18308
19282
  // data model only — enforcement lands with slice 2). null returns the
18309
19283
  // bot to boolean-only legacy behavior; {} means no tools.
@@ -18593,7 +19567,7 @@ const handleRequest = async (req, res) => {
18593
19567
  // come from local scripts only when all bots are idle; paired owners
18594
19568
  // and the packaged desktop's private capability can grant it mid-turn.
18595
19569
  if (loosened.includes("managedSections") && auth.kind === "loopback" && !DESKTOP_MANAGED && store.bots.some(bot => bot.busy)) {
18596
- return json(res, 409, { error: "Stop running bots before granting access to another team, or use the desktop app or a paired owner session." });
19570
+ return json(res, 409, { error: "Stop running bots before granting access to another team, or use the desktop app or a paired Full-access session." });
18597
19571
  }
18598
19572
  if (body.peers !== undefined && Array.isArray(existingBot?.peers)) {
18599
19573
  const nextPeers = patch.peers;
@@ -18640,6 +19614,13 @@ const handleRequest = async (req, res) => {
18640
19614
  sectionKey(existingBot?.section) !== sectionKey(section);
18641
19615
  let bot;
18642
19616
  const freshBrowserBot = store.bot(m[1]);
19617
+ // Connector validation and runtime revocation can yield after the first
19618
+ // memory check. Keep the same idle-only transition at the final commit.
19619
+ if (freshBrowserBot && body.memoryEnabled !== undefined &&
19620
+ body.memoryEnabled !== (freshBrowserBot.memoryEnabled !== false) &&
19621
+ (freshBrowserBot.busy || activeGroupTurnForBot(freshBrowserBot.id))) {
19622
+ return json(res, 409, { error: "stop this bot's turn before changing its memory setting" });
19623
+ }
18643
19624
  // Custom servers are process configuration: a running turn cannot
18644
19625
  // unmount them. Recheck after any awaited runtime revocation above.
18645
19626
  if (body.mcpServers !== undefined) {
@@ -18695,7 +19676,7 @@ const handleRequest = async (req, res) => {
18695
19676
  }
18696
19677
  if (!bot)
18697
19678
  return json(res, 404, { error: "no such bot" });
18698
- if (body.memoryUpkeep === false)
19679
+ if (body.memoryUpkeep === false || body.memoryEnabled === false)
18699
19680
  memoryUpkeep.dropBot(bot.id);
18700
19681
  // A defined Works on is the newest explicit choice: this bot's
18701
19682
  // auto-recorded pins that now point elsewhere give way immediately, so
@@ -19165,6 +20146,9 @@ const handleRequest = async (req, res) => {
19165
20146
  // receipt after a task switch, while a genuinely new send still has to
19166
20147
  // target the task that is active now.
19167
20148
  const threadId = body.threadId ?? bot.threadId;
20149
+ const notYours = cloudGuestSendRefusal(auth, threadId);
20150
+ if (notYours)
20151
+ return json(res, 403, { error: notYours });
19168
20152
  // Who this message is from, for the ledger. It is captured here and
19169
20153
  // travels with the message: into the turn it starts, or into the queue
19170
20154
  // until it drains. A message steered into someone else's running turn
@@ -19425,6 +20409,9 @@ const handleRequest = async (req, res) => {
19425
20409
  const body = await readBody(req);
19426
20410
  requirePinnedClientThread(m[1], body?.threadId);
19427
20411
  const bot = requestedTaskBot(m[1], body?.threadId);
20412
+ const notYours = cloudThreadRefusal(auth, bot.threadId);
20413
+ if (notYours)
20414
+ return json(res, 403, { error: notYours });
19428
20415
  await startTurn(bot.id, "Summarize conversation context", {
19429
20416
  threadId: bot.threadId, compactOnly: true, cardContinuation: true, trigger: usageTriggerFor(auth),
19430
20417
  });
@@ -19440,6 +20427,9 @@ const handleRequest = async (req, res) => {
19440
20427
  const body = await readBody(req);
19441
20428
  requirePinnedClientThread(m[1], body?.threadId);
19442
20429
  const bot = requestedTaskBot(m[1], body.threadId);
20430
+ const notYours = cloudThreadRefusal(auth, bot.threadId);
20431
+ if (notYours)
20432
+ return json(res, 403, { error: notYours });
19443
20433
  const text = String(body.text ?? "").trim();
19444
20434
  if (!text)
19445
20435
  return json(res, 400, { error: "text required" });
@@ -19492,6 +20482,9 @@ const handleRequest = async (req, res) => {
19492
20482
  const body = await readBody(req);
19493
20483
  requirePinnedClientThread(m[1], body?.threadId);
19494
20484
  const bot = requestedTaskBot(m[1], body.threadId);
20485
+ const notYours = cloudThreadRefusal(auth, bot.threadId);
20486
+ if (notYours)
20487
+ return json(res, 403, { error: notYours });
19495
20488
  if (threadBusy(bot.id, bot.threadId))
19496
20489
  return json(res, 409, { error: "the thread is working — stop it before switching versions" });
19497
20490
  if (phoneSecretSubmissions.hasThread(bot.threadId)) {
@@ -19532,7 +20525,7 @@ const handleRequest = async (req, res) => {
19532
20525
  threadId: bot.threadId,
19533
20526
  requestId: String(body.requestId),
19534
20527
  behavior,
19535
- }))
20528
+ }, auth.kind === "session" && cloudOwnerSession(auth)))
19536
20529
  return;
19537
20530
  if (resolveAndSendProfile(res, {
19538
20531
  botId: bot.id,
@@ -19630,7 +20623,7 @@ const handleRequest = async (req, res) => {
19630
20623
  threadId,
19631
20624
  requestId,
19632
20625
  behavior,
19633
- }))
20626
+ }, auth.kind === "session" && cloudOwnerSession(auth)))
19634
20627
  return;
19635
20628
  }
19636
20629
  const setupCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.teamSetupRequest);
@@ -19869,7 +20862,7 @@ const handleRequest = async (req, res) => {
19869
20862
  return json(res, 500, { error: "couldn't create that task" });
19870
20863
  // Who opened it decides who may answer its cards on a shared workspace.
19871
20864
  if (auth.kind === "session")
19872
- threadStarters.set(task.threadId, personKey(auth.session));
20865
+ threadStarters.set(task.threadId, actorKey(auth));
19873
20866
  const fresh = botWithThread(store.bot(bot.id));
19874
20867
  broadcast({ kind: "bot", bot: fresh });
19875
20868
  return json(res, 201, { bot: fresh, task: wireTask(task) });
@@ -19912,9 +20905,19 @@ const handleRequest = async (req, res) => {
19912
20905
  const current = store.projectBotForTask(m[1], m[2]);
19913
20906
  if (!current)
19914
20907
  return json(res, 404, { error: "no such task" });
19915
- const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt", "pinned", "snoozedUntil", "surface"]);
20908
+ const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt", "pinned", "snoozedUntil", "surface", "refreshPermissions"]);
19916
20909
  if (Object.keys(body).some((key) => !allowed.has(key)))
19917
20910
  return json(res, 400, { error: "unsupported thread setting" });
20911
+ const notYours = cloudThreadRefusal(auth, m[2]);
20912
+ if (notYours)
20913
+ return json(res, 403, { error: notYours });
20914
+ // On a Cloud home only the owner decides whether a bot may act without
20915
+ // asking them, even in a conversation someone else started, and what
20916
+ // the bot itself defaults to.
20917
+ if (CLOUD_HOME && !cloudOwnerSession(auth) &&
20918
+ (body.approvalMode !== undefined || body.autoApprove !== undefined || body.acknowledgeLocalAuto !== undefined || body.updateBotDefault === true || body.refreshPermissions === true)) {
20919
+ return json(res, 403, { error: "On this Cloud only its owner can change how a bot asks for approval, or its default model." });
20920
+ }
19918
20921
  for (const key of ["requireAvailableModel", "acknowledgeLocalAuto", "updateBotDefault", "resetApprovalToAsk"]) {
19919
20922
  if (body[key] !== undefined && typeof body[key] !== "boolean")
19920
20923
  return json(res, 400, { error: `${key} must be a boolean` });
@@ -19927,6 +20930,50 @@ const handleRequest = async (req, res) => {
19927
20930
  (body.approvalMode !== undefined && body.approvalMode !== "ask") || body.autoApprove === true)) {
19928
20931
  return json(res, 400, { error: "resetApprovalToAsk requires a model selection and cannot be combined with another approval mode" });
19929
20932
  }
20933
+ if (body.refreshPermissions !== undefined && body.refreshPermissions !== true) {
20934
+ return json(res, 400, { error: "refreshPermissions must be true" });
20935
+ }
20936
+ if (body.refreshPermissions === true) {
20937
+ const extra = Object.keys(body).filter((key) => key !== "refreshPermissions" && key !== "acknowledgeLocalAuto");
20938
+ if (extra.length)
20939
+ return json(res, 400, { error: "refreshPermissions cannot be combined with other thread settings" });
20940
+ const profile = store.bot(m[1]);
20941
+ if (!profile)
20942
+ return json(res, 404, { error: "no such bot" });
20943
+ if (profile.approvalGrant)
20944
+ return json(res, 409, { error: "the bot's approval mode is still being confirmed" });
20945
+ if (threadBusy(profile.id, m[2]))
20946
+ return json(res, 409, { error: "stop this thread before changing its approval mode" });
20947
+ const nextMode = approvalModeFor({ ...profile, approvalGrant: undefined });
20948
+ // Full and Custom never travel over the bot-reachable HTTP surface.
20949
+ // The desktop's private channel copies those levels, and it is also
20950
+ // the only way to leave Custom.
20951
+ if (nextMode === "full" || nextMode === "custom") {
20952
+ return json(res, 403, { error: "Refresh Full or Custom access from the packaged desktop app" });
20953
+ }
20954
+ if (approvalModeFor(current) === "custom") {
20955
+ return json(res, 403, { error: "Leaving Custom approval requires the packaged desktop app" });
20956
+ }
20957
+ if (!supportsApprovalMode(current.modelSelection, nextMode)) {
20958
+ return json(res, 400, { error: "This provider does not support the selected approval level" });
20959
+ }
20960
+ if (nextMode === "auto" && current.computer === "local" && approvalModeFor(current) !== "auto" && body.acknowledgeLocalAuto !== true) {
20961
+ return json(res, 400, { error: "Auto mode on this computer requires confirming the warning first (acknowledgeLocalAuto)" });
20962
+ }
20963
+ const approvalRank = { ask: 0, edits: 1, auto: 2, full: 3, custom: 4 };
20964
+ const widensMode = approvalRank[nextMode] > approvalRank[approvalModeFor(current)];
20965
+ const nextAllow = profile.alwaysAllow ?? [];
20966
+ const widensAllow = nextAllow.some((key) => !(current.alwaysAllow ?? []).includes(key));
20967
+ if ((widensMode || widensAllow) && auth.kind === "loopback" && !DESKTOP_MANAGED && !req.headers.origin && store.bots.some((bot) => bot.busy)) {
20968
+ return json(res, 409, { error: "Change approval mode from the app or a paired device while bots are working." });
20969
+ }
20970
+ const task = store.refreshTaskPermissions(profile.id, m[2]);
20971
+ if (!task)
20972
+ return json(res, 404, { error: "no such task" });
20973
+ const fresh = botWithThread(store.bot(profile.id));
20974
+ broadcast({ kind: "bot", bot: fresh });
20975
+ return json(res, 200, { task: wireTask(task), bot: fresh });
20976
+ }
19930
20977
  const patch = {};
19931
20978
  if (body.projectId !== undefined) {
19932
20979
  if (body.projectId === null)
@@ -20040,6 +21087,9 @@ const handleRequest = async (req, res) => {
20040
21087
  if (!bot || !store.taskByThread(bot.id, m[2])) {
20041
21088
  return json(res, 404, { error: "no such task" });
20042
21089
  }
21090
+ const notYours = cloudThreadRefusal(auth, m[2]);
21091
+ if (notYours)
21092
+ return json(res, 403, { error: notYours });
20043
21093
  if (phoneSecretSubmissions.hasThread(m[2])) {
20044
21094
  return json(res, 409, { error: "this task is securely saving a credential — try again when it finishes" });
20045
21095
  }
@@ -20060,6 +21110,55 @@ const handleRequest = async (req, res) => {
20060
21110
  broadcast({ kind: "bot", bot: fresh });
20061
21111
  return json(res, 200, { bot: fresh });
20062
21112
  }
21113
+ // Regenerate title (#1858): name the thread again from its conversation
21114
+ // as it is now. The person asked, so the result replaces any title, but
21115
+ // only the one this started from: a rename that lands while the one-shot
21116
+ // runs wins, and the late result is dropped.
21117
+ m = path.match(/^\/api\/bots\/([\w-]+)\/tasks\/([\w-]+)\/title$/);
21118
+ if (m && method === "POST") {
21119
+ const current = store.projectBotForTask(m[1], m[2]);
21120
+ if (!current)
21121
+ return json(res, 404, { error: "no such task" });
21122
+ const notYours = cloudThreadRefusal(auth, m[2]);
21123
+ if (notYours)
21124
+ return json(res, 403, { error: notYours });
21125
+ if (!llmThreadTitlesEnabled(cfg))
21126
+ return json(res, 409, { error: "generated thread titles are turned off on this server" });
21127
+ if (hostedModels && !hostedModels.allows(current.modelSelection))
21128
+ return json(res, 409, { error: hostedModels.error() });
21129
+ const instance = registry.get(current.modelSelection.instanceId);
21130
+ if (!instance?.generateText)
21131
+ return json(res, 409, { error: "this bot's model can't generate a title — rename the thread instead" });
21132
+ // An engine the organisation disallows never receives the text, even for a title.
21133
+ const refusal = policyModelRefusal(instance);
21134
+ if (refusal)
21135
+ return json(res, 409, { error: refusal });
21136
+ if (titleRegenerations.has(m[2]))
21137
+ return json(res, 409, { error: "a new title is already being generated for this thread" });
21138
+ const excerpt = titleConversationExcerpt(store.messagesTail(m[2], 200).messages);
21139
+ if (!excerpt)
21140
+ return json(res, 409, { error: "this thread has no messages to take a title from yet" });
21141
+ try {
21142
+ assertWithinBudget(cfg, DATA_DIR);
21143
+ }
21144
+ catch (error) {
21145
+ return json(res, 409, { error: error instanceof Error ? error.message : String(error) });
21146
+ }
21147
+ const startedFrom = store.taskByThread(m[1], m[2]).title;
21148
+ titleRegenerations.add(m[2]);
21149
+ try {
21150
+ const title = await generateThreadTitle(instance, excerpt, "conversation");
21151
+ if (!title)
21152
+ return json(res, 502, { error: "couldn't generate a title — try again, or rename the thread" });
21153
+ const task = store.retitleTask(m[1], m[2], startedFrom, title) ?? store.taskByThread(m[1], m[2]);
21154
+ if (!task)
21155
+ return json(res, 404, { error: "no such task" });
21156
+ return json(res, 200, { task: wireTask(task) });
21157
+ }
21158
+ finally {
21159
+ titleRegenerations.delete(m[2]);
21160
+ }
21161
+ }
20063
21162
  // Named team Boats use real independent ownership, never a hidden bot or
20064
21163
  // an arbitrary provider id. These new routes remain admin-only by default.
20065
21164
  if (path === "/api/team-computers" && method === "GET") {
@@ -20253,7 +21352,7 @@ const handleRequest = async (req, res) => {
20253
21352
  // what the user's machine can host: which runtime is installed, whether
20254
21353
  // its daemon is up, and whether the desktop image and container exist
20255
21354
  if (method === "GET" && path === "/api/local-computer") {
20256
- return json(res, 200, await localVmPayload(SHARED_LOCAL_VM_TARGET));
21355
+ return json(res, 200, await localVmPayload(SHARED_LOCAL_VM_TARGET, auth));
20257
21356
  }
20258
21357
  if (method === "GET" && path === "/api/local-computer/instances") {
20259
21358
  res.setHeader("cache-control", "private, no-store");
@@ -20294,14 +21393,16 @@ const handleRequest = async (req, res) => {
20294
21393
  localVmLifecycleBusy.add(SHARED_LOCAL_VM_TARGET.key);
20295
21394
  try {
20296
21395
  const status = await containerComputerAction(action, undefined, undefined, SHARED_LOCAL_VM_TARGET);
20297
- if (action === "run" || action === "start")
21396
+ if (action === "run" || action === "start") {
21397
+ localVmSeen.add(SHARED_LOCAL_VM_TARGET.key);
20298
21398
  localVmIdleFor(SHARED_LOCAL_VM_TARGET).touch();
21399
+ }
20299
21400
  if (action === "stop" || action === "remove")
20300
21401
  localVmIdleFor(SHARED_LOCAL_VM_TARGET).cancel();
20301
21402
  return json(res, 200, {
20302
- ...status,
21403
+ ...localVmViewerStatus(status, auth),
20303
21404
  commands: setupCommands(status.runtime, process.platform, SHARED_LOCAL_VM_TARGET),
20304
- idle_timeout_ms: LOCAL_VM_IDLE_MS,
21405
+ idle_timeout_ms: localVmIdleMs(),
20305
21406
  mode: localVmMode(cfg),
20306
21407
  max_instances: localVmMaxInstances(cfg),
20307
21408
  });
@@ -20325,9 +21426,9 @@ const handleRequest = async (req, res) => {
20325
21426
  const bot = computerPreviewBot(m[1], url);
20326
21427
  if (!bot)
20327
21428
  return json(res, 404, { error: "no such bot" });
20328
- return json(res, 200, await localVmPayload(localVmTargetForStatus(bot.id, bot.threadId)));
21429
+ return json(res, 200, await localVmPayload(localVmTargetForStatus(bot.id, bot.threadId), auth));
20329
21430
  }
20330
- m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer\/(run|stop|remove)$/);
21431
+ m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer\/(run|start|stop|remove)$/);
20331
21432
  if (m && method === "POST") {
20332
21433
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
20333
21434
  return json(res, 415, { error: "content-type must be application/json" });
@@ -20338,7 +21439,7 @@ const handleRequest = async (req, res) => {
20338
21439
  if (boatLifecycleBusyBots.has(bot.id)) {
20339
21440
  return json(res, 409, { error: "this bot's computer is being changed or deleted — wait for it to finish" });
20340
21441
  }
20341
- const action = z.enum(["run", "stop", "remove"]).parse(m[2]);
21442
+ const action = z.enum(["run", "start", "stop", "remove"]).parse(m[2]);
20342
21443
  const target = localVmTargetForBot(bot.id);
20343
21444
  if (target.key === SHARED_LOCAL_VM_TARGET.key) {
20344
21445
  return json(res, 409, { error: "Shared mode manages this desktop in App Settings → Computers" });
@@ -20377,14 +21478,16 @@ const handleRequest = async (req, res) => {
20377
21478
  }
20378
21479
  }
20379
21480
  const status = await containerComputerAction(action, undefined, undefined, target);
20380
- if (action === "run")
21481
+ if (action === "run" || action === "start") {
21482
+ localVmSeen.add(target.key);
20381
21483
  localVmIdleFor(target).touch();
21484
+ }
20382
21485
  if (action === "stop" || action === "remove")
20383
21486
  localVmIdleFor(target).cancel();
20384
21487
  return json(res, 200, {
20385
- ...status,
21488
+ ...localVmViewerStatus(status, auth),
20386
21489
  commands: setupCommands(status.runtime, process.platform, target),
20387
- idle_timeout_ms: LOCAL_VM_IDLE_MS,
21490
+ idle_timeout_ms: localVmIdleMs(),
20388
21491
  mode: localVmMode(cfg),
20389
21492
  max_instances: localVmMaxInstances(cfg),
20390
21493
  });
@@ -20410,6 +21513,61 @@ const handleRequest = async (req, res) => {
20410
21513
  image: await containerComputerScreenshot(undefined, undefined, target),
20411
21514
  });
20412
21515
  }
21516
+ // The Local VM's live desktop for a phone, granted only to the control
21517
+ // lease that holds this bot's computer right now. Two callers:
21518
+ //
21519
+ // The companion sidecar (a loopback caller) gets the VM's own noVNC
21520
+ // address, VNC password included, relays it to a paired phone the owner
21521
+ // has allowed computer access, keeps re-checking the lease with
21522
+ // `action: "check"`, and cuts the relay when it no longer holds.
21523
+ //
21524
+ // A phone paired with this server directly (`openmausbot serve`, no
21525
+ // sidecar) never sees that address. It gets this server's own
21526
+ // authenticated desktop proxy, bound to its lease: the proxy re-checks
21527
+ // the lease and the session every few seconds and closes the socket when
21528
+ // either lapses. Reaching here at all took the admin scope, which a
21529
+ // chat-only pairing (`openmausbot pair --client`) does not have, so
21530
+ // computer access is the same explicit choice the owner's browser makes.
21531
+ m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer\/join$/);
21532
+ if (m && method === "POST") {
21533
+ if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
21534
+ return json(res, 415, { error: "content-type must be application/json" });
21535
+ }
21536
+ const bot = computerPreviewBot(m[1], url);
21537
+ if (!bot)
21538
+ return json(res, 404, { error: "no such bot" });
21539
+ const threadId = url.searchParams.has("threadId") ? bot.threadId : undefined;
21540
+ if (threadId && await computerPreviewSurface(bot, threadId) !== "vm") {
21541
+ return json(res, 409, { error: "This conversation is not using the Local VM" });
21542
+ }
21543
+ // A bot's human-control hold does not reserve a shared desktop or pool
21544
+ // seat. Another bot could drive it while the phone is holding this bot.
21545
+ if (localVmMode(cfg) !== "per-bot") {
21546
+ return json(res, 409, { error: "Phone control requires a per-bot Local VM. Select per-bot mode in Settings → Computers." });
21547
+ }
21548
+ const lease = controlLeaseIdSchema.safeParse(url.searchParams.get("controlLeaseId") ?? undefined);
21549
+ if (!lease.success)
21550
+ return json(res, 400, { error: "controlLeaseId is required" });
21551
+ const controlBot = store.bot(bot.id);
21552
+ if (!controlBot || !computerControl.ownsLease(botComputerControlKey(controlBot), lease.data)) {
21553
+ return json(res, 409, { error: "Take control of this computer first" });
21554
+ }
21555
+ const target = localVmTargetForStatus(bot.id, threadId);
21556
+ const status = await containerComputerStatus(undefined, undefined, target);
21557
+ if (!status.ready || !status.managed || status.container !== "running" || status.network !== "loopback"
21558
+ || !status.viewer_url) {
21559
+ return json(res, 409, { error: status.problem ?? "The Local VM is not ready" });
21560
+ }
21561
+ localVmIdleFor(target).touch();
21562
+ res.setHeader("cache-control", "private, no-store");
21563
+ if (auth.kind === "loopback")
21564
+ return json(res, 200, { joinUrl: status.viewer_url });
21565
+ const socket = new URLSearchParams({ botId: bot.id, ...(threadId ? { threadId } : {}), controlLeaseId: lease.data });
21566
+ return json(res, 200, {
21567
+ socketPath: `api/desktop-viewer/${viewerTargetId(target)}/websockify?${socket}`,
21568
+ password: new URLSearchParams(new URL(status.viewer_url).hash.slice(1)).get("password"),
21569
+ });
21570
+ }
20413
21571
  // identity handshake for the packaged app's port fallback: the forked
20414
21572
  // child proves it is OURS by echoing its pid (a stray dev server has
20415
21573
  // the same API shape but a different pid)
@@ -20549,7 +21707,7 @@ const handleRequest = async (req, res) => {
20549
21707
  return json(res, 400, { error: `provider must be one of ${PROVIDER_KEY_KINDS.join(", ")}` });
20550
21708
  }
20551
21709
  const kind = provider;
20552
- const saved = kind === "anthropic" ? cfg.anthropic : kind === "openaiCompat" ? cfg.openaiCompat : kind === "mistral" ? cfg.mistral : cfg.xai;
21710
+ const saved = { anthropic: cfg.anthropic, openai: cfg.openai, openrouter: cfg.openrouter, openaiCompat: cfg.openaiCompat, mistral: cfg.mistral, cerebras: cfg.cerebras, xai: cfg.xai }[kind];
20553
21711
  if (body?.key !== undefined && typeof body.key !== "string") {
20554
21712
  return json(res, 400, { error: "key must be a string" });
20555
21713
  }
@@ -20666,6 +21824,30 @@ const handleRequest = async (req, res) => {
20666
21824
  providerConfigBusy = false;
20667
21825
  }
20668
21826
  }
21827
+ if (method === "POST" && path === "/api/instances/chatgpt-accounts") {
21828
+ if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json"))
21829
+ return json(res, 415, { error: "content-type must be application/json" });
21830
+ const parsed = createClaudeAccountSchema.pick({ displayName: true }).safeParse(await readBody(req, 8192));
21831
+ if (!parsed.success)
21832
+ return json(res, 400, { error: "Enter an account name (up to 80 characters)." });
21833
+ if (providerConfigBusy)
21834
+ return json(res, 409, { error: "provider settings are already being updated" });
21835
+ providerConfigBusy = true;
21836
+ try {
21837
+ const instances = persistableInstanceConfigs(cfg);
21838
+ const instanceId = `chatgpt-${randomUUID()}`;
21839
+ // Reuse the selected executable, never another account's environment.
21840
+ const cli = instances.chatgpt?.config?.cli
21841
+ ?? instances.codex?.config?.cli;
21842
+ instances[instanceId] = { driver: "codex", displayName: parsed.data.displayName,
21843
+ config: { authMode: "chatgpt-plan", ...(typeof cli === "string" && cli ? { cli } : {}) } };
21844
+ await persistProviderInstance(instanceId, instances);
21845
+ return json(res, 201, { instanceId, instances: await describeInstances() });
21846
+ }
21847
+ finally {
21848
+ providerConfigBusy = false;
21849
+ }
21850
+ }
20669
21851
  if (method === "POST" && path === "/api/instances/claude-accounts") {
20670
21852
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
20671
21853
  return json(res, 415, { error: "content-type must be application/json" });
@@ -20716,6 +21898,11 @@ const handleRequest = async (req, res) => {
20716
21898
  const instance = registry.get(instanceId);
20717
21899
  if (!instance)
20718
21900
  return json(res, 404, { error: "unknown instance" });
21901
+ // The official local OAuth callback terminates on this machine,
21902
+ // not a phone or the browser visiting a remotely hosted workspace.
21903
+ if (instance.authenticationMethod === "browser-pkce" && (isProxied(req) || !isLoopbackHost(req.socket.remoteAddress))) {
21904
+ return json(res, 403, { error: "Continue with ChatGPT on the computer running OpenMausBot. Hosted Pro sign-in requires OpenAI's hosted-app approval." });
21905
+ }
20719
21906
  const started = await providerAuthSessions.start(instance, owner);
20720
21907
  // Revocation can arrive while the CLI is obtaining a device code.
20721
21908
  if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
@@ -20861,8 +22048,8 @@ const handleRequest = async (req, res) => {
20861
22048
  return json(res, 400, { error: "Account settings are currently available for Claude only." });
20862
22049
  }
20863
22050
  if (body.tools !== undefined) {
20864
- if (!["openai-compat", "grok", "minimax", "mistral"].includes(entry.driver)) {
20865
- return json(res, 400, { error: "The tools setting is available for OpenAI-compatible, Grok API, MiniMax API and Mistral API instances only." });
22051
+ if (!["openai-compat", "grok", "minimax", "mistral", "cerebras"].includes(entry.driver)) {
22052
+ return json(res, 400, { error: "The tools setting is available for OpenAI-compatible, Grok API, MiniMax API, Mistral API and Cerebras API instances only." });
20866
22053
  }
20867
22054
  entry.config = { ...entry.config, tools: body.tools };
20868
22055
  }
@@ -20939,13 +22126,78 @@ const handleRequest = async (req, res) => {
20939
22126
  res.once("close", disconnect);
20940
22127
  mcpProbesInFlight += 1;
20941
22128
  try {
20942
- return json(res, 200, await probeMcpServer(parsed.server, undefined, controller.signal));
22129
+ const name = mcpTest[1];
22130
+ let target = parsed.server;
22131
+ if (isRemoteMcpServer(target) && mcpOAuth.authState(name, target.url) === "signed-in") {
22132
+ const signedIn = (await withMcpSignIn({ [name]: target }, mcpOAuth))[name];
22133
+ if (signedIn)
22134
+ target = { ...signedIn, enabled: target.enabled };
22135
+ }
22136
+ const result = await probeMcpServer(target, undefined, controller.signal);
22137
+ if (!result.ok && result.auth === "required" && isRemoteMcpServer(target))
22138
+ mcpOAuth.markNeedsSignIn(name, target.url);
22139
+ return json(res, 200, result);
20943
22140
  }
20944
22141
  finally {
20945
22142
  res.off("close", disconnect);
20946
22143
  mcpProbesInFlight -= 1;
20947
22144
  }
20948
22145
  }
22146
+ // The loopback listener and authenticated paste-back complete the same flow.
22147
+ const mcpSignIn = /^\/api\/mcp\/servers\/([a-z][a-z0-9_-]{0,31})\/(sign-in|sign-out)(?:\/([0-9a-f-]{36}))?$/.exec(path);
22148
+ if (mcpSignIn) {
22149
+ const [, name, action, flowId] = mcpSignIn;
22150
+ const raw = cfg.mcpServers?.[name];
22151
+ const parsed = raw === undefined ? null : parseStoredMcpServer(name, raw);
22152
+ if (!parsed?.ok)
22153
+ return json(res, 404, { error: "MCP server not found." });
22154
+ if (!isRemoteMcpServer(parsed.server))
22155
+ return json(res, 400, { error: "Only servers reached by URL can be signed in to." });
22156
+ const url = parsed.server.url;
22157
+ if (action === "sign-out" && method === "POST" && !flowId) {
22158
+ await mcpOAuth.signOut(name, url);
22159
+ return json(res, 200, mcpServerResponse());
22160
+ }
22161
+ const owner = auth.kind === "session" ? auth.session.id : "loopback";
22162
+ res.setHeader("Cache-Control", "no-store");
22163
+ try {
22164
+ if (action === "sign-in" && method === "GET" && flowId) {
22165
+ const status = mcpOAuth.status(name, flowId, owner);
22166
+ return status ? json(res, 200, { auth: status }) : json(res, 404, { error: "This sign-in is no longer available in this browser. Start again." });
22167
+ }
22168
+ if (action === "sign-in" && method === "DELETE") {
22169
+ mcpOAuth.cancelFlow(name, owner, flowId);
22170
+ return json(res, 200, { ok: true });
22171
+ }
22172
+ if (action === "sign-in" && method === "POST" && flowId) {
22173
+ if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
22174
+ return json(res, 415, { error: "content-type must be application/json" });
22175
+ }
22176
+ const body = await readBody(req, 20_480);
22177
+ if (typeof body?.callbackUrl !== "string")
22178
+ return json(res, 400, { error: "A complete callbackUrl is required." });
22179
+ return json(res, 200, { auth: await mcpOAuth.completeCallback(name, flowId, body.callbackUrl, owner) });
22180
+ }
22181
+ if (action === "sign-in" && method === "POST" && !flowId) {
22182
+ const started = await mcpOAuth.start(name, url, undefined, owner);
22183
+ if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
22184
+ mcpOAuth.revokeOwner(owner);
22185
+ return json(res, 401, { error: "Your session ended. Start a new sign-in." });
22186
+ }
22187
+ return json(res, 200, { auth: started });
22188
+ }
22189
+ }
22190
+ catch (error) {
22191
+ if (error instanceof McpSignInError)
22192
+ return json(res, error.status, { error: error.message });
22193
+ if (error instanceof McpOAuthError)
22194
+ return json(res, 400, { error: error.message, code: error.code });
22195
+ if (method === "POST" && !flowId)
22196
+ return json(res, 502, { error: error instanceof Error ? error.message : "Sign-in could not start." });
22197
+ throw error;
22198
+ }
22199
+ return json(res, 405, { error: "method not allowed" });
22200
+ }
20949
22201
  if (method === "POST" && path === "/api/mcp/servers") {
20950
22202
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
20951
22203
  return json(res, 415, { error: "content-type must be application/json" });
@@ -21031,6 +22283,7 @@ const handleRequest = async (req, res) => {
21031
22283
  const next = { ...current };
21032
22284
  delete next[name];
21033
22285
  persistMcpServers(next);
22286
+ mcpOAuth.forget(name);
21034
22287
  return json(res, 200, mcpServerResponse());
21035
22288
  }
21036
22289
  const existing = parseStoredMcpServer(name, current[name]);
@@ -21052,6 +22305,17 @@ const handleRequest = async (req, res) => {
21052
22305
  if (refusal)
21053
22306
  return json(res, 403, { error: refusal, code: "managed_policy" });
21054
22307
  persistMcpServers({ ...current, [name]: parsed.server });
22308
+ // a sign-in belongs to one address; a token never follows the entry elsewhere
22309
+ const before = isRemoteMcpServer(existing.server) ? existing.server.url : null;
22310
+ const after = isRemoteMcpServer(parsed.server) ? parsed.server.url : null;
22311
+ // ...and to one sign-in app with its scopes; a new secret for the same app keeps it
22312
+ const app = (server) => isRemoteMcpServer(server) && server.oauth
22313
+ ? JSON.stringify([server.oauth.clientId, server.oauth.scopes ?? []]) : null;
22314
+ // Any other edit (a new header, say) drops a sign-in request that has
22315
+ // no tokens behind it; the next Test asks again if it still applies.
22316
+ if (before !== after || app(existing.server) !== app(parsed.server)
22317
+ || (after && mcpOAuth.authState(name, after) === "needs-sign-in"))
22318
+ mcpOAuth.forget(name);
21055
22319
  return json(res, 200, mcpServerResponse());
21056
22320
  }
21057
22321
  finally {
@@ -21064,9 +22328,13 @@ const handleRequest = async (req, res) => {
21064
22328
  }
21065
22329
  if ((method === "PUT" || method === "PATCH") && path === "/api/config") {
21066
22330
  const body = await readBody(req);
21067
- if (hostedModels && ["instances", "anthropic", "openaiCompat", "xai", "mistral", "opencodeGo"].some(key => Object.hasOwn(body, key)))
22331
+ if (hostedModels && ["instances", "anthropic", "openai", "openrouter", "openaiCompat", "xai", "mistral", "cerebras", "opencodeGo"].some(key => Object.hasOwn(body, key)))
21068
22332
  return json(res, 403, { error: HOSTED_PROVIDER_SETTINGS_ERROR });
21069
22333
  const patch = parseConfigPatch(body);
22334
+ // A Cloud home is personal: nobody is invited to sign in to it.
22335
+ if (CLOUD_HOME && (patch.signIn?.admins?.length || patch.signIn?.members?.length)) {
22336
+ return json(res, 403, { error: CLOUD_PERSONAL_REFUSAL, code: "cloud_personal" });
22337
+ }
21070
22338
  if (patch.newBotDefaults) {
21071
22339
  if (patch.newBotDefaults.profile.modelSelection) {
21072
22340
  const checked = checkedModelSelection(patch.newBotDefaults.profile.modelSelection);
@@ -21506,9 +22774,17 @@ const handleRequest = async (req, res) => {
21506
22774
  }
21507
22775
  throw error;
21508
22776
  }
22777
+ // Desktops already counting down move to the new window at once, in
22778
+ // every isolation mode; busy work still defers its expiry.
22779
+ if (patch.localVm?.idleTimeoutMinutes !== undefined) {
22780
+ for (const idle of localVmIdles.values())
22781
+ idle.setIdleMs(localVmIdleMs());
22782
+ }
21509
22783
  let browserReferenceCleanupError = null;
21510
- if (patch.signIn !== undefined)
22784
+ if (patch.signIn !== undefined) {
21511
22785
  sessions.revalidateEmailSessions();
22786
+ resyncOpenCodeProviderKeys();
22787
+ }
21512
22788
  if (!lendingEnabled()) {
21513
22789
  sharedComputers.close();
21514
22790
  sharedComputerControl.close();
@@ -21930,13 +23206,19 @@ const handleRequest = async (req, res) => {
21930
23206
  const result = computerControl.releaseLease(controlKey, controlLeaseId);
21931
23207
  return json(res, 200, { ...result.snapshot, released: result.released });
21932
23208
  }
23209
+ // Read-only: does this lease still hold the wheel? The companion
23210
+ // sidecar asks this while it relays a phone's live Local VM desktop,
23211
+ // and cuts the relay the moment the answer is no.
23212
+ if (action === "check" && controlLeaseId) {
23213
+ return json(res, 200, { ...computerControl.snapshot(controlKey), owned: computerControl.ownsLease(controlKey, controlLeaseId) });
23214
+ }
21933
23215
  if (action === "take")
21934
23216
  return json(res, 200, computerControl.take(controlKey));
21935
23217
  if (action === "release")
21936
23218
  return json(res, 200, computerControl.release(controlKey));
21937
23219
  if (action === "dismiss-help")
21938
23220
  return json(res, 200, computerControl.dismissHelp(controlKey));
21939
- return json(res, 400, { error: "action must be take, release, or dismiss-help" });
23221
+ return json(res, 400, { error: "action must be take, release, check, or dismiss-help" });
21940
23222
  }
21941
23223
  return json(res, 405, { error: "method not allowed" });
21942
23224
  }
@@ -21948,7 +23230,13 @@ const handleRequest = async (req, res) => {
21948
23230
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
21949
23231
  return json(res, 415, { error: "content-type must be application/json" });
21950
23232
  }
21951
- return json(res, 200, bot.cloudBackend === "vps" ? vps.closeVpsDesktopTunnel(bot.id) : { closed: false });
23233
+ // A directly paired phone's Local VM desktop runs through this server's
23234
+ // own proxy under its lease on this bot; hand-back closes it here, ahead
23235
+ // of the lease re-check. The session's other viewers are left alone.
23236
+ const closedViewers = auth.kind === "session" ? desktopViewer.closeForOwner(auth.session.id, bot.id) : 0;
23237
+ if (bot.cloudBackend === "vps")
23238
+ return json(res, 200, vps.closeVpsDesktopTunnel(bot.id));
23239
+ return json(res, 200, { closed: closedViewers > 0 });
21952
23240
  }
21953
23241
  m = path.match(/^\/api\/bots\/([\w-]+)\/computer\/(provision|join|sleep|exec|screenshot|remove)$/);
21954
23242
  if (m && method === "POST") {
@@ -21969,6 +23257,10 @@ const handleRequest = async (req, res) => {
21969
23257
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
21970
23258
  return json(res, 415, { error: "content-type must be application/json" });
21971
23259
  }
23260
+ if (m[2] === "join" && bot.cloudBackend === "vps") {
23261
+ for (let pending; (pending = vpsJoinRequests.get(botId));)
23262
+ await pending;
23263
+ }
21972
23264
  const remoteProvider = bot.cloudBackend === "vps" ? "vps" : "box";
21973
23265
  if (computerProviderConfigTransitions.has(remoteProvider)) {
21974
23266
  return json(res, 409, { error: providerTransitionMessage(remoteProvider) });
@@ -22016,6 +23308,10 @@ const handleRequest = async (req, res) => {
22016
23308
  // Opening the existing SSH viewer can coexist with a capture. Start,
22017
23309
  // stop, remove and Settings deletion still exclude pending previews.
22018
23310
  const releaseComputerLifecycle = claimBotComputerLifecycle(botId, m[2] === "join");
23311
+ // Settles only after the lane is released, so a waiter can claim it.
23312
+ let joinSettled;
23313
+ if (m[2] === "join")
23314
+ vpsJoinRequests.set(botId, new Promise(resolve => { joinSettled = resolve; }));
22019
23315
  try {
22020
23316
  if (m[2] === "exec") {
22021
23317
  return json(res, 409, { error: "the VPS console is available to the bot through its scoped computer tools" });
@@ -22027,13 +23323,20 @@ const handleRequest = async (req, res) => {
22027
23323
  return json(res, 409, { error: "the VPS computer is being used by this bot — interrupt the turn first" });
22028
23324
  }
22029
23325
  if (m[2] === "join") {
22030
- return json(res, 200, await vps.vpsComputerJoin(cfg, botId));
23326
+ const remote = auth.kind !== "loopback";
23327
+ const result = await vps.vpsComputerJoin(cfg, botId, undefined, remote);
23328
+ return json(res, 200, { ...result, joinUrl: remote
23329
+ ? desktopViewerUrl(`vps/${botId}`, bot.threadId) : result.joinUrl });
22031
23330
  }
22032
23331
  const action = m[2] === "provision" ? "provision" : m[2] === "remove" ? "remove" : "stop";
22033
23332
  return json(res, 200, await vps.vpsComputerAction(action, cfg, botId));
22034
23333
  }
22035
23334
  finally {
22036
23335
  releaseComputerLifecycle();
23336
+ if (joinSettled) {
23337
+ vpsJoinRequests.delete(botId);
23338
+ joinSettled();
23339
+ }
22037
23340
  }
22038
23341
  }
22039
23342
  const activeBoatTurn = botHasActiveTurn(botId);
@@ -22098,6 +23401,7 @@ const handleRequest = async (req, res) => {
22098
23401
  }
22099
23402
  };
22100
23403
  const server = createServer(handleRequest);
23404
+ desktopViewer.attach(server, handleRequest);
22101
23405
  calendarCalls.start();
22102
23406
  // Resolve the edition before accepting requests so /api/edition is never a guess.
22103
23407
  console.log(describeEdition(await loadEnterpriseLayer()));
@@ -22278,6 +23582,7 @@ if (TUNNEL_SOCKET) {
22278
23582
  if (process.platform !== "win32")
22279
23583
  rmSync(TUNNEL_SOCKET, { force: true });
22280
23584
  tunnelListener = createServer(handleRequest);
23585
+ desktopViewer.attach(tunnelListener, handleRequest);
22281
23586
  tunnelListener.listen(TUNNEL_SOCKET, () => {
22282
23587
  console.log(`openmausbot tunnel listener on ${TUNNEL_SOCKET}`);
22283
23588
  });
@@ -22296,6 +23601,7 @@ const gracefulShutdown = createGracefulShutdown({
22296
23601
  sharedComputers.close();
22297
23602
  sharedComputerControl.close();
22298
23603
  browserLive.closeAll();
23604
+ desktopViewer.closeAll();
22299
23605
  for (const idle of localVmIdles.values())
22300
23606
  idle.cancel();
22301
23607
  vps.closeAllVpsDesktopTunnels();
@@ -22332,6 +23638,7 @@ const gracefulShutdown = createGracefulShutdown({
22332
23638
  code = 1;
22333
23639
  }
22334
23640
  closeMessageDb();
23641
+ mcpOAuth.dispose();
22335
23642
  releaseDataDirLeaseAtExit();
22336
23643
  // A Cloud home's launcher starts the server again on this code only.
22337
23644
  process.exit(cloudHomeRestartRequested && code === 0 ? CLOUD_HOME_RESTART_EXIT_CODE : code);