agentbox-app 0.1.7 → 0.1.12

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 (255) hide show
  1. package/README.md +220 -58
  2. package/bin/agentbox.mjs +18 -3
  3. package/briefs/message-rules.md +24 -3
  4. package/briefs/worker.md +63 -49
  5. package/briefs/writing-rules.md +90 -131
  6. package/main/account-discovery.mjs +21 -20
  7. package/main/account-tooling.mjs +11 -11
  8. package/main/agent-activity.mjs +45 -5
  9. package/main/agent-schedule.mjs +14 -14
  10. package/main/agent-sessions.mjs +94 -10
  11. package/main/agent-updates.mjs +116 -0
  12. package/main/agents.mjs +52 -46
  13. package/main/analytics.mjs +27 -0
  14. package/main/approval-prompt-server.mjs +2 -2
  15. package/main/approvals.mjs +14 -5
  16. package/main/artifact-path.mjs +11 -0
  17. package/main/claude-bin.mjs +32 -5
  18. package/main/claude-input.mjs +45 -3
  19. package/main/claude-plan.mjs +25 -13
  20. package/main/code-change.mjs +3 -3
  21. package/main/codex-account.mjs +1 -1
  22. package/main/codex-bin.mjs +18 -0
  23. package/main/codex-launch-env.mjs +31 -0
  24. package/main/codex-memory-gate.mjs +260 -0
  25. package/main/codex-models.mjs +12 -7
  26. package/main/codex-threads.mjs +46 -23
  27. package/main/codex-usage-file.mjs +7 -7
  28. package/main/codex-usage.mjs +4 -4
  29. package/main/codex-watch.mjs +12 -12
  30. package/main/config.mjs +82 -17
  31. package/main/demo.mjs +17 -14
  32. package/main/dev-window.mjs +9 -9
  33. package/main/doc-scheme.mjs +2 -0
  34. package/main/engine-setup.mjs +204 -0
  35. package/main/feedback.mjs +55 -0
  36. package/main/folder-pictures.mjs +48 -0
  37. package/main/frame-scrollbars.mjs +90 -0
  38. package/main/fresh-user.mjs +7 -4
  39. package/main/git-change-offthread.mjs +70 -0
  40. package/main/git-change-thread.mjs +11 -0
  41. package/main/git-change.mjs +9 -3
  42. package/main/img-scheme.mjs +70 -1
  43. package/main/instruction-settings.mjs +134 -60
  44. package/main/ipc.mjs +335 -42
  45. package/main/leftovers.mjs +518 -0
  46. package/main/live-replies.mjs +43 -5
  47. package/main/machine.mjs +48 -1
  48. package/main/main.mjs +138 -15
  49. package/main/memory-gate-history.mjs +254 -0
  50. package/main/memory-gate-server.mjs +461 -0
  51. package/main/memory-gate.mjs +206 -0
  52. package/main/message-priority.mjs +87 -0
  53. package/main/notify.mjs +1 -1
  54. package/main/rail-note.mjs +2 -2
  55. package/main/refresh-agent-models.mjs +21 -0
  56. package/main/repeats.mjs +4 -4
  57. package/main/row-label.mjs +115 -51
  58. package/main/runs-on.mjs +93 -0
  59. package/main/serve.mjs +77 -5
  60. package/main/settings.mjs +245 -26
  61. package/main/ship-queue.mjs +279 -0
  62. package/main/sign-in-files.mjs +39 -0
  63. package/main/source-updater.mjs +298 -0
  64. package/main/store/home.mjs +84 -7
  65. package/main/store/project.mjs +15 -19
  66. package/main/store/work-items.mjs +150 -6
  67. package/main/store.mjs +245 -13
  68. package/main/supervisor.mjs +1634 -417
  69. package/main/task-commands.mjs +8 -10
  70. package/main/task-folders.mjs +94 -16
  71. package/main/task-remote-control.mjs +3 -3
  72. package/main/task-terminals.mjs +14 -2
  73. package/main/team/index.mjs +440 -0
  74. package/main/team/memory-cloud.mjs +247 -0
  75. package/main/team/projects.mjs +162 -0
  76. package/main/team/session.mjs +225 -0
  77. package/main/team/sign-in.mjs +106 -0
  78. package/main/team/supabase-backend.mjs +231 -0
  79. package/main/team/sync.mjs +163 -0
  80. package/main/updater.mjs +15 -5
  81. package/main/write-before-reload.mjs +50 -0
  82. package/mcp/core/page.mjs +10 -12
  83. package/mcp/core/work.mjs +123 -26
  84. package/mcp/tools.mjs +13 -6
  85. package/package.json +21 -9
  86. package/renderer/dist/assets/agentbox-icon-F1oN9nj_.svg +15 -0
  87. package/renderer/dist/assets/index-ChKsQkRw.js +1169 -0
  88. package/renderer/dist/assets/index-ClBXnq3B.css +32 -0
  89. package/renderer/dist/index.html +2 -2
  90. package/scripts/after-install.mjs +14 -0
  91. package/scripts/approval-server.sh +7 -2
  92. package/scripts/memory-gate-hook.sh +59 -0
  93. package/shared/agent-import.mjs +39 -16
  94. package/shared/agents.d.mts +26 -8
  95. package/shared/agents.mjs +100 -108
  96. package/shared/analytics-events.mjs +19 -2
  97. package/shared/answers.mjs +12 -0
  98. package/shared/bridge-map.d.mts +12 -0
  99. package/shared/bridge-map.mjs +38 -1
  100. package/shared/claude-commands.generated.mjs +2 -2
  101. package/shared/claude-models.generated.mjs +6 -6
  102. package/shared/claude-usage.mjs +6 -6
  103. package/shared/codex-import.mjs +7 -7
  104. package/shared/codex-modes.d.mts +50 -0
  105. package/shared/codex-modes.mjs +1 -1
  106. package/shared/codex-usage.mjs +7 -8
  107. package/shared/demo-world.mjs +6 -6
  108. package/shared/engines.mjs +27 -10
  109. package/shared/feedback.d.mts +14 -0
  110. package/shared/feedback.mjs +44 -0
  111. package/shared/first-run-examples.mjs +4 -4
  112. package/shared/first-run-practice.d.mts +16 -0
  113. package/shared/first-run-practice.mjs +176 -70
  114. package/shared/fresh-user-home.mjs +34 -4
  115. package/shared/instruction-defaults.json +2 -2
  116. package/shared/product-name.mjs +4 -2
  117. package/shared/project-folder-check.mjs +9 -3
  118. package/shared/rank.d.mts +1 -0
  119. package/shared/rank.mjs +22 -0
  120. package/shared/referenced-files.mjs +1 -1
  121. package/shared/runs-on.d.mts +20 -0
  122. package/shared/runs-on.mjs +79 -0
  123. package/shared/schemes.d.mts +11 -0
  124. package/shared/schemes.mjs +1 -1
  125. package/shared/shortcuts.d.mts +41 -0
  126. package/shared/shortcuts.mjs +236 -0
  127. package/shared/spawn-trouble.d.mts +2 -0
  128. package/shared/spawn-trouble.mjs +40 -19
  129. package/shared/team-rules.d.mts +17 -0
  130. package/shared/team-rules.mjs +230 -0
  131. package/shared/team-status.d.mts +11 -0
  132. package/shared/team-status.mjs +90 -0
  133. package/shared/terminal-links.d.mts +2 -0
  134. package/shared/terminal-links.mjs +55 -0
  135. package/shared/terminal-state.d.mts +1 -0
  136. package/shared/terminal-state.mjs +16 -0
  137. package/shared/thread-cards.d.mts +19 -0
  138. package/shared/thread-cards.mjs +254 -0
  139. package/shared/usage.d.mts +1 -1
  140. package/shared/usage.mjs +6 -7
  141. package/shared/work-items.mjs +258 -28
  142. package/shared/work-lines.mjs +393 -16
  143. package/worker-permissions.json +1 -1
  144. package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
  145. package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
  146. package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
  147. package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
  148. package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
  149. package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
  150. package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
  151. package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
  152. package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
  153. package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
  154. package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
  155. package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
  156. package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
  157. package/renderer/dist/assets/index-B7hhfHrT.js +0 -684
  158. package/renderer/dist/assets/index-CwiDv5bL.css +0 -32
  159. package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
  160. package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
  161. package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
  162. package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
  163. package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
  164. package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
  165. package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
  166. package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
  167. package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
  168. package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
  169. package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
  170. package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
  171. package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
  172. package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
  173. package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
  174. package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
  175. package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
  176. package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
  177. package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
  178. package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
  179. package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
  180. package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
  181. package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
  182. package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
  183. package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
  184. package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
  185. package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
  186. package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
  187. package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
  188. package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
  189. package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
  190. package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
  191. package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
  192. package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
  193. package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
  194. package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
  195. package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
  196. package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
  197. package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
  198. package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
  199. package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
  200. package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
  201. package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
  202. package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
  203. package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
  204. package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
  205. package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
  206. package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
  207. package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
  208. package/renderer/dist/emulator/audio-rules.mjs +0 -74
  209. package/renderer/dist/emulator/data/compression/README.md +0 -11
  210. package/renderer/dist/emulator/data/compression/extract7z.js +0 -1
  211. package/renderer/dist/emulator/data/compression/extractzip.js +0 -1
  212. package/renderer/dist/emulator/data/compression/libunrar.js +0 -519
  213. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  214. package/renderer/dist/emulator/data/cores/README.md +0 -22
  215. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  216. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  217. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  218. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  219. package/renderer/dist/emulator/data/cores/package.json +0 -65
  220. package/renderer/dist/emulator/data/cores/reports/mgba.json +0 -1
  221. package/renderer/dist/emulator/data/emulator.css +0 -1614
  222. package/renderer/dist/emulator/data/loader.js +0 -170
  223. package/renderer/dist/emulator/data/localization/README.md +0 -63
  224. package/renderer/dist/emulator/data/localization/af-FR.json +0 -339
  225. package/renderer/dist/emulator/data/localization/ar-AR.json +0 -301
  226. package/renderer/dist/emulator/data/localization/ben-BEN.json +0 -301
  227. package/renderer/dist/emulator/data/localization/de-GER.json +0 -301
  228. package/renderer/dist/emulator/data/localization/el-GR.json +0 -301
  229. package/renderer/dist/emulator/data/localization/en-US.json +0 -339
  230. package/renderer/dist/emulator/data/localization/es-ES.json +0 -310
  231. package/renderer/dist/emulator/data/localization/fa-AF.json +0 -310
  232. package/renderer/dist/emulator/data/localization/hi-HI.json +0 -301
  233. package/renderer/dist/emulator/data/localization/it-IT.json +0 -302
  234. package/renderer/dist/emulator/data/localization/ja-JA.json +0 -301
  235. package/renderer/dist/emulator/data/localization/jv-JV.json +0 -301
  236. package/renderer/dist/emulator/data/localization/ko-KO.json +0 -336
  237. package/renderer/dist/emulator/data/localization/pt-BR.json +0 -361
  238. package/renderer/dist/emulator/data/localization/retroarch.json +0 -617
  239. package/renderer/dist/emulator/data/localization/ro-RO.json +0 -310
  240. package/renderer/dist/emulator/data/localization/ru-RU.json +0 -301
  241. package/renderer/dist/emulator/data/localization/tr-TR.json +0 -310
  242. package/renderer/dist/emulator/data/localization/vi-VN.json +0 -302
  243. package/renderer/dist/emulator/data/localization/zh-CN.json +0 -351
  244. package/renderer/dist/emulator/data/src/GameManager.js +0 -464
  245. package/renderer/dist/emulator/data/src/compression.js +0 -142
  246. package/renderer/dist/emulator/data/src/emulator.js +0 -6158
  247. package/renderer/dist/emulator/data/src/gamepad.js +0 -167
  248. package/renderer/dist/emulator/data/src/nipplejs.js +0 -1
  249. package/renderer/dist/emulator/data/src/shaders.js +0 -236
  250. package/renderer/dist/emulator/data/src/socket.io.min.js +0 -7
  251. package/renderer/dist/emulator/data/src/storage.js +0 -125
  252. package/renderer/dist/emulator/data/version.json +0 -5
  253. package/renderer/dist/emulator/host.js +0 -299
  254. package/renderer/dist/emulator/index.html +0 -28
  255. package/renderer/dist/emulator/provenance.json +0 -6
package/main/agents.mjs CHANGED
@@ -18,7 +18,7 @@ import { execFile } from 'node:child_process';
18
18
  import { promisify } from 'node:util';
19
19
  import { NAME, Name, WAS } from '../shared/product-name.mjs';
20
20
  import {
21
- WORK_CHARS, agentKey, changedFile, fullSubject, saidCount, sameInstant, threadWindow, workSubject, workVerb,
21
+ WORK_CHARS, agentKey, changedFile, commandWork, fullSubject, isBookkeeping, saidCount, sameInstant, threadWindow, workSubject, workVerb,
22
22
  } from '../shared/agents.mjs';
23
23
 
24
24
  const run = promisify(execFile);
@@ -64,7 +64,7 @@ function stillTheSame(rec, proc) {
64
64
  }
65
65
 
66
66
  /* ------------------------------- the sources ----------------------------- */
67
- // TWO SOURCES, UNIONED, because on 2026-08-16 neither one alone saw her
67
+ // TWO SOURCES, UNIONED, because when measured neither one alone saw the whole
68
68
  // machine. `claude agents --json` (the public scripting flag) listed 3 sessions
69
69
  // while 16 were live with sockets bound; the per-session state files listed
70
70
  // those 16 and none of the 3. They disagree about which sessions they know, and
@@ -125,35 +125,29 @@ function scrubbed(env) {
125
125
  // Claude Code already writes three facts into the same transcript this function
126
126
  // is already reading, and they were simply being skipped:
127
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…"
128
+ // ai-title a one-line title the session wrote for ITSELF. Every session
129
+ // measured had one inside the 96KB tail, and it is usually the
130
+ // sentence a person would use to recall the session.
131
+ // last-prompt the last thing the user typed into that session.
134
132
  // tool_use the files it has been editing, reading and writing.
135
133
  //
136
134
  // So the reading is one pass over a tail that was already being read, and the
137
135
  // card goes from three lines about a pid to what the work actually is.
138
136
  //
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.
137
+ // WHAT WAS MISSING WAS THE BEGINNING. The human side of a conversation fits on
138
+ // a card. The agent's side is what is megabytes.
144
139
  //
145
140
  // 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
141
+ // (pasted images are single transcript lines of hundreds of KB), and 96KB
147
142
  // 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.
143
+ // saw only 46% of the human turns.
150
144
  //
151
145
  // 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.
146
+ // Measured: the first pass over 15 transcripts, 117MB, costs 353ms in total
147
+ // and never happens again for that session; after it, a refresh reads only the
148
+ // bytes that arrived. This is the same read the tail was already doing, moved
149
+ // from "the last 96KB" to "everything, once". No model sees any of it, by
150
+ // design.
157
151
  const CHUNK_BYTES = 4 << 20;
158
152
  const historyCache = new Map(); // sessionId -> { file, size, mtimeMs, offset, ...facts }
159
153
  const dirCache = new Map(); // sessionId -> transcript path
@@ -186,11 +180,11 @@ function transcriptPath(sessionId, cwd) {
186
180
  const NOTHING = { lastActiveAt: 0, lastSaid: '', saidAt: 0, saidOpen: false, about: '', lastAsked: '', touched: [], asked: [] };
187
181
 
188
182
  // 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.
183
+ // a bound on the accumulator and not on what the user reads. A human reply is
184
+ // rarely anywhere near 6KB.
191
185
  const SAID_CHARS = 8000;
192
186
 
193
- // WHAT COUNTS AS SOMETHING SHE TYPED. A Claude Code transcript calls a great
187
+ // WHAT COUNTS AS SOMETHING THE USER TYPED. A Claude Code transcript calls a great
194
188
  // many things a `user` message that no human wrote: every tool result comes
195
189
  // back as one, every system reminder is one, every slash command expands into
196
190
  // one. Reading those onto the card would fill her recall with the machine
@@ -198,7 +192,7 @@ const SAID_CHARS = 8000;
198
192
  //
199
193
  // So a turn is hers only when it is a `user` line that is not flagged meta, is
200
194
  // 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.
195
+ // open with a tag. Checked against 155 real human turns.
202
196
  //
203
197
  // THE CARD AND THE CONVERSATION READ THE SAME LINE DIFFERENTLY, which is why
204
198
  // this is two functions and not one. A card quotes a sentence, so it flattens
@@ -210,9 +204,12 @@ const RELAY_MARK = `Relayed from ${NAME},`;
210
204
  // WRITTEN UNDER THE CURRENT NAME, RECOGNISED UNDER EVERY NAME THIS APP HAS
211
205
  // HAD. A transcript is a file on her disk and it outlives a rename, so a
212
206
  // 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));
207
+ // changes, and the app's own preamble starts reading as something the user typed.
208
+ // The letter case is ignored as well: the name was written lowercase until
209
+ // 2026-10-02, and those transcripts say "Relayed from agentbox,".
210
+ const RELAY_MARKERS = [RELAY_MARK, ...WAS.map((was) => `Relayed from ${was},`)].map((m) => m.toLowerCase());
211
+ const wasRelayed = (line) => RELAY_MARKERS.some((m) => line.toLowerCase().startsWith(m));
212
+ export { wasRelayed as __wasRelayed };
216
213
 
217
214
  function herTurnRaw(obj) {
218
215
  if (obj.type !== 'user' || obj.isMeta || obj.isCompactSummary || obj.toolUseResult) return '';
@@ -225,10 +222,10 @@ function herTurnRaw(obj) {
225
222
  const clean = String(text).trim();
226
223
  const flat = clean.replace(/\s+/g, ' ');
227
224
  // 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`
225
+ // front of what the user typed, and today Claude Code stamps the result `isMeta`
229
226
  // 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.
227
+ // instead, the card would quote Agentbox talking to an agent back at the user
228
+ // as their own words. `spoke` already holds what the user actually typed.
232
229
  if (!flat || flat.startsWith('<') || flat.startsWith('[Request interrupted') || wasRelayed(flat)) return '';
233
230
  return clean;
234
231
  }
@@ -431,7 +428,7 @@ function fingerprint(agents) {
431
428
  // a tick and pushing on it re-renders her window for nothing. The first moves
432
429
  // only when the session's status moves, and since a row's LIST depends on it:
433
430
  // 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
431
+ // same `status` both times, and without this the row the user replied to would sit
435
432
  // in In progress until some other push happened to come. AND WHEN IT LAST
436
433
  // SAID SOMETHING, which is the fact this list existed to notice and did
437
434
  // not.An agent that answers her while she is looking at its card moves
@@ -591,12 +588,12 @@ function defaultSocket(pid) {
591
588
  // Nothing here claims her authority.
592
589
  //
593
590
  // 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.
591
+ // and WHAT it is about. MEASURED on a session that had given no response
592
+ // without it; one sentence of context is the whole of the difference.
597
593
  //
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.
594
+ // THE USER'S OWN TEXT IS UNCHANGED AND IS WHAT THE CARD QUOTES: `spoke` records
595
+ // what the user typed, never this, so nothing the user never wrote is ever drawn
596
+ // as their words.
600
597
  export function fromTheApp(message) {
601
598
  return [
602
599
  `${RELAY_MARK} the inbox your user reads. A person typed this, not an agent,`,
@@ -622,7 +619,7 @@ export async function reply(pid, text, { timeoutMs = 3000 } = {}) {
622
619
  const before = await watermark(agent);
623
620
  try {
624
621
  // 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
622
+ // below stays the user's words, so `heldNotice` cannot start matching every
626
623
  // message by its shared opening and the card cannot quote a preamble.
627
624
  await writeToSocket(agent.socket, fromTheApp(message), tokenFor(pid));
628
625
  } catch (err) {
@@ -787,7 +784,7 @@ function heldNotice(agent, sinceSize, message) {
787
784
  const content = String(obj?.content ?? '');
788
785
  if (!HOLD_LINE.test(content)) continue;
789
786
  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:
787
+ // Said in plain words, not the CLI's. What the user can DO about it is the point:
791
788
  // the message is sitting in that session's own window waiting for a yes.
792
789
  return `${agent.name} held your message for approval in its own window, so it has not read it yet.`;
793
790
  }
@@ -846,8 +843,8 @@ export function reveal(pid) {
846
843
  // the one surface built to make her trust the app. readline hands the lines
847
844
  // back a chunk at a time and the app draws in between.
848
845
  //
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.
846
+ // NO MODEL SEES ANY OF IT, which is a condition of the whole feature, and
847
+ // nothing is written anywhere: this opens a file the user already has and reads it.
851
848
 
852
849
  // One turn is capped here rather than where it is drawn, so a pasted 600KB
853
850
  // blob never becomes 600KB of React. It is far more than anybody reads and far
@@ -894,7 +891,13 @@ function workedOn(obj, events, byId, dir = '') {
894
891
  const at = Date.parse(obj.timestamp ?? 0) || 0;
895
892
  for (const c of obj.message.content) {
896
893
  if (c.type !== 'tool_use') continue;
897
- const subject = workSubject(c.input, dir, os.homedir());
894
+ // The store's own records are how a session talks to the app, not work it
895
+ // did, and a thread that opens on them reads as noise (w-0bd0d8b2ef).
896
+ if (isBookkeeping(c.name)) continue;
897
+ // WHAT A COMMAND DID, IN WORDS, when the whole of it is recognised; null on
898
+ // anything else, and the line is then the command it always was.
899
+ const plain = c.name === 'Bash' ? commandWork(c.input?.command, dir, os.homedir()) : null;
900
+ const subject = plain ? plain.subject : workSubject(c.input, dir, os.homedir());
898
901
  const whole = fullSubject(c.input);
899
902
  // THE FILE IT CHANGED, WHOLE, so the thread can offer it as a chip into the
900
903
  // code. Empty on everything that did not write a file, which is most calls:
@@ -903,10 +906,13 @@ function workedOn(obj, events, byId, dir = '') {
903
906
  const event = {
904
907
  kind: 'work',
905
908
  at,
906
- verb: workVerb(c.name),
909
+ verb: plain ? plain.verb : workVerb(c.name),
907
910
  subject,
908
911
  ...(changed ? { file: changed } : {}),
909
- full: whole && whole.replace(/\s+/g, ' ').trim() !== subject ? cappedHead(whole) : '',
912
+ // A line put in words always carries the command, even when the words name
913
+ // nothing ("Checked what changed"): the words replaced the only copy of it
914
+ // on the screen, so it has to stay one press behind them.
915
+ full: plain || (whole && whole.replace(/\s+/g, ' ').trim() !== subject) ? cappedHead(whole) : '',
910
916
  output: '',
911
917
  lines: 0,
912
918
  failed: false,
@@ -1057,11 +1063,11 @@ export async function conversation({ pid, sessionId, cwd, spoke = [], whole = fa
1057
1063
  const its = saidByIt(obj);
1058
1064
  if (its) addTurn(turns, 'it', its, at);
1059
1065
  // 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
1066
+ // message above it. The chosen shape says each thing it ran sits BETWEEN the
1061
1067
  // messages, so a reply that stopped twice for a tool is three things on
1062
1068
  // the screen in the order they occurred rather than one bubble with the
1063
1069
  // work swept behind it. This is the one place the fold is deliberately
1064
- // broken, and breaking it is the shape she picked.
1070
+ // broken, and breaking it is the shape that was chosen.
1065
1071
  workedOn(obj, turns, byId, dir);
1066
1072
  }
1067
1073
  } catch {
@@ -204,3 +204,30 @@ export function createAnalytics({ config, version, dir, client, env = process.en
204
204
  async shutdown() { try { await posthog?.shutdown?.(); } catch {} },
205
205
  };
206
206
  }
207
+
208
+ // "USED TODAY", ONCE PER CALENDAR DAY (2026-10-06). Retention is read from
209
+ // whether an install was used on a day, and `app_opened` cannot say that: a Mac
210
+ // app left open for a week sends it once. This is touched at launch and every
211
+ // time the window comes forward, and sends only on the first touch of each
212
+ // local day. Kept in memory: a relaunch on the same day may send it again,
213
+ // which PostHog's per-install, per-day counting absorbs.
214
+ export function createDailyCount(track) {
215
+ let last = null;
216
+ return (now) => {
217
+ const d = new Date(now);
218
+ const day = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
219
+ if (day === last) return;
220
+ last = day;
221
+ track('day_active');
222
+ };
223
+ }
224
+
225
+ // WHAT AN AGENT'S RUN REPORTS WHEN IT ENDS: how long, whether it failed, and
226
+ // which engine. Nothing about the task it was on.
227
+ export function runEndedProps(session, now) {
228
+ return {
229
+ seconds: Math.round((now - (session.startedAt ?? now)) / 1000),
230
+ failed: !!session.exitFailed,
231
+ engine: session.engine,
232
+ };
233
+ }
@@ -26,7 +26,7 @@ import { answerIsHers, appendSpoolLine, cardDigest, readSpoolJson, removeSpoolEn
26
26
 
27
27
  const SPOOL = process.env.ZERO_APPROVALS_DIR;
28
28
 
29
- // THE KEY THAT SAYS AN ANSWER IS HERS, AND IT IS SAFE TO HAND US.
29
+ // THE KEY THAT SAYS AN ANSWER IS THE USER'S, AND IT IS SAFE TO HAND US.
30
30
  //
31
31
  // This process is the worker's own child, so its environment is the worker's to
32
32
  // read and anything secret given here is a secret the forger has. This is the
@@ -93,7 +93,7 @@ async function decide(args) {
93
93
  // against the command THIS PROCESS is still holding. `request` here is
94
94
  // that memory: it was built from the tool call and has never been read
95
95
  // back off the disk, so comparing it against the digest she signed is
96
- // the whole check. A mismatch is a deny -- she said yes to something
96
+ // the whole check. A mismatch is a deny -- the user said yes to something
97
97
  // else -- and it is loud, because only a rewrite can produce it.
98
98
  const mine = cardDigest(request);
99
99
  if (!mine || answer.card !== mine) {
@@ -387,12 +387,20 @@ const REQUEST_SUFFIX = '.request.json';
387
387
  * FORWARD to now. A worker can therefore make its own request look younger than
388
388
  * it is, which costs it the front of the stack, and can never make it look
389
389
  * older. Nanoseconds because APFS keeps them and two files written back to back
390
- * differ by about 170 microseconds; equal stamps keep `readdir` order, which is
391
- * as good as anything when two things really did happen at once.
390
+ * differ by about 170 microseconds.
391
+ *
392
+ * A coarse clock stamps both files the same. tmpfs does this: two writes in
393
+ * one poll share a ctime, and `readdir` then comes back in name order, which
394
+ * is the forgery the paragraph above is about. The inode breaks that tie.
395
+ * The kernel assigns it, a later create in a fresh directory gets a higher
396
+ * one, and a worker cannot move it backwards. Equal inode and equal ctime
397
+ * keep the order we already had.
392
398
  */
393
399
  function bornAt(dir, name) {
394
- try { return fs.lstatSync(path.join(dir, name), { bigint: true }).ctimeNs; }
395
- catch { return 2n ** 63n; } // gone or unreadable: never in front of something real
400
+ try {
401
+ const st = fs.lstatSync(path.join(dir, name), { bigint: true });
402
+ return { ctime: st.ctimeNs, ino: st.ino };
403
+ } catch { return { ctime: 2n ** 63n, ino: 2n ** 63n }; } // gone or unreadable: never in front of something real
396
404
  }
397
405
 
398
406
  export function listPending(storeRoot, { now = Date.now } = {}) {
@@ -409,7 +417,8 @@ export function listPending(storeRoot, { now = Date.now } = {}) {
409
417
  for (const { f } of names
410
418
  .filter((f) => !seenAt.has(prefix + f.slice(0, -REQUEST_SUFFIX.length)))
411
419
  .map((f) => ({ f, born: bornAt(dir, f) }))
412
- .sort((a, b) => (a.born < b.born ? -1 : a.born > b.born ? 1 : 0))) {
420
+ .sort((a, b) => (a.born.ctime < b.born.ctime ? -1 : a.born.ctime > b.born.ctime ? 1
421
+ : a.born.ino < b.born.ino ? -1 : a.born.ino > b.born.ino ? 1 : 0))) {
413
422
  seenAt.set(prefix + f.slice(0, -REQUEST_SUFFIX.length), { at, seq: sightings += 1 });
414
423
  }
415
424
  const present = new Set();
@@ -125,6 +125,17 @@ export function resolveArtifact({ src, product, products = [], accountRoot, stor
125
125
  }
126
126
  } else {
127
127
  for (const root of roots) candidates.push(path.join(root, raw));
128
+ // AND THE WORKER'S OWN CHECKOUT, when the path names its task. Every run
129
+ // works in `<repo>/.claude/worktrees/<task id>` (./task-folders.mjs), so
130
+ // `designs/w-1b574413db/a1.png` is relative to that folder, and the hunt
131
+ // below never finds it because it skips every folder starting with a dot.
132
+ // Only a task the path itself names: a bare `shots/a.png` must not reach
133
+ // into some other task's checkout (2026-10-04, w-7fe215448b).
134
+ if (own?.repoPath) {
135
+ for (const segment of raw.split('/')) {
136
+ if (/^w-[0-9a-f]{6,}$/i.test(segment)) candidates.push(path.join(own.repoPath, '.claude', 'worktrees', segment, raw));
137
+ }
138
+ }
128
139
  }
129
140
 
130
141
  for (const full of candidates) {
@@ -6,7 +6,8 @@
6
6
  // the app found nothing, said "missing" on a grey dot in Settings, and gave no
7
7
  // sentence and no next step. It read as broken rather than as unfinished.
8
8
  //
9
- // HER RULE, 2026-08-23, and it is the one this file exists to keep:
9
+ // THE RULE this file exists to keep: never tell someone Claude Code is
10
+ // missing unless we are certain it is.
10
11
  //
11
12
  // So there are two different questions here and they must never be answered by
12
13
  // the same value. `found` is whether a runnable file is really there, and it
@@ -45,10 +46,10 @@
45
46
  // the same way, and it is a real state: the installer points ~/.local/bin/claude
46
47
  // at a versioned file and swaps that file on every update.
47
48
  //
48
- // Nothing here overrides her file. An explicit `claudeBin` in zero.config.json
49
- // wins untouched and is never searched around, because that file's first line
50
- // is "all paths explicit, no discovery magic" and a search that second-guesses
51
- // a setting she wrote is exactly that.
49
+ // Nothing here overrides the config file. An explicit `claudeBin` in
50
+ // zero.config.json wins untouched and is never searched around, because that
51
+ // file promises explicit paths with no discovery, and a search that
52
+ // second-guesses a written setting would break that promise.
52
53
 
53
54
  import fs from 'node:fs';
54
55
  import os from 'node:os';
@@ -70,6 +71,7 @@ export function candidatePaths(home = os.homedir()) {
70
71
  path.join(home, '.npm-packages/bin/claude'), // actually write in a guide
71
72
  path.join(home, '.yarn/bin/claude'), // yarn global
72
73
  path.join(home, 'Library/pnpm/claude'), // pnpm's own bin on macOS
74
+ path.join(home, '.local/share/pnpm/claude'), // pnpm's own bin on Linux
73
75
  path.join(home, '.deno/bin/claude'), // deno
74
76
  path.join(home, 'bin/claude'), // a hand-rolled ~/bin on PATH
75
77
  '/opt/local/bin/claude', // MacPorts
@@ -96,6 +98,27 @@ function defaultReaddir(dir) {
96
98
  try { return fs.readdirSync(dir); } catch { return []; }
97
99
  }
98
100
 
101
+ // THE COPY THE CLAUDE APP KEEPS FOR ITSELF, one folder per version, newest
102
+ // first. Somebody who has only ever used Claude Code inside the Claude app has
103
+ // this and nothing else (w-9f6975906c, measured 2026-10-05: 2.1.209 there, and
104
+ // it shared the CLI's sign-in). It is searched LAST, after the shell, because it
105
+ // updates when the app does and a real install is fresher.
106
+ export function appCopyPaths(home = os.homedir(), readdir = defaultReaddir) {
107
+ const dir = path.join(home, 'Library/Application Support/Claude/claude-code');
108
+ return readdir(dir)
109
+ .filter((v) => /^\d+(\.\d+)*$/.test(v))
110
+ .sort(newestFirst)
111
+ .map((v) => path.join(dir, v, 'claude.app/Contents/MacOS/claude'));
112
+ }
113
+
114
+ function newestFirst(a, b) {
115
+ const x = a.split('.').map(Number), y = b.split('.').map(Number);
116
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
117
+ if ((x[i] ?? 0) !== (y[i] ?? 0)) return (y[i] ?? 0) - (x[i] ?? 0);
118
+ }
119
+ return 0;
120
+ }
121
+
99
122
  // WHAT CLAUDE CODE LEAVES BEHIND. Any one of these means it has run on this
100
123
  // Mac. None of them is a binary and none of them is ever spawned; they exist
101
124
  // only to stop the app announcing an absence it cannot support.
@@ -263,6 +286,10 @@ export function findClaudeBin({
263
286
  const fromShell = shellSaid(shellLookup());
264
287
  if (fromShell.path) return { path: fromShell.path, found: true, certain: true, from: 'shell', searched, evidence: null };
265
288
 
289
+ for (const candidate of appCopyPaths(home, readdir)) {
290
+ if (exists(candidate)) return { path: candidate, found: true, certain: true, from: 'app', searched, evidence: null };
291
+ }
292
+
266
293
  // NOT FOUND, AND WHETHER THAT IS KNOWN. Every cheap path missed and every
267
294
  // shell we could reach missed, so the last question is whether this Mac shows
268
295
  // any sign of Claude Code anyway. If it does, we looked in the wrong places
@@ -12,6 +12,10 @@ function turnClosed(message) {
12
12
  return error;
13
13
  }
14
14
 
15
+ // How long a finished background job may go without the agent waking for it
16
+ // before the session is closed anyway. Measured wakes arrive in about a second.
17
+ const WAKE_GRACE_MS = 2 * 60 * 1000;
18
+
15
19
  export function claudeStreamArgs(args) {
16
20
  const copy = [...args];
17
21
  const index = copy.indexOf('-p');
@@ -27,7 +31,21 @@ export function attachClaudeInput(child) {
27
31
  const controls = new Map();
28
32
  const seenUsers = new Set();
29
33
  const pending = new Map();
30
- const closeInput = () => { closed = true; child.stdin.end(); };
34
+ // WHAT THE AGENT STARTED AND HAS NOT HEARD BACK FROM: a background command,
35
+ // a command Claude Code moved to the background past its time limit, a
36
+ // background helper. Claude Code lists them in `background_tasks_changed`
37
+ // and wakes the agent itself when one ends, but only while stdin is open. A
38
+ // result with any of these out is "I'll report once it finishes", not the
39
+ // end of the run, and closing there killed the suite it was waiting on
40
+ // (tests/an-agent-waiting-on-its-own-tests-does-not-come-back-to-you).
41
+ let waiting = [], wakeTimer = null;
42
+ // Input only ever closes at a result with nothing out, so a closed input is
43
+ // the one proof the run reached its end. A session that exits with input
44
+ // still open was stopped part-way, whatever its last turn said
45
+ // (tests/a-worker-whose-helpers-are-still-working-does-not-come-back-to-you).
46
+ let ended = false;
47
+ const closeInput = () => { closed = true; ended = true; clearTimeout(wakeTimer); child.stdin.end(); };
48
+ const mayClose = () => !held && atResult && !pending.size && !waiting.length;
31
49
  const fail = reason => {
32
50
  closed = true;
33
51
  for (const request of pending.values()) {
@@ -72,16 +90,29 @@ export function attachClaudeInput(child) {
72
90
  atResult = false;
73
91
  request.resolve({ accepted: true });
74
92
  }
93
+ if (event.type === 'system' && event.subtype === 'background_tasks_changed' && Array.isArray(event.tasks)) {
94
+ waiting = event.tasks;
95
+ // The last one ended between turns. Claude Code wakes the agent for
96
+ // it, and that turn's result closes as usual. The timer is only for a
97
+ // wake that never comes, so a session cannot sit open forever.
98
+ clearTimeout(wakeTimer);
99
+ if (!waiting.length && atResult && !closed) {
100
+ wakeTimer = setTimeout(() => { if (mayClose() && !closed) closeInput(); }, WAKE_GRACE_MS);
101
+ wakeTimer.unref?.();
102
+ }
103
+ }
75
104
  if (event.type === 'result') {
76
105
  atResult = true;
77
- if (!held && !pending.size) closeInput();
106
+ if (mayClose()) closeInput();
78
107
  }
79
108
  }
80
109
  });
110
+ child.waitingOn = () => [...waiting];
111
+ child.ranToTheEnd = () => ended;
81
112
  child.holdInput = value => {
82
113
  if (closed && value) throw Error('This session has ended. Open remote control again to resume it.');
83
114
  held = !!value;
84
- if (!held && atResult && !pending.size) closeInput();
115
+ if (mayClose()) closeInput();
85
116
  };
86
117
  child.control = (request, timeoutMs = 30000) => new Promise((resolve, reject) => {
87
118
  if (closed || !child.stdin.writable) return reject(Error('The agent connection is closed.'));
@@ -92,6 +123,17 @@ export function attachClaudeInput(child) {
92
123
  try { child.stdin.write(JSON.stringify({ type:'control_request', request_id, request }) + '\n'); }
93
124
  catch(error) { clearTimeout(timer); controls.delete(request_id); reject(error); }
94
125
  });
126
+ // SEND NOW: cut the step the agent is in so her waiting message is answered
127
+ // as a turn of its own, instead of after a command that may run for minutes
128
+ // (tests/send-now-cuts-the-current-step-so-the-agent-answers). Measured on a
129
+ // 45-second command: 43 s of waiting without it, 1.4 s to the answer with it.
130
+ // ONLY WHILE A MESSAGE OF HERS IS WAITING. The cut turn ends in a result,
131
+ // and with nothing pending that result closes the input and ends the run.
132
+ child.interrupt = () => {
133
+ if (closed || !pending.size) return Promise.resolve({ interrupted: false });
134
+ child.lastInterruptAt = Date.now();
135
+ return child.control({ subtype: 'interrupt' }).then(() => ({ interrupted: true }));
136
+ };
95
137
  // NO CLOCK ON THE ACKNOWLEDGEMENT. Claude replays a queued message at its next
96
138
  // break, and a break can be minutes away: a worker inside one long command
97
139
  // (a render poll, `sleep 12` fifty times) holds it until the command returns.
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // THE PROBLEM. Agentbox runs several Claude Code sessions at once and the number
6
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
7
+ // chosen with the biggest plan in mind and has never been anything else. Nothing
8
8
  // anywhere in the app mentions a plan: not the walk, not the Accounts page, not
9
9
  // a row that dies. So a person on a smaller plan gets three sessions started on
10
10
  // their behalf, all three burn the same allowance, and what they see is agents
@@ -12,16 +12,16 @@
12
12
  // founder was asking out loud while she watched a tester use it.
13
13
  //
14
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:
15
+ // into its config file and refreshes it as it runs. On a machine with two
16
+ // accounts signed in, for example:
17
17
  //
18
18
  // ~/.claude.json oauthAccount.organizationType claude_max
19
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
20
+ // profileFetchedAt (within the day)
21
+ // ~/.claude-second/.claude.json the same two fields
22
+ // profileFetchedAt (within the day)
23
23
  //
24
- // Both fetched within the day, so the field is live rather than a one-time
24
+ // Each is refetched within the day, so the field is live rather than a one-time
25
25
  // stamp from a login months ago, and it is the honest source: it is Claude
26
26
  // Code's own answer about its own subscription, not a guess of ours.
27
27
  //
@@ -30,14 +30,14 @@
30
30
  // runs with nothing set, so its file is `~/.claude.json`; a second account runs
31
31
  // with CLAUDE_CONFIG_DIR pointing at a folder, so its file is inside that
32
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.
33
+ // above. Note that a `~/.claude/.claude.json` can also exist and be days
34
+ // STALE — it is what a run with CLAUDE_CONFIG_DIR=~/.claude left behind.
35
35
  // It is not read for the default account, and that is deliberate: the rule here
36
36
  // is Claude Code's own rule, not a search for the likeliest looking file.
37
37
  //
38
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
39
+ // nobody here has seen in a real file, and inventing `default_claude_pro` in
40
+ // a table would be a number nobody
41
41
  // measured. So `max` is decided by looking for the word max, the label is
42
42
  // derived from the raw string mechanically, and anything unreadable comes back
43
43
  // `known: false` and changes nothing anywhere.
@@ -95,13 +95,25 @@ export function isMaxTier(raw) {
95
95
  * as it was". */
96
96
  export function readPlan(profile, { home = os.homedir(), file = null } = {}) {
97
97
  const at = file ?? planFile(profile, home);
98
- const none = { known: false, max: false, label: null, tier: null, from: null, file: at };
99
98
  let account = null;
100
99
  try {
101
100
  account = JSON.parse(fs.readFileSync(at, 'utf8'))?.oauthAccount ?? null;
102
101
  } catch {
103
- return none;
102
+ return planOfAccount(null, at);
104
103
  }
104
+ return planOfAccount(account, at);
105
+ }
106
+
107
+ /**
108
+ * THE SAME ANSWER OFF AN `oauthAccount` SOMEBODY ALREADY HAS IN HAND.
109
+ *
110
+ * Lifted out of `readPlan` so a caller that has read the file for its own
111
+ * reasons (main/runs-on.mjs, which also wants to know whether there is a login
112
+ * in there at all) does not carry a second copy of the tier ordering. One copy
113
+ * of that rule, and the file read stays in one place per caller.
114
+ */
115
+ export function planOfAccount(account, at = null) {
116
+ const none = { known: false, max: false, label: null, tier: null, from: null, file: at };
105
117
  if (!account || typeof account !== 'object') return none;
106
118
  // THE ORDER IS MOST SPECIFIC FIRST. A rate limit tier names the plan AND its
107
119
  // size (`max_20x` against `max_5x`), which is the thing worth showing; the
@@ -14,7 +14,7 @@
14
14
  // IT IS NOT ENOUGH ON ITS OWN, AND THAT WAS MEASURED, NOT ARGUED. An agent
15
15
  // that rewrites a file with sed or a heredoc puts nothing in its transcript,
16
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
17
+ // over two days. Every one of those left a tester an empty card. So the disk is
18
18
  // read too, by main/git-change.mjs, and `mergeChange` below puts the two
19
19
  // together: git's lines, the conversation's words, git winning where they
20
20
  // disagree.
@@ -39,9 +39,9 @@ const NOT_CODE = /(^|\/)(node_modules|\.git)\//;
39
39
  // AND SCRATCH, WHICH IS NOT THE PRODUCT. A run writes throwaway scripts to
40
40
  // /tmp constantly and one of them landed in the tree as a folder called `tmp`
41
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
42
+ // /tmp/shelf-shots.mjs). Dropped rather than shown: what a tester is being asked
43
43
  // to review is the product, and a file nobody will ever open again reads as
44
- // work he has to account for.
44
+ // work they have to account for.
45
45
  const SCRATCH = /^\/(private\/)?(tmp|var\/folders)\//;
46
46
 
47
47
  /**
@@ -35,7 +35,7 @@ const AUTH_CLAIM = 'https://api.openai.com/auth';
35
35
 
36
36
  /**
37
37
  * The plan ids whose proper names are known. Everything else is printed exactly
38
- * as Codex spells it, and that is deliberate: her own token reads "prolite",
38
+ * as Codex spells it, and that is deliberate: a real token can read "prolite",
39
39
  * which title-cases into "Prolite", a product name nobody at OpenAI has ever
40
40
  * used. Inventing a label is how a screen tells a confident lie about what
41
41
  * somebody is paying for, so an id we do not know is shown as the id. */
@@ -114,6 +114,7 @@ export function candidatePaths(home = os.homedir()) {
114
114
  path.join(home, '.npm-packages/bin/codex'), // actually write in a guide
115
115
  path.join(home, '.yarn/bin/codex'),
116
116
  path.join(home, 'Library/pnpm/codex'), // pnpm's own bin on macOS
117
+ path.join(home, '.local/share/pnpm/codex'), // pnpm's own bin on Linux
117
118
  path.join(home, '.deno/bin/codex'),
118
119
  path.join(home, 'bin/codex'), // a hand-rolled ~/bin on PATH
119
120
  '/opt/local/bin/codex', // MacPorts
@@ -140,6 +141,19 @@ function defaultReaddir(dir) {
140
141
  try { return fs.readdirSync(dir); } catch { return []; }
141
142
  }
142
143
 
144
+ // THE COPY THE CHATGPT APP CARRIES INSIDE ITSELF. Somebody who has only ever
145
+ // used Codex in the ChatGPT app has this and nothing else (w-9f6975906c,
146
+ // measured 2026-10-05: codex-cli 0.158.0-alpha.2, sharing the CLI's sign-in).
147
+ // Searched LAST, after the shell, because it moves with the app's releases.
148
+ //
149
+ // AND THE ONE THE CODEX DESKTOP APP CARRIES (w-d5d632e503). Its reported place,
150
+ // not measured on a Mac here: none of the Macs this was written on had the app.
151
+ // A path that is not there costs one missed `exists`.
152
+ export function appCopyPaths(home = os.homedir()) {
153
+ const inside = ['ChatGPT.app/Contents/Resources/codex-cli/bin/codex', 'Codex.app/Contents/Resources/codex'];
154
+ return inside.flatMap((p) => [path.join('/Applications', p), path.join(home, 'Applications', p)]);
155
+ }
156
+
143
157
  // WHAT CODEX LEAVES BEHIND. None of these is a binary and none is ever spawned.
144
158
  // They exist only to stop the app claiming an absence it has not established.
145
159
  //
@@ -214,6 +228,10 @@ export function findCodexBin({
214
228
  const fromShell = shellSaid(shellLookup());
215
229
  if (fromShell.path) return { path: fromShell.path, found: true, certain: true, from: 'shell', searched, evidence: null };
216
230
 
231
+ for (const candidate of appCopyPaths(home)) {
232
+ if (exists(candidate)) return { path: candidate, found: true, certain: true, from: 'app', searched, evidence: null };
233
+ }
234
+
217
235
  // NOT FOUND, AND WHETHER THAT IS KNOWN. Every cheap path missed and every
218
236
  // shell we could reach missed, so the last question is whether this Mac shows
219
237
  // any sign of Codex anyway.