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,148 @@
1
+ // WHICH CLAUDE PLAN THIS MACHINE IS SIGNED IN ON, and what Agentbox should do
2
+ // about it. Written for, 2026-08-29, on the one bullet from a tester's
3
+ // onboarding that no session had ever looked at: "not on max plan?"
4
+ //
5
+ // THE PROBLEM. Agentbox runs several Claude Code sessions at once and the number
6
+ // is a flat default (`maxConcurrentSessions: 3`, main/config.mjs) that was
7
+ // chosen on a Max subscription and has never been anything else. Nothing
8
+ // anywhere in the app mentions a plan: not the walk, not the Accounts page, not
9
+ // a row that dies. So a person on a smaller plan gets three sessions started on
10
+ // their behalf, all three burn the same allowance, and what they see is agents
11
+ // that are slow and rows that fail for a reason nobody named. That is what the
12
+ // founder was asking out loud while she watched a tester use it.
13
+ //
14
+ // THE ANSWER IS ALREADY ON THE DISK. Claude Code writes its own account profile
15
+ // into its config file and refreshes it as it runs. MEASURED on her Mac
16
+ // 2026-08-29 20:5x, both of her accounts:
17
+ //
18
+ // ~/.claude.json oauthAccount.organizationType claude_max
19
+ // oauthAccount.organizationRateLimitTier default_claude_max_20x
20
+ // profileFetchedAt 2026-08-29 05:00Z
21
+ // ~/.claude-second/.claude.json the same two values
22
+ // profileFetchedAt 2026-08-29 20:47Z
23
+ //
24
+ // Both fetched within the day, so the field is live rather than a one-time
25
+ // stamp from a login months ago, and it is the honest source: it is Claude
26
+ // Code's own answer about its own subscription, not a guess of ours.
27
+ //
28
+ // WHERE THE FILE IS. Claude Code keeps its config at CLAUDE_CONFIG_DIR when
29
+ // that is set, and at the home folder when it is not. Agentbox's default account
30
+ // runs with nothing set, so its file is `~/.claude.json`; a second account runs
31
+ // with CLAUDE_CONFIG_DIR pointing at a folder, so its file is inside that
32
+ // folder (main/supervisor.mjs sets exactly that per spawn). Both are verified
33
+ // above. Note that `~/.claude/.claude.json` also exists on her Mac and is NINE
34
+ // DAYS STALE — it is what a run with CLAUDE_CONFIG_DIR=~/.claude left behind.
35
+ // It is not read for the default account, and that is deliberate: the rule here
36
+ // is Claude Code's own rule, not a search for the likeliest looking file.
37
+ //
38
+ // WHAT THIS DELIBERATELY DOES NOT DO. It does not guess the strings for plans
39
+ // nobody here has seen. Only one plan has ever been measured on this machine,
40
+ // and inventing `default_claude_pro` in a table would be a number nobody
41
+ // measured. So `max` is decided by looking for the word max, the label is
42
+ // derived from the raw string mechanically, and anything unreadable comes back
43
+ // `known: false` and changes nothing anywhere.
44
+
45
+ import fs from 'node:fs';
46
+ import os from 'node:os';
47
+ import path from 'node:path';
48
+
49
+ /**
50
+ * WHERE CLAUDE CODE'S CONFIG FILE IS FOR ONE OF AGENTBOX'S ACCOUNTS.
51
+ *
52
+ * 'default' is the account with no CLAUDE_CONFIG_DIR, so its file is beside
53
+ * the home folder rather than inside `~/.claude`. Anything else is the folder
54
+ * CLAUDE_CONFIG_DIR names, and the file is inside it. */
55
+ export function planFile(profile, home = os.homedir()) {
56
+ if (!profile || profile === 'default') return path.join(home, '.claude.json');
57
+ const dir = profile.startsWith('~/') ? path.join(home, profile.slice(2)) : profile;
58
+ return path.join(dir, '.claude.json');
59
+ }
60
+
61
+ /**
62
+ * THE WORDS A PERSON WOULD USE FOR A RATE LIMIT TIER, worked out from the
63
+ * string rather than from a table, so a plan this code has never seen still
64
+ * reads as itself instead of as "unknown".
65
+ *
66
+ * `default_claude_max_20x` -> `Max 20x`. The `default_` and `claude_` parts
67
+ * are Anthropic's own scaffolding around the name and say nothing to anybody
68
+ * looking at a settings page. */
69
+ export function planLabel(raw) {
70
+ const s = String(raw ?? '').trim();
71
+ if (!s) return null;
72
+ const bare = s.replace(/^default_/, '').replace(/^claude_/, '');
73
+ if (!bare) return null;
74
+ return bare
75
+ .split('_')
76
+ .filter(Boolean)
77
+ .map((word) => (/^\d/.test(word) ? word : word.charAt(0).toUpperCase() + word.slice(1)))
78
+ .join(' ');
79
+ }
80
+
81
+ /**
82
+ * WHETHER A TIER STRING IS A MAX PLAN. Whole word, so a plan that merely
83
+ * contains the letters (a hypothetical `maximum_something`) is not mistaken
84
+ * for one. */
85
+ export function isMaxTier(raw) {
86
+ return /(^|_)max(_|$)/i.test(String(raw ?? ''));
87
+ }
88
+
89
+ /**
90
+ * WHAT PLAN ONE ACCOUNT IS ON.
91
+ *
92
+ * Never throws and never guesses. A missing file, a half-written one, a login
93
+ * Claude Code has not fetched a profile for yet: all of them come back
94
+ * `known: false`, which every caller here treats as "leave everything exactly
95
+ * as it was". */
96
+ export function readPlan(profile, { home = os.homedir(), file = null } = {}) {
97
+ const at = file ?? planFile(profile, home);
98
+ const none = { known: false, max: false, label: null, tier: null, from: null, file: at };
99
+ let account = null;
100
+ try {
101
+ account = JSON.parse(fs.readFileSync(at, 'utf8'))?.oauthAccount ?? null;
102
+ } catch {
103
+ return none;
104
+ }
105
+ if (!account || typeof account !== 'object') return none;
106
+ // THE ORDER IS MOST SPECIFIC FIRST. A rate limit tier names the plan AND its
107
+ // size (`max_20x` against `max_5x`), which is the thing worth showing; the
108
+ // organization type only says which family it is in. The user tier is read
109
+ // last because it is null on both accounts measured here and exists for seats
110
+ // inside an organization.
111
+ const tier = [account.organizationRateLimitTier, account.userRateLimitTier, account.organizationType]
112
+ .find((v) => typeof v === 'string' && v.trim());
113
+ if (!tier) return none;
114
+ const from = tier === account.organizationRateLimitTier
115
+ ? 'org-tier'
116
+ : tier === account.userRateLimitTier ? 'user-tier' : 'org-type';
117
+ return { known: true, max: isMaxTier(tier), label: planLabel(tier), tier, from, file: at };
118
+ }
119
+
120
+ /**
121
+ * THE PLAN ON EVERY ACCOUNT AGENTBOX IS CONFIGURED TO RUN ON, in the same order
122
+ * the supervisor round-robins them. */
123
+ export function readPlans(profiles, { home = os.homedir() } = {}) {
124
+ const list = (Array.isArray(profiles) && profiles.length ? profiles : ['default']).map((p) => p || 'default');
125
+ return list.map((profile) => ({ profile, ...readPlan(profile, { home }) }));
126
+ }
127
+
128
+ /**
129
+ * HOW MANY SESSIONS AT ONCE, WHEN NOBODY HAS SAID.
130
+ *
131
+ * `fallback` is the number the app has always used and is what comes back in
132
+ * every case except one: at least one account is readable and is NOT a Max
133
+ * plan. Then it is one at a time.
134
+ *
135
+ * WHY THE SMALLEST ACCOUNT DECIDES. The cap is ONE number applied per account
136
+ * (main/supervisor.mjs multiplies it by the live accounts), so there is no way
137
+ * here to give a Max account three and a smaller one fewer. Between over- and
138
+ * under-running somebody's allowance, the under-run is the one that still
139
+ * leaves them a working app, and the number is a stepper on the Agents page
140
+ * that they can put back up.
141
+ *
142
+ * AND AN UNREADABLE ACCOUNT NEVER LOWERS IT. Somebody whose config file we
143
+ * cannot parse gets exactly the app they had before this existed. */
144
+ export function slotsForPlans(plans, fallback = 3) {
145
+ const known = (plans ?? []).filter((p) => p.known);
146
+ if (!known.length) return fallback;
147
+ return known.every((p) => p.max) ? fallback : 1;
148
+ }
@@ -0,0 +1,149 @@
1
+ // RUNNING `/usage`, WHICH TAKES TWENTY-FIVE SECONDS AND MUST NEVER BE WAITED ON.
2
+ //
3
+ // The pure half is shared/claude-usage.mjs: the text in, the limits out, the
4
+ // words the pill prints. This is the half that shells out, and everything about
5
+ // its shape comes from one measurement.
6
+ //
7
+ // TIMED TWICE ON HER MAC, 2026-08-31: 23.2s and 28.0s for `claude -p '/usage'`.
8
+ // The percentage did not move between the two, which is the other half of the
9
+ // answer: this is not a number that changes second to second, so nothing is lost
10
+ // by reading it every few minutes and everything is lost by reading it on a
11
+ // timer the window waits for.
12
+ //
13
+ // SO IT IS A CACHE WITH A BACKGROUND REFRESH, and never a call anything awaits:
14
+ //
15
+ // - `read` answers instantly, always, from the last measurement or with null.
16
+ // It never blocks and never throws.
17
+ // - A refresh is kicked off when the cached figure is older than STALE_MS,
18
+ // and only ever one at a time.
19
+ // - The window is told when a new one lands, the same way everything else in
20
+ // this app tells it: a push.
21
+ //
22
+ // WHY NOT A SESSION. Agentbox spawns real workers through the supervisor and could
23
+ // have asked one of them. It must not: a worker is a unit of her work, it lands
24
+ // in her inbox and burns a slot in the fleet, and this is a status reading. It
25
+ // gets its own short-lived child process with no store, no brief and no row.
26
+ //
27
+ // WHAT IS NOT SENT ANYWHERE. The output carries her usage figures and nothing
28
+ // else leaves this machine because of it; `main/analytics.mjs` is the only file
29
+ // that sends, and none of its eight counts is fed from here.
30
+
31
+ import { execFile } from 'node:child_process';
32
+ import fs from 'node:fs';
33
+ import os from 'node:os';
34
+ import path from 'node:path';
35
+ import { readUsage } from '../shared/claude-usage.mjs';
36
+ import { localZone } from '../shared/usage.mjs';
37
+
38
+ // WHERE THE READING RUNS, AND WHY IT IS NOT "WHEREVER THE APP IS". An app
39
+ // opened from Finder has `/` as its working directory, and this used to spawn
40
+ // Claude Code without saying one, so it inherited that. Claude Code then did
41
+ // what it does in any project folder it is started in: it indexed the folder,
42
+ // which was the whole disk. Measured on her Mac the first time she opened the
43
+ // downloaded 0.1.3: a Claude Code transcript with `cwd: "/"` started at
44
+ // 18:07:31, and TCC recorded "the app" asking for files on a network volume at
45
+ // 18:07:41 and for her Apple Music library at 18:07:45. Those are `/Volumes/*`
46
+ // and `~/Music` being listed, ten seconds into a session whose only job was to
47
+ // print her usage.
48
+ //
49
+ // So the reading runs inside a folder of its own that holds nothing, under the
50
+ // temp directory, which macOS guards with no permission panel at all. Claude
51
+ // Code indexes an empty folder and asks for nothing. The same rule is already
52
+ // what `main/agents.mjs` does for `claude agents --json`.
53
+ export function usageCwd() {
54
+ const dir = path.join(os.tmpdir(), 'agentbox-usage');
55
+ try { fs.mkdirSync(dir, { recursive: true }); } catch { /* the fallback below still keeps it off the disk root */ }
56
+ return fs.existsSync(dir) ? dir : os.tmpdir();
57
+ }
58
+
59
+ // How old a reading may be before the next look at it starts a refresh. Five
60
+ // minutes against a five hour limit is about a percent of it, which is finer
61
+ // than the number itself is reported to.
62
+ export const STALE_MS = 5 * 60_000;
63
+
64
+ // Long enough for the slowest run measured plus most of the same again. Past
65
+ // this the reading is not late, it is not coming.
66
+ const TIMEOUT_MS = 90_000;
67
+
68
+ // After a failure, how long before it is worth trying again. A machine that is
69
+ // signed out, rate limited, or has no Claude Code at all would otherwise be
70
+ // asked every five minutes forever.
71
+ const AFTER_A_MISS_MS = 30 * 60_000;
72
+
73
+ export class ClaudeUsage {
74
+ /**
75
+ * @param {object} how @param { => string|null} how.bin Where Claude Code
76
+ * is, asked each time rather than captured: it can be installed while the
77
+ * app is running, and the settings screen already promises that works.
78
+ * @param { => void} [how.onChange] Told when a new reading lands, so the
79
+ * window redraws. Never called for a refresh that changed nothing.
80
+ */
81
+ constructor({ bin, onChange = () => {}, run = execFile, now = () => Date.now(), where = usageCwd } = {}) {
82
+ this._bin = bin;
83
+ this._onChange = onChange;
84
+ this._run = run;
85
+ this._now = now;
86
+ this._where = where;
87
+ /** @type {{ limits: any[], at: number, zone: string|null }|null} */
88
+ this._last = null;
89
+ this._busy = false;
90
+ this._missedUntil = 0;
91
+ }
92
+
93
+ /**
94
+ * THE LAST READING, INSTANTLY, and a refresh started if it has gone stale.
95
+ *
96
+ * Null until the first one lands, which is the honest answer for the first few
97
+ * seconds of a launch: nothing is known yet, so the corner draws nothing rather
98
+ * than a pill full of dashes.
99
+ */
100
+ read() {
101
+ this._maybeRefresh();
102
+ return this._last ? { limits: this._last.limits, at: this._last.at } : null;
103
+ }
104
+
105
+ // The secondary subscription can show its last reading without starting
106
+ // another coding process merely to populate the sidebar.
107
+ peek() { return this._last ? { limits: this._last.limits, at: this._last.at } : null; }
108
+
109
+ _maybeRefresh() {
110
+ const now = this._now();
111
+ if (this._busy || now < this._missedUntil) return;
112
+ if (this._last && now - this._last.at < STALE_MS) return;
113
+ this._refresh();
114
+ }
115
+
116
+ /** Ask again now, whatever the cache says. The settings screen's own button. */
117
+ refreshNow() {
118
+ this._missedUntil = 0;
119
+ if (!this._busy) this._refresh();
120
+ }
121
+
122
+ _refresh() {
123
+ const bin = (() => { try { return this._bin(); } catch { return null; } })();
124
+ if (!bin) { this._missed(); return; }
125
+ this._busy = true;
126
+ this._run(
127
+ bin,
128
+ ['-p', '/usage', '--output-format', 'text'],
129
+ // `cwd` is the whole fix for the permission panels; see usageCwd above.
130
+ { cwd: this._where(), timeout: TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, encoding: 'utf8' },
131
+ (err, stdout) => {
132
+ this._busy = false;
133
+ // A COMMAND THAT FAILED LEAVES THE OLD READING ALONE. It is stale rather
134
+ // than wrong, and a corner that empties itself every time the network
135
+ // hiccups is worse than one that is five minutes behind.
136
+ if (err && !stdout) { this._missed(); return; }
137
+ const limits = readUsage(stdout, this._now(), localZone());
138
+ if (!limits.length) { this._missed(); return; }
139
+ const before = JSON.stringify(this._last?.limits ?? null);
140
+ this._last = { limits, at: this._now(), zone: localZone() };
141
+ if (JSON.stringify(limits) !== before) this._onChange();
142
+ },
143
+ );
144
+ }
145
+
146
+ _missed() {
147
+ this._missedUntil = this._now() + AFTER_A_MISS_MS;
148
+ }
149
+ }
@@ -0,0 +1,383 @@
1
+ // WHAT A RUN CHANGED, WRITTEN DOWN AS A FILE SHE CAN OPEN.
2
+ //
3
+ // This is the half that runs when the session ends. The pane that draws it is
4
+ // renderer/src/components/CodeArtifact.tsx and the arithmetic it draws with is
5
+ // renderer/src/code-artifact.ts; nothing here draws anything.
6
+ //
7
+ // WHAT THIS FILE READS, AND WHAT IT NO LONGER READS ALONE. The conversation:
8
+ // every edit a Claude Code session makes through the editing tool is a
9
+ // `tool_use` block in its own transcript, carrying the exact text it replaced
10
+ // and the exact text it wrote, stamped, in order. It is the only place the
11
+ // agent's own sentence beside a change exists, and the only reading available
12
+ // on a product with no repository.
13
+ //
14
+ // IT IS NOT ENOUGH ON ITS OWN, AND THAT WAS MEASURED, NOT ARGUED. An agent
15
+ // that rewrites a file with sed or a heredoc puts nothing in its transcript,
16
+ // and on her Mac 2026-08-24 that was 101 of the 105 runs that changed a file
17
+ // over two days. Every one of those left David an empty card. So the disk is
18
+ // read too, by main/git-change.mjs, and `mergeChange` below puts the two
19
+ // together: git's lines, the conversation's words, git winning where they
20
+ // disagree.
21
+ //
22
+ // WHAT IS STILL REFUSED, from her 2026-08-23 answer: a branch diffed against
23
+ // main, and a slab of raw patch handed to the window. What replaced it is a
24
+ // photograph of this run's own checkout, taken when the run spawns.
25
+ // tests/the-old-git-diff-screen-stays-gone.test.mjs holds the line.
26
+ //
27
+ // MEASURED ON HER MAC, 2026-08-23, over the sixty largest conversations: 4,442
28
+ // file writes, 99.7% with the agent's own prose earlier in the same message.
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import { machineryPath } from './store/home.mjs';
32
+
33
+ /** The tools that change a file on disk. Read, Bash and the rest are not edits. */
34
+ const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
35
+
36
+ /** Source we would never show her: the store's own bookkeeping and scratch. */
37
+ const NOT_CODE = /(^|\/)(node_modules|\.git)\//;
38
+
39
+ // AND SCRATCH, WHICH IS NOT THE PRODUCT. A run writes throwaway scripts to
40
+ // /tmp constantly and one of them landed in the tree as a folder called `tmp`
41
+ // on the first real change we read (18 files, and one of them was
42
+ // /tmp/shelf-shots.mjs). Dropped rather than shown: what David is being asked
43
+ // to review is the product, and a file nobody will ever open again reads as
44
+ // work he has to account for.
45
+ const SCRATCH = /^\/(private\/)?(tmp|var\/folders)\//;
46
+
47
+ /**
48
+ * A root, spelt the way the filesystem really spells it.
49
+ *
50
+ * Resolved off the DEEPEST ANCESTOR THAT STILL EXISTS rather than off the root
51
+ * itself, because this runs after the work is over: a worktree can be removed,
52
+ * a scratch folder cleaned up, a branch checked out elsewhere, and a change she
53
+ * can still open must not depend on the checkout outliving the run. `/tmp/gone`
54
+ * resolves through `/tmp` to `/private/tmp/gone` with nothing on disk at all.
55
+ */
56
+ function realRoot(root) {
57
+ let head = path.resolve(root);
58
+ const tail = [];
59
+ for (let i = 0; i < 64; i += 1) {
60
+ try {
61
+ const real = fs.realpathSync(head);
62
+ const full = tail.length ? path.join(real, ...tail) : real;
63
+ return full === path.resolve(root) ? null : full;
64
+ } catch { /* not there; try its parent */ }
65
+ const parent = path.dirname(head);
66
+ if (parent === head) return null;
67
+ tail.unshift(path.basename(head));
68
+ head = parent;
69
+ }
70
+ return null;
71
+ }
72
+
73
+ function lines(text) {
74
+ const s = String(text ?? '');
75
+ if (!s) return [];
76
+ const out = s.split('\n');
77
+ // A trailing newline is a line terminator, not an empty last line.
78
+ if (out.length > 1 && out[out.length - 1] === '') out.pop();
79
+ return out;
80
+ }
81
+
82
+ /**
83
+ * The rows of one edit: what came out, what went in, and the lines around them
84
+ * that did not move.
85
+ *
86
+ * A plain longest-common-subsequence over the two texts, which is what makes a
87
+ * one-line insertion read as one green line inside its own neighbours rather
88
+ * than as the whole block replaced. `old` empty (a Write) is every line green.
89
+ */
90
+ export function rowsForEdit(oldText, newText) {
91
+ const a = lines(oldText);
92
+ const b = lines(newText);
93
+ if (!a.length) return b.map((l) => ['+', l]);
94
+ if (!b.length) return a.map((l) => ['-', l]);
95
+
96
+ // A guard on the table rather than on the file: a 400x400 table is 160k
97
+ // cells and instant, a 20k x 20k one is 400M and would hang the exit
98
+ // handler. Past it the edit is shown as a straight replacement, which is
99
+ // what a whole-file rewrite honestly is.
100
+ if (a.length * b.length > 4_000_000) {
101
+ return [...a.map((l) => ['-', l]), ...b.map((l) => ['+', l])];
102
+ }
103
+
104
+ const n = a.length, m = b.length;
105
+ const table = new Uint32Array((n + 1) * (m + 1));
106
+ const at = (i, j) => i * (m + 1) + j;
107
+ for (let i = n - 1; i >= 0; i--) {
108
+ for (let j = m - 1; j >= 0; j--) {
109
+ table[at(i, j)] = a[i] === b[j]
110
+ ? table[at(i + 1, j + 1)] + 1
111
+ : Math.max(table[at(i + 1, j)], table[at(i, j + 1)]);
112
+ }
113
+ }
114
+ const rows = [];
115
+ let i = 0, j = 0;
116
+ while (i < n && j < m) {
117
+ if (a[i] === b[j]) { rows.push(['=', a[i]]); i++; j++; }
118
+ else if (table[at(i + 1, j)] >= table[at(i, j + 1)]) { rows.push(['-', a[i]]); i++; }
119
+ else { rows.push(['+', b[j]]); j++; }
120
+ }
121
+ while (i < n) { rows.push(['-', a[i]]); i++; }
122
+ while (j < m) { rows.push(['+', b[j]]); j++; }
123
+ return rows;
124
+ }
125
+
126
+ /** Every edit inside one tool call, as one hunk each. */
127
+ function hunksFromCall(input) {
128
+ const out = [];
129
+ if (Array.isArray(input?.edits)) {
130
+ for (const e of input.edits) out.push(rowsForEdit(e?.old_string, e?.new_string));
131
+ return out;
132
+ }
133
+ if (typeof input?.new_string === 'string' || typeof input?.old_string === 'string') {
134
+ out.push(rowsForEdit(input.old_string, input.new_string));
135
+ return out;
136
+ }
137
+ if (typeof input?.content === 'string') {
138
+ out.push(rowsForEdit('', input.content));
139
+ return out;
140
+ }
141
+ if (typeof input?.new_source === 'string') {
142
+ out.push(rowsForEdit(input.old_source ?? '', input.new_source));
143
+ return out;
144
+ }
145
+ return out;
146
+ }
147
+
148
+ /**
149
+ * The agent's own sentence, taken from the text blocks of the same message the
150
+ * edit was in. One line, because that is all the file ever needs to carry.
151
+ */
152
+ function proseFrom(content) {
153
+ if (!Array.isArray(content)) return '';
154
+ const said = content.filter((b) => b?.type === 'text').map((b) => String(b.text ?? '')).join('\n').trim();
155
+ if (!said) return '';
156
+ const first = said.split('\n').map((l) => l.trim()).filter(Boolean).pop() ?? '';
157
+ return first.slice(0, 400);
158
+ }
159
+
160
+ /**
161
+ * A change, built out of one session's own transcript.
162
+ *
163
+ * `roots` are the checkouts this product's work happens in. Anything written
164
+ * outside all of them is somebody else's file or /tmp scratch and is dropped,
165
+ * which is the same rule run-files.ts already applies to the chips on a card.
166
+ * When no root matches at all the absolute path is kept, because a change with
167
+ * no files in it is worse than one with a long name in it.
168
+ */
169
+ /**
170
+ * The tally the reader fills in. It owns the two rules that decide whether a
171
+ * file belongs in her artifact at all — inside one of this run's own roots, and
172
+ * not scratch — and the arithmetic underneath the panel.
173
+ */
174
+ function collector({ roots = [] } = {}) {
175
+ const files = new Map();
176
+ const totals = { plus: 0, minus: 0, editCount: 0, withProse: 0, startedAt: 0, endedAt: 0 };
177
+
178
+ // EACH ROOT AND ALSO WHAT IT REALLY IS, because a tool may report either
179
+ // spelling. Measured 2026-08-26 in a repo under /tmp: the app knew the
180
+ // checkout as `/tmp/proof/widget-repo` and an edit written against
181
+ // `/private/tmp/proof/widget-repo/math.js` matched no root, fell through to
182
+ // the scratch rule below and was dropped — a run that really did change her
183
+ // code finishing with an artifact that did not mention it.
184
+ //
185
+ // macOS is the common case (/tmp is a symlink to /private/tmp, and so is
186
+ // /var), but nothing here is macOS-specific: any checkout reached through a
187
+ // symlink has the same shape, and matching both spellings costs one stat per
188
+ // root, once.
189
+ const spellings = new Set();
190
+ for (const r of roots.filter(Boolean)) {
191
+ spellings.add(r);
192
+ const real = realRoot(r);
193
+ if (real) spellings.add(real);
194
+ }
195
+ const sorted = [...spellings].map((r) => (r.endsWith('/') ? r : `${r}/`)).sort((a, b) => b.length - a.length);
196
+
197
+ const relative = (abs) => {
198
+ for (const root of sorted) if (abs.startsWith(root)) return abs.slice(root.length);
199
+ return null;
200
+ };
201
+
202
+ return {
203
+ files,
204
+ totals,
205
+ add(abs, rows, { said = '', stamp = 0 } = {}) {
206
+ abs = String(abs ?? '').trim();
207
+ if (!abs || NOT_CODE.test(abs)) return;
208
+ const rel = relative(abs);
209
+ // Inside one of this run's own roots it is the product, wherever it
210
+ // sits. Outside all of them AND under scratch, it is nobody's.
211
+ if (rel === null && SCRATCH.test(abs)) return;
212
+ if (!rows.length) return;
213
+ const p = rows.filter((r) => r[0] === '+').length;
214
+ const m = rows.filter((r) => r[0] === '-').length;
215
+ if (!p && !m) return;
216
+ const key = rel ?? abs;
217
+ const file = files.get(key) ?? { path: key, abs, hunks: [], plus: 0, minus: 0, at: stamp };
218
+ file.hunks.push({ rows, said: said || undefined, at: stamp || undefined, plus: p, minus: m });
219
+ file.plus += p; file.minus += m;
220
+ files.set(key, file);
221
+ totals.plus += p; totals.minus += m;
222
+ totals.editCount += 1;
223
+ if (said) totals.withProse += 1;
224
+ if (stamp) {
225
+ if (!totals.startedAt || stamp < totals.startedAt) totals.startedAt = stamp;
226
+ if (stamp > totals.endedAt) totals.endedAt = stamp;
227
+ }
228
+ },
229
+ done() {
230
+ return {
231
+ files: [...files.values()],
232
+ // The checkouts this run's work happened in, so a change read back
233
+ // weeks later can still find the file a short path belongs to.
234
+ roots: [...roots].filter(Boolean),
235
+ plus: totals.plus,
236
+ minus: totals.minus,
237
+ editCount: totals.editCount,
238
+ withProse: totals.withProse,
239
+ startedAt: totals.startedAt || undefined,
240
+ endedAt: totals.endedAt || undefined,
241
+ };
242
+ },
243
+ };
244
+ }
245
+
246
+ export function changeFromTranscript(text, { roots = [], since = 0 } = {}) {
247
+ const into = collector({ roots });
248
+
249
+ for (const raw of String(text ?? '').split('\n')) {
250
+ if (!raw.trim()) continue;
251
+ let row;
252
+ try { row = JSON.parse(raw); } catch { continue; }
253
+ if (row?.type !== 'assistant') continue;
254
+ const content = row?.message?.content;
255
+ if (!Array.isArray(content)) continue;
256
+ const stamp = Date.parse(row?.timestamp ?? '') || 0;
257
+ if (since && stamp && stamp < since) continue;
258
+ const said = proseFrom(content);
259
+ for (const block of content) {
260
+ if (block?.type !== 'tool_use' || !EDIT_TOOLS.has(block.name)) continue;
261
+ // THE ABSOLUTE PATH IS KEPT BESIDE THE SHORT ONE, because a line she
262
+ // types into the change has to be written back to the file it came from,
263
+ // and `renderer/src/api.ts` on its own does not say which checkout that
264
+ // is. The short path is still what the tree and the crumb draw; this is
265
+ // never shown.
266
+ const abs = block?.input?.file_path ?? block?.input?.notebook_path ?? '';
267
+ for (const rows of hunksFromCall(block.input)) into.add(abs, rows, { said, stamp });
268
+ }
269
+ }
270
+
271
+ return into.done();
272
+ }
273
+
274
+ /** Where a run's change is written, product-relative. */
275
+ export function changePath(itemId) {
276
+ return `runs/${itemId}/the-change-it-made.change`;
277
+ }
278
+
279
+ /** An absolute path as the shortest of the run's roots can say it. */
280
+ export function shortPath(abs, roots = []) {
281
+ const sorted = [...roots].filter(Boolean).map((r) => (r.endsWith('/') ? r : `${r}/`)).sort((a, b) => b.length - a.length);
282
+ for (const root of sorted) if (String(abs).startsWith(root)) return String(abs).slice(root.length);
283
+ return null;
284
+ }
285
+
286
+ /**
287
+ * The two readings of one run, put together.
288
+ *
289
+ * WHERE THEY DISAGREE, THE DISK WINS. The conversation only knows what the
290
+ * agent typed through the editing tool, and a run that used sed for half its
291
+ * work would otherwise show half a change, which reads as a finished one. What
292
+ * the transcript keeps is the agent's own sentence beside the hunk, so a file
293
+ * both of them have takes git's lines and the conversation's words.
294
+ *
295
+ * The transcript also keeps every file git cannot see: the drawings and
296
+ * reports a run writes into the product's own folder, which is not a checkout,
297
+ * and anything under a product with no repository at all.
298
+ */
299
+ export function mergeChange(fromTranscript, repoChanges = [], { roots = [] } = {}) {
300
+ const files = new Map(fromTranscript.files.map((f) => [f.path, f]));
301
+ let fromDisk = 0;
302
+ for (const repo of repoChanges) {
303
+ if (!repo?.root) continue;
304
+ for (const file of repo.files ?? []) {
305
+ const abs = path.join(repo.root, file.path);
306
+ const key = shortPath(abs, roots) ?? file.path;
307
+ const said = files.get(key)?.hunks?.find((h) => h.said)?.said;
308
+ const at = files.get(key)?.at;
309
+ const hunks = file.hunks.map((h, i) => (i === 0 && said ? { ...h, said } : h));
310
+ files.set(key, { path: key, abs, hunks, plus: file.plus, minus: file.minus, at, fromDisk: true });
311
+ fromDisk += 1;
312
+ }
313
+ }
314
+ const out = [...files.values()];
315
+ const spawnedAt = repoChanges.map((r) => r?.at).filter(Boolean).sort()[0];
316
+ return {
317
+ ...fromTranscript,
318
+ // A run whose conversation held no edits still has a clock: the moment its
319
+ // checkout was photographed, and the moment it was read back.
320
+ startedAt: fromTranscript.startedAt ?? spawnedAt,
321
+ endedAt: fromTranscript.endedAt ?? (fromDisk ? Date.now() : undefined),
322
+ files: out,
323
+ plus: out.reduce((n, f) => n + (f.plus ?? 0), 0),
324
+ minus: out.reduce((n, f) => n + (f.minus ?? 0), 0),
325
+ editCount: out.reduce((n, f) => n + (f.hunks?.length ?? 0), 0),
326
+ // How many of the files on the card came from reading the disk rather than
327
+ // the conversation. Not drawn; it is how the next round can measure this
328
+ // without re-deriving it.
329
+ fromDisk,
330
+ };
331
+ }
332
+
333
+ /**
334
+ * Build the change for one finished run and put it in the product's folders.
335
+ *
336
+ * `repos` are the snapshots taken of this run's checkouts when it spawned
337
+ * (main/git-change.mjs), already read back into changes. Passing none is the
338
+ * old behaviour exactly: the conversation alone.
339
+ *
340
+ * `change` IS THE CONVERSATION HALF, ALREADY READ, for a run whose conversation
341
+ * this file cannot read. `changeFromTranscript` speaks Claude Code's JSONL and
342
+ * only that; a Codex run's own edits arrive as app-server notifications and are
343
+ * read by `changeFromCodexTurn` in main/codex.mjs, which produces the same
344
+ * shape. When one is handed over, the transcript is not opened at all -- there
345
+ * is nothing in a Codex rollout this reader would find, and half an artifact
346
+ * looks exactly like a finished one.
347
+ *
348
+ * The caller owns the roots it prepared that half with. They are the same roots
349
+ * it passes here, and the repo roots this function adds are already among them
350
+ * on every path that does this (the checkout a run works in is its cwd).
351
+ *
352
+ * Returns the absolute path, or null when the run touched no code at all,
353
+ * which is most runs: a session that answered a question or drew a page has
354
+ * nothing to show here and must not leave an empty artifact behind.
355
+ */
356
+ export function writeChangeForRun({ transcript, docsDir, itemId, roots = [], since = 0, repos = [], change: prepared = null }) {
357
+ if (!docsDir || !itemId) return null;
358
+ const allRoots = [...roots, ...repos.map((r) => r?.root).filter(Boolean)];
359
+ let said = prepared;
360
+ if (!said) {
361
+ let text = '';
362
+ try { text = fs.readFileSync(transcript, 'utf8'); } catch { text = ''; }
363
+ said = changeFromTranscript(text, { roots: allRoots, since });
364
+ }
365
+ const change = mergeChange(said, repos, { roots: allRoots });
366
+ if (!change.files.length) return null;
367
+ // THROUGH THE HOME, NOT INTO HER FOLDER. This line used to be
368
+ // `path.join(docsDir, ...)`, which is the one writer the move in
369
+ // main/store/home.mjs did not catch. So `runs/` was carried out of her folder
370
+ // once and then re-created in it by the next run that changed any code, which
371
+ // is why files kept reappearing there after the move was supposedly done.
372
+ const out = machineryPath(docsDir, changePath(itemId));
373
+ fs.mkdirSync(path.dirname(out), { recursive: true });
374
+ fs.writeFileSync(out, JSON.stringify({ item: itemId, ...change }, null, 1));
375
+ return out;
376
+ }
377
+
378
+ /** Read a change back off disk for the pane. */
379
+ export function readChange(file) {
380
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
381
+ if (!parsed || !Array.isArray(parsed.files)) throw new Error('that file is not a change');
382
+ return parsed;
383
+ }