agentbox-app 0.1.13 → 0.1.16

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 (278) hide show
  1. package/README.md +24 -9
  2. package/bin/agentbox.mjs +23 -1
  3. package/briefs/message-rules.md +36 -8
  4. package/briefs/worker.md +46 -23
  5. package/main/account-discovery.mjs +122 -30
  6. package/main/account-tooling.mjs +29 -1
  7. package/main/account-usage.mjs +46 -0
  8. package/main/agent-sessions.mjs +282 -268
  9. package/main/agents.mjs +179 -466
  10. package/main/analytics.mjs +12 -2
  11. package/main/answer-modes.mjs +103 -37
  12. package/main/approval-prompt-core.mjs +197 -0
  13. package/main/approval-prompt-server.mjs +4 -138
  14. package/main/approvals.mjs +101 -297
  15. package/main/artifact-path.mjs +47 -18
  16. package/main/claude-input.mjs +48 -4
  17. package/main/claude-plan.mjs +48 -0
  18. package/main/claude-session-open.mjs +28 -0
  19. package/main/claude-usage-process.mjs +40 -0
  20. package/main/claude-usage.mjs +18 -40
  21. package/main/cloud/account.mjs +179 -0
  22. package/main/cloud/aws.mjs +127 -0
  23. package/main/cloud/browser-return.mjs +58 -0
  24. package/main/cloud/clients.mjs +15 -0
  25. package/main/cloud/codex-cloud.mjs +53 -0
  26. package/main/cloud/config.mjs +107 -0
  27. package/main/cloud/docs.mjs +219 -0
  28. package/main/cloud/exec.mjs +27 -0
  29. package/main/cloud/host.mjs +165 -0
  30. package/main/cloud/kit.mjs +80 -0
  31. package/main/cloud/logins.mjs +543 -0
  32. package/main/cloud/managed.mjs +115 -0
  33. package/main/cloud/marker.mjs +104 -0
  34. package/main/cloud/org.mjs +53 -0
  35. package/main/cloud/reuse.mjs +208 -0
  36. package/main/cloud/run-cli.mjs +177 -0
  37. package/main/cloud/run.mjs +1934 -0
  38. package/main/cloud/usage.mjs +76 -0
  39. package/main/cloud-permission.mjs +51 -0
  40. package/main/codex-account.mjs +78 -0
  41. package/main/codex-app-server.mjs +276 -409
  42. package/main/codex-approvals.mjs +148 -433
  43. package/main/codex-models.mjs +20 -2
  44. package/main/codex-session.mjs +212 -507
  45. package/main/codex-threads-worker.mjs +12 -0
  46. package/main/codex-threads.mjs +71 -3
  47. package/main/codex-usage-file.mjs +5 -3
  48. package/main/codex-usage.mjs +56 -7
  49. package/main/codex-watch.mjs +17 -3
  50. package/main/codex.mjs +95 -363
  51. package/main/config.mjs +11 -1
  52. package/main/corner-tag.mjs +340 -19
  53. package/main/dev-window.mjs +1 -1
  54. package/main/engine-bin.mjs +232 -282
  55. package/main/engine-setup.mjs +21 -9
  56. package/main/feedback.mjs +3 -25
  57. package/main/folder-pictures.mjs +2 -2
  58. package/main/freeze-recorder-thread.mjs +86 -0
  59. package/main/freeze-recorder.mjs +146 -0
  60. package/main/fresh-user.mjs +20 -9
  61. package/main/git-change-offthread.mjs +17 -3
  62. package/main/git-change-thread.mjs +5 -2
  63. package/main/harnesses/claude.mjs +32 -11
  64. package/main/harnesses/codex.mjs +9 -2
  65. package/main/importable-threads.mjs +64 -6
  66. package/main/ipc.mjs +362 -111
  67. package/main/leftovers.mjs +117 -25
  68. package/main/liquid-glass.mjs +26 -0
  69. package/main/live-replies.mjs +10 -4
  70. package/main/main-went-red.mjs +330 -0
  71. package/main/main.mjs +199 -84
  72. package/main/memory-gate-history.mjs +36 -10
  73. package/main/memory-gate-server.mjs +62 -0
  74. package/main/memory-gate.mjs +14 -1
  75. package/main/menu.mjs +4 -2
  76. package/main/message-priority.mjs +2 -2
  77. package/main/mic.mjs +38 -0
  78. package/main/native/liquid-glass/build/liquid-glass.node +0 -0
  79. package/main/new-chats-watch.mjs +110 -0
  80. package/main/new-user-window.mjs +25 -0
  81. package/main/notify.mjs +24 -8
  82. package/main/phone-alerts.mjs +117 -0
  83. package/main/phone-inbox.mjs +625 -0
  84. package/main/phone-link.mjs +29 -9
  85. package/main/phone-uploads.mjs +476 -0
  86. package/main/picture-shape.mjs +114 -0
  87. package/main/quit-sequence.mjs +143 -0
  88. package/main/refresh-agent-models.mjs +15 -2
  89. package/main/relay-client.mjs +69 -1
  90. package/main/repeats.mjs +10 -4
  91. package/main/row-label.mjs +12 -41
  92. package/main/run-change.mjs +13 -0
  93. package/main/run-ledger.mjs +135 -0
  94. package/main/save-attachments.mjs +41 -0
  95. package/main/serve.mjs +45 -3
  96. package/main/settings.mjs +220 -44
  97. package/main/shell-path.mjs +185 -37
  98. package/main/ship-conflict-brief.mjs +119 -0
  99. package/main/ship-queue.mjs +115 -19
  100. package/main/source-updater.mjs +18 -10
  101. package/main/stage-attachment.mjs +68 -0
  102. package/main/stall-watch.mjs +50 -0
  103. package/main/store/home.mjs +87 -232
  104. package/main/store/paths.mjs +11 -0
  105. package/main/store/teammates-files.mjs +99 -0
  106. package/main/store/work-items.mjs +231 -13
  107. package/main/store.mjs +226 -52
  108. package/main/supervisor.mjs +3103 -341
  109. package/main/task-folders.mjs +430 -30
  110. package/main/task-terminals.mjs +110 -34
  111. package/main/team/index.mjs +114 -10
  112. package/main/team/memory-cloud.mjs +183 -1
  113. package/main/team/room-hours.mjs +48 -0
  114. package/main/team/room-setup.mjs +31 -0
  115. package/main/team/rooms-memory.mjs +454 -0
  116. package/main/team/rooms-shape.mjs +42 -0
  117. package/main/team/rooms-supabase.mjs +324 -0
  118. package/main/team/rooms.mjs +575 -0
  119. package/main/team/session.mjs +96 -26
  120. package/main/team/supabase-backend.mjs +180 -0
  121. package/main/team/sync.mjs +109 -1
  122. package/main/teammates.mjs +580 -0
  123. package/main/terminal-host.mjs +143 -0
  124. package/main/terminal-hosts.mjs +261 -0
  125. package/main/transcript-search.mjs +80 -0
  126. package/main/webview-guard.mjs +45 -0
  127. package/main/worktree-include.mjs +90 -219
  128. package/mcp/core/documents.mjs +3 -1
  129. package/mcp/core/repeats.mjs +88 -0
  130. package/mcp/core/teammates.mjs +222 -0
  131. package/mcp/core/work.mjs +76 -4
  132. package/mcp/stdio.mjs +24 -11
  133. package/mcp/tools.mjs +116 -8
  134. package/package.json +21 -5
  135. package/renderer/dist/assets/{abnfDiagram-YUKMZFIV-DPPuMWt-.js → abnfDiagram-YUKMZFIV-CoWB6YUK.js} +1 -1
  136. package/renderer/dist/assets/{arc-HmFfdseH.js → arc-Ch0NrCul.js} +1 -1
  137. package/renderer/dist/assets/{architectureDiagram-47P4ROYG-DxJ3zHrH.js → architectureDiagram-47P4ROYG-CXz9M5tQ.js} +1 -1
  138. package/renderer/dist/assets/{blockDiagram-E7TT5TSB-CbTXzOqQ.js → blockDiagram-E7TT5TSB-DchGUuHH.js} +1 -1
  139. package/renderer/dist/assets/{c4Diagram-CRA5TL53-X5ItFyLn.js → c4Diagram-CRA5TL53-Cip-zNyr.js} +1 -1
  140. package/renderer/dist/assets/channel-BQuQSL--.js +1 -0
  141. package/renderer/dist/assets/{chunk-2Q5K7J3B-DC12Ho_L.js → chunk-2Q5K7J3B-BoXid_2r.js} +1 -1
  142. package/renderer/dist/assets/{chunk-5VM5RSS4-ClVtfPd0.js → chunk-5VM5RSS4-Cpez1rRz.js} +1 -1
  143. package/renderer/dist/assets/{chunk-7TKQ45FW-DgcOm0HT.js → chunk-7TKQ45FW-DKA84YkE.js} +1 -1
  144. package/renderer/dist/assets/{chunk-AW2ZBBNX-C01s4yoF.js → chunk-AW2ZBBNX-Dg-SumPG.js} +1 -1
  145. package/renderer/dist/assets/{chunk-FVRAUYC3-Bj0zxhR4.js → chunk-FVRAUYC3-06TCvPm1.js} +2 -2
  146. package/renderer/dist/assets/{chunk-HTAEGDNF-DWDeEhLU.js → chunk-HTAEGDNF-CMEBy1Td.js} +1 -1
  147. package/renderer/dist/assets/{chunk-JWPE2WC7-Dsqwv3eW.js → chunk-JWPE2WC7-BJ2ObmV-.js} +1 -1
  148. package/renderer/dist/assets/{chunk-KQW6MTUR-CE86ANL2.js → chunk-KQW6MTUR-CGWOYXUE.js} +1 -1
  149. package/renderer/dist/assets/{chunk-XXDRQBXY-CoNTASfs.js → chunk-XXDRQBXY-CiZp07XC.js} +1 -1
  150. package/renderer/dist/assets/{classDiagram-v2-K4WV4PDN-2wprLqtw.js → classDiagram-v2-K4WV4PDN-DJNIX719.js} +1 -1
  151. package/renderer/dist/assets/cornerTag-CPgvu-q3.js +1 -0
  152. package/renderer/dist/assets/cornerTag-oMfEjzCO.css +1 -0
  153. package/renderer/dist/assets/{cose-bilkent-JH36ORCC-enqMiYbn.js → cose-bilkent-JH36ORCC-CbkyZOUH.js} +1 -1
  154. package/renderer/dist/assets/{cynefin-EF2NZ3EQ-DHngLxvQ.js → cynefin-EF2NZ3EQ-78iOroZO.js} +1 -1
  155. package/renderer/dist/assets/{cynefinDiagram-3GCD6N5R-C0rZZIl3.js → cynefinDiagram-3GCD6N5R-GcCopMs7.js} +1 -1
  156. package/renderer/dist/assets/{dagre-W4DXFKR2-DQnaEWO7.js → dagre-W4DXFKR2-DHLlXLkz.js} +1 -1
  157. package/renderer/dist/assets/{diagram-2UJZ2QOL-CS8AlGPx.js → diagram-2UJZ2QOL-DuZSUHcH.js} +1 -1
  158. package/renderer/dist/assets/{diagram-OVF4WLC6-yiDMHIkY.js → diagram-OVF4WLC6-Ckxu3cqH.js} +1 -1
  159. package/renderer/dist/assets/{diagram-PFPMY2P6-XbQKzbBA.js → diagram-PFPMY2P6-CsTw9Ycn.js} +1 -1
  160. package/renderer/dist/assets/{diagram-UMYRVEAY-dhVvlfl_.js → diagram-UMYRVEAY-CTXd8qVg.js} +1 -1
  161. package/renderer/dist/assets/{diagram-YEKJPTXX-DZHe8SCf.js → diagram-YEKJPTXX-CJs1T_9m.js} +1 -1
  162. package/renderer/dist/assets/{diagram-ZIFT7M5P-B5h6aXVR.js → diagram-ZIFT7M5P-CvjI23mf.js} +1 -1
  163. package/renderer/dist/assets/{ebnfDiagram-VR2GEFS7-PR9ScDco.js → ebnfDiagram-VR2GEFS7-d_IeAzOJ.js} +1 -1
  164. package/renderer/dist/assets/{elk-IJKZMXRS-Cg-yQXPd.js → elk-IJKZMXRS-zo9KFP3N.js} +1 -1
  165. package/renderer/dist/assets/{erDiagram-O2IAPWRE-omOrJMxu.js → erDiagram-O2IAPWRE-DqfYf5Dt.js} +1 -1
  166. package/renderer/dist/assets/{flowDiagram-OXPTDLAJ-aOX2hzvT.js → flowDiagram-OXPTDLAJ-DAWsMLmo.js} +1 -1
  167. package/renderer/dist/assets/{ganttDiagram-R7TSEDQI-B1H3pWyI.js → ganttDiagram-R7TSEDQI-4ml_bhwR.js} +1 -1
  168. package/renderer/dist/assets/{gitGraphDiagram-XJZIOB7I-Cvw304HC.js → gitGraphDiagram-XJZIOB7I-B_WcqSId.js} +1 -1
  169. package/renderer/dist/assets/{infoDiagram-5W2HQ5XZ-_ZXcNViH.js → infoDiagram-5W2HQ5XZ-CI-jYqzc.js} +1 -1
  170. package/renderer/dist/assets/{ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js → ishikawaDiagram-K3B6WC7H-B56VWRlV.js} +1 -1
  171. package/renderer/dist/assets/{journeyDiagram-COXZFDF6-fr62QoAt.js → journeyDiagram-COXZFDF6-D1-p09Lr.js} +1 -1
  172. package/renderer/dist/assets/{kanban-definition-P3RFRI5V-J8zsabCe.js → kanban-definition-P3RFRI5V-Cv3w7JoN.js} +1 -1
  173. package/renderer/dist/assets/{linear-BmNdEtIi.js → linear-C35V2Spi.js} +1 -1
  174. package/renderer/dist/assets/main-BcoBiGwv.css +32 -0
  175. package/renderer/dist/assets/main-CLnIOulD.js +1191 -0
  176. package/renderer/dist/assets/{mermaid.core-CnYzvFkz.js → mermaid.core-BzGT5Vo9.js} +4 -4
  177. package/renderer/dist/assets/{mindmap-definition-LZFPQGKD-Bfcxc7PM.js → mindmap-definition-LZFPQGKD-BZUfuymC.js} +1 -1
  178. package/renderer/dist/assets/{pegDiagram-BGZESJAR-CwUJOq6X.js → pegDiagram-BGZESJAR-DFh9XNGe.js} +1 -1
  179. package/renderer/dist/assets/{pieDiagram-CAPJLFHJ-BrwMmqfC.js → pieDiagram-CAPJLFHJ-C65tPVsy.js} +1 -1
  180. package/renderer/dist/assets/{product-name-CTKLYNWY.js → product-name-CSNW6d7s.js} +1 -1
  181. package/renderer/dist/assets/{quadrantDiagram-GDMTTRAM-BN4Stoj-.js → quadrantDiagram-GDMTTRAM-Cushj_nk.js} +1 -1
  182. package/renderer/dist/assets/{railroadDiagram-SM67HX2B-CPZ5bbeR.js → railroadDiagram-SM67HX2B-DZGebGx5.js} +1 -1
  183. package/renderer/dist/assets/{requirementDiagram-X7JNWC4A-CgRnimVe.js → requirementDiagram-X7JNWC4A-DpAB0MNo.js} +1 -1
  184. package/renderer/dist/assets/{sankeyDiagram-UM26HJRW-Bt2rej__.js → sankeyDiagram-UM26HJRW-D-29rHra.js} +1 -1
  185. package/renderer/dist/assets/{sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js → sequenceDiagram-ZO4K6R2Y-FfOQAZol.js} +1 -1
  186. package/renderer/dist/assets/{sizeCapture-INFHLROL-usyNyXyh.js → sizeCapture-INFHLROL-C_n6fDBY.js} +1 -1
  187. package/renderer/dist/assets/{stateDiagram-v2-TBUQTH76-CRzUBb0u.js → stateDiagram-v2-TBUQTH76-BZjenzJG.js} +1 -1
  188. package/renderer/dist/assets/{swimlanes-N4OXWK64-C1GJvMS0.js → swimlanes-N4OXWK64-DxaMU9vX.js} +1 -1
  189. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cb2kM6rr.js +8 -0
  190. package/renderer/dist/assets/{timeline-definition-YOQAKGHF-caeyPlcc.js → timeline-definition-YOQAKGHF-DTg5HJKz.js} +1 -1
  191. package/renderer/dist/assets/{usecaseDiagram-VIAY4XPW-BN_SV6PC.js → usecaseDiagram-VIAY4XPW-Bc3MZDsl.js} +1 -1
  192. package/renderer/dist/assets/{vennDiagram-BLWOH2XV-BLGuaUX-.js → vennDiagram-BLWOH2XV-GIVGk9Id.js} +1 -1
  193. package/renderer/dist/assets/{wardleyDiagram-YMQ3BMBF-Da0cJeNc.js → wardleyDiagram-YMQ3BMBF-NmJnbQOg.js} +1 -1
  194. package/renderer/dist/assets/{xychartDiagram-TAQBALBS-cGfUffMk.js → xychartDiagram-TAQBALBS-p-kfGfnW.js} +1 -1
  195. package/renderer/dist/corner-tag.html +3 -3
  196. package/renderer/dist/index.html +3 -3
  197. package/scripts/after-install.mjs +30 -8
  198. package/scripts/cloud-shim.pl +57 -0
  199. package/shared/account-names.d.mts +6 -0
  200. package/shared/account-names.mjs +15 -0
  201. package/shared/agent-hours.mjs +136 -0
  202. package/shared/agent-import.mjs +60 -34
  203. package/shared/agents.mjs +126 -522
  204. package/shared/analytics-events.mjs +12 -0
  205. package/shared/approval-card.mjs +36 -0
  206. package/shared/bridge-map.mjs +27 -1
  207. package/shared/claude-code-shape.mjs +6 -0
  208. package/shared/claude-usage.d.mts +1 -0
  209. package/shared/claude-usage.mjs +42 -1
  210. package/shared/cloud-hand-back.d.mts +7 -0
  211. package/shared/cloud-hand-back.mjs +20 -0
  212. package/shared/cloud-kit.d.mts +219 -0
  213. package/shared/cloud-kit.mjs +341 -0
  214. package/shared/cloud-login-name.mjs +4 -0
  215. package/shared/cloud-start.mjs +17 -0
  216. package/shared/cloud-versions.mjs +23 -0
  217. package/shared/codex-import.mjs +2 -2
  218. package/shared/codex-modes.d.mts +22 -9
  219. package/shared/codex-modes.mjs +67 -11
  220. package/shared/codex-usage.mjs +108 -2
  221. package/shared/corner-tag.d.mts +23 -1
  222. package/shared/corner-tag.mjs +284 -29
  223. package/shared/dead-run-trace.mjs +19 -2
  224. package/shared/engines.mjs +70 -309
  225. package/shared/feedback.d.mts +7 -0
  226. package/shared/feedback.mjs +25 -0
  227. package/shared/first-run-practice.mjs +17 -24
  228. package/shared/harness-definitions.mjs +8 -0
  229. package/shared/instruction-defaults.json +2 -2
  230. package/shared/key-caps.d.mts +14 -0
  231. package/shared/key-caps.mjs +68 -0
  232. package/shared/message-edits.d.mts +7 -0
  233. package/shared/message-edits.mjs +35 -0
  234. package/shared/move-thread.d.mts +24 -0
  235. package/shared/move-thread.mjs +112 -0
  236. package/shared/named-threads.mjs +72 -0
  237. package/shared/not-shipped.d.mts +22 -0
  238. package/shared/not-shipped.mjs +89 -0
  239. package/shared/notify-rules.d.mts +6 -0
  240. package/shared/notify-rules.mjs +36 -8
  241. package/shared/phone-files.mjs +28 -0
  242. package/shared/phone-settings.mjs +118 -0
  243. package/shared/phone-snapshot.d.mts +16 -0
  244. package/shared/phone-snapshot.mjs +348 -0
  245. package/shared/picture-formats.d.mts +1 -0
  246. package/shared/picture-formats.mjs +13 -0
  247. package/shared/rank.d.mts +2 -1
  248. package/shared/rank.mjs +32 -19
  249. package/shared/repeats.d.mts +8 -0
  250. package/shared/repeats.mjs +33 -2
  251. package/shared/room-cards.mjs +167 -0
  252. package/shared/room-links.d.mts +4 -0
  253. package/shared/room-links.mjs +21 -0
  254. package/shared/runs-on.d.mts +3 -3
  255. package/shared/runs-on.mjs +8 -5
  256. package/shared/shipped-note.d.mts +4 -0
  257. package/shared/shipped-note.mjs +6 -0
  258. package/shared/shortcuts.mjs +14 -6
  259. package/shared/spawn-trouble.d.mts +4 -0
  260. package/shared/spawn-trouble.mjs +443 -40
  261. package/shared/team-rules.mjs +9 -2
  262. package/shared/teammates.d.mts +19 -0
  263. package/shared/teammates.mjs +756 -0
  264. package/shared/terminal-focus.d.mts +1 -0
  265. package/shared/terminal-focus.mjs +13 -0
  266. package/shared/thread-cards.mjs +4 -1
  267. package/shared/usage.d.mts +9 -0
  268. package/shared/usage.mjs +49 -3
  269. package/shared/websites.d.mts +15 -0
  270. package/shared/websites.mjs +61 -0
  271. package/shared/work-items.mjs +127 -30
  272. package/shared/work-lines.mjs +92 -244
  273. package/renderer/dist/assets/channel-BlT2g9Sz.js +0 -1
  274. package/renderer/dist/assets/cornerTag-BLrxiDE5.css +0 -1
  275. package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +0 -1
  276. package/renderer/dist/assets/main-ChNdnUvt.js +0 -1132
  277. package/renderer/dist/assets/main-nj0QR5kg.css +0 -32
  278. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +0 -8
@@ -1,123 +1,60 @@
1
- // HER CLAUDE CODE THREADS, WHICH ARE THE AGENTS SHE MEANT.
1
+ // Claude Code conversation threads for the import card. These are separate from
2
+ // the `.md` agent definitions that agent-files.mjs reads, and the card shows both.
2
3
  //
3
- // That sentence retires the assumption `agent-files.mjs` was built on. That
4
- // module reads `.md` agent DEFINITIONS, which is a real thing Claude Code has
5
- // and is not what she is looking at when she says "my agents". the four
6
- // definition files on this Mac were last edited on 23 and 30 March, and she had
7
- // four threads going the evening she complained.
4
+ // A thread lives in one of two places:
8
5
  //
9
- // So the import gets a second source, and this is it. Nothing here replaces the
10
- // files; a definition and a thread are different objects and the card shows
11
- // both.
6
+ // <claude data folder>/projects/<folder>/<id>.jsonl a terminal or editor
7
+ // session, one JSON object per line (data folders: main/agent-homes.mjs)
8
+ // ~/Library/Application Support/Claude/claude-code-sessions/**/local_<id>.json
9
+ // the desktop app's small record about a session, with a real `title`
12
10
  //
13
- // WHERE A THREAD LIVES. Two places, because she named two:
14
- //
15
- // ~/.claude/projects/<folder>/<id>.jsonl every terminal session (and the
16
- // same under every other Claude
17
- // data folder: main/agent-homes.mjs)
18
- // ~/Library/Application Support/Claude/
19
- // claude-code-sessions/**/local_<id>.json the desktop app
20
- //
21
- // The terminal store is the transcript itself, one JSON object per line. The
22
- // desktop store is a small record ABOUT the session, and it is the richer of
23
- // the two because the app writes a real `title` into it. Measured 2026-08-29:
24
- // 2,444 transcripts in 240 folders, and 38 desktop records whose newest is
25
- // 7 July, so on her Mac today the terminal side is all of it.
26
- //
27
- // THE HARD PART IS NOT FINDING THEM, IT IS THROWING THEM AWAY. hundreds exist
28
- // and a handful are relevant.
29
- //
30
- // The line that separates them is not a heuristic, which is the good news. Every
31
- // transcript row carries an `entrypoint`, and it says who started the session.
32
- // Counted over all 2,212 transcripts in her store on 2026-08-29:
33
- //
34
- // 2,158 sdk-cli something started it: Agentbox's own workers, Agentbox's
35
- // chats, and our measurement scripts running twenty
36
- // agents at a time in temp folders
37
- // 53 cli she opened a terminal and typed
38
- // 1 claude-desktop she used the desktop app
39
- //
40
- // So 54 of 2,212 were started by a person, and those 54 are the handful she
41
- // means. Her own test is one of them: `~/Desktop/dev/zero`, 8:38 PM, "this is a
42
- // test", `entrypoint: "cli"`.
43
- //
44
- // A FIRST DRAFT OF THIS GUESSED INSTEAD, and the guess is worth keeping written
45
- // down because it looked like it worked. It dropped anything opening with the
46
- // work item brief and anything running in a temp folder, which on that evening
47
- // removed 126 and 171 rows and left a believable list. It would have kept every
48
- // chat Agentbox itself runs in a product folder, because those open with her own
49
- // question and run somewhere real. `entrypoint` was on the row the whole time.
11
+ // Most transcripts on a machine running Agentbox were started by Agentbox. The
12
+ // `entrypoint` on every transcript row says who started the session, so the
13
+ // filter reads that rather than guessing from the prompt or the folder.
50
14
 
51
15
  import fs from 'node:fs';
52
16
  import os from 'node:os';
53
17
  import path from 'node:path';
54
18
  import { claudeDataDirs } from './agent-homes.mjs';
19
+ import { howTheChatEnded } from '../shared/agent-import.mjs';
55
20
 
56
- /**
57
- * "Recent = last few days", in days.
58
- *
59
- * Seven rather than three, and the number is measured rather than chosen.
60
- * Counting only the threads a person started, her Mac on 2026-08-29 has 2 in
61
- * the last three days and 9 in the last seven, in two folders she works in.
62
- * Three days is a card with tonight's test on it and almost nothing else; the
63
- * filters have already done the work that "a few" was standing in for, so the
64
- * window can afford to be the generous reading.
65
- *
66
- * Ten since the launch list (w-db6f5e331e, 2026-10-05): "showing your recently
67
- * active agents over the last 10 days". Codex reads the same number
21
+ /** How many days back counts as recent. Codex reads the same number
68
22
  * (main/codex-threads.mjs), so the two halves of the card agree. */
69
23
  export const RECENT_DAYS = 10;
70
24
 
71
- /**
72
- * How much of a transcript is read to find its first human turn. The first
73
- * turn is at the top of the file by construction, and the files are not small:
74
- * the largest in her store on 2026-08-29 is 13MB, and there are hundreds. */
25
+ /** How much of a transcript is read to find its first human turn, which is at
26
+ * the top of the file. Transcripts run to many megabytes. */
75
27
  const HEAD_BYTES = 96 * 1024;
76
28
 
77
- /**
78
- * How far past the head one cut-off first message may be followed
79
- * (w-24edc04548, 2026-10-05). A screenshot pasted into the first message is
80
- * written into that row as base64, so the row outgrows the head: three of her
81
- * ten days' terminal threads opened with rows of 240KB, 294KB and 486KB, were
82
- * cut, lost their name and fell off the card. Eight megabytes is the Codex
83
- * reader's own ceiling (main/codex-threads.mjs) and several screenshots. */
29
+ /** How far past the head one cut-off first message may be followed. A
30
+ * screenshot pasted into the first message is written into that row as
31
+ * base64, so the row can be hundreds of KB. Eight MB matches the Codex
32
+ * reader's ceiling (main/codex-threads.mjs). */
84
33
  const FIRST_TURN_BYTES = 8 * 1024 * 1024;
85
34
 
86
- /**
87
- * A stop on how many transcripts are OPENED, for the same reason the folder
88
- * scan in agent-files.mjs has one: opening a card may not turn into a disk
89
- * crawl.
90
- *
91
- * It is a cap on reading, never on looking. The walk stats every file, which
92
- * is cheap, throws away everything older than the cutoff, and sorts what is
93
- * left newest first before it opens anything. An earlier draft capped the walk
94
- * itself and the bug is worth keeping written down: the cap was reached in
95
- * directory order, so on her Mac it stopped after 1,500 of 2,444 files and
96
- * whether tonight's thread survived was down to where its folder sorted. A
97
- * cap that can drop the newest thread on the machine is not a safety rail. */
35
+ /** How much of a transcript's end is read for its title and last reply. */
36
+ const TAIL_BYTES = 256 * 1024;
37
+ /** The longest last reply kept, from its end, which is where the answer is. */
38
+ const SAID_CHARS = 8000;
39
+ /** The longest first prompt kept for an imported row's opening message. */
40
+ const FIRST_CHARS = 4000;
41
+
42
+ /** A cap on how many threads are kept, so opening the card is not a disk
43
+ * crawl. It caps reading, never looking: the terminal walk stats every file
44
+ * and sorts newest first before it opens any, so the cap only drops older
45
+ * threads. */
98
46
  const MAX_READS = 1200;
99
- // How long the conversation read may take, in ms (w-ec62ab6b38). The card that
100
- // uses it says "Looking" meanwhile; three seconds reaches two thirds of her
101
- // week's 9,073 transcripts and all of a normal Mac's.
47
+ // The card shows "Looking" while this runs.
102
48
  const READ_BUDGET_MS = 3000;
103
49
 
104
- /* ------------------------- WHAT IS NOT HERS TO IMPORT ---------------------- */
105
-
106
- /**
107
- * A FOLDER NOBODY WORKS IN.
108
- *
109
- * `/private/var/folders/19/.../T/speedsize-6AGMVw-run-12` is a real cwd of a
110
- * real Claude Code session, and it is one of twenty a measurement script of
111
- * ours started in a loop. 258 of her 448 recent threads are that. Nobody opens
112
- * a temp directory to work, so a thread that ran in one is not a thread she
113
- * would recognise, and offering it is how a list of hundreds happens. */
50
+ /** A temp folder, where nobody works. Agentbox's own scripts and workers run
51
+ * Claude Code sessions there, so a thread from one is not the person's. */
114
52
  export function isScratchFolder(folder, home = os.homedir()) {
115
53
  const p = String(folder ?? '');
116
54
  if (!p) return true;
117
- // A FOLDER INSIDE YOUR OWN HOME IS YOURS, whatever the disk path says. This
118
- // is the rule the temp-path list is a proxy for, and it has to be stated
119
- // first, because a Mac can put a home directory anywhere and the tests put
120
- // one under `/var/folders` on purpose.
55
+ // A folder inside the home folder is the person's, whatever the path. This
56
+ // check comes first because a Mac can put a home folder anywhere, and the
57
+ // tests put one under `/var/folders`.
121
58
  const here = path.resolve(String(home ?? ''));
122
59
  if (here && (p === here || p.startsWith(`${here}/`))) return false;
123
60
  return /^\/private\/tmp(\/|$)/.test(p)
@@ -126,14 +63,10 @@ export function isScratchFolder(folder, home = os.homedir()) {
126
63
  || /^\/var\/folders(\/|$)/.test(p);
127
64
  }
128
65
 
129
- /**
130
- * THE SAME RULE, READ OFF THE FOLDER CLAUDE CODE FILED THE TRANSCRIPT IN, so
131
- * it can be applied before the file is opened (w-24edc04548, 2026-10-05).
132
- * Claude Code names that folder after the working folder with every other
133
- * character turned into `-`, so `/private/tmp/w-1` is `-private-tmp-w-1`.
134
- * Of her 12,272 transcripts touched in ten days about 12,000 were in temp
135
- * folders, and opening each one only to refuse it spent the card's three
136
- * seconds before it reached her oldest threads. */
66
+ /** The same rule read off the folder Claude Code filed the transcript in, so
67
+ * a temp-folder transcript is refused without being opened. Claude Code names
68
+ * that folder after the working folder with every character that is not a
69
+ * letter or digit turned into `-`, so `/private/tmp/w-1` is `-private-tmp-w-1`. */
137
70
  export function isScratchProjectDir(name, home = os.homedir()) {
138
71
  const n = String(name ?? '');
139
72
  const here = path.resolve(String(home ?? '')).replace(/[^a-zA-Z0-9]/g, '-');
@@ -141,24 +74,12 @@ export function isScratchProjectDir(name, home = os.homedir()) {
141
74
  return /^-(private-)?(tmp|var-folders)(-|$)/.test(n);
142
75
  }
143
76
 
144
- /**
145
- * THE ONES A PERSON STARTED, which is the whole filter.
146
- *
147
- * `cli` is a terminal she opened. `claude-desktop` is the desktop app, which
148
- * she named. Everything else was started by software, and on this Mac that is
149
- * overwhelmingly Agentbox itself: its workers, its chats, and the scripts we run
150
- * to measure it. Offering those on an import card would be offering to bring
151
- * the inbox into the inbox.
152
- *
153
- * An entrypoint we have never seen is treated as not hers. The cost of being
154
- * wrong that way is one missing row on a card; the other way is the hundreds
155
- * she is complaining about.
156
- *
157
- * `claude-vscode` IS A PERSON TOO (w-e46f2e9cea, 2026-10-11). It is what the
158
- * Claude Code extension writes in VS Code, Cursor and Windsurf, and leaving it
159
- * out threw away every session of everybody who runs Claude Code in an editor.
160
- * Read out of the 2.1.293 binary, which maps it to `claude_code_vscode`. The
161
- * `remote*` family stays out: those run in the cloud, not on this disk. */
77
+ /** Entrypoints a person starts. `cli` is a terminal, `claude-desktop` the
78
+ * desktop app, and `claude-vscode` the Claude Code extension in VS Code,
79
+ * Cursor and Windsurf. Everything else, mostly `sdk-cli`, was started by
80
+ * software, usually Agentbox's own workers and chats. An unknown entrypoint
81
+ * counts as not the person's: a missing row costs less than hundreds of
82
+ * wrong ones. `remote*` sessions run in the cloud, not on this disk. */
162
83
  const BY_HAND = new Set(['cli', 'claude-desktop', 'claude-vscode']);
163
84
  export function startedByHand(entrypoint) {
164
85
  return BY_HAND.has(entrypoint);
@@ -170,44 +91,25 @@ export function sourceOf(entrypoint) {
170
91
  return entrypoint === 'claude-vscode' ? 'editor' : 'terminal';
171
92
  }
172
93
 
173
- /**
174
- * A SUBAGENT IS NOT A THREAD SHE OPENED. Claude Code files them under
175
- * `<session>/subagents/`, one per fan-out, so the parent already stands for
176
- * them and listing both says the same work twice. */
94
+ /** Claude Code files subagent transcripts under `<session>/subagents/`. The
95
+ * parent session already stands for that work. */
177
96
  export function isSubagentPath(file) {
178
97
  return /(^|\/)subagents\//.test(String(file ?? ''));
179
98
  }
180
99
 
181
- /* ------------------------------ WHAT IT IS CALLED -------------------------- */
182
-
183
- /**
184
- * Lines a person did not type, which a transcript's first turn is often made
185
- * of: the slash command wrapper, the resume caveat, and anything the harness
186
- * injected. A thread named `<command-name>/clear</command-name>` is a thread
187
- * she cannot recognise. */
100
+ /** Lines Claude Code writes into a first turn that a person did not type: the
101
+ * slash command wrapper, the resume caveat and injected reminders. */
188
102
  const NOT_TYPED = [
189
103
  /^<command-name>/, /^<command-message>/, /^<command-args>/, /^<local-command/,
190
104
  /^<system-reminder>/, /^<user-memory/, /^Caveat: The messages below/,
191
105
  /^This session is being continued from/, /^\[Request interrupted/,
192
106
  ];
193
107
 
194
- /**
195
- * THE NAME ON THE ROW, out of the first thing she actually typed.
196
- *
197
- * A terminal transcript has no title in it: measured 2026-08-29, ZERO of her
198
- * 495 recent files carry a `summary` row, so there is nothing to read but the
199
- * conversation. The first human sentence is what the desktop app titles from
200
- * too, and it is the line she would know the thread by.
201
- *
202
- * SEVERAL MESSAGES ARE OFFERED, NOT ONE, and that is a fix rather than a
203
- * flourish. A session that opens with a slash command has the wrapper as its
204
- * first user message and her actual words in the second, so reading only the
205
- * first left four of her own threads in an outside repo nameless and
206
- * therefore off the card. The first message that is something a person typed
207
- * wins; a thread with no such message has no name and is dropped, which is the
208
- * right outcome for a session that was never spoken to.
209
- *
210
- * Markdown is flattened rather than rendered, because this lands in a list. */
108
+ /** The row's name, from the first line a person typed. Terminal transcripts
109
+ * carry no title, so the conversation is all there is. Takes several messages
110
+ * because a session that opens with a slash command has the wrapper first and
111
+ * the person's words second. Returns '' when nobody typed anything, and the
112
+ * caller drops that thread. */
211
113
  export function threadTitle(texts, max = 72) {
212
114
  for (const text of Array.isArray(texts) ? texts : [texts]) {
213
115
  const got = titleFromOne(text, max);
@@ -220,7 +122,7 @@ function titleFromOne(text, max) {
220
122
  const raw = String(text ?? '').replace(/\r/g, '');
221
123
  for (const line of raw.split('\n')) {
222
124
  // `[Image #1]` and `[Pasted text #1 +40 lines]` are Claude Code's stand-ins
223
- // for what was pasted, not words she typed.
125
+ // for what was pasted.
224
126
  const t = line.replace(/\[(?:Image|Pasted text) #\d+[^\]]*\]/g, '').trim();
225
127
  if (!t) continue;
226
128
  if (NOT_TYPED.some((re) => re.test(t))) return '';
@@ -238,27 +140,17 @@ function titleFromOne(text, max) {
238
140
  return '';
239
141
  }
240
142
 
241
- /**
242
- * `/Users/you/Desktop/dev/zero` → `~/Desktop/dev/zero`. Same reason as the
243
- * one in agent-files.mjs: a path carrying her account name reads like a
244
- * system string rather than a folder she knows. */
143
+ /** Writes a folder under home as `~/...`. */
245
144
  export function shortFolder(folder, home = os.homedir()) {
246
145
  const here = path.resolve(home);
247
146
  const full = path.resolve(String(folder ?? '') || here);
248
147
  return full === here || full.startsWith(`${here}/`) ? `~${full.slice(here.length)}` : full;
249
148
  }
250
149
 
251
- /* ------------------------------ THE TERMINAL SIDE -------------------------- */
252
-
253
- /**
254
- * The head of a transcript, and out of it the three things a row needs: who
255
- * started the session, which folder it ran in, and the first thing the person
256
- * typed.
257
- *
258
- * Read as a fixed head rather than a whole file. `entrypoint` and `cwd` are on
259
- * every message row and the first human turn is the first message, so all
260
- * three are at the top, and the alternative is reading gigabytes to fill in a
261
- * list. Her largest transcript is 13MB and there are hundreds. */
150
+ /** Reads a transcript's head for who started the session, which folder it ran
151
+ * in and the first messages the person typed. `entrypoint` and `cwd` are on
152
+ * every message row and the first human turn comes first, so a fixed head is
153
+ * enough. */
262
154
  export function readTranscriptHead(file, bytes = HEAD_BYTES, most = FIRST_TURN_BYTES) {
263
155
  let fd = null;
264
156
  try {
@@ -266,18 +158,18 @@ export function readTranscriptHead(file, bytes = HEAD_BYTES, most = FIRST_TURN_B
266
158
  const buf = Buffer.alloc(bytes);
267
159
  const n = fs.readSync(fd, buf, 0, bytes, 0);
268
160
  const text = buf.slice(0, n).toString('utf8');
269
- // The last line of a fixed read is usually cut in half. Dropping it costs
270
- // nothing, because what is wanted is at the top. UNLESS THE WHOLE FILE FIT:
271
- // then the last line is complete, and a file whose one row has no newline
272
- // after it lost that row (found by Codex's review, 2026-10-11).
161
+ // The last line of a full-size read is usually cut in half, so it is set
162
+ // aside. When the whole file fit, the last line is complete and is kept.
273
163
  const lines = text.split('\n');
274
164
  const cut = n < bytes ? null : lines.pop();
275
165
  let cwd = null;
276
166
  let entrypoint = null;
167
+ let firstAt = 0;
277
168
  const said = [];
278
169
  const take = (line) => {
279
170
  let row;
280
171
  try { row = JSON.parse(line); } catch { return; }
172
+ if (!firstAt && row.timestamp) firstAt = Date.parse(row.timestamp) || 0;
281
173
  if (!cwd && typeof row.cwd === 'string' && row.cwd) cwd = row.cwd;
282
174
  if (!entrypoint && typeof row.entrypoint === 'string' && row.entrypoint) entrypoint = row.entrypoint;
283
175
  if (row.type === 'user' && said.length < 5) {
@@ -290,29 +182,81 @@ export function readTranscriptHead(file, bytes = HEAD_BYTES, most = FIRST_TURN_B
290
182
  take(line);
291
183
  if (cwd && entrypoint && said.length >= 5) break;
292
184
  }
293
- // UNLESS THE HALF LINE IS HER FIRST MESSAGE, cut because a screenshot was
294
- // pasted into it. Then it is read to its end, once, up to `most`. Only a
295
- // cut row that names itself a user message near its start qualifies, and
296
- // only when nothing she said was found and the session is not already
297
- // known to be the app's own, so the thousands of worker transcripts are
298
- // never read past their head.
185
+ // If the cut line is the first user message, cut because a screenshot was
186
+ // pasted into it, read it to its end, up to `most`. Only when nothing typed
187
+ // was found and the session is not known to be the app's own, so worker
188
+ // transcripts are never read past their head.
299
189
  if (!said.length && n === bytes && cut && /"type"\s*:\s*"user"/.test(cut.slice(0, 2048))
300
190
  && (!entrypoint || startedByHand(entrypoint))) {
301
191
  const rest = restOfLine(fd, n, most - n);
302
192
  if (rest) take(Buffer.concat([buf.subarray(buf.lastIndexOf(0x0a, n - 1) + 1, n), rest]).toString('utf8'));
303
193
  }
304
- return { cwd, entrypoint, said };
194
+ return { cwd, entrypoint, said, firstAt };
195
+ } catch {
196
+ return { cwd: null, entrypoint: null, said: [], firstAt: 0 };
197
+ } finally {
198
+ if (fd !== null) { try { fs.closeSync(fd); } catch { /* nothing to do */ } }
199
+ }
200
+ }
201
+
202
+ /** A `user` line a person typed, as opposed to a tool result, a reminder or a
203
+ * command's wrapper. Only these end the agent's reply. */
204
+ function typedTurn(row) {
205
+ if (row?.type !== 'user' || row.toolUseResult || row.isMeta || row.isCompactSummary) return false;
206
+ return threadTitle(messageText(row)) !== '';
207
+ }
208
+
209
+ /** Reads a transcript's end for Claude Code's own title (`ai-title`, the last
210
+ * one wins) and the agent's last reply, every text block of it since the last
211
+ * thing a person typed. */
212
+ export function readTranscriptTail(file, bytes = TAIL_BYTES) {
213
+ let fd = null;
214
+ try {
215
+ fd = fs.openSync(file, 'r');
216
+ const size = fs.fstatSync(fd).size;
217
+ const from = Math.max(0, size - bytes);
218
+ const buf = Buffer.alloc(size - from);
219
+ const n = fs.readSync(fd, buf, 0, buf.length, from);
220
+ const lines = buf.slice(0, n).toString('utf8').split('\n');
221
+ if (from > 0) lines.shift();
222
+ let aiTitle = '';
223
+ let reply = '';
224
+ for (const line of lines) {
225
+ if (!line.trim()) continue;
226
+ let row;
227
+ try { row = JSON.parse(line); } catch { continue; }
228
+ if (row.type === 'ai-title' && row.aiTitle) aiTitle = String(row.aiTitle);
229
+ else if (typedTurn(row)) reply = '';
230
+ else if (row.type === 'assistant') {
231
+ const text = messageText(row).trim();
232
+ if (text) reply = reply ? `${reply}\n\n${text}` : text;
233
+ }
234
+ }
235
+ return { aiTitle, lastSaid: reply.slice(-SAID_CHARS) };
305
236
  } catch {
306
- return { cwd: null, entrypoint: null, said: [] };
237
+ return { aiTitle: '', lastSaid: '' };
307
238
  } finally {
308
239
  if (fd !== null) { try { fs.closeSync(fd); } catch { /* nothing to do */ } }
309
240
  }
310
241
  }
311
242
 
312
- /**
313
- * The bytes from `from` up to the next newline, or null if there is none
314
- * within `budget` bytes: a line that long is given up on, not read whole. A
315
- * file that ends without a newline ends the line too. */
243
+ /** What a finished read knows about how a chat ended, for its row and the
244
+ * setup card. */
245
+ function endOf(file) {
246
+ const { aiTitle, lastSaid } = file ? readTranscriptTail(file) : { aiTitle: '', lastSaid: '' };
247
+ return { aiTitle, lastSaid, ...howTheChatEnded(lastSaid) };
248
+ }
249
+
250
+ /** The first message a person typed, whole, for an imported row's opening. */
251
+ function firstTyped(said) {
252
+ for (const text of said) {
253
+ if (threadTitle(text)) return String(text).trim().slice(0, FIRST_CHARS);
254
+ }
255
+ return '';
256
+ }
257
+
258
+ /** The bytes from `from` up to the next newline or the end of the file, or
259
+ * null if neither comes within `budget` bytes. */
316
260
  function restOfLine(fd, from, budget) {
317
261
  const parts = [];
318
262
  let at = from;
@@ -328,9 +272,7 @@ function restOfLine(fd, from, budget) {
328
272
  return null;
329
273
  }
330
274
 
331
- /**
332
- * The words out of one transcript row, whichever of the two shapes it is in.
333
- * Content is a string on the plain ones and a list of blocks on the rest. */
275
+ /** A row's content is either a string or a list of blocks. */
334
276
  function messageText(row) {
335
277
  const c = row?.message?.content;
336
278
  if (typeof c === 'string') return c;
@@ -343,22 +285,13 @@ function messageText(row) {
343
285
  return '';
344
286
  }
345
287
 
346
- /**
347
- * Every terminal thread, newest first, with the three kinds of noise gone.
348
- *
349
- * `skipped` comes back with it because the counts are the argument for the
350
- * filters, and a session that changes one of them should be able to see what
351
- * it changed rather than take this comment's word for it. */
352
- function readTerminalThreads({ home, dirs, cutoff, max, skipIds = new Set() }) {
288
+ /** `skipped` counts why each transcript was left out, so a change to a filter
289
+ * can be measured. */
290
+ function readTerminalThreads({ home, dirs, cutoff, max, budgetMs = READ_BUDGET_MS, skipIds = new Set() }) {
353
291
  const skipped = { old: 0, scratch: 0, started: 0, subagent: 0, empty: 0, unread: 0, desktopDupe: 0, copy: 0 };
354
292
  const out = [];
355
- // Stat everything, keep the recent, newest first, and only then start opening
356
- // files. This is the order that makes `max` safe: whatever it cuts off is
357
- // older than everything it kept.
358
- //
359
- // EVERY DATA FOLDER, NOT ONLY ~/.claude (main/agent-homes.mjs). They are
360
- // pooled before the sort, so the cap and the time budget are spent on the
361
- // newest sessions on the machine, whichever folder they are in.
293
+ // Every data folder is pooled and sorted newest first before any file is
294
+ // opened, so `max` and the time budget only ever cut off older sessions.
362
295
  const recent = [];
363
296
  for (const dir of dirs) {
364
297
  const root = path.join(dir, 'projects');
@@ -374,37 +307,42 @@ function readTerminalThreads({ home, dirs, cutoff, max, skipIds = new Set() }) {
374
307
  }
375
308
  }
376
309
  recent.sort((a, b) => b.when - a.when);
377
- // ONE SESSION IS ONE ROW, even when two folders hold it: a backup folder with
378
- // a copy of a conversation is ordinary. Newest first, so the copy kept is the
379
- // one still being written to.
310
+ // One session is one row even when two data folders hold a copy. Newest
311
+ // first, so the copy kept is the one still being written to.
380
312
  const seenIds = new Set();
381
- // THE CAP COUNTS WHAT IS KEPT, NOT WHAT IS OPENED (w-ec62ab6b38, 2026-09-28).
382
- // On a busy machine the last seven days held 9,073 transcripts, the newest
383
- // 1,200 were opened, and every one of those was started by agentbox itself,
384
- // so a conversation started by hand was never looked at and the import card
385
- // said there was nothing, as if the app were not looking in Claude Code at
386
- // all. Somebody running dozens of agents fills this folder with ours. So it
387
- // opens newest first until it has `max` of the person's own, or until
388
- // READ_BUDGET_MS has gone, whichever is first. Reading all 9,073 heads took
389
- // 5.0s there.
313
+ // The cap counts threads kept, not files opened: on a machine running many
314
+ // workers, the newest thousand transcripts can all be Agentbox's own.
390
315
  const started = Date.now();
391
316
  let opened = 0;
317
+ // The moment the read gave up at, zero when it read the whole window. A
318
+ // name is only known once the file is open, so a search that stopped in
319
+ // August must not answer "no such chat" as if it had looked at September.
320
+ let reached = 0;
321
+ let lastOpened = 0;
322
+ // The desktop app's own record wins over its transcript, but how the chat
323
+ // ended is only in the transcript.
324
+ const desktopFiles = new Map();
392
325
  for (const { file, when } of recent) {
393
- if (out.length >= max || Date.now() - started > READ_BUDGET_MS) {
326
+ if (out.length >= max || Date.now() - started > budgetMs) {
394
327
  skipped.unread = recent.length - opened;
328
+ reached = lastOpened;
395
329
  break;
396
330
  }
397
331
  opened += 1;
332
+ lastOpened = when;
398
333
  const id = path.basename(file, '.jsonl');
399
- // THE DESKTOP APP WRITES A THREAD TWICE, once as a transcript here and once
400
- // as its own record, joined by `cliSessionId`. Measured 2026-08-29 on the
401
- // one desktop thread in her store. Its own record wins, because the app
402
- // wrote a real title into it and this side would have to guess one.
403
- if (skipIds.has(id)) { skipped.desktopDupe += 1; continue; }
334
+ // The desktop app writes a thread twice, as a transcript here and as its
335
+ // own record joined by `cliSessionId`. Its record wins because it has a
336
+ // real title.
337
+ if (skipIds.has(id)) {
338
+ skipped.desktopDupe += 1;
339
+ if (!desktopFiles.has(id)) desktopFiles.set(id, file);
340
+ continue;
341
+ }
404
342
  if (seenIds.has(id)) { skipped.copy += 1; continue; }
405
- const { cwd, entrypoint, said } = readTranscriptHead(file);
406
- // A copy is only the copy once it could be read. A damaged newer one, cut
407
- // off mid-write, would otherwise hide the good older one in another folder.
343
+ const { cwd, entrypoint, said, firstAt } = readTranscriptHead(file);
344
+ // Only a readable copy counts, so a newer copy cut off mid-write does not
345
+ // hide a good older one.
408
346
  if (cwd || entrypoint) seenIds.add(id);
409
347
  if (!startedByHand(entrypoint)) { skipped.started += 1; continue; }
410
348
  if (isScratchFolder(cwd, home)) { skipped.scratch += 1; continue; }
@@ -419,15 +357,53 @@ function readTerminalThreads({ home, dirs, cutoff, max, skipIds = new Set() }) {
419
357
  title,
420
358
  when,
421
359
  path: file,
360
+ firstSaid: firstTyped(said),
361
+ startedAt: firstAt || when,
422
362
  });
423
363
  }
424
- return { threads: out, skipped };
364
+ return { threads: out, skipped, reached, desktopFiles };
365
+ }
366
+
367
+ /** How many of the newest threads get their end read, and for how long. The
368
+ * head walk above has its own budget, so this one only ever adds detail to
369
+ * threads already listed. */
370
+ const ENDS_MOST = 120;
371
+ const ENDS_BUDGET_MS = 2000;
372
+
373
+ function withEnds(threads, desktopFiles) {
374
+ const started = Date.now();
375
+ return threads.map((t, i) => {
376
+ const file = t.source === 'desktop' ? desktopFiles.get(t.id) ?? null : t.path;
377
+ if (i >= ENDS_MOST || Date.now() - started > ENDS_BUDGET_MS || !file) {
378
+ // Not "finished": nothing is known of how it ended until it is picked.
379
+ return { ...t, waiting: false, ask: '', lastSaid: '', endUnknown: true };
380
+ }
381
+ const end = endOf(file);
382
+ const firstSaid = t.firstSaid ?? firstTyped(readTranscriptHead(file).said);
383
+ return {
384
+ ...t,
385
+ title: t.source === 'desktop' ? t.title : threadTitle(end.aiTitle) || t.title,
386
+ waiting: end.waiting,
387
+ ask: end.ask,
388
+ lastSaid: end.lastSaid,
389
+ firstSaid,
390
+ };
391
+ });
425
392
  }
426
393
 
427
- /**
428
- * Every transcript under the projects root, subagent folders included so the
429
- * filter above can count them rather than the walk hiding them. Unbounded on
430
- * purpose: this only lists names, and the cap that matters is on opening. */
394
+ /** The chats somebody picked, with the end read for any the list left unread
395
+ * (past its cap, or found by name). Codex and desktop chats come read. */
396
+ export function readChatEnds(threads) {
397
+ return threads.map((t) => {
398
+ if (t.source === 'codex' || t.source === 'desktop' || !t.path) return t;
399
+ if (t.lastSaid !== undefined && !t.endUnknown) return t;
400
+ const { endUnknown, ...rest } = t;
401
+ return withEnds([rest], new Map())[0];
402
+ });
403
+ }
404
+
405
+ /** Lists every transcript, subagents included so the caller can count them.
406
+ * Not capped: listing names is cheap, and the cap is on opening. */
431
407
  function walkTranscripts(dir, into, depth = 4) {
432
408
  if (depth < 0) return;
433
409
  let entries = [];
@@ -439,30 +415,17 @@ function walkTranscripts(dir, into, depth = 4) {
439
415
  }
440
416
  }
441
417
 
442
- /* ------------------------------- THE DESKTOP SIDE -------------------------- */
443
-
444
- /** Where the desktop app keeps what it knows about a session. */
445
418
  export function desktopSessionsDir(home = os.homedir()) {
446
419
  return path.join(home, 'Library', 'Application Support', 'Claude', 'claude-code-sessions');
447
420
  }
448
421
 
449
- /**
450
- * THE DESKTOP APP'S THREADS, which are the easy half.
451
- *
452
- * Its record is small and it carries a real `title` the app wrote, plus the
453
- * folder, the branch and the moment of the last turn. So there is no
454
- * transcript to read and no name to guess: measured on her Mac 2026-08-29, 38
455
- * records, every one of them titled, for example "Fix duplicate cowork task
456
- * display issue".
457
- *
458
- * Archived ones are left out. She archived them, which is the desktop app's
459
- * word for done with. */
422
+ /** The desktop app's record carries a real `title`, the folder and the time of
423
+ * the last turn, so no transcript is read. Archived sessions are left out. */
460
424
  function readDesktopThreads({ home, cutoff, max }) {
461
425
  const root = desktopSessionsDir(home);
462
426
  const skipped = { old: 0, scratch: 0, archived: 0, empty: 0 };
463
427
  const out = [];
464
- // Every id this side owns, recent or not, so the transcript walk can leave
465
- // them alone rather than draw the same thread under a worse name.
428
+ // Every id this side owns, recent or not, so the transcript walk skips them.
466
429
  const owned = new Set();
467
430
  const files = [];
468
431
  walkDesktop(root, files, max);
@@ -486,6 +449,7 @@ function readDesktopThreads({ home, cutoff, max }) {
486
449
  title,
487
450
  when,
488
451
  path: file,
452
+ startedAt: Number(rec?.createdAt || when),
489
453
  });
490
454
  }
491
455
  return { threads: out, skipped, owned };
@@ -503,46 +467,96 @@ function walkDesktop(dir, into, max, depth = 3) {
503
467
  }
504
468
  }
505
469
 
506
- /* ---------------------------------- BOTH ----------------------------------- */
507
-
508
- /**
509
- * BOTH SOURCES, NEWEST FIRST, THE HANDFUL SHE MEANT.
510
- *
511
- * `days` is her "last few days" and it is a parameter rather than a constant
512
- * because the number is the one thing on this card somebody may want to argue
513
- * about. Everything else here is a fact about the file.
514
- *
515
- * One row per thread and no folding by folder: the folder is on the row and
516
- * the card groups by it, the same way it already groups agent files. */
470
+ /** Terminal, editor and desktop threads a person started, newest first, one
471
+ * row per thread. */
517
472
  export function readSessionThreads({
518
473
  home = os.homedir(),
519
- // Every Claude Code data folder to read (main/agent-homes.mjs). The app hands
520
- // in the list it made with its own environment and settings; left out, it is
521
- // the folders under `home`, and nothing from whatever CLAUDE_CONFIG_DIR the
522
- // caller's process happens to carry.
474
+ // The app passes the data folders it found with its own environment and
475
+ // settings. The default is the folders under `home`, ignoring whatever
476
+ // CLAUDE_CONFIG_DIR the calling process has.
523
477
  dirs = claudeDataDirs({ home }),
524
478
  days = RECENT_DAYS,
525
479
  now = Date.now(),
526
480
  max = MAX_READS,
481
+ // The card's list keeps the three seconds it was measured for; a search is
482
+ // allowed longer, reaching back a year with somebody waiting on it.
483
+ budgetMs = READ_BUDGET_MS,
484
+ // Whether to read each listed chat's end too: its own title, its last reply
485
+ // and whether it is waiting on you. The year-long search does not need it.
486
+ ends = true,
527
487
  } = {}) {
528
488
  const cutoff = now - Math.max(0, Number(days) || 0) * 86400 * 1000;
529
- // The desktop side goes first so its ids are known before the transcripts are
530
- // walked, which is what stops one thread appearing twice.
489
+ // The desktop side goes first so its ids are known before the transcripts
490
+ // are walked, which stops one thread appearing twice.
531
491
  const desk = readDesktopThreads({ home, cutoff, max });
532
- const term = readTerminalThreads({ home, dirs, cutoff, max, skipIds: desk.owned });
533
- const threads = [...term.threads, ...desk.threads].sort((a, b) => b.when - a.when);
492
+ const term = readTerminalThreads({ home, dirs, cutoff, max, budgetMs, skipIds: desk.owned });
493
+ const listed = [...term.threads, ...desk.threads].sort((a, b) => b.when - a.when);
494
+ const threads = ends ? withEnds(listed, term.desktopFiles) : listed;
534
495
  return {
535
496
  threads,
536
497
  skipped: { ...term.skipped, desktopOld: desk.skipped.old, archived: desk.skipped.archived },
498
+ /** Zero when the whole window was read. See `reached` above. */
499
+ reached: term.reached,
537
500
  };
538
501
  }
539
502
 
540
- /**
541
- * THE FOLDERS THOSE THREADS RAN IN, each with its own threads under it.
542
- *
543
- * The import card is already a section per place the rows can land, so this is
544
- * the shape it wants. Sorted by the newest thread in each, because the folder
545
- * she touched an hour ago is the one she is looking for. */
503
+ /* -------------------- AND THE ONE THE NEW-CHATS WATCH USES ------------------ */
504
+ // The watch (main/new-chats-watch.mjs) runs every minute on the main process,
505
+ // so it reads only the folders the projects point at, which also leaves out
506
+ // Agentbox's own runs in their task folders, only files changed since the
507
+ // switch went on, and each file once until it changes.
508
+
509
+ const PROJECT_CHATS_KEPT = 2000;
510
+ const projectChats = new Map();
511
+ const slugOf = (folder) => String(folder ?? '').replace(/[^a-zA-Z0-9]/g, '-');
512
+ const sameFolder = (a, b) => String(a ?? '').replace(/\/+$/, '') === String(b ?? '').replace(/\/+$/, '');
513
+
514
+ function readOneChat(file, { id, folder, home, when }) {
515
+ const { cwd, entrypoint, said, firstAt } = readTranscriptHead(file);
516
+ if (!startedByHand(entrypoint) || !sameFolder(cwd, folder)) return null;
517
+ const title = threadTitle(said);
518
+ if (!title) return null;
519
+ const end = endOf(file);
520
+ return {
521
+ id, source: sourceOf(entrypoint), folder: cwd, folderName: path.basename(cwd), short: shortFolder(cwd, home),
522
+ title: threadTitle(end.aiTitle) || title, when, path: file,
523
+ waiting: end.waiting, ask: end.ask, lastSaid: end.lastSaid, firstSaid: firstTyped(said), startedAt: firstAt || when,
524
+ };
525
+ }
526
+
527
+ export function readProjectChats({ home = os.homedir(), dirs = claudeDataDirs({ home }), folders = [], since = 0 } = {}) {
528
+ const out = [];
529
+ const seen = new Set();
530
+ for (const dir of dirs) {
531
+ for (const folder of folders) {
532
+ const at = path.join(dir, 'projects', slugOf(folder));
533
+ let names = [];
534
+ try { names = fs.readdirSync(at); } catch { continue; }
535
+ for (const name of names) {
536
+ if (!name.endsWith('.jsonl')) continue;
537
+ const id = name.slice(0, -'.jsonl'.length);
538
+ if (seen.has(id)) continue;
539
+ const file = path.join(at, name);
540
+ let st;
541
+ try { st = fs.statSync(file); } catch { continue; }
542
+ if (st.mtimeMs < since) continue;
543
+ const kept = projectChats.get(file);
544
+ let thread = kept?.thread ?? null;
545
+ if (!kept || kept.size !== st.size || kept.mtimeMs !== st.mtimeMs) {
546
+ thread = readOneChat(file, { id, folder, home, when: st.mtimeMs });
547
+ projectChats.delete(file);
548
+ projectChats.set(file, { size: st.size, mtimeMs: st.mtimeMs, thread });
549
+ if (projectChats.size > PROJECT_CHATS_KEPT) projectChats.delete(projectChats.keys().next().value);
550
+ }
551
+ if (thread) { seen.add(id); out.push(thread); }
552
+ }
553
+ }
554
+ }
555
+ return out.sort((a, b) => b.when - a.when);
556
+ }
557
+
558
+ /** Groups threads by folder, each folder sorted by its newest thread. Expects
559
+ * the threads newest first, as readSessionThreads returns them. */
546
560
  export function threadsByFolder(threads = []) {
547
561
  const byFolder = new Map();
548
562
  for (const t of threads) {