openmausbot 0.1.84 → 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 (230) 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-DNa2umw-.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/companion/src/routes.js +1 -0
  125. package/dist-server/container-mcp.js +231 -17
  126. package/dist-server/drivers/agents-proxy.js +16097 -1411
  127. package/dist-server/enterprise/LICENSE +32 -0
  128. package/dist-server/enterprise/server/index.js +6 -5
  129. package/dist-server/hooks/omb-hook.js +74 -0
  130. package/dist-server/index.js +12495 -7584
  131. package/dist-server/local-computer-proxy.js +83 -1
  132. package/dist-server/local-computer.js +14 -2
  133. package/dist-server/mcp-server.js +3 -1
  134. package/dist-server/openmausbot.js +5458 -4587
  135. package/dist-server/pair-cli.js +5458 -4587
  136. package/dist-server/proxy-paths.js +1 -0
  137. package/dist-server/server/admin-activity.js +410 -0
  138. package/dist-server/server/agent-tool-policy.js +1 -0
  139. package/dist-server/server/bot-package.js +2 -0
  140. package/dist-server/server/bot-visibility.js +462 -0
  141. package/dist-server/server/box.js +25 -5
  142. package/dist-server/server/browser-engine.js +87 -10
  143. package/dist-server/server/browser-live.js +7 -5
  144. package/dist-server/server/browser-runtime.js +66 -11
  145. package/dist-server/server/channel-queue.js +2 -0
  146. package/dist-server/server/checkpoints.js +62 -18
  147. package/dist-server/server/cli-prompts.js +26 -2
  148. package/dist-server/server/cli.js +79 -10
  149. package/dist-server/server/commands.js +27 -0
  150. package/dist-server/server/compaction-summary.js +78 -0
  151. package/dist-server/server/composio.js +49 -11
  152. package/dist-server/server/config.js +137 -14
  153. package/dist-server/server/context-budget.js +29 -0
  154. package/dist-server/server/context-rebuild.js +72 -0
  155. package/dist-server/server/decision-log.js +209 -48
  156. package/dist-server/server/default-model-selection.js +20 -3
  157. package/dist-server/server/delegations.js +11 -9
  158. package/dist-server/server/digest.js +130 -0
  159. package/dist-server/server/drivers/acp/core.js +637 -238
  160. package/dist-server/server/drivers/acp/grok.js +4 -3
  161. package/dist-server/server/drivers/agents-call.js +1123 -0
  162. package/dist-server/server/drivers/agents-catalog.js +659 -0
  163. package/dist-server/server/drivers/agents-client.js +28 -0
  164. package/dist-server/server/drivers/agents-proxy.js +18 -1683
  165. package/dist-server/server/drivers/agents-result.js +23 -0
  166. package/dist-server/server/drivers/chat-mcp-tools.js +17 -2
  167. package/dist-server/server/drivers/claude.js +83 -16
  168. package/dist-server/server/drivers/codex-mcp-names.js +44 -0
  169. package/dist-server/server/drivers/codex.js +32 -4
  170. package/dist-server/server/drivers/grok.js +2 -1
  171. package/dist-server/server/drivers/openai-chat.js +66 -45
  172. package/dist-server/server/drivers/openai-compat.js +16 -8
  173. package/dist-server/server/enterprise.js +97 -16
  174. package/dist-server/server/external-runtime.js +83 -0
  175. package/dist-server/server/guarded-requests.js +60 -0
  176. package/dist-server/server/harness/http.js +61 -0
  177. package/dist-server/server/hooks/omb-hook.js +103 -0
  178. package/dist-server/server/hosted-models.js +151 -0
  179. package/dist-server/server/hosted-slack.js +11 -0
  180. package/dist-server/server/index.js +2810 -678
  181. package/dist-server/server/local-computer.js +21 -2
  182. package/dist-server/server/managed-desktop.js +79 -14
  183. package/dist-server/server/managed-policy.js +153 -0
  184. package/dist-server/server/mcp-bridge.js +10 -2
  185. package/dist-server/server/mcp-tool-images.js +37 -0
  186. package/dist-server/server/mcp-tool-schema.js +108 -0
  187. package/dist-server/server/message-db.js +108 -21
  188. package/dist-server/server/message-file.js +3 -2
  189. package/dist-server/server/model-context-window.js +7 -0
  190. package/dist-server/server/model-prices.js +121 -0
  191. package/dist-server/server/notify.js +10 -1
  192. package/dist-server/server/package-export.js +1 -0
  193. package/dist-server/server/peer-roster.js +17 -4
  194. package/dist-server/server/provider-icon.js +20 -0
  195. package/dist-server/server/proxy-paths.js +1 -0
  196. package/dist-server/server/recent-work.js +15 -0
  197. package/dist-server/server/request-auth.js +97 -0
  198. package/dist-server/server/room-handoffs.js +41 -13
  199. package/dist-server/server/routes/hosted-slack.js +20 -0
  200. package/dist-server/server/routes/table.js +14 -0
  201. package/dist-server/server/routine-requests.js +39 -1
  202. package/dist-server/server/routines.js +65 -9
  203. package/dist-server/server/shared-computer-control.js +31 -4
  204. package/dist-server/server/spend.js +111 -16
  205. package/dist-server/server/steer-queue.js +12 -1
  206. package/dist-server/server/store.js +384 -33
  207. package/dist-server/server/system-prompt.js +4 -4
  208. package/dist-server/server/team-backup.js +19 -2
  209. package/dist-server/server/thread-starters.js +53 -0
  210. package/dist-server/server/tool-results.js +72 -0
  211. package/dist-server/server/tts/grok.js +74 -0
  212. package/dist-server/server/tts/index.js +19 -1
  213. package/dist-server/server/usage-ledger.js +31 -8
  214. package/dist-server/server/webhooks.js +28 -0
  215. package/dist-server/server/workspace-backup-policy.js +9 -2
  216. package/dist-server/server/workspace-backup.js +7 -4
  217. package/dist-server/shared/approval-mode.js +7 -1
  218. package/dist-server/shared/digest.js +1 -0
  219. package/dist-server/shared/markdown-windows-paths.js +36 -0
  220. package/dist-server/shared/mention-boundary.js +15 -0
  221. package/dist-server/shared/provider-icon.js +110 -0
  222. package/dist-server/shared/routines.js +7 -1
  223. package/dist-server/shared/team-backup.js +5 -0
  224. package/dist-server/src/lib/composer-attachments.js +16 -0
  225. package/dist-server/vps-container-mcp.js +236 -22
  226. package/enterprise/README.md +33 -12
  227. package/enterprise/server/index.js +6 -5
  228. package/package.json +1 -1
  229. package/dist/assets/index-BbU5REzd.js +0 -310
  230. package/dist/assets/index-CLGfYlx_.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";
@@ -11,12 +11,14 @@ import { removeBotFolder, soulFile, soulHash, writeSoulMirror } from "./bot-fold
11
11
  import { peerAllowKey } from "./peer-approval-key.js";
12
12
  import { DATA_DIR, EVENTS_DIR, NATIVE_DIR, loadBrowserProfileIdAliases } from "./config.js";
13
13
  import * as mdb from "./message-db.js";
14
+ import { runCommand } from "./commands.js";
14
15
  import { workspaceDir } from "./workspace.js";
15
16
  import { newId } from "./contracts.js";
16
17
  import { pickBotName } from "./names.js";
17
18
  import { redactSecretsInText } from "./redact.js";
18
19
  import { botAvatarProfile } from "../shared/bot-avatar.js";
19
20
  import { approvalModeFor, isApprovalMode } from "../shared/approval-mode.js";
21
+ import { isMentionBoundary, isMentionNameContinuation } from "../shared/mention-boundary.js";
20
22
  export const groupWireProjectionIsExact = true;
21
23
  // Unicode's complete emoji sequences include flags, skin tones and ZWJ
22
24
  // combinations. Also allow unqualified single symbols (e.g. ♥), but not
@@ -29,14 +31,23 @@ export const taskWireProjectionIsExact = true;
29
31
  /** The typed wire projection for one task. Pairs with the assertion above:
30
32
  * returning WireTask means an undeclared server field cannot ride silently. */
31
33
  export function toWireTask(task) {
32
- const { resumeCursors: _resumeCursors, lastInstanceId: _lastInstanceId, handedMessages: _handedMessages, ...wire } = task;
34
+ const { resumeCursors: _resumeCursors, lastInstanceId: _lastInstanceId, handedMessages: _handedMessages, appliedCompactionId: _appliedCompactionId, contextFloor: _contextFloor, lastContextModel: _lastContextModel, ...wire } = task;
33
35
  return wire;
34
36
  }
35
37
  const TASK_PATCH_FIELDS = [
36
38
  "title", "projectId", "modelSelection", "approvalMode", "autoApprove", "alwaysAllow",
37
- "unread", "rewound", "archivedAt", "pinnedMessageId", "resumeCursors", "lastInstanceId", "cwd",
38
- "routineRunId", "surface",
39
+ "unread", "rewound", "archivedAt", "pinned", "pinnedMessageId", "resumeCursors", "lastInstanceId", "cwd",
40
+ "routineRunId", "surface", "appliedCompactionId", "contextFloor", "lastContextModel",
39
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
+ }
40
51
  /** Everything the BOT authored is scrubbed of content-shaped secrets before
41
52
  * it is stored: its reply text, a tool title (an ACP engine's title can be
42
53
  * the whole command line) and the command beside it, a permission card's
@@ -50,6 +61,8 @@ function redactBotAuthored(message) {
50
61
  const out = { ...message };
51
62
  if (typeof out.text === "string")
52
63
  out.text = redactSecretsInText(out.text);
64
+ if (out.compaction)
65
+ out.compaction = { ...out.compaction, summary: redactSecretsInText(out.compaction.summary) };
53
66
  if (out.tool?.name) {
54
67
  out.tool = { ...out.tool, name: redactSecretsInText(out.tool.name) };
55
68
  if (out.tool.summary)
@@ -227,9 +240,46 @@ export function titleFromMessage(text) {
227
240
  const line = text.trim().split("\n")[0].trim();
228
241
  return line.length > 48 ? `${line.slice(0, 47)}…` : line || UNTITLED_TASK;
229
242
  }
243
+ /** One usable line out of a model's title reply: the first line, no
244
+ * surrounding quotes, code fences, or markdown decoration, no trailing
245
+ * period, single spaces — or null when what came back is empty, too long
246
+ * to be a title, or otherwise not a plain name. The caller keeps its
247
+ * fallback then. */
248
+ export function titleFromLlm(raw) {
249
+ const line = raw
250
+ .trim()
251
+ .split("\n")[0]
252
+ .replace(/^[#*\-\u2022]+/, "")
253
+ .replace(/^["'\u201C\u201D\u2018\u2019\u0060]+/, "")
254
+ .replace(/["'\u201C\u201D\u2018\u2019\u0060]+$/, "")
255
+ // decoration the quotes were hiding: "## Deploy app" keeps its
256
+ // markers through the strips above, which never reach past a quote
257
+ .replace(/^[#*\-\u2022]+/, "")
258
+ .replace(/[#*]+$/, "")
259
+ .replace(/[.\u3002]+$/, "")
260
+ .replace(/\s+/g, " ")
261
+ .trim();
262
+ return line.length >= 1 && line.length <= 48 ? line : null;
263
+ }
230
264
  export const botWireProjectionIsExact = true;
231
265
  const BOTS_FILE = join(DATA_DIR, "bots.json");
232
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
+ }
233
283
  const messagesFile = (threadId) => join(DATA_DIR, `messages-${threadId}.json`);
234
284
  const COLORS = [
235
285
  "green",
@@ -259,15 +309,15 @@ export function mentionedBots(text, peers) {
259
309
  const found = [];
260
310
  let at = -1;
261
311
  while ((at = lower.indexOf("@", at + 1)) !== -1) {
262
- if (at > 0 && !/\s/.test(text[at - 1]))
312
+ if (!isMentionBoundary(text, at))
263
313
  continue; // user@host, not a tag
264
314
  const rest = lower.slice(at + 1);
265
315
  const hit = candidates.find((p) => {
266
316
  const name = p.name.toLowerCase();
267
317
  if (!rest.startsWith(name))
268
318
  return false;
269
- const after = rest[name.length]; // must not run into a longer word
270
- 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);
271
321
  });
272
322
  if (hit && !found.includes(hit))
273
323
  found.push(hit);
@@ -300,8 +350,15 @@ export function normalizeGroupDefaultResponder(value, memberIds, dm = false) {
300
350
  * otherwise the room policy chooses one member, everyone, or nobody. */
301
351
  export function roomResponders(text, members, defaultResponder) {
302
352
  const available = members.filter((member) => !member.hidden);
303
- if (/(?:^|\s)@everyone\b/i.test(text))
304
- 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
+ }
305
362
  const mentioned = mentionedBots(text, available);
306
363
  if (mentioned.length)
307
364
  return mentioned;
@@ -327,6 +384,8 @@ export class Store {
327
384
  constructor(defaultSelection) {
328
385
  this.defaultSelection = defaultSelection;
329
386
  mkdirSync(DATA_DIR, { recursive: true });
387
+ for (const file of [BOTS_FILE, GROUPS_FILE])
388
+ tightenRegistryFile(file);
330
389
  try {
331
390
  this.bots = JSON.parse(readFileSync(BOTS_FILE, "utf8"));
332
391
  }
@@ -475,6 +534,7 @@ export class Store {
475
534
  }
476
535
  for (const g of this.groups) {
477
536
  g.busyBotId = null;
537
+ delete g.turnStartedAt;
478
538
  const normalized = normalizeGroupDefaultResponder(g.defaultResponder, g.memberIds, Boolean(g.dm));
479
539
  if (JSON.stringify(normalized) !== JSON.stringify(g.defaultResponder))
480
540
  groupsMigrated = true;
@@ -492,6 +552,7 @@ export class Store {
492
552
  threadId: g.threadId,
493
553
  title: this.firstUserLine(g.threadId) ?? UNTITLED_TASK,
494
554
  createdAt: g.createdAt,
555
+ updatedAt: g.createdAt,
495
556
  };
496
557
  if (g.pinnedCwd !== undefined)
497
558
  initialTask.pinnedCwd = g.pinnedCwd;
@@ -527,6 +588,7 @@ export class Store {
527
588
  threadId: b.threadId,
528
589
  title: this.firstUserLine(b.threadId) ?? UNTITLED_TASK,
529
590
  createdAt: b.createdAt,
591
+ updatedAt: b.createdAt,
530
592
  resumeCursors: b.resumeCursors ?? {},
531
593
  }];
532
594
  botsMigrated = true;
@@ -539,6 +601,7 @@ export class Store {
539
601
  threadId: b.threadId,
540
602
  title: this.firstUserLine(b.threadId) ?? UNTITLED_TASK,
541
603
  createdAt: b.createdAt,
604
+ updatedAt: b.createdAt,
542
605
  resumeCursors: b.resumeCursors ?? {},
543
606
  };
544
607
  b.tasks.unshift(active);
@@ -571,10 +634,11 @@ export class Store {
571
634
  delete task.approvalMode;
572
635
  botsMigrated = true;
573
636
  }
574
- if (task.busy !== undefined || task.activity !== undefined)
637
+ if (task.busy !== undefined || task.activity !== undefined || task.turnStartedAt !== undefined)
575
638
  botsMigrated = true;
576
639
  task.busy = false;
577
640
  task.activity = "idle";
641
+ task.turnStartedAt = undefined;
578
642
  }
579
643
  this.mirrorActiveTask(b, active);
580
644
  b.unread = b.tasks.some((task) => task.unread);
@@ -593,18 +657,124 @@ export class Store {
593
657
  if (existsSync(legacyFile))
594
658
  mdb.readThread(threadId, legacyFile);
595
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();
596
666
  this.registeringInitialSections = false;
597
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
+ }
598
765
  saveBots(bots = this.bots) {
599
766
  this.rememberSections([...this.bots, ...bots].map((bot) => bot.section));
600
767
  writeFileAtomic(BOTS_FILE, JSON.stringify(bots.map(({ busy: _busy, activity: _activity, ...bot }) => ({
601
768
  ...bot,
602
- tasks: bot.tasks?.map(({ busy: _taskBusy, activity: _taskActivity, ...task }) => task),
603
- })), null, 2));
769
+ tasks: bot.tasks?.map(({ busy: _taskBusy, activity: _taskActivity, turnStartedAt: _taskTurnStarted, ...task }) => persistedPin(task)),
770
+ })), null, 2), { mode: 0o600 });
604
771
  }
605
772
  saveGroups() {
606
773
  this.rememberSections(this.groups.map((group) => group.section));
607
- writeFileAtomic(GROUPS_FILE, JSON.stringify(this.groups.map(({ busyBotId: _busyBotId, ...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 });
608
778
  }
609
779
  get sections() { return readSections(); }
610
780
  rememberSections(names) {
@@ -689,7 +859,7 @@ export class Store {
689
859
  section,
690
860
  };
691
861
  if (!dm) {
692
- group.tasks = [{ threadId, title: UNTITLED_TASK, createdAt }];
862
+ group.tasks = [{ threadId, title: UNTITLED_TASK, createdAt, updatedAt: createdAt }];
693
863
  group.setupCompletedAt = setup?.completed ? createdAt : null;
694
864
  group.setupSkippedAt = null;
695
865
  }
@@ -709,7 +879,19 @@ export class Store {
709
879
  if (Object.prototype.hasOwnProperty.call(patch, "section")) {
710
880
  this.rememberSections([patch.section]);
711
881
  }
882
+ const previousBusyBotId = group.busyBotId;
712
883
  Object.assign(group, patch);
884
+ // The group's elapsed readout counts the busy member's turn from the
885
+ // claim time — the group-side twin of a task's turnStartedAt. Derived,
886
+ // never patched directly: stamp it on every transition into a busy
887
+ // speaker and clear it when the group goes idle, so each member's turn
888
+ // counts from its own start.
889
+ if (Object.prototype.hasOwnProperty.call(patch, "busyBotId")) {
890
+ if (patch.busyBotId && patch.busyBotId !== previousBusyBotId)
891
+ group.turnStartedAt = Date.now();
892
+ else if (!patch.busyBotId)
893
+ delete group.turnStartedAt;
894
+ }
713
895
  if (!group.dm && Object.prototype.hasOwnProperty.call(patch, "pinnedMessageId")) {
714
896
  const active = this.activeGroupTask(group.id);
715
897
  if (active)
@@ -809,10 +991,12 @@ export class Store {
809
991
  const group = this.group(groupId);
810
992
  if (!group || group.dm)
811
993
  return null;
994
+ const createdAt = Date.now();
812
995
  const task = {
813
996
  threadId: newId(),
814
997
  title: title?.trim().slice(0, 80) || UNTITLED_TASK,
815
- createdAt: Date.now(),
998
+ createdAt,
999
+ updatedAt: createdAt,
816
1000
  };
817
1001
  group.tasks = [task, ...(group.tasks ?? [])];
818
1002
  if (activate) {
@@ -836,6 +1020,18 @@ export class Store {
836
1020
  this.emit({ type: "group", groupId });
837
1021
  return group;
838
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
+ }
839
1035
  renameGroupTask(groupId, threadId, title) {
840
1036
  const task = this.groupTaskByThread(groupId, threadId);
841
1037
  if (!task)
@@ -845,13 +1041,27 @@ export class Store {
845
1041
  this.emit({ type: "group", groupId });
846
1042
  return task;
847
1043
  }
1044
+ /** Name a channel task after its first message, once. Returns the task
1045
+ * it named so a caller can later replace exactly that machine-made
1046
+ * title. */
848
1047
  titleGroupTaskFromFirstMessage(groupId, text, threadId) {
849
1048
  const task = threadId ? this.groupTaskByThread(groupId, threadId) : this.activeGroupTask(groupId);
850
- if (!task || task.title !== UNTITLED_TASK)
851
- return;
1049
+ if (!task || task.titleFromFirstMessage || task.title !== UNTITLED_TASK)
1050
+ return null;
852
1051
  task.title = titleFromMessage(text);
1052
+ task.titleFromFirstMessage = true;
853
1053
  this.saveGroups();
854
1054
  this.emit({ type: "group", groupId });
1055
+ return task;
1056
+ }
1057
+ /** Swap a machine-made first-message channel title for a generated one,
1058
+ * once, on the same snippet-equality contract as bot tasks: any rename
1059
+ * by the person breaks that equality first and always wins. */
1060
+ retitleGroupTask(groupId, threadId, machineTitle, title) {
1061
+ const task = this.groupTaskByThread(groupId, threadId);
1062
+ if (!task || task.title !== machineTitle)
1063
+ return null;
1064
+ return this.renameGroupTask(groupId, threadId, threadTitleFrom(title));
855
1065
  }
856
1066
  deleteGroupTask(groupId, threadId) {
857
1067
  const group = this.group(groupId);
@@ -951,6 +1161,9 @@ export class Store {
951
1161
  throw new Error("Cannot import over an existing conversation");
952
1162
  mdb.importThread(threadId, messages, activeLeafId);
953
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);
954
1167
  }
955
1168
  activeLeaf(threadId) {
956
1169
  return this.thread(threadId).activeLeafId;
@@ -982,18 +1195,24 @@ export class Store {
982
1195
  }
983
1196
  return null;
984
1197
  }
985
- appendMessage(threadId, message) {
1198
+ appendMessage(threadId, message, command) {
986
1199
  const t = this.thread(threadId);
987
1200
  const full = { id: newId(), at: Date.now(), parentId: t.activeLeafId, ...redactBotAuthored(message) };
1201
+ const persist = () => { mdb.appendMessage(threadId, full); return full; };
1202
+ const committed = command ? runCommand(command, persist) : persist();
1203
+ if (committed.id !== full.id)
1204
+ return committed;
1205
+ // Only publish the committed write. A failed receipt must leave both
1206
+ // the in-memory branch and subscribers unchanged, just like SQLite.
988
1207
  t.messages.push(full);
989
1208
  t.activeLeafId = full.id;
990
- mdb.appendMessage(threadId, full);
991
1209
  if (full.kind === "screen") {
992
1210
  for (const pruned of this.pruneScreenFrames(t)) {
993
1211
  mdb.updateMessage(threadId, pruned);
994
1212
  this.emit({ type: "message.patch", threadId, message: pruned });
995
1213
  }
996
1214
  }
1215
+ this.noteThreadActivity(threadId, full.at);
997
1216
  this.emit({ type: "message", threadId, message: full });
998
1217
  // The first-run quiz is not a live ask. Talking past it hides it so the
999
1218
  // transcript is just the greeting plus what they said. Cards with a
@@ -1024,6 +1243,7 @@ export class Store {
1024
1243
  this.emit({ type: "message.patch", threadId, message: pruned });
1025
1244
  }
1026
1245
  }
1246
+ this.noteThreadActivity(threadId, full.at);
1027
1247
  this.emit({ type: "message", threadId, message: full });
1028
1248
  // announced after the insert so no client ever sees two siblings
1029
1249
  // claiming the same parent
@@ -1079,6 +1299,7 @@ export class Store {
1079
1299
  t.messages.push(full);
1080
1300
  t.activeLeafId = full.id;
1081
1301
  mdb.appendMessage(threadId, full);
1302
+ this.noteThreadActivity(threadId, full.at);
1082
1303
  this.emit({ type: "message", threadId, message: full });
1083
1304
  // The message frame alone leaves every client on the OLD branch: a
1084
1305
  // client adopts a new message as its leaf only when it chains onto the
@@ -1107,7 +1328,7 @@ export class Store {
1107
1328
  this.emit({ type: "thread", threadId, activeLeafId: cur });
1108
1329
  return cur;
1109
1330
  }
1110
- patchMessage(threadId, messageId, patch) {
1331
+ patchMessage(threadId, messageId, patch, command) {
1111
1332
  const t = this.thread(threadId);
1112
1333
  const idx = t.messages.findIndex((m) => m.id === messageId);
1113
1334
  if (idx === -1)
@@ -1116,7 +1337,11 @@ export class Store {
1116
1337
  // SQLite is the durable source of truth. Persist before changing memory so
1117
1338
  // a failed write cannot make this process believe a card was answered
1118
1339
  // while a restart would still show it as pending.
1119
- mdb.updateMessage(threadId, next);
1340
+ let applied = false;
1341
+ const persist = () => { mdb.updateMessage(threadId, next); applied = true; return next; };
1342
+ const committed = command ? runCommand(command, persist) : persist();
1343
+ if (!applied)
1344
+ return committed;
1120
1345
  t.messages[idx] = next;
1121
1346
  this.emit({ type: "message.patch", threadId, message: next });
1122
1347
  return next;
@@ -1143,6 +1368,8 @@ export class Store {
1143
1368
  color: profile.color ?? COLORS[this.bots.length % COLORS.length],
1144
1369
  ...(profile.mascotExpression ? { mascotExpression: profile.mascotExpression } : {}),
1145
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) } : {}),
1146
1373
  unread: false,
1147
1374
  modelSelection: profile.modelSelection ?? this.defaultSelection(),
1148
1375
  resumeCursors: {},
@@ -1154,6 +1381,7 @@ export class Store {
1154
1381
  threadId: bot.threadId,
1155
1382
  title: UNTITLED_THREAD,
1156
1383
  createdAt: bot.createdAt,
1384
+ updatedAt: bot.createdAt,
1157
1385
  resumeCursors: {},
1158
1386
  modelSelection: structuredClone(bot.modelSelection),
1159
1387
  unread: false,
@@ -1209,7 +1437,10 @@ export class Store {
1209
1437
  title: "", description: "", soul: "", notifications: true, color: COLORS[nextBots.length % COLORS.length], unread: false,
1210
1438
  modelSelection: operation.fields.modelSelection, resumeCursors: {}, createdAt, ...operation.fields,
1211
1439
  approvalMode: "ask", autoApprove: false, composio: false, approvePeerComms: false,
1212
- 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: {},
1213
1444
  modelSelection: structuredClone(operation.fields.modelSelection), approvalMode: "ask", autoApprove: false,
1214
1445
  unread: false, activity: "idle", busy: false }],
1215
1446
  };
@@ -1441,8 +1672,13 @@ export class Store {
1441
1672
  const busy = ACTIVITY_BUSY.has(activity);
1442
1673
  if ((task.activity ?? "idle") === activity && Boolean(task.busy) === busy)
1443
1674
  return bot;
1675
+ const wasBusy = Boolean(task.busy);
1444
1676
  task.activity = activity;
1445
1677
  task.busy = busy;
1678
+ if (busy && !wasBusy)
1679
+ task.turnStartedAt = Date.now();
1680
+ else if (!busy)
1681
+ delete task.turnStartedAt;
1446
1682
  this.refreshBotActivity(bot);
1447
1683
  this.emit({ type: "bot", botId });
1448
1684
  return bot;
@@ -1485,6 +1721,64 @@ export class Store {
1485
1721
  this.emit({ type: "bot", botId: bot.id });
1486
1722
  return changed;
1487
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
+ }
1488
1782
  setResumeCursor(botId, instanceId, cursor, threadId) {
1489
1783
  const bot = this.bot(botId);
1490
1784
  if (!bot)
@@ -1731,6 +2025,8 @@ export class Store {
1731
2025
  }
1732
2026
  if (typeof patch.title === "string")
1733
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;
1734
2030
  if (bot.threadId === threadId)
1735
2031
  this.mirrorActiveTask(bot, task);
1736
2032
  bot.unread = bot.tasks.some((candidate) => candidate.unread);
@@ -1748,8 +2044,8 @@ export class Store {
1748
2044
  return null;
1749
2045
  const patch = { modelSelection: structuredClone(selection),
1750
2046
  ...(resetApprovalToAsk ? { approvalMode: "ask", autoApprove: false, alwaysAllow: [] } : {}) };
1751
- const nextTask = { ...task, ...taskPatch, ...patch,
1752
- ...(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 } : {}) });
1753
2049
  // Older threads may still inherit settings. Freeze their effective
1754
2050
  // values before updating the default so "other threads unchanged" also
1755
2051
  // holds for workspaces created before per-thread approval settings.
@@ -1769,6 +2065,20 @@ export class Store {
1769
2065
  this.emit({ type: "bot", botId });
1770
2066
  return task;
1771
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
+ }
1772
2082
  mirrorActiveTask(bot, task) {
1773
2083
  bot.threadId = task.threadId;
1774
2084
  bot.resumeCursors = structuredClone(task.resumeCursors);
@@ -1777,23 +2087,25 @@ export class Store {
1777
2087
  }
1778
2088
  /** A fresh context on the same bot: new thread, new session, same
1779
2089
  * persona/tools/computer. Becomes the active task. */
1780
- createTask(botId, title, activate = true, projectId, openedBy) {
2090
+ createTask(botId, title, activate = true, projectId, openedBy, approvalMode) {
1781
2091
  const bot = this.bot(botId);
1782
2092
  if (!bot)
1783
2093
  return null;
1784
2094
  if (projectId !== undefined && !this.project(botId, projectId))
1785
2095
  return null;
2096
+ const createdAt = Date.now();
1786
2097
  const task = {
1787
2098
  threadId: newId(),
1788
2099
  title: threadTitleFrom(title),
1789
- createdAt: Date.now(),
2100
+ createdAt,
2101
+ updatedAt: createdAt,
1790
2102
  ...(projectId ? { projectId } : {}),
1791
2103
  ...(openedBy ? { openedBy: structuredClone(openedBy) } : {}),
1792
2104
  resumeCursors: {},
1793
2105
  modelSelection: structuredClone(bot.modelSelection),
1794
- approvalMode: approvalModeFor(bot),
1795
- autoApprove: Boolean(bot.autoApprove),
1796
- alwaysAllow: [...(bot.alwaysAllow ?? [])],
2106
+ approvalMode: approvalMode ?? approvalModeFor(bot),
2107
+ autoApprove: approvalMode ? false : Boolean(bot.autoApprove),
2108
+ alwaysAllow: approvalMode ? [] : [...(bot.alwaysAllow ?? [])],
1797
2109
  unread: false,
1798
2110
  activity: "idle",
1799
2111
  busy: false,
@@ -1866,12 +2178,29 @@ export class Store {
1866
2178
  * interleave in one transcript. `label` names that thread; the caller
1867
2179
  * closes it once its result has been reported. A pair conversation
1868
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
+ }
1869
2192
  resolvePairConversation(sender, recipientId, options) {
1870
2193
  if (!this.bot(recipientId))
1871
2194
  return null;
1872
2195
  const title = `@${sender.name}`;
1873
2196
  const opener = (kind, at = Date.now()) => ({ botId: sender.id, name: sender.name, kind, at });
1874
- 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);
1875
2204
  let pair = fromSender.find((task) => task.openedBy?.kind === "pair");
1876
2205
  if (!pair) {
1877
2206
  const lastActivity = (task) => this.messagesTail(task.threadId, 1).messages.at(-1)?.at ?? task.openedBy?.at ?? task.createdAt;
@@ -1900,6 +2229,14 @@ export class Store {
1900
2229
  // back up, never replaced: closing is only the sidebar's idle state.
1901
2230
  if (pair.closedBy)
1902
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
+ }
1903
2240
  return { task: pair, created: false };
1904
2241
  }
1905
2242
  // The brief is never a title. An 80-character slice of an assignment
@@ -1932,14 +2269,28 @@ export class Store {
1932
2269
  renameTask(botId, threadId, title) {
1933
2270
  return this.patchTask(botId, threadId, { title });
1934
2271
  }
1935
- /** Name a task after its first message, once. */
2272
+ /** Name a task after its first message, once. Returns the task it named
2273
+ * so a caller can later replace exactly that machine-made title — and
2274
+ * can see the peer provenance it must leave alone. */
1936
2275
  titleTaskFromFirstMessage(botId, text, threadId) {
1937
2276
  const task = threadId ? this.taskByThread(botId, threadId) : this.activeTask(botId);
1938
- if (!task || (task.title !== UNTITLED_TASK && task.title !== UNTITLED_THREAD))
1939
- return;
2277
+ if (!task || task.titleFromFirstMessage || (task.title !== UNTITLED_TASK && task.title !== UNTITLED_THREAD))
2278
+ return null;
1940
2279
  task.title = titleFromMessage(text);
2280
+ task.titleFromFirstMessage = true;
1941
2281
  this.saveBots();
1942
2282
  this.emit({ type: "bot", botId });
2283
+ return task;
2284
+ }
2285
+ /** Swap a machine-made first-message title for a generated one, once.
2286
+ * Equality against the snippet is the whole contract: a rename by the
2287
+ * person, by pair adoption, or by an earlier generated title each break
2288
+ * it, so this never overwrites a name anyone chose. */
2289
+ retitleTask(botId, threadId, machineTitle, title) {
2290
+ const task = this.taskByThread(botId, threadId);
2291
+ if (!task || task.title !== machineTitle)
2292
+ return null;
2293
+ return this.renameTask(botId, threadId, threadTitleFrom(title));
1943
2294
  }
1944
2295
  /** Delete a task and its transcript, retaining generated project files.
1945
2296
  * When no visible tasks remain, replace it with a fresh conversation. */