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
package/main/agents.mjs CHANGED
@@ -1,13 +1,8 @@
1
1
  import { hookFailure } from '../shared/hook-failure.mjs';
2
- // HER CLAUDE CODE AGENTS, READ OFF THE MACHINE — and the one way Agentbox speaks
3
- // back to one. The rules about what any of it MEANS are in shared/agents.mjs,
4
- // pure and pinned; this file only measures and delivers.
5
- //
6
- // Everything here is READ-ONLY except `reply`, which writes one line into a
7
- // socket a live session is already listening on. Agentbox writes nothing to disk
8
- // outside its own store, and in particular it does not touch
9
- // ~/.claude/settings.json. That was the one thing the build was told to ask
10
- // before doing, and it turned out not to be needed: see the note on `reply`.
2
+ // Reads the Claude Code sessions running on this machine, and replies to one.
3
+ // What the readings mean is decided in shared/agents.mjs; this file only
4
+ // measures and delivers. Everything here is read-only except `reply`, which
5
+ // writes one line into the socket a live session already listens on.
11
6
 
12
7
  import fs from 'node:fs';
13
8
  import net from 'node:net';
@@ -17,25 +12,21 @@ import readline from 'node:readline';
17
12
  import { execFile } from 'node:child_process';
18
13
  import { promisify } from 'node:util';
19
14
  import { NAME, Name, WAS } from '../shared/product-name.mjs';
15
+ import { findTranscript, transcriptSearch } from './transcript-search.mjs';
20
16
  import {
21
17
  WORK_CHARS, agentKey, changedFile, commandWork, fullSubject, isBookkeeping, saidCount, sameInstant, threadWindow, workSubject, workVerb,
22
18
  } from '../shared/agents.mjs';
23
19
 
24
20
  const run = promisify(execFile);
25
21
 
26
- // Asked for each time rather than resolved once at import. It costs nothing —
27
- // os.homedir is a syscall-free read of the environment — and it is what lets
28
- // the transcript reader be driven over a temporary home in a test, which is the
29
- // only way the SHAPE of Claude Code's own jsonl gets checked at all.
22
+ // Resolved on every call rather than at import, so a test can point the
23
+ // transcript reader at a temporary home.
30
24
  const SESSIONS_DIR = () => path.join(os.homedir(), '.claude', 'sessions');
31
25
  const PROJECTS_DIR = () => path.join(os.homedir(), '.claude', 'projects');
32
26
 
33
- /* ---------------------------- what is actually up ------------------------ */
34
- // ONE `ps`, FOR EVERYTHING. It answers three questions at once and each of them
35
- // costs a process otherwise: which pids are alive, who started each one (the
36
- // dedupe key for the app's own workers), and when each one started (the guard
37
- // against pid reuse). Sixteen sessions on her machine at the time of writing,
38
- // so sixteen separate `ps` calls per snapshot poll is not a thing to do.
27
+ // One `ps` answers three questions: which pids are alive, who started each one
28
+ // (the dedupe key for the app's own workers), and when each one started (the
29
+ // guard against pid reuse).
39
30
  async function processTable() {
40
31
  const table = new Map();
41
32
  let out = '';
@@ -53,9 +44,8 @@ async function processTable() {
53
44
  return table;
54
45
  }
55
46
 
56
- // Is the process at that pid still the session the record describes? The rule
57
- // and the timezone trap behind it are in shared/agents.mjs, pure and pinned:
58
- // getting it wrong showed her three of her sixteen live agents.
47
+ // Is the process at that pid still the session the record describes? The rule,
48
+ // and the timezone trap behind it, are in `sameInstant` in shared/agents.mjs.
59
49
  function stillTheSame(rec, proc) {
60
50
  if (!proc) return false;
61
51
  if (!/claude/i.test(proc.command)) return false;
@@ -63,12 +53,9 @@ function stillTheSame(rec, proc) {
63
53
  return sameInstant(rec.procStart, proc.started);
64
54
  }
65
55
 
66
- /* ------------------------------- the sources ----------------------------- */
67
- // TWO SOURCES, UNIONED, because when measured neither one alone saw the whole
68
- // machine. `claude agents --json` (the public scripting flag) listed 3 sessions
69
- // while 16 were live with sockets bound; the per-session state files listed
70
- // those 16 and none of the 3. They disagree about which sessions they know, and
71
- // they agree about the shape of a session, so the honest read is both.
56
+ // Two sources, unioned. `claude agents --json` and the per-session state files
57
+ // each list sessions the other misses, and both describe a session in the same
58
+ // shape.
72
59
  function fromStateFiles() {
73
60
  const found = [];
74
61
  let names = [];
@@ -88,8 +75,8 @@ async function fromCli(claudeBin) {
88
75
  try {
89
76
  const { stdout } = await run(claudeBin, ['agents', '--json'], {
90
77
  cwd: os.tmpdir(), encoding: 'utf8', maxBuffer: 8 << 20, timeout: 8000,
91
- // The CLI must not think it is a child of this session, and the app's own
92
- // billing rule applies to every claude invocation it makes.
78
+ // So the CLI does not think it is a child of this session, and never
79
+ // inherits an API key.
93
80
  env: scrubbed(process.env),
94
81
  });
95
82
  const parsed = JSON.parse(stdout);
@@ -97,11 +84,8 @@ async function fromCli(claudeBin) {
97
84
  } catch { return []; }
98
85
  }
99
86
 
100
- // The same scrub the supervisor does before every spawn, for the same reason:
101
- // an inherited ANTHROPIC_API_KEY silently redirects billing off her
102
- // subscription. `claude agents --json` does not talk to the API, but the rule
103
- // in CLAUDE.md is about never handing one to the CLI at all, not about which
104
- // subcommand it happens to be.
87
+ // The same scrub the supervisor does before every spawn. `claude agents --json`
88
+ // does not call the API, but no claude invocation gets an inherited key.
105
89
  function scrubbed(env) {
106
90
  const out = { ...env };
107
91
  for (const key of Object.keys(out)) {
@@ -110,103 +94,36 @@ function scrubbed(env) {
110
94
  return out;
111
95
  }
112
96
 
113
- /* ------------------------------ the transcript --------------------------- */
114
- // WHEN IT LAST DID ANYTHING, which is a very different number from when its
115
- // process started: her six forgotten agents were all opened within five seconds
116
- // of each other on 12 August and the last thing anyone said in them was 29 and
117
- // 30 July. Started-at would have called them minutes old.
118
- //
119
- // The transcript is `~/.claude/projects/<slug>/<sessionId>.jsonl` and it can be
120
- // megabytes, so only the tail is read, and only when the file has moved since
121
- // last time. Without the cache this runs on every snapshot poll.
122
- //
123
- // AND WHAT THE SESSION IS ABOUT, which is the half of this that was missing.
124
- //
125
- // Claude Code already writes three facts into the same transcript this function
126
- // is already reading, and they were simply being skipped:
127
- //
128
- // ai-title a one-line title the session wrote for ITSELF. Every session
129
- // measured had one inside the 96KB tail, and it is usually the
130
- // sentence a person would use to recall the session.
131
- // last-prompt the last thing the user typed into that session.
132
- // tool_use the files it has been editing, reading and writing.
133
- //
134
- // So the reading is one pass over a tail that was already being read, and the
135
- // card goes from three lines about a pid to what the work actually is.
136
- //
137
- // WHAT WAS MISSING WAS THE BEGINNING. The human side of a conversation fits on
138
- // a card. The agent's side is what is megabytes.
139
- //
140
- // AND THE TAIL CANNOT SEE IT. The opening turn lands between 11KB and 614KB in
141
- // (pasted images are single transcript lines of hundreds of KB), and 96KB
142
- // off the end reaches none of it. Measured: a 1MB head plus a 512KB tail still
143
- // saw only 46% of the human turns.
144
- //
145
- // So the whole file is read, ONCE, and then only what has been appended since.
146
- // Measured: the first pass over 15 transcripts, 117MB, costs 353ms in total
147
- // and never happens again for that session; after it, a refresh reads only the
148
- // bytes that arrived. This is the same read the tail was already doing, moved
149
- // from "the last 96KB" to "everything, once". No model sees any of it, by
150
- // design.
97
+ // The transcript, `~/.claude/projects/<slug>/<sessionId>.jsonl`, says when the
98
+ // session last did anything (not when its process started), its `ai-title`,
99
+ // the `last-prompt`, the files its tool calls touched, and the human turns.
100
+ // The opening human turn can sit hundreds of KB in, behind pasted images, so
101
+ // the whole file is read once and after that only the bytes appended since.
102
+ // No model sees any of it.
151
103
  const CHUNK_BYTES = 4 << 20;
152
104
  const historyCache = new Map(); // sessionId -> { file, size, mtimeMs, offset, ...facts }
153
- const dirCache = new Map(); // sessionId -> transcript path
154
105
 
155
- // The files it has had its hands on, newest last, deduplicated, and only the
156
- // ones a person would recognise: the basename, never the path. Six is the most
157
- // the card will print and reading more of them is free, so the cap is applied
158
- // where it is shown rather than here.
106
+ // Tools whose file paths count as touched. The card prints at most six, and
107
+ // that cap is applied where they are shown, not here.
159
108
  const TOUCH_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'Read', 'MultiEdit']);
160
109
 
161
- function slugFor(cwd) {
162
- return String(cwd || '').replace(/[^a-zA-Z0-9]/g, '-');
163
- }
164
-
165
- function transcriptPath(sessionId, cwd) {
166
- const cached = dirCache.get(sessionId);
167
- if (cached && fs.existsSync(cached)) return cached;
168
- const direct = path.join(PROJECTS_DIR(), slugFor(cwd), `${sessionId}.jsonl`);
169
- if (fs.existsSync(direct)) { dirCache.set(sessionId, direct); return direct; }
170
- // The slug rule is Claude Code's, not ours, so a miss falls back to looking.
171
- let dirs = [];
172
- try { dirs = fs.readdirSync(PROJECTS_DIR()); } catch { return null; }
173
- for (const dir of dirs) {
174
- const p = path.join(PROJECTS_DIR(), dir, `${sessionId}.jsonl`);
175
- if (fs.existsSync(p)) { dirCache.set(sessionId, p); return p; }
176
- }
177
- return null;
178
- }
110
+ const transcriptPath = (sessionId, cwd) => findTranscript(PROJECTS_DIR(), sessionId, cwd);
179
111
 
180
112
  const NOTHING = { lastActiveAt: 0, lastSaid: '', saidAt: 0, saidOpen: false, about: '', lastAsked: '', touched: [], asked: [] };
181
113
 
182
- // HOW MUCH OF THE LAST REPLY IS KEPT. The card quotes the end of it, so this is
183
- // a bound on the accumulator and not on what the user reads. A human reply is
184
- // rarely anywhere near 6KB.
114
+ // The card quotes the end of the last reply, so this bounds the accumulator,
115
+ // not what the user reads.
185
116
  const SAID_CHARS = 8000;
186
117
 
187
- // WHAT COUNTS AS SOMETHING THE USER TYPED. A Claude Code transcript calls a great
188
- // many things a `user` message that no human wrote: every tool result comes
189
- // back as one, every system reminder is one, every slash command expands into
190
- // one. Reading those onto the card would fill her recall with the machine
191
- // talking to itself, which is worse than the blank it replaces.
192
- //
193
- // So a turn is hers only when it is a `user` line that is not flagged meta, is
194
- // not a compaction summary, carries no tool result, and whose text does not
195
- // open with a tag. Checked against 155 real human turns.
196
- //
197
- // THE CARD AND THE CONVERSATION READ THE SAME LINE DIFFERENTLY, which is why
198
- // this is two functions and not one. A card quotes a sentence, so it flattens
199
- // her paragraphs to a single line; the conversation shows what she actually
200
- // typed, so it keeps them. Splitting it here means the FILTER — the part that
201
- // decides what was written by a human at all — has one copy, and a transcript
202
- // change breaks both at once instead of only the one nobody is looking at.
118
+ // Claude Code writes tool results, system reminders and expanded slash
119
+ // commands as `user` lines too. A turn counts as typed by a person only when it
120
+ // is a `user` line that is not meta, not a compaction summary, carries no tool
121
+ // result, and does not open with a tag. `herTurnRaw` keeps paragraphs for the
122
+ // conversation and `herTurn` flattens them for the card, so the filter has one
123
+ // copy.
203
124
  const RELAY_MARK = `Relayed from ${NAME},`;
204
- // WRITTEN UNDER THE CURRENT NAME, RECOGNISED UNDER EVERY NAME THIS APP HAS
205
- // HAD. A transcript is a file on her disk and it outlives a rename, so a
206
- // marker matched on the current name alone stops matching the day the name
207
- // changes, and the app's own preamble starts reading as something the user typed.
208
- // The letter case is ignored as well: the name was written lowercase until
209
- // 2026-10-02, and those transcripts say "Relayed from agentbox,".
125
+ // The marker is recognised under every name this app has had, in any letter
126
+ // case, because a transcript outlives a rename.
210
127
  const RELAY_MARKERS = [RELAY_MARK, ...WAS.map((was) => `Relayed from ${was},`)].map((m) => m.toLowerCase());
211
128
  const wasRelayed = (line) => RELAY_MARKERS.some((m) => line.toLowerCase().startsWith(m));
212
129
  export { wasRelayed as __wasRelayed };
@@ -221,11 +138,9 @@ function herTurnRaw(obj) {
221
138
  : '';
222
139
  const clean = String(text).trim();
223
140
  const flat = clean.replace(/\s+/g, ' ');
224
- // AND NEVER AGENTBOX'S OWN PREAMBLE. `fromTheApp` puts a sentence of context in
225
- // front of what the user typed, and today Claude Code stamps the result `isMeta`
226
- // so this never sees it. If a future one records it as a plain user turn
227
- // instead, the card would quote Agentbox talking to an agent back at the user
228
- // as their own words. `spoke` already holds what the user actually typed.
141
+ // Also drop the app's own preamble from `fromTheApp`. Claude Code stamps it
142
+ // `isMeta` today, but if a later version records it as a plain user turn the
143
+ // card would quote it as the user's words.
229
144
  if (!flat || flat.startsWith('<') || flat.startsWith('[Request interrupted') || wasRelayed(flat)) return '';
230
145
  return clean;
231
146
  }
@@ -234,29 +149,16 @@ function herTurn(obj) {
234
149
  return herTurnRaw(obj).replace(/\s+/g, ' ').trim();
235
150
  }
236
151
 
237
- // SOMETHING ARRIVED, SO WHATEVER IT WAS SAYING IS FINISHED. An assistant does
238
- // not speak twice without being spoken to, so the line that ends a reply is the
239
- // next inbound one — and MOST OF THOSE ARE NOT QUOTED ANYWHERE. A message
240
- // Agentbox delivers comes back as `isMeta`, a peer's comes back wrapped, a
241
- // delivery notice comes back as neither, and `herTurnRaw` drops all three on
242
- // purpose. Dropping them from the READING was right; dropping them from the
243
- // COUNTING is what folded two days of separate answers into one bubble.
244
- //
245
- // MEASURED on session-46: its whole conversation came out as three turns, and
246
- // the last one was stamped 2:48pm today while opening with a sentence it had
247
- // said at 7:47pm two days earlier. She read that as the answer to what she had
248
- // just asked. It was not.
249
- //
250
- // A tool result is also a `user` line and is the one thing that does NOT end a
251
- // reply: it is the reply, still going.
152
+ // Any inbound `user` line ends the reply before it, including the ones
153
+ // `herTurnRaw` drops on purpose: messages Agentbox delivered, peer messages and
154
+ // delivery notices. A tool result is the exception, because it is part of the
155
+ // reply that is still going.
252
156
  function arrived(obj) {
253
157
  return obj?.type === 'user' && !obj.toolUseResult;
254
158
  }
255
159
 
256
- // HOW MUCH OF HER SIDE IS KEPT. The opening is kept forever because it is the
257
- // ask; after that the most recent are what tell her where the thread got to.
258
- // Her longest session had 29 turns, so this loses nothing on today's machine
259
- // and cannot grow without bound on a session that runs for a month.
160
+ // Human turns kept per session. The first is kept for good because it is the
161
+ // ask; after it, only the most recent.
260
162
  const KEEP_TURNS = 24;
261
163
 
262
164
  function remember(acc, line) {
@@ -266,42 +168,30 @@ function remember(acc, line) {
266
168
  const ts = Date.parse(obj.timestamp);
267
169
  if (Number.isFinite(ts)) acc.lastActiveAt = Math.max(acc.lastActiveAt, ts);
268
170
  }
269
- // The session's own one-line title, rewritten as the work changes, so the
270
- // LAST one in the file is the current one.
171
+ // Claude Code rewrites the title as the work changes, so the last one wins.
271
172
  if (obj.type === 'ai-title' && obj.aiTitle) acc.about = String(obj.aiTitle);
272
- // The last thing a HUMAN typed into it. Claude Code records it as its own
273
- // line, so this needs no walking back through tool results to find the one
274
- // user turn that was not a tool answering a tool.
275
173
  if (obj.type === 'last-prompt' && obj.lastPrompt) acc.lastAsked = String(obj.lastPrompt);
276
174
  const mine = herTurn(obj);
277
175
  if (mine) {
278
176
  acc.asked.push({ at: Date.parse(obj.timestamp ?? 0) || 0, text: mine });
279
- // The first one never leaves. Everything after it is a window on the end.
177
+ // The first turn never leaves; the rest is a window on the end.
280
178
  if (acc.asked.length > KEEP_TURNS) acc.asked.splice(1, 1);
281
179
  }
282
- // ONE REPLY IS SEVERAL LINES AND THE CARD WANTS THE REPLY. Claude Code writes
283
- // an answer as a new line every time it stops to use a tool, so this used to
284
- // keep whichever block came last — routinely a "Done." under four paragraphs
285
- // that said what was actually done. The blocks of one reply are joined, and
286
- // the next thing to ARRIVE starts a new one.
180
+ // Claude Code writes one answer as a new line each time it stops for a tool,
181
+ // so the blocks of one reply are joined until the next thing arrives.
287
182
  if (arrived(obj)) acc.saidOpen = false;
288
183
  if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
289
184
  const said = obj.message.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n').trim();
290
185
  if (said) {
291
186
  const open = acc.saidOpen;
292
187
  acc.lastSaid = open ? `${acc.lastSaid}\n\n${said}` : said;
293
- // The END is what is kept when a reply runs long, because the end is
294
- // where an answer is. See `howItEnded` in shared/agents.mjs.
188
+ // A long reply keeps its end, because that is where the answer is. See
189
+ // `howItEnded` in shared/agents.mjs.
295
190
  if (acc.lastSaid.length > SAID_CHARS) acc.lastSaid = acc.lastSaid.slice(-SAID_CHARS);
296
191
  acc.saidOpen = true;
297
- // WHEN IT SAID IT, which is the fact the card was missing entirely: six
298
- // pings and six cards that looked the same, because the quote underneath
299
- // them carried no time and could not be told from the last one.
300
- //
301
- // WHEN IT BEGAN, not when it stopped, and the card's heading is why. A
302
- // reply that STARTED after her message is an answer to it; one that
303
- // started before and only finished after is not, and stamping the end
304
- // would draw it as one.
192
+ // Stamped when the reply began, not when it ended. A reply that started
193
+ // before the user's message is not an answer to it, even if it finished
194
+ // after.
305
195
  const at = Date.parse(obj.timestamp ?? 0) || 0;
306
196
  if (at && !open) acc.saidAt = at;
307
197
  }
@@ -310,8 +200,6 @@ function remember(acc, line) {
310
200
  const p = c.input?.file_path ?? c.input?.notebook_path;
311
201
  if (!p) continue;
312
202
  const leaf = path.basename(String(p));
313
- // Newest wins its place: a file touched again moves to the end rather
314
- // than keeping the position it had an hour ago.
315
203
  const at = acc.touched.indexOf(leaf);
316
204
  if (at >= 0) acc.touched.splice(at, 1);
317
205
  acc.touched.push(leaf);
@@ -320,10 +208,9 @@ function remember(acc, line) {
320
208
  }
321
209
  }
322
210
 
323
- // Read [from, size) and fold every WHOLE line into the accumulator, returning
324
- // the offset the next read should start at. A transcript is appended to while
325
- // this runs, so the last line in the buffer is routinely half a line; it is
326
- // left behind rather than parsed, and the next pass picks it up entire.
211
+ // Folds every whole line in [from, size) into the accumulator and returns the
212
+ // offset the next read starts at. The transcript is appended to while this
213
+ // runs, so a trailing half line is left for the next pass.
327
214
  function fold(file, acc, from, size) {
328
215
  let at = from;
329
216
  let rest = '';
@@ -346,8 +233,6 @@ function fold(file, acc, from, size) {
346
233
  }
347
234
  } catch { /* a partly-read transcript is still worth what it gave us */ }
348
235
  finally { try { fs.closeSync(fd); } catch { /* nothing to do */ } }
349
- // Whatever is after the last newline has not been counted, so the next read
350
- // starts there and sees that line whole.
351
236
  return at - Buffer.byteLength(rest);
352
237
  }
353
238
 
@@ -361,20 +246,17 @@ function readHistory(sessionId, cwd) {
361
246
  return { ...hit, transcript: file };
362
247
  }
363
248
 
364
- // Carry on from where the last read stopped, unless the file is not the same
365
- // file any more. A transcript that SHRANK was rotated or rewritten and the
366
- // old cursor points into the middle of a different conversation, so that
367
- // case starts over rather than folding two sessions into one card.
249
+ // Carry on from the last read unless the file shrank. A smaller file was
250
+ // rotated or rewritten, and the old offset points into a different
251
+ // conversation.
368
252
  const resume = hit && hit.file === file && stat.size >= hit.size;
369
253
  const acc = resume
370
254
  ? {
371
255
  lastActiveAt: hit.lastActiveAt,
372
256
  lastSaid: hit.lastSaid,
373
257
  saidAt: hit.saidAt ?? 0,
374
- // A RESUMED READ IS MID-REPLY UNTIL PROVEN OTHERWISE. The last line the
375
- // previous pass folded may well have been one block of an answer that is
376
- // still being written, so the reply stays open across the seam and the
377
- // next block joins it rather than replacing it.
258
+ // The last pass may have stopped mid-reply, so the reply stays open
259
+ // across the seam.
378
260
  saidOpen: hit.saidOpen ?? false,
379
261
  about: hit.about,
380
262
  lastAsked: hit.lastAsked,
@@ -389,54 +271,32 @@ function readHistory(sessionId, cwd) {
389
271
  return { ...entry, transcript: file };
390
272
  }
391
273
 
392
- // Exported for the test that drives this against a real transcript's shape.
393
- // The fields it reads are Claude Code's, not ours, so a rename upstream is a
394
- // card that quietly goes back to saying nothing, and only a test against real
395
- // jsonl notices (tests/one-inbox-no-agents-tab.test.mjs).
274
+ // Exported for tests/one-inbox-no-agents-tab.test.mjs. The fields it reads are
275
+ // Claude Code's, so an upstream rename would blank the card without an error.
396
276
  export { readHistory as __readTail, readHistory as __readHistory };
277
+ export const __transcriptSearch = transcriptSearch;
397
278
 
398
- /* --------------------------------- the list ------------------------------ */
399
- // NOTHING IN HERE MAY EVER BLOCK THE MAIN PROCESS. The list rides the snapshot,
400
- // the snapshot is answered on the main thread, and the main thread is also the
401
- // app: measured 2026-08-16, one `ps` costs 45ms and `claude agents --json`
402
- // costs 215ms, so reading this synchronously on every refetch would freeze the
403
- // window for a quarter of a second at a time, every couple of seconds, forever.
404
- //
405
- // So the read is asynchronous and `listAgents` is a pure lookup that returns
406
- // whatever was last measured and quietly asks for a fresh one when it is stale.
407
- // The first call after boot returns nothing and the one a moment later is
408
- // right, which is exactly the shape of every other live fact in this app.
279
+ // The list rides the snapshot, which the main process answers, and a `ps` plus
280
+ // `claude agents --json` take a quarter of a second. So `listAgents` returns the
281
+ // last reading and starts a fresh one in the background when it is stale. The
282
+ // first call after boot returns nothing.
409
283
  let cache = { at: 0, agents: [] };
410
284
  let inFlight = null;
411
- // HOW THE LIST WAS MEASURED LAST TIME, so that anything measuring again does it
412
- // the same way. This is not tidiness: `reply` re-read the list with default
413
- // options, which drops `claude agents --json` (no binary path), and the probe
414
- // on 2026-08-16 came back "That agent is no longer running" while it was
415
- // sitting there listening on its socket. Anything visible through only one of
416
- // the two sources was unreplyable.
285
+ // Remembered so a later `refresh()` with no options measures the same way.
286
+ // Without the binary path it skips `claude agents --json`, and a session only
287
+ // that source sees could not be replied to.
417
288
  let lastOpts = {};
418
289
  const TTL_MS = 4000;
419
290
 
420
- // Called when the measured list actually CHANGES, not on every refresh: the
421
- // renderer already refetches on a push, and pushing at the refresh rate would
422
- // re-render the window for a `lastActiveAt` that ticked.
291
+ // Called only when the list changes, so a ticking `lastActiveAt` does not
292
+ // re-render the window.
423
293
  export let onChange = null;
424
294
  export function setOnChange(fn) { onChange = fn; }
425
295
 
426
296
  function fingerprint(agents) {
427
- // `statusAt` is in here and `lastActiveAt` is deliberately not. The second is
428
- // a tick and pushing on it re-renders her window for nothing. The first moves
429
- // only when the session's status moves, and since a row's LIST depends on it:
430
- // a session that went busy and back to idle between two readings shows the
431
- // same `status` both times, and without this the row the user replied to would sit
432
- // in In progress until some other push happened to come. AND WHEN IT LAST
433
- // SAID SOMETHING, which is the fact this list existed to notice and did
434
- // not.An agent that answers her while she is looking at its card moves
435
- // `saidAt` and nothing else — the status was idle before the reply and is
436
- // idle after it, and on a quiet machine the two readings either side can both
437
- // catch it idle — so the row went on showing the answer to the message
438
- // before. This is not the tick `lastActiveAt` is: it moves only when the
439
- // session actually says something new.
297
+ // `lastActiveAt` is left out because it ticks. `statusAt` and `saidAt` are in,
298
+ // because a session can go busy and back to idle, or answer, between two
299
+ // readings that both see it idle, and the row still has to move.
440
300
  return agents.map((a) => `${a.pid}:${a.status ?? ''}:${a.statusAt ?? 0}:${a.waitingFor ?? ''}:${a.saidAt ?? 0}`).sort().join('|');
441
301
  }
442
302
 
@@ -448,8 +308,8 @@ export function listAgents(opts = {}) {
448
308
  export async function refresh(opts) {
449
309
  if (opts) lastOpts = opts;
450
310
  const { zeroPid = process.pid, sessionIds = [], products = [], claudeBin = null } = opts ?? lastOpts;
451
- // One measurement at a time. Without this a burst of pushes starts a `claude
452
- // agents --json` per push and they queue up behind each other.
311
+ // One measurement at a time, so a burst of pushes does not queue a
312
+ // `claude agents --json` each.
453
313
  if (inFlight) return inFlight;
454
314
  inFlight = measure({ zeroPid, sessionIds, products, claudeBin })
455
315
  .then((agents) => {
@@ -459,8 +319,8 @@ export async function refresh(opts) {
459
319
  return agents;
460
320
  })
461
321
  .catch(() => {
462
- // Her inbox is not allowed to go empty because a `ps` failed. The last
463
- // good reading stands, and the stamp moves so the next call retries.
322
+ // A failed `ps` keeps the last good reading. The stamp moves so the
323
+ // next call retries.
464
324
  cache = { ...cache, at: Date.now() };
465
325
  return cache.agents;
466
326
  })
@@ -473,7 +333,6 @@ async function measure({ zeroPid, sessionIds, products, claudeBin }) {
473
333
  const zeroSessionIds = new Set(sessionIds.filter(Boolean));
474
334
 
475
335
  // A product owns an agent when the agent is working in that product's repo.
476
- // all 13 outside agents were in folders no the app product points at.
477
336
  const owners = new Map();
478
337
  for (const p of products) {
479
338
  if (p?.repoPath) owners.set(path.resolve(p.repoPath), p);
@@ -509,25 +368,16 @@ async function measure({ zeroPid, sessionIds, products, claudeBin }) {
509
368
  cwd: rec.cwd ?? '',
510
369
  startedAt: rec.startedAt ?? 0,
511
370
  status: rec.status ?? null,
512
- // WHEN IT LAST SAID WHAT IT WAS DOING, which is the only way to tell a
513
- // stop that happened BEFORE she spoke to it from one that happened after.
514
- // The CLI publishes it beside `status` on every change (measured in
515
- // 2.1.234: the status is one of busy, shell, idle, waiting, and
516
- // `statusUpdatedAt` is stamped whenever it moves). Without it a reply
517
- // could never leave In progress, because the session is still idle for
518
- // the moment or two before it picks the message up.
371
+ // Claude Code stamps `statusUpdatedAt` whenever `status` moves. It tells a
372
+ // stop before the user's reply from one after, while the session is still
373
+ // idle for a moment before it picks the message up.
519
374
  statusAt: Number(rec.statusUpdatedAt) || 0,
520
375
  waitingFor: rec.waitingFor ?? null,
521
376
  socket: rec.messagingSocketPath ?? defaultSocket(rec.pid),
522
377
  lastActiveAt: lastActiveAt || rec.startedAt || 0,
523
378
  lastSaid,
524
- // WHEN IT LAST SAID SOMETHING, which is how the card can tell an answer
525
- // to her newest message from a sentence it left there two days ago.
526
- // Without it every reply reads as "It stopped here" and nothing more, so
527
- // six pings produced six cards she could not tell apart.
379
+ // Tells an answer to the newest message from an older one.
528
380
  saidAt,
529
- // What the session is about, what she last told it, and what it has been
530
- // touching. All three come free with the read above.
531
381
  about,
532
382
  lastAsked,
533
383
  touched,
@@ -536,7 +386,7 @@ async function measure({ zeroPid, sessionIds, products, claudeBin }) {
536
386
  transcript,
537
387
  product: owner?.slug ?? null,
538
388
  productName: owner?.name ?? null,
539
- // The dedupe key. Both halves of it, so it cannot drift as either changes.
389
+ // The dedupe key for the app's own workers.
540
390
  startedByZero: rec.ppid === zeroPid || (!!rec.sessionId && zeroSessionIds.has(rec.sessionId)),
541
391
  });
542
392
  }
@@ -548,52 +398,9 @@ function defaultSocket(pid) {
548
398
  return path.join(base, 'cc-socks', `${pid}.sock`);
549
399
  }
550
400
 
551
- /* ------------------------------ speaking to one -------------------------- */
552
- // THE ONE WRITE. Claude Code sessions listen on a Unix socket and take
553
- // newline-delimited JSON; the CLI prints the shape itself when it binds one
554
- // ("[uds-messaging] Inject messages { echo '{"type":"auth",...}'; echo
555
- // '{"type":"user","message":{"role":"user","content":"hello"}}'; } | socat").
556
- // An auth line is required only when the session published a key file; when it
557
- // did, the token is in ~/.claude/sessions/<pid>.<hash>.key.
558
- //
559
- // MEASURED, NOT REASONED, 2026-08-16: sent into a throwaway session spawned in
560
- // /tmp, the message arrived, was read as her turn, and the agent went back to
561
- // work. It was NOT held for approval, and NO line was added to
562
- // ~/.claude/settings.json. The `crossSessionInbound: "hold"` gate the research
563
- // found applies to messages that carry a peer identity and assert a permission
564
- // mode; a plain injected user turn does not, so the settings line this build
565
- // was told to ask about is not needed and was never written.
566
- //
567
- // TWO DIFFERENT FACTS, AND NEITHER IS OVERSTATED. The socket takes the message
568
- // and does not acknowledge it, so what `reply` can honestly report has two
569
- // levels and it reports whichever one it actually has:
570
- //
571
- // ok, no movement seen The message was handed to a live process that was
572
- // listening. That IS a fact and it is what "Sent"
573
- // means everywhere else in this app.
574
- // ok, movement seen The session was then observed going to work, which
575
- // is strictly better news and says so.
576
- //
577
- // A failed connect is neither, and says so loudly. What is never done is the
578
- // third thing: a green toast over a message that went nowhere. That is the failure
579
- // this codebase cares about most.
580
- //
581
- // The movement check is BEST EFFORT and is honestly not proven on a real
582
- // waiting agent: no throwaway session could be made to publish the state file
583
- // that carries `status`, so it was only ever exercised against sessions that
584
- // publish neither that nor a transcript. It is written to be a bonus, never a
585
- // gate, precisely because of that.
586
- // WHO IS ASKING, WHICH THE SESSION HAS NO OTHER WAY TO KNOW.
587
- //
588
- // Nothing here claims her authority.
589
- //
590
- // What it does say is the part the wrapper cannot: WHERE the message came from
591
- // and WHAT it is about. MEASURED on a session that had given no response
592
- // without it; one sentence of context is the whole of the difference.
593
- //
594
- // THE USER'S OWN TEXT IS UNCHANGED AND IS WHAT THE CARD QUOTES: `spoke` records
595
- // what the user typed, never this, so nothing the user never wrote is ever drawn
596
- // as their words.
401
+ // Tells the session where the message came from and what it is about, which it
402
+ // has no other way to know; a session given the bare message did not answer.
403
+ // `spoke` records only what the user typed, so the card never quotes this.
597
404
  export function fromTheApp(message) {
598
405
  return [
599
406
  `${RELAY_MARK} the inbox your user reads. A person typed this, not an agent,`,
@@ -606,6 +413,15 @@ export function fromTheApp(message) {
606
413
  ].join('\n');
607
414
  }
608
415
 
416
+ // The one write. A Claude Code session listens on a Unix socket for
417
+ // newline-delimited JSON, in the shape the CLI prints when it binds one. An
418
+ // auth line is needed only when the session published a key file,
419
+ // ~/.claude/sessions/<pid>.<hash>.key. A plain injected user turn needs no
420
+ // change to ~/.claude/settings.json.
421
+ //
422
+ // The socket does not acknowledge, so a result of ok means the message was
423
+ // handed to a live, listening process, and `working` says the session was then
424
+ // seen going to work. The movement check is best effort and never a gate.
609
425
  export async function reply(pid, text, { timeoutMs = 3000 } = {}) {
610
426
  const message = String(text ?? '').trim();
611
427
  if (!message) return { ok: false, reason: 'There was nothing to send.' };
@@ -618,9 +434,9 @@ export async function reply(pid, text, { timeoutMs = 3000 } = {}) {
618
434
 
619
435
  const before = await watermark(agent);
620
436
  try {
621
- // What goes on the wire says who is asking; what is matched and recorded
622
- // below stays the user's words, so `heldNotice` cannot start matching every
623
- // message by its shared opening and the card cannot quote a preamble.
437
+ // The wire carries the wrapped message. Matching and recording below use
438
+ // the user's own words, so `heldNotice` does not match on the shared
439
+ // preamble.
624
440
  await writeToSocket(agent.socket, fromTheApp(message), tokenFor(pid));
625
441
  } catch (err) {
626
442
  return { ok: false, reason: reasonFor(err) };
@@ -628,23 +444,13 @@ export async function reply(pid, text, { timeoutMs = 3000 } = {}) {
628
444
 
629
445
  const moved = await waitForMovement(agent, before, timeoutMs);
630
446
 
631
- // A THIRD ANSWER, AND IT IS A FAILURE: THE SESSION HELD IT AND HAS NOT READ
632
- // IT. Claude Code parks an incoming socket message for the session's own user
633
- // to approve when the session bypasses permission prompts and the sender did
634
- // not attest a matching permission mode, which Agentbox cannot do. MEASURED
635
- // 2026-08-17 against a session started with --permission-mode
636
- // bypassPermissions: the message never became a turn, the agent never saw it,
637
- // and this function returned ok, delivered and working, because the session
638
- // burned CPU drawing the warning. That is the green toast over a message that
639
- // went nowhere this file says it will never write, so it is checked for.
640
- //
641
- // It is BEST EFFORT in one direction only: a session with no transcript
642
- // cannot be seen holding anything, so a hold that is not found is not a
643
- // promise there was none. A hold that IS found is a fact and outranks
644
- // everything above it.
645
- // Looked for a few times rather than once: `waitForMovement` returns the
646
- // instant it sees CPU, which on a held message is the session drawing the
647
- // warning, so the line saying so is often not on disk yet.
447
+ // Claude Code holds an incoming socket message for its own user to approve
448
+ // when the session bypasses permission prompts and the sender did not attest
449
+ // a matching permission mode, which Agentbox cannot do. The held session
450
+ // still burns CPU drawing the warning, so it looks like movement. A hold that
451
+ // is found outranks everything above; one that is not found proves nothing.
452
+ // Looked for a few times, because `waitForMovement` returns at the first CPU
453
+ // and the hold line may not be on disk yet.
648
454
  let held = null;
649
455
  for (let i = 0; i < 6 && !held; i += 1) {
650
456
  held = heldNotice(agent, before.size, message);
@@ -655,10 +461,8 @@ export async function reply(pid, text, { timeoutMs = 3000 } = {}) {
655
461
  return { ok: false, held: true, reason: held };
656
462
  }
657
463
  cache = { ...cache, at: 0 }; // the agent has moved; the next look re-measures
658
- // The KEY goes back with the answer so the caller can record that she spoke,
659
- // and it is worked out HERE because this is where the agent record is. A
660
- // caller re-deriving it from a pid would be using the one thing that is not an
661
- // identity (`agentKey`, shared/agents.mjs).
464
+ // The key is worked out here, where the agent record is, because a pid alone
465
+ // is not an identity (`agentKey`, shared/agents.mjs).
662
466
  return { ok: true, delivered: true, working: moved, name: agent.name, key: agentKey(agent), at: Date.now() };
663
467
  }
664
468
 
@@ -681,8 +485,8 @@ function writeToSocket(socketPath, message, token) {
681
485
  sock.on('connect', () => {
682
486
  if (token) sock.write(`${JSON.stringify({ type: 'auth', token })}\n`);
683
487
  sock.write(`${JSON.stringify({ type: 'user', message: { role: 'user', content: message } })}\n`, () => {
684
- // Half-close: the session reads to end of line, and an abrupt destroy
685
- // has been seen to drop the final frame.
488
+ // Half-close, because an abrupt destroy has been seen to drop the final
489
+ // frame.
686
490
  sock.end();
687
491
  resolve();
688
492
  });
@@ -698,18 +502,10 @@ function reasonFor(err) {
698
502
  return 'The message could not be handed over.';
699
503
  }
700
504
 
701
- // THREE WAYS TO SEE AN AGENT MOVE, because no single one of them is always
702
- // there. The transcript grows when the session writes the new turn (absent when
703
- // the session has transcript saving off); the state file's status leaves
704
- // `waiting` (absent when a session never published one); and the process starts
705
- // burning CPU, which is true of every session that took a turn and needs
706
- // nothing published anywhere.
707
- //
708
- // The CPU signal was added after the probe on 2026-08-16: a throwaway session
709
- // with no transcript and no state file took the message, answered it on screen,
710
- // and `reply` still had to report that it could not see it move. `ps -o time`
711
- // resolves to a hundredth of a second and an idle session drifts about 0.02s
712
- // in three, so a third of a second of new CPU is a session that went to work.
505
+ // Three signs of movement, because none is always there: the transcript grows,
506
+ // the state file's status changes, or the process burns CPU. CPU needs nothing
507
+ // published. `ps -o time` resolves to a hundredth of a second and an idle
508
+ // session drifts about 0.02 s in three, so this is well clear of idle.
713
509
  const CPU_MOVED = 0.15;
714
510
 
715
511
  async function cpuSeconds(pid) {
@@ -751,16 +547,14 @@ async function waitForMovement(agent, before, timeoutMs) {
751
547
  return false;
752
548
  }
753
549
 
754
- // THE SESSION SAYS SO ITSELF, in the lines written after the send: a `system`
755
- // line whose content opens "Held peer message" and quotes the message back.
756
- // Only the bytes appended since the watermark are read, because the biggest
757
- // transcript on her machine is 160MB and this runs on a click.
550
+ // A held session writes a `system` line that opens "Held peer message" and
551
+ // quotes the message back. Only bytes appended since the watermark are read,
552
+ // because a transcript can be over 100 MB and this runs on a click.
758
553
  const HOLD_LINE = /Held peer message/;
759
554
 
760
555
  function heldNotice(agent, sinceSize, message) {
761
- // Resolved rather than taken off the record: a session that had not written a
762
- // transcript when it was last measured has one by the time it holds a
763
- // message, and that is exactly the session this is for.
556
+ // Resolved again, because a session may have written its first transcript
557
+ // since it was last measured.
764
558
  const file = agent.transcript
765
559
  ?? (agent.sessionId ? transcriptPath(agent.sessionId, agent.cwd ?? '') : null);
766
560
  if (!file) return null;
@@ -784,15 +578,12 @@ function heldNotice(agent, sinceSize, message) {
784
578
  const content = String(obj?.content ?? '');
785
579
  if (!HOLD_LINE.test(content)) continue;
786
580
  if (opening && !content.includes(opening.slice(0, 40))) continue;
787
- // Said in plain words, not the CLI's. What the user can DO about it is the point:
788
- // the message is sitting in that session's own window waiting for a yes.
789
581
  return `${agent.name} held your message for approval in its own window, so it has not read it yet.`;
790
582
  }
791
583
  return null;
792
584
  }
793
585
 
794
- // Exported for the test that pins the hold, under a name that says it is not
795
- // part of the surface: what `reply` returns is.
586
+ // Exported for tests/a-held-message-is-not-delivered.test.mjs.
796
587
  export const __heldNotice = heldNotice;
797
588
 
798
589
  function readState(pid) {
@@ -801,17 +592,11 @@ function readState(pid) {
801
592
  } catch { return null; }
802
593
  }
803
594
 
804
- /* ----------------------------- taking her to it -------------------------- */
805
- // A REPLY CANNOT CLEAR A PERMISSION BOX, so that agent needs her where it is
806
- // running, and the honest version of "take me there" is smaller than it sounds.
807
- // All 13 of her outside agents are panes inside another app (CNVS), and there
808
- // is no public command that focuses one: `claude` in this version has no
809
- // `attach` and no `logs`, whatever the earlier write-up said. What CAN be done
810
- // is bring the app that owns the process to the front, so this walks the parent
811
- // chain to the first real application and offers that by name. If it finds
812
- // none, it says so rather than pretending.
813
- export function owningApp(pid) {
814
- const table = processTable();
595
+ // A reply cannot clear a permission box, so that agent needs the user at its
596
+ // own window. Claude Code has no command to focus a session, so this walks the
597
+ // parent chain to the first macOS .app and brings that to the front.
598
+ export async function owningApp(pid) {
599
+ const table = await processTable();
815
600
  let cur = table.get(pid);
816
601
  for (let hops = 0; cur && hops < 12; hops += 1) {
817
602
  const m = cur.command.match(/^(\/(?:Applications|System\/Applications)\/[^/]*?\.app)\//);
@@ -821,8 +606,8 @@ export function owningApp(pid) {
821
606
  return null;
822
607
  }
823
608
 
824
- export function reveal(pid) {
825
- const app = owningApp(pid);
609
+ export async function reveal(pid) {
610
+ const app = await owningApp(pid);
826
611
  if (!app) return { ok: false, reason: `${Name} cannot tell which window it is running in.` };
827
612
  return new Promise((resolve) => {
828
613
  execFile('/usr/bin/open', ['-a', app.path], (err) => {
@@ -831,29 +616,16 @@ export function reveal(pid) {
831
616
  });
832
617
  }
833
618
 
834
- /* ------------------------------ THE CONVERSATION -------------------------- */
835
- // What the rest of this file reads on a draw is the four facts a card needs;
836
- // this reads what the two of them actually SAID, and only ever because she
837
- // pressed something.
838
- //
839
- // IT IS STREAMED, AND THAT IS NOT A FLOURISH. The biggest transcript on her
840
- // machine is 160MB and costs 729ms to pull the readable text out of (measured
841
- // 2026-08-17 over all 1,250 files). Doing that with readFileSync on the main
842
- // process is the window frozen for three quarters of a second on a click, on
843
- // the one surface built to make her trust the app. readline hands the lines
844
- // back a chunk at a time and the app draws in between.
845
- //
846
- // NO MODEL SEES ANY OF IT, which is a condition of the whole feature, and
847
- // nothing is written anywhere: this opens a file the user already has and reads it.
619
+ // The conversation: what both sides said, read only when the user asks for it.
620
+ // It is streamed because a transcript can be over 100 MB, and reading that
621
+ // whole on the main process freezes the window. No model sees any of it.
848
622
 
849
- // One turn is capped here rather than where it is drawn, so a pasted 600KB
850
- // blob never becomes 600KB of React. It is far more than anybody reads and far
851
- // less than a session can hold.
623
+ // A turn is capped here rather than where it is drawn, so a pasted 600 KB blob
624
+ // never reaches React.
852
625
  const TURN_CHARS = 4000;
853
626
 
854
- // A session id is used as a FILENAME, so it is checked before it is one. The
855
- // renderer is ours, but a path with a slash in it reaching this would be a read
856
- // outside ~/.claude/projects and there is no reason to leave that open.
627
+ // A session id becomes a filename, so it is checked first: a slash in it would
628
+ // read outside ~/.claude/projects.
857
629
  function safeSessionId(id) {
858
630
  return /^[A-Za-z0-9._-]+$/.test(String(id ?? '')) ? String(id) : null;
859
631
  }
@@ -863,45 +635,30 @@ function saidByIt(obj) {
863
635
  return obj.message.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n').trim();
864
636
  }
865
637
 
866
- /* --------------------------- THE WORK IN BETWEEN -------------------------- */
867
- // WHAT IT DID lives in the assistant line; WHAT CAME BACK arrives later, in the
868
- // user line that answers it, keyed by the tool's id. So the events are built
869
- // forwards and their output is filled in behind them, which is why `byId` is
870
- // held across the read.
871
-
872
- // Output keeps its HEAD, unlike a turn, which keeps its tail. An answer puts
873
- // its conclusion at the end; a command puts the thing that went wrong at the
874
- // start, and `ls` of a big folder is alphabetical. The count of what was cut
875
- // goes on the line so nothing is rounded off in silence.
638
+ // Tool output keeps its head, unlike a turn, which keeps its tail: a command
639
+ // puts what went wrong at the start.
876
640
  function cappedHead(text) {
877
641
  const s = String(text ?? '');
878
642
  return s.length > WORK_CHARS ? `${s.slice(0, WORK_CHARS - 1)}…` : s;
879
643
  }
880
644
 
881
- // `dir` IS THE FOLDER THIS SESSION RUNS IN, and it is what turns
882
- // `/Users/you/Desktop/dev/zero/renderer/src/components/Tweaks.tsx` into
883
- // `renderer/src/components/Tweaks.tsx` on her screen.
884
- //
885
- // `full` IS THE WHOLE STRING, and it only travels when shortening actually
886
- // changed something. Her standing objection to every cap in this codebase is
887
- // one that rounds off without saying so, and the fix for a line she cannot read
888
- // cannot be a line she cannot recover.
645
+ // `dir` is the session's folder, used to shorten paths on screen. `full`
646
+ // carries the whole string only when shortening changed it, so a shortened line
647
+ // can always be recovered.
889
648
  function workedOn(obj, events, byId, dir = '') {
890
649
  if (obj.type !== 'assistant' || !Array.isArray(obj.message?.content)) return;
891
650
  const at = Date.parse(obj.timestamp ?? 0) || 0;
892
651
  for (const c of obj.message.content) {
893
652
  if (c.type !== 'tool_use') continue;
894
- // The store's own records are how a session talks to the app, not work it
895
- // did, and a thread that opens on them reads as noise (w-0bd0d8b2ef).
653
+ // Store tool calls are how a session talks to the app, not work it did.
896
654
  if (isBookkeeping(c.name)) continue;
897
- // WHAT A COMMAND DID, IN WORDS, when the whole of it is recognised; null on
898
- // anything else, and the line is then the command it always was.
655
+ // A command in plain words when the whole of it is recognised; otherwise
656
+ // null, and the line shows the command.
899
657
  const plain = c.name === 'Bash' ? commandWork(c.input?.command, dir, os.homedir()) : null;
900
658
  const subject = plain ? plain.subject : workSubject(c.input, dir, os.homedir());
901
659
  const whole = fullSubject(c.input);
902
- // THE FILE IT CHANGED, WHOLE, so the thread can offer it as a chip into the
903
- // code. Empty on everything that did not write a file, which is most calls:
904
- // a command, a search, a read.
660
+ // The file it changed, so the thread can offer a chip into the code. Empty
661
+ // for calls that wrote no file.
905
662
  const changed = changedFile(c.name, c.input);
906
663
  const event = {
907
664
  kind: 'work',
@@ -909,9 +666,8 @@ function workedOn(obj, events, byId, dir = '') {
909
666
  verb: plain ? plain.verb : workVerb(c.name),
910
667
  subject,
911
668
  ...(changed ? { file: changed } : {}),
912
- // A line put in words always carries the command, even when the words name
913
- // nothing ("Checked what changed"): the words replaced the only copy of it
914
- // on the screen, so it has to stay one press behind them.
669
+ // A line put in words always carries the command, because the words
670
+ // replaced the only copy of it on screen.
915
671
  full: plain || (whole && whole.replace(/\s+/g, ' ').trim() !== subject) ? cappedHead(whole) : '',
916
672
  output: '',
917
673
  lines: 0,
@@ -936,41 +692,20 @@ function cameBack(obj, byId) {
936
692
  const all = String(text ?? '').replace(/\s+$/, '');
937
693
  event.output = cappedHead(all);
938
694
  event.lines = all ? all.split('\n').length : 0;
939
- // THE ONLY COLOUR ON THE WHOLE SURFACE, and it is the tool's own verdict
940
- // rather than ours: nothing here reads the output looking for the word
941
- // error, because "0 errors" is not a failure and guessing at one on her
942
- // screen is worse than saying nothing.
695
+ // The tool's own verdict. The output is never searched for "error",
696
+ // because "0 errors" is not a failure.
943
697
  event.failed = b.is_error === true;
944
698
  }
945
699
  }
946
700
 
947
- // CONSECUTIVE LINES FROM THE SAME SIDE ARE ONE TURN. Claude Code writes an
948
- // assistant's answer as several lines when it stops to use a tool half way
949
- // through, so a single reply arrives here as three. Folding them keeps the
950
- // count honest: "40 messages" should mean forty things somebody said, not forty
951
- // rows in a file.
952
- //
953
- // UNTIL SOMETHING ARRIVES. Folding on the SIDE alone folded every answer a
954
- // session ever gave into one bubble, because the things that end an answer —
955
- // her message from Agentbox, a peer's, a delivery notice — are all dropped from
956
- // the reading and so could not end anything. `seal` is that missing edge, and
957
- // `arrived` above is what calls it.
958
- //
959
- // A TURN IS STAMPED WHEN IT BEGAN, not when it ended, and that is the other
960
- // half of the same bug. The old code moved the stamp forward on every fold
961
- // while the text she reads is the head, so session-46's last bubble said 2:48pm
962
- // today over a sentence from 7:47pm two days earlier and she took it for the
963
- // answer to what she had just asked. The time and the first line she sees now
964
- // come from the same moment, and cannot drift apart again. The ellipsis is
965
- // INSIDE the cap, not on top of it: this is a bound on what the screen is
966
- // handed, and a bound that can be exceeded by one is not one.
701
+ // The ellipsis is inside the cap, so the result is never longer than
702
+ // TURN_CHARS.
967
703
  function capped(text) {
968
704
  return text.length > TURN_CHARS ? `…${text.slice(-(TURN_CHARS - 1))}` : text;
969
705
  }
970
706
 
971
- // The last thing anybody SAID, looking back past however many things it ran in
972
- // between. Shape B puts work lines in the thread, so the block above a block is
973
- // routinely a command rather than a sentence.
707
+ // The last thing anybody said, skipping the work lines that sit between
708
+ // messages in the thread.
974
709
  function lastSpoken(turns) {
975
710
  for (let i = turns.length - 1; i >= 0; i -= 1) {
976
711
  if (turns[i].kind !== 'work') return turns[i];
@@ -981,44 +716,34 @@ function lastSpoken(turns) {
981
716
  function seal(turns) {
982
717
  const last = turns[turns.length - 1];
983
718
  if (last) last.sealed = true;
984
- // AND THE LAST THING SAID, which is not the last thing in the list once work
985
- // lines are in the thread. Without this, a reminder arriving between a tool
986
- // call and the next sentence would seal the tool call, leave the sentence
987
- // above it open, and the two halves of two different answers would be counted
988
- // as one message.
719
+ // Also seal the last spoken turn, which is not the last entry once work lines
720
+ // are in the thread. Otherwise two different answers would count as one.
989
721
  const spoken = lastSpoken(turns);
990
722
  if (spoken) spoken.sealed = true;
991
723
  }
992
724
 
725
+ // Consecutive lines from the same side are one turn, because Claude Code splits
726
+ // one answer into several lines when it stops for a tool. A sealed turn takes
727
+ // nothing more, so separate answers never fold together. A turn keeps the time
728
+ // it began, so its time and its first line come from the same moment.
993
729
  function addTurn(turns, who, text, at) {
994
730
  const last = turns[turns.length - 1];
995
731
  if (last && !last.sealed && last.who === who) {
996
- // WHAT OVERFLOWS IS THE OLDEST, NEVER THE NEWEST. This used to cut from the
997
- // front, so the longer an answer ran the more surely its conclusion was the
998
- // part thrown away. Measured on session-46: a bubble sat at exactly 4000
999
- // characters with everything after the first 4000 gone.
732
+ // Overflow drops the oldest text, so a long answer keeps its conclusion.
1000
733
  last.text = capped(`${last.text}\n\n${text}`);
1001
734
  return;
1002
735
  }
1003
- // A CONTINUATION, NOT A NEW MESSAGE. The work between these two blocks is why
1004
- // they are two blocks, and the thread draws them in that order because that
1005
- // is what happened. But it is still one reply: the count says so, and the
1006
- // screen does not put a second name and time over the second half of a
1007
- // sentence somebody was in the middle of.
736
+ // A continuation, not a new message: work between two blocks of one reply
737
+ // splits them on screen, but the count and the heading treat them as one.
1008
738
  const spoken = lastSpoken(turns);
1009
739
  const same = !!spoken && !spoken.sealed && spoken.who === who && turns[turns.length - 1] !== spoken;
1010
740
  turns.push(same ? { who, at, text: capped(text), same: true } : { who, at, text: capped(text) });
1011
741
  }
1012
742
 
1013
- // THE ROW ALREADY KNOWS WHICH SESSION IT IS, so this does not re-measure the
1014
- // machine to find out. That is not a saving of milliseconds: `refresh` runs a
1015
- // `ps` over every process on the machine, and doing it on a click would put a
1016
- // third of a second between her pressing the line and anything happening. The
1017
- // pid is only walked back to a live session when the caller has no session id,
1018
- // which is the case a dead session leaves behind. `whole` is the gap line in
1019
- // the thread being pressed: she is asking for the middle of the conversation
1020
- // back, so every message is sent and `omitted` comes back zero. Only the work
1021
- // stays capped.
743
+ // Uses the session id the row already has, because `refresh` runs `ps` over
744
+ // the whole machine and that is a visible delay on a click. The pid is walked
745
+ // back to a live session only when there is no session id. `whole` asks for
746
+ // every message, so `omitted` comes back zero; only the work stays capped.
1022
747
  export async function conversation({ pid, sessionId, cwd, spoke = [], whole = false } = {}) {
1023
748
  let id = safeSessionId(sessionId);
1024
749
  let dir = cwd ?? '';
@@ -1035,9 +760,8 @@ export async function conversation({ pid, sessionId, cwd, spoke = [], whole = fa
1035
760
  if (!file) return { ok: false, reason: 'This session has not written a transcript yet.' };
1036
761
 
1037
762
  const turns = [];
1038
- // WHAT IT RAN, KEYED BY ITS ID, because the tool call and the thing that came
1039
- // back from it are two lines apart in the file and the second one is the only
1040
- // place the output exists.
763
+ // Tool calls by id. A call is in an assistant line and its output arrives
764
+ // later in a user line, so the events are filled in behind.
1041
765
  const byId = new Map();
1042
766
  try {
1043
767
  const lines = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
@@ -1046,9 +770,7 @@ export async function conversation({ pid, sessionId, cwd, spoke = [], whole = fa
1046
770
  let obj;
1047
771
  try { obj = JSON.parse(line); } catch { continue; }
1048
772
  const at = Date.parse(obj.timestamp ?? 0) || 0;
1049
- // Anything inbound closes whatever was being said, whether or not it is
1050
- // shown: a message Agentbox delivered, a peer's, a delivery notice. None of
1051
- // those are quoted here and all of them mean the answer above is over.
773
+ // Anything inbound ends the answer above it, shown or not.
1052
774
  if (arrived(obj)) seal(turns);
1053
775
  cameBack(obj, byId);
1054
776
  const hook = hookFailure(obj);
@@ -1062,23 +784,16 @@ export async function conversation({ pid, sessionId, cwd, spoke = [], whole = fa
1062
784
  if (mine) { addTurn(turns, 'you', mine, at); seal(turns); continue; }
1063
785
  const its = saidByIt(obj);
1064
786
  if (its) addTurn(turns, 'it', its, at);
1065
- // THE WORK GOES IN THE THREAD, IN THE ORDER IT HAPPENED, and it ends the
1066
- // message above it. The chosen shape says each thing it ran sits BETWEEN the
1067
- // messages, so a reply that stopped twice for a tool is three things on
1068
- // the screen in the order they occurred rather than one bubble with the
1069
- // work swept behind it. This is the one place the fold is deliberately
1070
- // broken, and breaking it is the shape that was chosen.
787
+ // Work goes in the thread in the order it happened, between messages.
788
+ // This is the one place the fold is broken on purpose.
1071
789
  workedOn(obj, turns, byId, dir);
1072
790
  }
1073
791
  } catch {
1074
- // A half-read transcript is still worth what it gave us: a conversation
1075
- // missing its last line beats an empty screen and a shrug.
792
+ // A half-read transcript still shows what it gave.
1076
793
  }
1077
794
 
1078
- // HER REPLIES FROM AGENTBOX ARE NOT IN THE FILE AS HERS. What IS hers is what
1079
- // Agentbox itself delivered, which the caller passes in from the record
1080
- // written at the moment of delivery. Slotted by time, so the thread still
1081
- // reads in the order it happened.
795
+ // Replies sent from Agentbox are not in the file as the user's. The caller
796
+ // passes what Agentbox delivered, and each is slotted in by time.
1082
797
  const said = new Set(turns.filter((t) => t.who === 'you').map((t) => t.text.replace(/\s+/g, ' ').trim()));
1083
798
  for (const t of Array.isArray(spoke) ? spoke : []) {
1084
799
  const text = String(t?.text ?? '').trim();
@@ -1089,13 +804,11 @@ export async function conversation({ pid, sessionId, cwd, spoke = [], whole = fa
1089
804
  turns.splice(i, 0, { who: 'you', at, text: text.slice(0, TURN_CHARS) });
1090
805
  }
1091
806
 
1092
- // The seal is scaffolding for the fold above and not a fact about a turn, so
1093
- // it does not go over the wire pretending to be one.
807
+ // `sealed` is scaffolding for the fold, not a fact about a turn.
1094
808
  for (const t of turns) delete t.sealed;
1095
809
 
1096
810
  const { events: shown, omitted } = threadWindow(turns, { whole: !!whole });
1097
- // `total` counts what somebody SAID, not what is on the screen. The header
1098
- // reads "40 messages, oldest first", and a run of tool calls is not messages.
811
+ // `total` counts what somebody said; tool calls are not messages.
1099
812
  const total = saidCount(turns);
1100
813
  return { ok: true, name, turns: shown, omitted, total };
1101
814
  }