agentbox-app 0.1.7 → 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-B7hhfHrT.js +0 -684
  147. package/renderer/dist/assets/index-CwiDv5bL.css +0 -32
  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
@@ -1,7 +1,9 @@
1
1
  import { claudeActivity, codexActivity, currentActivity } from './agent-activity.mjs';
2
2
  import { taskRemoteControl } from './task-remote-control.mjs';
3
3
  import { taskFolderPath, real, restoreTaskFolder } from './task-folders.mjs';
4
+ import { SHIP_LABEL, ShipQueue, readShipSettings, shipScriptFor } from './ship-queue.mjs';
4
5
  import { folderJob } from './task-folders-offthread.mjs';
6
+ import { gitJob } from './git-change-offthread.mjs';
5
7
  import { queuedReplyText } from './live-replies.mjs';
6
8
  import { attachClaudeInput, claudeStreamArgs } from './claude-input.mjs';
7
9
  import { hookFailure } from '../shared/hook-failure.mjs';
@@ -39,8 +41,18 @@ import fs from 'node:fs';
39
41
  import path from 'node:path';
40
42
  import os from 'node:os';
41
43
  import { isUrgent, itemPriority, normalizeOrder, orderFromTiers, productRankScore } from '../shared/rank.mjs';
44
+ import { keysForAgents } from '../shared/shortcuts.mjs';
42
45
  import { isCleanRun, ruleIdOf } from '../shared/repeats.mjs';
43
- import { agentSpokeSince, answerSettled, answerTs } from '../shared/answers.mjs';
46
+ import { mayRunHere } from '../shared/team-rules.mjs';
47
+
48
+ // WHO IS SIGNED IN, handed to the store server a worker talks through. An MCP
49
+ // server starts with only the variables it is given, not the app's, and every
50
+ // line it writes for a shared project has to say whose Mac wrote it
51
+ // (main/store/work-items.mjs stamps it from this).
52
+ function teamPersonEnv() {
53
+ return process.env.AGENTBOX_PERSON_ID ? { AGENTBOX_PERSON_ID: process.env.AGENTBOX_PERSON_ID } : {};
54
+ }
55
+ import { agentSpokeSince, answerSettled, answerTs, stoppedByHer } from '../shared/answers.mjs';
44
56
  import { DEFAULT_SESSIONS_AT_ONCE } from './config.mjs';
45
57
  import { linkAccountTooling, toolingLine } from './account-tooling.mjs';
46
58
  import { effectiveProfiles } from './account-discovery.mjs';
@@ -71,7 +83,7 @@ import { isEffort, isEffortWord } from '../shared/effort-levels.mjs';
71
83
  // for every row on every machine, including one she marked `codex` in August.
72
84
  import {
73
85
  engineFor, engineLabel, engineChoiceSince, engineChoiceOnRowIsStale,
74
- availableEngines, isEngine, modelForEngine,
86
+ availableEngines, isEngine, modelForEngine, homeEngine,
75
87
  ENGINE_CHOICE_ENABLED, DEFAULT_ENGINE, ENGINE_IDS,
76
88
  } from '../shared/engines.mjs';
77
89
  // SAYING_CAP LIVES THERE AND NOT HERE. It was a module-private const in this
@@ -91,7 +103,15 @@ import { createCodexApprovals } from './codex-approvals.mjs';
91
103
  import { approvalPublicKey } from './approvals.mjs';
92
104
  import { codexDefaultModel, codexHome, codexKnownSlugs, codexModelLevels, codexModels } from './codex-models.mjs';
93
105
  import { nameRow, wantsName } from './row-label.mjs';
106
+ import { LEVELS, latestMessage, sortMessage, wantsPriority } from './message-priority.mjs';
94
107
  import { NAME, Name, envName, nameSlug, isOurSlug } from '../shared/product-name.mjs';
108
+ import { signInFiles, signInStamp } from './sign-in-files.mjs';
109
+ import { MemoryGateServer, defaultSocketPath as memoryGateSocketPath } from './memory-gate-server.mjs';
110
+ import { autoSlots, DEFAULTS as MEMORY_GATE } from './memory-gate.mjs';
111
+ import { LeftoverCleaner } from './leftovers.mjs';
112
+ import { syncCodexMemoryGate } from './codex-memory-gate.mjs';
113
+ import { autoAgents, perAccountAgents } from './machine.mjs';
114
+ import { runEndedProps } from './analytics.mjs';
95
115
 
96
116
  const POLL_MS = 15_000;
97
117
  // HOW LONG SHE WAITS AFTER PRESSING THE BUTTON, and until now it was the line
@@ -126,11 +146,21 @@ const DIGEST_MIN_MS = 12 * 3_600_000;
126
146
  // invariant is self-healing, which without this would mean re-spawning every
127
147
  // fifteen seconds against whatever made the first attempt fail.
128
148
  const DIGEST_RETRY_MS = 30 * 60_000;
149
+ // How long a row the namer could not name waits before it is asked about again,
150
+ // so it cannot hold the rows behind it (nameTheRows).
151
+ const NAME_RETRY_MS = 10 * 60_000;
129
152
  // A spawn that exits faster than this did no real work (limit hit, bad flag,
130
153
  // untrusted workspace); consecutive fast exits back spawning off exponentially.
131
154
  const FAST_EXIT_MS = 45_000;
132
155
  const BACKOFF_BASE_MS = 60_000;
133
156
  const BACKOFF_CAP_MS = 30 * 60_000;
157
+ // HOW LONG A SIGNED-OUT LOGIN SITS OUT WHEN NOTHING SAYS IT CAME BACK. It was
158
+ // the same half hour as an ambiguous death, and that half hour is exactly how
159
+ // long her agents stayed "Queued" after she had signed in again (2026-10-04).
160
+ // The app now watches for the login itself (`_noticeSignIns`); this is only the
161
+ // fallback for a login that lands somewhere it cannot see. A try on a login
162
+ // that is still out costs a two-second run and is charged to nobody.
163
+ const SIGNED_OUT_REST_MS = 5 * 60_000;
134
164
  // How long past a limit's printed reset hour the fleet waits before trying.
135
165
  const LIMIT_RESET_GRACE_MS = 30_000;
136
166
  // HOW LONG NOTHING MAY WORK BEFORE THE APP SAYS SO OUT LOUD.
@@ -143,10 +173,13 @@ const TROUBLE_QUIET_MS = 20 * 60_000;
143
173
  // A RUNNING SESSION THAT HAS GONE SILENT, AND WHOSE CLAIM HAS LAPSED, IS DEAD.
144
174
  // w-3a14ab56d6, 2026-09-23: a Codex turn logged its last line at 19:10:56, its
145
175
  // store heartbeats stopped the same minute, and the turn never ended, so no
146
- // exit fired and the row sat in In progress for 50 minutes until she typed
147
- // "stuck?". Both signals together, never one: a long render is silent but its
176
+ // exit fired and the row sat in In progress for 50 minutes until the user
177
+ // noticed it was stuck. Both signals together, never one: a long render is silent but its
148
178
  // store server keeps beating, and a session that never claimed has no lease.
149
179
  const HUNG_QUIET_MS = 10 * 60_000;
180
+ // A pass this late was not late, the Mac was asleep (forgiveSleep). Eight
181
+ // missed passes: past any slow pass, well under the ten minutes above.
182
+ const SLEPT_GAP_MS = 2 * 60_000;
150
183
  // Two hangs on one row inside this window and the app stops restarting it and
151
184
  // tells her instead, so a row that hangs every time is not killed forever.
152
185
  const HUNG_REPEAT_WINDOW_MS = 2 * 3_600_000;
@@ -211,9 +244,9 @@ const FIRST_RUN_HOLD_MS = 20 * 60_000;
211
244
  // tool prefix.
212
245
  //
213
246
  // A translation for configs still granting the old spelling lived beside this
214
- // for one round. It is gone: her zero.config.json was corrected in the same
215
- // step, and keeping the old word in the code so as to keep reading the old word
216
- // in the config is exactly the reference she asked to be rid of.
247
+ // for one round. It is gone: configs were corrected in the same step, and
248
+ // keeping the old word in the code so as to keep reading the old word in the
249
+ // config would keep alive exactly the reference the rename was meant to remove.
217
250
  const STORE_SERVER = nameSlug;
218
251
 
219
252
  /**
@@ -227,13 +260,13 @@ const STORE_SERVER = nameSlug;
227
260
  * and holding nothing: no claim, no checkpoint, no result, and no error either,
228
261
  * because a grant for a tool that does not exist is not a failure of anything.
229
262
  *
230
- * HER FILE IS NOT REWRITTEN. This is what a session READS, the same way
231
- * `buildBrief` fills the name into a brief she keeps in tokens; her config keeps
232
- * whatever she typed, and the day she corrects it nothing here changes.
263
+ * THE CONFIG FILE IS NOT REWRITTEN. This is what a session READS, the same way
264
+ * `buildBrief` fills the name into a brief kept in tokens; the config keeps
265
+ * whatever the user typed, and the day they correct it nothing here changes.
233
266
  *
234
267
  * ONLY OUR OWN NAMES ARE TOUCHED, which is what makes this safe to do at all.
235
- * A worker also sees MCP servers of HERS (there is no `--strict-mcp-config` on
236
- * the spawn), so `mcp__playwright` in her args is a live grant for a live
268
+ * A worker also sees the user's own MCP servers (there is no `--strict-mcp-config`
269
+ * on the spawn), so `mcp__playwright` in the args is a live grant for a live
237
270
  * server and must survive untouched. `isOurSlug` is the whole of the rule: a
238
271
  * name this app has actually answered to, and nothing else.
239
272
  */
@@ -287,20 +320,33 @@ export class Supervisor {
287
320
  // the user's data folder inside a packaged app, because a signed bundle
288
321
  // cannot be written into without breaking its own signature.
289
322
  this.dataDir = dataDir;
290
- // AND THE TWO FILES THAT ARE ONLY EVER HERS COME FROM userDir, WHICH IS
291
- // NEVER THE CHECKOUT (w-3dc46f3a67, 2026-09-21). Her standing instructions
292
- // and her writing rules used to live at `dataDir/briefs`, which running
293
- // from source IS the git checkout every worker fast-forwards, stashes and
294
- // checks out. Measured on her repo that day: the whole history of
295
- // briefs/founder.md is four agent commits, and the working copy matched the
296
- // newest one exactly, so nothing she typed after the first three rules
297
- // existed anywhere. A rule she wrote and lost is worse than no box at all.
323
+ // AND THE TWO FILES THAT ARE ONLY EVER THE USER'S COME FROM userDir, WHICH
324
+ // IS NEVER THE CHECKOUT (w-3dc46f3a67). The standing instructions and the
325
+ // writing rules used to live at `dataDir/briefs`, which running from source
326
+ // IS the git checkout every worker fast-forwards, stashes and checks out, so
327
+ // agent commits could silently replace what the user had written there. A
328
+ // rule the user wrote and lost is worse than no box at all.
298
329
  //
299
330
  // These are the same two files package.json excludes from the download
300
331
  // (`!briefs/founder.md`, `!briefs/writing-rules.md`), and that is the line:
301
332
  // a file we SHIP is a default and belongs in the checkout, a file only she
302
333
  // writes belongs in her own folder. main.mjs carries the text across once.
303
334
  this.userDir = userDir;
335
+ // THE APP SHIPS A TASK THE AGENT MARKED READY, for a project a person
336
+ // turned that on for in this app's own data folder (main/ship-queue.mjs).
337
+ // With no such setting it never runs anything.
338
+ this.onShipped = () => {};
339
+ this.shipQueue = new ShipQueue({
340
+ store,
341
+ userDir,
342
+ folderFor: (item, product) => { try { return taskFolderPath(this.productFolder(product), item.id); } catch { return null; } },
343
+ isLive: (item) => this.sessions.has(item.id),
344
+ handBack: (item, reply) => {
345
+ const fresh = this.store.readItem?.(item.product, item.id) ?? item;
346
+ this.spawnWorker(fresh, { continuation: true, shipFailure: reply });
347
+ },
348
+ afterShip: () => this.onShipped(),
349
+ });
304
350
  this._compactionJobs = new Map();
305
351
  this.sessions = new Map(); // itemId -> {child, product, startedAt, tail, itemId}
306
352
  // Spawns waiting on their row's folder, and folders made for the spawn that
@@ -318,6 +364,8 @@ export class Supervisor {
318
364
  // Persisted: an in-memory-only set re-spawned a continuation for every
319
365
  // still-open answered item on every app restart.
320
366
  this._handledAnswers = new Set(state.handledAnswers ?? []);
367
+ // Which conversation already holds each of her words (`_noteHeard`).
368
+ this._heardAnswers = state.heardAnswers ?? {};
321
369
  // `_personalSessions` used to sit here, itemId -> claude session id for a
322
370
  // personal project's threads. Personal projects are gone (w-d19d6d387c)
323
371
  // and `_rowSessions` below does that job for every row there is.
@@ -344,8 +392,7 @@ export class Supervisor {
344
392
  // and briefed a stranger with a transcript of the whole thread to catch
345
393
  // up on.
346
394
  //
347
- // MEASURED ON HER MACHINE, 2026-08-29, across 2,789 runs: 2,321 were
348
- // replies like that, and in the median one 65% of the first twenty files
395
+ // MEASURED across a few thousand runs: most were replies like that, and in the median one 65% of the first twenty files
349
396
  // it opened had already been opened by the run before it on the same row,
350
397
  // which then read for 206 seconds before it wrote anything.
351
398
  //
@@ -376,8 +423,8 @@ export class Supervisor {
376
423
  // WHAT IS WRONG WITH EACH ACCOUNT, KEPT ACROSS A RESTART (w-cf0e8821b3).
377
424
  //
378
425
  // The app came back up knowing that nothing was running and no longer
379
- // knowing the reason, which is precisely the half-a-fact she said is
380
- // worse than either the whole thing or silence.
426
+ // knowing the reason, and half a fact is worse than either the whole
427
+ // thing or silence.
381
428
  //
382
429
  // Restoring it cannot strand her, because nothing here is believed for
383
430
  // long: the first session that survives on an account clears it
@@ -389,7 +436,7 @@ export class Supervisor {
389
436
  // important first. Agents never write it. It replaced three buckets
390
437
  // (high/normal/low) because buckets left every tie inside a bucket
391
438
  // unanswered, and "which of these two comes first" is the actual
392
- // question the fleet needs settled (founder, 2026-08-06).
439
+ // question the fleet needs settled.
393
440
  this.productOrder = normalizeOrder(state.productOrder ?? orderFromTiers(state.productTiers));
394
441
  // Hidden from the composer's picker, and from nothing else. A hidden
395
442
  // product still ranks, still spawns workers, and its work still reaches
@@ -398,6 +445,7 @@ export class Supervisor {
398
445
  this.hiddenProducts = new Set(state.hiddenProducts ?? []);
399
446
  } catch {
400
447
  this._handledAnswers = new Set();
448
+ this._heardAnswers = {};
401
449
  this._liveSessions = {};
402
450
  this._rowSessions = {};
403
451
  this._compactions = {};
@@ -440,6 +488,15 @@ export class Supervisor {
440
488
  // slot we just made — which is a kill that cost her a session and bought
441
489
  // her nothing.
442
490
  this._preempted = new Map();
491
+ // TASKS SHE PUSHED WITH "RUN NOW", from the three-dot menu on a waiting
492
+ // task (`runNow` below). itemId -> { product, at, started }.
493
+ //
494
+ // In memory only, for the reason `_resumeQueue` gives: this is her intent
495
+ // right now, and a push that survived a restart would jump a task she
496
+ // asked for hours ago over everything filed since. The entry stays while
497
+ // the pushed task runs, so a second push cannot pause the first, and goes
498
+ // the moment that run ends (`_settleRunNow`).
499
+ this._runNow = new Map();
443
500
  // Rows the toast has already spoken about. In memory only: a restart is a
444
501
  // sweep she has not been told about yet, so it gets to speak once.
445
502
  this._announcedRecovery = new Set();
@@ -485,9 +542,8 @@ export class Supervisor {
485
542
  // `isPersonal` used to live here. A personal project ran its sessions on the
486
543
  // founder's message alone, with no brief, no store and no digest, and every
487
544
  // path in this file that touched a spawn had a branch for it. All of that is
488
- // deleted (w-d19d6d387c, 2026-09-22). Hers: "I would get rid of 'personal' as
489
- // well. I think there is no case where we should ever have that." Every
490
- // project is now a project, and a reply on any row still goes back to the
545
+ // deleted (w-d19d6d387c), because there is no case where a project should
546
+ // run without its brief and store. Every project is now a project, and a reply on any row still goes back to the
491
547
  // session that wrote it through `_rowSessions`.
492
548
 
493
549
  /**
@@ -528,6 +584,8 @@ export class Supervisor {
528
584
  // the restart she ruled out. The sweep gets there first and hands each row
529
585
  // back to the session that was doing it.
530
586
  try { this.recoverInterrupted('startup'); } catch (e) { console.warn('zero: startup recovery:', e.message); }
587
+ if (this.config.memoryGate) this.setMemoryGate(true).catch((e) => console.warn('zero: memory gate:', e.message));
588
+ this.setLeftoverCleanup(!!this.config.cleanupLeftovers).catch((e) => console.warn('zero: leftovers:', e.message));
531
589
  this._timer = setInterval(() => this.tick().catch((e) => console.warn('supervisor:', e.message)), POLL_MS);
532
590
  this.tick().catch(() => {});
533
591
  }
@@ -543,18 +601,77 @@ export class Supervisor {
543
601
  // and app restart charged the item a failed delivery attempt.
544
602
  /** HER OWN HAND OUTRANKS A TIMER (2026-09-22).
545
603
  * Called for the things only she does that mean "run this now": a reply,
546
- * Resume, reopening a row. An account waiting on a PERSON (signed out,
547
- * switched off by an admin) stays out, because retrying it cannot help
548
- * however often she asks. The streak
549
- * count is kept, so a real outage still escalates. */
604
+ * Resume, reopening a row. The streak count is kept, so a real outage still
605
+ * escalates.
606
+ *
607
+ * AN ACCOUNT WAITING ON A PERSON (signed out, switched off by an admin) IS
608
+ * LIFTED TOO WHEN NOTHING ELSE ON ITS ENGINE COULD RUN (2026-10-04). It used
609
+ * to stay out on the theory that retrying cannot help, but she IS the person:
610
+ * she signs back in, presses Resume, and that is the only signal the app
611
+ * gets. With only one login, a park of half an hour meant every Resume and
612
+ * "continue" in between left the rows queued. If she is still signed out
613
+ * the trial dies in two seconds and parks it again on the spot. Where another
614
+ * login is working, the dead one stays out: the work is moving without it. */
550
615
  liftBrakeForHer() {
551
616
  this._spawnCooldownUntil = 0;
552
617
  for (const account of Object.keys(this._profileCooldown ?? {})) {
553
- if (needsHerHands(this._profileTrouble?.[account]?.cause)) continue;
618
+ if (needsHerHands(this._profileTrouble?.[account]?.cause) && this._anotherAccountCanRun(account)) continue;
554
619
  this._profileCooldown[account] = 0;
555
620
  }
556
621
  }
557
622
 
623
+ /**
624
+ * A LOGIN THAT LANDED PUTS ITS ACCOUNT STRAIGHT BACK.
625
+ *
626
+ * Trouble on an account is only cleared by a session surviving on it, and a
627
+ * signed-out account was benched so that no session could start on it. So
628
+ * nothing she did could bring it back, and the bench ran out on its own half
629
+ * an hour later (2026-10-04). Here every tick asks whether the files a login
630
+ * writes have moved since the account was found signed out. If they have,
631
+ * the account gets one try now. The trouble itself stays until that try
632
+ * survives, so a write that was not a login (another Claude window touching
633
+ * the same file) buys one two-second try and no more: the stamp moves with
634
+ * it, and the next lift needs the next write.
635
+ */
636
+ _noticeSignIns() {
637
+ let lifted = false;
638
+ for (const [key, trouble] of Object.entries(this._profileTrouble ?? {})) {
639
+ if (trouble?.cause !== 'signed-out') continue;
640
+ const stamp = signInStamp(this._signInFilesFor(key));
641
+ if (!stamp || stamp <= (trouble.stamp ?? trouble.at ?? 0)) continue;
642
+ trouble.stamp = stamp;
643
+ if (this._profileCooldown) this._profileCooldown[key] = 0;
644
+ if (!key.startsWith('codex:')) this._spawnCooldownUntil = 0;
645
+ lifted = true;
646
+ }
647
+ if (lifted) this._saveState();
648
+ return lifted;
649
+ }
650
+
651
+ /** Which files one account's login writes, by the key its trouble is kept under. */
652
+ _signInFilesFor(key) {
653
+ const home = this.config.home || os.homedir();
654
+ if (key.startsWith('codex:')) {
655
+ const profile = key.slice('codex:'.length);
656
+ return signInFiles({ engine: 'codex', folder: this._codexProfileHome(profile), home });
657
+ }
658
+ return signInFiles({ folder: key === 'default' ? null : key, home });
659
+ }
660
+
661
+ /** Whether one Codex login has a sign-in on disk at all. Only the file's time is read. */
662
+ _codexSignedIn(profile) {
663
+ return signInStamp(this._signInFilesFor(`codex:${profile}`)) > 0;
664
+ }
665
+
666
+ // Whether some OTHER login on this account's engine could run once her hand
667
+ // has lifted what time alone would lift, i.e. one not waiting on a person.
668
+ _anotherAccountCanRun(account) {
669
+ const engine = String(account).startsWith('codex:') ? 'codex' : DEFAULT_ENGINE;
670
+ return this._profilesFor(engine)
671
+ .map((p) => this._accountKey(engine, p))
672
+ .some((key) => key !== account && !needsHerHands(this._profileTrouble?.[key]?.cause));
673
+ }
674
+
558
675
  _kill(session) {
559
676
  if (!session) return false;
560
677
  session.stoppedByUs = true;
@@ -574,7 +691,15 @@ export class Supervisor {
574
691
  // a stopped fleet and spawn one more worker on the way out.
575
692
  clearTimeout(this._wakeTimer);
576
693
  this._wakeTimer = null;
577
- // And a spawn still waiting on its folder never starts.
694
+ // And a spawn still waiting on its folder never starts. ITS REPLY GOES BACK
695
+ // IN LINE: the tick saved the reply as delivered when it picked it, and a
696
+ // start that never happened delivered nothing. Left standing, the saved
697
+ // mark had the restarted app skip the reply for good, with no agent on it:
698
+ // an Urgent task sat over an hour while a Medium in its project ran
699
+ // (w-53a72e6e7f, tests/a-reply-waiting-for-its-folder-survives-a-restart).
700
+ for (const { item, opts } of this._preparing?.values() ?? []) {
701
+ if (opts?.continuation && item?.answer) this.redeliverAnswer(item, item.answer);
702
+ }
578
703
  this._preparing?.clear();
579
704
  for (const s of this.sessions.values()) this._kill(s);
580
705
  this.sessions.clear();
@@ -589,6 +714,18 @@ export class Supervisor {
589
714
  return this._kill(session);
590
715
  }
591
716
 
717
+ // SEND NOW, on a message of hers already waiting for the agent's current step
718
+ // to end: cut that step so the message is answered at once
719
+ // (`interrupt` in claude-input.mjs). The run goes on; only the step stops.
720
+ // An engine with no way to cut a step takes the message at its next one.
721
+ async sendNow(product, itemId) {
722
+ const session = this.sessions.get(itemId);
723
+ if (!session || session.product !== product) return { ok: false, interrupted: false };
724
+ if (typeof session.child?.interrupt !== 'function') return { ok: true, interrupted: false };
725
+ const { interrupted } = await session.child.interrupt();
726
+ return { ok: true, interrupted };
727
+ }
728
+
592
729
  // App quit: take the workers down with the supervisor. Killing the app used
593
730
  // to orphan every running claude session; six orphans from before a restart
594
731
  // kept working under stale permission rules and flooded the founder with
@@ -597,6 +734,8 @@ export class Supervisor {
597
734
  killAll() {
598
735
  for (const session of this.sessions.values()) this._kill(session);
599
736
  this._closeCodex(`${Name} is quitting`);
737
+ this.stopMemoryGate();
738
+ clearInterval(this._leftoverTimer);
600
739
  }
601
740
 
602
741
  // PUT A WORKER BACK ON THESE ROWS, WHATEVER STATE THEY ARE IN.
@@ -729,11 +868,12 @@ export class Supervisor {
729
868
  // worker is on it, and that promise is exactly what sleep breaks. So a
730
869
  // stranded row is a row she cannot see.
731
870
  //
732
- // HER RULE, and the only reason this is not three lines: resume, never
871
+ // THE RULE, and the only reason this is not three lines: resume, never
733
872
  // restart. A row whose session is still on disk gets that session back, with
734
873
  // everything it had read and written. A row whose session is not gets NOTHING
735
874
  // done to it: no fresh worker, no cleared mark, no stranger briefed from
736
- // zero. It waits for her, which is what she chose over restarting it.
875
+ // zero. It waits for the user, which is deliberately preferred over
876
+ // restarting it.
737
877
  recoverInterrupted(reason = 'startup') {
738
878
  const out = { reason, resumed: 0, waiting: 0, queued: 0, running: 0, resting: 0, missing: 0, resumedIds: [], waitingIds: [], missingIds: [] };
739
879
  this._recoverPending = false;
@@ -784,12 +924,12 @@ export class Supervisor {
784
924
  // Resume forces the row's own profile, and it has to: the session lives
785
925
  // in that account's home and in no other, so a resume is only a resume on
786
926
  // the account that made it. That is exactly why this path walked past the
787
- // quarantine `_pickProfile` respects, and on 2026-08-24 that cost her
788
- // seven and a half hours. Her second subscription's login expired at
789
- // 10:39; two rows had their sessions on it; every sweep resumed both,
790
- // both died in two seconds, both were still stranded for the next sweep.
791
- // Four dead spawns a minute, two of her three slots gone to them on every
792
- // pass, and the healthy account never got to start anything.
927
+ // quarantine `_pickProfile` respects, and that once cost seven and a
928
+ // half hours. A second subscription's login expired; two rows had their
929
+ // sessions on it; every sweep resumed both, both died in two seconds,
930
+ // both were still stranded for the next sweep. Four dead spawns a minute,
931
+ // two of three slots gone to them on every pass, and the healthy account
932
+ // never got to start anything.
793
933
  //
794
934
  // Resting is not `waiting`. Waiting is her word for a session that is
795
935
  // gone off disk and needs a person; this session is intact and this
@@ -878,14 +1018,11 @@ export class Supervisor {
878
1018
  //
879
1019
  // Ordering was already fixed and was not enough. put every spawnable row into
880
1020
  // one scored queue, which decides who gets the NEXT FREE slot; nothing frees
881
- // one. Measured over her whole store for the five days since that fix landed
882
- // (08-19 19:37 to 08-24): 68 answers on Urgent rows, median wait to start 1.7
883
- // minutes, and 23 of them waited longer than five. Seventeen of those 23 had
884
- // a lower-priority session running at the moment the urgent one finally
885
- // started. In the 19 clearest cases — where a lower-priority session was
886
- // already running when her answer landed — her urgent work spent 7.8 hours
887
- // waiting, the worst single wait 61.5 minutes, and the sessions in front of
888
- // it ran a median of 21 minutes.
1021
+ // one. Measured over the five days after that fix landed: about a third of
1022
+ // answers on Urgent rows waited longer than five minutes to start, most of
1023
+ // them behind a lower-priority session that was already running, the worst
1024
+ // single wait an hour, and the sessions in front of them ran a median of 21
1025
+ // minutes.
889
1026
  //
890
1027
  // WHAT MAKES THIS SAFE IS THAT THE WORK IS KEPT, and it is kept by machinery
891
1028
  // that already existed rather than anything invented here. `_kill` marks the
@@ -910,7 +1047,15 @@ export class Supervisor {
910
1047
  // she would have lost a running worker for nothing. So the candidate list is
911
1048
  // scoped to the engine the urgent row is going to spawn on, and a full fleet
912
1049
  // on the OTHER engine is simply not this row's problem.
1050
+ //
1051
+ // A ROW SHE PUSHED WITH "RUN NOW" TAKES A SLOT TOO, by the same door and with
1052
+ // the same promise that what it pauses comes back as itself. Two things are
1053
+ // different, both because she asked for this row by name, this minute: it
1054
+ // may pause anything that is not itself pushed, whatever its project or tag,
1055
+ // and the empty-runs rule below does not apply to it.
913
1056
  _preemptFor(item, items, engine = DEFAULT_ENGINE) {
1057
+ const pushed = this._runNow.has(item.id);
1058
+ if (pushed) return this._takeSlotFor(item, items, engine, (victim) => !this._runNow.has(victim.id));
914
1059
  if (!isUrgent(item)) return null;
915
1060
  // A ROW WORKERS KEEP LEAVING ALONE MAY NOT KILL ANYTHING.
916
1061
  //
@@ -929,12 +1074,24 @@ export class Supervisor {
929
1074
  // answer restores the interrupt immediately — which is the case that
930
1075
  // matters most, an Urgent row she has just replied to.
931
1076
  if (this._emptyRuns(item) > 1) return null;
1077
+ // STRICTLY lower. Equal scores do not interrupt each other: two Urgent
1078
+ // rows in the same product would otherwise take turns killing each
1079
+ // other for as long as they both existed. Nor does it pause a row she
1080
+ // pushed with Run now, which she asked for by name.
1081
+ const want = this._score(item);
1082
+ return this._takeSlotFor(item, items, engine,
1083
+ (victim) => this._score(victim) < want && !this._runNow.has(victim.id));
1084
+ }
1085
+
1086
+ // The half of an interruption both doors share: pick the session to pause,
1087
+ // pause it, and hold it out of the queue until `item` is running.
1088
+ // `mayTake(victim)` is the door's own rule about whom it may pause.
1089
+ _takeSlotFor(item, items, engine, mayTake) {
932
1090
  // A slot taken for a row that cannot take it is a kill that buys her
933
1091
  // nothing. The queue's own guards cover the answered case; this covers a
934
1092
  // lease somebody else still holds, which would refuse this worker the
935
1093
  // claim whatever we killed to make room for it.
936
1094
  if (this.claimHeldElsewhere(item)) return null;
937
- const want = this._score(item);
938
1095
  const candidates = [];
939
1096
  for (const session of this.sessions.values()) {
940
1097
  const id = session.itemId;
@@ -949,10 +1106,7 @@ export class Supervisor {
949
1106
  // since been archived. We cannot score it and we cannot resume it onto
950
1107
  // anything, so it is left alone.
951
1108
  if (!victim) continue;
952
- // STRICTLY lower. Equal scores do not interrupt each other: two Urgent
953
- // rows in the same product would otherwise take turns killing each
954
- // other for as long as they both existed.
955
- if (this._score(victim) >= want) continue;
1109
+ if (!mayTake(victim)) continue;
956
1110
  const rec = this._liveSessions[id];
957
1111
  // The two halves of "the work is kept". Without the record there is no
958
1112
  // session id to resume; without the transcript the CLI's --resume exits
@@ -998,12 +1152,60 @@ export class Supervisor {
998
1152
  const done = this.sessions.has(rec.forItem)
999
1153
  || !target
1000
1154
  || (target.status !== 'open' && target.status !== 'claimed')
1001
- || !isUrgent(target)
1155
+ || (!isUrgent(target) && !this._runNow.has(target.id))
1002
1156
  || now - rec.at > PREEMPTION_HOLD_MS;
1003
1157
  if (done) this._preempted.delete(id);
1004
1158
  }
1005
1159
  }
1006
1160
 
1161
+ /**
1162
+ * RUN THIS ONE NOW: the three-dot menu's row on a waiting task.
1163
+ *
1164
+ * Her tag on a task could not do this. A project's place in her order is
1165
+ * worth a hundred times any tag (shared/rank.mjs), so an Urgent task in a
1166
+ * lower project waited behind a Low one in a higher project and could not
1167
+ * interrupt it either; lowering the other tasks only reordered the waiting
1168
+ * line, never a running one. So this is not a priority at all. It is a
1169
+ * one-time push: the task goes to the front of the line, and if every slot
1170
+ * on its engine is full the tick pauses the least important running task to
1171
+ * make room (`_preemptFor`), which comes back as its own session afterwards.
1172
+ * The task's tag and everything else's order are left exactly as they were.
1173
+ */
1174
+ runNow(product, id) {
1175
+ const item = this.store.listItems(Date.now()).find((i) => i.id === id && i.product === product);
1176
+ if (!item) return { ok: false, reason: 'missing' };
1177
+ if (this.sessions.has(id)) return { ok: false, reason: 'running' };
1178
+ // A PUSH NOTHING WILL ACT ON IS REFUSED OUT LOUD. The menu now offers Run
1179
+ // now on anything In progress (renderer/src/run-now.ts), which takes in
1180
+ // rows the tick is not going to start. Accepting those would read as Up
1181
+ // next while nothing moved, so each says why instead.
1182
+ if (this.paused) return { ok: false, reason: 'paused' };
1183
+ if (!this.store.isDue(item, Date.now())) return { ok: false, reason: 'scheduled' };
1184
+ if (this.claimHeldElsewhere(item)) return { ok: false, reason: 'held' };
1185
+ this._runNow.set(id, { product, at: Date.now(), started: false });
1186
+ // Asking for a row by name outranks a rest the fleet earned on it, the
1187
+ // same way `resumeItems` treats it.
1188
+ if (this._fruitless[`${product}:${id}`]) {
1189
+ delete this._fruitless[`${product}:${id}`];
1190
+ this._saveState();
1191
+ }
1192
+ this.onChange?.();
1193
+ this.wake();
1194
+ return { ok: true };
1195
+ }
1196
+
1197
+ // A push lasts while its task runs, so a second push cannot pause the first,
1198
+ // and ends with that run. One that never starts ends when the row stops being
1199
+ // open work: finished, archived, or gone.
1200
+ _settleRunNow(items) {
1201
+ for (const [id, rec] of [...this._runNow]) {
1202
+ const item = items.find((i) => i.id === id && i.product === rec.product);
1203
+ if (!item || (item.status !== 'open' && item.status !== 'claimed')) { this._runNow.delete(id); continue; }
1204
+ if (this.sessions.has(id)) rec.started = true;
1205
+ else if (rec.started) this._runNow.delete(id);
1206
+ }
1207
+ }
1208
+
1007
1209
  // AN ACCOUNT THAT IS OUT OF THE ROTATION RIGHT NOW.
1008
1210
  //
1009
1211
  // The cooldown and nothing else, deliberately. `_liveProfiles` falls back to
@@ -1020,14 +1222,13 @@ export class Supervisor {
1020
1222
  * AN ACCOUNT ONLY A PERSON CAN MEND HOLDS NO CHAT.
1021
1223
  *
1022
1224
  * A row is a chat, and a chat lives on one account: `_rowSessions` records
1023
- * which, and every reply she writes goes back there, because a transcript
1225
+ * which, and every reply the user writes goes back there, because a transcript
1024
1226
  * sits under one profile's home and under no other.
1025
1227
  *
1026
- * It was. Measured on her store that afternoon: 28 of 67 remembered chats
1027
- * were on ~/.claude-work, whose organization has Claude Code switched off,
1028
- * and one of them died four times in four hours, each death about twenty
1029
- * seconds after she wrote on the row, while her other subscription worked
1030
- * throughout. Two of those four spawns went out INSIDE the thirty-minute
1228
+ * It was. Chats remembered on an account whose organization had Claude Code
1229
+ * switched off died again and again, each death about twenty seconds after a
1230
+ * reply on the row, while another subscription worked throughout. Some of
1231
+ * those spawns went out INSIDE the thirty-minute
1031
1232
  * quarantine this file had already put that account in: `_recoverPending`'s
1032
1233
  * sweep checks the quarantine and the reply path never did.
1033
1234
  *
@@ -1042,11 +1243,11 @@ export class Supervisor {
1042
1243
  * Losing a chat costs one fresh brief, which is what every row got before
1043
1244
  * chats existed, so the worst case of this guard is the old normal.
1044
1245
  *
1045
- * A USAGE LIMIT IS NOW A THIRD HALF, and a PER-ENGINE one (2026-09-24,
1046
- * w-ca48e69535). "Worth waiting out" was written about a five-hour limit.
1047
- * Her Codex work login hit a WEEKLY one, four days from resetting, and her
1048
- * reply sat on it while the other login was at 0%. Her words: "it should be
1049
- * running because I switched the account". So an account resting on its
1246
+ * A USAGE LIMIT IS NOW A THIRD HALF, and a PER-ENGINE one (w-ca48e69535).
1247
+ * "Worth waiting out" was written about a five-hour limit. A Codex login
1248
+ * that hits a WEEKLY one can be days from resetting, and a reply sat on it
1249
+ * while the other login was at 0%. A user who has switched accounts expects
1250
+ * the work to run on the new one. So an account resting on its
1050
1251
  * limit gives the chat up too, when another account on the SAME engine can
1051
1252
  * run. This used to read `_healthyProfiles` and an unkeyed trouble entry,
1052
1253
  * both Claude-only, so it could never fire for a Codex row at all. */
@@ -1103,11 +1304,11 @@ export class Supervisor {
1103
1304
 
1104
1305
  /**
1105
1306
  * Whether this Mac has the harness at all, which is a different question
1106
- * from whether it has a free slot. Claude Code is always the answer for
1107
- * itself: the app refuses to open an inbox without it and says so in its own
1108
- * words (main/claude-bin.mjs), so a second sentence here would be noise. */
1307
+ * from whether it has a free slot. Claude Code answers yes for itself unless
1308
+ * this Mac runs on Codex alone: with neither, the app refuses to open an
1309
+ * inbox and says so in its own words, so a second sentence here is noise. */
1109
1310
  _engineCanRun(engine) {
1110
- return engineOf(engine) === DEFAULT_ENGINE ? true : !!this.config.codexBin;
1311
+ return engineOf(engine) === DEFAULT_ENGINE ? this._homeEngine() === DEFAULT_ENGINE : !!this.config.codexBin;
1111
1312
  }
1112
1313
 
1113
1314
  /**
@@ -1239,15 +1440,15 @@ export class Supervisor {
1239
1440
  //
1240
1441
  // Until this, every session on a product ran in the one checkout the product
1241
1442
  // registers, and staying out of each other's way was a rule in the worker
1242
- // brief rather than anything the app did. Measured on her Mac that day: 25
1243
- // leftover copies of this repository holding 26 GB, twelve of them carrying
1244
- // edits committed nowhere, because the rule's first half (make a copy) is
1245
- // kept by almost every session and its second half (fold it back, delete it)
1246
- // by almost none. Her decision, the same day: "the app makes the folder for
1247
- // both agents." main/task-folders.mjs is the whole of how.
1443
+ // brief rather than anything the app did. Measured: 25 leftover copies of
1444
+ // this repository holding 26 GB, twelve of them carrying edits committed
1445
+ // nowhere, because the rule's first half (make a copy) is kept by almost
1446
+ // every session and its second half (fold it back, delete it) by almost
1447
+ // none. So the app makes the folder, for both engines. main/task-folders.mjs
1448
+ // is the whole of how.
1248
1449
  //
1249
1450
  // THE PRODUCT'S OWN CHECKOUT IS STILL THE ANSWER for a product with no
1250
- // repository (most of hers), for a personal chat, and any time making the
1451
+ // repository, for a personal chat, and any time making the
1251
1452
  // folder fails. Nothing here may ever stop a spawn: a session that runs in
1252
1453
  // the shared checkout is what happened yesterday, and a session that does not
1253
1454
  // run is a row she never hears back on.
@@ -1353,6 +1554,39 @@ export class Supervisor {
1353
1554
  }
1354
1555
  }
1355
1556
 
1557
+ /**
1558
+ * A SESSION LETS GO OF ITS ROW, AND ONLY OF ITS OWN ROW.
1559
+ *
1560
+ * One entry in the session map per row, so two sessions on one row share it,
1561
+ * and an exit used to delete that entry whoever it belonged to. Measured on
1562
+ * w-4d722ecf92, 2026-09-30: the row's folder had been built at the path a
1563
+ * session runs in, a tick mid-build started a worker in it, the finished
1564
+ * build started a second worker on the same row, and when the first exited it
1565
+ * deleted the entry belonging to the one still running and handed its folder
1566
+ * back. A folder freshly made at main is clean and already in main, which is
1567
+ * exactly the case `releaseTaskFolder` DELETES rather than keeps, so the
1568
+ * ground went out from under a live agent about a minute in.
1569
+ *
1570
+ * The build is atomic now (`buildingFolderPath`) so two sessions should not
1571
+ * reach one row at all. This is the second lock on the same door, because the
1572
+ * cost of being wrong here is an agent's work deleted mid run.
1573
+ */
1574
+ endSession(item, session, product) {
1575
+ if (session && this.sessions.get(item.id) !== session) {
1576
+ console.warn(`zero: a later session is on ${item.id}, so this one leaves its folder alone`);
1577
+ return Promise.resolve(null);
1578
+ }
1579
+ this.sessions.delete(item.id);
1580
+ // The run is over, said by the only app that runs this row; what it left
1581
+ // running may now be stopped on the cleaner's clock (main/leftovers.mjs).
1582
+ this._leftoverCleaner?.ended(item.id);
1583
+ // The row's folder goes back. It is only deleted when losing it costs
1584
+ // nothing, so the usual answer is that it stays, holding work she has not
1585
+ // approved. The same call sweeps the folders whose work has since landed in
1586
+ // main, which is what stops them piling up unseen.
1587
+ return Promise.resolve(this.releaseWorkFolder(item, product));
1588
+ }
1589
+
1356
1590
  /**
1357
1591
  * THE ROW'S FOLDER, MADE BEFORE THE SPAWN AND NOT ON THIS THREAD.
1358
1592
  *
@@ -1379,9 +1613,18 @@ export class Supervisor {
1379
1613
  const base = this.productFolder(product);
1380
1614
  let folder = null;
1381
1615
  try { folder = taskFolderPath(base, item.id); } catch { return false; }
1382
- if (fs.existsSync(folder)) return false;
1616
+ // WHO IS ALREADY MAKING ONE IS ASKED BEFORE THE DISK IS, and that order is
1617
+ // the whole of it. A build takes about ten seconds, and it used to leave a
1618
+ // directory at this path for all of them, so a tick arriving mid-build read
1619
+ // "the folder is there" and started a session in a folder holding almost
1620
+ // none of the code. The build then finished and started its own session on
1621
+ // the same row: two workers, and the second one's claim refused
1622
+ // (2026-09-30, w-4d722ecf92). The folder is built elsewhere and moved in
1623
+ // now, so the disk no longer lies here, and this answers first anyway
1624
+ // because a row somebody is already preparing is never a row to spawn on.
1383
1625
  const held = this._preparing.get(item.id);
1384
1626
  if (held) { held.item = item; held.opts = opts; return true; }
1627
+ if (fs.existsSync(folder)) return false;
1385
1628
  const entry = { item, opts, engine };
1386
1629
  this._preparing.set(item.id, entry);
1387
1630
  folderJob('restoreTaskFolder', base, item.id)
@@ -1393,6 +1636,13 @@ export class Supervisor {
1393
1636
  // Stopped, or the app quit, while the folder was being made.
1394
1637
  if (this._preparing.get(item.id) !== entry) return;
1395
1638
  this._preparing.delete(item.id);
1639
+ // AND A SESSION THAT ARRIVED WHILE WE WERE BUILDING ALREADY HAS THIS
1640
+ // ROW. Starting a second one here is what gave w-4d722ecf92 two workers
1641
+ // and had the second one's claim refused. A spawn cannot reach this
1642
+ // point through `_folderFirst` any more, but a remote-control spawn
1643
+ // skips it entirely, so the one place that starts a worker without
1644
+ // anybody having just checked asks for itself.
1645
+ if (this.sessions.has(item.id)) { this.onChange?.(); return; }
1396
1646
  this._madeFolders.set(item.id, cwd);
1397
1647
  try { this.spawnWorker(entry.item, entry.opts); }
1398
1648
  catch (error) { console.warn(`zero: could not start ${item.id}:`, error.message); }
@@ -1402,6 +1652,45 @@ export class Supervisor {
1402
1652
  return true;
1403
1653
  }
1404
1654
 
1655
+ /**
1656
+ * THE CHECKOUT, PHOTOGRAPHED OFF THE MAIN THREAD BEFORE THE RUN STARTS
1657
+ * (2026-10-01, main/git-change-offthread.mjs says why). The same pending
1658
+ * entry `_folderFirst` uses, so a row being photographed counts against its
1659
+ * slot and Stop cancels it exactly as it cancels a folder being made.
1660
+ */
1661
+ _photoFirst(item, product, engine, opts) {
1662
+ if (opts.remoteOnly) return false;
1663
+ this._photos ??= new Map();
1664
+ if (this._photos.has(item.id)) return false;
1665
+ const held = this._preparing?.get(item.id);
1666
+ if (held) { held.item = item; held.opts = opts; return true; }
1667
+ let cwd = null;
1668
+ try { cwd = this.workFolderFor(item, product); } catch { return false; }
1669
+ // Only a folder that is itself a checkout (a task folder carries a `.git`
1670
+ // file, a repository a `.git` folder). Anything else has nothing to
1671
+ // photograph, and keeps starting in the same call as before.
1672
+ if (!cwd || !fs.existsSync(path.join(cwd, '.git'))) return false;
1673
+ this._preparing ??= new Map();
1674
+ const entry = { item, opts, engine, madeFolder: this._madeFolders?.get(item.id) };
1675
+ this._preparing.set(item.id, entry);
1676
+ gitJob('snapshotRepo', cwd)
1677
+ .then((photo) => photo ?? null, () => null)
1678
+ .then((photo) => {
1679
+ if (this._preparing.get(item.id) !== entry) return;
1680
+ this._preparing.delete(item.id);
1681
+ this._photos.set(item.id, photo);
1682
+ if (entry.madeFolder !== undefined) { this._madeFolders ??= new Map(); this._madeFolders.set(item.id, entry.madeFolder); }
1683
+ try { this.spawnWorker(entry.item, entry.opts); }
1684
+ catch (error) { console.warn(`zero: could not start ${item.id}:`, error.message); }
1685
+ finally {
1686
+ this._photos.delete(item.id);
1687
+ if (entry.madeFolder !== undefined) this._madeFolders?.delete(item.id);
1688
+ }
1689
+ this.onChange?.();
1690
+ });
1691
+ return true;
1692
+ }
1693
+
1405
1694
  /**
1406
1695
  * THE FOLDERS OF ROWS THAT ARE FINISHED, PUT AWAY.
1407
1696
  *
@@ -1464,6 +1753,15 @@ export class Supervisor {
1464
1753
  if (item) break;
1465
1754
  }
1466
1755
  if (item && item.status !== 'done') continue;
1756
+ // AND NEVER A ROW SOMEBODY IS STILL HOLDING, which is the rule the bug
1757
+ // report asked for: whatever cleans folders up must not remove one whose
1758
+ // row is claimed by a live session. The session map above is only
1759
+ // the sessions THIS process started, so a worker the app has lost track
1760
+ // of, or one whose lease has not run out yet, is invisible to it. The
1761
+ // claim in the ledger is the fact that outlives this process. The cost of
1762
+ // reading it is that a finished folder waits out a lease; the cost of not
1763
+ // reading it is an agent's folder deleted while it works (w-4d722ecf92).
1764
+ if (item?.claim && !item.claimExpired) continue;
1467
1765
  if (busy(folder.id)) continue;
1468
1766
  if ((await folderJob('parkTaskFolder', base, folder.id)).parked) put += 1;
1469
1767
  }
@@ -1497,7 +1795,7 @@ export class Supervisor {
1497
1795
  // But a bigger cap is not what makes it safe either, because CLAUDE CODE
1498
1796
  // DELETES ITS OWN TRANSCRIPTS AT THIRTY DAYS and an entry whose transcript
1499
1797
  // is gone is already refused by `rowSessionFor`. Measured the same day
1500
- // across both her logins: 3,808 transcript files, oldest 31.0 days, exactly
1798
+ // across a real set of transcripts: 3,808 files, oldest 31.0 days, exactly
1501
1799
  // one over 30. So thirty days is the true life of a chat whatever we set
1502
1800
  // here, and the honest prune is by age, with the count left as a backstop
1503
1801
  // far above where it can bite: replaying her 1,605 real replies through this
@@ -1507,7 +1805,7 @@ export class Supervisor {
1507
1805
  // at 500. Re-run it all with `scripts/replay-the-forgetting-rule.mjs`.
1508
1806
  static get ROW_SESSION_CAP() { return 2000; }
1509
1807
 
1510
- // Claude Code's own `cleanupPeriodDays`, which neither of her settings files
1808
+ // Claude Code's own `cleanupPeriodDays`, which a settings file rarely
1511
1809
  // overrides. Past this the transcript is gone and the entry is dead weight.
1512
1810
  static get ROW_SESSION_MAX_AGE_MS() { return 30 * 24 * 60 * 60 * 1000; }
1513
1811
 
@@ -1620,14 +1918,18 @@ export class Supervisor {
1620
1918
  const parts = [
1621
1919
  'The founder has replied on the work item you were just working on. This is',
1622
1920
  'that same session: everything you read and wrote is still here, so do not go',
1623
- 'back over the files or the item to catch up. Read her words and carry on.',
1921
+ 'back over the files or the item to catch up.',
1922
+ '',
1923
+ 'Their newest message is the last one below, and it outranks anything',
1924
+ 'before it. Do what it asks first, before you go back to anything you were',
1925
+ 'doing; if it says to keep going afterwards, keep going then.',
1624
1926
  '',
1625
1927
  'Two things did change while you were stopped. Your claim on the row was',
1626
1928
  'released when your last run ended, so claim it again before you write to it.',
1627
- 'And the row itself may have been rewritten since, by her or by another',
1929
+ 'And the row itself may have been rewritten since, by them or by another',
1628
1930
  'session, so read it back before you trust what you remember of it.',
1629
1931
  '',
1630
- 'What she said:',
1932
+ 'What they said:',
1631
1933
  '',
1632
1934
  String(item?.answer ?? ''),
1633
1935
  ];
@@ -1635,9 +1937,39 @@ export class Supervisor {
1635
1937
  // attached to the reply that woke it.
1636
1938
  const files = product ? this.attachmentBlock({ ...item, body: '', result: '', note: '' }, product) : '';
1637
1939
  if (files) parts.push(files);
1940
+ // A resumed session never saw the full brief's ship section if it began
1941
+ // before the project shipped through the app, and her "merge it" is
1942
+ // exactly when it needs it.
1943
+ if (product && this.shipsThroughTheApp(product)) parts.push(this.shipBrief());
1638
1944
  return parts.join('\n');
1639
1945
  }
1640
1946
 
1947
+ /** Whether this Mac ships this project's tasks itself (main/ship-queue.mjs). */
1948
+ shipsThroughTheApp(product) {
1949
+ return !!shipScriptFor(product, readShipSettings(this.userDir));
1950
+ }
1951
+
1952
+ // HOW A TASK SHIPS, when the app does it. The agent's last step is a label,
1953
+ // not a push: a push or a move of another checkout from inside an agent
1954
+ // session is what Claude Code's safety check refuses, and a refusal asks
1955
+ // nobody, so the task just stopped.
1956
+ shipBrief() {
1957
+ return [
1958
+ '',
1959
+ '# How this task ships',
1960
+ '',
1961
+ `${Name} ships this project's work itself. When the work is done and its tests`,
1962
+ 'pass, commit it on your branch in your task folder, then add the label',
1963
+ `\`${SHIP_LABEL}\` to this work item (update_work_item, keeping its other labels)`,
1964
+ 'and end your turn as usual. Whether to wait for approval first is set by the',
1965
+ 'instructions you were given, not by this.',
1966
+ '',
1967
+ `Do NOT push, do NOT merge into main, and do NOT touch any other folder. ${Name}`,
1968
+ 'merges the latest main into your branch, runs the tests, pushes, and updates',
1969
+ 'the app folder. If that fails you will be woken with its exact words.',
1970
+ ].join('\n');
1971
+ }
1972
+
1641
1973
  // WHAT A RESUMED WORKER IS TOLD, and why it is six lines rather than a brief.
1642
1974
  // Everything it knew is still in the session it is being handed back: the
1643
1975
  // work item, her instructions, every file it read. A second copy of
@@ -1654,13 +1986,15 @@ export class Supervisor {
1654
1986
  'and re-read the work item if you need to.',
1655
1987
  ];
1656
1988
  if (continuation && item?.answer && item.answer !== '(withdrawn)') {
1657
- parts.push('', 'The founder has answered since you stopped:', '', String(item.answer));
1989
+ parts.push('', 'The founder has answered since you stopped. Their newest message is the',
1990
+ 'last one below: do what it asks before you carry on with anything else.', '', String(item.answer));
1658
1991
  // A resumed session has everything it read still in it, EXCEPT anything
1659
1992
  // she attached to the reply that woke it. That is new, and it is the one
1660
1993
  // thing a six-line wake-up can genuinely be missing.
1661
1994
  const files = product ? this.attachmentBlock({ ...item, body: '', result: '', note: '' }, product) : '';
1662
1995
  if (files) parts.push(files);
1663
1996
  }
1997
+ if (product && this.shipsThroughTheApp(product)) parts.push(this.shipBrief());
1664
1998
  return parts.join('\n');
1665
1999
  }
1666
2000
 
@@ -1668,12 +2002,10 @@ export class Supervisor {
1668
2002
  * THE FIRST WORDS A FORK HEARS, AND WHY IT NEEDS DIFFERENT ONES.
1669
2003
  *
1670
2004
  * A forked session is the old conversation continued under a new id, so it
1671
- * remembers everything, INCLUDING which work item it was on. On 2026-09-23
1672
- * that is exactly what it did: the fork of `w-5ebf7bf7bb` kept writing its
1673
- * results onto `w-5ebf7bf7bb`, so one thread in her inbox had two agents
1674
- * talking in it and the fork's own row sat silent. Her words the next day: "I
1675
- * thought I answered this in a different agent but this one picked it up...
1676
- * Could this indicate that our fork is not working?"
2005
+ * remembers everything, INCLUDING which work item it was on. That is exactly
2006
+ * what it did: the fork of `w-5ebf7bf7bb` kept writing its results onto
2007
+ * `w-5ebf7bf7bb`, so one thread in the inbox had two agents talking in it and
2008
+ * the fork's own row sat silent, which looked as if forking did not work.
1677
2009
  *
1678
2010
  * It was working. Nobody had told it that it had moved. So the fork's first
1679
2011
  * message says which row it is on now, that the row it remembers belongs to
@@ -1689,7 +2021,7 @@ export class Supervisor {
1689
2021
  `Your work item is now ${item.id}: ${item.title}`,
1690
2022
  ...(from ? [`The row you remember, ${from}, belongs to another session now. Do not`, 'write on it, claim it, or answer on it. Everything you have to say goes on', 'your own row.'] : []),
1691
2023
  '',
1692
- 'What she asked this fork to try:',
2024
+ 'What they asked this fork to try:',
1693
2025
  '',
1694
2026
  String(item.body ?? '').trim() || '(nothing beyond the fork itself)',
1695
2027
  ];
@@ -1698,8 +2030,8 @@ export class Supervisor {
1698
2030
  return parts.join('\n');
1699
2031
  }
1700
2032
 
1701
- // Which delivery this is. Keyed on WHEN she wrote, not only on what she
1702
- // wrote: two identical nudges four minutes apart are two asks, and a key made
2033
+ // Which delivery this is. Keyed on WHEN the user wrote, not only on what
2034
+ // they wrote: two identical nudges four minutes apart are two asks, and a key made
1703
2035
  // of the words alone made the second one already-handled, silently and
1704
2036
  // permanently. The old text-only key is still honoured on read so the marks
1705
2037
  // already on disk keep suppressing the spawns they were written for; they age
@@ -1717,6 +2049,24 @@ export class Supervisor {
1717
2049
  || this._handledAnswers.has(this._legacyAnswerKey(item, answer));
1718
2050
  }
1719
2051
 
2052
+ // WHICH CONVERSATION ALREADY HOLDS THESE WORDS, which is not whether a run
2053
+ // finished carrying them. A stop releases the delivery mark on purpose, so
2054
+ // the row gets another run, and the resume used to read that release as
2055
+ // "never heard" and hand the same conversation everything again: on
2056
+ // w-f37a34def6 a 2,000-character round of feedback it had acted on for forty
2057
+ // minutes, ahead of the one short ask it had not seen, which it then missed.
2058
+ // Written when a run starts on the words or takes them mid-run, and never
2059
+ // released, because a stop does not unhear anything.
2060
+ _noteHeard(item, sessionId, answer = item?.answer) {
2061
+ if (!sessionId || !item?.wrote?.answer) return;
2062
+ (this._heardAnswers ??= {})[this._answerKey(item, answer)] = sessionId;
2063
+ this._saveState();
2064
+ }
2065
+
2066
+ _heardIn(item, sessionId, answer = item?.answer) {
2067
+ return !!sessionId && this._heardAnswers?.[this._answerKey(item, answer)] === sessionId;
2068
+ }
2069
+
1720
2070
  // SOMEONE ELSE IS STILL HOLDING THIS ROW, so a worker sent to it now would be
1721
2071
  // refused it and could do nothing at all.
1722
2072
  //
@@ -1748,14 +2098,13 @@ export class Supervisor {
1748
2098
  // session exits, and the reason "stopped · 5h" rows existed at all.
1749
2099
  //
1750
2100
  // The mark is written at SPAWN, which is a promise that a worker will carry
1751
- // her words, not a record that one did. Releasing it was already understood
1752
- // to be necessary and was wired to the wrong predicate: sessions dying inside
1753
- // 45 seconds. Three of her items sat stranded for two, two and six hours
1754
- // against that, because they died at two to four minutes with
1755
- // `error_during_execution` — past the window, so the mark stood, so no tick
1756
- // would ever look at them again. Nothing retried and nothing said so. Her
1757
- // words were in the ledger and no worker would ever read them (2026-08-06,
1758
- // The Frontier / /).
2101
+ // the user's words, not a record that one did. Releasing it was already
2102
+ // understood to be necessary and was wired to the wrong predicate: sessions
2103
+ // dying inside 45 seconds. Items sat stranded for hours against that, because
2104
+ // they died at two to four minutes with `error_during_execution`, past the
2105
+ // window, so the mark stood, so no tick would ever look at them again. Nothing
2106
+ // retried and nothing said so. The user's words were in the ledger and no
2107
+ // worker would ever read them.
1759
2108
  //
1760
2109
  // Duration was never the question. A SESSION THAT DID NOT FINISH DELIVERED
1761
2110
  // NOTHING, however long it took not to finish, so the predicate is the
@@ -1769,7 +2118,7 @@ export class Supervisor {
1769
2118
  //
1770
2119
  // `session.stoppedByUs` is how that is known, and the signal is not. The CLI
1771
2120
  // traps SIGTERM and exits itself, so `signal` arrives null on our own kills
1772
- // and this branch never once ran in production; three of her ordinary actions
2121
+ // and this branch never once ran in production; three ordinary user actions
1773
2122
  // on one row exhausted its retries and stranded it for good. The signal is
1774
2123
  // still honoured for a child that dies without handling one.
1775
2124
  //
@@ -1781,7 +2130,7 @@ export class Supervisor {
1781
2130
  // AND "FINISHED" IS ABOUT THE ROW, NOT ABOUT THE PROCESS. A session that
1782
2131
  // exits with a result and no error has told us nothing about her item; the
1783
2132
  // only proof her answer was carried is a word an agent left on the row after
1784
- // she wrote. Without that check the mark was written on sessions that touched
2133
+ // the user wrote. Without that check the mark was written on sessions that touched
1785
2134
  // nothing, and the row came straight back to her inbox saying what it said
1786
2135
  // before she answered (five times in two days; 22 seconds on). Treated as a
1787
2136
  // delivery that did not happen, which is what the retry counter below is
@@ -1793,7 +2142,7 @@ export class Supervisor {
1793
2142
  } catch { return true; }
1794
2143
  }
1795
2144
 
1796
- // WHICH OF HER WORDS THIS SESSION ACTUALLY RECEIVED, which is not always the
2145
+ // WHICH OF THE USER'S WORDS THIS SESSION ACTUALLY RECEIVED, which is not always the
1797
2146
  // one it was spawned for.
1798
2147
  //
1799
2148
  // A reply typed at a session that is RUNNING is steered straight into it
@@ -1805,15 +2154,14 @@ export class Supervisor {
1805
2154
  // "stopped" on the row, and nothing coming, because the live reply is marked
1806
2155
  // delivered and no continuation will ever be spawned for it.
1807
2156
  //
1808
- // MEASURED on her own ledgers, 2026-09-22: of 1,841 settlements ever written,
1809
- // 62 recorded an older answer than the newest one on the row at that moment,
1810
- // across 39 rows. One of them is the row she photographed for w-72aa8c0b8d,
1811
- // where three rounds in a row settled the answer before the one she had just
1812
- // sent (19:09, 19:38 and 19:48 the same evening), so a finished round with the
1813
- // result written on it never once reached her inbox.
2157
+ // MEASURED on real ledgers: a few percent of all settlements recorded an older
2158
+ // answer than the newest one on the row at that moment. On one row
2159
+ // (w-72aa8c0b8d) three rounds in a row settled the answer before the one just
2160
+ // sent, so a finished round with the result written on it never once reached
2161
+ // the inbox.
1814
2162
  //
1815
2163
  // So the newest word the session held, which is the spawn's answer or the
1816
- // last one steered into it, whichever she wrote later.
2164
+ // last one steered into it, whichever was written later.
1817
2165
  carriedThrough(item, session) {
1818
2166
  return Math.max(answerTs(item), answerTs(session?.lastLiveReply));
1819
2167
  }
@@ -1826,7 +2174,7 @@ export class Supervisor {
1826
2174
  if (reply) this.redeliverAnswer(reply, reply.answer);
1827
2175
  }
1828
2176
 
1829
- // A REPLY SHE TYPED INTO A RUN THAT WAS NOT CARRYING AN ANSWER AT ALL.
2177
+ // A REPLY THE USER TYPED INTO A RUN THAT WAS NOT CARRYING AN ANSWER AT ALL.
1830
2178
  //
1831
2179
  // `settleDelivery` is only reached on a continuation, a session spawned
1832
2180
  // BECAUSE she had spoken. The other half of the same day is ordinary fresh
@@ -1836,7 +2184,7 @@ export class Supervisor {
1836
2184
  //
1837
2185
  // Same three outcomes as a continuation, without the retry counter: this
1838
2186
  // delivery was never a spawn, so there is nothing to count and nothing to
1839
- // give up on. Either the run spoke after her words, or the word goes back in
2187
+ // give up on. Either the run spoke after the user's words, or the word goes back in
1840
2188
  // the queue for the next one.
1841
2189
  settleLiveReply(item, session) {
1842
2190
  const carried = answerTs(session?.lastLiveReply);
@@ -1855,7 +2203,7 @@ export class Supervisor {
1855
2203
  * @param {{ command?: boolean }} [how] `command` means her reply WAS one of
1856
2204
  * Claude Code's own commands and the run was that command, not a worker. Such
1857
2205
  * a run answers in the thread and deliberately writes nothing on the row, so
1858
- * the ordinary test below — did an agent speak on the row since she wrote —
2206
+ * the ordinary test below (did an agent speak on the row since the user wrote)
1859
2207
  * is one it can never pass. Without this it would be redelivered to the retry
1860
2208
  * cap and she would read the same context table three times.
1861
2209
  */
@@ -1875,7 +2223,7 @@ export class Supervisor {
1875
2223
  catch (e) { console.warn('zero: could not record the settled answer:', e.message); }
1876
2224
  return 'delivered';
1877
2225
  }
1878
- // THE WORD SHE TYPED INTO THE RUNNING SESSION IS NOT SETTLED EITHER, so it
2226
+ // THE WORD THE USER TYPED INTO THE RUNNING SESSION IS NOT SETTLED EITHER, so it
1879
2227
  // must go back in the queue with the one this session was spawned for.
1880
2228
  // Steering it in marks it delivered (live-replies.mjs), and a mark nothing
1881
2229
  // releases is a word no worker will ever be sent to carry.
@@ -1889,7 +2237,10 @@ export class Supervisor {
1889
2237
  // answered read "stopped", which CLAUDE.md says must never mean "and
1890
2238
  // nothing will retry". The account health of the engine is still charged
1891
2239
  // for it, in `noteExitForBackoff`; only the delivery is spared.
1892
- if (signal || session?.stoppedByUs || session?.transportFault) {
2240
+ // AND A FOURTH: the ACCOUNT refused it (signed out, out of usage, switched
2241
+ // off). Her words never reached a worker, so they wait for the account
2242
+ // rather than being spent on it (`accountFault`, noteExitForBackoff).
2243
+ if (signal || session?.stoppedByUs || session?.transportFault || session?.accountFault) {
1893
2244
  this.redeliverAnswer(item, answerAtSpawn); // saves state
1894
2245
  return 'interrupted';
1895
2246
  }
@@ -1906,16 +2257,15 @@ export class Supervisor {
1906
2257
  /**
1907
2258
  * SETTLE THE DELIVERY, AND WHEN WE STOP TRYING TO CARRY HER ANSWER, SAY SO.
1908
2259
  *
1909
- * This is the worst shape of the silence she reported, because she did
1910
- * something first. She answers a row, a session is spawned to carry the
2260
+ * This is the worst shape of silence, because the user did something
2261
+ * first. They answer a row, a session is spawned to carry the
1911
2262
  * answer, it dies, three attempts go by and `settleDelivery` returns 'given
1912
2263
  * up' having written nothing anywhere she can see. The row goes on showing
1913
2264
  * the message she was replying to, so it reads exactly like a row nobody has
1914
2265
  * got to yet, and it reads that way forever.
1915
2266
  *
1916
- * MEASURED on her store 2026-08-28 by scripts/what-her-answers-got-back.mjs:
1917
- * of 556 rows carrying an answer of hers, 2 had a run die after she spoke
1918
- * with nothing said back, 9 dead runs between them.
2267
+ * MEASURED by scripts/what-her-answers-got-back.mjs: it is rare, but it
2268
+ * happens, and a row it happens to has several dead runs and nothing said back.
1919
2269
  *
1920
2270
  * ONLY ON 'given up'. A retry that is still coming may well work, and a
1921
2271
  * sentence written before then would be undone by the next run.
@@ -1989,13 +2339,36 @@ export class Supervisor {
1989
2339
  return seen.runs;
1990
2340
  }
1991
2341
 
2342
+ /**
2343
+ * THE LAST WORDS ON THIS ROW ARE OURS, ABOUT AN ACCOUNT, NOT AN AGENT'S.
2344
+ *
2345
+ * A fresh row whose run died on a signed-out login (or a usage limit) gets
2346
+ * our own sentence written onto it ("Claude Code could not sign in... it
2347
+ * picks straight up"), so she knows. That write is an agent-sourced result,
2348
+ * and `awaitingHer` reads any such result as an agent having answered her,
2349
+ * so the fresh-work pass walked past the row for good: the account came back
2350
+ * and the row never ran, the opposite of what the sentence promised. Proved
2351
+ * with real processes in
2352
+ * tests/a-signed-out-claude-comes-back-on-its-own-end-to-end (no run at all
2353
+ * after signing in, before this). True only when what we said was about the
2354
+ * account and nothing has been written on the row since, so a real answer
2355
+ * still waits for her. When the row may run is still `restingUntil`'s call.
2356
+ */
2357
+ _onlyWeSpokeForAnAccount(item) {
2358
+ const seen = this._fruitless[`${item.product}:${item.id}`];
2359
+ if (!seen?.saidTs || !(needsHerHands(seen.saidWhy) || seen.saidWhy === 'at-limit')) return false;
2360
+ return (item.wrote?.result?.ts ?? 0) <= seen.saidTs;
2361
+ }
2362
+
1992
2363
  restingUntil(item) {
1993
2364
  const seen = this._fruitless[`${item.product}:${item.id}`];
1994
2365
  const runs = this._emptyRuns(item);
1995
2366
  if (!runs) return 0;
1996
- // AN URGENT ROW NEVER CLIMBS THE LADDER: "Urgent tasks are being
1997
- // prioritized below high priority tasks, when urgent tasks are so important
1998
- // they are even meant to INTERRUPT high and below."
2367
+ // Rested because its ACCOUNT refused it: awake as soon as the account is.
2368
+ if (seen?.account && !this._profileResting(seen.account)) return 0;
2369
+ // AN URGENT ROW NEVER CLIMBS THE LADDER. Urgent outranks High, and is
2370
+ // even meant to INTERRUPT High and below, so it must never end up waiting
2371
+ // behind them.
1999
2372
  //
2000
2373
  // Both fixes she already has are about the ORDER of the queue: one scored
2001
2374
  // list, then an Urgent row taking a slot off a lesser session. Neither
@@ -2004,11 +2377,10 @@ export class Supervisor {
2004
2377
  // row is never ranked against anything at all. Priority only ever decided
2005
2378
  // who wins among the awake.
2006
2379
  //
2007
- // Measured on her own state the day she filed it:, Urgent, three empty
2008
- // runs, asleep 14:23 to 18:23;, Urgent, five empty runs, asleep a full
2009
- // twenty-four hours. She wrote the complaint at 14:46, twenty-three minutes
2010
- // into that four-hour sleep, and 58 sessions started on 20 other rows
2011
- // inside it, every one of them High, Medium or Low.
2380
+ // Measured: an Urgent row with three empty runs slept for four hours and
2381
+ // another with five slept a full twenty-four, while dozens of sessions
2382
+ // started on other rows inside that sleep, every one of them High, Medium
2383
+ // or Low.
2012
2384
  //
2013
2385
  // The cap is the FIRST RUNG rather than nothing at all, and it is the same
2014
2386
  // answer this file already gives a spawn that died on arrival: fifteen
@@ -2037,7 +2409,7 @@ export class Supervisor {
2037
2409
  // blocked it, finished it, or drew an answer out of the founder has moved it,
2038
2410
  // and the count is dropped.
2039
2411
  /**
2040
- * PUT THE FAILURE ON THE ROW, IN HER WORDS.
2412
+ * PUT THE FAILURE ON THE ROW, IN PLAIN WORDS.
2041
2413
  *
2042
2414
  * Whether a run needs this said for it is `saidNothingSheCanUse` at the foot
2043
2415
  * of this file, which is the rule that decides between the two writers.
@@ -2071,6 +2443,26 @@ export class Supervisor {
2071
2443
  * says so on the row and parks it with her, because a third try is unlikely
2072
2444
  * to go differently and a silent loop is the thing being fixed.
2073
2445
  */
2446
+ /**
2447
+ * TIME THE MAC SPENT ASLEEP IS NOT SILENCE.
2448
+ *
2449
+ * Both of the hang check's signals are made by a sleep: no output, because
2450
+ * nothing ran, and a lapsed claim, because the heartbeats were frozen too.
2451
+ * So the first pass after the lid opened stopped every worker that had been
2452
+ * waiting on its helpers (the landing page thread, 2026-10-03: asleep 03:36
2453
+ * to 03:52, stopped two seconds after the wake;
2454
+ * tests/a-worker-whose-helpers-are-still-working-does-not-come-back-to-you).
2455
+ *
2456
+ * A pass normally comes every fifteen seconds, so a gap of minutes means the
2457
+ * process was not running. Every worker's quiet clock starts again from
2458
+ * now, which gives a genuinely hung one ten more minutes, not a pass.
2459
+ */
2460
+ forgiveSleep(now = Date.now()) {
2461
+ if (!this._lastTickAt || now - this._lastTickAt < SLEPT_GAP_MS) return false;
2462
+ for (const session of this.sessions.values()) session.lastOutputAt = Math.max(session.lastOutputAt ?? 0, now);
2463
+ return true;
2464
+ }
2465
+
2074
2466
  reapHungSessions(items, now = Date.now()) {
2075
2467
  if (!this.sessions.size) return [];
2076
2468
  const byId = new Map((items ?? []).map((i) => [i.id, i]));
@@ -2146,7 +2538,7 @@ export class Supervisor {
2146
2538
  runs,
2147
2539
  resetsAt,
2148
2540
  });
2149
- this.store.recordSessionResult(item.product, item.id, { result, status: 'open' });
2541
+ const written = this.store.recordSessionResult(item.product, item.id, { result, status: 'open' });
2150
2542
  // REMEMBER THAT WE SAID IT, keyed on the run we said it about. The pass
2151
2543
  // below re-reads every resting row on every tick, and without a
2152
2544
  // watermark it would write the same sentence onto the same row every
@@ -2154,6 +2546,9 @@ export class Supervisor {
2154
2546
  const seen = this._fruitless[`${item.product}:${item.id}`];
2155
2547
  if (seen) {
2156
2548
  seen.saidAt = seen.endedAt ?? Date.now();
2549
+ // The stamp of OUR write, so `_onlyWeSpokeForAnAccount` can tell it
2550
+ // from anything written on the row afterwards.
2551
+ seen.saidTs = written?.wrote?.result?.ts ?? Date.now();
2157
2552
  // AND WHY, WHICH IS THE HALF THE COUNT ABOVE HER LIST READS. Writing
2158
2553
  // the watermark alone told the pass below "this row has been dealt
2159
2554
  // with" and nothing else, so the row went silently uncounted and the
@@ -2180,10 +2575,9 @@ export class Supervisor {
2180
2575
  *
2181
2576
  * That covers exactly one moment and nothing either side of it:
2182
2577
  *
2183
- * - A row that died BEFORE this code existed is never revisited. Four of her
2184
- * Codex rows died for the last time at 13:48 and 13:51 on 2026-08-27, the
2185
- * fix went in at 15:02, and at 15:48 all four still carried not one word.
2186
- * That is the screen she was looking at when she wrote the line above.
2578
+ * - A row that died BEFORE this code existed is never revisited. Codex rows
2579
+ * that died for the last time shortly before the fix went in still carried
2580
+ * not one word an hour after it.
2187
2581
  * - A row whose death was noticed while the app was closing loses it with the
2188
2582
  * process, because the only record was the handler that never finished.
2189
2583
  *
@@ -2222,22 +2616,106 @@ export class Supervisor {
2222
2616
  * binary, a bad exit, a timeout or a reply that is not a name, and `nameItem`
2223
2617
  * treats '' as a no-op. A row that cannot be named keeps her own title, which
2224
2618
  * is what every row in her store had before this existed.
2619
+ *
2620
+ * A MISS WAITS TEN MINUTES. Newest first with no memory of a failure meant
2621
+ * one row that could not be named was picked again every tick, and every
2622
+ * row behind it waited for good (tests/a-row-is-named-on-an-account-that-has-room).
2225
2623
  */
2226
- nameTheRows(items) {
2227
- if (this._naming) return;
2624
+ nameTheRows(items, now = Date.now()) {
2625
+ if (this._naming) return undefined;
2626
+ this._nameMisses ??= new Map();
2627
+ const key = (i) => `${i.product}:${i.id}`;
2228
2628
  const next = items
2229
- .filter((i) => wantsName(i))
2629
+ .filter((i) => wantsName(i) && !(now - (this._nameMisses.get(key(i)) ?? -Infinity) < NAME_RETRY_MS))
2230
2630
  .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
2231
- if (!next) return;
2631
+ if (!next) return undefined;
2232
2632
  this._naming = true;
2233
- nameRow(next, { claudeBin: this.config?.claudeBin })
2633
+ return this._askSmall((opts) => nameRow(next, opts))
2234
2634
  .then((label) => {
2235
- if (label) this.store.nameItem(next.product, next.id, label);
2635
+ if (!label) { this._nameMisses.set(key(next), now); return; }
2636
+ this._nameMisses.delete(key(next));
2637
+ this.store.nameItem(next.product, next.id, label);
2236
2638
  })
2237
2639
  .catch((e) => console.warn('zero: could not name a row:', e.message))
2238
2640
  .finally(() => { this._naming = false; });
2239
2641
  }
2240
2642
 
2643
+ /**
2644
+ * ONE SMALL-MODEL QUESTION, ASKED ON AN ACCOUNT THAT HAS ROOM.
2645
+ *
2646
+ * The namer and the message sorter used to spawn with the app's own
2647
+ * environment, which bills the default Claude account and nothing else. On
2648
+ * 2026-10-06 that account hit its weekly limit; agents moved to the second
2649
+ * account and the namer did not, so every call died in two seconds and no
2650
+ * row was named for a day.
2651
+ *
2652
+ * So every account is tried in turn, the ones not resting first, and the
2653
+ * first answer wins: the home engine's accounts, then the other engine's when
2654
+ * this Mac offers it (`engineChoices`, so a Codex nobody opted into is never
2655
+ * billed). Each is asked once, so a Mac with every account at its limit costs
2656
+ * one call per account and then waits for the next pass. The environment is
2657
+ * a worker's (`_workerEnv`), so no API key from the app's own shell reaches
2658
+ * the call, and the account rides it the way it does on a worker's spawn.
2659
+ */
2660
+ async _askSmall(ask) {
2661
+ const home = this._homeEngine();
2662
+ const other = home === 'codex' ? DEFAULT_ENGINE : 'codex';
2663
+ const engines = [home, ...(this.engineChoices().some((e) => e.id === other) ? [other] : [])];
2664
+ for (const engine of engines) {
2665
+ for (const env of this._smallModelEnvs(engine)) {
2666
+ const answer = await ask({ claudeBin: this.config?.claudeBin, codexBin: this.config?.codexBin, engine, env });
2667
+ if (answer) return answer;
2668
+ }
2669
+ }
2670
+ return '';
2671
+ }
2672
+
2673
+ /** One environment per account on an engine, the accounts not resting first. */
2674
+ _smallModelEnvs(engine) {
2675
+ const env = this._workerEnv(engine);
2676
+ const now = Date.now();
2677
+ const resting = (p) => {
2678
+ const key = this._accountKey(engine, p);
2679
+ return (this._profileCooldown?.[key] ?? 0) >= now || !!this._profileTrouble?.[key];
2680
+ };
2681
+ const all = this._profilesFor(engine);
2682
+ return [...all.filter((p) => !resting(p)), ...all.filter(resting)].map((p) => {
2683
+ if (engine === 'codex') return { ...env, CODEX_HOME: this._codexProfileHome(p) };
2684
+ return p === 'default' ? env : { ...env, CLAUDE_CONFIG_DIR: p };
2685
+ });
2686
+ }
2687
+
2688
+ /**
2689
+ * SORT ONE TEAMMATE'S MESSAGE A TICK by how urgent it is
2690
+ * (main/message-priority.mjs). Paced like the namer above: one call at a
2691
+ * time, newest first, not awaited by the tick. Each message is asked about
2692
+ * once per run of the app, so a write that does not take is not retried
2693
+ * every fifteen seconds.
2694
+ */
2695
+ sortTheMessages(items, me) {
2696
+ if (this._sortingMessages || !me) return undefined;
2697
+ this._sortedMessages ??= new Set();
2698
+ const products = new Map((this.store.listProducts?.() ?? []).map((p) => [p.slug, p]));
2699
+ const next = items
2700
+ .filter((i) => wantsPriority(i, products.get(i.product), me))
2701
+ .map((i) => ({ item: i, latest: latestMessage(i) }))
2702
+ .filter(({ item, latest }) => !this._sortedMessages.has(`${item.product}:${item.id}:${latest.ts}`))
2703
+ .sort((a, b) => b.latest.ts - a.latest.ts)[0];
2704
+ if (!next) return undefined;
2705
+ this._sortingMessages = true;
2706
+ this._sortedMessages.add(`${next.item.product}:${next.item.id}:${next.latest.ts}`);
2707
+ return this._askPriority(next.latest)
2708
+ .then((level) => {
2709
+ if (level) this.store.prioritizeItem(next.item.product, next.item.id, LEVELS[level]);
2710
+ })
2711
+ .catch((e) => console.warn('zero: could not sort a message:', e.message))
2712
+ .finally(() => { this._sortingMessages = false; });
2713
+ }
2714
+
2715
+ _askPriority(latest) {
2716
+ return this._askSmall((opts) => sortMessage(latest, opts));
2717
+ }
2718
+
2241
2719
  sayItOnEveryStrandedRow(items, now = Date.now()) {
2242
2720
  let dirty = false;
2243
2721
  // AND THE SAME PASS COUNTS THEM, for the line above her list. The count
@@ -2313,7 +2791,7 @@ export class Supervisor {
2313
2791
  // (`stderr` in shared/dead-run-trace.mjs is stderr AND the error
2314
2792
  // result), so the hour was there to be read the whole time.
2315
2793
  const resetsAt = cause === 'at-limit' ? limitResetsAt(trace.stderr) : null;
2316
- this.store.recordSessionResult(product, id, {
2794
+ const written = this.store.recordSessionResult(product, id, {
2317
2795
  result: deadRunSentence({
2318
2796
  engineWord: 'Claude Code',
2319
2797
  cause,
@@ -2323,6 +2801,7 @@ export class Supervisor {
2323
2801
  status: 'open',
2324
2802
  });
2325
2803
  seen.saidAt = seen.endedAt ?? now;
2804
+ seen.saidTs = written?.wrote?.result?.ts ?? now;
2326
2805
  seen.saidWhy = cause;
2327
2806
  // KEPT, because the line above the list wants the same hour and the
2328
2807
  // trace it came off is not read again after this pass.
@@ -2380,7 +2859,7 @@ export class Supervisor {
2380
2859
  * on arrival: it never got as far as being a session, so what it proves is
2381
2860
  * that the machine is broken, not that this row has nothing in it.
2382
2861
  */
2383
- noteFreshRun(item, { everRan = true } = {}) {
2862
+ noteFreshRun(item, { everRan = true, account = null } = {}) {
2384
2863
  const key = `${item.product}:${item.id}`;
2385
2864
  // Re-read: `item` is the snapshot taken at spawn, and the whole question is
2386
2865
  // what the session did to the row since.
@@ -2404,16 +2883,21 @@ export class Supervisor {
2404
2883
  // worth less than the first. It is wrong for a worker that never started,
2405
2884
  // because nobody has looked at the row at all.
2406
2885
  //
2407
- // Measured the day this landed: her Codex tasks could not start, and two
2408
- // of them earned five strikes each and went to sleep for twenty-four
2409
- // hours. The engine was fixed at 15:04 and those two rows would still not
2410
- // have been tried again until 13:48 the following afternoon, serving a
2411
- // sentence earned entirely by a bug that no longer exists.
2886
+ // Measured the day this landed: Codex tasks could not start, and two of
2887
+ // them earned five strikes each and went to sleep for twenty-four hours.
2888
+ // Once the engine was fixed, those two rows would still not have been
2889
+ // tried again until the following afternoon, serving a sentence earned
2890
+ // entirely by a bug that no longer exists.
2412
2891
  //
2413
2892
  // The first rung still applies, so a broken engine is not hammered: fifteen
2414
2893
  // minutes between attempts, forever, until it works or she is told.
2415
2894
  if (!everRan) runs = Math.min(runs, 1);
2416
- this._fruitless[key] = { runs, endedAt: Date.now(), founderAt };
2895
+ // NOR A SPAWN ITS ACCOUNT REFUSED. Nobody looked at the row; the account
2896
+ // was out. The rest is remembered against that account and ends the
2897
+ // moment it is back (`restingUntil`), rather than fifteen minutes after
2898
+ // she has signed in again.
2899
+ if (account) runs = Math.min(runs, 1);
2900
+ this._fruitless[key] = { runs, endedAt: Date.now(), founderAt, ...(account ? { account } : {}) };
2417
2901
  this._saveState();
2418
2902
  return 'rested';
2419
2903
  }
@@ -2450,22 +2934,22 @@ export class Supervisor {
2450
2934
  }
2451
2935
 
2452
2936
  /* ----------------------- auth profiles (subscriptions) ------------------ */
2453
- // The founder holds two Claude subscriptions; workers round-robin across
2454
- // them (CLAUDE_CONFIG_DIR per spawn), which roughly doubles throughput. A
2455
- // profile whose spawns keep dying instantly (expired plan, broken login)
2456
- // quarantines itself for 30 minutes, and the fleet behaves as if only the
2457
- // healthy subscription exists; a resubscribe simply starts working again.
2458
- // HERS FIRST, THEN WHATEVER IS SIGNED IN ON THE DISK. It used to be only the
2459
- // config key, and an account she logged into herself could therefore sit
2460
- // unused while the fleet starved on one that was at its limit
2461
- // (main/account-discovery.mjs has the afternoon that cost). One copy of the
2937
+ // A user may hold more than one Claude subscription; workers round-robin
2938
+ // across them (CLAUDE_CONFIG_DIR per spawn), which roughly doubles throughput
2939
+ // with two. A profile whose spawns keep dying instantly (expired plan, broken
2940
+ // login) quarantines itself for 30 minutes, and the fleet behaves as if only
2941
+ // the healthy subscriptions exist; a resubscribe simply starts working again.
2942
+ // THE CONFIGURED ONES FIRST, THEN WHATEVER IS SIGNED IN ON THE DISK. It used
2943
+ // to be only the config key, and an account someone logged into themselves
2944
+ // could therefore sit unused while the fleet starved on one that was at its
2945
+ // limit (main/account-discovery.mjs has what that costs). One copy of the
2462
2946
  // rule, there, so the fleet and the Agents page can never disagree about
2463
2947
  // which accounts exist.
2464
2948
  _profiles() {
2465
2949
  return effectiveProfiles(this.config.authProfiles, { home: this.config.home });
2466
2950
  }
2467
2951
 
2468
- // HER CODEX LOGIN, WHICH IS NOT ONE OF HER CLAUDE ONES.
2952
+ // THE CODEX LOGIN, WHICH IS NOT ONE OF THE CLAUDE ONES.
2469
2953
  //
2470
2954
  // Every entry in `authProfiles` is a CLAUDE_CONFIG_DIR: a folder holding a
2471
2955
  // Claude login and no `auth.json` Codex could ever read. Handing one of them
@@ -2549,8 +3033,8 @@ export class Supervisor {
2549
3033
  // IT IS ALLOWED TO COME BACK EMPTY, and that is the change. It used to end
2550
3034
  // `return live.length ? live: ['default']`, which is right for PICKING --
2551
3035
  // somewhere has to be tried -- and wrong for COUNTING, and this one method
2552
- // was doing both. On her two Claude logins the fallback hid at worst half the
2553
- // truth. On CODEX, which has a single login, it inverted the entire
3036
+ // was doing both. With two Claude logins the fallback hid at worst half the
3037
+ // truth. On a CODEX setup with a single login, it inverted the entire
2554
3038
  // mechanism: quarantining the one account it has put that same account
2555
3039
  // straight back in the pool on the next line, so a login that had run out was
2556
3040
  // struck, cooled down for thirty minutes, and then handed the very next
@@ -2571,7 +3055,7 @@ export class Supervisor {
2571
3055
 
2572
3056
  // ROUND-ROBIN WITHIN ONE ENGINE. The cursor is per engine because the pools
2573
3057
  // are: one shared counter would have a Codex spawn advance the rotation
2574
- // across her two Claude subscriptions, so a run of Codex tasks could leave
3058
+ // across the Claude subscriptions, so a run of Codex tasks could leave
2575
3059
  // one of them doing all the Claude work.
2576
3060
  _pickProfile(engine = DEFAULT_ENGINE) {
2577
3061
  const which = engineOf(engine);
@@ -2582,7 +3066,11 @@ export class Supervisor {
2582
3066
  // every account resting, and a pick of `undefined` there would hand a spawn
2583
3067
  // a CLAUDE_CONFIG_DIR of undefined rather than her default login.
2584
3068
  const live = this._liveProfilesFor(which);
2585
- const pool = live.length ? live : ['default'];
3069
+ // A CODEX ACCOUNT STILL BEING ADDED IS NOT WHERE WORK GOES. Add account
3070
+ // lists the new home before its sign-in finishes, and the round-robin
3071
+ // handed it the very first thread while the signed-in one sat idle.
3072
+ const signedIn = which === 'codex' ? live.filter((p) => this._codexSignedIn(p)) : live;
3073
+ const pool = signedIn.length ? signedIn : live.length ? live : ['default'];
2586
3074
  this._rr = this._rr ?? {};
2587
3075
  this._rr[which] = ((this._rr[which] ?? 0) + 1) % pool.length;
2588
3076
  return pool[this._rr[which]];
@@ -2593,11 +3081,10 @@ export class Supervisor {
2593
3081
  *
2594
3082
  * `profile` is a folder name and BOTH ENGINES HAVE A FOLDER CALLED 'default',
2595
3083
  * so without this a Codex session that died would strike her Claude default
2596
- * subscription and quarantine it for thirty minutes. That is exactly the bug
2597
- * of 2026-08-24 -- a fact about one account applied to another, where one
2598
- * signed-out subscription stopped the one that was working, five times
2599
- * between 17:39 and 19:08 -- and the second engine would have reintroduced it
2600
- * for free.
3084
+ * subscription and quarantine it for thirty minutes. That is exactly an
3085
+ * earlier bug -- a fact about one account applied to another, where one
3086
+ * signed-out subscription stopped the one that was working, again and again
3087
+ * -- and the second engine would have reintroduced it for free.
2601
3088
  *
2602
3089
  * An engine nobody recorded is Claude Code, so every key written before the
2603
3090
  * second engine existed still names the account it always named.
@@ -2626,7 +3113,7 @@ export class Supervisor {
2626
3113
  // WHAT IS WRONG WITH ONE ACCOUNT, remembered until a session on it survives.
2627
3114
  //
2628
3115
  // This exists because the fleet-wide banner was reporting an account-shaped
2629
- // fact as an app-shaped one: her second Claude subscription was signed out,
3116
+ // fact as an app-shaped one: with a second Claude subscription signed out,
2630
3117
  // half of every spawn round-robined onto it and died in two seconds, and the
2631
3118
  // screen said all agents were failing while the other half worked the whole
2632
3119
  // time. A fact about one account now stays on that account.
@@ -2636,6 +3123,12 @@ export class Supervisor {
2636
3123
  this._profileTrouble = this._profileTrouble ?? {};
2637
3124
  const had = this._profileTrouble[profile];
2638
3125
  this._profileTrouble[profile] = { since: had?.since ?? Date.now(), at: Date.now(), cause, raw: String(raw ?? '').slice(0, 300) };
3126
+ // What the login files looked like when it was found out, so that
3127
+ // `_noticeSignIns` can tell a login landing afterwards from one before.
3128
+ if (cause === 'signed-out') {
3129
+ const stamp = signInStamp(this._signInFilesFor(profile));
3130
+ this._profileTrouble[profile].stamp = Math.max(stamp, had?.stamp ?? 0);
3131
+ }
2639
3132
  return cause;
2640
3133
  }
2641
3134
 
@@ -2654,7 +3147,7 @@ export class Supervisor {
2654
3147
  * both engines call their primary login 'default'; `status` exports that book
2655
3148
  * verbatim as `accountTrouble`; and its one reader, the Accounts page in
2656
3149
  * main/settings.mjs, indexes it by a bare Claude folder name and therefore
2657
- * could never match `codex:default`. Her Codex subscription could lapse,
3150
+ * could never match `codex:default`. A Codex subscription could lapse,
2658
3151
  * every Codex task stop, and Settings show a green Claude row and nothing
2659
3152
  * else -- the fleet brake being deliberately Claude-only, so "No agents can
2660
3153
  * start" cannot be true about Codex either.
@@ -2684,23 +3177,23 @@ export class Supervisor {
2684
3177
  // next spawn might well work. A login that has run out is not ambiguous, and
2685
3178
  // the two spawns it takes to reach three strikes are two real pieces of her
2686
3179
  // work handed to an account that cannot do them.
2687
- _strikeProfile(profile, { hard = false } = {}) {
3180
+ _strikeProfile(profile, { hard = false, rest = 30 * 60_000 } = {}) {
2688
3181
  if (!profile) return;
2689
3182
  this._profileStrikes = this._profileStrikes ?? {};
2690
3183
  this._profileCooldown = this._profileCooldown ?? {};
2691
3184
  this._profileStrikes[profile] = (this._profileStrikes[profile] ?? 0) + 1;
2692
3185
  if (hard || this._profileStrikes[profile] >= 3) {
2693
- this._profileCooldown[profile] = Date.now() + 30 * 60_000;
3186
+ this._profileCooldown[profile] = Date.now() + rest;
2694
3187
  this._profileStrikes[profile] = 0;
2695
3188
  }
2696
3189
  }
2697
3190
 
2698
3191
  /*
2699
- * CAPACITY IS PER SUBSCRIPTION, AND SHE HAS TWO KINDS OF SUBSCRIPTION NOW.
3192
+ * CAPACITY IS PER SUBSCRIPTION, AND THERE ARE TWO KINDS OF SUBSCRIPTION NOW.
2700
3193
  *
2701
3194
  * IT DID NOT. This was one number for the whole Mac and it was built out of
2702
3195
  * `_liveProfiles`, which is the CLAUDE auth-profile pool -- so a Codex task
2703
- * queued behind three Claude workers while her OpenAI subscription did
3196
+ * queued behind three Claude workers while the OpenAI subscription did
2704
3197
  * nothing, and logging into Codex could never widen anything, because the
2705
3198
  * multiplier counted Claude logins.
2706
3199
  *
@@ -2719,8 +3212,8 @@ export class Supervisor {
2719
3212
  * words that the binary "is the same fact `_engineFor` refuses on". That was
2720
3213
  * false. `_engineFor` refuses on the capability gate FIRST and the binary
2721
3214
  * second, and so does `engineChoices`. So every Mac with `codex` on PATH and
2722
- * no `engineChoice` moment written -- which is every Mac before she opts in,
2723
- * including hers -- counted three Codex slots that `engineFor` will never
3215
+ * no `engineChoice` moment written -- which is every Mac before the user opts
3216
+ * in -- counted three Codex slots that `engineFor` will never
2724
3217
  * hand a row to, and printed six directly above a row saying three run
2725
3218
  * together.
2726
3219
  *
@@ -2775,8 +3268,41 @@ export class Supervisor {
2775
3268
  * used when nobody has said, which is also the number it already gets on
2776
3269
  * every Mac whose Claude plan is Max or unreadable.
2777
3270
  */
3271
+ /**
3272
+ * THE MACHINE AUTOMATIC SIZES FOR. Empty means this one: `autoAgents` reads
3273
+ * the memory and the cores itself. A test names a machine here, so what it
3274
+ * proves does not change with the computer the suite runs on (GitHub's
3275
+ * runner came out at one agent and turned three claims red, 2026-10-05). */
3276
+ _machine() {
3277
+ return {};
3278
+ }
3279
+
2778
3280
  _slotsPerAccount(engine = DEFAULT_ENGINE) {
2779
3281
  const planSpokeForClaude = engineOf(engine) !== DEFAULT_ENGINE && !!this.config.planSlotsFrom;
3282
+ // AUTOMATIC ASKS THE MACHINE (w-e5225b62ba), and the whole of the change is
3283
+ // where the number comes from: this still answers PER ACCOUNT and
3284
+ // `_capacityFor` still multiplies it by the live accounts, so nothing
3285
+ // downstream of it moved.
3286
+ //
3287
+ // THE DIVISION IS THE POINT. The number used to be per account, so
3288
+ // connecting a second login silently asked the Mac to carry twice as much
3289
+ // while the page still read 3. Automatic answers for the machine and shares
3290
+ // that out, so what the Mac is asked to do does not change when an account
3291
+ // is added.
3292
+ //
3293
+ // A SMALL PLAN STILL WINS. `slotsForPlans` answers one at a time for a
3294
+ // subscription that is not Max; that is a fact about the subscription and
3295
+ // not about this Mac, so Automatic may only ever come out at or under it.
3296
+ if (this.config.agentsAuto && !planSpokeForClaude) {
3297
+ const total = autoAgents({
3298
+ ...this._machine(),
3299
+ gated: !!this.config.memoryGate,
3300
+ nudge: this.config.agentsNudge,
3301
+ });
3302
+ const per = perAccountAgents(total, this._liveProfilesFor(engineOf(engine)).length);
3303
+ const plan = this.config.planSlotsFrom ? Number(this.config.maxConcurrentSessions) : null;
3304
+ return Number.isFinite(plan) && plan >= 1 ? Math.min(Math.floor(plan), per) : per;
3305
+ }
2780
3306
  const set = Number(planSpokeForClaude ? DEFAULT_SESSIONS_AT_ONCE : this.config.maxConcurrentSessions);
2781
3307
  return Number.isFinite(set) && set >= 1 ? Math.floor(set) : 1;
2782
3308
  }
@@ -2792,8 +3318,8 @@ export class Supervisor {
2792
3318
  }
2793
3319
 
2794
3320
  // HOW MANY SESSIONS ONE ENGINE'S CAP IS COUNTING, and the number every spawn
2795
- // decision in this file is measured against. A command she typed is not a
2796
- // worker and is not counted as one -- her words and the measurement are under
3321
+ // decision in this file is measured against. A command the user typed is not a
3322
+ // worker and is not counted as one -- the reason and the measurement are under
2797
3323
  // `_load` below, which is the sum of these. The engine is read off the
2798
3324
  // SESSION, because that is where `_engineFor`'s single answer was written
2799
3325
  // down; reading the row again here would be a second decision.
@@ -2860,15 +3386,14 @@ export class Supervisor {
2860
3386
  * became per subscription. What a spawn is measured against is `_loadFor`
2861
3387
  * against `_capacityFor`, one engine at a time; this is the whole Mac's
2862
3388
  * number, the one `_capacity` is the ceiling for. The rule below is written
2863
- * here because this is where she said it, and `_loadFor` applies the same
2864
- * exclusion for the same reason.
3389
+ * here, and `_loadFor` applies the same exclusion for the same reason.
2865
3390
  *
2866
3391
  * So one of Claude Code's own eight commands is not a worker and is not
2867
3392
  * counted as one. It carries no brief, claims no row, writes no files, and
2868
3393
  * exits as soon as the CLI has printed its table; the runs measured on this
2869
3394
  * row took 2.8, 4.0 and 4.8 seconds. Holding a slot for something that short
2870
3395
  * pushes a real worker's start back for nothing, and queueing it behind a
2871
- * session that has been going twenty minutes is the wait she is describing.
3396
+ * session that has been going twenty minutes is a wait nobody should have.
2872
3397
  *
2873
3398
  * The cap still means what it meant for everything else. It exists because
2874
3399
  * her subscription's rate limit is split between whatever is running, and
@@ -2902,9 +3427,9 @@ export class Supervisor {
2902
3427
  if (session.stoppedByUs) return;
2903
3428
  // A CODEX RUN CANNOT STRIKE A CLAUDE ACCOUNT, and until this slice the only
2904
3429
  // way to be sure of that was to return here: `_noteProfileTrouble` and
2905
- // `_strikeProfile` took `session.profile`, which was one of her Claude
2906
- // logins whatever engine had died on it, so a Codex failure quarantined her
2907
- // working Claude subscription -- the 2026-08-24 shape, where one signed-out
3430
+ // `_strikeProfile` took `session.profile`, which was one of the Claude
3431
+ // logins whatever engine had died on it, so a Codex failure quarantined a
3432
+ // working Claude subscription -- the old shape, where one signed-out
2908
3433
  // account halted the one that was fine.
2909
3434
  //
2910
3435
  // THAT GUARANTEE IS NOW IN `_accountKey` INSTEAD, which is a better place
@@ -2933,17 +3458,24 @@ export class Supervisor {
2933
3458
  // the fleet-wide brake, because `_healthyProfiles` excludes an account
2934
3459
  // carrying trouble and zero healthy accounts is what the brake is for; and
2935
3460
  // three of them would have quarantined a working subscription for half an
2936
- // hour. That is CLAUDE.md's 2026-08-11 entry wearing a new hat: "the agents
2937
- // constantly stop for no apparent reason".
3461
+ // hour. That is the failure CLAUDE.md already records, agents stopping for
3462
+ // no apparent reason, wearing a new hat.
2938
3463
  const quickButReal = session.result != null && !session.resultIsError;
2939
3464
  // A RUN THAT ENDS ON ITS ACCOUNT'S LIMIT SAYS SO WHATEVER ITS LENGTH.
2940
- // Measured 2026-09-24 (w-ca48e69535): her Codex work login hit its weekly
2941
- // limit and every reply after that worked for about two minutes, then
2942
- // failed with "You've hit your usage limit". Two minutes is not a fast exit,
2943
- // so each one fell to the branch below that CLEARS the account's trouble,
2944
- // and the row went straight back to the capped login four times while her
2945
- // other account sat at 0%.
2946
- const limitHit = !!session.resultIsError && troubleCause(session.result) === 'at-limit';
3465
+ // When a Codex login hits its weekly limit, every reply after that works
3466
+ // for about two minutes, then fails with "You've hit your usage limit".
3467
+ // Two minutes is not a fast exit, so each one fell to the branch below
3468
+ // that CLEARS the account's trouble, and with a second account idle the
3469
+ // row still went straight back to the capped login, again and again.
3470
+ //
3471
+ // AND SO DOES A RUN THE ACCOUNT REFUSED IN ANY OTHER WAY (2026-10-04). A
3472
+ // signed-out Codex retries ten times, five over WebSocket and five over
3473
+ // HTTPS, before its turn fails with the 401, so the run can outlast
3474
+ // FAST_EXIT_MS. Past it, the branch below CLEARED the account's trouble:
3475
+ // no bench, no "Signed out" on the row, and her reply was charged one of
3476
+ // its three tries. The error result names its cause whatever the length.
3477
+ const refusedBy = session.resultIsError ? troubleCause(session.result) : null;
3478
+ const limitHit = refusedBy === 'at-limit' || needsHerHands(refusedBy);
2947
3479
  if ((Date.now() - session.startedAt < FAST_EXIT_MS && !quickButReal) || limitHit) {
2948
3480
  // The last words of a fast-dead session are the diagnosis. They are kept,
2949
3481
  // but as EVIDENCE now, not as copy: shared/spawn-trouble.mjs turns them
@@ -2964,8 +3496,17 @@ export class Supervisor {
2964
3496
  this._profileCooldown = this._profileCooldown ?? {};
2965
3497
  this._profileCooldown[account] = resetAt + LIMIT_RESET_GRACE_MS;
2966
3498
  } else {
2967
- this._strikeProfile(account, { hard: needsHerHands(cause) });
3499
+ this._strikeProfile(account, {
3500
+ hard: needsHerHands(cause),
3501
+ rest: cause === 'signed-out' ? SIGNED_OUT_REST_MS : undefined,
3502
+ });
2968
3503
  }
3504
+ // A DEATH THE ACCOUNT CAUSED IS NOT THE ROW'S. Read by `settleDelivery`
3505
+ // (her reply is handed back rather than spending one of its three tries)
3506
+ // and by `noteFreshRun` (the row's rest ends when the account is back).
3507
+ // Measured 2026-10-04: her "continue" on a row, sent while signed out,
3508
+ // died in two seconds and counted against the reply.
3509
+ if (needsHerHands(cause) || cause === 'at-limit') session.accountFault = { cause, account };
2969
3510
 
2970
3511
  // ONLY THE ENGINE SHE IS ACTUALLY RUNNING ON MAY BRAKE THE FLEET.
2971
3512
  //
@@ -2983,10 +3524,9 @@ export class Supervisor {
2983
3524
  // ONLY A FLEET WITH NOWHERE LEFT TO RUN GETS THE FLEET-WIDE BRAKE.
2984
3525
  //
2985
3526
  // This used to fire on every fast exit whatever else was healthy, so one
2986
- // signed-out subscription stopped her working one for a minute, then two,
2987
- // then four, doubling to thirty. Measured on her machine 2026-08-24: five
2988
- // dead spawns on the second account between 17:39 and 19:08, each one
2989
- // halting the account that was working. The per-account cooldown above
3527
+ // signed-out subscription stopped a working one for a minute, then two,
3528
+ // then four, doubling to thirty: every dead spawn on the signed-out
3529
+ // account halted the account that was working. The per-account cooldown above
2990
3530
  // already keeps a bad account out of the rotation; the brake is only for
2991
3531
  // when there is no good account left to be kept out of anything.
2992
3532
  const healthy = this._healthyProfiles().length;
@@ -2994,13 +3534,13 @@ export class Supervisor {
2994
3534
  this._fastExits = (this._fastExits ?? 0) + 1;
2995
3535
  const delay = resetAt
2996
3536
  ? Math.max(0, resetAt + LIMIT_RESET_GRACE_MS - Date.now())
2997
- : Math.min(BACKOFF_BASE_MS * 2 ** (this._fastExits - 1), BACKOFF_CAP_MS);
3537
+ : Math.min(BACKOFF_BASE_MS * 2 ** (this._fastExits - 1), cause === 'signed-out' ? SIGNED_OUT_REST_MS : BACKOFF_CAP_MS);
2998
3538
  this._spawnCooldownUntil = Date.now() + delay;
2999
3539
  this._fleetTroubleSince = this._fleetTroubleSince || Date.now();
3000
3540
  this._lastFastExit = { at: Date.now(), cause, raw };
3001
3541
  onLine(`fast exit #${this._fastExits}; next spawn in ${Math.round(delay / 60000)}m`);
3002
3542
  } else {
3003
- // Her account is fine and it stays fine. Nothing is said on screen and
3543
+ // The working account is fine and it stays fine. Nothing is said on screen and
3004
3544
  // nothing is slowed down; the trace log has the whole story.
3005
3545
  this._fastExits = 0;
3006
3546
  this._spawnCooldownUntil = 0;
@@ -3033,6 +3573,7 @@ export class Supervisor {
3033
3573
  try {
3034
3574
  fs.writeFileSync(this._stateFile, JSON.stringify({
3035
3575
  handledAnswers: [...this._handledAnswers].slice(-500),
3576
+ heardAnswers: Object.fromEntries(Object.entries(this._heardAnswers ?? {}).slice(-500)),
3036
3577
  productOrder: this.productOrder,
3037
3578
  hiddenProducts: [...this.hiddenProducts],
3038
3579
  liveSessions: this._liveSessions,
@@ -3077,12 +3618,24 @@ export class Supervisor {
3077
3618
  return !!this._firstRunUntil && now < this._firstRunUntil;
3078
3619
  }
3079
3620
 
3621
+ /**
3622
+ * A THREAD THEY SENT THEMSELVES ENDS THE HOLD. The walk's own thread carries
3623
+ * FIRST_RUN_LABEL and is answered by the app, so the hold has nothing left
3624
+ * to protect once a real one arrives. Without this, a thread sent while the
3625
+ * walk was still up, or left half finished, read "Queued" for up to twenty
3626
+ * minutes with nothing running anywhere.
3627
+ */
3628
+ sentByThem(labels = []) {
3629
+ if (!this.firstRunHolding()) return;
3630
+ if ((labels ?? []).includes(FIRST_RUN_LABEL)) return;
3631
+ this._firstRunUntil = 0;
3632
+ this.onChange?.();
3633
+ }
3634
+
3080
3635
  /* `pauseProduct` stopped one project and left the rest of the fleet running.
3081
- It is gone (w-d19d6d387c, 2026-09-22). Hers: "I don't know what 'paused'
3082
- means or whether it stops the session. I didn't know that was a feature we
3083
- had, and I can't imagine why it would be used so I'd suggest we get rid of
3084
- that." Nothing had ever used it: `pausedProducts` was absent from her state
3085
- file. Pausing the whole fleet is still a switch in Settings and a row in
3636
+ It is gone (w-d19d6d387c): "paused" did not say whether it stopped the
3637
+ session, nobody knew the feature existed, and nothing had ever used it.
3638
+ Pausing the whole fleet is still a switch in Settings and a row in
3086
3639
  ⌘K, and stopping one task is still S on the row. */
3087
3640
 
3088
3641
  status() {
@@ -3132,12 +3685,22 @@ export class Supervisor {
3132
3685
  try {
3133
3686
  const nowTs = Date.now();
3134
3687
  const autonomous = new Set(this.config.autonomousProducts ?? []);
3688
+ // THE TEAM VERSION: a shared row is queued only on its runner's Mac, and
3689
+ // a row a person holds on nobody's, by the same rule the tick obeys.
3690
+ const productBySlug = new Map((this.store.listProducts?.() ?? []).map((p) => [p.slug, p]));
3691
+ const me = process.env.AGENTBOX_PERSON_ID;
3135
3692
  queued = this.store.listItems(nowTs)
3693
+ .filter((i) => mayRunHere(i, productBySlug.get(i.product), me))
3694
+ // NOTHING RUNS IN THE PRACTICE PROJECT, so nothing there is waiting
3695
+ // its turn. The tick skips these (`noWorkHere`) and `spawnWorker`
3696
+ // refuses them; this list did neither, so a thread sent there read
3697
+ // "Queued. An agent starts on it as soon as one is free" for ever.
3698
+ .filter((i) => productBySlug.get(i.product)?.practice !== true)
3136
3699
  .filter((i) => !this.sessions.has(i.id) && i.status === 'open'
3137
3700
  && this.store.isDue(i, nowTs))
3138
3701
  // The walk's own example task is NOT queued: nothing is going to spawn
3139
- // on it, which is the whole of FIRST_RUN_LABEL. Her report on was a
3140
- // row reading "queued" under a sentence saying it was reading, and
3702
+ // on it, which is the whole of FIRST_RUN_LABEL. The bug was a row
3703
+ // reading "queued" under a sentence saying it was reading, and
3141
3704
  // this list promises exactly the opposite of that.
3142
3705
  .filter((i) => !(i.labels ?? []).includes(FIRST_RUN_LABEL))
3143
3706
  // Leased to someone else, so NOT queued, for the same reason resting is
@@ -3156,8 +3719,19 @@ export class Supervisor {
3156
3719
  .map((i) => i.id);
3157
3720
  } catch {}
3158
3721
  if (this.paused) queued = [];
3722
+ let signInNeeded = {};
3723
+ try {
3724
+ const productBySlug = new Map((this.store.listProducts?.() ?? []).map((p) => [p.slug, p]));
3725
+ const me = process.env.AGENTBOX_PERSON_ID;
3726
+ signInNeeded = this._waitingOnSignIn(this.store.listItems(Date.now())
3727
+ .filter((i) => mayRunHere(i, productBySlug.get(i.product), me)));
3728
+ } catch {}
3159
3729
  return {
3160
3730
  paused: this.paused,
3731
+ // ROWS NOTHING CAN START BECAUSE THEIR TOOL IS SIGNED OUT, by id, with the
3732
+ // tool's name. The row says so instead of "Queued", which promised an
3733
+ // agent "as soon as one is free" while none could be (2026-10-04).
3734
+ signInNeeded,
3161
3735
  running: [...this.sessions.values()].filter(s => !s.remoteIdle).map((s) => ({
3162
3736
  itemId: s.itemId,
3163
3737
  product: s.product,
@@ -3184,9 +3758,12 @@ export class Supervisor {
3184
3758
  })),
3185
3759
  stalled,
3186
3760
  queued,
3761
+ // Waiting tasks she pushed with Run now that have not started yet, so
3762
+ // the task can say it is next rather than merely queued.
3763
+ runNow: [...this._runNow].filter(([id, r]) => !r.started && !this.sessions.has(id)).map(([id]) => id),
3187
3764
  scheduled,
3188
- // ROWS WHOSE LAST RUN ENDED WITHOUT SAYING ANYTHING. Her pick
3189
- // 2026-08-31: "Say it on the row." See `_silentRows`.
3765
+ // ROWS WHOSE LAST RUN ENDED WITHOUT SAYING ANYTHING. The row says so.
3766
+ // See `_silentRows`.
3190
3767
  silent: this._silentRows(),
3191
3768
  productOrder: this.productOrder,
3192
3769
  hiddenProducts: [...this.hiddenProducts],
@@ -3200,8 +3777,8 @@ export class Supervisor {
3200
3777
  // THE ONE LINE ABOVE HER LIST, and only when it is really true.
3201
3778
  //
3202
3779
  // Two facts can fill it and they are asked in this order, because they
3203
- // are not equally likely to be the one she needs. First: are there tasks
3204
- // nothing has been able to run on? That is hers, look four on. Second,
3780
+ // are not equally likely to be the one the user needs. First: are there
3781
+ // tasks nothing has been able to run on? That is theirs to act on. Second,
3205
3782
  // and only when there are none: the older fleet sentence, which says
3206
3783
  // nothing can start ANYWHERE.
3207
3784
  //
@@ -3214,8 +3791,8 @@ export class Supervisor {
3214
3791
  /**
3215
3792
  * A RUN ENDED AND SAID NOTHING, AND NOW THE ROW ADMITS IT.
3216
3793
  *
3217
- * Her pick, 2026-08-31: "Say it on the row. When a run ends having written
3218
- * nothing, the row says so in the same voice the four live words use."
3794
+ * When a run ends having written nothing, the row says so, in the same voice
3795
+ * the four live words use.
3219
3796
  *
3220
3797
  * WHAT THIS IS REPAIRING, and it came from outside.
3221
3798
  *
@@ -3277,8 +3854,8 @@ export class Supervisor {
3277
3854
  * The line it fills already existed and it could never have fired for her.
3278
3855
  * `_spawnTrouble` below wants `_fleetTroubleSince`, which is only ever set
3279
3856
  * when NO Claude account is left healthy, and it wants `sessions.size === 0`.
3280
- * Her Claude agents worked all day while four Codex tasks and four on a
3281
- * signed-out second login sat there, so both gates were open the whole time
3857
+ * Claude agents can work all day while Codex tasks and tasks on a
3858
+ * signed-out second login sit there, so both gates were open the whole time
3282
3859
  * and the app had no way at all to say what she could plainly see. The
3283
3860
  * subject is the tasks now, not the fleet, and a working engine beside a
3284
3861
  * broken one no longer buys the broken one silence.
@@ -3356,6 +3933,30 @@ export class Supervisor {
3356
3933
  // is only ever set when no healthy account remains; twenty unbroken minutes;
3357
3934
  // and something it is actually stopping, so there is work waiting and nothing
3358
3935
  // running.
3936
+ /**
3937
+ * WHICH OPEN ROWS ARE WAITING ON A SIGN-IN, and in which tool. True of a row
3938
+ * when every account its engine could run it on is sitting out and at least
3939
+ * one of them is signed out. One sitting-out account beside a working one is
3940
+ * not this: the work goes to the working one.
3941
+ */
3942
+ _waitingOnSignIn(rows) {
3943
+ const out = {};
3944
+ const blocked = new Map();
3945
+ const isBlocked = (engine) => {
3946
+ if (!blocked.has(engine)) {
3947
+ blocked.set(engine, !this._liveProfilesFor(engine).length
3948
+ && this._profilesFor(engine).some((p) => this._profileTrouble?.[this._accountKey(engine, p)]?.cause === 'signed-out'));
3949
+ }
3950
+ return blocked.get(engine);
3951
+ };
3952
+ for (const i of rows ?? []) {
3953
+ if (i.status !== 'open' || this.sessions.has(i.id)) continue;
3954
+ const engine = engineOf(this._engineFor(i));
3955
+ if (isBlocked(engine)) out[i.id] = engineLabel(engine);
3956
+ }
3957
+ return out;
3958
+ }
3959
+
3359
3960
  _spawnTrouble(queued) {
3360
3961
  const since = this._fleetTroubleSince ?? 0;
3361
3962
  if (!since) return null;
@@ -3384,6 +3985,7 @@ export class Supervisor {
3384
3985
  // fifteen second wait back again for exactly the row that asked for it.
3385
3986
  if (this._ticking) { this._tickAgain = true; return; }
3386
3987
  this._ticking = true;
3988
+ this.forgiveSleep(Date.now());
3387
3989
  this._lastTickAt = Date.now();
3388
3990
  try {
3389
3991
  return await this._tick();
@@ -3398,6 +4000,9 @@ export class Supervisor {
3398
4000
  async _tick() {
3399
4001
  if (this.paused) return;
3400
4002
  if (this.firstRunHolding()) return;
4003
+ // Has a login landed since an account was found signed out? Ahead of the
4004
+ // brake below, which would otherwise hold the fleet past the sign-in.
4005
+ try { this._noticeSignIns(); } catch (e) { console.warn('zero: could not look for a sign-in:', e.message); }
3401
4006
  // A COOLDOWN STOPS THE SPAWNING AND IT MUST NOT STOP THE TELLING. The brake
3402
4007
  // goes on when there is no healthy account left anywhere, for up to half an
3403
4008
  // hour, which is precisely the half hour her tasks are all stuck and the
@@ -3426,6 +4031,8 @@ export class Supervisor {
3426
4031
  // is exactly when no session exit is coming to do this.
3427
4032
  this.parkOnATimer(now);
3428
4033
  const items = this.store.listItems(now);
4034
+ // A task its agent marked ready goes out, one at a time, beside the tick.
4035
+ try { this.shipQueue.tick(items, this.store.listProducts()); } catch (e) { console.warn('zero: the ship queue:', e.message); }
3429
4036
 
3430
4037
  // A session that died without exiting still holds its row. Stopping it
3431
4038
  // here lets the passes below put a fresh worker on the row.
@@ -3441,10 +4048,14 @@ export class Supervisor {
3441
4048
  // And is there a row still wearing the first line of what she dictated?
3442
4049
  // One a tick, never awaited, silent on every failure (nameTheRows above).
3443
4050
  try { this.nameTheRows(items); } catch (e) { console.warn('zero: could not name a row:', e.message); }
4051
+ // And has a teammate written something that needs sorting by urgency?
4052
+ try { this.sortTheMessages(items, process.env.AGENTBOX_PERSON_ID); } catch (e) { console.warn('zero: could not sort a message:', e.message); }
3444
4053
 
3445
4054
  // Before anything is resumed or spawned: has the urgent row we interrupted
3446
4055
  // something FOR actually got its slot? Every hold this clears is a row that
3447
- // becomes resumable again on the passes below, in this same tick.
4056
+ // becomes resumable again on the passes below, in this same tick. A push
4057
+ // whose run has ended is settled first, since it no longer holds anything.
4058
+ this._settleRunNow(items);
3448
4059
  this._settlePreemptions(items, now);
3449
4060
 
3450
4061
  // Interrupted sessions the last sweep could not fit in a slot. Ahead of
@@ -3484,14 +4095,13 @@ export class Supervisor {
3484
4095
  // pass, so an Urgent task could not outrank a Medium answer, ever. It was
3485
4096
  // not a close call either; it was absolute precedence dressed as ordering.
3486
4097
  //
3487
- // Measured on her own store, 2026-08-19/20. She filed an Urgent
3488
- // landing-page task at 22:43:58. Nothing started on it until 00:09:36, 85
3489
- // minutes later. In that window Agentbox started 36 sessions on that one
3490
- // product and every single one was a continuation; 20 of them were Medium.
3491
- // Not one fresh item spawned until the answer backlog ran dry, and then
3492
- // two started in the same second. That is starvation, and it is also why
3493
- // stopping a task did not help her: the slot she freed went straight to
3494
- // the next answer in line.
4098
+ // Measured: an Urgent task waited 85 minutes before anything started on
4099
+ // it. In that window Agentbox started 36 sessions on that one product and
4100
+ // every single one was a continuation; 20 of them were Medium. Not one
4101
+ // fresh item spawned until the answer backlog ran dry, and then two
4102
+ // started in the same second. That is starvation, and it is also why
4103
+ // stopping a task did not help: the freed slot went straight to the next
4104
+ // answer in line.
3495
4105
  //
3496
4106
  // Continuations still WIN A TIE, which keeps the old intent (an answer she
3497
4107
  // has already given beats new work of equal standing) without letting it
@@ -3514,6 +4124,11 @@ export class Supervisor {
3514
4124
  // behind the clause outlives them: blocked means a session stopped until
3515
4125
  // something outside it changed, and her reply IS that change.
3516
4126
  if (i.status !== 'open' && i.status !== 'blocked') return false;
4127
+ // BUT NOT A ROW SHE STOPPED, on the reply she sent before stopping it.
4128
+ // The kill hands that reply back to the queue, and this used to start a
4129
+ // fresh run on it ten seconds after her stop, on a row sitting in her
4130
+ // inbox. Her stop is the newest thing she said; a later reply wakes it.
4131
+ if (stoppedByHer(i)) return false;
3517
4132
  return true;
3518
4133
  };
3519
4134
 
@@ -3543,18 +4158,18 @@ export class Supervisor {
3543
4158
  // the next worker arrives at a question that is already answered, has
3544
4159
  // nothing new to do, and writes the answer a second time.
3545
4160
  //
3546
- // Measured on her store, 2026-08-31: 266 results were written with no
3547
- // reply from her since the previous one, across 119 rows, and 107 of
3548
- // those opened on a character-identical bold line. Five rows were in
3549
- // that loop at the time of measuring, three of them already carrying
3550
- // three answers to one unanswered ask. This very row was one: three
4161
+ // Measured: hundreds of results were written with no reply since the
4162
+ // previous one, many opening on a character-identical bold line, and
4163
+ // several rows were carrying three answers to one unanswered ask: three
3551
4164
  // sessions, three near-identical results, no reply between any of them.
3552
4165
  //
3553
4166
  // She loses nothing by the wait. `rowSummary` already shows a result
3554
4167
  // newer than the body on an OPEN row, so the answer is in her inbox
3555
4168
  // being read; the only thing that stops is writing it again. Any word
3556
4169
  // from her moves `lastFounderWrite` past it and the row is fresh again.
3557
- && !awaitingHer(i)
4170
+ // EXCEPT when the words are OURS, about an account that refused the run
4171
+ // (`_onlyWeSpokeForAnAccount`): those are not an answer to her.
4172
+ && (!awaitingHer(i) || this._onlyWeSpokeForAnAccount(i))
3558
4173
  // A row a worker already took and gave nothing back rests before it is
3559
4174
  // handed to another one. This pass had no memory whatsoever, which is
3560
4175
  // the whole of the every-tick loop (FRUITLESS_RESTS_MS).
@@ -3568,13 +4183,22 @@ export class Supervisor {
3568
4183
  // and cannot be got round by writing a new one. `worksHere` is that rule
3569
4184
  // and it is the same one `spawnWorker` refuses on; this set only saves the
3570
4185
  // queue the work of considering rows it would refuse anyway.
4186
+ const products = this.store.listProducts();
3571
4187
  const noWorkHere = new Set(
3572
- this.store.listProducts().filter((p) => !this.worksHere(p)).map((p) => p.slug),
4188
+ products.filter((p) => !this.worksHere(p)).map((p) => p.slug),
3573
4189
  );
4190
+ // A SHARED PROJECT'S ROW RUNS ON ITS RUNNER'S MAC AND NOWHERE ELSE, and a
4191
+ // row a person has been given runs nowhere until they hand it back. Every
4192
+ // teammate's supervisor sees every shared row; without this each would
4193
+ // start its own worker on it. shared/team-rules.mjs is the rule, and the
4194
+ // window reads the same one to decide whose inbox a row is in.
4195
+ const productBySlug = new Map(products.map((p) => [p.slug, p]));
4196
+ const me = process.env.AGENTBOX_PERSON_ID || null;
3574
4197
 
3575
4198
  const queue = [];
3576
4199
  for (const item of items) {
3577
4200
  if (noWorkHere.has(item.product)) continue;
4201
+ if (!mayRunHere(item, productBySlug.get(item.product), me)) continue;
3578
4202
  if (this.sessions.has(item.id)) continue;
3579
4203
  // Interrupted a moment ago so an Urgent row could have its slot. Putting
3580
4204
  // a worker back on it here — as a stranger, on the fresh-work or
@@ -3616,6 +4240,14 @@ export class Supervisor {
3616
4240
  && this._engineFor(item) === DEFAULT_ENGINE,
3617
4241
  });
3618
4242
  }
4243
+ // THE SLOT AN INTERRUPTION FREED BELONGS TO THE ROW IT WAS FREED FOR.
4244
+ // Without this the slot went to the top of the queue on the next tick,
4245
+ // and the top is often a higher project's row that could never have
4246
+ // interrupted anything itself: the session was paused for nothing and the
4247
+ // Urgent row went on waiting. Rows ranked above it lose nothing by it,
4248
+ // since the session it paused scored below it and held that slot anyway.
4249
+ const heldFor = new Set([...this._preempted.values()].map((r) => `${r.forProduct}:${r.forItem}`));
4250
+ const holds = (row) => (heldFor.has(`${row.item.product}:${row.item.id}`) ? 1 : 0);
3619
4251
  queue.sort((a, b) =>
3620
4252
  // A COMMAND GOES TO THE FRONT, ahead of the priority she set on anything
3621
4253
  // else, because it is not competing with them for anything. It takes no
@@ -3627,6 +4259,12 @@ export class Supervisor {
3627
4259
  // two answered rows on a full fleet was never reached at all. Measured as
3628
4260
  // a failing test before this line existed.
3629
4261
  (b.command ? 1 : 0) - (a.command ? 1 : 0)
4262
+ // THEN WHAT SHE PUSHED WITH RUN NOW, ahead of every project and tag,
4263
+ // the earliest push first. Sorting first is also what gives it this
4264
+ // tick's one interruption on its engine (`spent` below).
4265
+ || (this._runNow.has(b.item.id) ? 1 : 0) - (this._runNow.has(a.item.id) ? 1 : 0)
4266
+ || (this._runNow.get(a.item.id)?.at ?? 0) - (this._runNow.get(b.item.id)?.at ?? 0)
4267
+ || holds(b) - holds(a)
3630
4268
  || this._score(b.item) - this._score(a.item)
3631
4269
  // The tie-break, and the only thing left of "continuations go first".
3632
4270
  || (b.continuation ? 1 : 0) - (a.continuation ? 1 : 0)
@@ -3642,17 +4280,23 @@ export class Supervisor {
3642
4280
  * to break out of the queue entirely. That break was correct while there
3643
4281
  * was one cap -- nothing further down could fit either -- and it is a
3644
4282
  * starvation bug now that there are two: a Codex row sitting behind three
3645
- * unfittable Claude rows would never be reached at all, so her Codex
4283
+ * unfittable Claude rows would never be reached at all, so the Codex
3646
4284
  * subscription would go on doing nothing with a Codex task in the queue.
3647
4285
  * That is the same shape as the bug the slash-command sort-to-front was
3648
4286
  * written for, and the deleted 2026-08-25 build shipped with it.
3649
4287
  *
3650
4288
  * WHAT THE BREAK WAS ALSO DOING, and what this set keeps: at most one
3651
- * session is interrupted per engine per tick. Nothing further down the
3652
- * queue outranks the row we just made room for, so a second preemption on
3653
- * the same engine could only take a slot for a row that is going to wait
3654
- * anyway. An engine goes in here whether or not `_preemptFor` actually
3655
- * took anything, because that is exactly what the break did.
4289
+ * session is interrupted per engine per tick.
4290
+ *
4291
+ * AN ENGINE GOES IN HERE ONLY WHEN SOMETHING WAS ACTUALLY INTERRUPTED. It
4292
+ * used to go in on the first row that did not fit, whatever that row was,
4293
+ * and the first row is usually a higher project's Medium, which may not
4294
+ * interrupt anything. The Urgent row below it, which may, was never
4295
+ * asked: an Astral Video Urgent waited 49 minutes while the Medium in its
4296
+ * own project ran (w-53a72e6e7f,
4297
+ * tests/an-urgent-task-takes-its-own-projects-slot-even-behind-a-higher-project).
4298
+ * The slot that interruption frees is held for the row that made it
4299
+ * (`heldFor` above), so the rows above it do not take it instead.
3656
4300
  */
3657
4301
  const spent = new Set();
3658
4302
 
@@ -3675,7 +4319,7 @@ export class Supervisor {
3675
4319
  // A session already finished on this answer, and said so on the item
3676
4320
  // rather than only in this process's memory. The mark below is the same
3677
4321
  // promise, made durable: without it, every restart and every resume
3678
- // handed a worker a word she said days ago, which it read, recognised as
4322
+ // handed a worker a word the user said days ago, which it read, recognised as
3679
4323
  // done, and exited on inside a minute. Twenty-three times, on one row.
3680
4324
  //
3681
4325
  // A row still leased to someone else would refuse this worker the claim,
@@ -3684,7 +4328,7 @@ export class Supervisor {
3684
4328
  if (continuation
3685
4329
  && (answerSettled(item) || this._answerDelivered(item) || this.claimHeldElsewhere(item))) continue;
3686
4330
  /*
3687
- * A COMMAND SHE TYPED NEVER QUEUES, EVEN WITH EVERY SLOT FULL.
4331
+ * A COMMAND THE USER TYPED NEVER QUEUES, EVEN WITH EVERY SLOT FULL.
3688
4332
  *
3689
4333
  * Asked above the cap, and sorted to the front of the queue above that,
3690
4334
  * because the cap breaks rather than skips. The guards over this line
@@ -3711,10 +4355,7 @@ export class Supervisor {
3711
4355
  // exit before its slot is really free, and an exit nudges a tick, so
3712
4356
  // the ordinary cost of this is seconds rather than the fifteen-second
3713
4357
  // poll.
3714
- if (!spent.has(engine)) {
3715
- spent.add(engine);
3716
- this._preemptFor(item, items, engine);
3717
- }
4358
+ if (!spent.has(engine) && this._preemptFor(item, items, engine)) spent.add(engine);
3718
4359
  // AND ON DOWN THE QUEUE, because the next row may be on the engine
3719
4360
  // that is still idle. See `spent` above for what this used to be.
3720
4361
  continue;
@@ -3730,9 +4371,10 @@ export class Supervisor {
3730
4371
  // next tick: the row we interrupted becomes resumable in the same pass
3731
4372
  // that spawns the row we interrupted it for, and waiting a tick to notice
3732
4373
  // would leave a slot standing empty for fifteen seconds.
4374
+ this._settleRunNow(items);
3733
4375
  this._settlePreemptions(items, now);
3734
4376
 
3735
- // The digest: NOT a schedule, an INVARIANT. (the founder, 2026-08-06). So
4377
+ // The digest: NOT a schedule, an INVARIANT. So
3736
4378
  // nothing fires on a clock. Every tick asks whether the invariant holds
3737
4379
  // and, where it does not, spawns the one session that makes it hold.
3738
4380
  //
@@ -3754,10 +4396,10 @@ export class Supervisor {
3754
4396
  if (!autonomous.has(product.slug)) continue;
3755
4397
  if ([...this.sessions.values()].some((s) => s.product === product.slug)) continue;
3756
4398
  if (now - (this.lastDigestTry[product.slug] ?? 0) < DIGEST_RETRY_MS) continue;
3757
- // A DIGEST IS A CLAUDE CODE SESSION. It carries a synthetic row with no
3758
- // engine on it, so `_engineFor` answers the default and the slot it needs
3759
- // is the default engine's -- never a Codex slot that could not run it.
3760
- if (!this._hasSlotFor(DEFAULT_ENGINE)) break;
4399
+ // A DIGEST RUNS ON THIS MAC'S HOME ENGINE: Claude Code, or Codex on a Mac
4400
+ // with nothing else. It carries a synthetic row with no engine on it, so
4401
+ // the slot it checks and the engine `spawnDigest` forces are one answer.
4402
+ if (!this._hasSlotFor(this._homeEngine())) break;
3761
4403
  const owed = this._digestOwed(items, product.slug, now);
3762
4404
  if (!owed) continue;
3763
4405
  this.lastDigestTry[product.slug] = now;
@@ -3765,14 +4407,13 @@ export class Supervisor {
3765
4407
  this.spawnDigest(product, owed);
3766
4408
  }
3767
4409
 
3768
- // THE DRIVE RAN HERE AND IT IS GONE (w-d19d6d387c, 2026-09-22).
4410
+ // THE DRIVE RAN HERE AND IT IS GONE (w-d19d6d387c).
3769
4411
  // Self-driving meant the system, not the founder, asking "what next?" when
3770
- // a product went quiet, and spending her plan on the answer. Hers, closing
3771
- // it: "we should get rid of the code that self-drives the product
3772
- // entirely. That should never have existed anyway, especially because
3773
- // we're open sourcing it soon." It had been off by default since
3774
- // w-8cb4d3ffc3 ("Yep let's remove the self-driving", 2026-08-19) and the
3775
- // machinery was kept for a paid tier that no longer justifies it.
4412
+ // a product went quiet, and spending the user's plan on the answer. An app
4413
+ // that spends someone's plan on work they did not ask for should not exist,
4414
+ // so the code was removed entirely. It had been off by default since
4415
+ // w-8cb4d3ffc3, and the machinery was kept for a paid tier that no longer
4416
+ // justifies it.
3776
4417
  //
3777
4418
  // So nothing in this app spawns work the founder did not ask for. What
3778
4419
  // spawns now, and the whole list: her own tasks, her answers, the tasks
@@ -3897,7 +4538,7 @@ export class Supervisor {
3897
4538
  'was spent and what went live, if anything. Where nothing happened in',
3898
4539
  'some lane that matters, saying so plainly is worth more than padding.',
3899
4540
  ].join('\n'),
3900
- }, { engine: DEFAULT_ENGINE });
4541
+ }, { engine: this._homeEngine() });
3901
4542
  }
3902
4543
 
3903
4544
  // A founder reply that lands while a session runs must not die with it: the
@@ -3934,13 +4575,13 @@ export class Supervisor {
3934
4575
  // it did. It is here for the two resume paths only: a session belongs to the
3935
4576
  // harness that wrote it, and a thread id handed to the wrong CLI dies on
3936
4577
  // arrival with "No conversation found with session ID".
3937
- spawnPlan(item, product, { continuation = false, resumeSessionId = null, engine = DEFAULT_ENGINE } = {}) {
4578
+ spawnPlan(item, product, { continuation = false, resumeSessionId = null, engine = DEFAULT_ENGINE, shipFailure = null } = {}) {
3938
4579
  // A ROW'S OWN CHAT. Her reply on a row goes back to the session that wrote
3939
4580
  // what she is replying to. Four guards, and each one falls back to the old
3940
4581
  // behaviour rather than to anything worse:
3941
4582
  //
3942
4583
  // `continuation` and a live answer, so this is a spawn that exists
3943
- // BECAUSE she wrote on the row. Fresh work is briefed as it always was.
4584
+ // BECAUSE the user wrote on the row. Fresh work is briefed as it always was.
3944
4585
  //
3945
4586
  // `!resumeSessionId`, so the wake sweep still wins outright. That path
3946
4587
  // knows which session it interrupted and is resuming a run that never
@@ -3965,8 +4606,8 @@ export class Supervisor {
3965
4606
  const resumeIdOnDisk = resumeSessionId ?? rowChat?.sessionId ?? forkFrom?.sessionId ?? null;
3966
4607
  // AND WHICH SUBSCRIPTION IT LIVES ON. A resume is only a resume on the
3967
4608
  // account that started it: the transcript sits under that profile's home
3968
- // and under no other. She holds two Claude logins and spawns round-robin
3969
- // across them, so half of her replies handed `--resume` an id the other
4609
+ // and under no other. With two Claude logins, spawns round-robin across
4610
+ // them, so half of the replies handed `--resume` an id the other
3970
4611
  // home had never heard of. The CLI printed "No conversation found with
3971
4612
  // session ID" and exited in under a second. spawnWorker uses this instead
3972
4613
  // of picking, and null still means pick.
@@ -3993,8 +4634,12 @@ export class Supervisor {
3993
4634
  : (forkFrom ? forkFrom.profile ?? null : (strandedThread ? null : walkedHome));
3994
4635
  // Preserve multiple replies queued before a worker starts, including on a
3995
4636
  // resumed session whose native history has not seen them yet.
4637
+ // A CONVERSATION GOING BACK TO WORK IS NOT TOLD AGAIN WHAT IT ALREADY HEARD
4638
+ // (`_noteHeard`). Only that conversation: a fork or a fresh session needs
4639
+ // every word.
3996
4640
  if (continuation && item.answer && this.store?.readHistory) {
3997
- const answer = queuedReplyText(item, this.store.readHistory(item.product, item.id), (row) => this._answerDelivered(row));
4641
+ const conversation = forkFrom ? null : resumeId;
4642
+ const answer = queuedReplyText(item, this.store.readHistory(item.product, item.id), (row) => this._answerDelivered(row) || this._heardIn(row, conversation));
3998
4643
  if (answer !== item.answer) item = { ...item, answer };
3999
4644
  }
4000
4645
  let prompt;
@@ -4007,13 +4652,22 @@ export class Supervisor {
4007
4652
  // cannot work out for itself is that it has moved.
4008
4653
  else if (forkFrom) prompt = this.forkBrief(item, { product, from: forkFrom.sourceId });
4009
4654
  else prompt = resumeId ? this.resumeBrief(item, { continuation, product }) : this.buildBrief(item, product, { continuation, engine });
4655
+ // A BRANCH THE APP COULD NOT SHIP (main/ship-queue.mjs) goes back to the
4656
+ // session that wrote it, and that is all it is told: it has everything
4657
+ // else already. A fresh session gets its brief first.
4658
+ if (shipFailure) prompt = resumeId || rowChat ? shipFailure : `${prompt}\n\n${shipFailure}`;
4659
+ // WHAT HAPPENS TO WHAT IT LEAVES RUNNING, when that switch is on
4660
+ // (main/leftovers.mjs): the rule, and the programs of this task still
4661
+ // shutting down, which it must not reuse.
4662
+ const leftoverNote = this.leftoverBriefNote(item);
4663
+ if (leftoverNote) prompt = `${prompt}\n\n${leftoverNote}`;
4010
4664
  // A project may override what its own sessions may do. Absent (the normal
4011
4665
  // case) means the workspace default, which is why this is a lookup with no
4012
4666
  // entry rather than a copy of the default written onto every project: a
4013
4667
  // copy is a grant that stops tracking the one place grants are reviewed.
4014
4668
  const override = this.projectSessionArgs(item.product);
4015
4669
  const base = override ?? this.defaultSessionArgs();
4016
- // WHAT SHE SAID THIS ONE REPLY MAY DO, which outranks the project and the
4670
+ // WHAT THE USER SAID THIS ONE REPLY MAY DO, which outranks the project and the
4017
4671
  // workspace for exactly this run.
4018
4672
  //
4019
4673
  // It rewrites only the --permission-mode flag; every other grant in her
@@ -4021,12 +4675,12 @@ export class Supervisor {
4021
4675
  // everything else through untouched. A value that is not one of Claude
4022
4676
  // Code's six is ignored rather than passed to the CLI.
4023
4677
  const oneOff = CLAUDE_MODES.includes(item.answerMode) ? item.answerMode : null;
4024
- // HER CONFIG GOES TO THE CLI AS SHE WROTE IT, and only a one-off rewrites
4678
+ // THE USER'S CONFIG GOES TO THE CLI AS WRITTEN, and only a one-off rewrites
4025
4679
  // it. This was briefly the other way round, running every spawn through
4026
4680
  // buildSessionArgs so that exactly one permission flag always reached the
4027
4681
  // CLI, and that was the wrong trade: the parser cannot know the arity of
4028
4682
  // flags it does not own, so a value that happens to look like a flag gets
4029
- // read as one and its owner is left dangling. Rewriting a config she typed
4683
+ // read as one and its owner is left dangling. Rewriting a config the user typed
4030
4684
  // by hand, on every spawn, to fix a display problem is not worth that.
4031
4685
  //
4032
4686
  // The display problem is fixed at the reading end instead: permissionMode
@@ -4034,10 +4688,10 @@ export class Supervisor {
4034
4688
  // names whatever her flags really add up to. Caught in review, 2026-08-23.
4035
4689
  const grants = oneOff ? buildSessionArgs(base, oneOff) : base;
4036
4690
  /*
4037
- * SHE TYPED ONE OF CLAUDE CODE'S OWN COMMANDS, so the command is the whole
4038
- * prompt and the brief is skipped.
4691
+ * THE USER TYPED ONE OF CLAUDE CODE'S OWN COMMANDS, so the command is the
4692
+ * whole prompt and the brief is skipped.
4039
4693
  *
4040
- * The eight she then picked are in the reply box's slash menu;
4694
+ * The eight supported ones are in the reply box's slash menu;
4041
4695
  * shared/claude-commands.mjs is the list and the reason.
4042
4696
  *
4043
4697
  * IT HAS TO REPLACE THE PROMPT RATHER THAN RIDE IN FRONT OF IT, and that is
@@ -4049,7 +4703,7 @@ export class Supervisor {
4049
4703
  *
4050
4704
  * THREE GUARDS, AND EACH IS A DIFFERENT WAY OF NOT DOING THIS BY ACCIDENT:
4051
4705
  *
4052
- * `continuation` means this spawn exists BECAUSE she wrote on the row.
4706
+ * `continuation` means this spawn exists BECAUSE the user wrote on the row.
4053
4707
  * Without it, `item.answer` sits on the row for good and an ordinary wake
4054
4708
  * three hours later would run /context instead of the work.
4055
4709
  *
@@ -4121,7 +4775,7 @@ export class Supervisor {
4121
4775
  const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--include-partial-messages', ...claudeGrants];
4122
4776
  if (claudeEffort) args.push('--effort', claudeEffort);
4123
4777
  // Her standing instructions, on every session there is: workers, digests,
4124
- // and every resumed turn of a thread that started before she wrote the
4778
+ // and every resumed turn of a thread that started before the user wrote the
4125
4779
  // rule. This is the ONE place a session's arguments are built, which is why
4126
4780
  // they cannot miss a path.
4127
4781
  //
@@ -4138,7 +4792,8 @@ export class Supervisor {
4138
4792
  // belongs: it is the only block here she did not necessarily write, so her
4139
4793
  // standing instructions and her project instructions both sit above it and
4140
4794
  // the framing line inside it says out loud that they win. Emptying the box
4141
- // in settings drops it entirely, which is the whole point of the box.
4795
+ // in settings drops the user's own words; the app's rules stay
4796
+ // (w-3ec9f07978), because the inbox reads every message by them.
4142
4797
  //
4143
4798
  // SINCE w-3dc46f3a67 THIS BLOCK IS BOTH MESSAGE DOCUMENTS, not just the
4144
4799
  // finishing half. The writing rules used to be spliced into the worker
@@ -4148,8 +4803,8 @@ export class Supervisor {
4148
4803
  const project = this.projectInstructions(item.product, product);
4149
4804
  const messages = this.messageRules();
4150
4805
  // ADHD MODE RIDES DIRECTLY UNDER THE MESSAGE RULES, and only while it is on
4151
- // (w-5737fe67cf): "append that text to your content if you have ADHD mode
4152
- // on, and then remove it when you turn it off."
4806
+ // (w-5737fe67cf): its text is appended while the mode is on and removed
4807
+ // when it is turned off.
4153
4808
  const adhd = this.adhdRules();
4154
4809
  // {{name}} and {{Name}} become the app's name here too, on the same rule as
4155
4810
  // buildBrief: these blocks are markdown files that cannot import it, and
@@ -4165,7 +4820,7 @@ export class Supervisor {
4165
4820
  * `command` GOES BACK WITH THE ARGUMENTS, because the exit handler has to
4166
4821
  know what this run was. A command run does not write on the row and is
4167
4822
  not supposed to: its answer is the text in the thread. settleDelivery's
4168
- ordinary test is "did an agent speak on the row since her answer", which
4823
+ ordinary test is "did an agent speak on the row since the answer", which
4169
4824
  a command run fails, and failing it means her reply is delivered again —
4170
4825
  so /context would run three times and she would read the same table
4171
4826
  three times. */
@@ -4209,16 +4864,14 @@ export class Supervisor {
4209
4864
  /**
4210
4865
  * THE ANSWER A COMMAND LEFT, WHILE IT IS STILL THE NEWEST THING ON THE ROW.
4211
4866
  *
4212
- * Hers, 2026-09-23, with a picture of her own pane: "this 'fork text' is still
4213
- * applied, but it shouldn't be there continuously at the bottom, infinitely,
4214
- * even though we forked a while ago." One result per task is published here
4215
- * and the pane draws it at the foot of the thread, and nothing ever took it
4216
- * down. It is saved with the session state, so a restart brought it back too,
4217
- * and her `/fork` answer sat under messages written hours later, reading as
4218
- * though the command had just run again.
4867
+ * A command's answer must not sit at the bottom of the thread forever. One
4868
+ * result per task is published here and the pane draws it at the foot of the
4869
+ * thread, and nothing ever took it down. It is saved with the session state,
4870
+ * so a restart brought it back too, and a `/fork` answer sat under messages
4871
+ * written hours later, reading as though the command had just run again.
4219
4872
  *
4220
4873
  * A PREDICATE RATHER THAN A TIMER, on this codebase's usual rule for anything
4221
- * that expires. The answer belongs to the moment she ran the command, so it
4874
+ * that expires. The answer belongs to the moment the command ran, so it
4222
4875
  * shows until the row has moved on, and the row's own `updatedAt` is what says
4223
4876
  * that. Nothing fires and nothing sweeps, and a restart cannot resurrect it
4224
4877
  * because the comparison is made every time it is read. A command still
@@ -4357,25 +5010,22 @@ export class Supervisor {
4357
5010
  // tests/the-state-page-is-gone.test.mjs has the measurements and keeps it
4358
5011
  // out. Nothing replaced it: a row carries its own history.
4359
5012
 
4360
- // HER RULE ABOUT WHEN WORK GETS ITS OWN ROW had a switch here, read off
4361
- // `subagentRule: false`. It is always on now (w-5737fe67cf, 2026-09-23). Hers:
4362
- // "don't you think this should just be an always-on rule rather than a
4363
- // toggle?" Off only took one paragraph out of the brief, and the paragraph is
4364
- // what keeps one piece of work from reaching her as several rows. A config
4365
- // that still says false is ignored.
5013
+ // THE RULE ABOUT WHEN WORK GETS ITS OWN ROW had a switch here, read off
5014
+ // `subagentRule: false`. It is always on now (w-5737fe67cf), because it is a
5015
+ // rule and not a preference. Off only took one paragraph out of the brief,
5016
+ // and the paragraph is what keeps one piece of work from reaching the user as
5017
+ // several rows. A config that still says false is ignored.
4366
5018
 
4367
- // HOW AGENTS WRITE TO HER: the titles, the opening lines, the questions, and
4368
- // the last message a run sends. ONE document (w-3dc46f3a67, 2026-09-22).
5019
+ // HOW AGENTS WRITE TO THE USER: the titles, the opening lines, the questions,
5020
+ // and the last message a run sends. ONE document (w-3dc46f3a67).
4369
5021
  //
4370
- // IT WAS TWO AND THE SPLIT WAS OURS, NOT HERS. `writing-rules.md` rode inside
4371
- // the worker brief and shaped the messages during a task; `finishing.md` rode
4372
- // in the system prompt and shaped the final one. Hers, shown the five prompts
4373
- // a person could edit: "Those five layers of prompts that the user can adjust
4374
- // might be overkill. Maybe we decide not to expose some of them, or maybe we
4375
- // would best aggregate others." The evidence they were one thing all along is
4376
- // her own rules file, which spends three of its four paragraphs on how agents
4377
- // write to her rather than on what they do. That is what somebody types when
4378
- // they sit down; the seam between the two files is an implementation detail.
5022
+ // IT WAS TWO AND THE SPLIT WAS OURS, NOT THE USER'S. `writing-rules.md` rode
5023
+ // inside the worker brief and shaped the messages during a task;
5024
+ // `finishing.md` rode in the system prompt and shaped the final one. Five
5025
+ // editable layers of prompts was too many, so some were merged. A person's
5026
+ // rules file tends to be mostly about how agents write to them rather than
5027
+ // what agents do. That is what somebody types when they sit down; the seam
5028
+ // between the two files is an implementation detail.
4379
5029
  //
4380
5030
  // WHERE IT RIDES, AND WHY THAT IS THE WHOLE DOCUMENT NOW. The writing half
4381
5031
  // used to be spliced into the worker brief, which is the one thing some runs
@@ -4395,32 +5045,56 @@ export class Supervisor {
4395
5045
  return path.join(this.userDir, 'briefs', 'message-rules.md');
4396
5046
  }
4397
5047
 
5048
+ // OURS AND THEIRS ARE TWO LAYERS, NOT ONE FILE (w-3ec9f07978). The shipped
5049
+ // rules are what the inbox reads every message by: the bold first line the
5050
+ // list clips at, the Options section the picker draws, the last message
5051
+ // being the answer. A person editing those breaks their own inbox, so they
5052
+ // ride from the checkout on every run and are never in the box. The box is
5053
+ // only what the user wrote, and it rides above ours so their words win.
5054
+ shippedMessageRules() {
5055
+ try { return fs.readFileSync(this.messageRulesDefaultFile(), 'utf8').trim(); } catch { return ''; }
5056
+ }
5057
+
4398
5058
  // Read fresh at every spawn, never cached, like everything else she can edit
4399
- // while the fleet is running. An EMPTY file is not a missing one: emptying the
4400
- // box in settings is how she turns this off, so it means no message rules at
4401
- // all rather than "fall back to the shipped ones".
5059
+ // while the fleet is running. Emptying the box takes the user's words out,
5060
+ // never ours.
4402
5061
  messageRules() {
4403
- let text = null;
4404
- try { text = fs.readFileSync(this.messageRulesFile(), 'utf8'); } catch {}
4405
- if (text === null && this.messageRulesFile() !== this.messageRulesDefaultFile()) {
4406
- try { text = fs.readFileSync(this.messageRulesDefaultFile(), 'utf8'); } catch {}
5062
+ let theirs = '';
5063
+ if (this.messageRulesFile() !== this.messageRulesDefaultFile()) {
5064
+ try { theirs = fs.readFileSync(this.messageRulesFile(), 'utf8').trim(); } catch {}
4407
5065
  }
4408
- if (text === null) return null;
4409
- const body = text.trim();
4410
- if (!body) return null;
4411
- // ONE framing line, for the same reason her project instructions carry one:
4412
- // a session has no other way to know that these particular words are the
4413
- // app's defaults rather than the founder's own, or that anything above them
5066
+ const ours = this.shippedMessageRules();
5067
+ // ONE framing line each, for the same reason her project instructions carry
5068
+ // one: a session has no other way to know whose words these are, or which
4414
5069
  // wins where the two disagree.
4415
- return [
5070
+ const blocks = [];
5071
+ if (theirs) blocks.push([
5072
+ 'How the person reading this wants agents to write to them, in their own',
5073
+ 'words. Where these and the app\'s rules below disagree, these win.',
5074
+ '',
5075
+ '---',
5076
+ '',
5077
+ theirs,
5078
+ ].join('\n'));
5079
+ // THE APP'S KEYS RIDE WITH OUR RULES (w-7ec8553e23). An agent told the user
5080
+ // to "Press S" to schedule three days after S became the summary, going by
5081
+ // a memory its own session saved before the key moved. They ride inside
5082
+ // this block, never alone, so a run the app hands no rules still gets no
5083
+ // flag at all.
5084
+ if (ours) blocks.push([
4416
5085
  'How to write to the person reading this, and how to finish, from the app',
4417
5086
  'they read it in. These are its defaults, so any instruction above this one',
4418
5087
  'outranks them.',
4419
5088
  '',
4420
5089
  '---',
4421
5090
  '',
4422
- body,
4423
- ].join('\n');
5091
+ ours,
5092
+ '',
5093
+ '---',
5094
+ '',
5095
+ keysForAgents(),
5096
+ ].join('\n'));
5097
+ return blocks.length ? blocks.join('\n\n') : null;
4424
5098
  }
4425
5099
 
4426
5100
  // ADHD MODE (w-5737fe67cf, 2026-09-25): a short set of writing rules that ride
@@ -4439,16 +5113,11 @@ export class Supervisor {
4439
5113
  return ['ADHD mode is on. Follow these rules as well.', '', body].join('\n');
4440
5114
  }
4441
5115
 
4442
- // What the settings box shows: her copy if she has one, otherwise the shipped
4443
- // text, so the box opens filled in on a machine she has never edited it on.
5116
+ // What the settings box shows: the user's own words and nothing of ours
5117
+ // (w-3ec9f07978), so it opens empty until they write something.
4444
5118
  readMessageRules() {
4445
5119
  try {
4446
5120
  return fs.readFileSync(this.messageRulesFile(), 'utf8');
4447
- } catch (err) {
4448
- if (err.code !== 'ENOENT') throw err;
4449
- }
4450
- try {
4451
- return fs.readFileSync(this.messageRulesDefaultFile(), 'utf8');
4452
5121
  } catch (err) {
4453
5122
  if (err.code === 'ENOENT') return '';
4454
5123
  throw err;
@@ -4575,7 +5244,7 @@ export class Supervisor {
4575
5244
  : this.config;
4576
5245
  const ranOn = engineFor(item, {
4577
5246
  config,
4578
- found: { codex: !!this.config.codexBin },
5247
+ found: this._enginesFound(),
4579
5248
  // THE OPT-IN, AND THE ONLY EXPRESSION IN THE APP THAT PRODUCES THE TOKEN.
4580
5249
  // Derived from the same value the staleness rule reads, so the permission
4581
5250
  // and the moment cannot come apart: there is no way to open the gate
@@ -4617,8 +5286,33 @@ export class Supervisor {
4617
5286
  * no second boolean anywhere that could disagree with it.
4618
5287
  */
4619
5288
  engineChoices() {
5289
+ // A Mac with only Codex is offered Codex whether or not the gate is open:
5290
+ // one engine is not a choice, so there is nothing for the gate to hold.
5291
+ if (this._homeEngine() !== DEFAULT_ENGINE) return availableEngines(this._enginesFound());
4620
5292
  if (!this.engineChoiceOpened()) return availableEngines();
4621
- return availableEngines({ codex: !!this.config.codexBin });
5293
+ return availableEngines(this._enginesFound());
5294
+ }
5295
+
5296
+ /**
5297
+ * WHICH CODING AGENTS ARE ON THIS MAC, as the shape `shared/engines.mjs`
5298
+ * reads. `claude` is false only when the search said it is not here
5299
+ * (main/config.mjs `claudeFound`); a config that never said leaves it true,
5300
+ * which is what it always meant. */
5301
+ _enginesFound() {
5302
+ const codex = !!this.config.codexBin;
5303
+ // A CLAUDE CODE NOBODY IS SIGNED INTO, BESIDE A CODEX SOMEBODY IS, COUNTS
5304
+ // AS NOT HERE (w-d5d632e503). The Claude app keeps a copy of Claude Code
5305
+ // whether or not its owner pays for it, so a person who only uses Codex
5306
+ // had every task sent to a Claude Code that could not run. Both answers
5307
+ // come from the sign-in check (main/engine-setup.mjs); until it has
5308
+ // answered, nothing moves.
5309
+ const claudeSignedOut = this.config.claudeSignedIn === false && codex && this.config.codexSignedIn === true;
5310
+ return { claude: this.config.claudeFound !== false && !claudeSignedOut, codex };
5311
+ }
5312
+
5313
+ /** What runs when nobody chose: Claude Code, or Codex on a Mac without it. */
5314
+ _homeEngine() {
5315
+ return homeEngine(this._enginesFound());
4622
5316
  }
4623
5317
 
4624
5318
  /**
@@ -4854,7 +5548,7 @@ export class Supervisor {
4854
5548
  * "before EVERY spawn", with "never pass an API key into a worker's env"
4855
5549
  * beside it, so a Codex worker is not handed her `ANTHROPIC_API_KEY` either.
4856
5550
  * The two halves are not the same kind of claim: the Anthropic one is a rule
4857
- * she has already ruled on, and the OpenAI one was added by the Codex slice
5551
+ * that is already settled, and the OpenAI one was added by the Codex slice
4858
5552
  * and never asked for. Symmetry is not worth weakening the older of the two.
4859
5553
  *
4860
5554
  * The branch is over the engine the spawn is FOR, which is the one fact this
@@ -5008,8 +5702,8 @@ export class Supervisor {
5008
5702
  *
5009
5703
  * STILL NEVER TRANSLATED, and that half is unchanged. There is no honest map
5010
5704
  * from "opus" to a Codex slug: which model stands in for which is a judgement
5011
- * about cost and capability that is hers, and guessing it would run her work
5012
- * on a model she did not pick.
5705
+ * about cost and capability that belongs to the user, and guessing it would
5706
+ * run their work on a model they did not pick.
5013
5707
  *
5014
5708
  * AND RUNNING ON CODEX'S DEFAULT IS THAT SAME GUESS. That is the whole
5015
5709
  * argument for throwing rather than dropping the word and carrying on. A run
@@ -5017,8 +5711,8 @@ export class Supervisor {
5017
5711
  * worker's stderr only becomes a sentence on her row through `sayTheRunDied`,
5018
5712
  * which fires on a run that DIED, so on the ordinary successful run the
5019
5713
  * notice would exist only in a trace file nobody opens. CLAUDE.md's rule is
5020
- * that when the system swallows something she said, she cannot tell it from
5021
- * the work not happening. Stopping is the only outcome she can see.
5714
+ * that when the system swallows something the user said, they cannot tell it
5715
+ * from the work not happening. Stopping is the only outcome they can see.
5022
5716
  *
5023
5717
  * A THROW HERE COSTS THE ROW NOTHING. `threadParams` is called inside
5024
5718
  * `createCodexWorker`'s promise chain, so this lands in the catch at the end
@@ -5255,6 +5949,7 @@ export class Supervisor {
5255
5949
  env: {
5256
5950
  STORE_ACCOUNT_ID: this.config.accountId,
5257
5951
  ...storeRootEnv(this.config.storeRoot),
5952
+ ...teamPersonEnv(),
5258
5953
  ZERO_PRODUCT: item.product,
5259
5954
  ZERO_ITEM: item.id,
5260
5955
  },
@@ -5294,8 +5989,16 @@ export class Supervisor {
5294
5989
  if (live && !live.client.isClosed()) return live;
5295
5990
  const client = createCodexAppServer({
5296
5991
  spawn: () => spawn(this.config.codexBin, ['app-server'], {
5297
- cwd: this.appDir,
5298
- env: { ...this._workerEnv('codex'), CODEX_HOME: home },
5992
+ // HER HOME, NEVER THE APP'S OWN FOLDER. In an installed build that is
5993
+ // `app.asar`, a file, and Node throws `spawn ENOTDIR` for a cwd that is
5994
+ // not a folder: every Codex thread on every downloaded copy sat at
5995
+ // Queued (2026-10-06). Each thread names its own cwd at thread/start,
5996
+ // so this one is only somewhere real to stand.
5997
+ cwd: this.config.home || os.homedir(),
5998
+ // The socket its threads' commands ask on, and no row: see
5999
+ // `codexMemoryGateEnv`. One app-server is every Codex thread of this
6000
+ // login, so a row named here would be the wrong row for all but one.
6001
+ env: { ...this._workerEnv('codex'), CODEX_HOME: home, ...this.codexMemoryGateEnv() },
5299
6002
  stdio: ['pipe', 'pipe', 'pipe'],
5300
6003
  }),
5301
6004
  // WHAT IS LEFT OF THIS LOGIN'S LIMIT, ARRIVING UNBIDDEN. `onNotification`
@@ -5315,6 +6018,24 @@ export class Supervisor {
5315
6018
  handshake.catch(() => { /* every worker chains its own catch off this */ });
5316
6019
  const entry = { client, handshake, home };
5317
6020
  this._codexServers.set(home, entry);
6021
+ // AND THE MEMORY HOOK IS TRUSTED HERE, ON THE WAY UP (w-e5225b62ba).
6022
+ //
6023
+ // It has to happen on a server that is starting anyway. Codex will not run an
6024
+ // untrusted hook, and the trust needs `hooks/list` to report the hash Codex
6025
+ // computed -- which cannot be worked out locally, it is not a hash of the
6026
+ // command text. So the one place that has both an app-server and no reason to
6027
+ // start one is right here.
6028
+ //
6029
+ // The alternative was for the switch to start an app-server itself just to
6030
+ // ask, and on the Mac this was written on that is a few hundred megabytes
6031
+ // spent to protect against spending memory. Chained off the handshake, it
6032
+ // costs two calls on a process that was being started regardless, and it runs
6033
+ // before any thread of this server exists.
6034
+ if (this.config.memoryGate) {
6035
+ handshake
6036
+ .then(() => this.setCodexMemoryGate(true, home, entry))
6037
+ .catch((e) => console.warn(`zero: codex memory gate: ${e.message}`));
6038
+ }
5318
6039
  return entry;
5319
6040
  }
5320
6041
 
@@ -5341,8 +6062,8 @@ export class Supervisor {
5341
6062
  * WHOSE LIMIT THE CARD IS ABOUT: the login she picked, else her primary one.
5342
6063
  *
5343
6064
  * This used to be `_codexHome` outright, which is always the primary login.
5344
- * The pick moved her work onto a second account and left the figure behind,
5345
- * so she switched off an account at 99% and the card still said 99%
6065
+ * The pick moved the work onto a second account and left the figure behind,
6066
+ * so switching off an account at 99% left the card still saying 99%
5346
6067
  * (w-cad7e3e509). With no pick every login runs, and the primary one stays
5347
6068
  * the one the card speaks for, as before.
5348
6069
  */
@@ -5467,7 +6188,7 @@ export class Supervisor {
5467
6188
  this._codexServers?.clear();
5468
6189
  }
5469
6190
 
5470
- spawnWorker(item, { continuation = false, resumeSessionId = null, profile: forcedProfile = null, engine: forcedEngine = null, remoteOnly = false } = {}) {
6191
+ spawnWorker(item, { continuation = false, resumeSessionId = null, profile: forcedProfile = null, engine: forcedEngine = null, remoteOnly = false, shipFailure = null } = {}) {
5471
6192
  if (this._compactionJobs?.has(JSON.stringify([item.product, item.id]))) return;
5472
6193
  // ASKED ONCE, HERE, AND ANSWERED CLAUDE CODE ON EVERY MACHINE TODAY. See
5473
6194
  // `_engineFor` for the two independent reasons why. It is asked ahead of
@@ -5490,20 +6211,53 @@ export class Supervisor {
5490
6211
  // and check a Claude Code slot; this is how that one answer reaches the
5491
6212
  // spawn instead of being derived again down here and disagreeing.
5492
6213
  const engine = forcedEngine ? engineOf(forcedEngine) : this._engineFor(item);
6214
+ // THIS TASK'S LEFTOVERS WERE ASKED TO STOP SECONDS AGO (main/leftovers.mjs)
6215
+ // and may still be on their way out, so the agent waits out the rest of
6216
+ // those 30 seconds rather than finding a dying server and reusing it. It
6217
+ // waits as a pending entry, the same one a folder being built uses, so it
6218
+ // counts against its slot, Stop cancels it, and a quit puts its reply back
6219
+ // in line (Codex's review, 2026-10-05). The latest call's options win.
6220
+ const hold = this._leftoverCleaner?.holdMs?.(item.id) ?? 0;
6221
+ if (hold > 0) {
6222
+ this._preparing ??= new Map();
6223
+ const opts = { continuation, resumeSessionId, profile: forcedProfile, engine: forcedEngine, remoteOnly, shipFailure };
6224
+ const held = this._preparing.get(item.id);
6225
+ if (held) { held.item = item; held.opts = opts; return; }
6226
+ const entry = { item, opts, engine };
6227
+ this._preparing.set(item.id, entry);
6228
+ const t = setTimeout(() => {
6229
+ if (this._preparing?.get(item.id) !== entry) return; // stopped, or the app quit
6230
+ this._preparing.delete(item.id);
6231
+ if (this.sessions.has(item.id)) return;
6232
+ try { this.spawnWorker(entry.item, entry.opts); }
6233
+ catch (error) { console.warn(`zero: could not start ${item.id}:`, error.message); }
6234
+ this.onChange?.();
6235
+ }, hold + 100);
6236
+ t.unref?.();
6237
+ return;
6238
+ }
5493
6239
  // A ROW WHOSE MODEL BELONGS TO THE OTHER HARNESS DOES NOT RUN, AND SAYS SO.
5494
6240
  // Asked here rather than inside `spawnPlan` because the answer is not a
5495
6241
  // narrower plan, it is no run at all -- and asked before the slot check and
5496
6242
  // before the product lookup, so a refusal costs the fleet nothing.
5497
6243
  if (item?.model && !modelForEngine(item, engine)) {
5498
6244
  this._sayTheModelIsForAnotherEngine(item, engine);
5499
- // HER WORDS ARE NOT DELIVERED BY A WORKER THAT NEVER STARTED. The queue
6245
+ // THE USER'S WORDS ARE NOT DELIVERED BY A WORKER THAT NEVER STARTED. The queue
5500
6246
  // writes the delivery mark before it calls this method and that mark is
5501
- // persisted, so leaving it standing would record her reply as handed over
6247
+ // persisted, so leaving it standing would record the reply as handed over
5502
6248
  // for good. `redeliverAnswer` is the sanctioned way back.
5503
6249
  if (continuation && item.answer) this.redeliverAnswer(item, item.answer);
5504
6250
  return;
5505
6251
  }
5506
6252
  if (!this._hasSlotFor(engine) && !continuation) return;
6253
+ // HER REPLY WAITS FOR AN ACCOUNT THAT CAN CARRY IT. A continuation skips the
6254
+ // slot check by design, so with every account on this engine sitting out it
6255
+ // would go to one anyway, die in two seconds, and go again next tick. Held
6256
+ // here and handed back, it goes out the tick the account returns.
6257
+ if (continuation && !this._liveProfilesFor(engine).length) {
6258
+ if (item.answer) this.redeliverAnswer(item, item.answer);
6259
+ return;
6260
+ }
5507
6261
  const product = this.store.listProducts().find((p) => p.slug === item.product);
5508
6262
  if (!product) return;
5509
6263
  // AND THIS IS WHERE "nothing ever runs in the practice project" IS ACTUALLY
@@ -5519,8 +6273,9 @@ export class Supervisor {
5519
6273
  return;
5520
6274
  }
5521
6275
  if (this._folderFirst(item, product, engine, { continuation, resumeSessionId, profile: forcedProfile, engine: forcedEngine, remoteOnly })) return;
6276
+ if (this._photoFirst(item, product, engine, { continuation, resumeSessionId, profile: forcedProfile, engine: forcedEngine, remoteOnly })) return;
5522
6277
 
5523
- const plan = this.spawnPlan(item, product, { continuation, resumeSessionId, engine });
6278
+ const plan = this.spawnPlan(item, product, { continuation, resumeSessionId, engine, shipFailure });
5524
6279
  const { args } = plan;
5525
6280
  // ONE RUN, ONE PAIR OF FILES. The MCP config and the settings used to be a
5526
6281
  // fixed name each in the temp folder, fine while every spawn wrote the
@@ -5545,9 +6300,9 @@ export class Supervisor {
5545
6300
  // round-robin pick here would hand `--resume` an id the CLI cannot find and
5546
6301
  // the session would die on arrival.
5547
6302
  //
5548
- // THAT WAS TRUE OF THE WAKE SWEEP ONLY, AND IT IS THE BUG SHE FILED.
6303
+ // THAT WAS TRUE OF THE WAKE SWEEP ONLY, AND THAT WAS THE BUG.
5549
6304
  // `forcedProfile` is passed by _resumeInterrupted and by nothing else, so
5550
- // the one path that resumes on every reply she writes, a personal
6305
+ // the one path that resumes on every reply the user writes, a personal
5551
6306
  // continuation, fell through to the pick and lost the account half of the
5552
6307
  // time. plan.resumeProfile is that same answer for that path.
5553
6308
  const profile = forcedProfile ?? plan.resumeProfile ?? this._pickProfile(engine);
@@ -5566,6 +6321,11 @@ export class Supervisor {
5566
6321
  // symlinks made into a Claude home on behalf of a session that will never
5567
6322
  // open them.
5568
6323
  const tooling = engine === 'codex' ? null : linkAccountTooling(profile);
6324
+ // A RUN IS STARTING ON THIS ROW, said before the worker exists and in the
6325
+ // same turn of the event loop as the spawn, so the cleaner can never stop
6326
+ // this task's programs between its last check and this agent arriving
6327
+ // (main/leftovers.mjs, agreed with Codex).
6328
+ this._leftoverCleaner?.starting(item.id);
5569
6329
  const env = this._workerEnv(engine);
5570
6330
  const child = engine === 'codex'
5571
6331
  ? this._spawnCodexWorker(plan, { cwd, item, profile })
@@ -5584,6 +6344,8 @@ export class Supervisor {
5584
6344
  // So approval cards can say who is asking and about what.
5585
6345
  ZERO_PRODUCT: item.product,
5586
6346
  ZERO_ITEM: item.id,
6347
+ // Where its shell commands ask about memory, when that is on.
6348
+ ...this.memoryGateEnv(item),
5587
6349
  },
5588
6350
  stdio: ['pipe', 'pipe', 'pipe'],
5589
6351
  });
@@ -5609,6 +6371,11 @@ export class Supervisor {
5609
6371
  spawnFiles,
5610
6372
  };
5611
6373
  this.sessions.set(item.id, session);
6374
+ // THAT an agent started work, and on which engine; nothing about the task.
6375
+ // `count` is set by main.mjs and is absent wherever nothing is sent.
6376
+ this.count?.('run_started', { engine });
6377
+ // Its worker, so a run left "running" by a crash can later be told ended.
6378
+ if (Number.isInteger(child?.pid)) this._leftoverCleaner?.worker(item.id, child.pid);
5612
6379
  if (engine !== 'codex') {
5613
6380
  attachClaudeInput(child);
5614
6381
  if (remoteOnly) { child.holdInput(true); session.remoteIdle = true; session.remoteHeld = true; }
@@ -5646,23 +6413,24 @@ export class Supervisor {
5646
6413
  //
5647
6414
  // Never allowed to stop a spawn. A product with no repository gets null
5648
6415
  // and the conversation carries the card exactly as it did before.
5649
- try { session.repoBefore = snapshotRepo(cwd); } catch { session.repoBefore = null; }
6416
+ // Taken off the main thread a moment ago by `_photoFirst`, before the
6417
+ // process existed; read here inline only when that step did not run.
6418
+ if (this._photos?.has(item.id)) session.repoBefore = this._photos.get(item.id);
6419
+ else { try { session.repoBefore = snapshotRepo(cwd); } catch { session.repoBefore = null; } }
5650
6420
 
5651
6421
  // A MODE BELONGS TO THE THREAD, NOT TO ONE SEND, AND THIS IS WHERE THAT
5652
6422
  // CHANGED (w-34b7b861b6, 2026-09-24).
5653
6423
  //
5654
6424
  // What stood here spent the mode the moment the child process came up:
5655
6425
  // `clearAnswerMode` on the `spawn` event, so the very next run on the same
5656
- // row went out in the fleet's mode again. She put one agent in Manual to
5657
- // test it, got the approval cards she expected out of that first run, and
5658
- // then found every in-progress row's footer reading "In Auto Mode" -- which
5659
- // was not a display bug. The grant really was gone by the time she looked.
5660
- // Her words: "It should change for the whole conversation. If you switch to
5661
- // manual mode, every task after that, until you switch it back for that
5662
- // thread, should then be in manual mode."
6426
+ // row went out in the fleet's mode again. An agent put in Manual produced
6427
+ // the expected approval cards on its first run, and then every in-progress
6428
+ // row's footer read "In Auto Mode" -- which was not a display bug. The grant
6429
+ // really was gone. A mode chosen for a thread should hold for every run in
6430
+ // that thread until it is switched back.
5663
6431
  //
5664
- // So nothing is spent here any more. The grant sits on the row until she
5665
- // moves it or clears it, `answerModes()` keeps returning it, and the reply
6432
+ // So nothing is spent here any more. The grant sits on the row until the
6433
+ // user moves it or clears it, `answerModes()` keeps returning it, and the reply
5666
6434
  // box re-seeds from it on every open, which is what makes the footer tell
5667
6435
  // the truth on run two.
5668
6436
  //
@@ -5670,16 +6438,16 @@ export class Supervisor {
5670
6438
  // still NOT on the ledger. It lives in the app's own storage, written by
5671
6439
  // main and by nothing else (main/answer-modes.mjs), because a ledger line
5672
6440
  // names its own author as a plain string and nothing checks the string: a
5673
- // worker could once have appended one line claiming to be her and handed
5674
- // its own next run bypassPermissions. Only she grants one, through the
5675
- // reply box. What this commit widens is the BLAST RADIUS of a grant she
5676
- // makes deliberately, which is the thing she asked for; it does not widen
5677
- // who can make one.
6441
+ // worker could once have appended one line claiming to be the user and
6442
+ // handed its own next run bypassPermissions. Only the user grants one,
6443
+ // through the reply box. What this commit widens is the BLAST RADIUS of a
6444
+ // grant made deliberately, which is the point; it does not widen who can
6445
+ // make one.
5678
6446
  //
5679
6447
  // What is left here is a touch rather than a clear: it restamps the grant's
5680
6448
  // clock so the month-long sweep counts from this run instead of from the
5681
- // moment she picked the mode, and a thread she is working in therefore
5682
- // keeps its mode for as long as she works in it. Still on the child's own
6449
+ // moment the mode was picked, and a thread being worked in therefore keeps
6450
+ // its mode for as long as it is worked in. Still on the child's own
5683
6451
  // `spawn` event, and the reason is the reason the clear was there: `spawn`
5684
6452
  // is the only signal the process exists. `spawn()` returns a ChildProcess
5685
6453
  // before an ENOENT arrives asynchronously on 'error'.
@@ -5767,6 +6535,12 @@ export class Supervisor {
5767
6535
  const absorb = (...frame) => {
5768
6536
  session.lastOutputAt = Date.now();
5769
6537
  capture(session, ...frame);
6538
+ // The conversation is up, so the words it was started with are in it
6539
+ // (`_noteHeard`). Here rather than at exit: an app quit may not wait for one.
6540
+ if (continuation && item.answer && session.sessionId && !session.heardNoted) {
6541
+ session.heardNoted = true;
6542
+ this._noteHeard(item, session.sessionId);
6543
+ }
5770
6544
  if (activity(session, ...frame)) this.onChange?.();
5771
6545
  if (session.remoteHeld && engine === DEFAULT_ENGINE) {
5772
6546
  try {
@@ -5777,7 +6551,9 @@ export class Supervisor {
5777
6551
  this.store.recordSessionResult(item.product,item.id,{result:String(event.result || '(the session ended with an empty reply)')});
5778
6552
  session.remoteResultRecorded = true;
5779
6553
  if (session.lastLiveReply) this._handledAnswers.add(this._answerKey(session.lastLiveReply));
5780
- } else {
6554
+ } else if (!(Date.now() - (session.child?.lastInterruptAt ?? 0) < 30000)) {
6555
+ // A step she cut with Send now ends in an error result too; that
6556
+ // is her message being taken, not the turn failing.
5781
6557
  this._remoteControls?.set(JSON.stringify([item.product,item.id]),{state:'failed',at:Date.now(),mayBeActive:true,text:String(event.result || 'Claude could not complete the remote turn.')});
5782
6558
  }
5783
6559
  this._saveState(); this.onChange?.();
@@ -5872,10 +6648,11 @@ export class Supervisor {
5872
6648
  exited = true;
5873
6649
  if (how?.transportFault) session.transportFault = true;
5874
6650
  session.exitFailed = !!(signal || code !== 0 || session.transportFault || session.resultIsError || session.result == null);
6651
+ this.count?.('run_finished', runEndedProps(session, Date.now()));
5875
6652
  onLine(`session exited (${code})`);
5876
6653
  // WHAT THE RUN CHANGED, SAVED WHERE SHE CAN OPEN IT.
5877
6654
  //
5878
- // David could not see code on his onboarding call because there was no
6655
+ // A tester could not see code on their onboarding call because there was no
5879
6656
  // artifact to open. This is the line that makes one exist: the moment a
5880
6657
  // worker exits, its own transcript is read for the edits it made, the
5881
6658
  // checkout is read for what actually moved on the disk, and the two are
@@ -5929,12 +6706,7 @@ export class Supervisor {
5929
6706
  } catch (e) { console.warn('zero: could not save what the run changed:', e.message); }
5930
6707
  try { trace?.write(`\n# exited (${code}) ${new Date().toISOString()}\n`); trace?.end(); } catch {}
5931
6708
  this.removeSpawnFiles(session);
5932
- this.sessions.delete(item.id);
5933
- // The row's folder goes back. It is only deleted when losing it costs
5934
- // nothing, so the usual answer is that it stays, holding work she has not
5935
- // approved. The same call sweeps the folders whose work has since landed
5936
- // in main, which is what stops them piling up unseen.
5937
- this.releaseWorkFolder(item, product);
6709
+ this.endSession(item, session, product);
5938
6710
  if (session.remoteIdle) {
5939
6711
  delete this._liveSessions[item.id];
5940
6712
  this._saveState(); this.onChange?.(); return;
@@ -5984,42 +6756,33 @@ export class Supervisor {
5984
6756
  this.store.recordSessionResult(item.product, item.id, { result: session.result, status: null });
5985
6757
  } catch (e) { console.warn('zero: writing the closing message as the answer failed:', e.message); }
5986
6758
  }
5987
- // A session that finished under its own power has nothing left to resume.
5988
- // ANYTHING ELSE STAYS REMEMBERED, and that includes a kill of ours: an
5989
- // app quit is the commonest way her fleet dies, and it is the case where
5990
- // going back to the same session rather than briefing a stranger is worth
5991
- // the most. What keeps that honest is the sweep's own guard: it only ever
5992
- // puts a worker back on a row that is still open or claimed.
5993
- //
6759
+ this.releaseFinishedClaim(item, session);
5994
6760
  // NOTHING HERE TOUCHES `_rowSessions`, and that is the whole change of. A
5995
6761
  // finished session has nothing left to RESCUE, which is what this line is
5996
6762
  // about, but it is exactly the session her next reply should go back to.
5997
6763
  // `pruneRowSessions` is what eventually forgets it.
5998
- if (session.result != null && !session.resultIsError) {
5999
- delete this._liveSessions[item.id];
6000
- this._saveState();
6001
- }
6764
+ this.forgetFinishedSession(item, session);
6002
6765
  this.noteExitForBackoff(session, { onLine });
6003
6766
  const carryingAnswer = continuation && answerAtSpawn;
6004
6767
  if (carryingAnswer) this.settleDeliveryAndSay(item, answerAtSpawn, session, signal, { command: !!plan.command });
6005
- // AND THE REPLY SHE TYPED INTO A RUN THAT WAS NOT CARRYING ONE. Ordinary
6768
+ // AND THE REPLY THE USER TYPED INTO A RUN THAT WAS NOT CARRYING ONE. Ordinary
6006
6769
  // fresh work never reached the line above, so a correction steered into a
6007
6770
  // working session was acted on, answered, and then remembered as owed
6008
6771
  // forever (`settleLiveReply`, and the numbers are with it). Its own
6009
- // statement rather than a branch of the chain below: this settles her
6010
- // words, and the chain below is about what the RUN did.
6772
+ // statement rather than a branch of the chain below: this settles the
6773
+ // user's words, and the chain below is about what the RUN did.
6011
6774
  if (!carryingAnswer && session.lastLiveReply) this.settleLiveReply(item, session);
6012
6775
  // Fresh work only. A continuation already has its own memory (one spawn
6013
6776
  // per answer, capped), and a session WE killed is not evidence the row
6014
6777
  // has nothing left in it, the same reasoning settleDelivery uses. Its
6015
- // own `if`: the settle above is about her words and this is about the
6778
+ // own `if`: the settle above is about the user's words and this is about the
6016
6779
  // run, and a run that carried a live reply still has to be noted here.
6017
6780
  if (!continuation && !session.stoppedByUs) {
6018
6781
  // A session that never got an id and never said a word never started:
6019
6782
  // Claude Code announces itself with `session_id` on its first line of
6020
6783
  // output, so a session with neither died before the CLI was running.
6021
6784
  const everRan = session.sessionId != null || session.result != null;
6022
- const outcome = this.noteFreshRun(item, { everRan });
6785
+ const outcome = this.noteFreshRun(item, { everRan, account: session.accountFault?.account ?? null });
6023
6786
  if (outcome === 'rested') {
6024
6787
  const until = this.restingUntil(item);
6025
6788
  if (until > Date.now()) onLine(`nothing moved on this row; resting ${Math.round((until - Date.now()) / 60_000)}m`);
@@ -6068,16 +6831,15 @@ export class Supervisor {
6068
6831
  /* --------------------- what she attached, and the thread ---------------- */
6069
6832
  // THE FILES A TASK CARRIES, AS PATHS A STRANGER PROCESS CAN ACTUALLY OPEN.
6070
6833
  //
6071
- // Her screenshots were reaching the ledger and stopping there. A pasted image
6072
- // lands in the body as `![shot.png](attachments/x-shot.png)`, which is right
6073
- // for the reading pane and useless to a worker: the path is relative to the
6074
- // product dir, the worker's cwd is the code repo when one is registered, and
6075
- // NOTHING IN THE BRIEF EVER SAID TO OPEN IT. Measured over her own store on
6076
- // 2026-08-21, by folding each row to the moment each session was spawned and
6077
- // then looking for the file's name anywhere in that session's log: 320
6078
- // sessions were handed a task carrying a picture she attached, and 155 of
6079
- // them, 48%, never opened one, across 42 different rows. Her instruction was
6080
- // half in words and half in the picture, and only the words were delivered.
6834
+ // Attached screenshots were reaching the ledger and stopping there. A pasted
6835
+ // image lands in the body as `![shot.png](attachments/x-shot.png)`, which is
6836
+ // right for the reading pane and useless to a worker: the path is relative to
6837
+ // the product dir, the worker's cwd is the code repo when one is registered,
6838
+ // and NOTHING IN THE BRIEF EVER SAID TO OPEN IT. Measured by folding each row
6839
+ // to the moment each session was spawned and then looking for the file's name
6840
+ // anywhere in that session's log: about half the sessions handed a task
6841
+ // carrying an attached picture never opened it. The instruction was half in
6842
+ // words and half in the picture, and only the words were delivered.
6081
6843
  //
6082
6844
  // Absolute, because relative was the bug. Existence-checked, because a path
6083
6845
  // that 404s teaches a worker to distrust the whole section. Pictures first
@@ -6163,7 +6925,7 @@ export class Supervisor {
6163
6925
  pictures.length === 1
6164
6926
  ? 'THE FOUNDER ATTACHED A PICTURE. READ IT BEFORE YOU DO ANYTHING ELSE.'
6165
6927
  : `THE FOUNDER ATTACHED ${pictures.length} PICTURES. READ THEM BEFORE YOU DO ANYTHING ELSE.`,
6166
- 'Half of what she asked for is in the image and none of it is in the text.',
6928
+ 'Half of what they asked for is in the image and none of it is in the text.',
6167
6929
  // THE TRUE SENTENCE ON EACH ENGINE. `Read` is Claude Code's tool and
6168
6930
  // Codex has nothing by that name, so naming it there sent a worker
6169
6931
  // looking for something that does not exist. Codex is handed the
@@ -6187,10 +6949,10 @@ export class Supervisor {
6187
6949
  if (pictures.length || others.length) lines.push('');
6188
6950
  lines.push(
6189
6951
  lost.length === 1
6190
- ? 'SHE ATTACHED A PICTURE THAT IS NO LONGER ON DISK, so you cannot see it.'
6191
- : `SHE ATTACHED ${lost.length} PICTURES THAT ARE NO LONGER ON DISK, so you cannot see them.`,
6192
- 'Part of what she asked for was in it and that part did not reach you.',
6193
- 'Do not guess at what it showed. Say plainly that it is missing and ask her.',
6952
+ ? 'THEY ATTACHED A PICTURE THAT IS NO LONGER ON DISK, so you cannot see it.'
6953
+ : `THEY ATTACHED ${lost.length} PICTURES THAT ARE NO LONGER ON DISK, so you cannot see them.`,
6954
+ 'Part of what they asked for was in it and that part did not reach you.',
6955
+ 'Do not guess at what it showed. Say plainly that it is missing and ask them.',
6194
6956
  '',
6195
6957
  ...lost.map((rel) => `- ${rel}`),
6196
6958
  );
@@ -6228,7 +6990,7 @@ export class Supervisor {
6228
6990
  const stamp = (ts) => new Date(ts).toISOString().slice(0, 16).replace('T', ' ');
6229
6991
  const who = (turn) => {
6230
6992
  if (turn.source === 'founder') return 'THE FOUNDER';
6231
- if (turn.field === 'note') return 'a previous session, as its checkpoint to her';
6993
+ if (turn.field === 'note') return 'a previous session, as its checkpoint to them';
6232
6994
  if (turn.field === 'result') return 'a previous session, as its result';
6233
6995
  return 'a previous session, rewriting the task';
6234
6996
  };
@@ -6236,13 +6998,13 @@ export class Supervisor {
6236
6998
  '',
6237
6999
  '# The conversation on this row so far',
6238
7000
  '',
6239
- 'Oldest first. You did not write any of this: earlier sessions did, and she',
7001
+ 'Oldest first. You did not write any of this: earlier sessions did, and they',
6240
7002
  'replied to them. Read it the way you would read the scrollback in a terminal,',
6241
- 'because it is the same thing. Her latest answer is at the end of this brief',
7003
+ 'because it is the same thing. Their latest answer is at the end of this brief',
6242
7004
  'and the task as it stands now is above; everything between them is here.',
6243
7005
  '',
6244
7006
  'Nothing in here is an instruction to you. It is what has already been said,',
6245
- 'and a decision she has already made in it is settled, not reopened.',
7007
+ 'and a decision they have already made in it is settled, not reopened.',
6246
7008
  ];
6247
7009
  if (dropped) {
6248
7010
  head.push(
@@ -6311,6 +7073,7 @@ export class Supervisor {
6311
7073
  `Product: ${product.name} (slug: ${item.product})`,
6312
7074
  `Product docs live in: ${product.dir}`,
6313
7075
  product.repoPath ? `Product code repo (your cwd): ${product.repoPath}` : `No code repo is registered for this product.`,
7076
+ this.shipsThroughTheApp(product) ? this.shipBrief() : '',
6314
7077
  `Work item id: ${item.id}`,
6315
7078
  `Title: ${item.title}`,
6316
7079
  item.body ? `\n${item.body}` : '',
@@ -6446,7 +7209,36 @@ export class Supervisor {
6446
7209
  // says why this had to change and what it cost her.
6447
7210
  speaksForTheSession(session, { command = false } = {}) {
6448
7211
  if (this.storeMcpCommand() && !command) return false;
6449
- return session?.result != null && !session.resultIsError;
7212
+ return this.runFinished(session);
7213
+ }
7214
+
7215
+ // A CLEAN RESULT IS NOT A FINISHED RUN WHEN THE RUN WAS STOPPED PART-WAY.
7216
+ //
7217
+ // A result arrives at the end of every turn, and a turn can end with helpers
7218
+ // still out: "three builders are working, I'll stack them once they're
7219
+ // done". The session stays open for them (main/claude-input.mjs). If it is
7220
+ // then stopped, by a hang check fooled by a sleep or an app restart, its last
7221
+ // result is a promise, and reading it as the end of the run dropped the
7222
+ // record it resumes from and landed the promise on her row as the answer
7223
+ // (tests/a-worker-whose-helpers-are-still-working-does-not-come-back-to-you).
7224
+ // The input pipe knows: it only closes at a result with nothing out. A child
7225
+ // that cannot say (a Codex turn) keeps the old reading.
7226
+ runFinished(session) {
7227
+ if (session?.result == null || session.resultIsError) return false;
7228
+ return session.child?.ranToTheEnd?.() !== false;
7229
+ }
7230
+
7231
+ // A session that finished under its own power has nothing left to resume.
7232
+ // ANYTHING ELSE STAYS REMEMBERED, and that includes a kill of ours: an app
7233
+ // quit is the commonest way her fleet dies, and it is the case where going
7234
+ // back to the same session rather than briefing a stranger is worth the
7235
+ // most. What keeps that honest is the sweep's own guard: it only ever puts a
7236
+ // worker back on a row that is still open or claimed.
7237
+ forgetFinishedSession(item, session) {
7238
+ if (!this.runFinished(session)) return false;
7239
+ delete this._liveSessions[item.id];
7240
+ this._saveState();
7241
+ return true;
6450
7242
  }
6451
7243
 
6452
7244
  // A WORKER'S CLOSING MESSAGE IS ITS ANSWER, ON EVERY INSTALL.
@@ -6457,7 +7249,7 @@ export class Supervisor {
6457
7249
  // Code session, it ended the way every session ends: by saying its answer.
6458
7250
  // Two surfaces, one answer, drawn one under the other. A fold in the thread
6459
7251
  // and two rewrites of the brief tried to stop the second copy after the
6460
- // fact; the run in her screenshot had "the answer is written once" in its
7252
+ // fact; one run had "the answer is written once" in its
6461
7253
  // own brief and wrote it twice anyway, three seconds apart.
6462
7254
  //
6463
7255
  // So the second surface goes. A downloaded copy with no store tools has
@@ -6475,7 +7267,7 @@ export class Supervisor {
6475
7267
  // line would land on a row another session is holding.
6476
7268
  closingMessageIsTheAnswer(session) {
6477
7269
  if (!this.storeMcpCommand() || session?.command) return false;
6478
- if (session?.result == null || session.resultIsError || !String(session.result).trim()) return false;
7270
+ if (!this.runFinished(session) || !String(session.result).trim()) return false;
6479
7271
  if (session.claimRefused) return false;
6480
7272
  try {
6481
7273
  const fresh = this.store.readItem?.(session.product, session.itemId);
@@ -6485,6 +7277,19 @@ export class Supervisor {
6485
7277
  } catch { return false; }
6486
7278
  }
6487
7279
 
7280
+ // A clean terminal result ends the run, even if its shared tool server
7281
+ // stays alive for other turns. Release the run's claim without choosing a
7282
+ // status for the conversation. Failed runs retain the existing retry path.
7283
+ releaseFinishedClaim(item, session) {
7284
+ if (session.exitFailed || !this.runFinished(session) || session.claimRefused || session.stoppedByUs
7285
+ || !String(session.result ?? '').trim()) return;
7286
+ try {
7287
+ this.store.releaseRunClaim?.(item.product, item.id, {
7288
+ afterEpoch: item.epoch ?? 0, startedAt: session.startedAt,
7289
+ });
7290
+ } catch (error) { console.warn('zero: could not release the finished run:', error.message); }
7291
+ }
7292
+
6488
7293
  // The permission rules handed to a worker, generated fresh at every spawn for
6489
7294
  // the same reason the MCP config is: the folders in them are THIS person's
6490
7295
  // folders. The file we ship carries the rules and an EMPTY
@@ -6512,6 +7317,8 @@ export class Supervisor {
6512
7317
  add(this.config.storeRoot);
6513
7318
  if (product?.dir) add(product.dir);
6514
7319
  rules.permissions = { ...(rules.permissions ?? {}), additionalDirectories: dirs };
7320
+ const gateHooks = this.memoryGateHooks();
7321
+ if (gateHooks) rules.hooks = { ...(rules.hooks ?? {}), ...gateHooks };
6515
7322
  // Beside the MCP config, and written the same way: a real file on disk,
6516
7323
  // because the CLI reading --settings is not this process. Named by the
6517
7324
  // run, because two spawns with different rules must never share a file.
@@ -6531,6 +7338,226 @@ export class Supervisor {
6531
7338
  if (rules) { spawnFiles.push(rules); args.push('--settings', rules); }
6532
7339
  }
6533
7340
 
7341
+ /* ------------------ heavy work waits when memory is short ----------------- */
7342
+ // w-3958c3753d. With the switch on, every shell command a Claude Code worker
7343
+ // runs asks main/memory-gate-server.mjs first, through a PreToolUse hook on
7344
+ // Bash, and reports back after it. Reading and editing files never ask. With
7345
+ // the switch off a worker carries none of it. The rules and the measurements
7346
+ // behind them are in main/memory-gate.mjs.
7347
+
7348
+ /** The hooks for the per-spawn settings file, or null when it is off. */
7349
+ memoryGateHooks() {
7350
+ if (!this.config.memoryGate) return null;
7351
+ const script = unpacked(path.join(this.appDir, 'scripts', 'memory-gate-hook.sh'));
7352
+ const quoted = `'${script.replace(/'/g, `'\\''`)}'`;
7353
+ // Above the longest wait by ten minutes: a hook that times out lets its
7354
+ // command run (measured), so the app's own refusal must always come first.
7355
+ const timeout = Math.ceil(MEMORY_GATE.maxWaitMs / 1000) + 600;
7356
+ const on = (mode, extra = {}) => [{ matcher: 'Bash', hooks: [{ type: 'command', command: `${quoted} ${mode}`, ...extra }] }];
7357
+ return {
7358
+ PreToolUse: on('pre', { timeout }),
7359
+ PostToolUse: on('post', { timeout: 10 }),
7360
+ PostToolUseFailure: on('post', { timeout: 10 }),
7361
+ };
7362
+ }
7363
+
7364
+ /** Where a worker's hook asks, and who it is asking for. Empty when off. */
7365
+ memoryGateEnv(item) {
7366
+ if (!this.config.memoryGate || !item) return {};
7367
+ return {
7368
+ AGENTBOX_GATE_SOCK: this.config.memoryGateSocket || memoryGateSocketPath(),
7369
+ AGENTBOX_GATE_ITEM: String(item.id ?? ''),
7370
+ AGENTBOX_GATE_PRODUCT: String(item.product ?? ''),
7371
+ AGENTBOX_GATE_SCORE: String(Math.max(0, Math.round(this._score(item)) || 0)),
7372
+ };
7373
+ }
7374
+
7375
+ /**
7376
+ * THE SAME SOCKET, AND DELIBERATELY NO ROW (w-e5225b62ba). One
7377
+ * `codex app-server` serves every Codex thread of a login, so this environment
7378
+ * is shared by all of them: an AGENTBOX_GATE_ITEM here would label every Codex
7379
+ * thread as whichever row started first. Each thread names itself instead, by
7380
+ * the `session_id` its hook payload carries, which `memoryGateOwner` turns back
7381
+ * into a row.
7382
+ *
7383
+ * It is also what keeps the user's own `codex` out of this. The hook sits in a
7384
+ * hooks.json their terminal reads too, and it exits on its first line when
7385
+ * AGENTBOX_GATE_SOCK is absent — which it is everywhere but here.
7386
+ */
7387
+ codexMemoryGateEnv() {
7388
+ if (!this.config.memoryGate) return {};
7389
+ return { AGENTBOX_GATE_SOCK: this.config.memoryGateSocket || memoryGateSocketPath() };
7390
+ }
7391
+
7392
+ /**
7393
+ * WHICH ROW A CODEX THREAD IS DOING, asked by the gate on every command a
7394
+ * Codex worker runs. `session.sessionId` is set from `thread/started` in
7395
+ * main/codex.mjs, and the hook's `session_id` was measured to be that same
7396
+ * string. A thread this app is not running answers null, and its command is
7397
+ * let through.
7398
+ */
7399
+ memoryGateOwner(sessionId) {
7400
+ if (!sessionId) return null;
7401
+ for (const [itemId, session] of this.sessions) {
7402
+ if (session?.engine === 'codex' && session.sessionId === sessionId) {
7403
+ return { item: itemId, product: session.product ?? null };
7404
+ }
7405
+ }
7406
+ return null;
7407
+ }
7408
+
7409
+ /** The live rank of one of this app's own tasks, so a raise counts at once. */
7410
+ memoryGateScore(product, itemId) {
7411
+ if (!product || !itemId) return null;
7412
+ try {
7413
+ const item = this.store.readItem?.(product, itemId);
7414
+ return item ? this._score(item) : null;
7415
+ } catch { return null; }
7416
+ }
7417
+
7418
+ memoryGateSlots() {
7419
+ const n = Number(this.config.memoryGateSlots);
7420
+ return Number.isFinite(n) && n >= 1 ? Math.round(n) : autoSlots(os.totalmem());
7421
+ }
7422
+
7423
+ /**
7424
+ * PUT OUR HOOK IN A CODEX HOME, OR TAKE IT OUT, AND TRUST ONLY OURS.
7425
+ *
7426
+ * Codex reads its hooks from `hooks.json` in the Codex home, which may be the
7427
+ * user's own `~/.codex` and may already hold hooks of theirs, so the file is
7428
+ * merged and never replaced (main/codex-memory-gate.mjs).
7429
+ *
7430
+ * A HOOK ONLY RUNS ONCE TRUSTED, and the trust is satisfied for one key rather
7431
+ * than switched off for everybody: `hooks/list` names our hook and its hash,
7432
+ * and `config/batchWrite` writes `hooks.state."<key>".trusted_hash` for that
7433
+ * key alone. Codex does the editing of config.toml, so nothing of theirs is
7434
+ * rewritten by us. Measured on this Mac: a second hook in the same file stayed
7435
+ * untrusted, and an unrelated `[projects."..."]` table survived untouched.
7436
+ *
7437
+ * IT RUNS AT EVERY START, not once at install, because the key carries the
7438
+ * absolute path of the script: a new app version is a new hash, which Codex
7439
+ * calls "modified" and will not run until it is trusted again.
7440
+ *
7441
+ * NOTHING HERE IS ALLOWED TO STOP THE APP. A Codex home we cannot write, an
7442
+ * app-server that will not answer, a `config/batchWrite` that is refused: all
7443
+ * of them mean Codex workers go ungated, which is exactly where they were
7444
+ * before this existed.
7445
+ */
7446
+ async setCodexMemoryGate(on, home = this._codexHome(), server = null) {
7447
+ // IT NEVER STARTS AN APP-SERVER, and that is the point. The trust needs one
7448
+ // to ask `hooks/list` with, so it uses the one it was handed (`_codexServer`
7449
+ // chains this onto its own handshake) or one already live for this home, and
7450
+ // otherwise writes the file and stops. A process started here would be a few
7451
+ // hundred megabytes spent by the feature whose whole job is to save memory,
7452
+ // and the next Codex worker starts a server anyway and trusts it then.
7453
+ const live = server ?? (on ? this._codexServers?.get(home) : null);
7454
+ const request = live
7455
+ ? async (method, params) => { await live.handshake; return live.client.request(method, params); }
7456
+ : null;
7457
+ return syncCodexMemoryGate({
7458
+ home,
7459
+ scriptPath: unpacked(path.join(this.appDir, 'scripts', 'memory-gate-hook.sh')),
7460
+ on,
7461
+ request,
7462
+ cwds: [this.appDir],
7463
+ log: (line) => console.log(`zero: ${line}`),
7464
+ });
7465
+ }
7466
+
7467
+ async setMemoryGate(on) {
7468
+ if (!on) {
7469
+ await this.stopMemoryGate();
7470
+ await this.setCodexMemoryGate(false).catch(() => {});
7471
+ return;
7472
+ }
7473
+ if (this._memoryGate) { this._memoryGate.setSlots(this.memoryGateSlots()); return; }
7474
+ const server = new MemoryGateServer({
7475
+ socketPath: this.config.memoryGateSocket || memoryGateSocketPath(),
7476
+ // A test runs its own coordinator beside the real one; it names a port.
7477
+ ...(Number.isInteger(this.config.memoryGateLockPort) ? { lockPort: this.config.memoryGateLockPort } : {}),
7478
+ historyFile: path.join(this.userDir, 'memory-gate-history.json'),
7479
+ gate: { slots: this.memoryGateSlots() },
7480
+ scoreFor: (product, itemId) => this.memoryGateScore(product, itemId),
7481
+ ownerOf: (sessionId) => this.memoryGateOwner(sessionId),
7482
+ log: (line) => console.log(`zero: ${line}`),
7483
+ });
7484
+ this._memoryGate = server;
7485
+ await server.start();
7486
+ // The hook goes in after the socket is listening, so the first Codex command
7487
+ // to ask has something to ask. It never blocks the switch taking effect.
7488
+ this.setCodexMemoryGate(true).catch((e) => console.warn(`zero: codex memory gate: ${e.message}`));
7489
+ }
7490
+
7491
+ async stopMemoryGate() {
7492
+ const server = this._memoryGate;
7493
+ this._memoryGate = null;
7494
+ if (server) await server.stop();
7495
+ }
7496
+
7497
+ /* ------------- what finished agents leave running (main/leftovers.mjs) ------------- */
7498
+ // Its own switch, off until somebody turns it on. This app tells the cleaner
7499
+ // when each run starts (in spawnWorker, before the worker exists) and ends
7500
+ // (endSession), because only this app runs this store's tasks and so only
7501
+ // it can say for certain that a task's run is over.
7502
+
7503
+ // THE CLEANER ALWAYS RUNS; THE SWITCH DECIDES WHETHER IT MAY SIGNAL. Off, it
7504
+ // looks every five minutes and reports, so the Agents page can say what
7505
+ // turning it on would stop before anybody turns it on, and every run is
7506
+ // recorded either way, so turning it on later finds real records rather than
7507
+ // guesses (Codex's review, 2026-10-05). On, it looks every minute.
7508
+ async setLeftoverCleanup(on) {
7509
+ if (!this._leftoverCleaner) {
7510
+ this._leftoverCleaner = new LeftoverCleaner({
7511
+ file: path.join(this.userDir, 'leftovers.json'),
7512
+ enabled: !!on,
7513
+ enabledSince: Number(this.config.cleanupLeftoversSince) || Date.now(),
7514
+ live: (itemId) => this.sessions.has(itemId),
7515
+ owns: (itemId, product) => {
7516
+ if (!product) return false;
7517
+ try { return !!this.store.readItem?.(product, itemId); } catch { return false; }
7518
+ },
7519
+ log: (line) => console.log(`zero: ${line}`),
7520
+ });
7521
+ } else if (on) {
7522
+ this._leftoverCleaner.enable(Number(this.config.cleanupLeftoversSince) || Date.now());
7523
+ } else {
7524
+ this._leftoverCleaner.disable();
7525
+ }
7526
+ clearInterval(this._leftoverTimer);
7527
+ const tick = () => this._leftoverCleaner?.tick().catch((e) => console.warn('zero: leftovers:', e.message));
7528
+ this._leftoverTimer = setInterval(tick, on ? 60_000 : 5 * 60_000);
7529
+ this._leftoverTimer.unref?.();
7530
+ tick();
7531
+ }
7532
+
7533
+ leftoverStatus() {
7534
+ return this._leftoverCleaner ? this._leftoverCleaner.status() : null;
7535
+ }
7536
+
7537
+ /**
7538
+ * What an agent is told about this, or null. A fresh brief carries the rule
7539
+ * and the way to keep something; any brief carries the programs of this task
7540
+ * still shutting down, which the agent must not reuse.
7541
+ */
7542
+ leftoverBriefNote(item) {
7543
+ const cleaner = this._leftoverCleaner;
7544
+ if (!cleaner || !item?.id) return null;
7545
+ const parts = [];
7546
+ // EVERY RUN, not only a fresh one: a conversation resumed after the switch
7547
+ // went on, and a chat, never heard it otherwise (Codex's review).
7548
+ if (cleaner.enabled) {
7549
+ parts.push('Anything you leave running after your turn (a dev server, a preview, a background job) is stopped two hours after you finish, sooner if this Mac runs short of memory. '
7550
+ + 'If the person needs something to keep running, start it with AGENTBOX_KEEP=1 in its environment and say so in your answer. Stop anything else you started before you finish.');
7551
+ }
7552
+ const stopping = cleaner.stoppingNote(item.id);
7553
+ if (stopping) parts.push(stopping);
7554
+ return parts.length ? parts.join('\n\n') : null;
7555
+ }
7556
+
7557
+ memoryGateStatus() {
7558
+ return this._memoryGate ? this._memoryGate.status() : null;
7559
+ }
7560
+
6534
7561
  // The MCP config handed to a worker, generated fresh so the paths always track
6535
7562
  // zero.config.json. The approvals channel is Agentbox's own and always rides;
6536
7563
  // the store server rides only when one is configured. Returns null when there
@@ -6576,6 +7603,7 @@ export class Supervisor {
6576
7603
  env: {
6577
7604
  STORE_ACCOUNT_ID: this.config.accountId,
6578
7605
  ...storeRootEnv(this.config.storeRoot),
7606
+ ...teamPersonEnv(),
6579
7607
  },
6580
7608
  };
6581
7609
  }
@@ -6610,9 +7638,17 @@ export function unpacked(p) {
6610
7638
  // compared against a stored watermark and an undefined would make every
6611
7639
  // comparison false, so a row she has never touched would rest forever the
6612
7640
  // moment she did touch it.
7641
+ //
7642
+ // FILING IS NOT SPEAKING (2026-10-01). Marking a thread Private, moving its
7643
+ // priority, or editing a summary line or a link is the founder writing to the
7644
+ // row, and it used to count as a word: a persona test switched a finished
7645
+ // thread to Private and the agent ran again on its own, unasked and paid for.
7646
+ // Those fields describe the thread; they never ask it anything.
7647
+ const NOT_A_WORD = new Set(['visibility', 'priority', 'problem', 'progress', 'solution', 'blockedBy', 'blocks']);
6613
7648
  function lastFounderWrite(item) {
6614
7649
  let latest = 0;
6615
- for (const w of Object.values(item?.wrote ?? {})) {
7650
+ for (const [field, w] of Object.entries(item?.wrote ?? {})) {
7651
+ if (NOT_A_WORD.has(field)) continue;
6616
7652
  if (w?.source === 'founder' && w.ts > latest) latest = w.ts;
6617
7653
  }
6618
7654
  return latest;
@@ -6644,8 +7680,10 @@ function captureStream(session, line) {
6644
7680
  session.result = String(obj.result ?? '');
6645
7681
  session.resultIsError = !!obj.is_error;
6646
7682
  // A run cannot end with helpers still out. Cleared here as well as on
6647
- // exit so the number can never outlive the thing it counts.
6648
- session.helperIds?.clear();
7683
+ // exit so the number can never outlive the thing it counts. Unless
7684
+ // something is still out in the background: then this was a turn
7685
+ // ending, not the run, and the session stays open for it.
7686
+ if (!session.child?.waitingOn?.().length) session.helperIds?.clear();
6649
7687
  }
6650
7688
  noteClaimAnswer(session, obj);
6651
7689
  countHelpers(session, obj);
@@ -6757,7 +7795,7 @@ export function streamingText(session, line) {
6757
7795
  // to move with it. DID THIS RUN LEAVE HER ANYTHING TO READ?
6758
7796
  //
6759
7797
  // The rule that divides the two writers, kept in one named place because
6760
- // getting it wrong is silence and silence is the failure she filed:
7798
+ // getting it wrong is silence, and silence is the failure that matters most:
6761
7799
  //
6762
7800
  // - said nothing at all -> the supervisor speaks for it (sayTheRunDied)
6763
7801
  // - said something wrong -> the supervisor speaks for it, in OUR words