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
@@ -1,10 +1,10 @@
1
1
  // THE ACCOUNTS AGENTBOX RUNS ON ARE THE ONES ON THE DISK, not the ones somebody
2
2
  // remembered to type into a config file.
3
3
  //
4
- // It was logged in. ~/.claude-work held work@example.com, signed in at 15:19
5
- // that afternoon, and the fleet had been stopped since 15:14 on a personal
6
- // subscription that had hit its WEEKLY limit — three days of nothing, with a
7
- // rested paid account on the same disk. The register of accounts was the
4
+ // Picture someone with two subscriptions: a second account signed in through
5
+ // its own CLAUDE_CONFIG_DIR, and a first one that has hit its WEEKLY limit.
6
+ // The fleet stops for days, with a rested account on the same disk the whole
7
+ // time. The register of accounts was the
8
8
  // hand-written `authProfiles` key in zero.config.json, which nothing in the app
9
9
  // writes and nothing in the app checks against reality, so an account set up
10
10
  // the ordinary way (a new CLAUDE_CONFIG_DIR, a /login) was invisible BY
@@ -18,24 +18,25 @@
18
18
  // - A folder under the home directory named `.claude` or `.claude-<anything>`
19
19
  // that holds a login is an account.
20
20
  // - IT ONLY COUNTS IF ITS SUBSCRIPTION IS NEW. Two folders holding one
21
- // account is her exact setup (~/.claude and ~/.claude-second were both
22
- // you@example.com), and counting it twice sends two thirds of every spawn
23
- // round to an account that is already at its limit. The test is on
21
+ // account is a real setup (~/.claude and ~/.claude-second both signed in
22
+ // as the same person), and counting it twice sends two thirds of every
23
+ // spawn round to an account that is already at its limit. The test is on
24
24
  // `accountUuid`, the id Claude Code stores for the subscription itself,
25
- // never on the email — she made that point herself and she was right.
26
- // - NOTHING SHE CONFIGURED IS EVER DROPPED OR REORDERED. Her file is a
27
- // decision; this is only a discovery. Discovered accounts go after hers.
28
- // - A folder with no login is not an account. Two of hers are exactly that
29
- // (~/.claude-swap-backup, ~/.claude-worktrees) and neither is a place to
30
- // send her work.
25
+ // never on the email, because one email can hold more than one subscription.
26
+ // - NOTHING THE USER CONFIGURED IS EVER DROPPED OR REORDERED. The config is a
27
+ // decision; this is only a discovery. Discovered accounts go after the
28
+ // configured ones.
29
+ // - A folder with no login is not an account. Folders such as
30
+ // ~/.claude-swap-backup or ~/.claude-worktrees are exactly that, and
31
+ // neither is a place to send work.
31
32
  //
32
- // WHY THERE IS NO REFRESH BUTTON, which was her own question. It is not costly.
33
+ // WHY THERE IS NO REFRESH BUTTON. It is not costly.
33
34
  // The directory listing is cached on the home folder's own mtime, so the
34
35
  // readdir happens once and then only when a folder appears or disappears; the
35
36
  // login inside each candidate is read through `accountIdentity`, which is
36
- // already cached on that file's mtime. Measured on her own home folder on
37
- // 2026-08-31 — 200 entries, 9 .claude-shaped, 5 of them directories, 3 holding
38
- // a login — a warm call costs 0.029 ms, and it is asked on the supervisor's
37
+ // already cached on that file's mtime. Measured on a home folder of 200
38
+ // entries, 9 .claude-shaped, 5 of them directories, 3 holding a login, a
39
+ // warm call costs 0.029 ms, and it is asked on the supervisor's
39
40
  // 15-second tick. A button would only be a thing to remember to press.
40
41
  //
41
42
  // THE DELIBERATE LIMIT: one level, under the home directory only. A profile
@@ -103,9 +104,9 @@ export function discoverProfiles({ home = os.homedir() } = {}) {
103
104
  }
104
105
 
105
106
  /**
106
- * THE PROFILES THE FLEET ACTUALLY RUNS ON: hers first, exactly as she wrote
107
- * them, then any subscription found on the disk that none of hers is already
108
- * running on.
107
+ * THE PROFILES THE FLEET ACTUALLY RUNS ON: the configured ones first, exactly
108
+ * as written, then any subscription found on the disk that none of the
109
+ * configured ones is already running on.
109
110
  *
110
111
  * Profiles are the strings the rest of the app has always used: the literal
111
112
  * 'default' for ~/.claude (which is what spawning with no CLAUDE_CONFIG_DIR
@@ -182,16 +182,15 @@ function list(names) {
182
182
  //
183
183
  // The Accounts page named its two rows `default` and `.claude-second`, which
184
184
  // are folders, not people. So there was no way to tell from the screen which
185
- // subscription either row was running on, and on 2026-08-29 that cost her the
186
- // second one. The page said the second account was signed out. She did exactly
187
- // what it told her to do, ran the command and typed /login at 1:47pm, and the
188
- // browser was already signed in as her personal account, so the login handed
189
- // that one back. Her work account, work@example.com, had been in that folder
190
- // since at least 08-23 and was gone by 1:50pm, replaced by the same account the
191
- // first row was already using. Measured off the config backups the CLI itself
192
- // writes. Nothing on the page changed when it happened, because nothing on the
193
- // page had ever said whose account it was, which is also exactly what she
194
- // reported: "Idk which is logged in nothing changed in settings".
185
+ // subscription either row was running on, and that can cost a person their
186
+ // second account. Say the page reports the second account signed out. They do
187
+ // exactly what it tells them to do, run the command and type /login, and the
188
+ // browser is already signed in as the account the first row uses, so the login
189
+ // hands that one back. The other account that had been in that folder is gone,
190
+ // replaced by the same account the first row was already using; the config
191
+ // backups the CLI itself writes show the swap. Nothing on the page changes
192
+ // when it happens, because nothing on the page had ever said whose account it
193
+ // was: "Idk which is logged in nothing changed in settings".
195
194
  //
196
195
  // One line of the answer, read from the file Claude Code keeps its own account
197
196
  // in. Null when the folder holds no login, which reads on the page as the one
@@ -199,7 +198,8 @@ function list(names) {
199
198
  const identityCache = new Map();
200
199
 
201
200
  export function accountIdentity(dir, { home = os.homedir() } = {}) {
202
- const file = path.join(dir || defaultClaudeHome(home), '.claude.json');
201
+ let file = path.join(dir || defaultClaudeHome(home), '.claude.json');
202
+ if (!dir && !fs.existsSync(file)) file = path.join(home, '.claude.json');
203
203
  // Cached on the file's own mtime rather than on a timer. That file is ~120 KB
204
204
  // on this machine and the settings page re-reads on every poll, so parsing it
205
205
  // each time would be real work for an answer that changes about twice a
@@ -1,7 +1,7 @@
1
1
  // Ephemeral, local status from matched tool start/end events. A persisted trace
2
2
  // is history and cannot prove a command is still running. Nothing here executes
3
3
  // commands, records reasoning, or changes worker permissions.
4
- import { plainCommand, shortPath } from '../shared/work-lines.mjs';
4
+ import { commandWork, isBookkeeping, plainCommand, shortPath } from '../shared/work-lines.mjs';
5
5
  const clean = value => typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
6
6
  function add(session, id, label, detail) {
7
7
  if (!id || !label) return false;
@@ -11,12 +11,43 @@ function add(session, id, label, detail) {
11
11
  return true;
12
12
  }
13
13
  function finish(session, id) { return session.activeTools?.delete(id) ?? false; }
14
- function clear(session) { const changed = !!session.activeTools?.size; session.activeTools?.clear(); return changed; }
14
+ // A turn ending clears what it was doing, but not a background job: that is
15
+ // still running, and the session is kept open waiting for it
16
+ // (main/claude-input.mjs), so the row keeps saying what it waits on.
17
+ function clear(session) {
18
+ let changed = false;
19
+ for (const [id, entry] of session.activeTools ?? []) {
20
+ if (entry.background) continue;
21
+ session.activeTools.delete(id);
22
+ changed = true;
23
+ }
24
+ return changed;
25
+ }
26
+ function backgroundJobs(session, tasks) {
27
+ const pending = session.activeTools ??= new Map();
28
+ const live = new Set(tasks.map(t => `bg:${t?.task_id}`));
29
+ let changed = false;
30
+ for (const [id, entry] of pending) if (entry.background && !live.has(id)) { pending.delete(id); changed = true; }
31
+ for (const task of tasks) {
32
+ const description = clean(task?.description);
33
+ const plain = commandWork(description);
34
+ const label = plain ? `${plain.doing} ${plain.subject}`.trim() : `Waiting on: ${description || 'a background job'}`;
35
+ if (add(session, `bg:${task?.task_id}`, label, description)) { pending.get(`bg:${task?.task_id}`).background = true; changed = true; }
36
+ }
37
+ return changed;
38
+ }
15
39
  export function currentActivity(session) { return [...(session.activeTools?.values() ?? [])]; }
16
40
  function toolLabel(name, input) {
17
41
  const command = clean(input.command);
18
42
  const file = clean(input.file_path ?? input.path);
19
- if (name === 'Bash') return ['Running ' + (plainCommand(command) || 'command'), input.command ?? ''];
43
+ if (name === 'Bash') {
44
+ // WHAT IT IS DOING, IN WORDS, when the whole command is recognised. The
45
+ // line under a running row is the same event as the line in the thread
46
+ // above it, read in the present tense (`doing`, shared/work-lines.mjs).
47
+ const plain = commandWork(command);
48
+ if (plain) return [`${plain.doing} ${plain.subject}`.trim(), input.command ?? ''];
49
+ return ['Running ' + (plainCommand(command) || 'command'), input.command ?? ''];
50
+ }
20
51
  if (['Read','Write','Edit','MultiEdit'].includes(name)) return [`${name === 'Read' ? 'Reading' : name === 'Write' ? 'Writing' : 'Editing'} ${shortPath(file) || 'file'}`, file];
21
52
  if (['Grep','Glob','WebSearch'].includes(name)) return [`Searching ${clean(input.pattern ?? input.query) || 'files'}`, input.pattern ?? input.query ?? ''];
22
53
  if (name === 'WebFetch') return ['Reading web page', input.url ?? ''];
@@ -28,9 +59,13 @@ export function claudeActivity(session, line) {
28
59
  const obj = JSON.parse(line);
29
60
  if (obj.parent_tool_use_id) return false;
30
61
  if (obj.type === 'result') return clear(session);
62
+ if (obj.type === 'system' && obj.subtype === 'background_tasks_changed' && Array.isArray(obj.tasks)) return backgroundJobs(session, obj.tasks);
31
63
  let changed = false;
32
64
  for (const part of Array.isArray(obj.message?.content) ? obj.message.content : []) {
33
65
  if (obj.type === 'assistant' && part.type === 'tool_use') {
66
+ // The store's own records never become the word on the row: taking the
67
+ // item is not what the session is doing (w-0bd0d8b2ef).
68
+ if (isBookkeeping(part.name)) continue;
34
69
  const [label, detail] = toolLabel(part.name, part.input ?? {});
35
70
  changed = add(session, part.id, label, detail) || changed;
36
71
  }
@@ -47,9 +82,14 @@ export function codexActivity(session, method, params) {
47
82
  if (method !== 'item/started') return false;
48
83
  if (item.status && !['inProgress', 'in_progress', 'running'].includes(item.status)) return false;
49
84
  switch (item.type) {
50
- case 'commandExecution': return add(session, item.id, `Running ${plainCommand(clean(item.command)) || 'command'}`, item.command);
85
+ case 'commandExecution': {
86
+ const plain = commandWork(clean(item.command));
87
+ const label = plain ? `${plain.doing} ${plain.subject}`.trim() : `Running ${plainCommand(clean(item.command)) || 'command'}`;
88
+ return add(session, item.id, label, item.command);
89
+ }
51
90
  case 'fileChange': return add(session, item.id, 'Editing files', (Array.isArray(item.changes) ? item.changes : []).map(c => c?.path ?? '').join('\n'));
52
- case 'mcpToolCall': return add(session, item.id, `Using ${clean(item.tool).replace(/_/g, ' ') || 'tool'}`, `${item.server ?? ''} · ${item.tool ?? ''}`);
91
+ case 'mcpToolCall': return isBookkeeping(clean(item.tool)) ? false
92
+ : add(session, item.id, `Using ${clean(item.tool).replace(/_/g, ' ') || 'tool'}`, `${item.server ?? ''} · ${item.tool ?? ''}`);
53
93
  case 'webSearch': return add(session, item.id, 'Searching the web', item.query ?? '');
54
94
  default: return false;
55
95
  }
@@ -1,4 +1,4 @@
1
- // WHEN SHE ASKED TO SEE AN AGENT AGAIN, AND WHEN SHE ASKED NOT TO. One small
1
+ // WHEN THE USER ASKED TO SEE AN AGENT AGAIN, AND WHEN THEY ASKED NOT TO. One small
2
2
  // durable map, and the only state the app keeps about a session it did not start.
3
3
  //
4
4
  // Three facts per agent and they are different facts. `runAt` is a moment she
@@ -41,9 +41,9 @@ import { agentKey } from '../shared/agents.mjs';
41
41
 
42
42
  const num = (v) => (Number.isFinite(Number(v)) ? Math.max(0, Math.trunc(Number(v))) : 0);
43
43
 
44
- // HER SIDE OF A THREAD IS SMALL, and this file is read on every draw. Measured
45
- // across her 16 real sessions on 2026-08-17: 155 turns of hers in total, median
46
- // 6 per session, median 131 characters. The card shows three. Eight, at 600
44
+ // THE USER'S SIDE OF A THREAD IS SMALL, and this file is read on every draw. A
45
+ // real session has a handful of human turns, typically a sentence or two each.
46
+ // The card shows three. Eight, at 600
47
47
  // characters, is far more than anything draws and small enough that a session
48
48
  // left running for a month cannot grow the file without bound.
49
49
  const KEEP_SPOKE = 8;
@@ -116,8 +116,8 @@ export class AgentSchedule {
116
116
  }
117
117
 
118
118
  // An entry that says none of "later", "seen", "I spoke to it" or "here is what
119
- // she said" is not an entry. The last of those four is why this is not the
120
- // same list as it was: her words outlive the reply that carried them, so an
119
+ // the user said" is not an entry. The last of those four is why this is not the
120
+ // same list as it was: the user's words outlive the reply that carried them, so an
121
121
  // entry holding only `spoke` is still the record of a conversation.
122
122
  _keep(key, entry, now) {
123
123
  if (entry.runAt > now || entry.doneThrough > 0 || entry.repliedAt > 0 || entry.spoke?.length) this.byKey[key] = entry;
@@ -155,8 +155,8 @@ export class AgentSchedule {
155
155
  // undid the close, saying a session was working on something she had
156
156
  // already finished with.
157
157
  //
158
- // BUT IT DOES NOT UNSAY WHAT SHE SAID. This used to empty `spoke` as well,
159
- // and `spoke` is the ONLY record anywhere that she ever spoke to a session:
158
+ // BUT IT DOES NOT UNSAY WHAT THE USER SAID. This used to empty `spoke` as well,
159
+ // and `spoke` is the ONLY record anywhere that the user ever spoke to a session:
160
160
  // Claude Code stamps an injected message `isMeta` and the transcript reader
161
161
  // drops it, correctly, so nothing else on the machine knows. Measured on
162
162
  // session-46: four of her messages reached that session and the file holds
@@ -200,12 +200,12 @@ export class AgentSchedule {
200
200
  *
201
201
  * It is here because it is the only place that knows. Claude Code stamps a
202
202
  * message injected over its socket `isMeta: true` and wraps it in "Another
203
- * Claude session sent a message… not typed by y measured 2026-08-17 on
204
- * session-67, where two messages she sent at 19:41 left the row saying only
205
- * what she had started it with four days earlier.
203
+ * Claude session sent a message…", so the transcript reader drops it, and
204
+ * without this record messages sent from Agentbox left the row saying only
205
+ * what the session was started with days earlier.
206
206
  *
207
- * A another of her sessions pinging this one is a real peer and drawing it as
208
- * her words would be a lie on the one card built to be trusted. So the record
207
+ * Another of the user's sessions pinging this one is a real peer, and drawing
208
+ * it as the user's words would be a lie on the one card built to be trusted. So the record
209
209
  * is written at the moment of delivery, by the only process that knows it
210
210
  * delivered it.
211
211
  */
@@ -236,7 +236,7 @@ export class AgentSchedule {
236
236
  if (entry.runAt > now) { out.runAt = entry.runAt; out.runAtSetAt = entry.setAt; }
237
237
  if (entry.doneThrough > 0) out.doneThrough = entry.doneThrough;
238
238
  if (entry.repliedAt > 0) out.repliedAt = entry.repliedAt;
239
- // the card quotes what she said last, and a session that has since
239
+ // the card quotes what the user said last, and a session that has since
240
240
  // stopped is exactly when she most needs to see it.
241
241
  if (entry.spoke?.length) out.spoke = entry.spoke;
242
242
  return out;
@@ -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,116 @@
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
+ import {codexLaunchEnv} from './codex-launch-env.mjs';
10
+ const exec=promisify(execFile);
11
+ const valid=v=>typeof v==='string'&&/^\d+\.\d+\.\d+$/.test(v);
12
+ export function newerVersion(installed,latest){
13
+ if(!valid(installed)||!valid(latest))return false;
14
+ const a=installed.split('.').map(Number),b=latest.split('.').map(Number);
15
+ for(let i=0;i<3;i++){if(b[i]!==a[i])return b[i]>a[i];}return false;
16
+ }
17
+ export function updatePlan({engine,bin,real,home=os.homedir(),channel='latest'}){
18
+ if(!['claude','codex'].includes(engine)||!path.isAbsolute(bin||'')||!real)return null;
19
+ const name=engine==='claude'?'Claude Code':'Codex';
20
+ const brew=real.match(/^(\/opt\/homebrew|\/usr\/local)\/Caskroom\/(claude-code(?:@latest)?|codex)\//);
21
+ if(brew&&((engine==='claude'&&brew[2].startsWith('claude-code'))||(engine==='codex'&&brew[2]==='codex')))
22
+ return {name,file:`${brew[1]}/bin/brew`,args:['upgrade','--cask',brew[2]],feed:`https://formulae.brew.sh/api/cask/${brew[2]}.json`,format:'brew'};
23
+ const pkg=engine==='claude'?'@anthropic-ai/claude-code':'@openai/codex';
24
+ const tail=`/lib/node_modules/${pkg}/`;
25
+ const at=real.indexOf(tail);
26
+ 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'};}
27
+ 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'};
28
+ const standalone=real.match(/^(.*)\/packages\/standalone\/(?:releases|current)\//);
29
+ 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'};
30
+ return null;
31
+ }
32
+ const json=file=>{try{return JSON.parse(fs.readFileSync(file,'utf8'))}catch{return {}}};
33
+ export async function installedAgent(engine,config){
34
+ const bin=engine==='codex'?config.codexBin:config.claudeBin;
35
+ if(!bin)throw Error('This agent is not installed.');
36
+ const home=config.home||os.homedir();
37
+ const settings=engine==='claude'?json(path.join(process.env.CLAUDE_CONFIG_DIR||path.join(home,'.claude'),'settings.json')):{};
38
+ const cleanEnv=terminalEnv();
39
+ const env=engine==='codex'?codexLaunchEnv(bin,cleanEnv):cleanEnv;
40
+ // Version inspection must not itself auto-update anything.
41
+ const {stdout}=await exec(bin,['--version'],{timeout:8000,maxBuffer:16384,env:{...env,DISABLE_AUTOUPDATER:'1'}});
42
+ const version=stdout.match(/\b\d+\.\d+\.\d+(?:-[\w.-]+)?\b/)?.[0];
43
+ const channel=settings.autoUpdatesChannel==='stable'?'stable':'latest';
44
+ return {engine,bin,real:fs.realpathSync(bin),home,version,channel,disabled:settings.env?.DISABLE_UPDATES==='1'||process.env.DISABLE_UPDATES==='1'};
45
+ }
46
+ export async function latestRelease(plan){
47
+ const r=await fetch(plan.feed,{signal:AbortSignal.timeout(10000),headers:{Accept:plan.format==='text'?'text/plain':'application/json'}});
48
+ if(!r.ok)throw Error('Release check failed.');
49
+ const body=await r.text();if(body.length>2_000_000)throw Error('Release response too large.');
50
+ const data=plan.format==='text'?null:JSON.parse(body);
51
+ const version=plan.format==='text'?body.trim():plan.format==='codex'?String(data.version||data.tag_name||'').replace(/^(rust-v|v)/,''):data.version;
52
+ if(!valid(version))throw Error('Release version could not be read.');return version;
53
+ }
54
+ export class AgentUpdates{
55
+ constructor({installation,latest=latestRelease,refresh=async()=>{},terminals,now=()=>Date.now()}={}){
56
+ 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();
57
+ this.terminals=terminals||new TaskTerminals({sweepMs:0});
58
+ 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}.`}};
59
+ }
60
+ status(engine){return this.entries.get(engine)||{engine,state:'unknown'};}
61
+ check(engine,force=false){
62
+ if(!['claude','codex'].includes(engine))return Promise.reject(Error('Unknown agent.'));
63
+ if(this.starts.has(engine)||this.status(engine).state==='running')return Promise.resolve(this.status(engine));
64
+ if(this.checks.has(engine))return this.checks.get(engine);
65
+ const saved=this.status(engine);if(!force&&saved.checkedAt&&this.now()-saved.checkedAt<3600000)return Promise.resolve(saved);
66
+ const work=(async()=>{let result;try{
67
+ const install=await this.installation(engine);const plan=install.disabled?null:updatePlan(install);
68
+ if(!plan)result={engine,state:'unsupported',message:'Update this installation using its own installer.'};
69
+ 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.');}
70
+ }catch{result={engine,state:'unknown',message:'Could not check for updates. Your current models are still available.'};}
71
+ 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));
72
+ this.checks.set(engine,work);return work;
73
+ }
74
+ start(engine){
75
+ if(this.starts.has(engine))return this.starts.get(engine);
76
+ if(this.status(engine).state==='running')return Promise.resolve(this.status(engine));
77
+ // Validate again at click time: an installation may have changed since
78
+ // the picker opened. Never trust a command or a release passed by the UI.
79
+ const work=(async()=>{
80
+ const install=await this.installation(engine);const plan=install.disabled?null:updatePlan(install);
81
+ if(!plan)throw Error('This installation must be updated using its own installer.');
82
+ const target=await this.latest(plan);
83
+ if(!valid(install.version))throw Error('Installed version could not be read.');
84
+ if(!newerVersion(install.version,target)){
85
+ 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;
86
+ }
87
+ this.plans.set(engine,{...plan,home:install.home});this.terminals.close(engine);
88
+ this.terminals.open(engine);
89
+ const running={engine,state:'running',installed:install.version,latest:target};this.entries.set(engine,running);
90
+ const finished=this.terminals.get(engine).exitPromise.then(async()=>{
91
+ try{
92
+ if(this.terminals.get(engine).exitCode!==0)throw Error('Update did not finish. You can retry.');
93
+ const after=await this.installation(engine);
94
+ if(!valid(after.version)||newerVersion(after.version,target))throw Error('The installed version is still older. Check the terminal output before retrying.');
95
+ await this.refresh(engine);
96
+ this.entries.set(engine,{engine,state:'current',installed:after.version,latest:target,checkedAt:this.now(),message:'Updated. Available models refreshed.'});
97
+ }catch(error){this.entries.set(engine,{...running,state:'failed',message:error.message});}
98
+ });this.finishes.set(engine,finished);return running;
99
+ })().catch(error=>{this.entries.set(engine,{engine,state:'failed',message:error.message});throw error;}).finally(()=>this.starts.delete(engine));
100
+ this.starts.set(engine,work);return work;
101
+ }
102
+ settled(engine){return this.finishes.get(engine)||Promise.resolve();}
103
+ terminal(engine,p){
104
+ // Opening the viewer is never an install action, including after it ended.
105
+ if(!this.plans.has(engine))throw Error('Click Update now first.');
106
+ switch(p.action){
107
+ case 'open':return this.terminals.read(engine,0);
108
+ case 'read':return this.terminals.read(engine,p.offset);
109
+ case 'write':return this.terminals.write(engine,p.data);
110
+ case 'resize':return this.terminals.resize(engine,p.cols,p.rows);
111
+ case 'close':return this.terminals.close(engine);
112
+ default:throw Error('Unknown terminal action.');
113
+ }
114
+ }
115
+ shutdown(){return this.terminals.shutdown();}
116
+ }