openmausbot 0.1.85 → 0.1.86

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 (193) hide show
  1. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  2. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  3. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  4. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  5. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  6. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  7. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  10. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  11. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  12. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  13. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  16. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  17. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  18. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  19. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  20. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  21. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  22. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  23. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  24. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  25. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  26. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  27. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  28. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  29. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  30. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  31. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  32. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  33. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  34. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  35. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  36. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  37. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  43. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  44. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  45. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  46. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  47. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  48. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  49. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  50. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  51. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  52. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  53. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  54. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  55. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  56. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  57. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  58. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  59. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  60. package/dist/assets/abnfDiagram-O67JEVCF-DpUZNnFD.js +1 -0
  61. package/dist/assets/arc-DW7mrI4p.js +1 -0
  62. package/dist/assets/architectureDiagram-NJMV4G6O-CyeZ5WLZ.js +36 -0
  63. package/dist/assets/blockDiagram-BEXU5L5S-CBVaNjO7.js +140 -0
  64. package/dist/assets/c4Diagram-YGBWAQC7-sf8d41uT.js +38 -0
  65. package/dist/assets/channel-BXFkgus9.js +1 -0
  66. package/dist/assets/chunk-2Q5K7J3B-BOGvy7Y_.js +1 -0
  67. package/dist/assets/chunk-3FUC2YCW-BiF9zyiz.js +2 -0
  68. package/dist/assets/chunk-5VM5RSS4-EFuZ1SBZ.js +15 -0
  69. package/dist/assets/chunk-GTNCS2PH-pJLNSAT7.js +88 -0
  70. package/dist/assets/chunk-GWA4HPMP-Cb-3_3IT.js +1 -0
  71. package/dist/assets/chunk-JWPE2WC7-DAf5aogn.js +1 -0
  72. package/dist/assets/chunk-NETBCI7D-DR3kiwZz.js +1 -0
  73. package/dist/assets/chunk-WEXAMYUT-BmU8PeyM.js +1 -0
  74. package/dist/assets/chunk-XXDRQBXY-Buh8g8OT.js +1 -0
  75. package/dist/assets/classDiagram-v2-NBCMYWYE-BurfmMWM.js +217 -0
  76. package/dist/assets/cose-bilkent-JH36ORCC-KkGZUEkU.js +1 -0
  77. package/dist/assets/cynefin-OW5HDTMX-C3PpV96d.js +166 -0
  78. package/dist/assets/cynefinDiagram-VND7K2PF-Dt8JwJaI.js +62 -0
  79. package/dist/assets/cytoscape.esm-BB4DxJjf.js +321 -0
  80. package/dist/assets/dagre-6A5THRUB-DkjN8266.js +4 -0
  81. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  82. package/dist/assets/diagram-22UHCM2B-DUAJS0JV.js +200 -0
  83. package/dist/assets/diagram-3UASUU5V-Dw4KhF1a.js +24 -0
  84. package/dist/assets/diagram-ATOU4E4O-Dd1gmQqf.js +3 -0
  85. package/dist/assets/diagram-CDSNMT55-CjwGs0Yw.js +30 -0
  86. package/dist/assets/diagram-MLGK6HIB-BL-69Zrq.js +24 -0
  87. package/dist/assets/diagram-MPIPVDR6-5Zi2N7wo.js +41 -0
  88. package/dist/assets/ebnfDiagram-ZINNZB2B-D9NekWUX.js +1 -0
  89. package/dist/assets/elk-276RUBZZ-CkaSQjxw.js +27 -0
  90. package/dist/assets/erDiagram-OPXOYQCR-CdAVBV8-.js +99 -0
  91. package/dist/assets/flowDiagram-KWPJA3E3-Bm_lWWH7.js +213 -0
  92. package/dist/assets/ganttDiagram-FUAMR5RP-FZ8ejYWJ.js +292 -0
  93. package/dist/assets/gitGraphDiagram-X574FWY7-Dlv9rizZ.js +106 -0
  94. package/dist/assets/graph-D8UmR1vK.js +1 -0
  95. package/dist/assets/index-BGgqlWPJ.css +1 -0
  96. package/dist/assets/index-BtHo8ZKl.js +577 -0
  97. package/dist/assets/{index-CTNNoqSJ.js → index-h9FFNwTu.js} +1 -1
  98. package/dist/assets/infoDiagram-VRGFBTTK-BdQ_gW5B.js +2 -0
  99. package/dist/assets/init-Gi6I4Gst.js +1 -0
  100. package/dist/assets/ishikawaDiagram-OU5B5YK6-EwssEp2X.js +70 -0
  101. package/dist/assets/journeyDiagram-ZHPQQLJL-BX1G1vOd.js +139 -0
  102. package/dist/assets/kanban-definition-PNTS6WVX-BB4kAsyC.js +89 -0
  103. package/dist/assets/linear-3su2TG93.js +1 -0
  104. package/dist/assets/mermaid.core-CDD1E0s9.js +311 -0
  105. package/dist/assets/mindmap-definition-NLK3R4M7-Ba50MsNK.js +96 -0
  106. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  107. package/dist/assets/pegDiagram-GJSIUBJH-DZLkkZlM.js +1 -0
  108. package/dist/assets/pieDiagram-5QR66LMP-DaTz77Uq.js +39 -0
  109. package/dist/assets/quadrantDiagram-O4NWA36T-CpaiidpR.js +7 -0
  110. package/dist/assets/railroadDiagram-XR7U4H2S-mN4JrMeI.js +1 -0
  111. package/dist/assets/requirementDiagram-PLB6GJNP-BSNDRpg7.js +84 -0
  112. package/dist/assets/sankeyDiagram-IPEJSGJF-FngzGNug.js +40 -0
  113. package/dist/assets/sequenceDiagram-PO4LG4MO-CM4pCC3L.js +169 -0
  114. package/dist/assets/sizeCapture-INFHLROL-CRICHXkw.js +1 -0
  115. package/dist/assets/stateDiagram-v2-GCMORJYK-oyTWpxH2.js +291 -0
  116. package/dist/assets/swimlanes-2SLR337P-D9h9mPBq.js +1 -0
  117. package/dist/assets/swimlanesDiagram-TC7HE7FX-BWrvB2ak.js +8 -0
  118. package/dist/assets/timeline-definition-EJHVYXUP-BJUlN9bW.js +120 -0
  119. package/dist/assets/usecaseDiagram-POWQR4AR-3uBNkbAb.js +370 -0
  120. package/dist/assets/vennDiagram-UO4OBE2U-D2odalzT.js +34 -0
  121. package/dist/assets/wardleyDiagram-VNRHLVJA-DHgFaB9S.js +78 -0
  122. package/dist/assets/xychartDiagram-PMCCYNJV-w5GYM2h0.js +7 -0
  123. package/dist/index.html +2 -2
  124. package/dist-server/container-mcp.js +19 -10
  125. package/dist-server/drivers/agents-proxy.js +16159 -1596
  126. package/dist-server/enterprise/LICENSE +32 -0
  127. package/dist-server/enterprise/server/index.js +5 -5
  128. package/dist-server/index.js +4779 -1667
  129. package/dist-server/mcp-server.js +3 -1
  130. package/dist-server/openmausbot.js +4679 -4266
  131. package/dist-server/pair-cli.js +4679 -4266
  132. package/dist-server/server/admin-activity.js +410 -0
  133. package/dist-server/server/bot-visibility.js +462 -0
  134. package/dist-server/server/box.js +2 -1
  135. package/dist-server/server/browser-runtime.js +1 -1
  136. package/dist-server/server/channel-queue.js +2 -0
  137. package/dist-server/server/cli-prompts.js +24 -2
  138. package/dist-server/server/cli.js +79 -10
  139. package/dist-server/server/composio.js +49 -11
  140. package/dist-server/server/config.js +79 -13
  141. package/dist-server/server/decision-log.js +209 -48
  142. package/dist-server/server/default-model-selection.js +20 -3
  143. package/dist-server/server/delegations.js +11 -9
  144. package/dist-server/server/drivers/acp/core.js +110 -22
  145. package/dist-server/server/drivers/acp/grok.js +4 -3
  146. package/dist-server/server/drivers/agents-call.js +1123 -0
  147. package/dist-server/server/drivers/agents-catalog.js +659 -0
  148. package/dist-server/server/drivers/agents-client.js +28 -0
  149. package/dist-server/server/drivers/agents-proxy.js +18 -1722
  150. package/dist-server/server/drivers/chat-mcp-tools.js +17 -2
  151. package/dist-server/server/drivers/claude.js +21 -6
  152. package/dist-server/server/drivers/codex-mcp-names.js +44 -0
  153. package/dist-server/server/drivers/codex.js +26 -4
  154. package/dist-server/server/drivers/grok.js +2 -1
  155. package/dist-server/server/drivers/openai-compat.js +15 -8
  156. package/dist-server/server/enterprise.js +97 -16
  157. package/dist-server/server/external-runtime.js +83 -0
  158. package/dist-server/server/guarded-requests.js +60 -0
  159. package/dist-server/server/harness/http.js +61 -0
  160. package/dist-server/server/hosted-models.js +151 -0
  161. package/dist-server/server/hosted-slack.js +11 -0
  162. package/dist-server/server/index.js +1718 -348
  163. package/dist-server/server/managed-desktop.js +79 -14
  164. package/dist-server/server/managed-policy.js +153 -0
  165. package/dist-server/server/message-db.js +28 -0
  166. package/dist-server/server/model-context-window.js +7 -0
  167. package/dist-server/server/model-prices.js +121 -0
  168. package/dist-server/server/notify.js +10 -1
  169. package/dist-server/server/peer-roster.js +13 -2
  170. package/dist-server/server/recent-work.js +15 -0
  171. package/dist-server/server/request-auth.js +96 -0
  172. package/dist-server/server/room-handoffs.js +25 -11
  173. package/dist-server/server/routes/hosted-slack.js +20 -0
  174. package/dist-server/server/routes/table.js +14 -0
  175. package/dist-server/server/routines.js +22 -0
  176. package/dist-server/server/spend.js +111 -16
  177. package/dist-server/server/steer-queue.js +6 -1
  178. package/dist-server/server/store.js +292 -21
  179. package/dist-server/server/team-backup.js +10 -1
  180. package/dist-server/server/thread-starters.js +53 -0
  181. package/dist-server/server/usage-ledger.js +31 -8
  182. package/dist-server/server/workspace-backup-policy.js +9 -2
  183. package/dist-server/server/workspace-backup.js +7 -4
  184. package/dist-server/shared/mention-boundary.js +15 -0
  185. package/dist-server/shared/routines.js +7 -1
  186. package/dist-server/shared/team-backup.js +2 -0
  187. package/dist-server/src/lib/composer-attachments.js +16 -0
  188. package/dist-server/vps-container-mcp.js +19 -10
  189. package/enterprise/README.md +33 -12
  190. package/enterprise/server/index.js +5 -5
  191. package/package.json +1 -1
  192. package/dist/assets/index-Ba9G44HI.js +0 -310
  193. package/dist/assets/index-CkRNp7wX.css +0 -1
@@ -3,7 +3,7 @@
3
3
  // ProviderSessionDirectory, recipe step 6: persist the binding from day
4
4
  // one). messages-<threadId>.json holds the folded transcript.
5
5
  import { createHash } from "node:crypto";
6
- import { existsSync, readFileSync, mkdirSync, rmSync, unlinkSync } from "node:fs";
6
+ import { chmodSync, existsSync, readFileSync, mkdirSync, rmSync, statSync, unlinkSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { writeFileAtomic } from "./atomic.js";
9
9
  import { ensureSections, readSections, changeEmptySection } from "./section-context.js";
@@ -18,6 +18,7 @@ import { pickBotName } from "./names.js";
18
18
  import { redactSecretsInText } from "./redact.js";
19
19
  import { botAvatarProfile } from "../shared/bot-avatar.js";
20
20
  import { approvalModeFor, isApprovalMode } from "../shared/approval-mode.js";
21
+ import { isMentionBoundary, isMentionNameContinuation } from "../shared/mention-boundary.js";
21
22
  export const groupWireProjectionIsExact = true;
22
23
  // Unicode's complete emoji sequences include flags, skin tones and ZWJ
23
24
  // combinations. Also allow unqualified single symbols (e.g. ♥), but not
@@ -35,9 +36,18 @@ export function toWireTask(task) {
35
36
  }
36
37
  const TASK_PATCH_FIELDS = [
37
38
  "title", "projectId", "modelSelection", "approvalMode", "autoApprove", "alwaysAllow",
38
- "unread", "rewound", "archivedAt", "pinnedMessageId", "resumeCursors", "lastInstanceId", "cwd",
39
+ "unread", "rewound", "archivedAt", "pinned", "pinnedMessageId", "resumeCursors", "lastInstanceId", "cwd",
39
40
  "routineRunId", "surface", "appliedCompactionId", "contextFloor", "lastContextModel",
40
41
  ];
42
+ /** Only `true` is a pin. false/undefined must not land in bots.json or groups.json. */
43
+ function persistedPin(task) {
44
+ if (task.pinned === true)
45
+ return task;
46
+ if (!("pinned" in task))
47
+ return task;
48
+ const { pinned: _pinned, ...rest } = task;
49
+ return rest;
50
+ }
41
51
  /** Everything the BOT authored is scrubbed of content-shaped secrets before
42
52
  * it is stored: its reply text, a tool title (an ACP engine's title can be
43
53
  * the whole command line) and the command beside it, a permission card's
@@ -254,6 +264,22 @@ export function titleFromLlm(raw) {
254
264
  export const botWireProjectionIsExact = true;
255
265
  const BOTS_FILE = join(DATA_DIR, "bots.json");
256
266
  const GROUPS_FILE = join(DATA_DIR, "groups.json");
267
+ /** The registries carry souls, project paths and per-bot settings, so they are
268
+ * owner-only like the other data-dir stores. A file written by an older
269
+ * release (or loosened by hand) is tightened on load. Best effort: failing to
270
+ * tighten must never stop the fleet from loading, and the next save replaces
271
+ * the file with a 0600 one anyway. Windows has no POSIX mode bits. */
272
+ function tightenRegistryFile(file) {
273
+ if (process.platform === "win32")
274
+ return;
275
+ try {
276
+ if ((statSync(file).mode & 0o077) !== 0)
277
+ chmodSync(file, 0o600);
278
+ }
279
+ catch {
280
+ /* absent, or not ours to change */
281
+ }
282
+ }
257
283
  const messagesFile = (threadId) => join(DATA_DIR, `messages-${threadId}.json`);
258
284
  const COLORS = [
259
285
  "green",
@@ -283,15 +309,15 @@ export function mentionedBots(text, peers) {
283
309
  const found = [];
284
310
  let at = -1;
285
311
  while ((at = lower.indexOf("@", at + 1)) !== -1) {
286
- if (at > 0 && !/\s/.test(text[at - 1]))
312
+ if (!isMentionBoundary(text, at))
287
313
  continue; // user@host, not a tag
288
314
  const rest = lower.slice(at + 1);
289
315
  const hit = candidates.find((p) => {
290
316
  const name = p.name.toLowerCase();
291
317
  if (!rest.startsWith(name))
292
318
  return false;
293
- const after = rest[name.length]; // must not run into a longer word
294
- return after === undefined || !/[a-z0-9]/i.test(after);
319
+ const after = rest.slice(name.length); // must not run into a longer word
320
+ return !isMentionNameContinuation(after);
295
321
  });
296
322
  if (hit && !found.includes(hit))
297
323
  found.push(hit);
@@ -324,8 +350,15 @@ export function normalizeGroupDefaultResponder(value, memberIds, dm = false) {
324
350
  * otherwise the room policy chooses one member, everyone, or nobody. */
325
351
  export function roomResponders(text, members, defaultResponder) {
326
352
  const available = members.filter((member) => !member.hidden);
327
- if (/(?:^|\s)@everyone\b/i.test(text))
328
- return available;
353
+ const everyone = "everyone";
354
+ for (let at = text.indexOf("@"); at !== -1; at = text.indexOf("@", at + 1)) {
355
+ const candidate = text.slice(at + 1, at + 1 + everyone.length);
356
+ if (isMentionBoundary(text, at) &&
357
+ candidate.toLocaleLowerCase() === everyone &&
358
+ !isMentionNameContinuation(text.slice(at + 1 + everyone.length))) {
359
+ return available;
360
+ }
361
+ }
329
362
  const mentioned = mentionedBots(text, available);
330
363
  if (mentioned.length)
331
364
  return mentioned;
@@ -351,6 +384,8 @@ export class Store {
351
384
  constructor(defaultSelection) {
352
385
  this.defaultSelection = defaultSelection;
353
386
  mkdirSync(DATA_DIR, { recursive: true });
387
+ for (const file of [BOTS_FILE, GROUPS_FILE])
388
+ tightenRegistryFile(file);
354
389
  try {
355
390
  this.bots = JSON.parse(readFileSync(BOTS_FILE, "utf8"));
356
391
  }
@@ -517,6 +552,7 @@ export class Store {
517
552
  threadId: g.threadId,
518
553
  title: this.firstUserLine(g.threadId) ?? UNTITLED_TASK,
519
554
  createdAt: g.createdAt,
555
+ updatedAt: g.createdAt,
520
556
  };
521
557
  if (g.pinnedCwd !== undefined)
522
558
  initialTask.pinnedCwd = g.pinnedCwd;
@@ -552,6 +588,7 @@ export class Store {
552
588
  threadId: b.threadId,
553
589
  title: this.firstUserLine(b.threadId) ?? UNTITLED_TASK,
554
590
  createdAt: b.createdAt,
591
+ updatedAt: b.createdAt,
555
592
  resumeCursors: b.resumeCursors ?? {},
556
593
  }];
557
594
  botsMigrated = true;
@@ -564,6 +601,7 @@ export class Store {
564
601
  threadId: b.threadId,
565
602
  title: this.firstUserLine(b.threadId) ?? UNTITLED_TASK,
566
603
  createdAt: b.createdAt,
604
+ updatedAt: b.createdAt,
567
605
  resumeCursors: b.resumeCursors ?? {},
568
606
  };
569
607
  b.tasks.unshift(active);
@@ -619,18 +657,124 @@ export class Store {
619
657
  if (existsSync(legacyFile))
620
658
  mdb.readThread(threadId, legacyFile);
621
659
  }
660
+ // After legacy transcripts are in SQLite, so the first boot sees their
661
+ // newest message instead of stamping createdAt and jumping next launch.
662
+ this.repairThreadUpdatedAts();
663
+ // After the roster is loaded, so identity resolution sees which opener
664
+ // ids are still live.
665
+ this.repairDuplicatePairConversations();
622
666
  this.registeringInitialSections = false;
623
667
  }
668
+ /** Advance a task's update stamp in memory only. Message writes must not
669
+ * rewrite bots.json; the next snapshot and the startup repair read this. */
670
+ noteThreadActivity(threadId, at) {
671
+ if (!Number.isFinite(at))
672
+ return;
673
+ const advance = (current) => Math.max(current ?? 0, at);
674
+ for (const bot of this.bots) {
675
+ const task = bot.tasks?.find((candidate) => candidate.threadId === threadId);
676
+ if (!task)
677
+ continue;
678
+ const next = advance(task.updatedAt);
679
+ if (task.updatedAt !== next)
680
+ task.updatedAt = next;
681
+ }
682
+ for (const group of this.groups) {
683
+ const task = group.tasks?.find((candidate) => candidate.threadId === threadId);
684
+ if (!task)
685
+ continue;
686
+ const next = advance(task.updatedAt);
687
+ if (task.updatedAt !== next)
688
+ task.updatedAt = next;
689
+ }
690
+ }
691
+ /** Fill missing stamps, and raise a stamp that is older than the newest
692
+ * stored message. Never moves a stamp backwards: there is no per-message
693
+ * delete, and a clock skew must not reshuffle the list on every boot. */
694
+ repairThreadUpdatedAts() {
695
+ const botTasks = this.bots.flatMap((bot) => bot.tasks ?? []);
696
+ const groupTasks = this.groups.flatMap((group) => group.tasks ?? []);
697
+ const latest = mdb.latestMessageAts([...botTasks, ...groupTasks].map((task) => task.threadId));
698
+ let botsDirty = false;
699
+ let groupsDirty = false;
700
+ const repair = (task) => {
701
+ const fromMessages = latest.get(task.threadId);
702
+ const next = fromMessages !== undefined && (task.updatedAt === undefined || fromMessages > task.updatedAt)
703
+ ? fromMessages
704
+ : task.updatedAt ?? task.createdAt;
705
+ if (task.updatedAt === next)
706
+ return false;
707
+ task.updatedAt = next;
708
+ return true;
709
+ };
710
+ for (const task of botTasks)
711
+ if (repair(task))
712
+ botsDirty = true;
713
+ for (const task of groupTasks)
714
+ if (repair(task))
715
+ groupsDirty = true;
716
+ if (botsDirty)
717
+ this.saveBots();
718
+ if (groupsDirty)
719
+ this.saveGroups();
720
+ }
721
+ /** At most one live pair row per (recipient, sender identity). Servers
722
+ * before identity-stable matching minted a second live pair row for a
723
+ * deleted-and-recreated sender while the predecessor's row dangled live
724
+ * forever; collapse those on load by keeping the row the resolver favors
725
+ * (first in the task list — the newest, actively used one) and demoting
726
+ * the rest to closed plain threads. History is kept, never deleted, and
727
+ * a demoted row is never re-adopted: adoption skips closed rows. Runs on
728
+ * every load and touches nothing in a store that already holds the
729
+ * invariant. */
730
+ repairDuplicatePairConversations() {
731
+ let botsDirty = false;
732
+ const sweep = (tasks, repaired) => {
733
+ const pairs = new Map();
734
+ for (const task of tasks ?? []) {
735
+ const by = task.openedBy;
736
+ if (by?.kind !== "pair" || task.closedBy)
737
+ continue;
738
+ const identity = this.openerIdentity(by);
739
+ pairs.set(identity, [...(pairs.get(identity) ?? []), task]);
740
+ }
741
+ for (const rows of pairs.values()) {
742
+ // rows are in task-list order, the same order the resolver's
743
+ // find() favors: the first is the one it keeps using.
744
+ for (const duplicate of rows.slice(1)) {
745
+ const by = duplicate.openedBy;
746
+ const identity = this.openerIdentity(by);
747
+ const { kind: _kind, ...opened } = by;
748
+ duplicate.openedBy = opened;
749
+ duplicate.closedBy = {
750
+ botId: identity,
751
+ name: this.bot(identity)?.name ?? by.name,
752
+ at: Date.now(),
753
+ };
754
+ repaired();
755
+ }
756
+ }
757
+ };
758
+ // Pair rows only ever live on bots: the resolver requires a bot
759
+ // recipient, and group tasks carry no peer stamps.
760
+ for (const bot of this.bots)
761
+ sweep(bot.tasks, () => { botsDirty = true; });
762
+ if (botsDirty)
763
+ this.saveBots();
764
+ }
624
765
  saveBots(bots = this.bots) {
625
766
  this.rememberSections([...this.bots, ...bots].map((bot) => bot.section));
626
767
  writeFileAtomic(BOTS_FILE, JSON.stringify(bots.map(({ busy: _busy, activity: _activity, ...bot }) => ({
627
768
  ...bot,
628
- tasks: bot.tasks?.map(({ busy: _taskBusy, activity: _taskActivity, turnStartedAt: _taskTurnStarted, ...task }) => task),
629
- })), null, 2));
769
+ tasks: bot.tasks?.map(({ busy: _taskBusy, activity: _taskActivity, turnStartedAt: _taskTurnStarted, ...task }) => persistedPin(task)),
770
+ })), null, 2), { mode: 0o600 });
630
771
  }
631
772
  saveGroups() {
632
773
  this.rememberSections(this.groups.map((group) => group.section));
633
- writeFileAtomic(GROUPS_FILE, JSON.stringify(this.groups.map(({ busyBotId: _busyBotId, turnStartedAt: _turnStartedAt, ...g }) => g), null, 2));
774
+ writeFileAtomic(GROUPS_FILE, JSON.stringify(this.groups.map(({ busyBotId: _busyBotId, turnStartedAt: _turnStartedAt, ...g }) => ({
775
+ ...g,
776
+ ...(g.tasks ? { tasks: g.tasks.map((task) => persistedPin(task)) } : {}),
777
+ })), null, 2), { mode: 0o600 });
634
778
  }
635
779
  get sections() { return readSections(); }
636
780
  rememberSections(names) {
@@ -715,7 +859,7 @@ export class Store {
715
859
  section,
716
860
  };
717
861
  if (!dm) {
718
- group.tasks = [{ threadId, title: UNTITLED_TASK, createdAt }];
862
+ group.tasks = [{ threadId, title: UNTITLED_TASK, createdAt, updatedAt: createdAt }];
719
863
  group.setupCompletedAt = setup?.completed ? createdAt : null;
720
864
  group.setupSkippedAt = null;
721
865
  }
@@ -847,10 +991,12 @@ export class Store {
847
991
  const group = this.group(groupId);
848
992
  if (!group || group.dm)
849
993
  return null;
994
+ const createdAt = Date.now();
850
995
  const task = {
851
996
  threadId: newId(),
852
997
  title: title?.trim().slice(0, 80) || UNTITLED_TASK,
853
- createdAt: Date.now(),
998
+ createdAt,
999
+ updatedAt: createdAt,
854
1000
  };
855
1001
  group.tasks = [task, ...(group.tasks ?? [])];
856
1002
  if (activate) {
@@ -874,6 +1020,18 @@ export class Store {
874
1020
  this.emit({ type: "group", groupId });
875
1021
  return group;
876
1022
  }
1023
+ setGroupTaskPinned(groupId, threadId, pinned) {
1024
+ const task = this.groupTaskByThread(groupId, threadId);
1025
+ if (!task)
1026
+ return null;
1027
+ if (pinned)
1028
+ task.pinned = true;
1029
+ else
1030
+ delete task.pinned;
1031
+ this.saveGroups();
1032
+ this.emit({ type: "group", groupId });
1033
+ return task;
1034
+ }
877
1035
  renameGroupTask(groupId, threadId, title) {
878
1036
  const task = this.groupTaskByThread(groupId, threadId);
879
1037
  if (!task)
@@ -1003,6 +1161,9 @@ export class Store {
1003
1161
  throw new Error("Cannot import over an existing conversation");
1004
1162
  mdb.importThread(threadId, messages, activeLeafId);
1005
1163
  this.threads.delete(threadId);
1164
+ const newest = messages.reduce((max, message) => Math.max(max, message.at), Number.NEGATIVE_INFINITY);
1165
+ if (Number.isFinite(newest))
1166
+ this.noteThreadActivity(threadId, newest);
1006
1167
  }
1007
1168
  activeLeaf(threadId) {
1008
1169
  return this.thread(threadId).activeLeafId;
@@ -1051,6 +1212,7 @@ export class Store {
1051
1212
  this.emit({ type: "message.patch", threadId, message: pruned });
1052
1213
  }
1053
1214
  }
1215
+ this.noteThreadActivity(threadId, full.at);
1054
1216
  this.emit({ type: "message", threadId, message: full });
1055
1217
  // The first-run quiz is not a live ask. Talking past it hides it so the
1056
1218
  // transcript is just the greeting plus what they said. Cards with a
@@ -1081,6 +1243,7 @@ export class Store {
1081
1243
  this.emit({ type: "message.patch", threadId, message: pruned });
1082
1244
  }
1083
1245
  }
1246
+ this.noteThreadActivity(threadId, full.at);
1084
1247
  this.emit({ type: "message", threadId, message: full });
1085
1248
  // announced after the insert so no client ever sees two siblings
1086
1249
  // claiming the same parent
@@ -1136,6 +1299,7 @@ export class Store {
1136
1299
  t.messages.push(full);
1137
1300
  t.activeLeafId = full.id;
1138
1301
  mdb.appendMessage(threadId, full);
1302
+ this.noteThreadActivity(threadId, full.at);
1139
1303
  this.emit({ type: "message", threadId, message: full });
1140
1304
  // The message frame alone leaves every client on the OLD branch: a
1141
1305
  // client adopts a new message as its leaf only when it chains onto the
@@ -1204,6 +1368,8 @@ export class Store {
1204
1368
  color: profile.color ?? COLORS[this.bots.length % COLORS.length],
1205
1369
  ...(profile.mascotExpression ? { mascotExpression: profile.mascotExpression } : {}),
1206
1370
  ...(profile.mascotBody ? { mascotBody: profile.mascotBody } : {}),
1371
+ // Restricted from its first frame: no one else is ever told it exists.
1372
+ ...(profile.visibility && profile.visibility !== "everyone" ? { visibility: structuredClone(profile.visibility) } : {}),
1207
1373
  unread: false,
1208
1374
  modelSelection: profile.modelSelection ?? this.defaultSelection(),
1209
1375
  resumeCursors: {},
@@ -1215,6 +1381,7 @@ export class Store {
1215
1381
  threadId: bot.threadId,
1216
1382
  title: UNTITLED_THREAD,
1217
1383
  createdAt: bot.createdAt,
1384
+ updatedAt: bot.createdAt,
1218
1385
  resumeCursors: {},
1219
1386
  modelSelection: structuredClone(bot.modelSelection),
1220
1387
  unread: false,
@@ -1270,7 +1437,10 @@ export class Store {
1270
1437
  title: "", description: "", soul: "", notifications: true, color: COLORS[nextBots.length % COLORS.length], unread: false,
1271
1438
  modelSelection: operation.fields.modelSelection, resumeCursors: {}, createdAt, ...operation.fields,
1272
1439
  approvalMode: "ask", autoApprove: false, composio: false, approvePeerComms: false,
1273
- tasks: [{ threadId: operation.threadId, title: UNTITLED_THREAD, createdAt, resumeCursors: {},
1440
+ // A Chief's new teammate is seen by exactly the Chief's audience:
1441
+ // a restricted Chief never creates a bot everyone sees.
1442
+ ...(chief.visibility && chief.visibility !== "everyone" ? { visibility: structuredClone(chief.visibility) } : {}),
1443
+ tasks: [{ threadId: operation.threadId, title: UNTITLED_THREAD, createdAt, updatedAt: createdAt, resumeCursors: {},
1274
1444
  modelSelection: structuredClone(operation.fields.modelSelection), approvalMode: "ask", autoApprove: false,
1275
1445
  unread: false, activity: "idle", busy: false }],
1276
1446
  };
@@ -1551,6 +1721,64 @@ export class Store {
1551
1721
  this.emit({ type: "bot", botId: bot.id });
1552
1722
  return changed;
1553
1723
  }
1724
+ /** Company instance ids became stable across re-enrolment. Moves every
1725
+ * saved reference to an old id onto its replacement in one save: model
1726
+ * choices, native resume cursors and handed-message records. An entry that
1727
+ * already exists under the new id wins over the old one. */
1728
+ renameInstances(ids) {
1729
+ const touches = (record) => Boolean(record && Object.keys(record).some(key => ids.has(key)));
1730
+ const rename = (record) => {
1731
+ const next = {};
1732
+ for (const [key, value] of Object.entries(record)) {
1733
+ const target = ids.get(key);
1734
+ if (!target)
1735
+ next[key] = value;
1736
+ else if (!Object.prototype.hasOwnProperty.call(record, target))
1737
+ next[target] = value;
1738
+ }
1739
+ return next;
1740
+ };
1741
+ const changed = [];
1742
+ for (const bot of this.bots) {
1743
+ let dirty = false;
1744
+ const selected = ids.get(bot.modelSelection.instanceId);
1745
+ if (selected) {
1746
+ bot.modelSelection = { ...bot.modelSelection, instanceId: selected };
1747
+ dirty = true;
1748
+ }
1749
+ if (touches(bot.resumeCursors)) {
1750
+ bot.resumeCursors = rename(bot.resumeCursors);
1751
+ dirty = true;
1752
+ }
1753
+ for (const task of bot.tasks ?? []) {
1754
+ const taskSelected = task.modelSelection && ids.get(task.modelSelection.instanceId);
1755
+ if (task.modelSelection && taskSelected) {
1756
+ task.modelSelection = { ...task.modelSelection, instanceId: taskSelected };
1757
+ dirty = true;
1758
+ }
1759
+ if (touches(task.resumeCursors)) {
1760
+ task.resumeCursors = rename(task.resumeCursors);
1761
+ dirty = true;
1762
+ }
1763
+ if (task.handedMessages && touches(task.handedMessages)) {
1764
+ task.handedMessages = rename(task.handedMessages);
1765
+ dirty = true;
1766
+ }
1767
+ const last = task.lastInstanceId && ids.get(task.lastInstanceId);
1768
+ if (last) {
1769
+ task.lastInstanceId = last;
1770
+ dirty = true;
1771
+ }
1772
+ }
1773
+ if (dirty)
1774
+ changed.push(bot);
1775
+ }
1776
+ if (changed.length)
1777
+ this.saveBots();
1778
+ for (const bot of changed)
1779
+ this.emit({ type: "bot", botId: bot.id });
1780
+ return changed.length;
1781
+ }
1554
1782
  setResumeCursor(botId, instanceId, cursor, threadId) {
1555
1783
  const bot = this.bot(botId);
1556
1784
  if (!bot)
@@ -1797,6 +2025,8 @@ export class Store {
1797
2025
  }
1798
2026
  if (typeof patch.title === "string")
1799
2027
  task.title = patch.title.trim().slice(0, 80) || UNTITLED_THREAD;
2028
+ if (Object.prototype.hasOwnProperty.call(patch, "pinned") && task.pinned !== true)
2029
+ delete task.pinned;
1800
2030
  if (bot.threadId === threadId)
1801
2031
  this.mirrorActiveTask(bot, task);
1802
2032
  bot.unread = bot.tasks.some((candidate) => candidate.unread);
@@ -1814,8 +2044,8 @@ export class Store {
1814
2044
  return null;
1815
2045
  const patch = { modelSelection: structuredClone(selection),
1816
2046
  ...(resetApprovalToAsk ? { approvalMode: "ask", autoApprove: false, alwaysAllow: [] } : {}) };
1817
- const nextTask = { ...task, ...taskPatch, ...patch,
1818
- ...(typeof taskPatch.title === "string" ? { title: taskPatch.title.trim().slice(0, 80) || UNTITLED_THREAD } : {}) };
2047
+ const nextTask = persistedPin({ ...task, ...taskPatch, ...patch,
2048
+ ...(typeof taskPatch.title === "string" ? { title: taskPatch.title.trim().slice(0, 80) || UNTITLED_THREAD } : {}) });
1819
2049
  // Older threads may still inherit settings. Freeze their effective
1820
2050
  // values before updating the default so "other threads unchanged" also
1821
2051
  // holds for workspaces created before per-thread approval settings.
@@ -1835,6 +2065,20 @@ export class Store {
1835
2065
  this.emit({ type: "bot", botId });
1836
2066
  return task;
1837
2067
  }
2068
+ /** One durable write: never leave only part of a bot's threads updated. */
2069
+ setAllThreadApprovalMode(botId, mode) {
2070
+ const bot = this.bot(botId);
2071
+ if (!bot)
2072
+ return null;
2073
+ const patch = { approvalMode: mode, autoApprove: false, alwaysAllow: [] };
2074
+ const tasks = (bot.tasks ?? []).map(task => ({ ...task, ...patch }));
2075
+ const next = { ...bot, ...patch, approvalGrant: undefined, tasks };
2076
+ this.saveBots(this.bots.map(candidate => candidate === bot ? next : candidate));
2077
+ bot.tasks?.forEach((task, index) => Object.assign(task, tasks[index]));
2078
+ Object.assign(bot, patch, { approvalGrant: undefined });
2079
+ this.emit({ type: "bot", botId });
2080
+ return bot;
2081
+ }
1838
2082
  mirrorActiveTask(bot, task) {
1839
2083
  bot.threadId = task.threadId;
1840
2084
  bot.resumeCursors = structuredClone(task.resumeCursors);
@@ -1843,23 +2087,25 @@ export class Store {
1843
2087
  }
1844
2088
  /** A fresh context on the same bot: new thread, new session, same
1845
2089
  * persona/tools/computer. Becomes the active task. */
1846
- createTask(botId, title, activate = true, projectId, openedBy) {
2090
+ createTask(botId, title, activate = true, projectId, openedBy, approvalMode) {
1847
2091
  const bot = this.bot(botId);
1848
2092
  if (!bot)
1849
2093
  return null;
1850
2094
  if (projectId !== undefined && !this.project(botId, projectId))
1851
2095
  return null;
2096
+ const createdAt = Date.now();
1852
2097
  const task = {
1853
2098
  threadId: newId(),
1854
2099
  title: threadTitleFrom(title),
1855
- createdAt: Date.now(),
2100
+ createdAt,
2101
+ updatedAt: createdAt,
1856
2102
  ...(projectId ? { projectId } : {}),
1857
2103
  ...(openedBy ? { openedBy: structuredClone(openedBy) } : {}),
1858
2104
  resumeCursors: {},
1859
2105
  modelSelection: structuredClone(bot.modelSelection),
1860
- approvalMode: approvalModeFor(bot),
1861
- autoApprove: Boolean(bot.autoApprove),
1862
- alwaysAllow: [...(bot.alwaysAllow ?? [])],
2106
+ approvalMode: approvalMode ?? approvalModeFor(bot),
2107
+ autoApprove: approvalMode ? false : Boolean(bot.autoApprove),
2108
+ alwaysAllow: approvalMode ? [] : [...(bot.alwaysAllow ?? [])],
1863
2109
  unread: false,
1864
2110
  activity: "idle",
1865
2111
  busy: false,
@@ -1932,12 +2178,29 @@ export class Store {
1932
2178
  * interleave in one transcript. `label` names that thread; the caller
1933
2179
  * closes it once its result has been reported. A pair conversation
1934
2180
  * never auto-closes. */
2181
+ /** The identity a peer-opened row belongs to: its opener's id while that
2182
+ * bot lives, else the one live bot the stamp's name still points at —
2183
+ * what a deleted-and-recreated same-name bot inherits — else the dead id
2184
+ * itself. A name two live bots share resolves to nobody's twin:
2185
+ * ambiguous means unmatched, never a wrong merge. */
2186
+ openerIdentity(openedBy) {
2187
+ if (this.bot(openedBy.botId))
2188
+ return openedBy.botId;
2189
+ const named = this.bots.filter((bot) => bot.name === openedBy.name);
2190
+ return named.length === 1 ? named[0].id : openedBy.botId;
2191
+ }
1935
2192
  resolvePairConversation(sender, recipientId, options) {
1936
2193
  if (!this.bot(recipientId))
1937
2194
  return null;
1938
2195
  const title = `@${sender.name}`;
1939
2196
  const opener = (kind, at = Date.now()) => ({ botId: sender.id, name: sender.name, kind, at });
1940
- const fromSender = this.tasks(recipientId).filter((task) => task.openedBy?.botId === sender.id);
2197
+ // Identity-stable: the sender's own rows, plus ones a deleted
2198
+ // predecessor opened when the stamp's name still points at exactly
2199
+ // this bot — a same-name recreation inherits the conversation instead
2200
+ // of minting a twin while the old row dangles live. A name two live
2201
+ // bots share matches nobody's inheritance: refusing the fallback can
2202
+ // cost a new row, never merge two bots' histories.
2203
+ const fromSender = this.tasks(recipientId).filter((task) => task.openedBy && this.openerIdentity(task.openedBy) === sender.id);
1941
2204
  let pair = fromSender.find((task) => task.openedBy?.kind === "pair");
1942
2205
  if (!pair) {
1943
2206
  const lastActivity = (task) => this.messagesTail(task.threadId, 1).messages.at(-1)?.at ?? task.openedBy?.at ?? task.createdAt;
@@ -1966,6 +2229,14 @@ export class Store {
1966
2229
  // back up, never replaced: closing is only the sidebar's idle state.
1967
2230
  if (pair.closedBy)
1968
2231
  this.setTaskClosedBy(recipientId, pair.threadId, null);
2232
+ // An inherited row carries the predecessor's id; rebind it to the
2233
+ // live bot so the name fallback is needed only once — a namesake
2234
+ // appearing later cannot claim the row. The hour it was opened
2235
+ // stays: inheritance is not a new conversation.
2236
+ const inherited = pair.openedBy;
2237
+ if (inherited && inherited.botId !== sender.id) {
2238
+ this.setTaskOpenedBy(recipientId, pair.threadId, { ...inherited, botId: sender.id, name: sender.name });
2239
+ }
1969
2240
  return { task: pair, created: false };
1970
2241
  }
1971
2242
  // The brief is never a title. An 80-character slice of an assignment
@@ -77,6 +77,7 @@ export function createTeamBackup(store, routines, name) {
77
77
  closedBy: "closedBy" in task && task.closedBy
78
78
  ? { botId: task.closedBy.botId, name: task.closedBy.name, at: task.closedBy.at }
79
79
  : undefined,
80
+ pinned: task.pinned === true ? true : undefined,
80
81
  activeLeafId: store.activeLeaf(task.threadId),
81
82
  messages: store.messagesFor(task.threadId).map((message) => ({
82
83
  id: message.id, role: message.role, text: messageText(message), at: message.at,
@@ -130,7 +131,7 @@ export function createTeamBackup(store, routines, name) {
130
131
  }
131
132
  /** Import is always additive, including sections and Chiefs. Rollback owns
132
133
  * only the fresh records below and cannot touch any pre-existing bot/chat. */
133
- export function importTeamBackup(store, routines, input, selection) {
134
+ export function importTeamBackup(store, routines, input, selection, options = {}) {
134
135
  const backup = parseTeamBackup(input);
135
136
  const bots = [];
136
137
  const groups = [];
@@ -175,6 +176,8 @@ export function importTeamBackup(store, routines, input, selection) {
175
176
  color: source.color, mascotExpression: source.mascotExpression,
176
177
  mascotBody: botMascotBody(source.mascotBody),
177
178
  modelSelection: selection, section: sectionFor(source.section),
179
+ // who may see the imported team is the importing admin's choice
180
+ ...(options.visibility ? { visibility: options.visibility } : {}),
178
181
  }, { seedMessages: false });
179
182
  bots.push(bot);
180
183
  botIds.set(source.key, bot.id);
@@ -203,6 +206,10 @@ export function importTeamBackup(store, routines, input, selection) {
203
206
  const closer = task.closedBy && botIds.get(task.closedBy.botId);
204
207
  if (task.closedBy && closer)
205
208
  record.closedBy = { botId: closer, name: task.closedBy.name, at: task.closedBy.at };
209
+ if (task.pinned === true)
210
+ record.pinned = true;
211
+ const newest = task.messages.reduce((max, message) => Math.max(max, message.at), task.createdAt);
212
+ record.updatedAt = newest;
206
213
  return record;
207
214
  });
208
215
  // Own the task IDs before writing their transcripts, so rollback also
@@ -229,6 +236,8 @@ export function importTeamBackup(store, routines, input, selection) {
229
236
  if (!source.dm) {
230
237
  group.tasks = source.tasks.map((task, i) => ({
231
238
  threadId: threads[i], title: task.title, createdAt: task.createdAt,
239
+ updatedAt: task.messages.reduce((max, message) => Math.max(max, message.at), task.createdAt),
240
+ ...(task.pinned === true ? { pinned: true } : {}),
232
241
  ...(task.titleFromFirstMessage ? { titleFromFirstMessage: true } : {}),
233
242
  }));
234
243
  store.switchGroupTask(group.id, threads[source.tasks.findIndex((task) => task.key === source.activeTask)]);
@@ -0,0 +1,53 @@
1
+ // Who each thread is for, when a signed-in person can be named: the person
2
+ // who opened it from a session, or — for a thread a bot opened while working
3
+ // on someone's request — the person that request came from. One small
4
+ // server-private file (<data>/thread-starters.json, 0600), never sent to a
5
+ // client and never part of a bot or room record, so no wire projection can
6
+ // leak it. It decides one thing only: on a workspace several people share,
7
+ // whose session may answer the cards raised in that thread
8
+ // (server/index.ts cardAnswerRefusal). A thread with no entry names nobody,
9
+ // and then anyone who may chat may answer, as before.
10
+ import { readFileSync } from "node:fs";
11
+ import { writeFileAtomic } from "./atomic.js";
12
+ const KEY = /^p_[\w-]{22}$/;
13
+ const THREAD = /^[\w-]{1,128}$/;
14
+ /** Oldest entries go first past this; a thread that old has long settled. */
15
+ const MAX_ENTRIES = 50_000;
16
+ export class ThreadStarters {
17
+ starters = new Map();
18
+ file;
19
+ constructor(file) {
20
+ this.file = file;
21
+ let raw;
22
+ try {
23
+ raw = JSON.parse(readFileSync(file, "utf8"));
24
+ }
25
+ catch {
26
+ return; // absent or unreadable: nobody is named, which is today's behaviour
27
+ }
28
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
29
+ return;
30
+ for (const [threadId, person] of Object.entries(raw)) {
31
+ if (THREAD.test(threadId) && typeof person === "string" && KEY.test(person))
32
+ this.starters.set(threadId, person);
33
+ }
34
+ }
35
+ get(threadId) {
36
+ return this.starters.get(threadId);
37
+ }
38
+ /** Record once; a thread keeps the person it was first opened for. */
39
+ set(threadId, person) {
40
+ if (!person || !KEY.test(person) || !THREAD.test(threadId) || this.starters.has(threadId))
41
+ return;
42
+ this.starters.set(threadId, person);
43
+ while (this.starters.size > MAX_ENTRIES)
44
+ this.starters.delete(this.starters.keys().next().value);
45
+ try {
46
+ writeFileAtomic(this.file, JSON.stringify(Object.fromEntries(this.starters)) + "\n", { mode: 0o600 });
47
+ }
48
+ catch (error) {
49
+ // Kept in memory for this run; after a restart the thread names nobody.
50
+ console.warn(`thread starters: could not save ${this.file}: ${error instanceof Error ? error.message : String(error)}`);
51
+ }
52
+ }
53
+ }