agentbox-app 0.1.7 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. package/README.md +220 -58
  2. package/bin/agentbox.mjs +18 -3
  3. package/briefs/message-rules.md +24 -3
  4. package/briefs/worker.md +63 -49
  5. package/briefs/writing-rules.md +90 -131
  6. package/main/account-discovery.mjs +21 -20
  7. package/main/account-tooling.mjs +11 -11
  8. package/main/agent-activity.mjs +45 -5
  9. package/main/agent-schedule.mjs +14 -14
  10. package/main/agent-sessions.mjs +94 -10
  11. package/main/agent-updates.mjs +116 -0
  12. package/main/agents.mjs +52 -46
  13. package/main/analytics.mjs +27 -0
  14. package/main/approval-prompt-server.mjs +2 -2
  15. package/main/approvals.mjs +14 -5
  16. package/main/artifact-path.mjs +11 -0
  17. package/main/claude-bin.mjs +32 -5
  18. package/main/claude-input.mjs +45 -3
  19. package/main/claude-plan.mjs +25 -13
  20. package/main/code-change.mjs +3 -3
  21. package/main/codex-account.mjs +1 -1
  22. package/main/codex-bin.mjs +18 -0
  23. package/main/codex-launch-env.mjs +31 -0
  24. package/main/codex-memory-gate.mjs +260 -0
  25. package/main/codex-models.mjs +12 -7
  26. package/main/codex-threads.mjs +46 -23
  27. package/main/codex-usage-file.mjs +7 -7
  28. package/main/codex-usage.mjs +4 -4
  29. package/main/codex-watch.mjs +12 -12
  30. package/main/config.mjs +82 -17
  31. package/main/demo.mjs +17 -14
  32. package/main/dev-window.mjs +9 -9
  33. package/main/doc-scheme.mjs +2 -0
  34. package/main/engine-setup.mjs +204 -0
  35. package/main/feedback.mjs +55 -0
  36. package/main/folder-pictures.mjs +48 -0
  37. package/main/frame-scrollbars.mjs +90 -0
  38. package/main/fresh-user.mjs +7 -4
  39. package/main/git-change-offthread.mjs +70 -0
  40. package/main/git-change-thread.mjs +11 -0
  41. package/main/git-change.mjs +9 -3
  42. package/main/img-scheme.mjs +70 -1
  43. package/main/instruction-settings.mjs +134 -60
  44. package/main/ipc.mjs +335 -42
  45. package/main/leftovers.mjs +518 -0
  46. package/main/live-replies.mjs +43 -5
  47. package/main/machine.mjs +48 -1
  48. package/main/main.mjs +138 -15
  49. package/main/memory-gate-history.mjs +254 -0
  50. package/main/memory-gate-server.mjs +461 -0
  51. package/main/memory-gate.mjs +206 -0
  52. package/main/message-priority.mjs +87 -0
  53. package/main/notify.mjs +1 -1
  54. package/main/rail-note.mjs +2 -2
  55. package/main/refresh-agent-models.mjs +21 -0
  56. package/main/repeats.mjs +4 -4
  57. package/main/row-label.mjs +115 -51
  58. package/main/runs-on.mjs +93 -0
  59. package/main/serve.mjs +77 -5
  60. package/main/settings.mjs +245 -26
  61. package/main/ship-queue.mjs +279 -0
  62. package/main/sign-in-files.mjs +39 -0
  63. package/main/source-updater.mjs +298 -0
  64. package/main/store/home.mjs +84 -7
  65. package/main/store/project.mjs +15 -19
  66. package/main/store/work-items.mjs +150 -6
  67. package/main/store.mjs +245 -13
  68. package/main/supervisor.mjs +1634 -417
  69. package/main/task-commands.mjs +8 -10
  70. package/main/task-folders.mjs +94 -16
  71. package/main/task-remote-control.mjs +3 -3
  72. package/main/task-terminals.mjs +14 -2
  73. package/main/team/index.mjs +440 -0
  74. package/main/team/memory-cloud.mjs +247 -0
  75. package/main/team/projects.mjs +162 -0
  76. package/main/team/session.mjs +225 -0
  77. package/main/team/sign-in.mjs +106 -0
  78. package/main/team/supabase-backend.mjs +231 -0
  79. package/main/team/sync.mjs +163 -0
  80. package/main/updater.mjs +15 -5
  81. package/main/write-before-reload.mjs +50 -0
  82. package/mcp/core/page.mjs +10 -12
  83. package/mcp/core/work.mjs +123 -26
  84. package/mcp/tools.mjs +13 -6
  85. package/package.json +21 -9
  86. package/renderer/dist/assets/agentbox-icon-F1oN9nj_.svg +15 -0
  87. package/renderer/dist/assets/index-ChKsQkRw.js +1169 -0
  88. package/renderer/dist/assets/index-ClBXnq3B.css +32 -0
  89. package/renderer/dist/index.html +2 -2
  90. package/scripts/after-install.mjs +14 -0
  91. package/scripts/approval-server.sh +7 -2
  92. package/scripts/memory-gate-hook.sh +59 -0
  93. package/shared/agent-import.mjs +39 -16
  94. package/shared/agents.d.mts +26 -8
  95. package/shared/agents.mjs +100 -108
  96. package/shared/analytics-events.mjs +19 -2
  97. package/shared/answers.mjs +12 -0
  98. package/shared/bridge-map.d.mts +12 -0
  99. package/shared/bridge-map.mjs +38 -1
  100. package/shared/claude-commands.generated.mjs +2 -2
  101. package/shared/claude-models.generated.mjs +6 -6
  102. package/shared/claude-usage.mjs +6 -6
  103. package/shared/codex-import.mjs +7 -7
  104. package/shared/codex-modes.d.mts +50 -0
  105. package/shared/codex-modes.mjs +1 -1
  106. package/shared/codex-usage.mjs +7 -8
  107. package/shared/demo-world.mjs +6 -6
  108. package/shared/engines.mjs +27 -10
  109. package/shared/feedback.d.mts +14 -0
  110. package/shared/feedback.mjs +44 -0
  111. package/shared/first-run-examples.mjs +4 -4
  112. package/shared/first-run-practice.d.mts +16 -0
  113. package/shared/first-run-practice.mjs +176 -70
  114. package/shared/fresh-user-home.mjs +34 -4
  115. package/shared/instruction-defaults.json +2 -2
  116. package/shared/product-name.mjs +4 -2
  117. package/shared/project-folder-check.mjs +9 -3
  118. package/shared/rank.d.mts +1 -0
  119. package/shared/rank.mjs +22 -0
  120. package/shared/referenced-files.mjs +1 -1
  121. package/shared/runs-on.d.mts +20 -0
  122. package/shared/runs-on.mjs +79 -0
  123. package/shared/schemes.d.mts +11 -0
  124. package/shared/schemes.mjs +1 -1
  125. package/shared/shortcuts.d.mts +41 -0
  126. package/shared/shortcuts.mjs +236 -0
  127. package/shared/spawn-trouble.d.mts +2 -0
  128. package/shared/spawn-trouble.mjs +40 -19
  129. package/shared/team-rules.d.mts +17 -0
  130. package/shared/team-rules.mjs +230 -0
  131. package/shared/team-status.d.mts +11 -0
  132. package/shared/team-status.mjs +90 -0
  133. package/shared/terminal-links.d.mts +2 -0
  134. package/shared/terminal-links.mjs +55 -0
  135. package/shared/terminal-state.d.mts +1 -0
  136. package/shared/terminal-state.mjs +16 -0
  137. package/shared/thread-cards.d.mts +19 -0
  138. package/shared/thread-cards.mjs +254 -0
  139. package/shared/usage.d.mts +1 -1
  140. package/shared/usage.mjs +6 -7
  141. package/shared/work-items.mjs +258 -28
  142. package/shared/work-lines.mjs +393 -16
  143. package/worker-permissions.json +1 -1
  144. package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
  145. package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
  146. package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
  147. package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
  148. package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
  149. package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
  150. package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
  151. package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
  152. package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
  153. package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
  154. package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
  155. package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
  156. package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
  157. package/renderer/dist/assets/index-B7hhfHrT.js +0 -684
  158. package/renderer/dist/assets/index-CwiDv5bL.css +0 -32
  159. package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
  160. package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
  161. package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
  162. package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
  163. package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
  164. package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
  165. package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
  166. package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
  167. package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
  168. package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
  169. package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
  170. package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
  171. package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
  172. package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
  173. package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
  174. package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
  175. package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
  176. package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
  177. package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
  178. package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
  179. package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
  180. package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
  181. package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
  182. package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
  183. package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
  184. package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
  185. package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
  186. package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
  187. package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
  188. package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
  189. package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
  190. package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
  191. package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
  192. package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
  193. package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
  194. package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
  195. package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
  196. package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
  197. package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
  198. package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
  199. package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
  200. package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
  201. package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
  202. package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
  203. package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
  204. package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
  205. package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
  206. package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
  207. package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
  208. package/renderer/dist/emulator/audio-rules.mjs +0 -74
  209. package/renderer/dist/emulator/data/compression/README.md +0 -11
  210. package/renderer/dist/emulator/data/compression/extract7z.js +0 -1
  211. package/renderer/dist/emulator/data/compression/extractzip.js +0 -1
  212. package/renderer/dist/emulator/data/compression/libunrar.js +0 -519
  213. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  214. package/renderer/dist/emulator/data/cores/README.md +0 -22
  215. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  216. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  217. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  218. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  219. package/renderer/dist/emulator/data/cores/package.json +0 -65
  220. package/renderer/dist/emulator/data/cores/reports/mgba.json +0 -1
  221. package/renderer/dist/emulator/data/emulator.css +0 -1614
  222. package/renderer/dist/emulator/data/loader.js +0 -170
  223. package/renderer/dist/emulator/data/localization/README.md +0 -63
  224. package/renderer/dist/emulator/data/localization/af-FR.json +0 -339
  225. package/renderer/dist/emulator/data/localization/ar-AR.json +0 -301
  226. package/renderer/dist/emulator/data/localization/ben-BEN.json +0 -301
  227. package/renderer/dist/emulator/data/localization/de-GER.json +0 -301
  228. package/renderer/dist/emulator/data/localization/el-GR.json +0 -301
  229. package/renderer/dist/emulator/data/localization/en-US.json +0 -339
  230. package/renderer/dist/emulator/data/localization/es-ES.json +0 -310
  231. package/renderer/dist/emulator/data/localization/fa-AF.json +0 -310
  232. package/renderer/dist/emulator/data/localization/hi-HI.json +0 -301
  233. package/renderer/dist/emulator/data/localization/it-IT.json +0 -302
  234. package/renderer/dist/emulator/data/localization/ja-JA.json +0 -301
  235. package/renderer/dist/emulator/data/localization/jv-JV.json +0 -301
  236. package/renderer/dist/emulator/data/localization/ko-KO.json +0 -336
  237. package/renderer/dist/emulator/data/localization/pt-BR.json +0 -361
  238. package/renderer/dist/emulator/data/localization/retroarch.json +0 -617
  239. package/renderer/dist/emulator/data/localization/ro-RO.json +0 -310
  240. package/renderer/dist/emulator/data/localization/ru-RU.json +0 -301
  241. package/renderer/dist/emulator/data/localization/tr-TR.json +0 -310
  242. package/renderer/dist/emulator/data/localization/vi-VN.json +0 -302
  243. package/renderer/dist/emulator/data/localization/zh-CN.json +0 -351
  244. package/renderer/dist/emulator/data/src/GameManager.js +0 -464
  245. package/renderer/dist/emulator/data/src/compression.js +0 -142
  246. package/renderer/dist/emulator/data/src/emulator.js +0 -6158
  247. package/renderer/dist/emulator/data/src/gamepad.js +0 -167
  248. package/renderer/dist/emulator/data/src/nipplejs.js +0 -1
  249. package/renderer/dist/emulator/data/src/shaders.js +0 -236
  250. package/renderer/dist/emulator/data/src/socket.io.min.js +0 -7
  251. package/renderer/dist/emulator/data/src/storage.js +0 -125
  252. package/renderer/dist/emulator/data/version.json +0 -5
  253. package/renderer/dist/emulator/host.js +0 -299
  254. package/renderer/dist/emulator/index.html +0 -28
  255. package/renderer/dist/emulator/provenance.json +0 -6
package/main/config.mjs CHANGED
@@ -11,7 +11,7 @@ import os from 'node:os';
11
11
  import crypto from 'node:crypto';
12
12
  import { resolveClaudeBin, candidatePaths } from './claude-bin.mjs';
13
13
  import { resolveCodexBin } from './codex-bin.mjs';
14
- import { Name } from '../shared/product-name.mjs';
14
+ import { defaultStoreRoot } from './store/home.mjs';
15
15
  import { readPlans, slotsForPlans } from './claude-plan.mjs';
16
16
  import { machineSlots } from './machine.mjs';
17
17
  import { effectiveProfiles } from './account-discovery.mjs';
@@ -34,14 +34,6 @@ const CONFIG_NAME = 'zero.config.json';
34
34
  export const DEFAULT_SESSIONS_AT_ONCE = 3;
35
35
 
36
36
  const DEFAULTS = {
37
- // Where the inbox's own files live. WHICH FOLDER THIS SHOULD BE ON A
38
- // STRANGER'S MAC IS NOT DECIDED HERE: it is the open question on.
39
- //
40
- // IT IS THE APP'S OWN NAME SINCE 2026-09-22, where it used to be an older
41
- // product's. A real store is always named in the config, so this default is
42
- // only ever the answer for an install carrying none, and renaming it moved
43
- // no files: the folder it used to point at is untouched and still on disk.
44
- storeRoot: path.join(os.homedir(), Name),
45
37
  // ONE ACCOUNT FOLDER PER INSTALL, AND NOBODY ELSE'S.
46
38
  //
47
39
  // This was a literal id for most of the app's life, which meant every copy
@@ -59,10 +51,30 @@ const DEFAULTS = {
59
51
  // How many sessions run at once. The queue absorbs the rest; when the plan
60
52
  // window caps out, a longer queue is the visible, benign failure mode.
61
53
  //
62
- // THIS IS THE NUMBER FOR A MAX SUBSCRIPTION, which is the only kind this was
63
- // ever chosen on. When nobody has set one, loadConfig below reads the plan
54
+ // THIS IS THE NUMBER FOR A MAX SUBSCRIPTION, the biggest plan. When nobody
55
+ // has set one, loadConfig below reads the plan
64
56
  // Claude Code is signed in on and lowers this for a smaller one.
65
57
  maxConcurrentSessions: DEFAULT_SESSIONS_AT_ONCE,
58
+ // LET AGENTBOX DECIDE HOW MANY RUN (w-e5225b62ba). On unless somebody has set
59
+ // a number themselves, which `loadConfig` works out below from whether one was
60
+ // ever written down. With it on, `maxConcurrentSessions` is not read at all:
61
+ // `Supervisor#_slotsPerAccount` asks main/machine.mjs for what this Mac
62
+ // carries and shares that out across the live accounts. `agentsNudge` is how
63
+ // many times "Something felt slow" has been pressed, and only ever subtracts.
64
+ agentsAuto: true,
65
+ agentsNudge: 0,
66
+ // HOLD HEAVY WORK WHEN MEMORY IS SHORT (w-3958c3753d). Off until somebody
67
+ // turns it on from the Agents page. With it on, every shell command a worker
68
+ // runs asks main/memory-gate-server.mjs first, and heavy ones wait their turn
69
+ // while the Mac is short of memory. `memoryGateSlots` is how many heavy
70
+ // commands may run at once; null is Auto, one per 8 GB.
71
+ memoryGate: false,
72
+ memoryGateSlots: null,
73
+ // STOP WHAT FINISHED AGENTS LEAVE RUNNING (main/leftovers.mjs). Its own
74
+ // switch, off until somebody turns it on; `cleanupLeftoversSince` is the
75
+ // moment they did, so nothing is stopped sooner than the idle time after it.
76
+ cleanupLeftovers: false,
77
+ cleanupLeftoversSince: null,
66
78
  // What spawned sessions are allowed to do. Left unset on purpose, because the
67
79
  // right default now depends on whether a store MCP server is present: see
68
80
  // Supervisor#sessionArgsFor. Setting it here still wins over that, which is
@@ -80,11 +92,9 @@ const DEFAULTS = {
80
92
  // PERSONAL PROJECTS AND THE DRIVE WERE BOTH DECLARED HERE AND BOTH ARE GONE
81
93
  // (w-d19d6d387c, 2026-09-22). A personal project ran its sessions on the
82
94
  // founder's message and nothing else, with no brief and no store; the drive
83
- // asked "what next?" by itself on a project that had gone quiet. Hers, on
84
- // the same day: "I would get rid of 'personal' as well. I think there is no
85
- // case where we should ever have that", and "we should get rid of the code
86
- // that self-drives the product entirely. That should never have existed
87
- // anyway, especially because we're open sourcing it soon."
95
+ // asked "what next?" by itself on a project that had gone quiet. There is no
96
+ // case where a project should run without a brief and a store, and code that
97
+ // drives a product on its own does not belong in an open-source app.
88
98
  //
89
99
  // A config still carrying `personalProducts` or `driveEnabled` is not an
90
100
  // error; nothing reads either key now.
@@ -142,6 +152,19 @@ export function loadConfig(appDir, { home = os.homedir() } = {}) {
142
152
  else console.warn(`zero: could not read ${file}: ${err.message}`);
143
153
  }
144
154
  const config = { ...DEFAULTS, ...overrides, freshInstall: fresh };
155
+ // Where the inbox's own files live, when the file names no store. A path in
156
+ // the file always wins, including on Linux. The default is computed here, not
157
+ // at import: on Linux it is the XDG data directory, and on macOS a folder of
158
+ // the app's name. An import-time path cannot see the `home` a test passes,
159
+ // and it cannot see a blanked XDG_DATA_HOME.
160
+ //
161
+ // A stand-in home does not inherit this machine's XDG_DATA_HOME. That
162
+ // variable is an absolute path in the real home, and a throwaway asked with
163
+ // `{ home }` would otherwise open the real store.
164
+ if (typeof overrides.storeRoot !== 'string' || !overrides.storeRoot.trim()) {
165
+ const real = path.resolve(home) === path.resolve(os.homedir());
166
+ config.storeRoot = defaultStoreRoot({ home, env: real ? process.env : {} });
167
+ }
145
168
  // An older alias for `storeRoot` was read here until 2026-09-22. A config
146
169
  // still carrying only the old key falls through to the default now, which is
147
170
  // why the key was renamed in zero.config.json in the same step and not after
@@ -150,11 +173,20 @@ export function loadConfig(appDir, { home = os.homedir() } = {}) {
150
173
  // The account id, minted once per install. Written back immediately, because
151
174
  // a generated id that is not saved is a different id next launch and the
152
175
  // store would move out from under everything.
176
+ //
177
+ // AND A NEW INSTALL MAY CHOOSE CODEX FROM ITS FIRST LAUNCH (w-db6f5e331e).
178
+ // `engineChoice` is the moment from which a row may run on Codex; it exists
179
+ // so August rows in one old store never move engine unasked, and it could
180
+ // only be written by hand, so a new person with both engines never got
181
+ // Codex at all. A Mac with no config file has no old rows, so its first
182
+ // launch is that moment. An install with a config is left as it was.
183
+ const firstLaunch = fresh && !config.engineChoice ? new Date().toISOString() : null;
184
+ if (firstLaunch) config.engineChoice = firstLaunch;
153
185
  if (typeof config.accountId !== 'string' || !config.accountId) {
154
186
  config.accountId = crypto.randomUUID();
155
187
  try {
156
188
  fs.mkdirSync(path.dirname(file), { recursive: true });
157
- fs.writeFileSync(file, `${JSON.stringify({ ...overrides, accountId: config.accountId }, null, 2)}\n`);
189
+ fs.writeFileSync(file, `${JSON.stringify({ ...overrides, accountId: config.accountId, ...(firstLaunch ? { engineChoice: firstLaunch } : {}) }, null, 2)}\n`);
158
190
  } catch (err) {
159
191
  console.warn(`zero: could not write the new account id to ${file}: ${err.message}`);
160
192
  }
@@ -205,6 +237,11 @@ export function loadConfig(appDir, { home = os.homedir() } = {}) {
205
237
  config.codexFound = codex.found;
206
238
  config.codexCertain = codex.certain !== false;
207
239
  config.codexFrom = codex.from;
240
+ // AND AN OLDER INSTALL WITH CODEX ON IT OPENS THE CHOICE TOO (w-d5d632e503).
241
+ // Only a Mac with no config file did, so a person who installed before
242
+ // that, or who added Codex later, never saw Codex anywhere. Written at the
243
+ // end, once `appDir` is on the config to save through.
244
+ const codexOpened = codexChoiceToOpen(config, codex.found);
208
245
  // HOW MANY AGENTS AT ONCE, WHEN NOBODY HAS SAID, IS THE PLAN AND THE MACHINE
209
246
  // TOGETHER. Somebody on a smaller plan gets several sessions started for them,
210
247
  // they all spend the same allowance, and what they see is agents that are slow
@@ -237,6 +274,16 @@ export function loadConfig(appDir, { home = os.homedir() } = {}) {
237
274
  // already in zero.config.json is untouched, for the same reason the plan cap
238
275
  // leaves it alone.
239
276
  config.machineSlots = machineSlots().slots;
277
+ // WHO IS DECIDING THE NUMBER, read off whether one was ever written down
278
+ // (w-e5225b62ba). A number in zero.config.json is somebody's own instruction
279
+ // about this Mac and keeps being obeyed; an install that never touched the
280
+ // stepper was already having its number filled in here, and Automatic is that
281
+ // same thing said out loud and kept up to date. An explicit `agentsAuto`
282
+ // outranks the guess, because turning Automatic back on after setting a number
283
+ // has to be possible.
284
+ if (typeof overrides.agentsAuto !== 'boolean') {
285
+ config.agentsAuto = typeof overrides.maxConcurrentSessions !== 'number';
286
+ }
240
287
  if (typeof overrides.maxConcurrentSessions !== 'number') {
241
288
  const plans = readPlans(effectiveProfiles(config.authProfiles, { home }), { home });
242
289
  const slots = slotsForPlans(plans, config.machineSlots);
@@ -254,9 +301,27 @@ export function loadConfig(appDir, { home = os.homedir() } = {}) {
254
301
  // Where the file is, so anything that edits a setting can write it back
255
302
  // without being handed appDir a second time.
256
303
  config.appDir = appDir;
304
+ if (codexOpened) {
305
+ try {
306
+ saveConfig(config, { engineChoice: codexOpened });
307
+ } catch (err) {
308
+ console.warn(`zero: could not open the Codex choice in ${file}: ${err.message}`);
309
+ }
310
+ }
257
311
  return config;
258
312
  }
259
313
 
314
+ /**
315
+ * THE MOMENT TO OPEN THE CODEX CHOICE AT, OR NULL. Now, when Codex is found
316
+ * and the key was never written. NOW is what keeps a row marked `codex` in
317
+ * August on Claude Code: it was written before this moment, so it is stale
318
+ * (`engineChoiceOnRowIsStale`). A moment already written is kept, and
319
+ * `"engineChoice": false` written by hand keeps the choice shut. */
320
+ export function codexChoiceToOpen(config, codexFound) {
321
+ if (!codexFound || config.engineChoice != null) return null;
322
+ return new Date().toISOString();
323
+ }
324
+
260
325
  export function configFile(appDir) {
261
326
  return path.join(appDir, CONFIG_NAME);
262
327
  }
package/main/demo.mjs CHANGED
@@ -34,9 +34,8 @@ import path from 'node:path';
34
34
  import { spawn } from 'node:child_process';
35
35
  import { DEMO_DOCS, DEMO_ENV, DEMO_PRODUCTS, demoLedgerLines } from '../shared/demo-world.mjs';
36
36
  import { prepareHome } from '../shared/fresh-user-home.mjs';
37
- import { Name } from '../shared/product-name.mjs';
38
37
  import { relaunchCommand } from './fresh-user.mjs';
39
- import { encodeProjectPath, storeRootEnv } from './store/home.mjs';
38
+ import { defaultStoreRoot, encodeProjectPath, storeRootEnv } from './store/home.mjs';
40
39
  import { NAME, nameSlug } from '../shared/product-name.mjs';
41
40
 
42
41
  /**
@@ -87,22 +86,21 @@ export function clearDemoHomes() {
87
86
  /**
88
87
  * Where the demo copy will keep its store, and where it will keep its own
89
88
  * records. Both are derived from the throwaway home rather than configured,
90
- * because the copy derives them the same way from the same home: `storeRoot`
91
- * defaults to `<home>/<Name>` (main/config.mjs) and the app's home defaults
92
- * to `<home>/.agentbox` (main/store/home.mjs).
89
+ * because the copy derives them the same way from the same home. Both are
90
+ * `defaultStoreRoot` for that home with no inherited data directory: on
91
+ * macOS a folder of the app's name, on Linux the XDG path under the
92
+ * throwaway. Passing the machine's XDG_DATA_HOME would seed the demo into
93
+ * the real store.
93
94
  *
94
- * THE FOLDER IS READ FROM THE SAME CONSTANT AS THAT DEFAULT, not spelled out
95
- * again. It was the literal 'the app' mirroring what config.mjs then said,
96
- * and when that default was renamed on 2026-09-22 this line went on pointing
97
- * at the old folder: the demo copy would have written its store to one place
98
- * and looked for it in another. Two literals that have to agree is the defect;
99
- * one import is the fix. */
95
+ * THE FOLDER IS THAT FUNCTION, not a second spelling of it. A literal next
96
+ * to the default is how the demo once wrote its store in one place and
97
+ * looked for it in another. */
100
98
  export function demoPaths(home) {
101
- const storeRoot = path.join(home, Name);
99
+ const storeRoot = defaultStoreRoot({ home, env: {} });
102
100
  return {
103
101
  storeRoot,
104
102
  accountRoot: path.join(storeRoot, 'accounts', DEMO_ACCOUNT),
105
- appHome: path.join(home, `.${nameSlug}`),
103
+ appHome: storeRoot,
106
104
  };
107
105
  }
108
106
 
@@ -192,7 +190,12 @@ export function seedDemoStore(home, { now = Date.now() } = {}) {
192
190
  * standing in an invented store. */
193
191
  export function demoEnv(home, env = process.env) {
194
192
  const { appHome } = demoPaths(home);
195
- return { ...env, HOME: home, CFFIXED_USER_HOME: home, ...storeRootEnv(appHome), [DEMO_ENV]: '1' };
193
+ const next = { ...env, HOME: home, CFFIXED_USER_HOME: home, ...storeRootEnv(appHome), [DEMO_ENV]: '1' };
194
+ // The copy's store is defaultStoreRoot of ITS home. The parent's
195
+ // XDG_DATA_HOME is the real data directory, and leaving it set points the
196
+ // demo at that directory. The other base directories go for the same reason.
197
+ for (const key of ['XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME']) delete next[key];
198
+ return next;
196
199
  }
197
200
 
198
201
  /**
@@ -1,16 +1,16 @@
1
- // A HOT-RELOADING WINDOW MUST NOT BE THE ONE SHE WORKS IN.
1
+ // A HOT-RELOADING WINDOW MUST NOT BE THE ONE THE USER WORKS IN.
2
2
  //
3
- // She was working in a window an agent had started against a vite dev server,
4
- // on her real store. Vite rebuilds that window whenever the source under it
5
- // changes, and the source under it is the checkout every agent shares: it was
6
- // checked out or merged five times in the eleven minutes before she wrote.
3
+ // A window an agent starts against a vite dev server can end up running on the
4
+ // real store. Vite rebuilds that window whenever the source under it changes,
5
+ // and the source under it is the checkout every agent shares, which can be
6
+ // checked out or merged several times in a few minutes.
7
7
  //
8
- // The one that gets her is a change to App.tsx that adds, removes or moves a
8
+ // The change that hurts is one to App.tsx that adds, removes or moves a
9
9
  // hook, which is what a branch switch does to it. React Fast Refresh keeps a
10
10
  // component's state only while its hooks match, so App is rebuilt from nothing:
11
11
  // `snap` is null again, App.tsx draws `<div className="boot">the app</div>`, and
12
- // every child of App is thrown away and remade. That is the screen she means by
13
- // the zero loading page, and that is the artifact closing and reopening. The
12
+ // every child of App is thrown away and remade. The window drops back to the
13
+ // loading page, and an open artifact closes and reopens. The
14
14
  // page never navigates, which is why nothing in the dev server's log looked bad
15
15
  // enough to be it. Six triggers were measured against a real window and exactly
16
16
  // one was guilty: scripts/what-throws-her-out-of-agentbox.mjs.
@@ -25,7 +25,7 @@
25
25
  // WHAT IS STILL ALLOWED, because a guard that stops the work is a guard somebody
26
26
  // deletes:
27
27
  //
28
- // Fixtures. Nothing in a fixtures window is hers, so there is nothing to be
28
+ // Fixtures. Nothing in a fixtures window is real, so there is nothing to be
29
29
  // thrown out of, and this is what screenshot and proof scripts use.
30
30
  //
31
31
  // A throwaway store. A script that points HOME at a temp directory has no
@@ -124,3 +124,5 @@ export class DocGrants {
124
124
  // main/zoom-keys.mjs) scales the window, and the pane is an ordinary iframe in
125
125
  // that window, so an open file follows it like everything else. Do not put a
126
126
  // listener back into a served page: nothing is injected into a file she reads.
127
+ // The one thing added to a page is its scrollbar's look, from outside and after
128
+ // it is served, and frame-scrollbars.mjs says why that is not this.
@@ -0,0 +1,204 @@
1
+ // SETTING UP SOMEBODY'S PLAN FOR THEM (w-9f6975906c, picked 2026-10-05).
2
+ //
3
+ // A person with a Claude or ChatGPT plan and no Claude Code or Codex used to
4
+ // reach a card with a link to an install page. Now the walk asks which plan
5
+ // they pay for and this does the rest: install the tool if it is missing, start
6
+ // its own sign-in, and wait until the tool itself says it is signed in. The one
7
+ // thing left for them is approving in their browser.
8
+ //
9
+ // EVERY COMMAND HERE IS THE TOOL'S OWN, and that is a rule, not a style.
10
+ // Anthropic does not allow third party apps to offer claude.ai login unless
11
+ // approved, so Agentbox never draws a sign-in of its own: it runs Claude Code's
12
+ // `auth login`, which opens Anthropic's page, and Codex's `login`, which opens
13
+ // OpenAI's. Measured 2026-10-05 into throwaway home folders with stdin closed:
14
+ // both installers finish with no prompts (Claude Code 24.6 s, Codex 9.7 s) and
15
+ // land in `~/.local/bin`, the first place each finder looks; `claude auth login
16
+ // --claudeai` runs `open <url>` with a localhost callback, so approving in the
17
+ // browser finishes it with nothing to paste.
18
+ //
19
+ // NO API KEY REACHES ANYTHING RUN HERE. An inherited ANTHROPIC_API_KEY makes
20
+ // Claude Code report itself signed in on the key and bill it, which is the
21
+ // opposite of setting up their plan; `terminalEnv` is the scrub the built-in
22
+ // terminal already uses.
23
+
24
+ import { spawn as nodeSpawn } from 'node:child_process';
25
+ import { terminalEnv } from './task-terminals.mjs';
26
+ import { codexLaunchEnv } from './codex-launch-env.mjs';
27
+
28
+ const INSTALLERS = {
29
+ claude: { shell: '/bin/bash', url: 'https://claude.ai/install.sh', pipe: 'bash' },
30
+ codex: { shell: '/bin/sh', url: 'https://chatgpt.com/codex/install.sh', pipe: 'sh' },
31
+ };
32
+
33
+ export function installCommand(engine) {
34
+ const i = INSTALLERS[engine];
35
+ return { file: i.shell, args: ['-c', `curl -fsSL ${i.url} | ${i.pipe}`] };
36
+ }
37
+
38
+ /** The plan, never the API console: `--claudeai` is Claude Code's own name for it. */
39
+ export function signInCommand(engine, bin) {
40
+ return engine === 'claude'
41
+ ? { file: bin, args: ['auth', 'login', '--claudeai'] }
42
+ : { file: bin, args: ['login'] };
43
+ }
44
+
45
+ /** Exit 0 is signed in and 1 is not, for both (measured 2026-10-05). */
46
+ export function statusCommand(engine, bin) {
47
+ return engine === 'claude'
48
+ ? { file: bin, args: ['auth', 'status', '--json'] }
49
+ : { file: bin, args: ['login', 'status'] };
50
+ }
51
+
52
+ const LOG_LIMIT = 64 * 1024;
53
+ const STATUS_TIMEOUT = 15_000;
54
+ const POLL = 2_000;
55
+ const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
56
+
57
+ function realSpawn(file, args, { env, onData, keepStdin = false } = {}) {
58
+ let child;
59
+ try {
60
+ child = nodeSpawn(file, args, { env, stdio: [keepStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
61
+ } catch (error) {
62
+ onData?.(`${error.message}\n`);
63
+ return { exit: Promise.resolve(127), kill() {} };
64
+ }
65
+ const feed = (d) => onData?.(String(d));
66
+ child.stdout?.on('data', feed);
67
+ child.stderr?.on('data', feed);
68
+ const exit = new Promise((resolve) => {
69
+ child.on('exit', (code) => resolve(code ?? 1));
70
+ child.on('error', (error) => { onData?.(`${error.message}\n`); resolve(127); });
71
+ });
72
+ return { exit, kill: () => { try { child.kill('SIGTERM'); } catch { /* already gone */ } } };
73
+ }
74
+
75
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
76
+
77
+ /**
78
+ * One setup per tool, held for the life of the app.
79
+ *
80
+ * `find(engine)` is the finder, looking again from scratch (the caller passes
81
+ * `recheckClaude` / `recheckCodex`, which also move the config so the rest of
82
+ * the app runs on what was just installed). `spawn` and `wait` are seams for
83
+ * the tests; nothing in the app passes them.
84
+ */
85
+ export function createEngineSetup({ find, spawn = realSpawn, wait = sleep, env = process.env, poll = POLL, onSignIn = () => {} } = {}) {
86
+ const runEnv = terminalEnv(env);
87
+ const toolEnv = (engine, bin) => engine === 'codex' ? codexLaunchEnv(bin, runEnv) : runEnv;
88
+ const jobs = new Map();
89
+
90
+ const fresh = (engine) => ({ engine, phase: 'idle', error: null, log: '', running: null, login: null, run: 0 });
91
+ const job = (engine) => { if (!jobs.has(engine)) jobs.set(engine, fresh(engine)); return jobs.get(engine); };
92
+ const say = (j, text) => { j.log = (j.log + String(text).replace(ANSI, '')).slice(-LOG_LIMIT); };
93
+ const lastLine = (text) => text.split('\n').map((l) => l.trim()).filter(Boolean).pop() ?? '';
94
+
95
+ async function signedIn(j, bin) {
96
+ const { file, args } = statusCommand(j.engine, bin);
97
+ let output = '';
98
+ const p = spawn(file, args, { env: toolEnv(j.engine, bin), onData: (d) => { output = (output + String(d).replace(ANSI, '')).slice(-LOG_LIMIT); } });
99
+ const TIMED_OUT = Symbol('no answer');
100
+ const code = await Promise.race([p.exit, wait(STATUS_TIMEOUT).then(() => { p.kill(); return TIMED_OUT; })]);
101
+ // TOLD TO THE APP, which routes on it (w-d5d632e503): a Claude Code that
102
+ // is signed out beside a Codex that is signed in is a Mac that runs on
103
+ // Codex. Only a real yes or no; a check that never answered says nothing.
104
+ if (code === 0 || code === 1) onSignIn(j.engine, code === 0);
105
+ if (code === 0 || code === 1) return code === 0;
106
+ if (output) say(j, output);
107
+ const name = j.engine === 'codex' ? 'Codex' : 'Claude Code';
108
+ throw new Error(code === TIMED_OUT
109
+ ? `${name} did not answer the sign-in check. Try again.`
110
+ : `${name} could not check sign-in: ${lastLine(output) || `exit ${code}`}`);
111
+ }
112
+
113
+ function startLogin(j, bin) {
114
+ j.login?.kill();
115
+ const { file, args } = signInCommand(j.engine, bin);
116
+ const login = spawn(file, args, { env: toolEnv(j.engine, bin), keepStdin: true, onData: (d) => say(j, d) });
117
+ j.login = login;
118
+ return login;
119
+ }
120
+
121
+ async function go(j, run) {
122
+ const live = () => j.run === run;
123
+ let hit = find(j.engine);
124
+ if (!hit?.found) {
125
+ j.phase = 'installing';
126
+ const { file, args } = installCommand(j.engine);
127
+ const p = spawn(file, args, { env: runEnv, onData: (d) => say(j, d) });
128
+ j.running = p;
129
+ const code = await p.exit;
130
+ j.running = null;
131
+ if (!live()) return;
132
+ if (code !== 0) return fail(j, `The installer stopped: ${lastLine(j.log) || `exit ${code}`}`);
133
+ hit = find(j.engine);
134
+ if (!hit?.found) return fail(j, 'The installer finished, but the app still cannot find it.');
135
+ }
136
+ j.bin = hit.path;
137
+ j.phase = 'checking';
138
+ if (await signedIn(j, hit.path)) return live() && ready(j);
139
+ if (!live()) return;
140
+ j.phase = 'signing-in';
141
+ let login = startLogin(j, hit.path);
142
+ let ended = false;
143
+ const watchLogin = (loginProcess) => loginProcess.exit.then(() => { if (loginProcess === j.login) ended = true; });
144
+ watchLogin(login);
145
+ for (;;) {
146
+ await wait(poll);
147
+ if (!live()) return;
148
+ if (await signedIn(j, hit.path)) { if (live()) ready(j); return; }
149
+ if (j.login !== login) {
150
+ login = j.login;
151
+ ended = false;
152
+ watchLogin(login);
153
+ }
154
+ if (ended) return fail(j, `The sign-in was closed before it finished.${lastLine(j.log) ? ` ${lastLine(j.log)}` : ''}`);
155
+ }
156
+ }
157
+
158
+ function ready(j) { j.login?.kill(); j.login = null; j.phase = 'ready'; j.error = null; }
159
+ function fail(j, error) { j.login?.kill(); j.login = null; j.phase = 'failed'; j.error = error; }
160
+
161
+ return {
162
+ start(engine) {
163
+ const j = job(engine);
164
+ if (['installing', 'checking', 'signing-in'].includes(j.phase)) return this.status(engine);
165
+ j.run += 1; j.phase = 'checking'; j.error = null; j.log = '';
166
+ const run = j.run;
167
+ go(j, run).catch((e) => { if (j.run === run) fail(j, String(e?.message ?? e)); });
168
+ return this.status(engine);
169
+ },
170
+ /** Kill the sign-in that is waiting and start a new one, which opens the page again. */
171
+ signInAgain(engine) {
172
+ const j = job(engine);
173
+ if (j.phase === 'signing-in' && j.bin) startLogin(j, j.bin);
174
+ return this.status(engine);
175
+ },
176
+ cancel(engine) {
177
+ const j = job(engine);
178
+ j.run += 1;
179
+ j.running?.kill(); j.login?.kill();
180
+ j.running = null; j.login = null;
181
+ if (j.phase !== 'ready') j.phase = 'idle';
182
+ return this.status(engine);
183
+ },
184
+ status(engine) {
185
+ const j = job(engine);
186
+ return { engine, phase: j.phase, error: j.error, log: j.log };
187
+ },
188
+ /**
189
+ * WHETHER THIS TOOL CAN RUN AN AGENT RIGHT NOW: found, and signed in by its
190
+ * own account. The walk asks before it asks anything, so a Mac that is
191
+ * already set up never sees the plan question. */
192
+ async readiness(engine) {
193
+ const hit = find(engine);
194
+ if (!hit?.found) return { engine, found: false, signedIn: false };
195
+ try { return { engine, found: true, signedIn: await signedIn({ engine, log: '' }, hit.path) }; }
196
+ catch (error) { return { engine, found: true, signedIn: false, error: error.message }; }
197
+ },
198
+ /** The same question against a path already found, so asking it at
199
+ * launch does not search the Mac again. */
200
+ signedInNow(engine, bin) {
201
+ return signedIn({ engine, log: '' }, bin).catch(() => false);
202
+ },
203
+ };
204
+ }
@@ -0,0 +1,55 @@
1
+ // SENDING FEEDBACK TO THE AGENTBOX TEAM (w-1b574413db, 2026-10-04).
2
+ //
3
+ // The card hands the main process what you wrote and what you attached, and
4
+ // this file posts it to one server function. That function holds who it goes
5
+ // to as a secret (`FEEDBACK_TO`, cloud/supabase/functions/feedback), so the
6
+ // address is in no file of this public repository and no install can show it.
7
+ //
8
+ // WHERE IT GOES IS BAKED IN, like the analytics key (main/analytics.mjs): the
9
+ // release writes `bakedFeedbackUrl` into the packaged package.json, a copy run
10
+ // from source carries none, and a `feedbackUrl` in zero.config.json outranks
11
+ // both. A copy with nowhere to send says so in words rather than pretending.
12
+ //
13
+ // It is not behind the diagnostics switch. That switch is about what Agentbox
14
+ // sends on its own; this is something a person typed and pressed Send on.
15
+ import { createRequire } from 'node:module';
16
+ import { checkFeedback } from '../shared/feedback.mjs';
17
+ import { Name, readEnv } from '../shared/product-name.mjs';
18
+
19
+ function bakedUrl() {
20
+ try {
21
+ return createRequire(import.meta.url)('../package.json')?.bakedFeedbackUrl ?? null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ const BAKED = bakedUrl();
28
+ const clean = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
29
+
30
+ export function feedbackUrl(config, baked = BAKED, env = process.env) {
31
+ return clean(config?.feedbackUrl) ?? clean(readEnv('FEEDBACK_URL', env)) ?? clean(baked);
32
+ }
33
+
34
+ // Answers { ok: true } or { ok: false, error } with the error as a sentence.
35
+ // It never throws, so the card always has something to say.
36
+ export async function sendFeedback({ url, payload, fetchImpl = globalThis.fetch, timeoutMs = 60_000 }) {
37
+ if (!url) return { ok: false, error: `Feedback is not set up in this copy of ${Name}.` };
38
+ // Checked again here, off the bytes that would actually travel, since the
39
+ // renderer is not the last word on what leaves this machine.
40
+ const sized = (payload?.files ?? []).map((f) => ({ size: Math.floor((String(f?.data ?? '').length * 3) / 4) }));
41
+ const check = checkFeedback({ text: payload?.text, files: sized });
42
+ if (!check.ok) return { ok: false, error: check.reason };
43
+ try {
44
+ const res = await fetchImpl(url, {
45
+ method: 'POST',
46
+ headers: { 'content-type': 'application/json' },
47
+ body: JSON.stringify(payload),
48
+ signal: AbortSignal.timeout(timeoutMs),
49
+ });
50
+ if (res?.ok) return { ok: true };
51
+ return { ok: false, error: 'It did not send. Try again in a moment.' };
52
+ } catch {
53
+ return { ok: false, error: 'It did not send. Check your connection and try again.' };
54
+ }
55
+ }
@@ -0,0 +1,48 @@
1
+ // THE PICTURES IN A FOLDER A MESSAGE NAMES, newest first.
2
+ //
3
+ // A worker that drew several versions names their folder on a line of its own,
4
+ // and until 2026-10-04 that line was grey text. Focus now draws the pictures
5
+ // under it (FolderPreview), and this is the one thing the window cannot do for
6
+ // itself: look inside a folder.
7
+ //
8
+ // Two rules, the same two the picture scheme enforces (./img-scheme.mjs), so a
9
+ // folder line is never a way to list anything else: the folder is inside this
10
+ // product's own folders or the account root, and only pictures come back. One
11
+ // level only, because a design folder keeps old rounds in folders of their own.
12
+ // tests/a-named-folder-shows-its-pictures.test.mjs holds every edge.
13
+ import fsDefault from 'node:fs';
14
+ import path from 'node:path';
15
+ import { resolveArtifact } from './artifact-path.mjs';
16
+ import { servable } from './img-scheme.mjs';
17
+
18
+ const PICTURE = /\.(png|jpe?g|gif|webp|avif)$/i;
19
+
20
+ export function folderPictures({ src, product, products = [], accountRoot, storeRoot, limit = 60, fs = fsDefault }) {
21
+ if (typeof src !== 'string' || !src.trim()) return { ok: false, error: 'No folder was named.' };
22
+ const named = src.replace(/\/+$/, '');
23
+ const found = resolveArtifact({ src: named, product, products, accountRoot, storeRoot, fs });
24
+ if (!found.ok) return { ok: false, error: found.error };
25
+ const own = products.find((p) => p.slug === product) ?? null;
26
+ const roots = [accountRoot, own?.dir, own?.repoPath].filter(Boolean);
27
+ let entries;
28
+ try {
29
+ if (!fs.statSync(found.path).isDirectory()) return { ok: false, error: `${src} is not a folder.` };
30
+ entries = fs.readdirSync(found.path, { withFileTypes: true });
31
+ } catch (e) {
32
+ return { ok: false, error: `${src} could not be read (${e.message}).` };
33
+ }
34
+ const pictures = [];
35
+ for (const entry of entries) {
36
+ if (!entry.isFile() || !PICTURE.test(entry.name)) continue;
37
+ const file = path.join(found.path, entry.name);
38
+ if (!servable(file, roots)) continue;
39
+ let mtime = 0;
40
+ try { mtime = fs.statSync(file).mtimeMs; } catch { continue; }
41
+ pictures.push({ file, mtime });
42
+ }
43
+ if (!pictures.length && !roots.some((r) => found.path === r || found.path.startsWith(r + path.sep))) {
44
+ return { ok: false, error: `${src} is not in this project.` };
45
+ }
46
+ pictures.sort((a, b) => b.mtime - a.mtime || a.file.localeCompare(b.file));
47
+ return { ok: true, folder: found.path, pictures: pictures.slice(0, limit).map((p) => p.file), total: pictures.length };
48
+ }