openmausbot 0.1.90 → 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 (177) hide show
  1. package/dist/assets/DesktopViewer-Do6lBa4C.js +5 -0
  2. package/dist/assets/{abnfDiagram-O67JEVCF-DUyXd0kL.js → abnfDiagram-O67JEVCF-Cd3X7Hxc.js} +1 -1
  3. package/dist/assets/{arc-q73qM-At.js → arc-DIYc7Ymx.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-D33O3Wur.js → architectureDiagram-NJMV4G6O-DBmRGVyP.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-DLsJeoXC.js → blockDiagram-BEXU5L5S-CPFePSEE.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-DnaljZnu.js → c4Diagram-YGBWAQC7-DD_DO1qy.js} +1 -1
  7. package/dist/assets/channel-Dtk5Mtic.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-Da0h2i79.js → chunk-2Q5K7J3B-BNEeNSjX.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-CfzHi54f.js → chunk-3FUC2YCW-BbinLaqH.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-LjE4d9Jf.js → chunk-5VM5RSS4-BBgYrgDR.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-DY-UGmvc.js → chunk-GTNCS2PH-ezXj-kA-.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-BqeGhyhP.js → chunk-GWA4HPMP-9FxZ3diV.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-BYa9EBAg.js → chunk-JWPE2WC7-CO-40JpI.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-vTrDtWyb.js → chunk-NETBCI7D-BEhjslYE.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-ic5tXadM.js → chunk-WEXAMYUT-CNeq4n7_.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-DvRCY-c6.js → chunk-XXDRQBXY-OsCY7KSi.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-DVbmgz_L.js → classDiagram-v2-NBCMYWYE-CloRUEtk.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-vdUs2n57.js → cose-bilkent-JH36ORCC-D1BGZhp4.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-BvLGlV3U.js → cynefin-OW5HDTMX-CUf4T2CN.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-C4GcJQDt.js → cynefinDiagram-VND7K2PF-DPEZrFGA.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-BMSaHpt_.js → dagre-6A5THRUB-bDe6GIv5.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-D5b9UBhC.js → diagram-22UHCM2B-CdcUQz55.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-CIKLlB7Y.js → diagram-3UASUU5V-BZpqGg7c.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-BzXb-7k7.js → diagram-ATOU4E4O-DvGM6ugp.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-HsTvJ7uT.js → diagram-CDSNMT55-DSTmMm0s.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-Db9ySNbQ.js → diagram-MLGK6HIB-15cucnMn.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-D6BKbpqY.js → diagram-MPIPVDR6-tMbuaE6n.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-Bq067L0t.js → ebnfDiagram-ZINNZB2B-BYxYzjHI.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-DhaD7fNJ.js → elk-276RUBZZ-v6bj852W.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-fwIhfz36.js → erDiagram-OPXOYQCR-5lBBlnd2.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-DygIyu0V.js → flowDiagram-KWPJA3E3-CZyl1zIj.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-DPYfSO96.js → ganttDiagram-FUAMR5RP-BiAI-ltZ.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-DhkHeCle.js → gitGraphDiagram-X574FWY7-BAZISkj3.js} +1 -1
  34. package/dist/assets/index-Cdmjrl28.css +1 -0
  35. package/dist/assets/{index-G1PHKGyh.js → index-DGBaEXrM.js} +1 -1
  36. package/dist/assets/index-DR7YPMkW.js +597 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-DstBLxME.js → infoDiagram-VRGFBTTK-B6ooOy-Z.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-CrIsGgch.js → ishikawaDiagram-OU5B5YK6-BZ9ksi-B.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-CYypGDbB.js → journeyDiagram-ZHPQQLJL-DWqlzIEe.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-DGjPt8E_.js → kanban-definition-PNTS6WVX-JX6piDwL.js} +1 -1
  41. package/dist/assets/{linear-Dwc3Hrru.js → linear-CmOWzX0P.js} +1 -1
  42. package/dist/assets/{mermaid.core-Dc4OePBL.js → mermaid.core-CF1zwoQ-.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-DV-vepj-.js → mindmap-definition-NLK3R4M7-CGh2K4fW.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-lx5Kj-n5.js → pegDiagram-GJSIUBJH-CDwYN0s7.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-Bq7Vmm15.js → pieDiagram-5QR66LMP-1rfjJocB.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-DeqeqOBj.js → quadrantDiagram-O4NWA36T-tKPvPiok.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-CXwRQzSC.js → railroadDiagram-XR7U4H2S-BHLm5vXR.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-8tAeoB88.js → requirementDiagram-PLB6GJNP-CB45STp7.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-BG5zjGWU.js → sankeyDiagram-IPEJSGJF-55QA_huC.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-DKF3iLcw.js → sequenceDiagram-PO4LG4MO-BckFmQHn.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-B-y1qUgT.js → sizeCapture-INFHLROL-BFCOJF11.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-MkzPgxAc.js → stateDiagram-v2-GCMORJYK-CZvmiVVN.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DnbaOEwH.js → swimlanes-2SLR337P-BpDUCEKy.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-M0GYW9Y3.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-Bb4xG_Bt.js → timeline-definition-EJHVYXUP-BpxHq6IK.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-JgkR9pFf.js → usecaseDiagram-POWQR4AR-Cj66vm02.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-CIPIrAYe.js → vennDiagram-UO4OBE2U-C9nRkWE_.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-BjCLuUvJ.js → wardleyDiagram-VNRHLVJA-D-me_nWj.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-DKgiaYxT.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 +128 -15
  63. package/dist-server/container-mcp.js +277 -45
  64. package/dist-server/drivers/agents-proxy.js +16 -4
  65. package/dist-server/enterprise/server/index.js +5 -1
  66. package/dist-server/index.js +46703 -39955
  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 +4099 -1217
  71. package/dist-server/pair-cli.js +4099 -1217
  72. package/dist-server/server/boat.js +67 -22
  73. package/dist-server/server/bot-overview.js +7 -4
  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 +136 -26
  80. package/dist-server/server/cloud-home.js +64 -5
  81. package/dist-server/server/cloud-lending.js +167 -0
  82. package/dist-server/server/cloud-move-http.js +268 -0
  83. package/dist-server/server/cloud-move.js +418 -0
  84. package/dist-server/server/cloud-owner.js +196 -0
  85. package/dist-server/server/cloud-secrets-boot.js +6 -0
  86. package/dist-server/server/cloud-secrets.js +65 -0
  87. package/dist-server/server/config.js +215 -17
  88. package/dist-server/server/container-computer.js +3 -2
  89. package/dist-server/server/decider/index.js +238 -0
  90. package/dist-server/server/decider/jev.js +183 -0
  91. package/dist-server/server/decider/log.js +62 -0
  92. package/dist-server/server/decider/relay.js +52 -0
  93. package/dist-server/server/decider/room-routing.js +88 -0
  94. package/dist-server/server/decider/types.js +5 -0
  95. package/dist-server/server/desktop-viewer-targets.js +26 -0
  96. package/dist-server/server/drivers/acp/core.js +167 -21
  97. package/dist-server/server/drivers/acp/opencode-go.js +614 -85
  98. package/dist-server/server/drivers/agents-catalog.js +22 -6
  99. package/dist-server/server/drivers/antigravity-acp.js +69 -11
  100. package/dist-server/server/drivers/antigravity-temp.js +360 -0
  101. package/dist-server/server/drivers/antigravity.js +21 -0
  102. package/dist-server/server/drivers/boatagent.js +20 -12
  103. package/dist-server/server/drivers/chatgpt-plan-auth.js +481 -0
  104. package/dist-server/server/drivers/claude.js +313 -34
  105. package/dist-server/server/drivers/codex.js +228 -18
  106. package/dist-server/server/drivers/grok.js +2 -2
  107. package/dist-server/server/drivers/mistral.js +4 -3
  108. package/dist-server/server/drivers/openai-chat-protocol.js +4 -3
  109. package/dist-server/server/drivers/openai-chat.js +41 -3
  110. package/dist-server/server/drivers/openai-compat.js +73 -23
  111. package/dist-server/server/engine-install.js +46 -12
  112. package/dist-server/server/env-path.js +15 -3
  113. package/dist-server/server/group-goal-run.js +6 -4
  114. package/dist-server/server/harness/registry.js +2 -2
  115. package/dist-server/server/harness-capabilities.js +2 -0
  116. package/dist-server/server/included-services.js +70 -0
  117. package/dist-server/server/index.js +1753 -263
  118. package/dist-server/server/lending-memory.js +479 -0
  119. package/dist-server/server/local-vm-idle.js +24 -6
  120. package/dist-server/server/managed-desktop.js +3 -0
  121. package/dist-server/server/mcp-bridge.js +5 -3
  122. package/dist-server/server/mcp-http.js +6 -3
  123. package/dist-server/server/mcp-oauth-discovery.js +133 -0
  124. package/dist-server/server/mcp-oauth-store.js +70 -0
  125. package/dist-server/server/mcp-oauth.js +550 -0
  126. package/dist-server/server/mcp-probe.js +14 -1
  127. package/dist-server/server/mcp-registry.js +43 -0
  128. package/dist-server/server/memory-upkeep.js +94 -84
  129. package/dist-server/server/model-catalog/catalog.js +374 -0
  130. package/dist-server/server/model-catalog/presets.js +176 -0
  131. package/dist-server/server/model-catalog/trim.js +219 -0
  132. package/dist-server/server/model-context-window.js +4 -4
  133. package/dist-server/server/package-export.js +17 -10
  134. package/dist-server/server/package-import.js +6 -3
  135. package/dist-server/server/package-parts.js +3 -1
  136. package/dist-server/server/provider-key-check.js +4 -2
  137. package/dist-server/server/recent-work.js +1 -1
  138. package/dist-server/server/request-auth.js +25 -0
  139. package/dist-server/server/routes/antigravity-leftovers.js +45 -0
  140. package/dist-server/server/routes/bot-memory.js +31 -6
  141. package/dist-server/server/routes/decider.js +19 -0
  142. package/dist-server/server/routes/desktop-viewer.js +194 -0
  143. package/dist-server/server/routine-requests.js +1 -1
  144. package/dist-server/server/routines.js +1 -1
  145. package/dist-server/server/sessions.js +56 -9
  146. package/dist-server/server/shared-computers.js +116 -30
  147. package/dist-server/server/skills.js +25 -10
  148. package/dist-server/server/store.js +79 -124
  149. package/dist-server/server/surface.js +13 -0
  150. package/dist-server/server/system-prompt.js +11 -0
  151. package/dist-server/server/team-backup.js +6 -2
  152. package/dist-server/server/team-manifest.js +11 -3
  153. package/dist-server/server/thread-events.js +2 -0
  154. package/dist-server/server/thread-starters.js +33 -0
  155. package/dist-server/server/thread-title.js +46 -0
  156. package/dist-server/server/tts/elevenlabs.js +11 -6
  157. package/dist-server/server/tts/fish.js +4 -5
  158. package/dist-server/server/tts/index.js +20 -11
  159. package/dist-server/server/vps-computer.js +79 -12
  160. package/dist-server/server/webhooks.js +18 -1
  161. package/dist-server/server/workspace-backup-policy.js +25 -3
  162. package/dist-server/server/workspace-backup.js +28 -8
  163. package/dist-server/server/workspace.js +65 -15
  164. package/dist-server/shared/cloud-home.js +4 -0
  165. package/dist-server/shared/package-format.js +30 -4
  166. package/dist-server/shared/team-backup.js +20 -3
  167. package/dist-server/shared/workspace-backup-client.js +1 -0
  168. package/dist-server/src/lib/citations.js +177 -0
  169. package/dist-server/src/lib/composer-attachments.js +7 -1
  170. package/dist-server/vps-container-mcp.js +278 -45
  171. package/dist-server/workspace-backup.worker.js +184 -38
  172. package/enterprise/server/index.js +5 -1
  173. package/package.json +1 -1
  174. package/dist/assets/channel-BCuyHjFV.js +0 -1
  175. package/dist/assets/index-CRvo-DS2.css +0 -1
  176. package/dist/assets/index-CWYGlA63.js +0 -588
  177. package/dist/assets/swimlanesDiagram-TC7HE7FX-BZQ07DGE.js +0 -8
@@ -94,7 +94,9 @@ export function createTeamBackup(store, routines, name) {
94
94
  key: group.id, name: group.name, section: group.section, dm: Boolean(group.dm) && memberIds.length === 2,
95
95
  bulletin: group.bulletin, memberIds,
96
96
  defaultResponder: group.defaultResponder.kind === "member" && !memberIds.includes(group.defaultResponder.botId)
97
- ? { kind: "mentions" } : group.defaultResponder,
97
+ ? { kind: "mentions" }
98
+ : group.defaultResponder.kind === "auto" && group.defaultResponder.fallbackBotId && !memberIds.includes(group.defaultResponder.fallbackBotId)
99
+ ? { kind: "auto" } : group.defaultResponder,
98
100
  activeTask: group.threadId, tasks: history(group),
99
101
  };
100
102
  });
@@ -226,7 +228,9 @@ export function importTeamBackup(store, routines, input, selection, options = {}
226
228
  groups.push(group);
227
229
  groupIds.set(source.key, group.id);
228
230
  const responder = source.defaultResponder;
229
- store.patchGroup(group.id, { bulletin: source.bulletin, setupCompletedAt: Date.now(), defaultResponder: responder.kind === "member" ? { kind: "member", botId: botIds.get(responder.botId) } : responder });
231
+ store.patchGroup(group.id, { bulletin: source.bulletin, setupCompletedAt: Date.now(), defaultResponder: responder.kind === "member" ? { kind: "member", botId: botIds.get(responder.botId) }
232
+ : responder.kind === "auto" ? { kind: "auto", ...(responder.fallbackBotId ? { fallbackBotId: botIds.get(responder.fallbackBotId) } : {}) }
233
+ : responder });
230
234
  // Use the existing task APIs for rooms; direct-message rooms have one.
231
235
  const threads = source.tasks.map((task, i) => {
232
236
  if (i === 0) {
@@ -25,11 +25,18 @@ const optionalText = (max) => z
25
25
  .transform((value) => value?.trim() || undefined)
26
26
  .refine((value) => value === undefined || value.length <= max, { message: "is too long" })
27
27
  .optional();
28
- const responderSchema = z.discriminatedUnion("kind", [
28
+ const RESPONDER_KINDS = new Set(["member", "everyone", "mentions", "auto"]);
29
+ /** A responder kind from a newer release reads as "no responder": the room
30
+ * is created with the default lead, never refused. */
31
+ const responderSchema = z.preprocess((value) => {
32
+ const kind = value && typeof value === "object" ? value.kind : undefined;
33
+ return typeof kind === "string" && !RESPONDER_KINDS.has(kind) ? { kind: "mentions" } : value;
34
+ }, z.discriminatedUnion("kind", [
29
35
  z.object({ kind: z.literal("member"), member: requiredText(64) }),
30
36
  z.object({ kind: z.literal("everyone") }),
31
37
  z.object({ kind: z.literal("mentions") }),
32
- ]);
38
+ z.object({ kind: z.literal("auto"), member: requiredText(64).optional() }),
39
+ ]));
33
40
  const memberSchema = z.object({
34
41
  key: requiredText(64).regex(/^[a-z0-9][a-z0-9_-]*$/, {
35
42
  message: "may only contain lowercase letters, numbers, - and _",
@@ -117,7 +124,8 @@ export function parseTeamManifest(value) {
117
124
  result.team.description = parsed.data.team.description;
118
125
  if (parsed.data.version === LEGACY_TEAM_MANIFEST_VERSION) {
119
126
  const defaultResponder = parsed.data.team.room.defaultResponder;
120
- if (defaultResponder.kind === "member" && !seenKeys.has(defaultResponder.member)) {
127
+ if ((defaultResponder.kind === "member" || defaultResponder.kind === "auto") && defaultResponder.member !== undefined &&
128
+ !seenKeys.has(defaultResponder.member)) {
121
129
  throw new Error(`Unknown default responder: ${defaultResponder.member}`);
122
130
  }
123
131
  result.team.room = {
@@ -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
+ }
@@ -10,7 +10,12 @@
10
10
  // client already splits text into utterances and fetches the next while the
11
11
  // current one plays, which gets the same perceived latency with far fewer
12
12
  // moving parts — and no socket to leak when a turn is interrupted.
13
- const API = process.env.OMB_ELEVENLABS_API || "https://api.elevenlabs.io/v1";
13
+ //
14
+ // The base URL is the caller's: ElevenLabs itself for the person's own key
15
+ // (OMB_ELEVENLABS_API overrides it for dev and tests), Cloud Pro's relay for
16
+ // its included token (included-services.ts). Only a key being saved is
17
+ // verified, and that is always an own key.
18
+ import { elevenLabsProviderApi } from "../included-services.js";
14
19
  const MODEL = "eleven_flash_v2_5";
15
20
  // 64kbps mono is indistinguishable for speech and a third of the bytes
16
21
  const FORMAT = "mp3_44100_64";
@@ -51,7 +56,7 @@ function message(status, what, body) {
51
56
  * picker needs next, so it tests exactly the capability that matters. */
52
57
  export async function verifyKey(key) {
53
58
  try {
54
- const res = await fetch(`${API}/voices`, {
59
+ const res = await fetch(`${elevenLabsProviderApi()}/voices`, {
55
60
  headers: { "xi-api-key": key },
56
61
  signal: AbortSignal.timeout(20_000),
57
62
  });
@@ -63,8 +68,8 @@ export async function verifyKey(key) {
63
68
  return { ok: false, message: "Couldn't reach ElevenLabs to check that key — check your connection." };
64
69
  }
65
70
  }
66
- export async function listVoices(key) {
67
- const res = await fetch(`${API}/voices`, {
71
+ export async function listVoices(key, api) {
72
+ const res = await fetch(`${api}/voices`, {
68
73
  headers: { "xi-api-key": key },
69
74
  signal: AbortSignal.timeout(20_000),
70
75
  });
@@ -79,8 +84,8 @@ export async function listVoices(key) {
79
84
  }))
80
85
  .filter((v) => v.id);
81
86
  }
82
- export async function synthesize(text, voiceId, key) {
83
- const res = await fetch(`${API}/text-to-speech/${encodeURIComponent(voiceId)}?output_format=${FORMAT}`, {
87
+ export async function synthesize(text, voiceId, key, api) {
88
+ const res = await fetch(`${api}/text-to-speech/${encodeURIComponent(voiceId)}?output_format=${FORMAT}`, {
84
89
  method: "POST",
85
90
  headers: { "xi-api-key": key, "content-type": "application/json", accept: "audio/mpeg" },
86
91
  body: JSON.stringify({ text, model_id: MODEL }),
@@ -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,
@@ -1,3 +1,4 @@
1
+ import { voiceCredential } from "../included-services.js";
1
2
  import * as chatterbox from "./chatterbox.js";
2
3
  import * as elevenlabs from "./elevenlabs.js";
3
4
  import * as grok from "./grok.js";
@@ -16,6 +17,9 @@ export class NoVoiceConfigured extends Error {
16
17
  this.reason = reason;
17
18
  }
18
19
  }
20
+ /** The ElevenLabs credential in use: the person's own key, else the one
21
+ * included with Cloud Pro. Settings' own-key flows read cfg.tts.key instead. */
22
+ const elevenLabs = (cfg) => voiceCredential(cfg.tts?.key);
19
23
  export function voiceProvider(cfg) {
20
24
  const provider = cfg.tts?.provider;
21
25
  return provider === "xai" || provider === "fish" || provider === "system" || provider === "chatterbox" ? provider : "elevenlabs";
@@ -33,7 +37,7 @@ export function providerConfigured(cfg) {
33
37
  return Boolean(cfg.tts?.baseUrl?.trim());
34
38
  if (provider === "fish")
35
39
  return Boolean(cfg.tts?.fishKey);
36
- return Boolean(cfg.tts?.key);
40
+ return Boolean(elevenLabs(cfg));
37
41
  }
38
42
  export function voiceConfigured(cfg) {
39
43
  const provider = voiceProvider(cfg);
@@ -46,7 +50,7 @@ export function voiceConfigured(cfg) {
46
50
  return Boolean(cfg.tts?.baseUrl?.trim() && cfg.tts?.voice);
47
51
  if (provider === "fish")
48
52
  return Boolean(cfg.tts?.fishKey && cfg.tts?.voice);
49
- return Boolean(cfg.tts?.key && cfg.tts?.voice);
53
+ return Boolean(elevenLabs(cfg) && cfg.tts?.voice);
50
54
  }
51
55
  /** A per-bot voice is a complete choice too; it should not be blocked just
52
56
  * because the app-wide fallback has not been selected yet. */
@@ -61,13 +65,16 @@ export function voiceReady(cfg, voiceId) {
61
65
  return Boolean(cfg.tts?.baseUrl?.trim() && (voiceId || cfg.tts?.voice));
62
66
  if (provider === "fish")
63
67
  return Boolean(cfg.tts?.fishKey && (voiceId || cfg.tts?.voice));
64
- return Boolean(cfg.tts?.key && (voiceId || cfg.tts?.voice));
68
+ return Boolean(elevenLabs(cfg) && (voiceId || cfg.tts?.voice));
65
69
  }
66
70
  /** What the settings panel needs. Never includes the key — same write-only
67
71
  * rule as every other credential. baseUrl and model are Chatterbox
68
- * settings, not credentials, so they come back in full. */
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. */
69
75
  export function describeVoice(cfg) {
70
76
  const provider = voiceProvider(cfg);
77
+ const included = provider === "elevenlabs" && elevenLabs(cfg)?.included === true;
71
78
  return {
72
79
  configured: providerConfigured(cfg),
73
80
  ready: voiceConfigured(cfg),
@@ -75,6 +82,8 @@ export function describeVoice(cfg) {
75
82
  provider,
76
83
  baseUrl: provider === "chatterbox" ? (cfg.tts?.baseUrl ?? "") : "",
77
84
  model: provider === "chatterbox" ? (cfg.tts?.model ?? "") : "",
85
+ ...(provider === "fish" ? { fishModel: cfg.tts?.fishModel ?? fish.DEFAULT_FISH_MODEL } : {}),
86
+ ...(included ? { included: true } : {}),
78
87
  };
79
88
  }
80
89
  export function verifyKey(provider, key) {
@@ -94,10 +103,10 @@ export async function listVoices(cfg, run) {
94
103
  const key = cfg.tts?.fishKey;
95
104
  return key ? fish.listVoices(key) : [];
96
105
  }
97
- const key = cfg.tts?.key;
98
- if (!key)
106
+ const credential = elevenLabs(cfg);
107
+ if (!credential)
99
108
  return [];
100
- return elevenlabs.listVoices(key);
109
+ return elevenlabs.listVoices(credential.token, credential.api);
101
110
  }
102
111
  /** Synthesize one utterance. Throws NoVoiceConfigured when there is nothing
103
112
  * to speak with, which the route turns into a 409 the client can explain. */
@@ -140,13 +149,13 @@ export function speak(cfg, text, voiceId, run) {
140
149
  const voice = voiceId || cfg.tts?.voice;
141
150
  if (!voice)
142
151
  throw new NoVoiceConfigured("voice");
143
- return fish.synthesize(text, voice, key);
152
+ return fish.synthesize(text, voice, key, cfg.tts?.fishModel);
144
153
  }
145
- const key = cfg.tts?.key;
146
- if (!key)
154
+ const credential = elevenLabs(cfg);
155
+ if (!credential)
147
156
  throw new NoVoiceConfigured("key");
148
157
  const voice = voiceId || cfg.tts?.voice;
149
158
  if (!voice)
150
159
  throw new NoVoiceConfigured("voice");
151
- return elevenlabs.synthesize(text, voice, key);
160
+ return elevenlabs.synthesize(text, voice, credential.token, credential.api);
152
161
  }
@@ -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
  }
@@ -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);
@@ -12,19 +12,38 @@ export function portableWorkspaceConfig(value) {
12
12
  const config = parseStoredConfig(value);
13
13
  return Object.fromEntries(PORTABLE_CONFIG_KEYS.filter((key) => config[key] !== undefined).map((key) => [key, config[key]]));
14
14
  }
15
+ // Switches that belong to the machine even inside a portable section.
16
+ // Computer sharing (lending a computer to this server) opens routes and a
17
+ // desktop connector here, so a restored workspace never turns it on or off.
18
+ const DESTINATION_FEATURES = ["sharedComputers"];
15
19
  export function restoredWorkspaceConfig(portable, destination) {
16
20
  if (!destination || typeof destination !== "object" || Array.isArray(destination))
17
21
  throw new Error("Invalid destination workspace configuration; connections were not changed.");
18
22
  const merged = { ...destination };
19
23
  for (const key of PORTABLE_CONFIG_KEYS)
20
24
  delete merged[key];
21
- return { ...merged, ...portableWorkspaceConfig(portable) };
25
+ const restored = { ...merged, ...portableWorkspaceConfig(portable) };
26
+ const own = destination.features ?? {};
27
+ const features = { ...restored.features };
28
+ for (const key of DESTINATION_FEATURES) {
29
+ delete features[key];
30
+ if (own[key] !== undefined)
31
+ features[key] = own[key];
32
+ }
33
+ if (Object.keys(features).length)
34
+ restored.features = features;
35
+ else
36
+ delete restored.features;
37
+ return restored;
22
38
  }
23
39
  /** Exact app-owned authentication paths, not a scan of user document text. */
24
40
  export function excludedWorkspaceAuthPath(path) {
25
41
  // Machine/provider-specific execution grants are not portable template data.
26
42
  if (/^command-allowlist\.json(?:$|\.\d+\.[0-9a-f-]+\.tmp$)/.test(path))
27
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;
28
47
  return /^(?:(?:providers|caddy|chrome-profile|\.agent-browser)(?:\/|$)|workspace-credentials\.json$|external-runtimes\.json$|browser-engine-key$)/.test(path) ||
29
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) ||
30
49
  /^(?:vm-home|vm-homes\/[^/]+)\/\.browser-profiles(?:\/|$)/.test(path);
@@ -41,7 +60,10 @@ export function redownloadedOrgLibraryPath(path) {
41
60
  /** Per-turn engine hook bearers (`hook-tokens/<digest>.token`, written by the
42
61
  * Claude driver). They are dead once their turn settles, so they are never
43
62
  * exported. Unlike the saved auth paths above they are not refused on import:
44
- * v0.1.85 did export them, and such a backup must still restore, without them. */
63
+ * v0.1.85 did export them, and such a backup must still restore, without them.
64
+ * The same holds for the session registry's open marker: it describes the
65
+ * process that wrote it, so it is never exported or installed, and a restore
66
+ * leaves the destination's own marker where it is (workspace-backup.ts). */
45
67
  export function ephemeralWorkspaceTokenPath(path) {
46
- return /^hook-tokens(?:\/|$)/.test(path);
68
+ return /^(?:hook-tokens(?:\/|$)|sessions\.json\.open$)/.test(path);
47
69
  }