openmausbot 0.1.91 → 0.1.92

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 (150) hide show
  1. package/dist/assets/DesktopViewer-Do6lBa4C.js +5 -0
  2. package/dist/assets/{abnfDiagram-O67JEVCF-yP-w8pRq.js → abnfDiagram-O67JEVCF-Cd3X7Hxc.js} +1 -1
  3. package/dist/assets/{arc-DsivfZEh.js → arc-DIYc7Ymx.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CILw7lzT.js → architectureDiagram-NJMV4G6O-DBmRGVyP.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-DEQUV3HF.js → blockDiagram-BEXU5L5S-CPFePSEE.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-lSXCZoKA.js → c4Diagram-YGBWAQC7-DD_DO1qy.js} +1 -1
  7. package/dist/assets/channel-Dtk5Mtic.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-QCD5bgWP.js → chunk-2Q5K7J3B-BNEeNSjX.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-C5-iZJuL.js → chunk-3FUC2YCW-BbinLaqH.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-cxsawMpY.js → chunk-5VM5RSS4-BBgYrgDR.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-DV8tV8H7.js → chunk-GTNCS2PH-ezXj-kA-.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-CJFZyfbW.js → chunk-GWA4HPMP-9FxZ3diV.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-BigbMXco.js → chunk-JWPE2WC7-CO-40JpI.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-oVxTfpDy.js → chunk-NETBCI7D-BEhjslYE.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CH4BiroW.js → chunk-WEXAMYUT-CNeq4n7_.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-KEDa6gax.js → chunk-XXDRQBXY-OsCY7KSi.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-BiPjfqVv.js → classDiagram-v2-NBCMYWYE-CloRUEtk.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-Caxew3OY.js → cose-bilkent-JH36ORCC-D1BGZhp4.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-B_Pui369.js → cynefin-OW5HDTMX-CUf4T2CN.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-X64ydIhP.js → cynefinDiagram-VND7K2PF-DPEZrFGA.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-TqYCI1E1.js → dagre-6A5THRUB-bDe6GIv5.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-TeOt3rAQ.js → diagram-22UHCM2B-CdcUQz55.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-D70BLhDA.js → diagram-3UASUU5V-BZpqGg7c.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-BbQDLwUC.js → diagram-ATOU4E4O-DvGM6ugp.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-ChNJZ1u-.js → diagram-CDSNMT55-DSTmMm0s.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-DMBl2APa.js → diagram-MLGK6HIB-15cucnMn.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-CEqDchOp.js → diagram-MPIPVDR6-tMbuaE6n.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-BVDKRp_-.js → ebnfDiagram-ZINNZB2B-BYxYzjHI.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-CRkofO9B.js → elk-276RUBZZ-v6bj852W.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-DruOMh5e.js → erDiagram-OPXOYQCR-5lBBlnd2.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-8GMQeB7t.js → flowDiagram-KWPJA3E3-CZyl1zIj.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-ORE9jcsM.js → ganttDiagram-FUAMR5RP-BiAI-ltZ.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-BmsedsWh.js → gitGraphDiagram-X574FWY7-BAZISkj3.js} +1 -1
  34. package/dist/assets/index-Cdmjrl28.css +1 -0
  35. package/dist/assets/{index-DKNEM8Q6.js → index-DGBaEXrM.js} +1 -1
  36. package/dist/assets/index-DR7YPMkW.js +597 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-E_p7AooJ.js → infoDiagram-VRGFBTTK-B6ooOy-Z.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-Y-B2mKda.js → ishikawaDiagram-OU5B5YK6-BZ9ksi-B.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-eA8RELcK.js → journeyDiagram-ZHPQQLJL-DWqlzIEe.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-DqIL2e8e.js → kanban-definition-PNTS6WVX-JX6piDwL.js} +1 -1
  41. package/dist/assets/{linear-yC_29R6e.js → linear-CmOWzX0P.js} +1 -1
  42. package/dist/assets/{mermaid.core-VhEMkSND.js → mermaid.core-CF1zwoQ-.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-oJ7kyQZy.js → mindmap-definition-NLK3R4M7-CGh2K4fW.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-BmfYcY0O.js → pegDiagram-GJSIUBJH-CDwYN0s7.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-DKvgP7w9.js → pieDiagram-5QR66LMP-1rfjJocB.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-BEKL3W0L.js → quadrantDiagram-O4NWA36T-tKPvPiok.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-BEsiqR5s.js → railroadDiagram-XR7U4H2S-BHLm5vXR.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-DWBcWvAP.js → requirementDiagram-PLB6GJNP-CB45STp7.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-rpZ2_O4s.js → sankeyDiagram-IPEJSGJF-55QA_huC.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-B99HQdJR.js → sequenceDiagram-PO4LG4MO-BckFmQHn.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-BjN0qT5T.js → sizeCapture-INFHLROL-BFCOJF11.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-BbsVgGsC.js → stateDiagram-v2-GCMORJYK-CZvmiVVN.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DG3gKkeR.js → swimlanes-2SLR337P-BpDUCEKy.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-M0GYW9Y3.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BTHOdtF_.js → timeline-definition-EJHVYXUP-BpxHq6IK.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-acLSLFwc.js → usecaseDiagram-POWQR4AR-Cj66vm02.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-C5JzNX9G.js → vennDiagram-UO4OBE2U-C9nRkWE_.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DL1-PrR_.js → wardleyDiagram-VNRHLVJA-D-me_nWj.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-Bvr1477p.js → xychartDiagram-PMCCYNJV-Dc_fL4mk.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/container-mcp.js +195 -15
  64. package/dist-server/drivers/agents-proxy.js +5 -4
  65. package/dist-server/enterprise/server/index.js +5 -1
  66. package/dist-server/index.js +9300 -4155
  67. package/dist-server/local-computer-proxy.js +128 -7
  68. package/dist-server/mcp-gate.js +11 -5
  69. package/dist-server/model-catalog/models-dev.snapshot.json +7299 -0
  70. package/dist-server/openmausbot.js +3910 -1182
  71. package/dist-server/pair-cli.js +3910 -1182
  72. package/dist-server/server/boat.js +2 -2
  73. package/dist-server/server/browser-engine.js +10 -1
  74. package/dist-server/server/browser-live.js +46 -2
  75. package/dist-server/server/browser-runtime.js +74 -8
  76. package/dist-server/server/browser-tool-shape.js +6 -4
  77. package/dist-server/server/chief-of-staff.js +1 -1
  78. package/dist-server/server/cloud-home-start.js +118 -26
  79. package/dist-server/server/cloud-home.js +39 -3
  80. package/dist-server/server/cloud-lending.js +74 -10
  81. package/dist-server/server/cloud-owner.js +196 -0
  82. package/dist-server/server/cloud-secrets-boot.js +6 -0
  83. package/dist-server/server/cloud-secrets.js +65 -0
  84. package/dist-server/server/config.js +174 -14
  85. package/dist-server/server/container-computer.js +3 -2
  86. package/dist-server/server/desktop-viewer-targets.js +26 -0
  87. package/dist-server/server/drivers/acp/core.js +167 -21
  88. package/dist-server/server/drivers/acp/opencode-go.js +614 -85
  89. package/dist-server/server/drivers/agents-catalog.js +6 -6
  90. package/dist-server/server/drivers/antigravity-acp.js +69 -11
  91. package/dist-server/server/drivers/antigravity-temp.js +360 -0
  92. package/dist-server/server/drivers/antigravity.js +21 -0
  93. package/dist-server/server/drivers/chatgpt-plan-auth.js +481 -0
  94. package/dist-server/server/drivers/claude.js +312 -34
  95. package/dist-server/server/drivers/codex.js +228 -18
  96. package/dist-server/server/drivers/grok.js +2 -2
  97. package/dist-server/server/drivers/mistral.js +4 -3
  98. package/dist-server/server/drivers/openai-chat-protocol.js +4 -3
  99. package/dist-server/server/drivers/openai-chat.js +41 -3
  100. package/dist-server/server/drivers/openai-compat.js +73 -23
  101. package/dist-server/server/engine-install.js +46 -12
  102. package/dist-server/server/env-path.js +15 -3
  103. package/dist-server/server/harness/registry.js +2 -2
  104. package/dist-server/server/index.js +1338 -184
  105. package/dist-server/server/lending-memory.js +479 -0
  106. package/dist-server/server/local-vm-idle.js +24 -6
  107. package/dist-server/server/managed-desktop.js +3 -0
  108. package/dist-server/server/mcp-bridge.js +5 -3
  109. package/dist-server/server/mcp-http.js +6 -3
  110. package/dist-server/server/mcp-oauth-discovery.js +133 -0
  111. package/dist-server/server/mcp-oauth-store.js +70 -0
  112. package/dist-server/server/mcp-oauth.js +550 -0
  113. package/dist-server/server/mcp-probe.js +14 -1
  114. package/dist-server/server/mcp-registry.js +43 -0
  115. package/dist-server/server/memory-upkeep.js +94 -84
  116. package/dist-server/server/model-catalog/catalog.js +374 -0
  117. package/dist-server/server/model-catalog/presets.js +176 -0
  118. package/dist-server/server/model-catalog/trim.js +219 -0
  119. package/dist-server/server/provider-key-check.js +4 -2
  120. package/dist-server/server/recent-work.js +1 -1
  121. package/dist-server/server/request-auth.js +23 -0
  122. package/dist-server/server/routes/antigravity-leftovers.js +45 -0
  123. package/dist-server/server/routes/bot-memory.js +31 -6
  124. package/dist-server/server/routes/desktop-viewer.js +194 -0
  125. package/dist-server/server/routine-requests.js +1 -1
  126. package/dist-server/server/routines.js +1 -1
  127. package/dist-server/server/sessions.js +56 -9
  128. package/dist-server/server/skills.js +25 -10
  129. package/dist-server/server/store.js +62 -122
  130. package/dist-server/server/thread-events.js +2 -0
  131. package/dist-server/server/thread-starters.js +33 -0
  132. package/dist-server/server/thread-title.js +46 -0
  133. package/dist-server/server/tts/fish.js +4 -5
  134. package/dist-server/server/tts/index.js +5 -3
  135. package/dist-server/server/vps-computer.js +79 -12
  136. package/dist-server/server/webhooks.js +18 -1
  137. package/dist-server/server/workspace-backup-policy.js +3 -0
  138. package/dist-server/server/workspace-backup.js +3 -0
  139. package/dist-server/server/workspace.js +65 -15
  140. package/dist-server/shared/workspace-backup-client.js +1 -0
  141. package/dist-server/src/lib/citations.js +177 -0
  142. package/dist-server/src/lib/composer-attachments.js +7 -1
  143. package/dist-server/vps-container-mcp.js +196 -15
  144. package/dist-server/workspace-backup.worker.js +73 -9
  145. package/enterprise/server/index.js +5 -1
  146. package/package.json +1 -1
  147. package/dist/assets/channel-DrfGMu7X.js +0 -1
  148. package/dist/assets/index-BlnCEcU6.js +0 -588
  149. package/dist/assets/index-DVxjOvID.css +0 -1
  150. package/dist/assets/swimlanesDiagram-TC7HE7FX-BCZhxl7e.js +0 -8
@@ -442,6 +442,10 @@ export function roomResponders(text, members, defaultResponder) {
442
442
  }
443
443
  return [];
444
444
  }
445
+ /** A hidden routine execution is not a conversation the person can open. */
446
+ function taskCountsAsBotUnread(task) {
447
+ return Boolean(task.unread) && !task.routineRunId;
448
+ }
445
449
  export class Store {
446
450
  bots = [];
447
451
  groups = [];
@@ -721,6 +725,13 @@ export class Store {
721
725
  task.unread = task === active && b.unread;
722
726
  botsMigrated = true;
723
727
  }
728
+ // Older builds left a failed hidden run unread, which kept the bot
729
+ // dot on after every visible chat was read. Clear it before the
730
+ // recompute below so the saved roster heals.
731
+ if (task.routineRunId && task.unread) {
732
+ task.unread = false;
733
+ botsMigrated = true;
734
+ }
724
735
  if (task === active) {
725
736
  if (task.rewound === undefined && b.rewound !== undefined) {
726
737
  task.rewound = b.rewound;
@@ -742,7 +753,7 @@ export class Store {
742
753
  task.turnStartedAt = undefined;
743
754
  }
744
755
  this.mirrorActiveTask(b, active);
745
- b.unread = b.tasks.some((task) => task.unread);
756
+ b.unread = b.tasks.some(taskCountsAsBotUnread);
746
757
  }
747
758
  if (botsMigrated)
748
759
  this.saveBots();
@@ -819,13 +830,20 @@ export class Store {
819
830
  if (groupsDirty)
820
831
  this.saveGroups();
821
832
  }
833
+ /** Resolve the identity of a historical pair row, including a sender
834
+ * recreated with the same unique name. */
835
+ openerIdentity(openedBy) {
836
+ if (this.bot(openedBy.botId))
837
+ return openedBy.botId;
838
+ const named = this.bots.filter((bot) => bot.name === openedBy.name);
839
+ return named.length === 1 ? named[0].id : openedBy.botId;
840
+ }
822
841
  /** At most one live pair row per (recipient, sender identity). Servers
823
842
  * before identity-stable matching minted a second live pair row for a
824
843
  * deleted-and-recreated sender while the predecessor's row dangled live
825
- * forever; collapse those on load by keeping the row the resolver favors
826
- * (first in the task list — the newest, actively used one) and demoting
827
- * the rest to closed plain threads. History is kept, never deleted, and
828
- * a demoted row is never re-adopted: adoption skips closed rows. Runs on
844
+ * forever; collapse those on load by keeping the first row in the task
845
+ * list (the newest, actively used one) and demoting
846
+ * the rest to closed plain threads. History is kept, never deleted. Runs on
829
847
  * every load and touches nothing in a store that already holds the
830
848
  * invariant. */
831
849
  repairDuplicatePairConversations() {
@@ -840,8 +858,7 @@ export class Store {
840
858
  pairs.set(identity, [...(pairs.get(identity) ?? []), task]);
841
859
  }
842
860
  for (const rows of pairs.values()) {
843
- // rows are in task-list order, the same order the resolver's
844
- // find() favors: the first is the one it keeps using.
861
+ // Rows are in task-list order; keep the newest.
845
862
  for (const duplicate of rows.slice(1)) {
846
863
  const by = duplicate.openedBy;
847
864
  const identity = this.openerIdentity(by);
@@ -856,8 +873,7 @@ export class Store {
856
873
  }
857
874
  }
858
875
  };
859
- // Pair rows only ever live on bots: the resolver requires a bot
860
- // recipient, and group tasks carry no peer stamps.
876
+ // Historical pair rows lived only on bots; group tasks have no peer stamps.
861
877
  for (const bot of this.bots)
862
878
  sweep(bot.tasks, () => { botsDirty = true; });
863
879
  if (botsDirty)
@@ -1796,7 +1812,7 @@ export class Store {
1796
1812
  Object.assign(task, { [key]: structuredClone(patch[key]) });
1797
1813
  }
1798
1814
  }
1799
- bot.unread = bot.tasks.some((candidate) => candidate.unread);
1815
+ bot.unread = bot.tasks.some(taskCountsAsBotUnread);
1800
1816
  }
1801
1817
  this.saveBots();
1802
1818
  this.emit({ type: "bot", botId: id });
@@ -2146,11 +2162,34 @@ export class Store {
2146
2162
  * current folder — unless the task already has a session (a thread from
2147
2163
  * before folders existed), which pins to the default so the folder can't
2148
2164
  * move under it. Returns the pinned value: a path, or null for default. */
2165
+ /** Forget where a conversation works: its next turn pins it again
2166
+ * (pinTaskCwd). For a Cloud home's conversation a revoked session opened,
2167
+ * whose next turn must work in its own folder (server/cloud-owner.ts). */
2168
+ unpinTaskCwd(threadId) {
2169
+ const bot = this.botByThread(threadId);
2170
+ const task = bot ? this.taskByThread(bot.id, threadId) : undefined;
2171
+ if (!bot || !task || task.cwd === undefined)
2172
+ return false;
2173
+ delete task.cwd;
2174
+ this.saveBots();
2175
+ this.emit({ type: "bot", botId: bot.id });
2176
+ return true;
2177
+ }
2149
2178
  pinTaskCwd(botId, threadId, fallbackCwd, opts = {}) {
2150
2179
  const bot = this.bot(botId);
2151
2180
  const task = bot ? this.taskByThread(botId, threadId) : undefined;
2152
2181
  if (!bot || !task)
2153
2182
  return null;
2183
+ // Its own folder, never the bot's project folder (a Cloud home's guest
2184
+ // conversation), pinned when it first runs. A conversation that already
2185
+ // ran elsewhere keeps its pin: it was not opened that way, and moving it
2186
+ // would lose its work and its session.
2187
+ if (opts.privateOnly && task.cwd === undefined) {
2188
+ task.cwd = fallbackCwd ?? null;
2189
+ this.saveBots();
2190
+ this.emit({ type: "bot", botId });
2191
+ return task.cwd;
2192
+ }
2154
2193
  if (opts.none) {
2155
2194
  if (task.cwd !== null) {
2156
2195
  task.cwd = null;
@@ -2318,9 +2357,13 @@ export class Store {
2318
2357
  task.title = patch.title.trim().slice(0, 80) || UNTITLED_THREAD;
2319
2358
  if (Object.prototype.hasOwnProperty.call(patch, "pinned") && task.pinned !== true)
2320
2359
  delete task.pinned;
2360
+ // Still hidden: attention belongs on the source conversation. A same-call
2361
+ // clear of routineRunId (no-source promotion) may keep unread.
2362
+ if (task.routineRunId)
2363
+ task.unread = false;
2321
2364
  if (bot.threadId === threadId)
2322
2365
  this.mirrorActiveTask(bot, task);
2323
- bot.unread = bot.tasks.some((candidate) => candidate.unread);
2366
+ bot.unread = bot.tasks.some(taskCountsAsBotUnread);
2324
2367
  this.saveBots();
2325
2368
  this.emit({ type: "bot", botId });
2326
2369
  return task;
@@ -2399,8 +2442,10 @@ export class Store {
2399
2442
  bot.pinnedMessageId = task.pinnedMessageId;
2400
2443
  }
2401
2444
  /** A fresh context on the same bot: new thread, new session, same
2402
- * persona/tools/computer. Becomes the active task. */
2403
- createTask(botId, title, activate = true, projectId, openedBy, approvalMode) {
2445
+ * persona/tools/computer. Becomes the active task. It runs on the bot's
2446
+ * default model unless the caller hands it one (a thread opened from
2447
+ * another of this bot's threads keeps that thread's model). */
2448
+ createTask(botId, title, activate = true, projectId, openedBy, approvalMode, modelSelection) {
2404
2449
  const bot = this.bot(botId);
2405
2450
  if (!bot)
2406
2451
  return null;
@@ -2415,7 +2460,7 @@ export class Store {
2415
2460
  ...(projectId ? { projectId } : {}),
2416
2461
  ...(openedBy ? { openedBy: structuredClone(openedBy) } : {}),
2417
2462
  resumeCursors: {},
2418
- modelSelection: structuredClone(bot.modelSelection),
2463
+ modelSelection: structuredClone(modelSelection ?? bot.modelSelection),
2419
2464
  approvalMode: approvalMode ?? approvalModeFor(bot),
2420
2465
  autoApprove: approvalMode ? false : Boolean(bot.autoApprove),
2421
2466
  alwaysAllow: approvalMode ? [] : [...(bot.alwaysAllow ?? [])],
@@ -2463,112 +2508,6 @@ export class Store {
2463
2508
  this.emit({ type: "bot", botId });
2464
2509
  return task;
2465
2510
  }
2466
- /** Where a bot-to-bot send outside a room lands: the PAIR CONVERSATION
2467
- * for (sender, recipient) — the recipient's task stamped `openedBy` this
2468
- * sender with kind "pair".
2469
- *
2470
- * Its scope is global for those two bots: deliberately not per source
2471
- * thread and not per assignment, so a teammate you work with all day is
2472
- * one readable row in the recipient's sidebar that remembers what was
2473
- * asked last time, instead of one row per message. Nothing about the
2474
- * caller's current turn takes part in choosing it — no dispatch
2475
- * generation, no request key — and never the recipient's selected
2476
- * thread, which belongs to the person.
2477
- *
2478
- * Two things bend that rule, both deliberately:
2479
- *
2480
- * adoption — a recipient still carrying threads this sender opened
2481
- * before pair conversations existed (one per assignment, each titled
2482
- * with a sliced brief) has its most recently active one stamped as the
2483
- * pair conversation instead of gaining yet another row, so the sprawl
2484
- * stops on upgrade day. Nothing is deleted or closed. A start_thread
2485
- * handoff is left alone: the sender named that job itself and tracks
2486
- * it by its own delegation id.
2487
- *
2488
- * concurrency — a second assignment arriving while the pair
2489
- * conversation is still working (`working`, which the caller answers
2490
- * from live turn state) gets its own work thread, so two jobs never
2491
- * interleave in one transcript. `label` names that thread; the caller
2492
- * closes it once its result has been reported. A pair conversation
2493
- * never auto-closes. */
2494
- /** The identity a peer-opened row belongs to: its opener's id while that
2495
- * bot lives, else the one live bot the stamp's name still points at —
2496
- * what a deleted-and-recreated same-name bot inherits — else the dead id
2497
- * itself. A name two live bots share resolves to nobody's twin:
2498
- * ambiguous means unmatched, never a wrong merge. */
2499
- openerIdentity(openedBy) {
2500
- if (this.bot(openedBy.botId))
2501
- return openedBy.botId;
2502
- const named = this.bots.filter((bot) => bot.name === openedBy.name);
2503
- return named.length === 1 ? named[0].id : openedBy.botId;
2504
- }
2505
- resolvePairConversation(sender, recipientId, options) {
2506
- if (!this.bot(recipientId))
2507
- return null;
2508
- const title = `@${sender.name}`;
2509
- const opener = (kind, at = Date.now()) => ({ botId: sender.id, name: sender.name, kind, at });
2510
- // Identity-stable: the sender's own rows, plus ones a deleted
2511
- // predecessor opened when the stamp's name still points at exactly
2512
- // this bot — a same-name recreation inherits the conversation instead
2513
- // of minting a twin while the old row dangles live. A name two live
2514
- // bots share matches nobody's inheritance: refusing the fallback can
2515
- // cost a new row, never merge two bots' histories.
2516
- const fromSender = this.tasks(recipientId).filter((task) => task.openedBy && this.openerIdentity(task.openedBy) === sender.id);
2517
- let pair = fromSender.find((task) => task.openedBy?.kind === "pair");
2518
- if (!pair) {
2519
- const lastActivity = (task) => this.messagesTail(task.threadId, 1).messages.at(-1)?.at ?? task.openedBy?.at ?? task.createdAt;
2520
- const adopted = fromSender
2521
- .filter((task) => !task.openedBy?.kind && !task.openedBy?.delegationId && !task.closedBy)
2522
- .sort((a, b) => lastActivity(b) - lastActivity(a))[0];
2523
- if (adopted) {
2524
- // Keep the hour it was really opened: list_threads and the sidebar
2525
- // order by it, and adoption is not a new conversation.
2526
- this.setTaskOpenedBy(recipientId, adopted.threadId, opener("pair", adopted.openedBy?.at ?? adopted.createdAt));
2527
- // The title changes only when nobody typed it. The rule: rename it
2528
- // when it still equals what createTask made of the assignment that
2529
- // opened the thread — and that assignment is still the thread's
2530
- // first message, "@Recipient <brief>" — so the comparison is
2531
- // threadTitleFrom(that brief). Anything else is a name a person
2532
- // chose, and a thread with no request to read (its handoff never
2533
- // ran) cannot be checked, so both keep the title they have.
2534
- if (adopted.title === this.openingRequestTitle(recipientId, adopted.threadId)) {
2535
- this.renameTask(recipientId, adopted.threadId, title);
2536
- }
2537
- pair = adopted;
2538
- }
2539
- }
2540
- if (pair && !options.working(pair.threadId)) {
2541
- // A conversation the sender closed after reading a result is picked
2542
- // back up, never replaced: closing is only the sidebar's idle state.
2543
- if (pair.closedBy)
2544
- this.setTaskClosedBy(recipientId, pair.threadId, null);
2545
- // An inherited row carries the predecessor's id; rebind it to the
2546
- // live bot so the name fallback is needed only once — a namesake
2547
- // appearing later cannot claim the row. The hour it was opened
2548
- // stays: inheritance is not a new conversation.
2549
- const inherited = pair.openedBy;
2550
- if (inherited && inherited.botId !== sender.id) {
2551
- this.setTaskOpenedBy(recipientId, pair.threadId, { ...inherited, botId: sender.id, name: sender.name });
2552
- }
2553
- return { task: pair, created: false };
2554
- }
2555
- // The brief is never a title. An 80-character slice of an assignment
2556
- // is the row nobody can read, and a durable conversation outlives the
2557
- // one brief that opened it.
2558
- const task = this.createTask(recipientId, pair ? `${title} · ${options.label || "parallel work"}` : title, false, undefined, opener(pair ? "work" : "pair"));
2559
- return task ? { task, created: true } : null;
2560
- }
2561
- /** The title a peer-opened thread was born with: what createTask made of
2562
- * the request that opened it, which is still the first message in it,
2563
- * addressed "@Recipient <brief>". null when there is no such message to
2564
- * read — an unrun handoff proves nothing about who named the row. */
2565
- openingRequestTitle(recipientId, threadId) {
2566
- const first = this.messagesFor(threadId)[0]?.text?.trim();
2567
- if (!first)
2568
- return null;
2569
- const addressed = `@${this.bot(recipientId)?.name ?? ""} `;
2570
- return threadTitleFrom(first.startsWith(addressed) ? first.slice(addressed.length) : first);
2571
- }
2572
2511
  switchTask(botId, threadId) {
2573
2512
  const bot = this.bot(botId);
2574
2513
  const task = bot?.tasks?.find((t) => t.threadId === threadId);
@@ -2598,7 +2537,8 @@ export class Store {
2598
2537
  /** Swap a machine-made first-message title for a generated one, once.
2599
2538
  * Equality against the snippet is the whole contract: a rename by the
2600
2539
  * person, by pair adoption, or by an earlier generated title each break
2601
- * it, so this never overwrites a name anyone chose. */
2540
+ * it, so this never overwrites a name anyone chose. Regenerate title passes
2541
+ * the title it started from, so a rename made while it ran stands. */
2602
2542
  retitleTask(botId, threadId, machineTitle, title) {
2603
2543
  const task = this.taskByThread(botId, threadId);
2604
2544
  if (!task || task.title !== machineTitle)
@@ -2620,7 +2560,7 @@ export class Store {
2620
2560
  this.mirrorActiveTask(bot, visible);
2621
2561
  }
2622
2562
  this.deleteThreadRecord(threadId);
2623
- bot.unread = bot.tasks.some((task) => task.unread);
2563
+ bot.unread = bot.tasks.some(taskCountsAsBotUnread);
2624
2564
  this.refreshBotActivity(bot);
2625
2565
  this.saveBots();
2626
2566
  this.emit({ type: "bot", botId });
@@ -215,6 +215,8 @@ function isRuntimeEvent(value) {
215
215
  value.source === "peer"));
216
216
  case "thread.token-usage.updated":
217
217
  return typeof value.input === "number" && typeof value.output === "number";
218
+ case "runtime.notice":
219
+ return typeof value.message === "string";
218
220
  case "runtime.error":
219
221
  return typeof value.message === "string" &&
220
222
  (value.setup === undefined || typeof value.setup === "boolean") &&
@@ -35,6 +35,39 @@ export class ThreadStarters {
35
35
  get(threadId) {
36
36
  return this.starters.get(threadId);
37
37
  }
38
+ /** The threads opened for one person, newest first, at most `limit`. */
39
+ threadsOf(person, limit) {
40
+ const threads = [];
41
+ for (const [threadId, starter] of [...this.starters].reverse()) {
42
+ if (starter !== person)
43
+ continue;
44
+ threads.push(threadId);
45
+ if (threads.length >= limit)
46
+ break;
47
+ }
48
+ return threads;
49
+ }
50
+ /** Everyone a thread here was opened for. */
51
+ people() {
52
+ return new Set(this.starters.values());
53
+ }
54
+ /** Name `to` for every thread whose person `move` accepts: a one-time
55
+ * migration (server/cloud-owner.ts), not an ordinary record. The threads
56
+ * that changed. */
57
+ reassign(move, to) {
58
+ if (!KEY.test(to))
59
+ return [];
60
+ const moved = [];
61
+ for (const [threadId, person] of this.starters) {
62
+ if (person === to || !move(person))
63
+ continue;
64
+ this.starters.set(threadId, to);
65
+ moved.push(threadId);
66
+ }
67
+ if (moved.length)
68
+ writeFileAtomic(this.file, JSON.stringify(Object.fromEntries(this.starters)) + "\n", { mode: 0o600 });
69
+ return moved;
70
+ }
38
71
  /** Record once; a thread keeps the person it was first opened for. */
39
72
  set(threadId, person) {
40
73
  if (!person || !KEY.test(person) || !THREAD.test(threadId) || this.starters.has(threadId))
@@ -0,0 +1,46 @@
1
+ // What a generated thread title is asked from.
2
+ //
3
+ // A fresh thread is named from its first message. "Regenerate title" (#1858)
4
+ // names it again from the conversation as it is now: the newest user and bot
5
+ // lines, never tool output, cards or screen frames, and bounded so the
6
+ // one-shot stays as cheap as the first-message one.
7
+ import { redactSecretsInText } from "./redact.js";
8
+ /** The most text either kind of title one-shot ever sees. */
9
+ export const TITLE_INPUT_MAX_CHARS = 1_500;
10
+ const EXCERPT_MAX_MESSAGES = 12;
11
+ const EXCERPT_LINE_MAX_CHARS = 300;
12
+ const ATTACHMENT_TAG = /<attached-(?:image|file)\b[^>]*\/>/g;
13
+ export function threadTitlePrompt(text, source = "first-message") {
14
+ return [
15
+ source === "conversation"
16
+ ? "Name the conversation below by what it is about now."
17
+ : "Name the conversation that begins with the message below.",
18
+ "Reply with only a short title: 3 to 6 words, plain text, no quotes, no trailing period.",
19
+ source === "conversation" ? "Conversation:" : "Message:",
20
+ text.trim().slice(0, TITLE_INPUT_MAX_CHARS),
21
+ ].join("\n");
22
+ }
23
+ /** The newest user and bot text lines, oldest first, one per line and each
24
+ * clipped, until the next line would pass the input cap. Attachment markup
25
+ * and secrets are scrubbed before anything leaves this machine. Empty when
26
+ * the thread has nothing a title could be drawn from. */
27
+ export function titleConversationExcerpt(messages) {
28
+ const lines = [];
29
+ let length = 0;
30
+ for (let index = messages.length - 1; index >= 0 && lines.length < EXCERPT_MAX_MESSAGES; index--) {
31
+ const message = messages[index];
32
+ // peer asides and still-queued lines are not the conversation yet
33
+ if (message.kind !== "text" || !message.text || message.aside || message.queued)
34
+ continue;
35
+ const text = redactSecretsInText(message.text.replace(ATTACHMENT_TAG, " ")).replace(/\s+/g, " ").trim();
36
+ if (!text)
37
+ continue;
38
+ const clipped = text.length > EXCERPT_LINE_MAX_CHARS ? `${text.slice(0, EXCERPT_LINE_MAX_CHARS - 1)}…` : text;
39
+ const line = `${message.role === "user" ? "User" : "Bot"}: ${clipped}`;
40
+ if (length + line.length + 1 > TITLE_INPUT_MAX_CHARS)
41
+ break;
42
+ lines.push(line);
43
+ length += line.length + 1;
44
+ }
45
+ return lines.reverse().join("\n");
46
+ }
@@ -1,7 +1,6 @@
1
- // Fish Audio text to speech. The API key stays on the harness: the renderer
2
- // receives only voice metadata and synthesized audio bytes.
3
1
  const API = (process.env.OMB_FISH_AUDIO_API || "https://api.fish.audio").replace(/\/+$/, "");
4
- const MODEL = "s2.1-pro";
2
+ /** Used when no model is saved, so existing setups keep their behavior. */
3
+ export const DEFAULT_FISH_MODEL = "s2.1-pro";
5
4
  const MAX_ERROR_LENGTH = 240;
6
5
  const PAGE_SIZE = 100;
7
6
  const MAX_OWNED_PAGES = 100;
@@ -124,7 +123,7 @@ export async function listVoices(key) {
124
123
  });
125
124
  return [...new Map(voices.map((voice) => [voice.id, voice])).values()];
126
125
  }
127
- export async function synthesize(text, voiceId, key) {
126
+ export async function synthesize(text, voiceId, key, model = DEFAULT_FISH_MODEL) {
128
127
  let res;
129
128
  try {
130
129
  res = await fetch(`${API}/v1/tts`, {
@@ -133,7 +132,7 @@ export async function synthesize(text, voiceId, key) {
133
132
  authorization: `Bearer ${key}`,
134
133
  "content-type": "application/json",
135
134
  accept: "audio/mpeg",
136
- model: MODEL,
135
+ model,
137
136
  },
138
137
  body: JSON.stringify({
139
138
  text,
@@ -69,8 +69,9 @@ export function voiceReady(cfg, voiceId) {
69
69
  }
70
70
  /** What the settings panel needs. Never includes the key — same write-only
71
71
  * rule as every other credential. baseUrl and model are Chatterbox
72
- * settings, not credentials, so they come back in full. `included` says the
73
- * voice is Cloud Pro's, not a saved key. */
72
+ * settings, not credentials, so they come back in full; `fishModel` is the
73
+ * Fish Audio speech model, resolved to its default when unset. `included`
74
+ * says the voice is Cloud Pro's, not a saved key. */
74
75
  export function describeVoice(cfg) {
75
76
  const provider = voiceProvider(cfg);
76
77
  const included = provider === "elevenlabs" && elevenLabs(cfg)?.included === true;
@@ -81,6 +82,7 @@ export function describeVoice(cfg) {
81
82
  provider,
82
83
  baseUrl: provider === "chatterbox" ? (cfg.tts?.baseUrl ?? "") : "",
83
84
  model: provider === "chatterbox" ? (cfg.tts?.model ?? "") : "",
85
+ ...(provider === "fish" ? { fishModel: cfg.tts?.fishModel ?? fish.DEFAULT_FISH_MODEL } : {}),
84
86
  ...(included ? { included: true } : {}),
85
87
  };
86
88
  }
@@ -147,7 +149,7 @@ export function speak(cfg, text, voiceId, run) {
147
149
  const voice = voiceId || cfg.tts?.voice;
148
150
  if (!voice)
149
151
  throw new NoVoiceConfigured("voice");
150
- return fish.synthesize(text, voice, key);
152
+ return fish.synthesize(text, voice, key, cfg.tts?.fishModel);
151
153
  }
152
154
  const credential = elevenLabs(cfg);
153
155
  if (!credential)
@@ -85,6 +85,8 @@ const SCREENSHOT_CLEANUP_BUDGET_MS = 10_000;
85
85
  const PREPARATION_LOCK_TIMEOUT_MS = SCREENSHOT_BUDGET_MS + LOCK_ACQUIRE_TIMEOUT_MS;
86
86
  const pendingScreenshots = new Map();
87
87
  const viewerConnections = new Map();
88
+ const REMOTE_VIEWER_GRACE_MS = 30_000;
89
+ const NATIVE_VIEWER_LIFETIME_MS = 8 * 60 * 60_000;
88
90
  const desktopTunnels = new Map();
89
91
  function invalidateVpsStatus(key) {
90
92
  statusCache.delete(key);
@@ -198,12 +200,53 @@ function stopDesktopTunnel(botId) {
198
200
  return true;
199
201
  }
200
202
  export function closeVpsDesktopTunnel(botId) {
203
+ const tunnel = desktopTunnels.get(botId);
204
+ // Remote tabs still hold the tunnel; drop only the native claim and let
205
+ // the last tab's release start the grace period.
206
+ if (tunnel?.viewers) {
207
+ tunnel.localViewer = false;
208
+ clearTimeout(tunnel.expiry);
209
+ return { closed: false };
210
+ }
201
211
  return { closed: stopDesktopTunnel(botId) };
202
212
  }
203
213
  export function closeAllVpsDesktopTunnels() {
204
214
  for (const botId of desktopTunnels.keys())
205
215
  stopDesktopTunnel(botId);
206
216
  }
217
+ /** Resolve only a tunnel opened by the authenticated join route. Multiple
218
+ * remote tabs share it; the final tab releases it unless a native viewer owns it. */
219
+ export function vpsDesktopConnection(botId) {
220
+ const tunnel = desktopTunnels.get(botId);
221
+ if (!tunnel || tunnel.child.exitCode !== null || tunnel.child.killed)
222
+ return;
223
+ const url = new URL(tunnel.joinUrl);
224
+ return {
225
+ port: Number(url.port),
226
+ password: new URLSearchParams(url.hash.slice(1)).get("password"),
227
+ live: () => desktopTunnels.get(botId) === tunnel && tunnel.child.exitCode === null && !tunnel.child.killed,
228
+ retain: () => {
229
+ tunnel.viewers++;
230
+ if (!tunnel.localViewer)
231
+ clearTimeout(tunnel.expiry);
232
+ let released = false;
233
+ return () => {
234
+ if (released)
235
+ return;
236
+ released = true;
237
+ tunnel.viewers--;
238
+ if (!tunnel.viewers && !tunnel.localViewer && desktopTunnels.get(botId) === tunnel) {
239
+ // A short grace period lets a reload/reconnect reuse the tunnel.
240
+ tunnel.expiry = setTimeout(() => {
241
+ if (desktopTunnels.get(botId) === tunnel)
242
+ stopDesktopTunnel(botId);
243
+ }, REMOTE_VIEWER_GRACE_MS);
244
+ tunnel.expiry.unref();
245
+ }
246
+ };
247
+ },
248
+ };
249
+ }
207
250
  const STREAM_CAP_CHARS = 16 * 1024 * 1024;
208
251
  /** Keeps the LAST 16MB of a stream without rebuilding one giant string per
209
252
  * chunk (a 10-minute `docker build` stream made that rebuild quadratic).
@@ -317,7 +360,7 @@ function emptyStatus(botId, alias) {
317
360
  desktopReady: false,
318
361
  desktop_error: null,
319
362
  ready: false,
320
- problem: alias ? "Docker over SSH is not reachable" : "Configure a VPS SSH alias in App Settings → Connections",
363
+ problem: alias ? "Docker over SSH is not reachable" : "Configure a VPS SSH alias in Settings → API keys",
321
364
  image_ref: VPS_IMAGE,
322
365
  base_image_ref: BASE_IMAGE,
323
366
  driver_version: CUA_DRIVER_VERSION,
@@ -335,6 +378,9 @@ function isMissingObjectMessage(message) {
335
378
  return /no such (object|image|container)/i.test(message);
336
379
  }
337
380
  function transportFailure(message) {
381
+ if (/timed out/i.test(message)) {
382
+ return "Docker over SSH failed while checking the VPS: the connection timed out. Check that the VPS is online, SSH is reachable from this computer, and Docker is running on the VPS, then retry. No computer was reset.";
383
+ }
338
384
  return `Docker over SSH failed while checking the VPS: ${message.trim().slice(0, 200) || "unknown transport error"}`;
339
385
  }
340
386
  function privateDockerIpv4(value) {
@@ -371,7 +417,7 @@ function hasNoPublishedPorts(config, networks) {
371
417
  }
372
418
  function statusProblem(status) {
373
419
  if (!status.configured)
374
- return "Configure a VPS SSH alias in App Settings → Connections";
420
+ return "Configure a VPS SSH alias in Settings → API keys";
375
421
  if (!status.daemonUp)
376
422
  return "Docker over SSH could not reach the VPS; check the SSH alias and Docker on the VPS";
377
423
  if (!status.image)
@@ -412,9 +458,23 @@ async function computeVpsComputerStatus(cfg, botId, runner) {
412
458
  return status;
413
459
  viewerConnections.delete(`${alias}:${status.container_name}`);
414
460
  const run = (args, timeoutMs = 10_000, input) => runner(vpsDockerArgs(alias, args), { timeoutMs, input });
461
+ // Only repeat read-only Docker inspections, never an exec or lifecycle
462
+ // mutation whose outcome is unknown after a lost connection. Awaiting the
463
+ // runner also lets its owned SSH process finish cleanup before retrying.
464
+ const inspect = async (args) => {
465
+ try {
466
+ return await run(args);
467
+ }
468
+ catch (error) {
469
+ const message = error instanceof Error ? error.message : String(error);
470
+ if (!/timed out|connection reset by peer|broken pipe/i.test(message))
471
+ throw error;
472
+ return run(args);
473
+ }
474
+ };
415
475
  let inspectedImageId = null;
416
476
  try {
417
- const inspected = JSON.parse((await run(["image", "inspect", VPS_IMAGE])).stdout);
477
+ const inspected = JSON.parse((await inspect(["image", "inspect", VPS_IMAGE])).stdout);
418
478
  status.daemonUp = true;
419
479
  const image = inspected[0];
420
480
  const labels = image?.Config?.Labels ?? image?.config?.Labels ?? image?.config?.labels;
@@ -434,7 +494,7 @@ async function computeVpsComputerStatus(cfg, botId, runner) {
434
494
  status.image = false;
435
495
  }
436
496
  try {
437
- const inspected = JSON.parse((await run(["inspect", status.container_name])).stdout);
497
+ const inspected = JSON.parse((await inspect(["inspect", status.container_name])).stdout);
438
498
  const detail = inspected[0];
439
499
  const labels = detail?.Config?.Labels;
440
500
  const containerId = detail?.Id ?? detail?.id;
@@ -897,7 +957,7 @@ export async function removeManagedVpsComputer(cfg, owners, containerName, confi
897
957
  cfg = snapshotVpsConfig(cfg);
898
958
  const alias = vpsSshAlias(cfg);
899
959
  if (!alias) {
900
- throw Object.assign(new Error("VPS is not configured — add an SSH config alias in Connections"), { status: 409 });
960
+ throw Object.assign(new Error("VPS is not configured — add an SSH config alias in Settings → API keys"), { status: 409 });
901
961
  }
902
962
  if (!MANAGED_VPS_CONTAINER_NAME.test(containerName)) {
903
963
  throw Object.assign(new Error("invalid managed VPS computer name"), { status: 400 });
@@ -948,7 +1008,7 @@ export async function vpsComputerAction(action, cfg, botId, runner = defaultRunn
948
1008
  cfg = snapshotVpsConfig(cfg);
949
1009
  const alias = vpsSshAlias(cfg);
950
1010
  if (!alias)
951
- throw Object.assign(new Error("VPS is not configured — add an SSH config alias in App Settings → Connections"), { status: 409 });
1011
+ throw Object.assign(new Error("VPS is not configured — add an SSH config alias in Settings → API keys"), { status: 409 });
952
1012
  const key = `${alias}:${vpsContainerName(botId)}`;
953
1013
  const pending = action === "provision" ? pendingProvisions.get(key) : undefined;
954
1014
  if (pending)
@@ -1054,13 +1114,20 @@ export async function inspectVpsForAuto(cfg, botId, runner = defaultRunner) {
1054
1114
  * returned URL is loopback-only and contains the per-container password in
1055
1115
  * its fragment (never sent in HTTP). Closing the app viewer calls the paired
1056
1116
  * close endpoint, and process shutdown closes every remaining child. */
1057
- export async function vpsComputerJoin(cfg, botId, runner = defaultRunner) {
1117
+ export async function vpsComputerJoin(cfg, botId, runner = defaultRunner, remoteViewer = false) {
1058
1118
  cfg = snapshotVpsConfig(cfg);
1059
1119
  const alias = vpsSshAlias(cfg);
1060
1120
  if (!alias)
1061
1121
  throw Object.assign(new Error("VPS is not configured"), { status: 409 });
1062
1122
  const existing = desktopTunnels.get(botId);
1063
1123
  if (existing && existing.child.exitCode === null && !existing.child.killed) {
1124
+ if (!remoteViewer)
1125
+ existing.localViewer = true;
1126
+ if (!existing.viewers || existing.localViewer) {
1127
+ clearTimeout(existing.expiry);
1128
+ existing.expiry = setTimeout(() => stopDesktopTunnel(botId), existing.localViewer ? NATIVE_VIEWER_LIFETIME_MS : REMOTE_VIEWER_GRACE_MS);
1129
+ existing.expiry.unref();
1130
+ }
1064
1131
  return { joinUrl: existing.joinUrl, state: "running" };
1065
1132
  }
1066
1133
  stopDesktopTunnel(botId);
@@ -1114,11 +1181,11 @@ export async function vpsComputerJoin(cfg, botId, runner = defaultRunner) {
1114
1181
  throw Object.assign(new Error(`Could not open the VPS live desktop: ${detail}`), { status: 502 });
1115
1182
  }
1116
1183
  const joinUrl = `http://127.0.0.1:${localPort}/vnc.html#autoconnect=true&resize=scale&password=${encodeURIComponent(connection.password)}`;
1117
- // Viewer-close is the normal cleanup. This unref'd ceiling is a backstop
1118
- // for a renderer crash or an old browser client that cannot signal close.
1119
- const expiry = setTimeout(() => stopDesktopTunnel(botId), 8 * 60 * 60_000);
1184
+ // Remote tabs retain the tunnel when their WebSocket opens. Reclaim an
1185
+ // abandoned join promptly; native windows retain the existing close/ceiling.
1186
+ const expiry = setTimeout(() => stopDesktopTunnel(botId), remoteViewer ? REMOTE_VIEWER_GRACE_MS : NATIVE_VIEWER_LIFETIME_MS);
1120
1187
  expiry.unref?.();
1121
- desktopTunnels.set(botId, { child, joinUrl, expiry });
1188
+ desktopTunnels.set(botId, { child, joinUrl, expiry, viewers: 0, localViewer: !remoteViewer });
1122
1189
  return { joinUrl, state: "running" };
1123
1190
  }
1124
1191
  export function vpsContainerMcpArgs(alias, containerName) {
@@ -1142,7 +1209,7 @@ export function vpsDriverError(driverKind, computerMcp) {
1142
1209
  return "The Computer engine runs its agent on Boat and cannot use a self-hosted VPS — choose Claude or an ACP engine";
1143
1210
  }
1144
1211
  if (!computerMcp) {
1145
- return "This model engine cannot mount a self-hosted VPS computer — choose Claude or an ACP engine";
1212
+ return "This model cannot mount a self-hosted VPS computer — choose Claude or an ACP model provider";
1146
1213
  }
1147
1214
  return null;
1148
1215
  }