agentbox-app 0.1.6 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/README.md +209 -55
  2. package/bin/agentbox.mjs +6 -0
  3. package/briefs/message-rules.md +13 -3
  4. package/briefs/worker.md +63 -49
  5. package/briefs/writing-rules.md +90 -131
  6. package/main/account-discovery.mjs +21 -20
  7. package/main/account-tooling.mjs +9 -10
  8. package/main/agent-activity.mjs +45 -5
  9. package/main/agent-schedule.mjs +14 -14
  10. package/main/agent-sessions.mjs +94 -10
  11. package/main/agent-updates.mjs +114 -0
  12. package/main/agents.mjs +52 -46
  13. package/main/analytics.mjs +27 -0
  14. package/main/approval-prompt-server.mjs +2 -2
  15. package/main/artifact-path.mjs +11 -0
  16. package/main/claude-bin.mjs +31 -5
  17. package/main/claude-input.mjs +45 -3
  18. package/main/claude-plan.mjs +11 -11
  19. package/main/code-change.mjs +3 -3
  20. package/main/codex-account.mjs +1 -1
  21. package/main/codex-bin.mjs +17 -0
  22. package/main/codex-memory-gate.mjs +260 -0
  23. package/main/codex-models.mjs +12 -7
  24. package/main/codex-threads.mjs +46 -23
  25. package/main/codex-usage-file.mjs +7 -7
  26. package/main/codex-usage.mjs +4 -4
  27. package/main/codex-watch.mjs +12 -12
  28. package/main/config.mjs +68 -8
  29. package/main/dev-window.mjs +9 -9
  30. package/main/doc-scheme.mjs +2 -0
  31. package/main/engine-setup.mjs +193 -0
  32. package/main/feedback.mjs +55 -0
  33. package/main/folder-pictures.mjs +48 -0
  34. package/main/frame-scrollbars.mjs +90 -0
  35. package/main/fresh-user.mjs +6 -3
  36. package/main/git-change-offthread.mjs +70 -0
  37. package/main/git-change-thread.mjs +11 -0
  38. package/main/git-change.mjs +1 -1
  39. package/main/img-scheme.mjs +70 -1
  40. package/main/instruction-settings.mjs +92 -54
  41. package/main/ipc.mjs +316 -42
  42. package/main/leftovers.mjs +518 -0
  43. package/main/live-replies.mjs +43 -5
  44. package/main/machine.mjs +47 -0
  45. package/main/main.mjs +119 -15
  46. package/main/memory-gate-history.mjs +254 -0
  47. package/main/memory-gate-server.mjs +461 -0
  48. package/main/memory-gate.mjs +206 -0
  49. package/main/message-priority.mjs +87 -0
  50. package/main/rail-note.mjs +2 -2
  51. package/main/refresh-agent-models.mjs +20 -0
  52. package/main/repeats.mjs +4 -4
  53. package/main/row-label.mjs +114 -51
  54. package/main/serve.mjs +67 -5
  55. package/main/settings.mjs +223 -26
  56. package/main/ship-queue.mjs +279 -0
  57. package/main/sign-in-files.mjs +39 -0
  58. package/main/source-updater.mjs +298 -0
  59. package/main/store/home.mjs +47 -2
  60. package/main/store/work-items.mjs +150 -6
  61. package/main/store.mjs +235 -9
  62. package/main/supervisor.mjs +1444 -406
  63. package/main/task-commands.mjs +8 -10
  64. package/main/task-folders.mjs +74 -13
  65. package/main/task-remote-control.mjs +3 -3
  66. package/main/task-terminals.mjs +2 -1
  67. package/main/team/index.mjs +440 -0
  68. package/main/team/memory-cloud.mjs +247 -0
  69. package/main/team/projects.mjs +162 -0
  70. package/main/team/session.mjs +225 -0
  71. package/main/team/sign-in.mjs +106 -0
  72. package/main/team/supabase-backend.mjs +231 -0
  73. package/main/team/sync.mjs +163 -0
  74. package/main/updater.mjs +15 -5
  75. package/main/write-before-reload.mjs +50 -0
  76. package/mcp/core/page.mjs +9 -11
  77. package/mcp/core/work.mjs +91 -25
  78. package/mcp/tools.mjs +12 -6
  79. package/package.json +21 -9
  80. package/renderer/dist/assets/agentbox-icon-F1oN9nj_.svg +15 -0
  81. package/renderer/dist/assets/index-B-2Az952.css +32 -0
  82. package/renderer/dist/assets/index-D6qncgai.js +1168 -0
  83. package/renderer/dist/index.html +2 -2
  84. package/scripts/memory-gate-hook.sh +59 -0
  85. package/shared/agent-import.mjs +38 -15
  86. package/shared/agents.d.mts +26 -8
  87. package/shared/agents.mjs +100 -108
  88. package/shared/analytics-events.mjs +19 -2
  89. package/shared/answers.mjs +12 -0
  90. package/shared/bridge-map.d.mts +12 -0
  91. package/shared/bridge-map.mjs +37 -1
  92. package/shared/claude-commands.generated.mjs +2 -2
  93. package/shared/claude-models.generated.mjs +4 -4
  94. package/shared/claude-usage.mjs +6 -6
  95. package/shared/codex-import.mjs +7 -7
  96. package/shared/codex-modes.d.mts +50 -0
  97. package/shared/codex-usage.mjs +7 -8
  98. package/shared/demo-world.mjs +5 -5
  99. package/shared/engines.mjs +27 -10
  100. package/shared/feedback.d.mts +14 -0
  101. package/shared/feedback.mjs +44 -0
  102. package/shared/first-run-examples.mjs +4 -4
  103. package/shared/first-run-practice.d.mts +16 -0
  104. package/shared/first-run-practice.mjs +176 -70
  105. package/shared/fresh-user-home.mjs +22 -3
  106. package/shared/instruction-defaults.json +2 -2
  107. package/shared/product-name.mjs +4 -2
  108. package/shared/project-folder-check.mjs +6 -0
  109. package/shared/rank.d.mts +1 -0
  110. package/shared/rank.mjs +22 -0
  111. package/shared/referenced-files.mjs +1 -1
  112. package/shared/schemes.d.mts +11 -0
  113. package/shared/schemes.mjs +1 -1
  114. package/shared/shortcuts.d.mts +41 -0
  115. package/shared/shortcuts.mjs +236 -0
  116. package/shared/spawn-trouble.d.mts +2 -0
  117. package/shared/spawn-trouble.mjs +39 -18
  118. package/shared/team-rules.d.mts +17 -0
  119. package/shared/team-rules.mjs +230 -0
  120. package/shared/team-status.d.mts +11 -0
  121. package/shared/team-status.mjs +90 -0
  122. package/shared/terminal-links.d.mts +2 -0
  123. package/shared/terminal-links.mjs +55 -0
  124. package/shared/terminal-state.d.mts +1 -0
  125. package/shared/terminal-state.mjs +16 -0
  126. package/shared/thread-cards.d.mts +19 -0
  127. package/shared/thread-cards.mjs +254 -0
  128. package/shared/usage.d.mts +1 -1
  129. package/shared/usage.mjs +6 -7
  130. package/shared/work-items.mjs +255 -27
  131. package/shared/work-lines.mjs +393 -16
  132. package/worker-permissions.json +1 -1
  133. package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
  134. package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
  135. package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
  136. package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
  137. package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
  138. package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
  139. package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
  140. package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
  141. package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
  142. package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
  143. package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
  144. package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
  145. package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
  146. package/renderer/dist/assets/index-CwiDv5bL.css +0 -32
  147. package/renderer/dist/assets/index-D9hCAVPl.js +0 -684
  148. package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
  149. package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
  150. package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
  151. package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
  152. package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
  153. package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
  154. package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
  155. package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
  156. package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
  157. package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
  158. package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
  159. package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
  160. package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
  161. package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
  162. package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
  163. package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
  164. package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
  165. package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
  166. package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
  167. package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
  168. package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
  169. package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
  170. package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
  171. package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
  172. package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
  173. package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
  174. package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
  175. package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
  176. package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
  177. package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
  178. package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
  179. package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
  180. package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
  181. package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
  182. package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
  183. package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
  184. package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
  185. package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
  186. package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
  187. package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
  188. package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
  189. package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
  190. package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
  191. package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
  192. package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
  193. package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
  194. package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
  195. package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
  196. package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
  197. package/renderer/dist/emulator/audio-rules.mjs +0 -74
  198. package/renderer/dist/emulator/data/compression/README.md +0 -11
  199. package/renderer/dist/emulator/data/compression/extract7z.js +0 -1
  200. package/renderer/dist/emulator/data/compression/extractzip.js +0 -1
  201. package/renderer/dist/emulator/data/compression/libunrar.js +0 -519
  202. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  203. package/renderer/dist/emulator/data/cores/README.md +0 -22
  204. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  205. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  206. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  207. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  208. package/renderer/dist/emulator/data/cores/package.json +0 -65
  209. package/renderer/dist/emulator/data/cores/reports/mgba.json +0 -1
  210. package/renderer/dist/emulator/data/emulator.css +0 -1614
  211. package/renderer/dist/emulator/data/loader.js +0 -170
  212. package/renderer/dist/emulator/data/localization/README.md +0 -63
  213. package/renderer/dist/emulator/data/localization/af-FR.json +0 -339
  214. package/renderer/dist/emulator/data/localization/ar-AR.json +0 -301
  215. package/renderer/dist/emulator/data/localization/ben-BEN.json +0 -301
  216. package/renderer/dist/emulator/data/localization/de-GER.json +0 -301
  217. package/renderer/dist/emulator/data/localization/el-GR.json +0 -301
  218. package/renderer/dist/emulator/data/localization/en-US.json +0 -339
  219. package/renderer/dist/emulator/data/localization/es-ES.json +0 -310
  220. package/renderer/dist/emulator/data/localization/fa-AF.json +0 -310
  221. package/renderer/dist/emulator/data/localization/hi-HI.json +0 -301
  222. package/renderer/dist/emulator/data/localization/it-IT.json +0 -302
  223. package/renderer/dist/emulator/data/localization/ja-JA.json +0 -301
  224. package/renderer/dist/emulator/data/localization/jv-JV.json +0 -301
  225. package/renderer/dist/emulator/data/localization/ko-KO.json +0 -336
  226. package/renderer/dist/emulator/data/localization/pt-BR.json +0 -361
  227. package/renderer/dist/emulator/data/localization/retroarch.json +0 -617
  228. package/renderer/dist/emulator/data/localization/ro-RO.json +0 -310
  229. package/renderer/dist/emulator/data/localization/ru-RU.json +0 -301
  230. package/renderer/dist/emulator/data/localization/tr-TR.json +0 -310
  231. package/renderer/dist/emulator/data/localization/vi-VN.json +0 -302
  232. package/renderer/dist/emulator/data/localization/zh-CN.json +0 -351
  233. package/renderer/dist/emulator/data/src/GameManager.js +0 -464
  234. package/renderer/dist/emulator/data/src/compression.js +0 -142
  235. package/renderer/dist/emulator/data/src/emulator.js +0 -6158
  236. package/renderer/dist/emulator/data/src/gamepad.js +0 -167
  237. package/renderer/dist/emulator/data/src/nipplejs.js +0 -1
  238. package/renderer/dist/emulator/data/src/shaders.js +0 -236
  239. package/renderer/dist/emulator/data/src/socket.io.min.js +0 -7
  240. package/renderer/dist/emulator/data/src/storage.js +0 -125
  241. package/renderer/dist/emulator/data/version.json +0 -5
  242. package/renderer/dist/emulator/host.js +0 -299
  243. package/renderer/dist/emulator/index.html +0 -28
  244. package/renderer/dist/emulator/provenance.json +0 -6
package/main/settings.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  import fs from 'node:fs';
16
16
  import os from 'node:os';
17
17
  import path from 'node:path';
18
- import { saveConfig } from './config.mjs';
18
+ import { codexChoiceToOpen, saveConfig } from './config.mjs';
19
19
  import { resolveClaudeBin, forgetClaudeBin, INSTALL_URL } from './claude-bin.mjs';
20
20
  import { AGENT_MODES } from '../shared/agents.mjs';
21
21
  import { analyticsKey, diagnosticsOn } from './analytics.mjs';
@@ -23,7 +23,9 @@ import { CLAUDE_PERMISSION_MODES } from '../shared/work-items.mjs';
23
23
  import { accountSentence, engineTroubleNote } from '../shared/spawn-trouble.mjs';
24
24
  import { readPlan } from './claude-plan.mjs';
25
25
  import { effectiveProfiles } from './account-discovery.mjs';
26
- import { machineSlots, machineNote, MAX_SLOTS } from './machine.mjs';
26
+ import { machineSlots, machineNote, autoAgents, MAX_SLOTS } from './machine.mjs';
27
+ import { NAME } from '../shared/product-name.mjs';
28
+ import { autoSlots } from './memory-gate.mjs';
27
29
  import { accountIdentity, claudeLoginCommand, duplicateAccountNote, linkAccountTooling, makeClaudeHome } from './account-tooling.mjs';
28
30
  import { isEngine } from '../shared/engines.mjs';
29
31
  import { codexAccount, codexLoginCommand, makeCodexHome } from './codex-account.mjs';
@@ -270,6 +272,10 @@ export function recheckClaude(config) {
270
272
  // has just found Claude Code updates it rather than leaving the rest of the
271
273
  // app pointing at where it used to not be.
272
274
  if (state.claudeFound && state.claudeBin) config.claudeBin = state.claudeBin;
275
+ // And whether it is here at all, which is what decides that a Mac with only
276
+ // Codex runs on Codex (`Supervisor#_enginesFound`). Only a certain answer
277
+ // moves it, so an unsure search never takes Claude Code away from anybody.
278
+ if (state.claudeFound || state.claudeCertain) config.claudeFound = state.claudeFound;
273
279
  return state;
274
280
  }
275
281
 
@@ -319,6 +325,12 @@ export function codexState(config, where = {}) {
319
325
  };
320
326
  }
321
327
 
328
+ /** `codexState` under the names the walk's last card reads beside Claude's. */
329
+ export function codexFoundState(config, where = {}) {
330
+ const state = codexState(config, where);
331
+ return { codexFound: state.found, codexCertain: state.certain, codexInstallUrl: state.url };
332
+ }
333
+
322
334
  /**
323
335
  * LOOK AGAIN, PROPERLY, for the second engine. The twin of `recheckClaude`
324
336
  * above, and it earns its place for a sharper reason: finding Codex here is
@@ -329,6 +341,10 @@ export function recheckCodex(config) {
329
341
  forgetCodexBin();
330
342
  const state = codexState(config);
331
343
  config.codexBin = state.found ? state.bin : null;
344
+ // Found while the app is open, by this button or the plan setup: the choice
345
+ // opens now, as it would have at the next launch (main/config.mjs).
346
+ const opened = codexChoiceToOpen(config, state.found);
347
+ if (opened && config.appDir) saveConfig(config, { engineChoice: opened });
332
348
  return state;
333
349
  }
334
350
 
@@ -366,9 +382,9 @@ export function readSettings({ config, supervisor, store }) {
366
382
  const trouble = troubles[profile] ?? null;
367
383
  const resting = (supervisor._profileCooldown?.[profile] ?? 0) >= now;
368
384
  // WHOSE SUBSCRIPTION THIS ROW IS, not merely which folder it lives in.
369
- // The page named its rows after folders, so nothing on it could tell her
370
- // that her work account had been replaced by her personal one;
371
- // main/account-tooling.mjs has the morning that cost. Null when the
385
+ // The page named its rows after folders, so nothing on it could tell a
386
+ // person that one of their accounts had been replaced by the other;
387
+ // main/account-tooling.mjs has how that happens. Null when the
372
388
  // folder holds no login, and then the row reads as it always did.
373
389
  const identity = accountIdentity(profile === 'default' ? null : profile);
374
390
  return {
@@ -421,9 +437,8 @@ export function readSettings({ config, supervisor, store }) {
421
437
  // THE MARK, already a drawable url or null, resolved once by listProducts.
422
438
  logo: product.logo,
423
439
  // ONE SWITCH A PROJECT STILL HAS (w-d19d6d387c, 2026-09-22). It had five
424
- // controls. Hers, on a picture of them: "I still don't actually
425
- // understand what those toggles do, even after reading them... I would
426
- // lean towards just having project-level instructions." The drive, the
440
+ // controls, and what they did was not clear even after reading their
441
+ // labels, so project-level instructions replace them. The drive, the
427
442
  // per-project pause and personal mode are gone from the app entirely.
428
443
  // `permission` stays readable because `projectSessionArgs` is still
429
444
  // honoured for anyone who sets one in the config by hand, and the page
@@ -439,10 +454,31 @@ export function readSettings({ config, supervisor, store }) {
439
454
  };
440
455
  });
441
456
 
457
+ // ARCHIVED PROJECTS, only so the Projects page can bring one back. Name and
458
+ // folder, the two things that tell three projects called Daydream apart.
459
+ let archivedProjects = [];
460
+ try {
461
+ archivedProjects = store.listProducts({ includeArchived: true })
462
+ .filter((p) => p.archived === true)
463
+ .map((p) => ({ slug: p.slug, name: p.name, dir: p.dir }));
464
+ } catch {}
465
+
442
466
  return {
467
+ archivedProjects,
443
468
  workspace: {
444
469
  agentsRunning: !supervisor.paused,
445
470
  sessionsAtOnce: config.maxConcurrentSessions,
471
+ // AUTOMATIC, AND WHAT IT WORKS OUT TO (w-e5225b62ba). `agentsAuto` is who
472
+ // is deciding; `agentsTotal` is what the whole Mac is running at once,
473
+ // which is the number the page shows, because the per-account one is
474
+ // arithmetic nobody should have to do. `agentsNudge` is how many steps
475
+ // down "Something felt slow" has taken it.
476
+ agentsAuto: !!config.agentsAuto,
477
+ agentsTotal: status.capacity,
478
+ agentsNudge: Math.max(0, Math.round(Number(config.agentsNudge) || 0)),
479
+ // What Automatic would say right now, so a manual number can be compared
480
+ // with it without turning Automatic back on to find out.
481
+ agentsAutoTotal: autoAgents({ gated: !!config.memoryGate, nudge: config.agentsNudge }),
446
482
  // THE PLAN THAT SET THE NUMBER ABOVE, when a plan set it. Null in the
447
483
  // ordinary case: she picked the number herself, or it is the three every
448
484
  // install has always had. It is only ever filled in when somebody would
@@ -457,6 +493,8 @@ export function readSettings({ config, supervisor, store }) {
457
493
  slotsSuggested: slotsHere.slots,
458
494
  slotsMax: MAX_SLOTS,
459
495
  machineNote: machineNote(slotsHere, config.maxConcurrentSessions),
496
+ memoryGate: memoryGateSettings({ config, supervisor }),
497
+ leftovers: leftoverSettings({ config, supervisor }),
460
498
  capacity: status.capacity,
461
499
  running: sessions.length,
462
500
  model: parseSessionArgs(workspaceArgs).model,
@@ -509,6 +547,10 @@ export function readSettings({ config, supervisor, store }) {
509
547
  // Someone who reads "install it, then open this screen again" and does
510
548
  // exactly that has to see it turn found, without restarting the app.
511
549
  ...claudeState(config),
550
+ // AND WHETHER CODEX IS, because the app needs one of the two and not
551
+ // both: the last card of the walk only shuts the inbox when neither is
552
+ // here (renderer/src/App.tsx).
553
+ ...codexFoundState(config),
512
554
  // AND WHAT THIS MAC'S CLAUDE CODE CALLS ITS MODELS.
513
555
  //
514
556
  // In the same trip, and for the same reason the line above is looked up
@@ -577,21 +619,26 @@ function engineSettings(supervisor, config) {
577
619
  // and that stopped being true somewhere between codex-cli versions.
578
620
  // main/codex-account.mjs holds the measurement. Null when nobody is signed
579
621
  // in, and the card then reads exactly as it always did.
580
- const codex = supervisor.engineChoiceOpened()
581
- ? {
582
- ...codexState(config),
583
- trouble: engineTroubleFor(supervisor, 'codex'),
584
- account: codexAccount(supervisor._codexHome()),
585
- // EVERY CODEX LOGIN ON THE MAC, NOT JUST THE FIRST. The supervisor has
586
- // run one app-server per CODEX_HOME since a second login became real;
587
- // what never existed was a screen that could see them. One entry per
588
- // `codexProfiles` word, resolved through the supervisor's own mapping so
589
- // the card and the fleet cannot disagree about which folder a name
590
- // means.
591
- accounts: codexAccountRows(supervisor),
592
- }
593
- : null;
594
- if (choices.length < 2) {
622
+ // A Mac that runs on Codex alone has asked about it by having nothing else.
623
+ //
624
+ // AND THE PAGE IS DRAWN ON EVERY MAC NOW (w-d5d632e503). A tester who only
625
+ // uses Codex looked for it in Settings, found nothing, and had no way to
626
+ // tell "not installed" from "not offered". Not found, the page says so and
627
+ // links the install, which is the question that person had.
628
+ const codexIsHome = engine === 'codex';
629
+ const codex = {
630
+ ...codexState(config),
631
+ trouble: engineTroubleFor(supervisor, 'codex'),
632
+ account: codexAccount(supervisor._codexHome()),
633
+ // EVERY CODEX LOGIN ON THE MAC, NOT JUST THE FIRST. The supervisor has
634
+ // run one app-server per CODEX_HOME since a second login became real;
635
+ // what never existed was a screen that could see them. One entry per
636
+ // `codexProfiles` word, resolved through the supervisor's own mapping so
637
+ // the card and the fleet cannot disagree about which folder a name
638
+ // means.
639
+ accounts: codexAccountRows(supervisor),
640
+ };
641
+ if (choices.length < 2 && !codexIsHome) {
595
642
  return { engine, engineChoices: choices, codex, codexModels: [], codexModelDefault: null, codexModel: null };
596
643
  }
597
644
  const home = supervisor._codexHome();
@@ -754,8 +801,8 @@ export function addCodexAccount({ config, supervisor }) {
754
801
  * accounting while this was a product we sold. She withdrew that on
755
802
  * w-3498e0cad2 ("we're no longer offering this as a commercially viable
756
803
  * product... the rules have changed so I think you should allow
757
- * multi-accounting"), and this is the door that decision asked for. Her reason
758
- * for wanting it is the ordinary one: a work login and a personal login, both
804
+ * multi-accounting"), and this is the door that decision asked for. The
805
+ * ordinary reason for wanting it: a work login and a personal login, both
759
806
  * live, on one Mac.
760
807
  *
761
808
  * Everything else is the Codex flow exactly. Main makes the folder and
@@ -781,19 +828,143 @@ export function addClaudeAccount({ config, supervisor }) {
781
828
  return { home: made.home, profile: made.profile, command: claudeLoginCommand(made.home) };
782
829
  }
783
830
 
831
+ /**
832
+ * The Agents page's memory rows: the switch, the number of heavy commands at
833
+ * once (null is Auto), what Auto works out to here, and one sentence about
834
+ * right now while it is on.
835
+ */
836
+ export function memoryGateSettings({ config, supervisor }) {
837
+ const on = !!config.memoryGate;
838
+ const slots = Number.isFinite(config.memoryGateSlots) ? config.memoryGateSlots : null;
839
+ // What Auto works out to on this Mac, whether or not somebody picked.
840
+ const slotsAuto = autoSlots(os.totalmem());
841
+ let now = null;
842
+ if (on) {
843
+ let s = null;
844
+ try { s = supervisor.memoryGateStatus?.() ?? null; } catch {}
845
+ if (s?.role === 'standby') now = `Another ${NAME} on this Mac is coordinating.`;
846
+ else if (s) {
847
+ const heavy = (s.running ?? []).filter((r) => r.holdsSlot).length;
848
+ const waiting = (s.waiting ?? []).length;
849
+ const mem = s.pressure === 'critical' ? 'Memory is very short.' : s.pressure === 'tight' ? 'Memory is tight.' : 'Memory is fine.';
850
+ const run = heavy ? `${heavy} heavy command${heavy === 1 ? '' : 's'} running` : 'Nothing heavy running';
851
+ now = `${mem} ${run}${waiting ? `, ${waiting} waiting` : ''}.`;
852
+ }
853
+ }
854
+ return { on, slots, slotsAuto, slotsMax: MAX_SLOTS, now, reading: memoryReading({ config, supervisor }) };
855
+ }
856
+
857
+ /**
858
+ * THE FIGURES THE RUNNING PAGE LEADS WITH (w-e5225b62ba), rather than the same
859
+ * facts tacked onto the end of three grey paragraphs: "6 running now", "Holding
860
+ * 3 commands now", "Nothing running now".
861
+ *
862
+ * Every field is null when it is not known, and the page leaves out what is
863
+ * null. A figure invented to fill a slot is worse than a gap: this card's whole
864
+ * job is to be the one place on the screen that is measured.
865
+ *
866
+ * `usedPct` is the kernel's own `kern.memorystatus_level` turned round, which is
867
+ * what the gate already polls, so the bar and the word above it cannot disagree.
868
+ */
869
+ export function memoryReading({ config, supervisor }) {
870
+ const out = { heavy: null, waiting: null, pressure: null, usedPct: null, asked: null, waited: null, refused: null };
871
+ if (!config.memoryGate) return out;
872
+ let s = null;
873
+ try { s = supervisor.memoryGateStatus?.() ?? null; } catch { return out; }
874
+ if (!s || s.role === 'standby') return out;
875
+ out.heavy = (s.running ?? []).filter((r) => r.holdsSlot).length;
876
+ out.waiting = (s.waiting ?? []).length;
877
+ out.pressure = s.pressure ?? null;
878
+ if (Number.isFinite(s.freePct)) out.usedPct = Math.max(0, Math.min(100, 100 - s.freePct));
879
+ const c = s.counts ?? {};
880
+ if (Number.isFinite(c.asked)) out.asked = c.asked;
881
+ if (Number.isFinite(c.waited)) out.waited = c.waited;
882
+ if (Number.isFinite(c.refused)) out.refused = c.refused;
883
+ return out;
884
+ }
885
+
886
+ /**
887
+ * The Agents page's leftovers row: the switch, and one sentence about what
888
+ * finished agents have left running while it is on.
889
+ */
890
+ export function leftoverSettings({ config, supervisor }) {
891
+ const on = !!config.cleanupLeftovers;
892
+ let now = null;
893
+ let s = null;
894
+ try { s = supervisor.leftoverStatus?.() ?? null; } catch {}
895
+ const list = s?.leftovers ?? [];
896
+ const stoppable = list.reduce((n, l) => n + (l.programs ?? 0), 0);
897
+ const kept = list.reduce((n, l) => n + (l.kept ?? 0), 0);
898
+ const total = stoppable + kept;
899
+ // OFF, IT SAYS WHAT TURNING IT ON WOULD STOP, before anybody does: the
900
+ // consent is to these programs too, not only to future ones (agreed with
901
+ // Codex, 2026-10-05).
902
+ if (!on) {
903
+ if (total && stoppable) {
904
+ now = `Right now finished agents have left ${total} program${total === 1 ? '' : 's'} running; turning this on stops ${stoppable === total ? (total === 1 ? 'it' : 'them') : `${stoppable} of them`}, two hours from then.`;
905
+ }
906
+ return { on, now };
907
+ }
908
+ {
909
+ if (!total) now = 'Nothing left running by finished agents.';
910
+ else {
911
+ const next = list.filter((l) => l.programs && l.stopsAt).map((l) => l.stopsAt).sort((a, b) => a - b)[0];
912
+ const when = (ms) => {
913
+ const m = Math.max(1, Math.round((ms - Date.now()) / 60_000));
914
+ return m >= 60 ? `${Math.floor(m / 60)} h${m % 60 ? ` ${m % 60} m` : ''}` : `${m} m`;
915
+ };
916
+ now = `Finished agents have left ${total} program${total === 1 ? '' : 's'} running.`;
917
+ if (stoppable) now += ` ${stoppable} ${next && next > Date.now() ? `stop in ${when(next)}` : 'are stopping'}`;
918
+ if (kept) now += `${stoppable ? ';' : ''} ${kept} ${kept === 1 ? 'is' : 'are'} kept`;
919
+ now += '.';
920
+ const survivors = s?.survivors?.length ?? 0;
921
+ if (survivors) now += ` ${survivors} did not stop when asked.`;
922
+ }
923
+ }
924
+ return { on, now };
925
+ }
926
+
784
927
  export function setWorkspaceSetting({ config, supervisor }, { key, value }) {
785
928
  switch (key) {
786
929
  case 'agentsRunning':
787
930
  supervisor.paused = !value;
788
931
  supervisor.onChange?.();
789
932
  break;
933
+ // WHO DECIDES THE NUMBER (w-e5225b62ba). Turning Automatic on does not
934
+ // throw away the number somebody set: it stays in the file, unread, so
935
+ // turning Automatic off again gives them their own number back rather than
936
+ // a fresh guess.
937
+ case 'agentsAuto':
938
+ saveConfig(config, { agentsAuto: !!value });
939
+ supervisor.wake?.();
940
+ supervisor.onChange?.();
941
+ break;
942
+ // SOMETHING FELT SLOW. The one lever a person has that no reading can
943
+ // replace: they are sitting in front of the Mac and the app is not. It only
944
+ // ever subtracts, and main/machine.mjs refuses to take it below one.
945
+ case 'agentsFeltSlow': {
946
+ const now = Math.max(0, Math.round(Number(config.agentsNudge) || 0));
947
+ saveConfig(config, { agentsNudge: value === 'reset' ? 0 : now + 1 });
948
+ supervisor.wake?.();
949
+ supervisor.onChange?.();
950
+ break;
951
+ }
790
952
  case 'sessionsAtOnce': {
791
953
  // THE MACHINE DOES NOT CLAMP THIS, and for one round it did. These are
792
954
  // developers using the tool as they want, so the range is the control's
793
955
  // own range, the same on every Mac. What the hardware does is SUGGEST a
794
956
  // starting number, one row up.
795
957
  const n = Math.max(1, Math.min(MAX_SLOTS, Math.round(Number(value) || 1)));
796
- saveConfig(config, { maxConcurrentSessions: n });
958
+ // Setting a number by hand IS taking the decision back, so the page never
959
+ // shows a stepper that the supervisor is quietly ignoring.
960
+ saveConfig(config, { maxConcurrentSessions: n, agentsAuto: false });
961
+ // An explicit choice replaces the startup plan suggestion for BOTH
962
+ // engines. Leaving this marker set makes Codex ignore the new number
963
+ // until loadConfig runs again at restart. It is derived, not persisted.
964
+ config.planSlotsFrom = null;
965
+ // Fill newly available slots through the usual queue and its guards.
966
+ // Lowering the cap leaves existing work running and gates new starts.
967
+ supervisor.wake?.();
797
968
  break;
798
969
  }
799
970
  // Written as an opt-OUT (`diagnostics: false`) so that on is what a config
@@ -801,6 +972,32 @@ export function setWorkspaceSetting({ config, supervisor }, { key, value }) {
801
972
  // see in her own file. `saveConfig` mutates the live config too, and
802
973
  // main/analytics.mjs reads the switch at every send, so off takes effect
803
974
  // from the moment she moves it and not at the next launch.
975
+ // HOLD HEAVY WORK WHEN MEMORY IS SHORT (w-3958c3753d). Takes effect now:
976
+ // the coordinator starts or stops this moment, and workers spawned from
977
+ // here on carry the check or do not. A worker already running keeps the
978
+ // hooks it was started with; with the switch off its hook finds no socket
979
+ // and lets every command straight through.
980
+ case 'memoryGate':
981
+ saveConfig(config, { memoryGate: !!value });
982
+ supervisor.setMemoryGate?.(!!value)?.catch?.((e) => console.warn('zero: memory gate:', e.message));
983
+ supervisor.onChange?.();
984
+ break;
985
+ // STOP WHAT FINISHED AGENTS LEAVE RUNNING (main/leftovers.mjs). Turning it
986
+ // on records the moment, which is the earliest the clock may start from.
987
+ case 'cleanupLeftovers':
988
+ saveConfig(config, value ? { cleanupLeftovers: true, cleanupLeftoversSince: Date.now() } : { cleanupLeftovers: false });
989
+ supervisor.setLeftoverCleanup?.(!!value)?.catch?.((e) => console.warn('zero: leftovers:', e.message));
990
+ supervisor.onChange?.();
991
+ break;
992
+ case 'memoryGateSlots': {
993
+ const n = value === null || value === undefined || value === 'auto'
994
+ ? null
995
+ : Math.max(1, Math.min(MAX_SLOTS, Math.round(Number(value) || 1)));
996
+ saveConfig(config, { memoryGateSlots: n });
997
+ if (config.memoryGate) supervisor.setMemoryGate?.(true)?.catch?.(() => {});
998
+ supervisor.onChange?.();
999
+ break;
1000
+ }
804
1001
  case 'diagnostics':
805
1002
  saveConfig(config, { diagnostics: !!value });
806
1003
  break;
@@ -0,0 +1,279 @@
1
+ // THE APP SHIPS A TASK; THE AGENT ONLY SAYS IT IS READY (2026-10-02).
2
+ //
3
+ // Agents used to finish by pushing to main and updating the folder the app
4
+ // runs from. Over thirty hours on one Mac, 17 tasks stalled because Claude
5
+ // Code's auto-mode safety check refused one of those steps (a push to a public
6
+ // repo, a fast-forward of another checkout, a rebase), 16 because a folder was
7
+ // left mid-merge or committed into, 6 racing each other's pushes. A refusal is
8
+ // final and asks nobody, so nothing reached the approvals corner; the task
9
+ // just said "the permission check refused it" and waited.
10
+ //
11
+ // So for a project that turns this on, the agent stops at a branch: it commits
12
+ // in its task folder, adds the label `ship` to its row, and ends its turn.
13
+ // This queue then runs the project's own ship script, one task at a time
14
+ // across the whole Mac, so two tasks never race. A failure goes back to the
15
+ // agent that wrote the change, as a reply, with the script's own words.
16
+ //
17
+ // OFF UNLESS A PERSON TURNS IT ON. The setting lives in the app's own data
18
+ // folder (`ship.json`), never in the store or the repo: agents can write
19
+ // project files, and a ship command they could rewrite would be a command the
20
+ // app runs for them outside every check. The script it names is run FROM THE
21
+ // BASE CHECKOUT, which only ever fast-forwards to the remote, so a branch
22
+ // cannot change the code that ships it. With no `ship.json`, nothing here does
23
+ // anything at all.
24
+
25
+ import { spawn } from 'node:child_process';
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import { Name } from '../shared/product-name.mjs';
29
+
30
+ export const SHIP_LABEL = 'ship';
31
+ const SHIP_MS = 20 * 60 * 1000;
32
+ const KEEP = 4000;
33
+
34
+ export const shipSettingsFile = (userDir) => path.join(userDir, 'ship.json');
35
+
36
+ // WHICH "READY" EACH TASK HAS ALREADY BEEN SHIPPED FOR, by the moment its
37
+ // label was written. Taking the label off is not enough: a label the person
38
+ // put on outranks the app on the ledger, so the app's removal is ignored, and
39
+ // on the first real run that shipped a task and then ran it twice more
40
+ // (2026-10-02). A new label carries a new moment and ships again.
41
+ const handledFile = (userDir) => path.join(userDir, 'ship-handled.json');
42
+ const keyOf = (item) => `${item.product}/${item.id}`;
43
+ const markedAt = (item) => item.wrote?.labels?.ts ?? item.updatedAt ?? 0;
44
+ function readHandled(userDir) {
45
+ try { const raw = JSON.parse(fs.readFileSync(handledFile(userDir), 'utf8')); return raw && typeof raw === 'object' ? raw : {}; } catch { return {}; }
46
+ }
47
+ function writeHandled(userDir, all) {
48
+ try {
49
+ const tmp = `${handledFile(userDir)}.${process.pid}.tmp`;
50
+ fs.writeFileSync(tmp, `${JSON.stringify(all)}\n`);
51
+ fs.renameSync(tmp, handledFile(userDir));
52
+ } catch { /* the label removal still covers the ordinary case */ }
53
+ }
54
+
55
+ /** `{ [projectSlug]: { script: 'scripts/ship.mjs' } }`, or `{}`. */
56
+ export function readShipSettings(userDir) {
57
+ try {
58
+ const raw = JSON.parse(fs.readFileSync(shipSettingsFile(userDir), 'utf8'));
59
+ return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
60
+ } catch {
61
+ return {};
62
+ }
63
+ }
64
+
65
+ /** The script that ships this project, inside its base checkout, or null. */
66
+ export function shipScriptFor(product, settings) {
67
+ const rel = settings?.[product?.slug]?.script;
68
+ if (!product?.repoPath || typeof rel !== 'string' || !rel || path.isAbsolute(rel)) return null;
69
+ const base = path.resolve(product.repoPath);
70
+ const script = path.resolve(base, rel);
71
+ if (!script.startsWith(base + path.sep)) return null;
72
+ return fs.existsSync(script) ? script : null;
73
+ }
74
+
75
+ const labelsOf = (item) => (Array.isArray(item?.labels) ? item.labels : []);
76
+ export const wantsShipping = (item) => labelsOf(item).includes(SHIP_LABEL);
77
+ export const withoutShipLabel = (item) => labelsOf(item).filter((l) => l !== SHIP_LABEL);
78
+
79
+ /**
80
+ * The next task to ship, oldest ask first, or null.
81
+ *
82
+ * Only a row of a project that ships, marked `ship`, with no session on it
83
+ * (an agent still working may yet commit), and with a task folder to ship from.
84
+ */
85
+ export function nextToShip(items, products, settings, { isLive = () => false, folderFor, handled = {} }) {
86
+ const bySlug = new Map(products.map((p) => [p.slug, p]));
87
+ const ready = items
88
+ .filter((i) => wantsShipping(i) && i.status !== 'done' && !isLive(i) && !(markedAt(i) <= (handled[keyOf(i)] ?? -1)))
89
+ .map((item) => {
90
+ const product = bySlug.get(item.product);
91
+ const script = product ? shipScriptFor(product, settings) : null;
92
+ const cwd = script ? folderFor(item, product) : null;
93
+ return script && cwd && fs.existsSync(cwd) ? { item, product, script, cwd } : null;
94
+ })
95
+ .filter(Boolean);
96
+ ready.sort((a, b) => markedAt(a.item) - markedAt(b.item));
97
+ return ready[0] ?? null;
98
+ }
99
+
100
+ /** The commit the script reports, from its own `ship: shipped <sha>.` line. */
101
+ export function shippedSha(output) {
102
+ return String(output ?? '').match(/ship: shipped ([0-9a-f]{7,40})\./)?.[1] ?? null;
103
+ }
104
+
105
+ // A LINE VITEST TICKED GREEN IS NEVER WHY A SHIP FAILED (w-c121bd85e6).
106
+ //
107
+ // Test files here are named after the behaviour they cover, so plenty of them
108
+ // carry "failed", "error" or "refused" inside the name, with a hyphen either
109
+ // side. A hyphen is a word boundary, so even the whole-word guard below
110
+ // matches inside "a-failed-hook-is-visible-while-the-agent-continues". On
111
+ // 2026-10-04 a ship that DIED was reported as that file, which had passed in
112
+ // 51 milliseconds. Vitest ticks every file it ran green, so the tick is the
113
+ // one reliable way to rule a line out.
114
+ const TICKED_GREEN = /^[✓✔]/;
115
+ // WHOLE WORDS (w-7ec8553e23): without \b, "red" matched inside "configured"
116
+ // and a harmless warning the tests print was shown as why a ship failed.
117
+ const NAMES_A_FAILURE = /\b(CONFLICT|error|red|refused|failed|FAIL)\b/i;
118
+ // Vitest's heading over the list of failures, "⎯⎯⎯ Failed Tests 3 ⎯⎯⎯". It
119
+ // carries the count and names nothing, and the FAIL lines that DO name
120
+ // something are printed directly under it, so taking the first match showed
121
+ // the heading and sent whoever read it to the GitHub run to find out which
122
+ // three (w-c121bd85e6, this branch's own first ship). Passed over while there
123
+ // is anything better, and still offered when it is all there is.
124
+ const ONLY_A_COUNT = /^⎯+ .* ⎯+$/;
125
+ // A LINE VITEST QUOTED IS NOT THE SCRIPT SPEAKING (w-560647d4db, five
126
+ // bounces). A failing text assertion prints the file's own text as a diff, so
127
+ // any source line carrying one of those words becomes a candidate — and the
128
+ // diff is printed long before the runner's list of failures. Twice the note
129
+ // read "+ than deleted: a bare corner here reads as a control
130
+ // that failed", which is a pre-existing code comment in a file the branch
131
+ // touched. It names no fault, and the real cause was three failing tests the
132
+ // note never mentioned. Both shapes of quotation are skipped: a diff line
133
+ // (`+`/`-`) and a code frame (`34| expect(...)`).
134
+ const QUOTED_SOURCE = /^([+-]|\d+\|)/;
135
+ // THE RUNNER'S OWN FAILURES OUTRANK A LOOSE WORD MATCH. These two name a
136
+ // file: `FAIL tests/x.test.mjs > ...` and `❯ tests/x.test.mjs (3 tests | 3
137
+ // failed)`. Anything else matching the words above — the script's closing
138
+ // line, a merge message, a warning — says less, wherever it sits in the
139
+ // output.
140
+ const NAMES_A_RED_TEST = /^(FAIL\b|❯ .*\|\s*\d+ failed)/;
141
+ // Vitest's totals, "Tests 3 failed | 1920 passed (1923)", and its heading
142
+ // "⎯⎯⎯ Failed Tests 3 ⎯⎯⎯". Not "Test Files 1 failed", which counts files.
143
+ const HOW_MANY = [/^Tests\s+(\d+) failed/, /Failed Tests\s+(\d+)/];
144
+
145
+ const DIED = 'the test run died without naming a failing test';
146
+
147
+ /**
148
+ * Why the branch did not ship, out of the script's own output.
149
+ *
150
+ * A run that went RED says so somewhere. A run that DIED — killed on a busy
151
+ * Mac, or cut off — leaves output with no failure in it at all, and the two
152
+ * have to be told apart: offering the nearest line instead sends whoever
153
+ * reads it after a fault that is not there.
154
+ *
155
+ * @returns {{ died: boolean, why: string|null, last: string|null, failed: number|null }}
156
+ */
157
+ export function whyItDidNotShip(output) {
158
+ const lines = String(output ?? '').split('\n').map((l) => l.trim()).filter(Boolean);
159
+ const said = lines.filter((l) => !TICKED_GREEN.test(l) && !QUOTED_SOURCE.test(l));
160
+ const named = said.filter((l) => NAMES_A_FAILURE.test(l));
161
+ const red = named.find((l) => NAMES_A_RED_TEST.test(l));
162
+ const why = red ?? named.find((l) => !ONLY_A_COUNT.test(l)) ?? named[0] ?? null;
163
+ // The count goes with a line that names ONE test, so that it reads as the
164
+ // first of several rather than as the whole story. On a line that is not a
165
+ // test — a conflict, a refused push — a number beside it would read as that
166
+ // line's own count, so it is left off.
167
+ const counted = red ? Number(said.map((l) => HOW_MANY.map((re) => re.exec(l)?.[1]).find(Boolean)).find(Boolean)) : NaN;
168
+ return { died: !why, why, last: said.at(-1) ?? null, failed: Number.isFinite(counted) ? counted : null };
169
+ }
170
+
171
+ /** "3 tests failed, the first is FAIL tests/…", or just the line. */
172
+ function diagnosis({ why, failed }) {
173
+ if (!why || !failed) return why;
174
+ return `${failed} test${failed === 1 ? '' : 's'} failed, the first is ${why}`;
175
+ }
176
+
177
+ /** What the agent is told when its branch did not ship. */
178
+ export function failureReply(output) {
179
+ const tail = String(output ?? '').trim().slice(-KEEP);
180
+ const { died, ...rest } = whyItDidNotShip(output);
181
+ return [
182
+ `${Name} tried to ship your branch and it did not go out. Nothing was pushed.`,
183
+ // THE SAME DIAGNOSIS THE THREAD SHOWS, above the tail (w-560647d4db). The
184
+ // tail holds everything, which is why two sessions read a quoted code
185
+ // comment in it as the fault. One line naming how many tests failed and
186
+ // which file is where to start.
187
+ ...(died ? [] : ['', `It went red: ${diagnosis(rest)}`]),
188
+ ...(died ? [
189
+ '',
190
+ 'Its test run DIED rather than going red: nothing it printed names a failing',
191
+ 'test. Look for what killed the run. This Mac runs a dozen agents at once, so',
192
+ 'a worker that was starved or timed out is likelier than a bug in your change.',
193
+ 'Run the tests for your change again before you go looking for one.',
194
+ ] : []),
195
+ '',
196
+ 'Fix what the script says below in your task folder, commit, then add the label',
197
+ `\`${SHIP_LABEL}\` to this row again and end your turn. Do not push or touch any`,
198
+ 'other folder yourself; the app ships it.',
199
+ '',
200
+ '```',
201
+ tail,
202
+ '```',
203
+ ].join('\n');
204
+ }
205
+
206
+ /** The line the thread shows: the app's own, short, and the first error in it. */
207
+ export function failureNote(output) {
208
+ const { died, last, ...rest } = whyItDidNotShip(output);
209
+ const said = died ? `${DIED}. It last said: ${last ?? 'nothing at all'}` : diagnosis(rest);
210
+ return `It did not ship, and it went back to its agent: ${said.slice(0, 300)}`;
211
+ }
212
+
213
+ export function runScript(script, cwd, { timeout = SHIP_MS, env = process.env } = {}) {
214
+ const node = env.npm_node_execpath && fs.existsSync(env.npm_node_execpath) ? env.npm_node_execpath : 'node';
215
+ return new Promise((resolve) => {
216
+ let out = '';
217
+ let child;
218
+ try {
219
+ child = spawn(node, [script], { cwd, env: { ...env, GIT_TERMINAL_PROMPT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] });
220
+ } catch (e) {
221
+ resolve({ code: 1, out: String(e?.message ?? e) });
222
+ return;
223
+ }
224
+ const keep = (b) => { out = (out + b).slice(-64 * 1024); };
225
+ child.stdout.on('data', keep);
226
+ child.stderr.on('data', keep);
227
+ const timer = setTimeout(() => { keep('\nship: stopped after 20 minutes.'); child.kill('SIGTERM'); }, timeout);
228
+ child.on('error', (e) => keep(`\n${e.message}`));
229
+ child.on('close', (code) => { clearTimeout(timer); resolve({ code: code ?? 1, out }); });
230
+ });
231
+ }
232
+
233
+ /**
234
+ * One task at a time, for the whole Mac. `tick` is called from the
235
+ * supervisor's own tick and returns at once; the run happens beside it.
236
+ */
237
+ export class ShipQueue {
238
+ constructor({ store, userDir, folderFor, isLive, handBack = () => {}, run = runScript, afterShip = () => {}, log = console }) {
239
+ Object.assign(this, { store, userDir, folderFor, isLive, handBack, run, afterShip, log });
240
+ this.busy = null;
241
+ }
242
+
243
+ tick(items, products) {
244
+ if (this.busy) return null;
245
+ const settings = readShipSettings(this.userDir);
246
+ if (!Object.keys(settings).length) return null;
247
+ const next = nextToShip(items, products, settings, { isLive: this.isLive, folderFor: this.folderFor, handled: readHandled(this.userDir) });
248
+ if (!next) return null;
249
+ // Marked handled only once the run has ENDED, pass or fail. Marked before
250
+ // it, a run cut off by an app restart was never tried again
251
+ // (w-f37a34def6, 2026-10-04). Within one app, `busy` already stops a
252
+ // second start; across a restart, trying again is the point.
253
+ const done = () => writeHandled(this.userDir, { ...readHandled(this.userDir), [keyOf(next.item)]: markedAt(next.item) });
254
+ this.busy = this.ship(next).then(done, done).finally(() => { this.busy = null; });
255
+ return this.busy;
256
+ }
257
+
258
+ async ship({ item, product, script, cwd }) {
259
+ this.log.info?.(`zero: shipping ${item.id} from ${cwd}`);
260
+ const { code, out } = await this.run(script, cwd);
261
+ try {
262
+ // Already on main: there is nothing to fix, so nobody is woken.
263
+ const already = /There is nothing to ship/.test(out);
264
+ if (code === 0 || already) {
265
+ this.store.shipped(product.slug, item.id, { sha: shippedSha(out), labels: withoutShipLabel(item) });
266
+ this.afterShip();
267
+ } else {
268
+ this.store.shipFailed(product.slug, item.id, { note: failureNote(out), labels: withoutShipLabel(item) });
269
+ // Straight back to the session that wrote the change. Not through the
270
+ // reply on the row: that field is hers, and the app's words written
271
+ // there would be drawn as hers and could not replace hers anyway.
272
+ this.handBack(item, failureReply(out));
273
+ }
274
+ } catch (e) {
275
+ this.log.warn?.(`zero: could not record the ship of ${item.id}:`, e.message);
276
+ }
277
+ return { code };
278
+ }
279
+ }