agentbox-app 0.1.6 → 0.1.11

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 (244) hide show
  1. package/README.md +209 -55
  2. package/bin/agentbox.mjs +6 -0
  3. package/briefs/message-rules.md +13 -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 +9 -10
  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 +114 -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/artifact-path.mjs +11 -0
  16. package/main/claude-bin.mjs +31 -5
  17. package/main/claude-input.mjs +45 -3
  18. package/main/claude-plan.mjs +11 -11
  19. package/main/code-change.mjs +3 -3
  20. package/main/codex-account.mjs +1 -1
  21. package/main/codex-bin.mjs +17 -0
  22. package/main/codex-memory-gate.mjs +260 -0
  23. package/main/codex-models.mjs +12 -7
  24. package/main/codex-threads.mjs +46 -23
  25. package/main/codex-usage-file.mjs +7 -7
  26. package/main/codex-usage.mjs +4 -4
  27. package/main/codex-watch.mjs +12 -12
  28. package/main/config.mjs +68 -8
  29. package/main/dev-window.mjs +9 -9
  30. package/main/doc-scheme.mjs +2 -0
  31. package/main/engine-setup.mjs +193 -0
  32. package/main/feedback.mjs +55 -0
  33. package/main/folder-pictures.mjs +48 -0
  34. package/main/frame-scrollbars.mjs +90 -0
  35. package/main/fresh-user.mjs +6 -3
  36. package/main/git-change-offthread.mjs +70 -0
  37. package/main/git-change-thread.mjs +11 -0
  38. package/main/git-change.mjs +1 -1
  39. package/main/img-scheme.mjs +70 -1
  40. package/main/instruction-settings.mjs +92 -54
  41. package/main/ipc.mjs +316 -42
  42. package/main/leftovers.mjs +518 -0
  43. package/main/live-replies.mjs +43 -5
  44. package/main/machine.mjs +47 -0
  45. package/main/main.mjs +119 -15
  46. package/main/memory-gate-history.mjs +254 -0
  47. package/main/memory-gate-server.mjs +461 -0
  48. package/main/memory-gate.mjs +206 -0
  49. package/main/message-priority.mjs +87 -0
  50. package/main/rail-note.mjs +2 -2
  51. package/main/refresh-agent-models.mjs +20 -0
  52. package/main/repeats.mjs +4 -4
  53. package/main/row-label.mjs +114 -51
  54. package/main/serve.mjs +67 -5
  55. package/main/settings.mjs +223 -26
  56. package/main/ship-queue.mjs +279 -0
  57. package/main/sign-in-files.mjs +39 -0
  58. package/main/source-updater.mjs +298 -0
  59. package/main/store/home.mjs +47 -2
  60. package/main/store/work-items.mjs +150 -6
  61. package/main/store.mjs +235 -9
  62. package/main/supervisor.mjs +1444 -406
  63. package/main/task-commands.mjs +8 -10
  64. package/main/task-folders.mjs +74 -13
  65. package/main/task-remote-control.mjs +3 -3
  66. package/main/task-terminals.mjs +2 -1
  67. package/main/team/index.mjs +440 -0
  68. package/main/team/memory-cloud.mjs +247 -0
  69. package/main/team/projects.mjs +162 -0
  70. package/main/team/session.mjs +225 -0
  71. package/main/team/sign-in.mjs +106 -0
  72. package/main/team/supabase-backend.mjs +231 -0
  73. package/main/team/sync.mjs +163 -0
  74. package/main/updater.mjs +15 -5
  75. package/main/write-before-reload.mjs +50 -0
  76. package/mcp/core/page.mjs +9 -11
  77. package/mcp/core/work.mjs +91 -25
  78. package/mcp/tools.mjs +12 -6
  79. package/package.json +21 -9
  80. package/renderer/dist/assets/agentbox-icon-F1oN9nj_.svg +15 -0
  81. package/renderer/dist/assets/index-B-2Az952.css +32 -0
  82. package/renderer/dist/assets/index-D6qncgai.js +1168 -0
  83. package/renderer/dist/index.html +2 -2
  84. package/scripts/memory-gate-hook.sh +59 -0
  85. package/shared/agent-import.mjs +38 -15
  86. package/shared/agents.d.mts +26 -8
  87. package/shared/agents.mjs +100 -108
  88. package/shared/analytics-events.mjs +19 -2
  89. package/shared/answers.mjs +12 -0
  90. package/shared/bridge-map.d.mts +12 -0
  91. package/shared/bridge-map.mjs +37 -1
  92. package/shared/claude-commands.generated.mjs +2 -2
  93. package/shared/claude-models.generated.mjs +4 -4
  94. package/shared/claude-usage.mjs +6 -6
  95. package/shared/codex-import.mjs +7 -7
  96. package/shared/codex-modes.d.mts +50 -0
  97. package/shared/codex-usage.mjs +7 -8
  98. package/shared/demo-world.mjs +5 -5
  99. package/shared/engines.mjs +27 -10
  100. package/shared/feedback.d.mts +14 -0
  101. package/shared/feedback.mjs +44 -0
  102. package/shared/first-run-examples.mjs +4 -4
  103. package/shared/first-run-practice.d.mts +16 -0
  104. package/shared/first-run-practice.mjs +176 -70
  105. package/shared/fresh-user-home.mjs +22 -3
  106. package/shared/instruction-defaults.json +2 -2
  107. package/shared/product-name.mjs +4 -2
  108. package/shared/project-folder-check.mjs +6 -0
  109. package/shared/rank.d.mts +1 -0
  110. package/shared/rank.mjs +22 -0
  111. package/shared/referenced-files.mjs +1 -1
  112. package/shared/schemes.d.mts +11 -0
  113. package/shared/schemes.mjs +1 -1
  114. package/shared/shortcuts.d.mts +41 -0
  115. package/shared/shortcuts.mjs +236 -0
  116. package/shared/spawn-trouble.d.mts +2 -0
  117. package/shared/spawn-trouble.mjs +39 -18
  118. package/shared/team-rules.d.mts +17 -0
  119. package/shared/team-rules.mjs +230 -0
  120. package/shared/team-status.d.mts +11 -0
  121. package/shared/team-status.mjs +90 -0
  122. package/shared/terminal-links.d.mts +2 -0
  123. package/shared/terminal-links.mjs +55 -0
  124. package/shared/terminal-state.d.mts +1 -0
  125. package/shared/terminal-state.mjs +16 -0
  126. package/shared/thread-cards.d.mts +19 -0
  127. package/shared/thread-cards.mjs +254 -0
  128. package/shared/usage.d.mts +1 -1
  129. package/shared/usage.mjs +6 -7
  130. package/shared/work-items.mjs +255 -27
  131. package/shared/work-lines.mjs +393 -16
  132. package/worker-permissions.json +1 -1
  133. package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
  134. package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
  135. package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
  136. package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
  137. package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
  138. package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
  139. package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
  140. package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
  141. package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
  142. package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
  143. package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
  144. package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
  145. package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
  146. package/renderer/dist/assets/index-CwiDv5bL.css +0 -32
  147. package/renderer/dist/assets/index-D9hCAVPl.js +0 -684
  148. package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
  149. package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
  150. package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
  151. package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
  152. package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
  153. package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
  154. package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
  155. package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
  156. package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
  157. package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
  158. package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
  159. package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
  160. package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
  161. package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
  162. package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
  163. package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
  164. package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
  165. package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
  166. package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
  167. package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
  168. package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
  169. package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
  170. package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
  171. package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
  172. package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
  173. package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
  174. package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
  175. package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
  176. package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
  177. package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
  178. package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
  179. package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
  180. package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
  181. package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
  182. package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
  183. package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
  184. package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
  185. package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
  186. package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
  187. package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
  188. package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
  189. package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
  190. package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
  191. package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
  192. package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
  193. package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
  194. package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
  195. package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
  196. package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
  197. package/renderer/dist/emulator/audio-rules.mjs +0 -74
  198. package/renderer/dist/emulator/data/compression/README.md +0 -11
  199. package/renderer/dist/emulator/data/compression/extract7z.js +0 -1
  200. package/renderer/dist/emulator/data/compression/extractzip.js +0 -1
  201. package/renderer/dist/emulator/data/compression/libunrar.js +0 -519
  202. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  203. package/renderer/dist/emulator/data/cores/README.md +0 -22
  204. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  205. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  206. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  207. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  208. package/renderer/dist/emulator/data/cores/package.json +0 -65
  209. package/renderer/dist/emulator/data/cores/reports/mgba.json +0 -1
  210. package/renderer/dist/emulator/data/emulator.css +0 -1614
  211. package/renderer/dist/emulator/data/loader.js +0 -170
  212. package/renderer/dist/emulator/data/localization/README.md +0 -63
  213. package/renderer/dist/emulator/data/localization/af-FR.json +0 -339
  214. package/renderer/dist/emulator/data/localization/ar-AR.json +0 -301
  215. package/renderer/dist/emulator/data/localization/ben-BEN.json +0 -301
  216. package/renderer/dist/emulator/data/localization/de-GER.json +0 -301
  217. package/renderer/dist/emulator/data/localization/el-GR.json +0 -301
  218. package/renderer/dist/emulator/data/localization/en-US.json +0 -339
  219. package/renderer/dist/emulator/data/localization/es-ES.json +0 -310
  220. package/renderer/dist/emulator/data/localization/fa-AF.json +0 -310
  221. package/renderer/dist/emulator/data/localization/hi-HI.json +0 -301
  222. package/renderer/dist/emulator/data/localization/it-IT.json +0 -302
  223. package/renderer/dist/emulator/data/localization/ja-JA.json +0 -301
  224. package/renderer/dist/emulator/data/localization/jv-JV.json +0 -301
  225. package/renderer/dist/emulator/data/localization/ko-KO.json +0 -336
  226. package/renderer/dist/emulator/data/localization/pt-BR.json +0 -361
  227. package/renderer/dist/emulator/data/localization/retroarch.json +0 -617
  228. package/renderer/dist/emulator/data/localization/ro-RO.json +0 -310
  229. package/renderer/dist/emulator/data/localization/ru-RU.json +0 -301
  230. package/renderer/dist/emulator/data/localization/tr-TR.json +0 -310
  231. package/renderer/dist/emulator/data/localization/vi-VN.json +0 -302
  232. package/renderer/dist/emulator/data/localization/zh-CN.json +0 -351
  233. package/renderer/dist/emulator/data/src/GameManager.js +0 -464
  234. package/renderer/dist/emulator/data/src/compression.js +0 -142
  235. package/renderer/dist/emulator/data/src/emulator.js +0 -6158
  236. package/renderer/dist/emulator/data/src/gamepad.js +0 -167
  237. package/renderer/dist/emulator/data/src/nipplejs.js +0 -1
  238. package/renderer/dist/emulator/data/src/shaders.js +0 -236
  239. package/renderer/dist/emulator/data/src/socket.io.min.js +0 -7
  240. package/renderer/dist/emulator/data/src/storage.js +0 -125
  241. package/renderer/dist/emulator/data/version.json +0 -5
  242. package/renderer/dist/emulator/host.js +0 -299
  243. package/renderer/dist/emulator/index.html +0 -28
  244. package/renderer/dist/emulator/provenance.json +0 -6
@@ -58,8 +58,12 @@ import path from 'node:path';
58
58
  * the last three days and 9 in the last seven, in two folders she works in.
59
59
  * Three days is a card with tonight's test on it and almost nothing else; the
60
60
  * filters have already done the work that "a few" was standing in for, so the
61
- * window can afford to be the generous reading. */
62
- export const RECENT_DAYS = 7;
61
+ * window can afford to be the generous reading.
62
+ *
63
+ * Ten since the launch list (w-db6f5e331e, 2026-10-05): "showing your recently
64
+ * active agents over the last 10 days". Codex reads the same number
65
+ * (main/codex-threads.mjs), so the two halves of the card agree. */
66
+ export const RECENT_DAYS = 10;
63
67
 
64
68
  /**
65
69
  * How much of a transcript is read to find its first human turn. The first
@@ -67,6 +71,15 @@ export const RECENT_DAYS = 7;
67
71
  * the largest in her store on 2026-08-29 is 13MB, and there are hundreds. */
68
72
  const HEAD_BYTES = 96 * 1024;
69
73
 
74
+ /**
75
+ * How far past the head one cut-off first message may be followed
76
+ * (w-24edc04548, 2026-10-05). A screenshot pasted into the first message is
77
+ * written into that row as base64, so the row outgrows the head: three of her
78
+ * ten days' terminal threads opened with rows of 240KB, 294KB and 486KB, were
79
+ * cut, lost their name and fell off the card. Eight megabytes is the Codex
80
+ * reader's own ceiling (main/codex-threads.mjs) and several screenshots. */
81
+ const FIRST_TURN_BYTES = 8 * 1024 * 1024;
82
+
70
83
  /**
71
84
  * A stop on how many transcripts are OPENED, for the same reason the folder
72
85
  * scan in agent-files.mjs has one: opening a card may not turn into a disk
@@ -80,6 +93,10 @@ const HEAD_BYTES = 96 * 1024;
80
93
  * whether tonight's thread survived was down to where its folder sorted. A
81
94
  * cap that can drop the newest thread on the machine is not a safety rail. */
82
95
  const MAX_READS = 1200;
96
+ // How long the conversation read may take, in ms (w-ec62ab6b38). The card that
97
+ // uses it says "Looking" meanwhile; three seconds reaches two thirds of her
98
+ // week's 9,073 transcripts and all of a normal Mac's.
99
+ const READ_BUDGET_MS = 3000;
83
100
 
84
101
  /* ------------------------- WHAT IS NOT HERS TO IMPORT ---------------------- */
85
102
 
@@ -106,6 +123,21 @@ export function isScratchFolder(folder, home = os.homedir()) {
106
123
  || /^\/var\/folders(\/|$)/.test(p);
107
124
  }
108
125
 
126
+ /**
127
+ * THE SAME RULE, READ OFF THE FOLDER CLAUDE CODE FILED THE TRANSCRIPT IN, so
128
+ * it can be applied before the file is opened (w-24edc04548, 2026-10-05).
129
+ * Claude Code names that folder after the working folder with every other
130
+ * character turned into `-`, so `/private/tmp/w-1` is `-private-tmp-w-1`.
131
+ * Of her 12,272 transcripts touched in ten days about 12,000 were in temp
132
+ * folders, and opening each one only to refuse it spent the card's three
133
+ * seconds before it reached her oldest threads. */
134
+ export function isScratchProjectDir(name, home = os.homedir()) {
135
+ const n = String(name ?? '');
136
+ const here = path.resolve(String(home ?? '')).replace(/[^a-zA-Z0-9]/g, '-');
137
+ if (here.length > 1 && (n === here || n.startsWith(`${here}-`))) return false;
138
+ return /^-(private-)?(tmp|var-folders)(-|$)/.test(n);
139
+ }
140
+
109
141
  /**
110
142
  * THE ONES A PERSON STARTED, which is the whole filter.
111
143
  *
@@ -171,7 +203,9 @@ export function threadTitle(texts, max = 72) {
171
203
  function titleFromOne(text, max) {
172
204
  const raw = String(text ?? '').replace(/\r/g, '');
173
205
  for (const line of raw.split('\n')) {
174
- const t = line.trim();
206
+ // `[Image #1]` and `[Pasted text #1 +40 lines]` are Claude Code's stand-ins
207
+ // for what was pasted, not words she typed.
208
+ const t = line.replace(/\[(?:Image|Pasted text) #\d+[^\]]*\]/g, '').trim();
175
209
  if (!t) continue;
176
210
  if (NOT_TYPED.some((re) => re.test(t))) return '';
177
211
  const clean = t
@@ -209,7 +243,7 @@ export function shortFolder(folder, home = os.homedir()) {
209
243
  * every message row and the first human turn is the first message, so all
210
244
  * three are at the top, and the alternative is reading gigabytes to fill in a
211
245
  * list. Her largest transcript is 13MB and there are hundreds. */
212
- export function readTranscriptHead(file, bytes = HEAD_BYTES) {
246
+ export function readTranscriptHead(file, bytes = HEAD_BYTES, most = FIRST_TURN_BYTES) {
213
247
  let fd = null;
214
248
  try {
215
249
  fd = fs.openSync(file, 'r');
@@ -218,22 +252,37 @@ export function readTranscriptHead(file, bytes = HEAD_BYTES) {
218
252
  const text = buf.slice(0, n).toString('utf8');
219
253
  // The last line of a fixed read is usually cut in half. Dropping it costs
220
254
  // nothing, because what is wanted is at the top.
221
- const lines = text.split('\n').slice(0, -1);
255
+ const lines = text.split('\n');
256
+ const cut = lines.pop();
222
257
  let cwd = null;
223
258
  let entrypoint = null;
224
259
  const said = [];
225
- for (const line of lines) {
226
- if (!line.trim()) continue;
260
+ const take = (line) => {
227
261
  let row;
228
- try { row = JSON.parse(line); } catch { continue; }
262
+ try { row = JSON.parse(line); } catch { return; }
229
263
  if (!cwd && typeof row.cwd === 'string' && row.cwd) cwd = row.cwd;
230
264
  if (!entrypoint && typeof row.entrypoint === 'string' && row.entrypoint) entrypoint = row.entrypoint;
231
265
  if (row.type === 'user' && said.length < 5) {
232
266
  const t = messageText(row);
233
267
  if (t.trim()) said.push(t);
234
268
  }
269
+ };
270
+ for (const line of lines) {
271
+ if (!line.trim()) continue;
272
+ take(line);
235
273
  if (cwd && entrypoint && said.length >= 5) break;
236
274
  }
275
+ // UNLESS THE HALF LINE IS HER FIRST MESSAGE, cut because a screenshot was
276
+ // pasted into it. Then it is read to its end, once, up to `most`. Only a
277
+ // cut row that names itself a user message near its start qualifies, and
278
+ // only when nothing she said was found and the session is not already
279
+ // known to be the app's own, so the thousands of worker transcripts are
280
+ // never read past their head.
281
+ if (!said.length && n === bytes && cut && /"type":"user"/.test(cut.slice(0, 2048))
282
+ && (!entrypoint || startedByHand(entrypoint))) {
283
+ const rest = restOfLine(fd, n, most - n);
284
+ if (rest) take(Buffer.concat([buf.subarray(buf.lastIndexOf(0x0a, n - 1) + 1, n), rest]).toString('utf8'));
285
+ }
237
286
  return { cwd, entrypoint, said };
238
287
  } catch {
239
288
  return { cwd: null, entrypoint: null, said: [] };
@@ -242,6 +291,25 @@ export function readTranscriptHead(file, bytes = HEAD_BYTES) {
242
291
  }
243
292
  }
244
293
 
294
+ /**
295
+ * The bytes from `from` up to the next newline, or null if there is none
296
+ * within `budget` bytes: a line that long is given up on, not read whole. A
297
+ * file that ends without a newline ends the line too. */
298
+ function restOfLine(fd, from, budget) {
299
+ const parts = [];
300
+ let at = from;
301
+ const chunk = Buffer.alloc(256 * 1024);
302
+ while (at - from < budget) {
303
+ const n = fs.readSync(fd, chunk, 0, Math.min(chunk.length, budget - (at - from)), at);
304
+ if (n <= 0) return Buffer.concat(parts);
305
+ const nl = chunk.subarray(0, n).indexOf(0x0a);
306
+ if (nl >= 0) { parts.push(Buffer.from(chunk.subarray(0, nl))); return Buffer.concat(parts); }
307
+ parts.push(Buffer.from(chunk.subarray(0, n)));
308
+ at += n;
309
+ }
310
+ return null;
311
+ }
312
+
245
313
  /**
246
314
  * The words out of one transcript row, whichever of the two shapes it is in.
247
315
  * Content is a string on the plain ones and a list of blocks on the rest. */
@@ -278,11 +346,27 @@ function readTerminalThreads({ home, cutoff, max, skipIds = new Set() }) {
278
346
  try { when = fs.statSync(file).mtimeMs; } catch { continue; }
279
347
  if (when < cutoff) { skipped.old += 1; continue; }
280
348
  if (isSubagentPath(file.slice(root.length))) { skipped.subagent += 1; continue; }
349
+ if (isScratchProjectDir(path.relative(root, file).split(path.sep)[0], home)) { skipped.scratch += 1; continue; }
281
350
  recent.push({ file, when });
282
351
  }
283
352
  recent.sort((a, b) => b.when - a.when);
284
- if (recent.length > max) skipped.unread = recent.length - max;
285
- for (const { file, when } of recent.slice(0, max)) {
353
+ // THE CAP COUNTS WHAT IS KEPT, NOT WHAT IS OPENED (w-ec62ab6b38, 2026-09-28).
354
+ // On a busy machine the last seven days held 9,073 transcripts, the newest
355
+ // 1,200 were opened, and every one of those was started by agentbox itself,
356
+ // so a conversation started by hand was never looked at and the import card
357
+ // said there was nothing, as if the app were not looking in Claude Code at
358
+ // all. Somebody running dozens of agents fills this folder with ours. So it
359
+ // opens newest first until it has `max` of the person's own, or until
360
+ // READ_BUDGET_MS has gone, whichever is first. Reading all 9,073 heads took
361
+ // 5.0s there.
362
+ const started = Date.now();
363
+ let opened = 0;
364
+ for (const { file, when } of recent) {
365
+ if (out.length >= max || Date.now() - started > READ_BUDGET_MS) {
366
+ skipped.unread = recent.length - opened;
367
+ break;
368
+ }
369
+ opened += 1;
286
370
  const id = path.basename(file, '.jsonl');
287
371
  // THE DESKTOP APP WRITES A THREAD TWICE, once as a transcript here and once
288
372
  // as its own record, joined by `cliSessionId`. Measured 2026-08-29 on the
@@ -0,0 +1,114 @@
1
+ // Release discovery is read-only. An updater is a dedicated PTY command,
2
+ // started only by the app's Update now action, never by a poll or terminal open.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import {execFile} from 'node:child_process';
7
+ import {promisify} from 'node:util';
8
+ import {TaskTerminals,terminalEnv} from './task-terminals.mjs';
9
+ const exec=promisify(execFile);
10
+ const valid=v=>typeof v==='string'&&/^\d+\.\d+\.\d+$/.test(v);
11
+ export function newerVersion(installed,latest){
12
+ if(!valid(installed)||!valid(latest))return false;
13
+ const a=installed.split('.').map(Number),b=latest.split('.').map(Number);
14
+ for(let i=0;i<3;i++){if(b[i]!==a[i])return b[i]>a[i];}return false;
15
+ }
16
+ export function updatePlan({engine,bin,real,home=os.homedir(),channel='latest'}){
17
+ if(!['claude','codex'].includes(engine)||!path.isAbsolute(bin||'')||!real)return null;
18
+ const name=engine==='claude'?'Claude Code':'Codex';
19
+ const brew=real.match(/^(\/opt\/homebrew|\/usr\/local)\/Caskroom\/(claude-code(?:@latest)?|codex)\//);
20
+ if(brew&&((engine==='claude'&&brew[2].startsWith('claude-code'))||(engine==='codex'&&brew[2]==='codex')))
21
+ return {name,file:`${brew[1]}/bin/brew`,args:['upgrade','--cask',brew[2]],feed:`https://formulae.brew.sh/api/cask/${brew[2]}.json`,format:'brew'};
22
+ const pkg=engine==='claude'?'@anthropic-ai/claude-code':'@openai/codex';
23
+ const tail=`/lib/node_modules/${pkg}/`;
24
+ const at=real.indexOf(tail);
25
+ if(at>0){const prefix=real.slice(0,at);return {name,file:path.join(prefix,'bin/npm'),args:['install','--global','--prefix',prefix,`${pkg}@${channel}`],env:{PATH:`${prefix}/bin:${process.env.PATH||''}`},feed:`https://registry.npmjs.org/${pkg}/${channel}`,format:'npm'};}
26
+ if(engine==='claude'&&real.startsWith(path.join(home,'.local/share/claude/versions')+'/'))return {name,file:bin,args:['update'],feed:`https://downloads.claude.ai/claude-code-releases/${channel}`,format:'text'};
27
+ const standalone=real.match(/^(.*)\/packages\/standalone\/(?:releases|current)\//);
28
+ if(engine==='codex'&&standalone)return {name,file:'/bin/bash',args:['-o','pipefail','-c','/usr/bin/curl -fsSL --connect-timeout 10 --max-time 60 https://chatgpt.com/codex/install.sh | /bin/sh'],env:{CODEX_HOME:standalone[1],CODEX_INSTALL_DIR:path.dirname(bin)},feed:'https://releases.openai.com/codex/channels/latest',format:'codex'};
29
+ return null;
30
+ }
31
+ const json=file=>{try{return JSON.parse(fs.readFileSync(file,'utf8'))}catch{return {}}};
32
+ export async function installedAgent(engine,config){
33
+ const bin=engine==='codex'?config.codexBin:config.claudeBin;
34
+ if(!bin)throw Error('This agent is not installed.');
35
+ const home=config.home||os.homedir();
36
+ const settings=engine==='claude'?json(path.join(process.env.CLAUDE_CONFIG_DIR||path.join(home,'.claude'),'settings.json')):{};
37
+ const env=terminalEnv();
38
+ // Version inspection must not itself auto-update anything.
39
+ const {stdout}=await exec(bin,['--version'],{timeout:8000,maxBuffer:16384,env:{...env,DISABLE_AUTOUPDATER:'1'}});
40
+ const version=stdout.match(/\b\d+\.\d+\.\d+(?:-[\w.-]+)?\b/)?.[0];
41
+ const channel=settings.autoUpdatesChannel==='stable'?'stable':'latest';
42
+ return {engine,bin,real:fs.realpathSync(bin),home,version,channel,disabled:settings.env?.DISABLE_UPDATES==='1'||process.env.DISABLE_UPDATES==='1'};
43
+ }
44
+ export async function latestRelease(plan){
45
+ const r=await fetch(plan.feed,{signal:AbortSignal.timeout(10000),headers:{Accept:plan.format==='text'?'text/plain':'application/json'}});
46
+ if(!r.ok)throw Error('Release check failed.');
47
+ const body=await r.text();if(body.length>2_000_000)throw Error('Release response too large.');
48
+ const data=plan.format==='text'?null:JSON.parse(body);
49
+ const version=plan.format==='text'?body.trim():plan.format==='codex'?String(data.version||data.tag_name||'').replace(/^(rust-v|v)/,''):data.version;
50
+ if(!valid(version))throw Error('Release version could not be read.');return version;
51
+ }
52
+ export class AgentUpdates{
53
+ constructor({installation,latest=latestRelease,refresh=async()=>{},terminals,now=()=>Date.now()}={}){
54
+ this.installation=installation;this.latest=latest;this.refresh=refresh;this.now=now;this.entries=new Map();this.checks=new Map();this.starts=new Map();this.finishes=new Map();this.plans=new Map();
55
+ this.terminals=terminals||new TaskTerminals({sweepMs:0});
56
+ this.terminals.resolve=engine=>{const p=this.plans.get(engine);if(!p)throw Error('Click Update now first.');return {cwd:p.home,command:{file:p.file,args:p.args,env:p.env},notice:`Updating ${p.name}.`}};
57
+ }
58
+ status(engine){return this.entries.get(engine)||{engine,state:'unknown'};}
59
+ check(engine,force=false){
60
+ if(!['claude','codex'].includes(engine))return Promise.reject(Error('Unknown agent.'));
61
+ if(this.starts.has(engine)||this.status(engine).state==='running')return Promise.resolve(this.status(engine));
62
+ if(this.checks.has(engine))return this.checks.get(engine);
63
+ const saved=this.status(engine);if(!force&&saved.checkedAt&&this.now()-saved.checkedAt<3600000)return Promise.resolve(saved);
64
+ const work=(async()=>{let result;try{
65
+ const install=await this.installation(engine);const plan=install.disabled?null:updatePlan(install);
66
+ if(!plan)result={engine,state:'unsupported',message:'Update this installation using its own installer.'};
67
+ else{const latest=await this.latest(plan);result={engine,state:newerVersion(install.version,latest)?'available':'current',installed:install.version,latest};if(!valid(install.version))throw Error('Installed version could not be read.');}
68
+ }catch{result={engine,state:'unknown',message:'Could not check for updates. Your current models are still available.'};}
69
+ result.checkedAt=this.now();if(this.starts.has(engine)||this.status(engine).state==='running')return this.status(engine);this.entries.set(engine,result);return result;})().finally(()=>this.checks.delete(engine));
70
+ this.checks.set(engine,work);return work;
71
+ }
72
+ start(engine){
73
+ if(this.starts.has(engine))return this.starts.get(engine);
74
+ if(this.status(engine).state==='running')return Promise.resolve(this.status(engine));
75
+ // Validate again at click time: an installation may have changed since
76
+ // the picker opened. Never trust a command or a release passed by the UI.
77
+ const work=(async()=>{
78
+ const install=await this.installation(engine);const plan=install.disabled?null:updatePlan(install);
79
+ if(!plan)throw Error('This installation must be updated using its own installer.');
80
+ const target=await this.latest(plan);
81
+ if(!valid(install.version))throw Error('Installed version could not be read.');
82
+ if(!newerVersion(install.version,target)){
83
+ await this.refresh(engine);const current={engine,state:'current',installed:install.version,latest:target,checkedAt:this.now(),message:'Up to date. Available models refreshed.'};this.entries.set(engine,current);return current;
84
+ }
85
+ this.plans.set(engine,{...plan,home:install.home});this.terminals.close(engine);
86
+ this.terminals.open(engine);
87
+ const running={engine,state:'running',installed:install.version,latest:target};this.entries.set(engine,running);
88
+ const finished=this.terminals.get(engine).exitPromise.then(async()=>{
89
+ try{
90
+ if(this.terminals.get(engine).exitCode!==0)throw Error('Update did not finish. You can retry.');
91
+ const after=await this.installation(engine);
92
+ if(!valid(after.version)||newerVersion(after.version,target))throw Error('The installed version is still older. Check the terminal output before retrying.');
93
+ await this.refresh(engine);
94
+ this.entries.set(engine,{engine,state:'current',installed:after.version,latest:target,checkedAt:this.now(),message:'Updated. Available models refreshed.'});
95
+ }catch(error){this.entries.set(engine,{...running,state:'failed',message:error.message});}
96
+ });this.finishes.set(engine,finished);return running;
97
+ })().catch(error=>{this.entries.set(engine,{engine,state:'failed',message:error.message});throw error;}).finally(()=>this.starts.delete(engine));
98
+ this.starts.set(engine,work);return work;
99
+ }
100
+ settled(engine){return this.finishes.get(engine)||Promise.resolve();}
101
+ terminal(engine,p){
102
+ // Opening the viewer is never an install action, including after it ended.
103
+ if(!this.plans.has(engine))throw Error('Click Update now first.');
104
+ switch(p.action){
105
+ case 'open':return this.terminals.read(engine,0);
106
+ case 'read':return this.terminals.read(engine,p.offset);
107
+ case 'write':return this.terminals.write(engine,p.data);
108
+ case 'resize':return this.terminals.resize(engine,p.cols,p.rows);
109
+ case 'close':return this.terminals.close(engine);
110
+ default:throw Error('Unknown terminal action.');
111
+ }
112
+ }
113
+ shutdown(){return this.terminals.shutdown();}
114
+ }
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) {
@@ -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) {