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
@@ -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;
@@ -757,7 +817,7 @@ export function vpsContainerRunArgs(containerName, imageRef = VPS_IMAGE, viewerS
757
817
  // someone opens the panel. unless-stopped survives reboots while still
758
818
  // honoring an explicit Stop. The shared hardening check accepts exactly
759
819
  // this policy for the VPS caller (and only "no"/unset for the Local VM,
760
- // whose desktop cannot safely resume).
820
+ // whose starts are controlled by OMB's turn lifecycle).
761
821
  "--restart",
762
822
  "unless-stopped",
763
823
  "-e",
@@ -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
  }
@@ -51,6 +51,10 @@ const storedWebhookSchema = z.object({
51
51
  updatedAt: z.number().finite().nonnegative(),
52
52
  lastReceivedAt: z.number().finite().nonnegative().optional(),
53
53
  lastRunId: z.string().optional(),
54
+ /** delivery:"post" only: the stable destination thread, resolved once
55
+ * via WebhookManagerOptions.resolvePostThread and reused forever
56
+ * after -- see the `post`/`resolvePostThread` doc comments above. */
57
+ resultsThreadId: z.string().min(1).optional(),
54
58
  deliveryCount: z.number().int().nonnegative(),
55
59
  verificationPending: z.boolean().optional(),
56
60
  verifiedAt: z.number().finite().nonnegative().optional(),
@@ -429,9 +433,22 @@ export class WebhookManager {
429
433
  // without a post sink is a configuration error, not a run request.
430
434
  if (!this.options.post)
431
435
  fail(503, "This server cannot post webhook messages to chat");
436
+ // Resolved on every dispatch, exactly like routines.ts's newRun calls
437
+ // resolveResultsThread on every run -- the resolver owns the
438
+ // reuse-vs-allocate decision from trigger.resultsThreadId, so a host
439
+ // without resolvePostThread wired can only fail closed here, never
440
+ // silently fall back to whatever thread the bot happens to have
441
+ // selected right now (the bug this replaces -- issue #2071).
442
+ const threadId = this.options.resolvePostThread?.(trigger, false);
443
+ if (!threadId)
444
+ fail(503, "This server could not resolve a destination thread for this delivery");
445
+ if (threadId !== trigger.resultsThreadId) {
446
+ trigger.resultsThreadId = threadId;
447
+ trigger.updatedAt = now;
448
+ }
432
449
  const raw = event.payload;
433
450
  const text = raw && typeof raw === "object" && typeof raw.text === "string" && raw.text.trim() ? raw.text : serializePayload(event.payload);
434
- this.options.post(trigger.botId, text.slice(0, 20000));
451
+ this.options.post(trigger.botId, threadId, text.slice(0, 20000));
435
452
  this.deliveries.push({ key: `${trigger.endpointId}:${deliveryId}`, runId: "post", at: now });
436
453
  if (this.deliveries.length > MAX_DELIVERIES)
437
454
  this.deliveries.splice(0, this.deliveries.length - MAX_DELIVERIES);
@@ -41,6 +41,9 @@ export function excludedWorkspaceAuthPath(path) {
41
41
  // Machine/provider-specific execution grants are not portable template data.
42
42
  if (/^command-allowlist\.json(?:$|\.\d+\.[0-9a-f-]+\.tmp$)/.test(path))
43
43
  return true;
44
+ // MCP sign-in tokens belong to this machine's browser sign-ins.
45
+ if (/^mcp-oauth\.json(?:$|\.\d+\.[0-9a-f-]+\.tmp$)/.test(path))
46
+ return true;
44
47
  return /^(?:(?:providers|caddy|chrome-profile|\.agent-browser)(?:\/|$)|workspace-credentials\.json$|external-runtimes\.json$|browser-engine-key$)/.test(path) ||
45
48
  /^(?:config\.json|webhooks\.json|workspace-credentials\.json|external-runtimes\.json|browser-engine-key|sessions\.json|tunnel-account\.json)\.\d+(?:\.[0-9a-f-]+)?\.tmp$/.test(path) ||
46
49
  /^(?:vm-home|vm-homes\/[^/]+)\/\.browser-profiles(?:\/|$)/.test(path);
@@ -29,11 +29,15 @@ const EXCLUDED = new Set([
29
29
  // This machine's decision-model log (server/decider/log.ts): local
30
30
  // measurement of what the classifier picked, not workspace data.
31
31
  "decider-log",
32
+ // A Cloud home's record of who its owner was (server/cloud-owner.ts): this
33
+ // machine's own, kept in place by a restore, which settles what it brought.
34
+ "cloud-owner.json",
32
35
  ]);
33
36
  const EXCLUSION_NOTES = [
34
37
  "Device pairing, server identity, live leases and runtime files (existing destination identities are preserved).",
35
38
  "Saved credentials, provider and MCP connections, managed provider login homes and browser login profiles are not transferred. Destination connections are preserved; reconnect on a new device.",
36
39
  "Downloaded tools and caches; these can be installed again.",
40
+ "Installed dependency folders (node_modules) and symbolic links inside conversation work folders; reinstall or recreate them in the project.",
37
41
  "External project folders, CLI login homes, browser session homes, OS keychains, companion devices and other servers.",
38
42
  "VM/container disk layers and remote cloud data; durable files inside this workspace are included.",
39
43
  ];
@@ -324,6 +328,8 @@ export async function createWorkspaceBackupSnapshot(dataDir, options, jobId) {
324
328
  const warnings = ["Stop external editors and managed desktops before exporting; files written outside this server cannot be frozen by the backup gate.", "Conversation text and user files are not redacted and may contain secrets you pasted. Keep the encrypted backup private.", RESTORE_WARNING];
325
329
  let bytes = 0;
326
330
  let skippedLinks = 0;
331
+ let skippedDependencies = 0;
332
+ let skippedWorkLinks = 0;
327
333
  try {
328
334
  const root = realpathSync(dataDir);
329
335
  const sourceDb = join(root, "messages.db");
@@ -367,6 +373,21 @@ export async function createWorkspaceBackupSnapshot(dataDir, options, jobId) {
367
373
  throw new Error("A workspace filename cannot be safely restored on supported platforms.");
368
374
  const source = join(directory, name);
369
375
  const stat = lstatSync(source);
376
+ // Installed dependencies come back from the project's lockfile, can
377
+ // hold more files than this format allows, and are often a link an
378
+ // agent made to another checkout's install. Leave them out.
379
+ if (name === "node_modules" && (stat.isDirectory() || stat.isSymbolicLink())) {
380
+ skippedDependencies++;
381
+ continue;
382
+ }
383
+ // A conversation's work folder (task-workspaces/<bot>/<thread>) holds
384
+ // what an agent made there, and agents link things in (another
385
+ // checkout, a virtualenv). A link's target is not this workspace's
386
+ // data, so the link is left out rather than failing the backup.
387
+ if (stat.isSymbolicLink() && path.startsWith("task-workspaces/")) {
388
+ skippedWorkLinks++;
389
+ continue;
390
+ }
370
391
  if (stat.isSymbolicLink()) {
371
392
  let target;
372
393
  try {
@@ -427,6 +448,10 @@ export async function createWorkspaceBackupSnapshot(dataDir, options, jobId) {
427
448
  walk(root);
428
449
  if (skippedLinks)
429
450
  warnings.push(`${skippedLinks} managed skill discovery link(s) were omitted and are recreated by the app.`);
451
+ if (skippedDependencies)
452
+ warnings.push(`${skippedDependencies} installed dependency folder(s) (node_modules) were omitted; reinstall them in the project after restoring.`);
453
+ if (skippedWorkLinks)
454
+ warnings.push(`${skippedWorkLinks} symbolic link(s) in conversation work folders were omitted; what they point to is outside this backup.`);
430
455
  const summary = {
431
456
  format: "openmaus.workspace-backup", version: 1, id: job.id, createdAt: new Date().toISOString(),
432
457
  appVersion: options.appVersion ?? "unknown", files: entries.filter((entry) => entry.type === "file").length,
@@ -8,7 +8,7 @@
8
8
  // memory/ holds topic files the bot reads on demand with its ordinary
9
9
  // file tools. Plain markdown on purpose — the user can open, edit, or
10
10
  // delete anything the bot believes.
11
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
11
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, statSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { writeFileAtomic } from "./atomic.js";
14
14
  import { isMemoryDate, untilMark, withoutExpired } from "./memory-entries.js";
@@ -20,7 +20,7 @@ export const WORKSPACES_DIR = join(DATA_DIR, "workspaces");
20
20
  export const TASK_WORKSPACES_DIR = join(DATA_DIR, "task-workspaces");
21
21
  /** MCP support does not imply native filesystem tools or a local working directory. */
22
22
  export function supportsWorkspaceFiles(driverKind) {
23
- return !["grok", "openai-compat", "minimax", "mistral", "boxAgent"].includes(driverKind);
23
+ return !["grok", "openai-compat", "minimax", "mistral", "cerebras", "boxAgent"].includes(driverKind);
24
24
  }
25
25
  /** Default task files are private to the thread, outside the bot's shared
26
26
  * memory folder. This is directory organization, not a shell sandbox. */
@@ -58,6 +58,53 @@ export function ensureWorkspace(botId) {
58
58
  export function workspaceDir(botId) {
59
59
  return join(WORKSPACES_DIR, botId);
60
60
  }
61
+ /** On an OMB Cloud home a bot's memory is read only from regular files,
62
+ * never through a link: server/lending-memory.ts judges a link by where it
63
+ * points, not by what is there, so a turn must never read through one.
64
+ * Elsewhere memory reads exactly as it always did. */
65
+ let regularMemoryFilesOnly = false;
66
+ export function readMemoryOnlyFromRegularFiles() {
67
+ regularMemoryFilesOnly = true;
68
+ }
69
+ /** A memory file's text. Throws like readFileSync when it cannot be read,
70
+ * and, on a Cloud home, when it is not a regular file. */
71
+ export function readMemoryText(path) {
72
+ if (!regularMemoryFilesOnly)
73
+ return readFileSync(path, "utf8");
74
+ // O_NOFOLLOW refuses a link at open where there is one; Windows has none
75
+ // (and opens what a link points at), so the entry is looked at first too.
76
+ if (!lstatSync(path).isFile())
77
+ throw Object.assign(new Error(`not a regular file: ${path}`), { code: "EINVAL" });
78
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
79
+ try {
80
+ if (!fstatSync(fd).isFile())
81
+ throw Object.assign(new Error(`not a regular file: ${path}`), { code: "EINVAL" });
82
+ return readFileSync(fd, "utf8");
83
+ }
84
+ finally {
85
+ closeSync(fd);
86
+ }
87
+ }
88
+ /** A memory folder's entries; on a Cloud home, none when the folder itself
89
+ * is a link. */
90
+ function memoryFolderEntries(dir) {
91
+ if (regularMemoryFilesOnly && !lstatSync(dir).isDirectory())
92
+ return [];
93
+ return readdirSync(dir);
94
+ }
95
+ function regularFile(path) {
96
+ try {
97
+ return lstatSync(path).isFile();
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ /** The stat a listing trusts: on a Cloud home, the entry's own (a link is
104
+ * not a file); elsewhere what it points at, as before. */
105
+ function memoryEntryStat(path) {
106
+ return regularMemoryFilesOnly ? lstatSync(path) : statSync(path);
107
+ }
61
108
  /** File locations, not file contents or wider tool permissions. Threads keep
62
109
  * independent working directories; the same bot can find its earlier output
63
110
  * without assuming that a file absent from the current directory was lost. */
@@ -89,7 +136,7 @@ export function memoryOverBudget(text) {
89
136
  export function loadMemory(botId, opts = {}) {
90
137
  let raw;
91
138
  try {
92
- raw = readFileSync(join(workspaceDir(botId), "MEMORY.md"), "utf8");
139
+ raw = readMemoryText(join(workspaceDir(botId), "MEMORY.md"));
93
140
  }
94
141
  catch {
95
142
  return null;
@@ -128,7 +175,7 @@ export const MEMORY_FILE_MAX_BYTES = 256 * 1024;
128
175
  export function readMemoryFile(botId) {
129
176
  let raw;
130
177
  try {
131
- raw = readFileSync(join(workspaceDir(botId), "MEMORY.md"), "utf8");
178
+ raw = readMemoryText(join(workspaceDir(botId), "MEMORY.md"));
132
179
  }
133
180
  catch {
134
181
  return { text: "", truncated: false };
@@ -159,7 +206,7 @@ function indexWrittenMemoryFile(botId, relativePath) {
159
206
  try {
160
207
  const path = join(workspaceDir(botId), relativePath);
161
208
  const stat = statSync(path);
162
- indexMemoryFile(botId, relativePath, searchableMemoryText(relativePath, readFileSync(path, "utf8")), { mtimeMs: stat.mtimeMs, bytes: stat.size });
209
+ indexMemoryFile(botId, relativePath, searchableMemoryText(relativePath, readMemoryText(path)), { mtimeMs: stat.mtimeMs, bytes: stat.size });
163
210
  }
164
211
  catch {
165
212
  // the next search's sync pass picks it up
@@ -176,7 +223,7 @@ function memoryFilesOnDisk(botId) {
176
223
  ];
177
224
  return names.flatMap((relativePath) => {
178
225
  try {
179
- const stat = statSync(join(dir, relativePath));
226
+ const stat = memoryEntryStat(join(dir, relativePath));
180
227
  return stat.isFile() ? [{ path: relativePath, mtimeMs: stat.mtimeMs, bytes: stat.size }] : [];
181
228
  }
182
229
  catch {
@@ -206,7 +253,7 @@ export function syncMemoryIndex(botId) {
206
253
  if (!dateChanged && known && known.bytes === file.bytes && known.mtimeMs === Math.trunc(file.mtimeMs))
207
254
  continue;
208
255
  try {
209
- const text = readFileSync(join(workspaceDir(botId), file.path), "utf8");
256
+ const text = readMemoryText(join(workspaceDir(botId), file.path));
210
257
  indexMemoryFile(botId, file.path, searchableMemoryText(file.path, text), file);
211
258
  }
212
259
  catch {
@@ -318,7 +365,7 @@ export function updateMemory(botId, update, opts = {}) {
318
365
  const dir = ensureWorkspace(botId);
319
366
  // Do not use readMemoryFile's editor-friendly missing/read-error fallback:
320
367
  // a failed read must never turn into a successful overwrite of old notes.
321
- const raw = readFileSync(join(dir, "MEMORY.md"), "utf8");
368
+ const raw = readMemoryText(join(dir, "MEMORY.md"));
322
369
  const current = raw === MEMORY_SEED ? "" : raw;
323
370
  const today = memoryDate(opts.now);
324
371
  // Every appended entry ends its own line; a file the person left without
@@ -419,7 +466,7 @@ export function appendMemoryLog(botId, text, opts = {}) {
419
466
  const path = join(dir, file);
420
467
  let current = "";
421
468
  try {
422
- current = readFileSync(path, "utf8");
469
+ current = readMemoryText(path);
423
470
  }
424
471
  catch {
425
472
  // first line of the day
@@ -442,7 +489,8 @@ export function writeMemoryLog(botId, name, text) {
442
489
  /** The bot's daily log files, oldest first, by day name. */
443
490
  export function listMemoryLogs(botId) {
444
491
  try {
445
- return readdirSync(join(workspaceDir(botId), "memory", MEMORY_LOG_DIR)).filter((name) => LOG_FILE_NAME.test(name)).sort();
492
+ const dir = join(workspaceDir(botId), "memory", MEMORY_LOG_DIR);
493
+ return memoryFolderEntries(dir).filter((name) => LOG_FILE_NAME.test(name) && (!regularMemoryFilesOnly || regularFile(join(dir, name)))).sort();
446
494
  }
447
495
  catch {
448
496
  return [];
@@ -453,7 +501,7 @@ export function readMemoryLog(botId, name) {
453
501
  if (!LOG_FILE_NAME.test(name))
454
502
  return null;
455
503
  try {
456
- return readFileSync(join(workspaceDir(botId), "memory", MEMORY_LOG_DIR, name), "utf8");
504
+ return readMemoryText(join(workspaceDir(botId), "memory", MEMORY_LOG_DIR, name));
457
505
  }
458
506
  catch {
459
507
  return null;
@@ -472,7 +520,7 @@ export function isMemoryTopicName(name) {
472
520
  export function listMemoryTopics(botId) {
473
521
  let entries;
474
522
  try {
475
- entries = readdirSync(join(workspaceDir(botId), "memory"));
523
+ entries = memoryFolderEntries(join(workspaceDir(botId), "memory"));
476
524
  }
477
525
  catch {
478
526
  return [];
@@ -481,7 +529,7 @@ export function listMemoryTopics(botId) {
481
529
  .filter(isMemoryTopicName)
482
530
  .flatMap((name) => {
483
531
  try {
484
- const stat = statSync(join(workspaceDir(botId), "memory", name));
532
+ const stat = memoryEntryStat(join(workspaceDir(botId), "memory", name));
485
533
  return stat.isFile() ? [{ name, bytes: stat.size }] : [];
486
534
  }
487
535
  catch {
@@ -507,7 +555,7 @@ export function readMemoryTopic(botId, name) {
507
555
  if (!isMemoryTopicName(name))
508
556
  return null;
509
557
  try {
510
- return readFileSync(join(workspaceDir(botId), "memory", name), "utf8");
558
+ return readMemoryText(join(workspaceDir(botId), "memory", name));
511
559
  }
512
560
  catch {
513
561
  return null;
@@ -541,12 +589,14 @@ export function memoryTopicIndex(botId) {
541
589
  const dir = join(workspaceDir(botId), "memory");
542
590
  return renderTopicIndex(listMemoryTopics(botId).map((topic) => ({ name: topic.name, header: parseTopicHeader(readTopicHead(join(dir, topic.name))) })));
543
591
  }
544
- /** The memory block appended to a bot's system prompt. Always present for
545
- * bots with a workspace, so the bot knows the mechanism exists even before
592
+ /** The memory block appended to a bot's system prompt when enabled. Present for
593
+ * enabled bots with a workspace, so the bot knows the mechanism exists even before
546
594
  * it has written anything. Content from other bots or imported files must
547
595
  * never be recorded as fact — memory is a prompt-injection persistence
548
596
  * vector the moment a bot copies untrusted text into it. */
549
597
  export function memorySystemPrompt(botId, opts = {}) {
598
+ if (opts.enabled === false)
599
+ return "";
550
600
  const memory = loadMemory(botId);
551
601
  const memoryFile = join(workspaceDir(botId), "MEMORY.md");
552
602
  const topicDir = join(workspaceDir(botId), "memory");
@@ -21,7 +21,7 @@ export function supportsApprovalMode(driverKind, mode) {
21
21
  // engines could never stop asking — not by its own level, and not through
22
22
  // a Chief's delegated Full access either.
23
23
  return ["codex", "claudeAgent", "antigravityAgent", "cursorAgent", "grokAgent", "opencodeGo", "qwenAgent", "geminiAgent",
24
- "openai-compat", "grok", "minimax", "mistral"].includes(driverKind ?? "");
24
+ "openai-compat", "grok", "minimax", "mistral", "cerebras"].includes(driverKind ?? "");
25
25
  }
26
26
  /** A Full/Custom grant belongs to one provider's tool semantics. Other
27
27
  * modes carry across only when the destination actually implements them.
@@ -21,6 +21,7 @@ const KEY_PREFIXES = [
21
21
  /\bsk-(?:ant-|proj-|live-|test-)?[A-Za-z0-9_-]{16,}/g, // anthropic / openai / stripe
22
22
  /\bxai-[A-Za-z0-9_-]{20,}/g, // xai (grok)
23
23
  /\bgsk_[A-Za-z0-9]{40,}/g, // groq
24
+ /\bcsk-[A-Za-z0-9]{32,}/g, // cerebras
24
25
  /\bhf_[A-Za-z0-9]{30,}/g, // hugging face
25
26
  /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g, // github classic
26
27
  /\bgithub_pat_[A-Za-z0-9_]{20,}/g, // github fine-grained
@@ -0,0 +1,22 @@
1
+ // A tool chip's display title is not a tool identity. Codex titles a
2
+ // commandExecution chip with the whole command line (drivers/codex.ts) and
3
+ // the ACP core prefers `rawInput.command` over the call's own name
4
+ // (drivers/acp/core.ts), because in a transcript the command IS the useful
5
+ // label. Anything that counts or groups tool calls needs the identity
6
+ // instead, so both readers share one predicate rather than guessing twice.
7
+ /** The generic identity every command-titled chip counts under. */
8
+ export const SHELL_TOOL = "shell";
9
+ /** A tool name that is itself a command line rather than a bare tool name
10
+ * such as `Bash`. Whitespace or a slash: no provider names a tool that way,
11
+ * and every shell command has one by the time it carries an argument or a
12
+ * path. */
13
+ export function nameIsCommand(name) {
14
+ return /[\s/]/.test(name);
15
+ }
16
+ /** What a tool chip should be counted as. Command-titled chips collapse to
17
+ * one `shell` bucket: keyed on the raw command they never aggregate (a turn
18
+ * that ran one tool twelve times reads as twelve tools), and the raw title
19
+ * is unredacted — unlike `tool.summary`, which drivers bound and redact. */
20
+ export function toolIdentity(name) {
21
+ return nameIsCommand(name) ? SHELL_TOOL : name;
22
+ }
@@ -7,6 +7,7 @@ export const WORKSPACE_BACKUP_CLIENT_KEYS = [
7
7
  "omb-draft-channel-modes",
8
8
  "omb-skin",
9
9
  "omb-show-threads",
10
+ "omb-show-run-card",
10
11
  "openmausbot.sidebarDensity",
11
12
  "openmausbot.sidebarCollapsedSections.v1",
12
13
  "openmausbot.sidebarSectionOrder.v1",