agentbox-app 0.1.6

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 (302) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +176 -0
  3. package/bin/agentbox.mjs +103 -0
  4. package/briefs/adhd-mode.md +5 -0
  5. package/briefs/message-rules.md +95 -0
  6. package/briefs/worker.md +230 -0
  7. package/briefs/writing-rules.md +336 -0
  8. package/main/account-discovery.mjs +145 -0
  9. package/main/account-tooling.mjs +261 -0
  10. package/main/agent-activity.mjs +56 -0
  11. package/main/agent-files.mjs +422 -0
  12. package/main/agent-schedule.mjs +254 -0
  13. package/main/agent-sessions.mjs +435 -0
  14. package/main/agents.mjs +1095 -0
  15. package/main/analytics.mjs +206 -0
  16. package/main/answer-modes.mjs +206 -0
  17. package/main/approval-prompt-server.mjs +160 -0
  18. package/main/approvals.mjs +487 -0
  19. package/main/artifact-path.mjs +144 -0
  20. package/main/claude-bin.mjs +311 -0
  21. package/main/claude-input.mjs +117 -0
  22. package/main/claude-models.mjs +244 -0
  23. package/main/claude-plan.mjs +148 -0
  24. package/main/claude-usage.mjs +149 -0
  25. package/main/code-change.mjs +383 -0
  26. package/main/code-file.mjs +116 -0
  27. package/main/codex-account.mjs +146 -0
  28. package/main/codex-app-server.mjs +796 -0
  29. package/main/codex-approvals.mjs +712 -0
  30. package/main/codex-bin.mjs +261 -0
  31. package/main/codex-compaction.mjs +49 -0
  32. package/main/codex-models.mjs +194 -0
  33. package/main/codex-session.mjs +797 -0
  34. package/main/codex-threads.mjs +268 -0
  35. package/main/codex-usage-file.mjs +192 -0
  36. package/main/codex-usage.mjs +149 -0
  37. package/main/codex-watch.mjs +35 -0
  38. package/main/codex.mjs +757 -0
  39. package/main/config.mjs +345 -0
  40. package/main/copy-selection.mjs +55 -0
  41. package/main/crash-report.mjs +158 -0
  42. package/main/demo.mjs +254 -0
  43. package/main/dev-window.mjs +103 -0
  44. package/main/doc-file.mjs +99 -0
  45. package/main/doc-scheme.mjs +126 -0
  46. package/main/first-run.mjs +190 -0
  47. package/main/folders.mjs +91 -0
  48. package/main/fresh-user.mjs +120 -0
  49. package/main/git-change.mjs +323 -0
  50. package/main/img-scheme.mjs +90 -0
  51. package/main/instruction-settings.mjs +205 -0
  52. package/main/ipc.mjs +1558 -0
  53. package/main/live-replies.mjs +77 -0
  54. package/main/machine.mjs +113 -0
  55. package/main/main.mjs +972 -0
  56. package/main/menu.mjs +177 -0
  57. package/main/notify.mjs +155 -0
  58. package/main/open-source-file.mjs +8 -0
  59. package/main/project-identity.mjs +149 -0
  60. package/main/provider-commands.mjs +32 -0
  61. package/main/rail-note.mjs +74 -0
  62. package/main/repeats.mjs +261 -0
  63. package/main/row-label.mjs +185 -0
  64. package/main/screen-detail.mjs +44 -0
  65. package/main/serve.mjs +262 -0
  66. package/main/settings.mjs +912 -0
  67. package/main/staleness.mjs +76 -0
  68. package/main/store/chats.mjs +188 -0
  69. package/main/store/home.mjs +383 -0
  70. package/main/store/migrate.mjs +40 -0
  71. package/main/store/paths.mjs +82 -0
  72. package/main/store/pending-writes.mjs +122 -0
  73. package/main/store/project-lock.mjs +200 -0
  74. package/main/store/project.mjs +523 -0
  75. package/main/store/queues.mjs +31 -0
  76. package/main/store/work-items.mjs +299 -0
  77. package/main/store/write-queue.mjs +14 -0
  78. package/main/store-modules.mjs +23 -0
  79. package/main/store.mjs +1269 -0
  80. package/main/supervisor.mjs +6875 -0
  81. package/main/task-commands.mjs +161 -0
  82. package/main/task-folders-offthread.mjs +84 -0
  83. package/main/task-folders-thread.mjs +13 -0
  84. package/main/task-folders.mjs +409 -0
  85. package/main/task-remote-control.mjs +65 -0
  86. package/main/task-terminals.mjs +169 -0
  87. package/main/terminal-access.mjs +6 -0
  88. package/main/updater.mjs +240 -0
  89. package/main/zoom-keys.mjs +54 -0
  90. package/main/zoom-readout.mjs +20 -0
  91. package/mcp/agentbox-mcp.sh +34 -0
  92. package/mcp/core/account.mjs +77 -0
  93. package/mcp/core/chat.mjs +61 -0
  94. package/mcp/core/documents.mjs +238 -0
  95. package/mcp/core/page.mjs +152 -0
  96. package/mcp/core/products.mjs +110 -0
  97. package/mcp/core/skills.mjs +126 -0
  98. package/mcp/core/work.mjs +377 -0
  99. package/mcp/stdio.mjs +70 -0
  100. package/mcp/tools.mjs +257 -0
  101. package/package.json +186 -0
  102. package/renderer/dist/assets/Geist-Variable-Bj2R_7yk.woff2 +0 -0
  103. package/renderer/dist/assets/GeistMono-Variable-Dispecij.woff2 +0 -0
  104. package/renderer/dist/assets/addon-fit-YJmn1quW.js +12 -0
  105. package/renderer/dist/assets/addon-serialize-CsFQTvP_.js +13 -0
  106. package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
  107. package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
  108. package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
  109. package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
  110. package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
  111. package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
  112. package/renderer/dist/assets/gouache-valley-ChSu14-t.webp +0 -0
  113. package/renderer/dist/assets/gouache-valley-d9qasI6k.webp +0 -0
  114. package/renderer/dist/assets/gouache-valley-sm-CdLjT5Ec.webp +0 -0
  115. package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
  116. package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
  117. package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
  118. package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
  119. package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
  120. package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
  121. package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
  122. package/renderer/dist/assets/index-CwiDv5bL.css +32 -0
  123. package/renderer/dist/assets/index-D9hCAVPl.js +684 -0
  124. package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
  125. package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
  126. package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
  127. package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
  128. package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
  129. package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
  130. package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
  131. package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
  132. package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
  133. package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
  134. package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
  135. package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
  136. package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
  137. package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
  138. package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
  139. package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
  140. package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
  141. package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
  142. package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
  143. package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
  144. package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
  145. package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
  146. package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
  147. package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
  148. package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
  149. package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
  150. package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
  151. package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
  152. package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
  153. package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
  154. package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
  155. package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
  156. package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
  157. package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
  158. package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
  159. package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
  160. package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
  161. package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
  162. package/renderer/dist/assets/source-sans-3-cyrillic-ext-wght-normal-DzyfIafT.woff2 +0 -0
  163. package/renderer/dist/assets/source-sans-3-cyrillic-wght-normal-BMDVbyM7.woff2 +0 -0
  164. package/renderer/dist/assets/source-sans-3-greek-ext-wght-normal-BWSLJLk6.woff2 +0 -0
  165. package/renderer/dist/assets/source-sans-3-greek-wght-normal-C9H9m1vD.woff2 +0 -0
  166. package/renderer/dist/assets/source-sans-3-latin-ext-wght-normal-C8iNium2.woff2 +0 -0
  167. package/renderer/dist/assets/source-sans-3-latin-wght-normal-BqRLTx4X.woff2 +0 -0
  168. package/renderer/dist/assets/source-sans-3-vietnamese-wght-normal-C1uRvKPU.woff2 +0 -0
  169. package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
  170. package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
  171. package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
  172. package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
  173. package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
  174. package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
  175. package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
  176. package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
  177. package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
  178. package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
  179. package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
  180. package/renderer/dist/assets/xterm-BT7tBf8A.js +27 -0
  181. package/renderer/dist/emulator/audio-rules.mjs +74 -0
  182. package/renderer/dist/emulator/data/compression/README.md +11 -0
  183. package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
  184. package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
  185. package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
  186. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  187. package/renderer/dist/emulator/data/cores/README.md +22 -0
  188. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  189. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  190. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  191. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  192. package/renderer/dist/emulator/data/cores/package.json +65 -0
  193. package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
  194. package/renderer/dist/emulator/data/emulator.css +1614 -0
  195. package/renderer/dist/emulator/data/loader.js +170 -0
  196. package/renderer/dist/emulator/data/localization/README.md +63 -0
  197. package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
  198. package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
  199. package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
  200. package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
  201. package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
  202. package/renderer/dist/emulator/data/localization/en-US.json +339 -0
  203. package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
  204. package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
  205. package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
  206. package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
  207. package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
  208. package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
  209. package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
  210. package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
  211. package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
  212. package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
  213. package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
  214. package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
  215. package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
  216. package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
  217. package/renderer/dist/emulator/data/src/GameManager.js +464 -0
  218. package/renderer/dist/emulator/data/src/compression.js +142 -0
  219. package/renderer/dist/emulator/data/src/emulator.js +6158 -0
  220. package/renderer/dist/emulator/data/src/gamepad.js +167 -0
  221. package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
  222. package/renderer/dist/emulator/data/src/shaders.js +236 -0
  223. package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
  224. package/renderer/dist/emulator/data/src/storage.js +125 -0
  225. package/renderer/dist/emulator/data/version.json +5 -0
  226. package/renderer/dist/emulator/host.js +299 -0
  227. package/renderer/dist/emulator/index.html +28 -0
  228. package/renderer/dist/emulator/provenance.json +6 -0
  229. package/renderer/dist/fixtures/onboard/designs/w-n13/the-first-row.html +1 -0
  230. package/renderer/dist/index.html +13 -0
  231. package/scripts/after-install.mjs +59 -0
  232. package/scripts/approval-server.sh +43 -0
  233. package/scripts/before-publish.mjs +133 -0
  234. package/shared/agent-import.d.mts +13 -0
  235. package/shared/agent-import.mjs +252 -0
  236. package/shared/agents.d.mts +190 -0
  237. package/shared/agents.mjs +937 -0
  238. package/shared/analytics-events.mjs +63 -0
  239. package/shared/answers.d.mts +12 -0
  240. package/shared/answers.mjs +80 -0
  241. package/shared/artifact-keys.d.mts +28 -0
  242. package/shared/artifact-keys.mjs +140 -0
  243. package/shared/bridge-map.mjs +128 -0
  244. package/shared/claude-commands.d.mts +20 -0
  245. package/shared/claude-commands.generated.mjs +149 -0
  246. package/shared/claude-commands.mjs +131 -0
  247. package/shared/claude-models.generated.d.mts +15 -0
  248. package/shared/claude-models.generated.mjs +26 -0
  249. package/shared/claude-usage.d.mts +16 -0
  250. package/shared/claude-usage.mjs +172 -0
  251. package/shared/codex-commands.d.mts +4 -0
  252. package/shared/codex-commands.mjs +17 -0
  253. package/shared/codex-import.d.mts +38 -0
  254. package/shared/codex-import.mjs +232 -0
  255. package/shared/codex-modes.mjs +159 -0
  256. package/shared/codex-usage.mjs +180 -0
  257. package/shared/contracts.mjs +412 -0
  258. package/shared/crash-scrub.mjs +182 -0
  259. package/shared/dashboard.mjs +481 -0
  260. package/shared/dead-run-trace.mjs +127 -0
  261. package/shared/demo-world.mjs +415 -0
  262. package/shared/effort-levels.d.mts +15 -0
  263. package/shared/effort-levels.mjs +76 -0
  264. package/shared/engines.d.mts +62 -0
  265. package/shared/engines.mjs +389 -0
  266. package/shared/first-run-examples.mjs +60 -0
  267. package/shared/first-run-line.mjs +149 -0
  268. package/shared/first-run-practice.d.mts +32 -0
  269. package/shared/first-run-practice.mjs +254 -0
  270. package/shared/first-run-shape.mjs +100 -0
  271. package/shared/fresh-user-home.mjs +189 -0
  272. package/shared/hook-failure.mjs +22 -0
  273. package/shared/install-id.mjs +39 -0
  274. package/shared/instruction-defaults.json +5 -0
  275. package/shared/library-folder.mjs +28 -0
  276. package/shared/notify-rules.mjs +120 -0
  277. package/shared/product-name.d.mts +6 -0
  278. package/shared/product-name.mjs +144 -0
  279. package/shared/project-folder-check.d.mts +22 -0
  280. package/shared/project-folder-check.mjs +152 -0
  281. package/shared/provider-commands.d.mts +8 -0
  282. package/shared/provider-commands.mjs +58 -0
  283. package/shared/rank.d.mts +19 -0
  284. package/shared/rank.mjs +146 -0
  285. package/shared/recovery.mjs +26 -0
  286. package/shared/referenced-files.d.mts +11 -0
  287. package/shared/referenced-files.mjs +122 -0
  288. package/shared/repeats.d.mts +45 -0
  289. package/shared/repeats.mjs +193 -0
  290. package/shared/schemes.mjs +33 -0
  291. package/shared/settings-terminal.d.mts +1 -0
  292. package/shared/settings-terminal.mjs +14 -0
  293. package/shared/side-build.mjs +57 -0
  294. package/shared/spawn-trouble.d.mts +8 -0
  295. package/shared/spawn-trouble.mjs +599 -0
  296. package/shared/terminal-state.d.mts +1 -0
  297. package/shared/terminal-state.mjs +58 -0
  298. package/shared/usage.d.mts +53 -0
  299. package/shared/usage.mjs +319 -0
  300. package/shared/work-items.mjs +561 -0
  301. package/shared/work-lines.mjs +360 -0
  302. package/worker-permissions.json +6 -0
@@ -0,0 +1,1095 @@
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`.
11
+
12
+ import fs from 'node:fs';
13
+ import net from 'node:net';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import readline from 'node:readline';
17
+ import { execFile } from 'node:child_process';
18
+ import { promisify } from 'node:util';
19
+ import { NAME, Name, WAS } from '../shared/product-name.mjs';
20
+ import {
21
+ WORK_CHARS, agentKey, changedFile, fullSubject, saidCount, sameInstant, threadWindow, workSubject, workVerb,
22
+ } from '../shared/agents.mjs';
23
+
24
+ const run = promisify(execFile);
25
+
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.
30
+ const SESSIONS_DIR = () => path.join(os.homedir(), '.claude', 'sessions');
31
+ const PROJECTS_DIR = () => path.join(os.homedir(), '.claude', 'projects');
32
+
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.
39
+ async function processTable() {
40
+ const table = new Map();
41
+ let out = '';
42
+ try {
43
+ ({ stdout: out } = await run('/bin/ps', ['-Ao', 'pid=,ppid=,lstart=,command='], {
44
+ encoding: 'utf8', maxBuffer: 16 << 20, timeout: 5000,
45
+ }));
46
+ } catch { return table; }
47
+ for (const line of out.split('\n')) {
48
+ // pid ppid <lstart: Www Mmm dd hh:mm:ss yyyy = 5 fields> command...
49
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.*)$/);
50
+ if (!m) continue;
51
+ table.set(Number(m[1]), { ppid: Number(m[2]), started: m[3].replace(/\s+/g, ' '), command: m[4] });
52
+ }
53
+ return table;
54
+ }
55
+
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.
59
+ function stillTheSame(rec, proc) {
60
+ if (!proc) return false;
61
+ if (!/claude/i.test(proc.command)) return false;
62
+ if (!rec.procStart) return true;
63
+ return sameInstant(rec.procStart, proc.started);
64
+ }
65
+
66
+ /* ------------------------------- the sources ----------------------------- */
67
+ // TWO SOURCES, UNIONED, because on 2026-08-16 neither one alone saw her
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.
72
+ function fromStateFiles() {
73
+ const found = [];
74
+ let names = [];
75
+ try { names = fs.readdirSync(SESSIONS_DIR()); } catch { return found; }
76
+ for (const name of names) {
77
+ if (!name.endsWith('.json')) continue;
78
+ try {
79
+ const rec = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR(), name), 'utf8'));
80
+ if (rec && rec.pid) found.push(rec);
81
+ } catch { /* a half-written state file is not a reason to show her nothing */ }
82
+ }
83
+ return found;
84
+ }
85
+
86
+ async function fromCli(claudeBin) {
87
+ if (!claudeBin) return [];
88
+ try {
89
+ const { stdout } = await run(claudeBin, ['agents', '--json'], {
90
+ 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.
93
+ env: scrubbed(process.env),
94
+ });
95
+ const parsed = JSON.parse(stdout);
96
+ return Array.isArray(parsed) ? parsed : [];
97
+ } catch { return []; }
98
+ }
99
+
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.
105
+ function scrubbed(env) {
106
+ const out = { ...env };
107
+ for (const key of Object.keys(out)) {
108
+ if (/^(ANTHROPIC_|CLAUDE_CODE_|CLAUDECODE$|CLAUDE_PID$|CLAUDE_EFFORT$)/.test(key)) delete out[key];
109
+ }
110
+ return out;
111
+ }
112
+
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. Measured across
129
+ // her 14 outside sessions 2026-08-17: 14 of 14 had one, inside the
130
+ // 96KB tail. session-71's is "Agentbox product not displaying in
131
+ // the app", which is the exact sentence she could not remember.
132
+ // last-prompt the last thing SHE typed into that session. "okay no worries.
133
+ // just make sure that auth persists across sessions…"
134
+ // tool_use the files it has been editing, reading and writing.
135
+ //
136
+ // So the reading is one pass over a tail that was already being read, and the
137
+ // card goes from three lines about a pid to what the work actually is.
138
+ //
139
+ // WHAT WAS MISSING WAS THE BEGINNING. Measured over all 16 of her live sessions
140
+ // on 2026-08-17, reading every human turn in 117MB of transcript:
141
+ //
142
+ // The conversation she needs to recall fits on a card. The agent's side is what
143
+ // is megabytes.
144
+ //
145
+ // AND THE TAIL CANNOT SEE IT. The opening turn lands between 11KB and 614KB in
146
+ // (her pasted images are single transcript lines of hundreds of KB), and 96KB
147
+ // off the end reaches none of it. Measured: a 1MB head plus a 512KB tail still
148
+ // saw only 72 of her 155 turns — 46% — and one of the two it missed on
149
+ // session-71 was the one she is complaining about.
150
+ //
151
+ // So the whole file is read, ONCE, and then only what has been appended since.
152
+ // Measured on her machine: the first pass over all 15 transcripts, 117MB, costs
153
+ // 353ms in total and never happens again for that session; after it, a refresh
154
+ // reads only the bytes that arrived. This is the same read the tail was already
155
+ // doing, moved from "the last 96KB" to "everything, once" — no model sees any
156
+ // of it, which was her condition.
157
+ const CHUNK_BYTES = 4 << 20;
158
+ const historyCache = new Map(); // sessionId -> { file, size, mtimeMs, offset, ...facts }
159
+ const dirCache = new Map(); // sessionId -> transcript path
160
+
161
+ // The files it has had its hands on, newest last, deduplicated, and only the
162
+ // ones a person would recognise: the basename, never the path. Six is the most
163
+ // the card will print and reading more of them is free, so the cap is applied
164
+ // where it is shown rather than here.
165
+ const TOUCH_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'Read', 'MultiEdit']);
166
+
167
+ function slugFor(cwd) {
168
+ return String(cwd || '').replace(/[^a-zA-Z0-9]/g, '-');
169
+ }
170
+
171
+ function transcriptPath(sessionId, cwd) {
172
+ const cached = dirCache.get(sessionId);
173
+ if (cached && fs.existsSync(cached)) return cached;
174
+ const direct = path.join(PROJECTS_DIR(), slugFor(cwd), `${sessionId}.jsonl`);
175
+ if (fs.existsSync(direct)) { dirCache.set(sessionId, direct); return direct; }
176
+ // The slug rule is Claude Code's, not ours, so a miss falls back to looking.
177
+ let dirs = [];
178
+ try { dirs = fs.readdirSync(PROJECTS_DIR()); } catch { return null; }
179
+ for (const dir of dirs) {
180
+ const p = path.join(PROJECTS_DIR(), dir, `${sessionId}.jsonl`);
181
+ if (fs.existsSync(p)) { dirCache.set(sessionId, p); return p; }
182
+ }
183
+ return null;
184
+ }
185
+
186
+ const NOTHING = { lastActiveAt: 0, lastSaid: '', saidAt: 0, saidOpen: false, about: '', lastAsked: '', touched: [], asked: [] };
187
+
188
+ // HOW MUCH OF THE LAST REPLY IS KEPT. The card quotes the end of it, so this is
189
+ // a bound on the accumulator and not on what she reads. Her longest single
190
+ // reply on this machine is under 6KB.
191
+ const SAID_CHARS = 8000;
192
+
193
+ // WHAT COUNTS AS SOMETHING SHE TYPED. A Claude Code transcript calls a great
194
+ // many things a `user` message that no human wrote: every tool result comes
195
+ // back as one, every system reminder is one, every slash command expands into
196
+ // one. Reading those onto the card would fill her recall with the machine
197
+ // talking to itself, which is worse than the blank it replaces.
198
+ //
199
+ // So a turn is hers only when it is a `user` line that is not flagged meta, is
200
+ // not a compaction summary, carries no tool result, and whose text does not
201
+ // open with a tag. Checked against all 155 real turns in her 16 sessions.
202
+ //
203
+ // THE CARD AND THE CONVERSATION READ THE SAME LINE DIFFERENTLY, which is why
204
+ // this is two functions and not one. A card quotes a sentence, so it flattens
205
+ // her paragraphs to a single line; the conversation shows what she actually
206
+ // typed, so it keeps them. Splitting it here means the FILTER — the part that
207
+ // decides what was written by a human at all — has one copy, and a transcript
208
+ // change breaks both at once instead of only the one nobody is looking at.
209
+ const RELAY_MARK = `Relayed from ${NAME},`;
210
+ // WRITTEN UNDER THE CURRENT NAME, RECOGNISED UNDER EVERY NAME THIS APP HAS
211
+ // HAD. A transcript is a file on her disk and it outlives a rename, so a
212
+ // marker matched on the current name alone stops matching the day the name
213
+ // changes, and the app's own preamble starts reading as something she typed.
214
+ const RELAY_MARKERS = [RELAY_MARK, ...WAS.map((was) => `Relayed from ${was},`)];
215
+ const wasRelayed = (line) => RELAY_MARKERS.some((m) => line.startsWith(m));
216
+
217
+ function herTurnRaw(obj) {
218
+ if (obj.type !== 'user' || obj.isMeta || obj.isCompactSummary || obj.toolUseResult) return '';
219
+ const content = obj.message?.content;
220
+ const text = typeof content === 'string'
221
+ ? content
222
+ : Array.isArray(content)
223
+ ? content.filter((c) => c.type === 'text').map((c) => c.text).join('\n')
224
+ : '';
225
+ const clean = String(text).trim();
226
+ const flat = clean.replace(/\s+/g, ' ');
227
+ // AND NEVER AGENTBOX'S OWN PREAMBLE. `fromTheApp` puts a sentence of context in
228
+ // front of what she typed, and today Claude Code stamps the result `isMeta`
229
+ // so this never sees it. If a future one records it as a plain user turn
230
+ // instead, the card would quote Agentbox talking to an agent back at her as her
231
+ // own words. `spoke` already holds what she actually typed.
232
+ if (!flat || flat.startsWith('<') || flat.startsWith('[Request interrupted') || wasRelayed(flat)) return '';
233
+ return clean;
234
+ }
235
+
236
+ function herTurn(obj) {
237
+ return herTurnRaw(obj).replace(/\s+/g, ' ').trim();
238
+ }
239
+
240
+ // SOMETHING ARRIVED, SO WHATEVER IT WAS SAYING IS FINISHED. An assistant does
241
+ // not speak twice without being spoken to, so the line that ends a reply is the
242
+ // next inbound one — and MOST OF THOSE ARE NOT QUOTED ANYWHERE. A message
243
+ // Agentbox delivers comes back as `isMeta`, a peer's comes back wrapped, a
244
+ // delivery notice comes back as neither, and `herTurnRaw` drops all three on
245
+ // purpose. Dropping them from the READING was right; dropping them from the
246
+ // COUNTING is what folded two days of separate answers into one bubble.
247
+ //
248
+ // MEASURED on session-46: its whole conversation came out as three turns, and
249
+ // the last one was stamped 2:48pm today while opening with a sentence it had
250
+ // said at 7:47pm two days earlier. She read that as the answer to what she had
251
+ // just asked. It was not.
252
+ //
253
+ // A tool result is also a `user` line and is the one thing that does NOT end a
254
+ // reply: it is the reply, still going.
255
+ function arrived(obj) {
256
+ return obj?.type === 'user' && !obj.toolUseResult;
257
+ }
258
+
259
+ // HOW MUCH OF HER SIDE IS KEPT. The opening is kept forever because it is the
260
+ // ask; after that the most recent are what tell her where the thread got to.
261
+ // Her longest session had 29 turns, so this loses nothing on today's machine
262
+ // and cannot grow without bound on a session that runs for a month.
263
+ const KEEP_TURNS = 24;
264
+
265
+ function remember(acc, line) {
266
+ let obj;
267
+ try { obj = JSON.parse(line); } catch { return; }
268
+ if (obj.timestamp) {
269
+ const ts = Date.parse(obj.timestamp);
270
+ if (Number.isFinite(ts)) acc.lastActiveAt = Math.max(acc.lastActiveAt, ts);
271
+ }
272
+ // The session's own one-line title, rewritten as the work changes, so the
273
+ // LAST one in the file is the current one.
274
+ if (obj.type === 'ai-title' && obj.aiTitle) acc.about = String(obj.aiTitle);
275
+ // The last thing a HUMAN typed into it. Claude Code records it as its own
276
+ // line, so this needs no walking back through tool results to find the one
277
+ // user turn that was not a tool answering a tool.
278
+ if (obj.type === 'last-prompt' && obj.lastPrompt) acc.lastAsked = String(obj.lastPrompt);
279
+ const mine = herTurn(obj);
280
+ if (mine) {
281
+ acc.asked.push({ at: Date.parse(obj.timestamp ?? 0) || 0, text: mine });
282
+ // The first one never leaves. Everything after it is a window on the end.
283
+ if (acc.asked.length > KEEP_TURNS) acc.asked.splice(1, 1);
284
+ }
285
+ // ONE REPLY IS SEVERAL LINES AND THE CARD WANTS THE REPLY. Claude Code writes
286
+ // an answer as a new line every time it stops to use a tool, so this used to
287
+ // keep whichever block came last — routinely a "Done." under four paragraphs
288
+ // that said what was actually done. The blocks of one reply are joined, and
289
+ // the next thing to ARRIVE starts a new one.
290
+ if (arrived(obj)) acc.saidOpen = false;
291
+ if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
292
+ const said = obj.message.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n').trim();
293
+ if (said) {
294
+ const open = acc.saidOpen;
295
+ acc.lastSaid = open ? `${acc.lastSaid}\n\n${said}` : said;
296
+ // The END is what is kept when a reply runs long, because the end is
297
+ // where an answer is. See `howItEnded` in shared/agents.mjs.
298
+ if (acc.lastSaid.length > SAID_CHARS) acc.lastSaid = acc.lastSaid.slice(-SAID_CHARS);
299
+ acc.saidOpen = true;
300
+ // WHEN IT SAID IT, which is the fact the card was missing entirely: six
301
+ // pings and six cards that looked the same, because the quote underneath
302
+ // them carried no time and could not be told from the last one.
303
+ //
304
+ // WHEN IT BEGAN, not when it stopped, and the card's heading is why. A
305
+ // reply that STARTED after her message is an answer to it; one that
306
+ // started before and only finished after is not, and stamping the end
307
+ // would draw it as one.
308
+ const at = Date.parse(obj.timestamp ?? 0) || 0;
309
+ if (at && !open) acc.saidAt = at;
310
+ }
311
+ for (const c of obj.message.content) {
312
+ if (c.type !== 'tool_use' || !TOUCH_TOOLS.has(c.name)) continue;
313
+ const p = c.input?.file_path ?? c.input?.notebook_path;
314
+ if (!p) continue;
315
+ const leaf = path.basename(String(p));
316
+ // Newest wins its place: a file touched again moves to the end rather
317
+ // than keeping the position it had an hour ago.
318
+ const at = acc.touched.indexOf(leaf);
319
+ if (at >= 0) acc.touched.splice(at, 1);
320
+ acc.touched.push(leaf);
321
+ if (acc.touched.length > 40) acc.touched.shift();
322
+ }
323
+ }
324
+ }
325
+
326
+ // Read [from, size) and fold every WHOLE line into the accumulator, returning
327
+ // the offset the next read should start at. A transcript is appended to while
328
+ // this runs, so the last line in the buffer is routinely half a line; it is
329
+ // left behind rather than parsed, and the next pass picks it up entire.
330
+ function fold(file, acc, from, size) {
331
+ let at = from;
332
+ let rest = '';
333
+ let fd;
334
+ try { fd = fs.openSync(file, 'r'); } catch { return from; }
335
+ try {
336
+ while (at < size) {
337
+ const want = Math.min(CHUNK_BYTES, size - at);
338
+ const buf = Buffer.alloc(want);
339
+ const got = fs.readSync(fd, buf, 0, want, at);
340
+ if (got <= 0) break;
341
+ const text = rest + buf.slice(0, got).toString('utf8');
342
+ const cut = text.lastIndexOf('\n');
343
+ at += got;
344
+ if (cut < 0) { rest = text; continue; }
345
+ rest = text.slice(cut + 1);
346
+ for (const line of text.slice(0, cut).split('\n')) {
347
+ if (line.trim()) remember(acc, line);
348
+ }
349
+ }
350
+ } catch { /* a partly-read transcript is still worth what it gave us */ }
351
+ finally { try { fs.closeSync(fd); } catch { /* nothing to do */ } }
352
+ // Whatever is after the last newline has not been counted, so the next read
353
+ // starts there and sees that line whole.
354
+ return at - Buffer.byteLength(rest);
355
+ }
356
+
357
+ function readHistory(sessionId, cwd) {
358
+ const file = transcriptPath(sessionId, cwd);
359
+ if (!file) return { ...NOTHING, transcript: null };
360
+ let stat;
361
+ try { stat = fs.statSync(file); } catch { return { ...NOTHING, transcript: null }; }
362
+ const hit = historyCache.get(sessionId);
363
+ if (hit && hit.file === file && hit.size === stat.size && hit.mtimeMs === stat.mtimeMs) {
364
+ return { ...hit, transcript: file };
365
+ }
366
+
367
+ // Carry on from where the last read stopped, unless the file is not the same
368
+ // file any more. A transcript that SHRANK was rotated or rewritten and the
369
+ // old cursor points into the middle of a different conversation, so that
370
+ // case starts over rather than folding two sessions into one card.
371
+ const resume = hit && hit.file === file && stat.size >= hit.size;
372
+ const acc = resume
373
+ ? {
374
+ lastActiveAt: hit.lastActiveAt,
375
+ lastSaid: hit.lastSaid,
376
+ saidAt: hit.saidAt ?? 0,
377
+ // A RESUMED READ IS MID-REPLY UNTIL PROVEN OTHERWISE. The last line the
378
+ // previous pass folded may well have been one block of an answer that is
379
+ // still being written, so the reply stays open across the seam and the
380
+ // next block joins it rather than replacing it.
381
+ saidOpen: hit.saidOpen ?? false,
382
+ about: hit.about,
383
+ lastAsked: hit.lastAsked,
384
+ touched: [...hit.touched],
385
+ asked: [...hit.asked],
386
+ }
387
+ : { ...NOTHING, touched: [], asked: [] };
388
+ const offset = fold(file, acc, resume ? hit.offset : 0, stat.size);
389
+
390
+ const entry = { file, size: stat.size, mtimeMs: stat.mtimeMs, offset, ...acc };
391
+ historyCache.set(sessionId, entry);
392
+ return { ...entry, transcript: file };
393
+ }
394
+
395
+ // Exported for the test that drives this against a real transcript's shape.
396
+ // The fields it reads are Claude Code's, not ours, so a rename upstream is a
397
+ // card that quietly goes back to saying nothing, and only a test against real
398
+ // jsonl notices (tests/one-inbox-no-agents-tab.test.mjs).
399
+ export { readHistory as __readTail, readHistory as __readHistory };
400
+
401
+ /* --------------------------------- the list ------------------------------ */
402
+ // NOTHING IN HERE MAY EVER BLOCK THE MAIN PROCESS. The list rides the snapshot,
403
+ // the snapshot is answered on the main thread, and the main thread is also the
404
+ // app: measured 2026-08-16, one `ps` costs 45ms and `claude agents --json`
405
+ // costs 215ms, so reading this synchronously on every refetch would freeze the
406
+ // window for a quarter of a second at a time, every couple of seconds, forever.
407
+ //
408
+ // So the read is asynchronous and `listAgents` is a pure lookup that returns
409
+ // whatever was last measured and quietly asks for a fresh one when it is stale.
410
+ // The first call after boot returns nothing and the one a moment later is
411
+ // right, which is exactly the shape of every other live fact in this app.
412
+ let cache = { at: 0, agents: [] };
413
+ let inFlight = null;
414
+ // HOW THE LIST WAS MEASURED LAST TIME, so that anything measuring again does it
415
+ // the same way. This is not tidiness: `reply` re-read the list with default
416
+ // options, which drops `claude agents --json` (no binary path), and the probe
417
+ // on 2026-08-16 came back "That agent is no longer running" while it was
418
+ // sitting there listening on its socket. Anything visible through only one of
419
+ // the two sources was unreplyable.
420
+ let lastOpts = {};
421
+ const TTL_MS = 4000;
422
+
423
+ // Called when the measured list actually CHANGES, not on every refresh: the
424
+ // renderer already refetches on a push, and pushing at the refresh rate would
425
+ // re-render the window for a `lastActiveAt` that ticked.
426
+ export let onChange = null;
427
+ export function setOnChange(fn) { onChange = fn; }
428
+
429
+ function fingerprint(agents) {
430
+ // `statusAt` is in here and `lastActiveAt` is deliberately not. The second is
431
+ // a tick and pushing on it re-renders her window for nothing. The first moves
432
+ // only when the session's status moves, and since a row's LIST depends on it:
433
+ // a session that went busy and back to idle between two readings shows the
434
+ // same `status` both times, and without this the row she replied to would sit
435
+ // in In progress until some other push happened to come. AND WHEN IT LAST
436
+ // SAID SOMETHING, which is the fact this list existed to notice and did
437
+ // not.An agent that answers her while she is looking at its card moves
438
+ // `saidAt` and nothing else — the status was idle before the reply and is
439
+ // idle after it, and on a quiet machine the two readings either side can both
440
+ // catch it idle — so the row went on showing the answer to the message
441
+ // before. This is not the tick `lastActiveAt` is: it moves only when the
442
+ // session actually says something new.
443
+ return agents.map((a) => `${a.pid}:${a.status ?? ''}:${a.statusAt ?? 0}:${a.waitingFor ?? ''}:${a.saidAt ?? 0}`).sort().join('|');
444
+ }
445
+
446
+ export function listAgents(opts = {}) {
447
+ if (Date.now() - cache.at >= TTL_MS) refresh(opts).catch(() => {});
448
+ return cache.agents;
449
+ }
450
+
451
+ export async function refresh(opts) {
452
+ if (opts) lastOpts = opts;
453
+ const { zeroPid = process.pid, sessionIds = [], products = [], claudeBin = null } = opts ?? lastOpts;
454
+ // One measurement at a time. Without this a burst of pushes starts a `claude
455
+ // agents --json` per push and they queue up behind each other.
456
+ if (inFlight) return inFlight;
457
+ inFlight = measure({ zeroPid, sessionIds, products, claudeBin })
458
+ .then((agents) => {
459
+ const changed = fingerprint(agents) !== fingerprint(cache.agents);
460
+ cache = { at: Date.now(), agents };
461
+ if (changed && onChange) { try { onChange(); } catch { /* a push is not worth a crash */ } }
462
+ return agents;
463
+ })
464
+ .catch(() => {
465
+ // Her inbox is not allowed to go empty because a `ps` failed. The last
466
+ // good reading stands, and the stamp moves so the next call retries.
467
+ cache = { ...cache, at: Date.now() };
468
+ return cache.agents;
469
+ })
470
+ .finally(() => { inFlight = null; });
471
+ return inFlight;
472
+ }
473
+
474
+ async function measure({ zeroPid, sessionIds, products, claudeBin }) {
475
+ const [table, cliRecords] = await Promise.all([processTable(), fromCli(claudeBin)]);
476
+ const zeroSessionIds = new Set(sessionIds.filter(Boolean));
477
+
478
+ // A product owns an agent when the agent is working in that product's repo.
479
+ // all 13 outside agents were in folders no the app product points at.
480
+ const owners = new Map();
481
+ for (const p of products) {
482
+ if (p?.repoPath) owners.set(path.resolve(p.repoPath), p);
483
+ }
484
+
485
+ const byPid = new Map();
486
+ const add = (rec, source) => {
487
+ if (!rec?.pid) return;
488
+ const proc = table.get(rec.pid);
489
+ if (!stillTheSame(rec, proc)) return;
490
+ const prev = byPid.get(rec.pid) ?? {};
491
+ byPid.set(rec.pid, {
492
+ ...prev,
493
+ ...rec,
494
+ ppid: proc.ppid,
495
+ sources: [...(prev.sources ?? []), source],
496
+ });
497
+ };
498
+ for (const rec of fromStateFiles()) add(rec, 'state-file');
499
+ for (const rec of cliRecords) add(rec, 'cli');
500
+
501
+ const agents = [];
502
+ for (const rec of byPid.values()) {
503
+ const { lastActiveAt, lastSaid, saidAt, about, lastAsked, touched, asked, transcript } = rec.sessionId
504
+ ? readHistory(rec.sessionId, rec.cwd)
505
+ : { ...NOTHING, transcript: null };
506
+ const owner = owners.get(path.resolve(rec.cwd ?? '/')) ?? null;
507
+ agents.push({
508
+ pid: rec.pid,
509
+ ppid: rec.ppid,
510
+ sessionId: rec.sessionId ?? null,
511
+ name: rec.name ?? `session ${rec.pid}`,
512
+ cwd: rec.cwd ?? '',
513
+ startedAt: rec.startedAt ?? 0,
514
+ status: rec.status ?? null,
515
+ // WHEN IT LAST SAID WHAT IT WAS DOING, which is the only way to tell a
516
+ // stop that happened BEFORE she spoke to it from one that happened after.
517
+ // The CLI publishes it beside `status` on every change (measured in
518
+ // 2.1.234: the status is one of busy, shell, idle, waiting, and
519
+ // `statusUpdatedAt` is stamped whenever it moves). Without it a reply
520
+ // could never leave In progress, because the session is still idle for
521
+ // the moment or two before it picks the message up.
522
+ statusAt: Number(rec.statusUpdatedAt) || 0,
523
+ waitingFor: rec.waitingFor ?? null,
524
+ socket: rec.messagingSocketPath ?? defaultSocket(rec.pid),
525
+ lastActiveAt: lastActiveAt || rec.startedAt || 0,
526
+ lastSaid,
527
+ // WHEN IT LAST SAID SOMETHING, which is how the card can tell an answer
528
+ // to her newest message from a sentence it left there two days ago.
529
+ // Without it every reply reads as "It stopped here" and nothing more, so
530
+ // six pings produced six cards she could not tell apart.
531
+ saidAt,
532
+ // What the session is about, what she last told it, and what it has been
533
+ // touching. All three come free with the read above.
534
+ about,
535
+ lastAsked,
536
+ touched,
537
+ // No model reads any of it.
538
+ asked,
539
+ transcript,
540
+ product: owner?.slug ?? null,
541
+ productName: owner?.name ?? null,
542
+ // The dedupe key. Both halves of it, so it cannot drift as either changes.
543
+ startedByZero: rec.ppid === zeroPid || (!!rec.sessionId && zeroSessionIds.has(rec.sessionId)),
544
+ });
545
+ }
546
+ return agents;
547
+ }
548
+
549
+ function defaultSocket(pid) {
550
+ const base = process.env.XDG_RUNTIME_DIR || os.tmpdir();
551
+ return path.join(base, 'cc-socks', `${pid}.sock`);
552
+ }
553
+
554
+ /* ------------------------------ speaking to one -------------------------- */
555
+ // THE ONE WRITE. Claude Code sessions listen on a Unix socket and take
556
+ // newline-delimited JSON; the CLI prints the shape itself when it binds one
557
+ // ("[uds-messaging] Inject messages { echo '{"type":"auth",...}'; echo
558
+ // '{"type":"user","message":{"role":"user","content":"hello"}}'; } | socat").
559
+ // An auth line is required only when the session published a key file; when it
560
+ // did, the token is in ~/.claude/sessions/<pid>.<hash>.key.
561
+ //
562
+ // MEASURED, NOT REASONED, 2026-08-16: sent into a throwaway session spawned in
563
+ // /tmp, the message arrived, was read as her turn, and the agent went back to
564
+ // work. It was NOT held for approval, and NO line was added to
565
+ // ~/.claude/settings.json. The `crossSessionInbound: "hold"` gate the research
566
+ // found applies to messages that carry a peer identity and assert a permission
567
+ // mode; a plain injected user turn does not, so the settings line this build
568
+ // was told to ask about is not needed and was never written.
569
+ //
570
+ // TWO DIFFERENT FACTS, AND NEITHER IS OVERSTATED. The socket takes the message
571
+ // and does not acknowledge it, so what `reply` can honestly report has two
572
+ // levels and it reports whichever one it actually has:
573
+ //
574
+ // ok, no movement seen The message was handed to a live process that was
575
+ // listening. That IS a fact and it is what "Sent"
576
+ // means everywhere else in this app.
577
+ // ok, movement seen The session was then observed going to work, which
578
+ // is strictly better news and says so.
579
+ //
580
+ // A failed connect is neither, and says so loudly. What is never done is the
581
+ // third thing: a green toast over a message that went nowhere. That is the failure
582
+ // this codebase cares about most.
583
+ //
584
+ // The movement check is BEST EFFORT and is honestly not proven on a real
585
+ // waiting agent: no throwaway session could be made to publish the state file
586
+ // that carries `status`, so it was only ever exercised against sessions that
587
+ // publish neither that nor a transcript. It is written to be a bonus, never a
588
+ // gate, precisely because of that.
589
+ // WHO IS ASKING, WHICH THE SESSION HAS NO OTHER WAY TO KNOW.
590
+ //
591
+ // Nothing here claims her authority.
592
+ //
593
+ // What it does say is the part the wrapper cannot: WHERE the message came from
594
+ // and WHAT it is about. MEASURED on session-46, which is the session in her
595
+ // screenshot. There was no response to see, and one sentence of context is the
596
+ // whole of the difference.
597
+ //
598
+ // HER OWN TEXT IS UNCHANGED AND IS WHAT THE CARD QUOTES: `spoke` records what
599
+ // she typed, never this, so nothing she never wrote is ever drawn as her words.
600
+ export function fromTheApp(message) {
601
+ return [
602
+ `${RELAY_MARK} the inbox your user reads. A person typed this, not an agent,`,
603
+ "and they are asking about THIS session's own work. \"this\", \"it\" and \"the project\"",
604
+ 'mean what you are doing here, not another session\'s. Answer in a few plain sentences',
605
+ 'they can read on a card, and do not go looking for other sessions to ask.',
606
+ '',
607
+ 'They said:',
608
+ message,
609
+ ].join('\n');
610
+ }
611
+
612
+ export async function reply(pid, text, { timeoutMs = 3000 } = {}) {
613
+ const message = String(text ?? '').trim();
614
+ if (!message) return { ok: false, reason: 'There was nothing to send.' };
615
+
616
+ const agent = (await refresh()).find((a) => a.pid === pid);
617
+ if (!agent) return { ok: false, reason: 'That agent is no longer running.' };
618
+ if (agent.waitingFor === 'permission prompt') {
619
+ return { ok: false, reason: 'It is waiting on a permission box, which only its own window can answer.' };
620
+ }
621
+
622
+ const before = await watermark(agent);
623
+ try {
624
+ // What goes on the wire says who is asking; what is matched and recorded
625
+ // below stays HER words, so `heldNotice` cannot start matching every
626
+ // message by its shared opening and the card cannot quote a preamble.
627
+ await writeToSocket(agent.socket, fromTheApp(message), tokenFor(pid));
628
+ } catch (err) {
629
+ return { ok: false, reason: reasonFor(err) };
630
+ }
631
+
632
+ const moved = await waitForMovement(agent, before, timeoutMs);
633
+
634
+ // A THIRD ANSWER, AND IT IS A FAILURE: THE SESSION HELD IT AND HAS NOT READ
635
+ // IT. Claude Code parks an incoming socket message for the session's own user
636
+ // to approve when the session bypasses permission prompts and the sender did
637
+ // not attest a matching permission mode, which Agentbox cannot do. MEASURED
638
+ // 2026-08-17 against a session started with --permission-mode
639
+ // bypassPermissions: the message never became a turn, the agent never saw it,
640
+ // and this function returned ok, delivered and working, because the session
641
+ // burned CPU drawing the warning. That is the green toast over a message that
642
+ // went nowhere this file says it will never write, so it is checked for.
643
+ //
644
+ // It is BEST EFFORT in one direction only: a session with no transcript
645
+ // cannot be seen holding anything, so a hold that is not found is not a
646
+ // promise there was none. A hold that IS found is a fact and outranks
647
+ // everything above it.
648
+ // Looked for a few times rather than once: `waitForMovement` returns the
649
+ // instant it sees CPU, which on a held message is the session drawing the
650
+ // warning, so the line saying so is often not on disk yet.
651
+ let held = null;
652
+ for (let i = 0; i < 6 && !held; i += 1) {
653
+ held = heldNotice(agent, before.size, message);
654
+ if (!held) await new Promise((r) => setTimeout(r, 250));
655
+ }
656
+ if (held) {
657
+ cache = { ...cache, at: 0 };
658
+ return { ok: false, held: true, reason: held };
659
+ }
660
+ cache = { ...cache, at: 0 }; // the agent has moved; the next look re-measures
661
+ // The KEY goes back with the answer so the caller can record that she spoke,
662
+ // and it is worked out HERE because this is where the agent record is. A
663
+ // caller re-deriving it from a pid would be using the one thing that is not an
664
+ // identity (`agentKey`, shared/agents.mjs).
665
+ return { ok: true, delivered: true, working: moved, name: agent.name, key: agentKey(agent), at: Date.now() };
666
+ }
667
+
668
+ function tokenFor(pid) {
669
+ let names = [];
670
+ try { names = fs.readdirSync(SESSIONS_DIR()); } catch { return null; }
671
+ const key = names.find((n) => n.startsWith(`${pid}.`) && n.endsWith('.key'));
672
+ if (!key) return null;
673
+ try {
674
+ return JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR(), key), 'utf8')).peerToken ?? null;
675
+ } catch { return null; }
676
+ }
677
+
678
+ function writeToSocket(socketPath, message, token) {
679
+ return new Promise((resolve, reject) => {
680
+ const sock = net.createConnection({ path: socketPath });
681
+ const fail = (err) => { sock.destroy(); reject(err); };
682
+ sock.setTimeout(4000, () => fail(new Error('ETIMEDOUT')));
683
+ sock.on('error', fail);
684
+ sock.on('connect', () => {
685
+ if (token) sock.write(`${JSON.stringify({ type: 'auth', token })}\n`);
686
+ sock.write(`${JSON.stringify({ type: 'user', message: { role: 'user', content: message } })}\n`, () => {
687
+ // Half-close: the session reads to end of line, and an abrupt destroy
688
+ // has been seen to drop the final frame.
689
+ sock.end();
690
+ resolve();
691
+ });
692
+ });
693
+ sock.on('close', () => resolve());
694
+ });
695
+ }
696
+
697
+ function reasonFor(err) {
698
+ const code = err?.code ?? err?.message ?? '';
699
+ if (String(code).includes('ENOENT')) return 'That agent is not listening any more.';
700
+ if (String(code).includes('ECONNREFUSED')) return 'That agent stopped listening.';
701
+ return 'The message could not be handed over.';
702
+ }
703
+
704
+ // THREE WAYS TO SEE AN AGENT MOVE, because no single one of them is always
705
+ // there. The transcript grows when the session writes the new turn (absent when
706
+ // the session has transcript saving off); the state file's status leaves
707
+ // `waiting` (absent when a session never published one); and the process starts
708
+ // burning CPU, which is true of every session that took a turn and needs
709
+ // nothing published anywhere.
710
+ //
711
+ // The CPU signal was added after the probe on 2026-08-16: a throwaway session
712
+ // with no transcript and no state file took the message, answered it on screen,
713
+ // and `reply` still had to report that it could not see it move. `ps -o time`
714
+ // resolves to a hundredth of a second and an idle session drifts about 0.02s
715
+ // in three, so a third of a second of new CPU is a session that went to work.
716
+ const CPU_MOVED = 0.15;
717
+
718
+ async function cpuSeconds(pid) {
719
+ try {
720
+ const { stdout } = await run('/bin/ps', ['-o', 'time=', '-p', String(pid)], { encoding: 'utf8', timeout: 3000 });
721
+ const m = stdout.trim().match(/(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)/);
722
+ if (!m) return null;
723
+ const [, d = 0, h = 0, mi, sec] = m;
724
+ return Number(d) * 86400 + Number(h) * 3600 + Number(mi) * 60 + Number(sec);
725
+ } catch { return null; }
726
+ }
727
+
728
+ async function watermark(agent) {
729
+ let size = 0;
730
+ let mtimeMs = 0;
731
+ try {
732
+ if (agent.transcript) { const s = fs.statSync(agent.transcript); size = s.size; mtimeMs = s.mtimeMs; }
733
+ } catch { /* no transcript is not a failure; there are two other signals */ }
734
+ return { size, mtimeMs, status: agent.status, cpu: await cpuSeconds(agent.pid) };
735
+ }
736
+
737
+ async function waitForMovement(agent, before, timeoutMs) {
738
+ const deadline = Date.now() + timeoutMs;
739
+ while (Date.now() < deadline) {
740
+ await new Promise((r) => setTimeout(r, 250));
741
+ try {
742
+ if (agent.transcript) {
743
+ const s = fs.statSync(agent.transcript);
744
+ if (s.size > before.size || s.mtimeMs > before.mtimeMs) return true;
745
+ }
746
+ } catch { /* keep watching the other two */ }
747
+ const state = readState(agent.pid);
748
+ if (state && state.status !== before.status) return true;
749
+ if (before.cpu != null) {
750
+ const now = await cpuSeconds(agent.pid);
751
+ if (now != null && now - before.cpu >= CPU_MOVED) return true;
752
+ }
753
+ }
754
+ return false;
755
+ }
756
+
757
+ // THE SESSION SAYS SO ITSELF, in the lines written after the send: a `system`
758
+ // line whose content opens "Held peer message" and quotes the message back.
759
+ // Only the bytes appended since the watermark are read, because the biggest
760
+ // transcript on her machine is 160MB and this runs on a click.
761
+ const HOLD_LINE = /Held peer message/;
762
+
763
+ function heldNotice(agent, sinceSize, message) {
764
+ // Resolved rather than taken off the record: a session that had not written a
765
+ // transcript when it was last measured has one by the time it holds a
766
+ // message, and that is exactly the session this is for.
767
+ const file = agent.transcript
768
+ ?? (agent.sessionId ? transcriptPath(agent.sessionId, agent.cwd ?? '') : null);
769
+ if (!file) return null;
770
+ let tail = '';
771
+ try {
772
+ const s = fs.statSync(file);
773
+ if (s.size <= sinceSize) return null;
774
+ const fd = fs.openSync(file, 'r');
775
+ try {
776
+ const buf = Buffer.alloc(Math.min(s.size - sinceSize, 1 << 20));
777
+ fs.readSync(fd, buf, 0, buf.length, sinceSize);
778
+ tail = buf.toString('utf8');
779
+ } finally { fs.closeSync(fd); }
780
+ } catch { return null; }
781
+
782
+ const opening = message.slice(0, 60);
783
+ for (const line of tail.split('\n')) {
784
+ if (!HOLD_LINE.test(line)) continue;
785
+ let obj;
786
+ try { obj = JSON.parse(line); } catch { continue; }
787
+ const content = String(obj?.content ?? '');
788
+ if (!HOLD_LINE.test(content)) continue;
789
+ if (opening && !content.includes(opening.slice(0, 40))) continue;
790
+ // Said in her words, not the CLI's. What she can DO about it is the point:
791
+ // the message is sitting in that session's own window waiting for a yes.
792
+ return `${agent.name} held your message for approval in its own window, so it has not read it yet.`;
793
+ }
794
+ return null;
795
+ }
796
+
797
+ // Exported for the test that pins the hold, under a name that says it is not
798
+ // part of the surface: what `reply` returns is.
799
+ export const __heldNotice = heldNotice;
800
+
801
+ function readState(pid) {
802
+ try {
803
+ return JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR(), `${pid}.json`), 'utf8'));
804
+ } catch { return null; }
805
+ }
806
+
807
+ /* ----------------------------- taking her to it -------------------------- */
808
+ // A REPLY CANNOT CLEAR A PERMISSION BOX, so that agent needs her where it is
809
+ // running, and the honest version of "take me there" is smaller than it sounds.
810
+ // All 13 of her outside agents are panes inside another app (CNVS), and there
811
+ // is no public command that focuses one: `claude` in this version has no
812
+ // `attach` and no `logs`, whatever the earlier write-up said. What CAN be done
813
+ // is bring the app that owns the process to the front, so this walks the parent
814
+ // chain to the first real application and offers that by name. If it finds
815
+ // none, it says so rather than pretending.
816
+ export function owningApp(pid) {
817
+ const table = processTable();
818
+ let cur = table.get(pid);
819
+ for (let hops = 0; cur && hops < 12; hops += 1) {
820
+ const m = cur.command.match(/^(\/(?:Applications|System\/Applications)\/[^/]*?\.app)\//);
821
+ if (m) return { path: m[1], name: path.basename(m[1], '.app') };
822
+ cur = table.get(cur.ppid);
823
+ }
824
+ return null;
825
+ }
826
+
827
+ export function reveal(pid) {
828
+ const app = owningApp(pid);
829
+ if (!app) return { ok: false, reason: `${Name} cannot tell which window it is running in.` };
830
+ return new Promise((resolve) => {
831
+ execFile('/usr/bin/open', ['-a', app.path], (err) => {
832
+ resolve(err ? { ok: false, reason: `${app.name} would not come to the front.` } : { ok: true, name: app.name });
833
+ });
834
+ });
835
+ }
836
+
837
+ /* ------------------------------ THE CONVERSATION -------------------------- */
838
+ // What the rest of this file reads on a draw is the four facts a card needs;
839
+ // this reads what the two of them actually SAID, and only ever because she
840
+ // pressed something.
841
+ //
842
+ // IT IS STREAMED, AND THAT IS NOT A FLOURISH. The biggest transcript on her
843
+ // machine is 160MB and costs 729ms to pull the readable text out of (measured
844
+ // 2026-08-17 over all 1,250 files). Doing that with readFileSync on the main
845
+ // process is the window frozen for three quarters of a second on a click, on
846
+ // the one surface built to make her trust the app. readline hands the lines
847
+ // back a chunk at a time and the app draws in between.
848
+ //
849
+ // NO MODEL SEES ANY OF IT, which was her condition on the whole feature, and
850
+ // nothing is written anywhere: this opens a file she already has and reads it.
851
+
852
+ // One turn is capped here rather than where it is drawn, so a pasted 600KB
853
+ // blob never becomes 600KB of React. It is far more than anybody reads and far
854
+ // less than a session can hold.
855
+ const TURN_CHARS = 4000;
856
+
857
+ // A session id is used as a FILENAME, so it is checked before it is one. The
858
+ // renderer is ours, but a path with a slash in it reaching this would be a read
859
+ // outside ~/.claude/projects and there is no reason to leave that open.
860
+ function safeSessionId(id) {
861
+ return /^[A-Za-z0-9._-]+$/.test(String(id ?? '')) ? String(id) : null;
862
+ }
863
+
864
+ function saidByIt(obj) {
865
+ if (obj.type !== 'assistant' || !Array.isArray(obj.message?.content)) return '';
866
+ return obj.message.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n').trim();
867
+ }
868
+
869
+ /* --------------------------- THE WORK IN BETWEEN -------------------------- */
870
+ // WHAT IT DID lives in the assistant line; WHAT CAME BACK arrives later, in the
871
+ // user line that answers it, keyed by the tool's id. So the events are built
872
+ // forwards and their output is filled in behind them, which is why `byId` is
873
+ // held across the read.
874
+
875
+ // Output keeps its HEAD, unlike a turn, which keeps its tail. An answer puts
876
+ // its conclusion at the end; a command puts the thing that went wrong at the
877
+ // start, and `ls` of a big folder is alphabetical. The count of what was cut
878
+ // goes on the line so nothing is rounded off in silence.
879
+ function cappedHead(text) {
880
+ const s = String(text ?? '');
881
+ return s.length > WORK_CHARS ? `${s.slice(0, WORK_CHARS - 1)}…` : s;
882
+ }
883
+
884
+ // `dir` IS THE FOLDER THIS SESSION RUNS IN, and it is what turns
885
+ // `/Users/you/Desktop/dev/zero/renderer/src/components/Tweaks.tsx` into
886
+ // `renderer/src/components/Tweaks.tsx` on her screen.
887
+ //
888
+ // `full` IS THE WHOLE STRING, and it only travels when shortening actually
889
+ // changed something. Her standing objection to every cap in this codebase is
890
+ // one that rounds off without saying so, and the fix for a line she cannot read
891
+ // cannot be a line she cannot recover.
892
+ function workedOn(obj, events, byId, dir = '') {
893
+ if (obj.type !== 'assistant' || !Array.isArray(obj.message?.content)) return;
894
+ const at = Date.parse(obj.timestamp ?? 0) || 0;
895
+ for (const c of obj.message.content) {
896
+ if (c.type !== 'tool_use') continue;
897
+ const subject = workSubject(c.input, dir, os.homedir());
898
+ const whole = fullSubject(c.input);
899
+ // THE FILE IT CHANGED, WHOLE, so the thread can offer it as a chip into the
900
+ // code. Empty on everything that did not write a file, which is most calls:
901
+ // a command, a search, a read.
902
+ const changed = changedFile(c.name, c.input);
903
+ const event = {
904
+ kind: 'work',
905
+ at,
906
+ verb: workVerb(c.name),
907
+ subject,
908
+ ...(changed ? { file: changed } : {}),
909
+ full: whole && whole.replace(/\s+/g, ' ').trim() !== subject ? cappedHead(whole) : '',
910
+ output: '',
911
+ lines: 0,
912
+ failed: false,
913
+ };
914
+ if (c.id) byId.set(c.id, event);
915
+ events.push(event);
916
+ }
917
+ }
918
+
919
+ function cameBack(obj, byId) {
920
+ if (!obj?.toolUseResult || !Array.isArray(obj.message?.content)) return;
921
+ for (const b of obj.message.content) {
922
+ if (b.type !== 'tool_result') continue;
923
+ const event = byId.get(b.tool_use_id);
924
+ if (!event) continue;
925
+ const text = typeof b.content === 'string'
926
+ ? b.content
927
+ : Array.isArray(b.content)
928
+ ? b.content.filter((x) => x.type === 'text').map((x) => x.text).join('\n')
929
+ : '';
930
+ const all = String(text ?? '').replace(/\s+$/, '');
931
+ event.output = cappedHead(all);
932
+ event.lines = all ? all.split('\n').length : 0;
933
+ // THE ONLY COLOUR ON THE WHOLE SURFACE, and it is the tool's own verdict
934
+ // rather than ours: nothing here reads the output looking for the word
935
+ // error, because "0 errors" is not a failure and guessing at one on her
936
+ // screen is worse than saying nothing.
937
+ event.failed = b.is_error === true;
938
+ }
939
+ }
940
+
941
+ // CONSECUTIVE LINES FROM THE SAME SIDE ARE ONE TURN. Claude Code writes an
942
+ // assistant's answer as several lines when it stops to use a tool half way
943
+ // through, so a single reply arrives here as three. Folding them keeps the
944
+ // count honest: "40 messages" should mean forty things somebody said, not forty
945
+ // rows in a file.
946
+ //
947
+ // UNTIL SOMETHING ARRIVES. Folding on the SIDE alone folded every answer a
948
+ // session ever gave into one bubble, because the things that end an answer —
949
+ // her message from Agentbox, a peer's, a delivery notice — are all dropped from
950
+ // the reading and so could not end anything. `seal` is that missing edge, and
951
+ // `arrived` above is what calls it.
952
+ //
953
+ // A TURN IS STAMPED WHEN IT BEGAN, not when it ended, and that is the other
954
+ // half of the same bug. The old code moved the stamp forward on every fold
955
+ // while the text she reads is the head, so session-46's last bubble said 2:48pm
956
+ // today over a sentence from 7:47pm two days earlier and she took it for the
957
+ // answer to what she had just asked. The time and the first line she sees now
958
+ // come from the same moment, and cannot drift apart again. The ellipsis is
959
+ // INSIDE the cap, not on top of it: this is a bound on what the screen is
960
+ // handed, and a bound that can be exceeded by one is not one.
961
+ function capped(text) {
962
+ return text.length > TURN_CHARS ? `…${text.slice(-(TURN_CHARS - 1))}` : text;
963
+ }
964
+
965
+ // The last thing anybody SAID, looking back past however many things it ran in
966
+ // between. Shape B puts work lines in the thread, so the block above a block is
967
+ // routinely a command rather than a sentence.
968
+ function lastSpoken(turns) {
969
+ for (let i = turns.length - 1; i >= 0; i -= 1) {
970
+ if (turns[i].kind !== 'work') return turns[i];
971
+ }
972
+ return null;
973
+ }
974
+
975
+ function seal(turns) {
976
+ const last = turns[turns.length - 1];
977
+ if (last) last.sealed = true;
978
+ // AND THE LAST THING SAID, which is not the last thing in the list once work
979
+ // lines are in the thread. Without this, a reminder arriving between a tool
980
+ // call and the next sentence would seal the tool call, leave the sentence
981
+ // above it open, and the two halves of two different answers would be counted
982
+ // as one message.
983
+ const spoken = lastSpoken(turns);
984
+ if (spoken) spoken.sealed = true;
985
+ }
986
+
987
+ function addTurn(turns, who, text, at) {
988
+ const last = turns[turns.length - 1];
989
+ if (last && !last.sealed && last.who === who) {
990
+ // WHAT OVERFLOWS IS THE OLDEST, NEVER THE NEWEST. This used to cut from the
991
+ // front, so the longer an answer ran the more surely its conclusion was the
992
+ // part thrown away. Measured on session-46: a bubble sat at exactly 4000
993
+ // characters with everything after the first 4000 gone.
994
+ last.text = capped(`${last.text}\n\n${text}`);
995
+ return;
996
+ }
997
+ // A CONTINUATION, NOT A NEW MESSAGE. The work between these two blocks is why
998
+ // they are two blocks, and the thread draws them in that order because that
999
+ // is what happened. But it is still one reply: the count says so, and the
1000
+ // screen does not put a second name and time over the second half of a
1001
+ // sentence somebody was in the middle of.
1002
+ const spoken = lastSpoken(turns);
1003
+ const same = !!spoken && !spoken.sealed && spoken.who === who && turns[turns.length - 1] !== spoken;
1004
+ turns.push(same ? { who, at, text: capped(text), same: true } : { who, at, text: capped(text) });
1005
+ }
1006
+
1007
+ // THE ROW ALREADY KNOWS WHICH SESSION IT IS, so this does not re-measure the
1008
+ // machine to find out. That is not a saving of milliseconds: `refresh` runs a
1009
+ // `ps` over every process on the machine, and doing it on a click would put a
1010
+ // third of a second between her pressing the line and anything happening. The
1011
+ // pid is only walked back to a live session when the caller has no session id,
1012
+ // which is the case a dead session leaves behind. `whole` is the gap line in
1013
+ // the thread being pressed: she is asking for the middle of the conversation
1014
+ // back, so every message is sent and `omitted` comes back zero. Only the work
1015
+ // stays capped.
1016
+ export async function conversation({ pid, sessionId, cwd, spoke = [], whole = false } = {}) {
1017
+ let id = safeSessionId(sessionId);
1018
+ let dir = cwd ?? '';
1019
+ let name = null;
1020
+ if (!id) {
1021
+ const live = (await refresh()).find((a) => a.pid === Number(pid));
1022
+ id = safeSessionId(live?.sessionId);
1023
+ dir = live?.cwd ?? dir;
1024
+ name = live?.name ?? null;
1025
+ }
1026
+ if (!id) return { ok: false, reason: `${Name} cannot tell which conversation this is.` };
1027
+
1028
+ const file = transcriptPath(id, dir);
1029
+ if (!file) return { ok: false, reason: 'This session has not written a transcript yet.' };
1030
+
1031
+ const turns = [];
1032
+ // WHAT IT RAN, KEYED BY ITS ID, because the tool call and the thing that came
1033
+ // back from it are two lines apart in the file and the second one is the only
1034
+ // place the output exists.
1035
+ const byId = new Map();
1036
+ try {
1037
+ const lines = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
1038
+ for await (const line of lines) {
1039
+ if (!line.trim()) continue;
1040
+ let obj;
1041
+ try { obj = JSON.parse(line); } catch { continue; }
1042
+ const at = Date.parse(obj.timestamp ?? 0) || 0;
1043
+ // Anything inbound closes whatever was being said, whether or not it is
1044
+ // shown: a message Agentbox delivered, a peer's, a delivery notice. None of
1045
+ // those are quoted here and all of them mean the answer above is over.
1046
+ if (arrived(obj)) seal(turns);
1047
+ cameBack(obj, byId);
1048
+ const hook = hookFailure(obj);
1049
+ if (hook) {
1050
+ seal(turns);
1051
+ addTurn(turns, 'it', hook, at);
1052
+ seal(turns);
1053
+ continue;
1054
+ }
1055
+ const mine = herTurnRaw(obj);
1056
+ if (mine) { addTurn(turns, 'you', mine, at); seal(turns); continue; }
1057
+ const its = saidByIt(obj);
1058
+ if (its) addTurn(turns, 'it', its, at);
1059
+ // THE WORK GOES IN THE THREAD, IN THE ORDER IT HAPPENED, and it ends the
1060
+ // message above it. Her shape says each thing it ran sits BETWEEN the
1061
+ // messages, so a reply that stopped twice for a tool is three things on
1062
+ // the screen in the order they occurred rather than one bubble with the
1063
+ // work swept behind it. This is the one place the fold is deliberately
1064
+ // broken, and breaking it is the shape she picked.
1065
+ workedOn(obj, turns, byId, dir);
1066
+ }
1067
+ } catch {
1068
+ // A half-read transcript is still worth what it gave us: a conversation
1069
+ // missing its last line beats an empty screen and a shrug.
1070
+ }
1071
+
1072
+ // HER REPLIES FROM AGENTBOX ARE NOT IN THE FILE AS HERS. What IS hers is what
1073
+ // Agentbox itself delivered, which the caller passes in from the record
1074
+ // written at the moment of delivery. Slotted by time, so the thread still
1075
+ // reads in the order it happened.
1076
+ const said = new Set(turns.filter((t) => t.who === 'you').map((t) => t.text.replace(/\s+/g, ' ').trim()));
1077
+ for (const t of Array.isArray(spoke) ? spoke : []) {
1078
+ const text = String(t?.text ?? '').trim();
1079
+ const at = Number(t?.at) || 0;
1080
+ if (!text || !at || said.has(text.replace(/\s+/g, ' ').trim())) continue;
1081
+ let i = turns.length;
1082
+ while (i > 0 && (turns[i - 1].at ?? 0) > at) i -= 1;
1083
+ turns.splice(i, 0, { who: 'you', at, text: text.slice(0, TURN_CHARS) });
1084
+ }
1085
+
1086
+ // The seal is scaffolding for the fold above and not a fact about a turn, so
1087
+ // it does not go over the wire pretending to be one.
1088
+ for (const t of turns) delete t.sealed;
1089
+
1090
+ const { events: shown, omitted } = threadWindow(turns, { whole: !!whole });
1091
+ // `total` counts what somebody SAID, not what is on the screen. The header
1092
+ // reads "40 messages, oldest first", and a run of tool calls is not messages.
1093
+ const total = saidCount(turns);
1094
+ return { ok: true, name, turns: shown, omitted, total };
1095
+ }