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
@@ -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 = [];
@@ -463,8 +467,13 @@ export class Store {
463
467
  mkdirSync(DATA_DIR, { recursive: true });
464
468
  for (const file of [BOTS_FILE, GROUPS_FILE])
465
469
  tightenRegistryFile(file);
470
+ // Whether the bot list is the real one. Room repair below trusts it to
471
+ // say which members no longer exist; an unreadable file must never read
472
+ // as "every member was deleted".
473
+ let botsLoaded = false;
466
474
  try {
467
475
  this.bots = JSON.parse(readFileSync(BOTS_FILE, "utf8"));
476
+ botsLoaded = Array.isArray(this.bots);
468
477
  }
469
478
  catch {
470
479
  this.bots = [];
@@ -633,9 +642,17 @@ export class Store {
633
642
  botsMigrated = true;
634
643
  }
635
644
  }
645
+ const botIds = new Set(this.bots.map((b) => b.id));
636
646
  for (const g of this.groups) {
637
647
  g.busyBotId = null;
638
648
  delete g.turnStartedAt;
649
+ // A deleted bot used to stay a member for good: counted on the room's
650
+ // Save button and refused by the roster check on every save (MOCA-264).
651
+ // Bot-to-bot channels keep their pair; they are not edited as rooms.
652
+ if (botsLoaded && !g.dm && g.memberIds.some((id) => !botIds.has(id))) {
653
+ g.memberIds = g.memberIds.filter((id) => botIds.has(id));
654
+ groupsMigrated = true;
655
+ }
639
656
  const normalized = normalizeGroupDefaultResponder(g.defaultResponder, g.memberIds, Boolean(g.dm));
640
657
  if (JSON.stringify(normalized) !== JSON.stringify(g.defaultResponder))
641
658
  groupsMigrated = true;
@@ -721,6 +738,13 @@ export class Store {
721
738
  task.unread = task === active && b.unread;
722
739
  botsMigrated = true;
723
740
  }
741
+ // Older builds left a failed hidden run unread, which kept the bot
742
+ // dot on after every visible chat was read. Clear it before the
743
+ // recompute below so the saved roster heals.
744
+ if (task.routineRunId && task.unread) {
745
+ task.unread = false;
746
+ botsMigrated = true;
747
+ }
724
748
  if (task === active) {
725
749
  if (task.rewound === undefined && b.rewound !== undefined) {
726
750
  task.rewound = b.rewound;
@@ -742,7 +766,7 @@ export class Store {
742
766
  task.turnStartedAt = undefined;
743
767
  }
744
768
  this.mirrorActiveTask(b, active);
745
- b.unread = b.tasks.some((task) => task.unread);
769
+ b.unread = b.tasks.some(taskCountsAsBotUnread);
746
770
  }
747
771
  if (botsMigrated)
748
772
  this.saveBots();
@@ -819,13 +843,20 @@ export class Store {
819
843
  if (groupsDirty)
820
844
  this.saveGroups();
821
845
  }
846
+ /** Resolve the identity of a historical pair row, including a sender
847
+ * recreated with the same unique name. */
848
+ openerIdentity(openedBy) {
849
+ if (this.bot(openedBy.botId))
850
+ return openedBy.botId;
851
+ const named = this.bots.filter((bot) => bot.name === openedBy.name);
852
+ return named.length === 1 ? named[0].id : openedBy.botId;
853
+ }
822
854
  /** At most one live pair row per (recipient, sender identity). Servers
823
855
  * before identity-stable matching minted a second live pair row for a
824
856
  * 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
857
+ * forever; collapse those on load by keeping the first row in the task
858
+ * list (the newest, actively used one) and demoting
859
+ * the rest to closed plain threads. History is kept, never deleted. Runs on
829
860
  * every load and touches nothing in a store that already holds the
830
861
  * invariant. */
831
862
  repairDuplicatePairConversations() {
@@ -840,8 +871,7 @@ export class Store {
840
871
  pairs.set(identity, [...(pairs.get(identity) ?? []), task]);
841
872
  }
842
873
  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.
874
+ // Rows are in task-list order; keep the newest.
845
875
  for (const duplicate of rows.slice(1)) {
846
876
  const by = duplicate.openedBy;
847
877
  const identity = this.openerIdentity(by);
@@ -856,8 +886,7 @@ export class Store {
856
886
  }
857
887
  }
858
888
  };
859
- // Pair rows only ever live on bots: the resolver requires a bot
860
- // recipient, and group tasks carry no peer stamps.
889
+ // Historical pair rows lived only on bots; group tasks have no peer stamps.
861
890
  for (const bot of this.bots)
862
891
  sweep(bot.tasks, () => { botsDirty = true; });
863
892
  if (botsDirty)
@@ -1748,6 +1777,27 @@ export class Store {
1748
1777
  this.saveBots(nextBots);
1749
1778
  this.bots = nextBots;
1750
1779
  this.legacyActivities.delete(id);
1780
+ // A deleted bot leaves every room it was in, and a room it led falls back
1781
+ // to its next member. Bot-to-bot channels keep their pair.
1782
+ const rooms = this.groups.filter((g) => !g.dm && g.memberIds.includes(id));
1783
+ for (const g of rooms) {
1784
+ g.memberIds = g.memberIds.filter((member) => member !== id);
1785
+ if (g.busyBotId === id) {
1786
+ g.busyBotId = null;
1787
+ delete g.turnStartedAt;
1788
+ }
1789
+ g.defaultResponder = normalizeGroupDefaultResponder(g.defaultResponder, g.memberIds, false);
1790
+ }
1791
+ if (rooms.length) {
1792
+ // Bot removal is already durable. Finish erasing its data even if this
1793
+ // write fails; startup repair removes these stale memberships later.
1794
+ try {
1795
+ this.saveGroups();
1796
+ }
1797
+ catch (error) {
1798
+ console.warn("store: room cleanup will retry on restart", error);
1799
+ }
1800
+ }
1751
1801
  // every task's transcript goes with the bot, not just the open one
1752
1802
  for (const threadId of new Set([bot.threadId, ...(bot.tasks ?? []).map((t) => t.threadId)])) {
1753
1803
  this.deleteThreadRecord(threadId);
@@ -1770,6 +1820,8 @@ export class Store {
1770
1820
  // The bot folder (SOUL.md mirror) is the bot's too.
1771
1821
  removeBotFolder(id);
1772
1822
  this.emit({ type: "bot.deleted", botId: id });
1823
+ for (const g of rooms)
1824
+ this.emit({ type: "group", groupId: g.id });
1773
1825
  return true;
1774
1826
  }
1775
1827
  patchBot(id, patch) {
@@ -1796,7 +1848,7 @@ export class Store {
1796
1848
  Object.assign(task, { [key]: structuredClone(patch[key]) });
1797
1849
  }
1798
1850
  }
1799
- bot.unread = bot.tasks.some((candidate) => candidate.unread);
1851
+ bot.unread = bot.tasks.some(taskCountsAsBotUnread);
1800
1852
  }
1801
1853
  this.saveBots();
1802
1854
  this.emit({ type: "bot", botId: id });
@@ -2146,11 +2198,34 @@ export class Store {
2146
2198
  * current folder — unless the task already has a session (a thread from
2147
2199
  * before folders existed), which pins to the default so the folder can't
2148
2200
  * move under it. Returns the pinned value: a path, or null for default. */
2201
+ /** Forget where a conversation works: its next turn pins it again
2202
+ * (pinTaskCwd). For a Cloud home's conversation a revoked session opened,
2203
+ * whose next turn must work in its own folder (server/cloud-owner.ts). */
2204
+ unpinTaskCwd(threadId) {
2205
+ const bot = this.botByThread(threadId);
2206
+ const task = bot ? this.taskByThread(bot.id, threadId) : undefined;
2207
+ if (!bot || !task || task.cwd === undefined)
2208
+ return false;
2209
+ delete task.cwd;
2210
+ this.saveBots();
2211
+ this.emit({ type: "bot", botId: bot.id });
2212
+ return true;
2213
+ }
2149
2214
  pinTaskCwd(botId, threadId, fallbackCwd, opts = {}) {
2150
2215
  const bot = this.bot(botId);
2151
2216
  const task = bot ? this.taskByThread(botId, threadId) : undefined;
2152
2217
  if (!bot || !task)
2153
2218
  return null;
2219
+ // Its own folder, never the bot's project folder (a Cloud home's guest
2220
+ // conversation), pinned when it first runs. A conversation that already
2221
+ // ran elsewhere keeps its pin: it was not opened that way, and moving it
2222
+ // would lose its work and its session.
2223
+ if (opts.privateOnly && task.cwd === undefined) {
2224
+ task.cwd = fallbackCwd ?? null;
2225
+ this.saveBots();
2226
+ this.emit({ type: "bot", botId });
2227
+ return task.cwd;
2228
+ }
2154
2229
  if (opts.none) {
2155
2230
  if (task.cwd !== null) {
2156
2231
  task.cwd = null;
@@ -2318,9 +2393,13 @@ export class Store {
2318
2393
  task.title = patch.title.trim().slice(0, 80) || UNTITLED_THREAD;
2319
2394
  if (Object.prototype.hasOwnProperty.call(patch, "pinned") && task.pinned !== true)
2320
2395
  delete task.pinned;
2396
+ // Still hidden: attention belongs on the source conversation. A same-call
2397
+ // clear of routineRunId (no-source promotion) may keep unread.
2398
+ if (task.routineRunId)
2399
+ task.unread = false;
2321
2400
  if (bot.threadId === threadId)
2322
2401
  this.mirrorActiveTask(bot, task);
2323
- bot.unread = bot.tasks.some((candidate) => candidate.unread);
2402
+ bot.unread = bot.tasks.some(taskCountsAsBotUnread);
2324
2403
  this.saveBots();
2325
2404
  this.emit({ type: "bot", botId });
2326
2405
  return task;
@@ -2392,6 +2471,24 @@ export class Store {
2392
2471
  this.emit({ type: "bot", botId });
2393
2472
  return bot;
2394
2473
  }
2474
+ /** Copy this bot's saved approval level and standing approvals onto one
2475
+ * thread. A new thread already gets them; this is how an existing
2476
+ * conversation catches up. Other threads, the transcript, and the bot
2477
+ * default stay put. A grant that has not committed yet is ignored, so a
2478
+ * refresh cannot copy the temporary Ask mask. */
2479
+ refreshTaskPermissions(botId, threadId) {
2480
+ const bot = this.bot(botId);
2481
+ const task = this.taskByThread(botId, threadId);
2482
+ if (!bot || !task)
2483
+ return null;
2484
+ const mode = approvalModeFor({ ...bot, approvalGrant: undefined });
2485
+ const alwaysAllow = structuredClone(bot.alwaysAllow ?? []);
2486
+ const autoApprove = mode === "auto";
2487
+ const sameAllow = JSON.stringify(task.alwaysAllow ?? []) === JSON.stringify(alwaysAllow);
2488
+ if (task.approvalMode === mode && task.autoApprove === autoApprove && sameAllow)
2489
+ return task;
2490
+ return this.patchTask(botId, threadId, { approvalMode: mode, autoApprove, alwaysAllow });
2491
+ }
2395
2492
  mirrorActiveTask(bot, task) {
2396
2493
  bot.threadId = task.threadId;
2397
2494
  bot.resumeCursors = structuredClone(task.resumeCursors);
@@ -2399,8 +2496,10 @@ export class Store {
2399
2496
  bot.pinnedMessageId = task.pinnedMessageId;
2400
2497
  }
2401
2498
  /** 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) {
2499
+ * persona/tools/computer. Becomes the active task. It runs on the bot's
2500
+ * default model unless the caller hands it one (a thread opened from
2501
+ * another of this bot's threads keeps that thread's model). */
2502
+ createTask(botId, title, activate = true, projectId, openedBy, approvalMode, modelSelection) {
2404
2503
  const bot = this.bot(botId);
2405
2504
  if (!bot)
2406
2505
  return null;
@@ -2415,7 +2514,7 @@ export class Store {
2415
2514
  ...(projectId ? { projectId } : {}),
2416
2515
  ...(openedBy ? { openedBy: structuredClone(openedBy) } : {}),
2417
2516
  resumeCursors: {},
2418
- modelSelection: structuredClone(bot.modelSelection),
2517
+ modelSelection: structuredClone(modelSelection ?? bot.modelSelection),
2419
2518
  approvalMode: approvalMode ?? approvalModeFor(bot),
2420
2519
  autoApprove: approvalMode ? false : Boolean(bot.autoApprove),
2421
2520
  alwaysAllow: approvalMode ? [] : [...(bot.alwaysAllow ?? [])],
@@ -2463,112 +2562,6 @@ export class Store {
2463
2562
  this.emit({ type: "bot", botId });
2464
2563
  return task;
2465
2564
  }
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
2565
  switchTask(botId, threadId) {
2573
2566
  const bot = this.bot(botId);
2574
2567
  const task = bot?.tasks?.find((t) => t.threadId === threadId);
@@ -2598,7 +2591,8 @@ export class Store {
2598
2591
  /** Swap a machine-made first-message title for a generated one, once.
2599
2592
  * Equality against the snippet is the whole contract: a rename by the
2600
2593
  * person, by pair adoption, or by an earlier generated title each break
2601
- * it, so this never overwrites a name anyone chose. */
2594
+ * it, so this never overwrites a name anyone chose. Regenerate title passes
2595
+ * the title it started from, so a rename made while it ran stands. */
2602
2596
  retitleTask(botId, threadId, machineTitle, title) {
2603
2597
  const task = this.taskByThread(botId, threadId);
2604
2598
  if (!task || task.title !== machineTitle)
@@ -2620,7 +2614,7 @@ export class Store {
2620
2614
  this.mirrorActiveTask(bot, visible);
2621
2615
  }
2622
2616
  this.deleteThreadRecord(threadId);
2623
- bot.unread = bot.tasks.some((task) => task.unread);
2617
+ bot.unread = bot.tasks.some(taskCountsAsBotUnread);
2624
2618
  this.refreshBotActivity(bot);
2625
2619
  this.saveBots();
2626
2620
  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)