agentbox-app 0.1.7 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. package/README.md +220 -58
  2. package/bin/agentbox.mjs +18 -3
  3. package/briefs/message-rules.md +24 -3
  4. package/briefs/worker.md +63 -49
  5. package/briefs/writing-rules.md +90 -131
  6. package/main/account-discovery.mjs +21 -20
  7. package/main/account-tooling.mjs +11 -11
  8. package/main/agent-activity.mjs +45 -5
  9. package/main/agent-schedule.mjs +14 -14
  10. package/main/agent-sessions.mjs +94 -10
  11. package/main/agent-updates.mjs +116 -0
  12. package/main/agents.mjs +52 -46
  13. package/main/analytics.mjs +27 -0
  14. package/main/approval-prompt-server.mjs +2 -2
  15. package/main/approvals.mjs +14 -5
  16. package/main/artifact-path.mjs +11 -0
  17. package/main/claude-bin.mjs +32 -5
  18. package/main/claude-input.mjs +45 -3
  19. package/main/claude-plan.mjs +25 -13
  20. package/main/code-change.mjs +3 -3
  21. package/main/codex-account.mjs +1 -1
  22. package/main/codex-bin.mjs +18 -0
  23. package/main/codex-launch-env.mjs +31 -0
  24. package/main/codex-memory-gate.mjs +260 -0
  25. package/main/codex-models.mjs +12 -7
  26. package/main/codex-threads.mjs +46 -23
  27. package/main/codex-usage-file.mjs +7 -7
  28. package/main/codex-usage.mjs +4 -4
  29. package/main/codex-watch.mjs +12 -12
  30. package/main/config.mjs +82 -17
  31. package/main/demo.mjs +17 -14
  32. package/main/dev-window.mjs +9 -9
  33. package/main/doc-scheme.mjs +2 -0
  34. package/main/engine-setup.mjs +204 -0
  35. package/main/feedback.mjs +55 -0
  36. package/main/folder-pictures.mjs +48 -0
  37. package/main/frame-scrollbars.mjs +90 -0
  38. package/main/fresh-user.mjs +7 -4
  39. package/main/git-change-offthread.mjs +70 -0
  40. package/main/git-change-thread.mjs +11 -0
  41. package/main/git-change.mjs +9 -3
  42. package/main/img-scheme.mjs +70 -1
  43. package/main/instruction-settings.mjs +134 -60
  44. package/main/ipc.mjs +335 -42
  45. package/main/leftovers.mjs +518 -0
  46. package/main/live-replies.mjs +43 -5
  47. package/main/machine.mjs +48 -1
  48. package/main/main.mjs +138 -15
  49. package/main/memory-gate-history.mjs +254 -0
  50. package/main/memory-gate-server.mjs +461 -0
  51. package/main/memory-gate.mjs +206 -0
  52. package/main/message-priority.mjs +87 -0
  53. package/main/notify.mjs +1 -1
  54. package/main/rail-note.mjs +2 -2
  55. package/main/refresh-agent-models.mjs +21 -0
  56. package/main/repeats.mjs +4 -4
  57. package/main/row-label.mjs +115 -51
  58. package/main/runs-on.mjs +93 -0
  59. package/main/serve.mjs +77 -5
  60. package/main/settings.mjs +245 -26
  61. package/main/ship-queue.mjs +279 -0
  62. package/main/sign-in-files.mjs +39 -0
  63. package/main/source-updater.mjs +298 -0
  64. package/main/store/home.mjs +84 -7
  65. package/main/store/project.mjs +15 -19
  66. package/main/store/work-items.mjs +150 -6
  67. package/main/store.mjs +245 -13
  68. package/main/supervisor.mjs +1634 -417
  69. package/main/task-commands.mjs +8 -10
  70. package/main/task-folders.mjs +94 -16
  71. package/main/task-remote-control.mjs +3 -3
  72. package/main/task-terminals.mjs +14 -2
  73. package/main/team/index.mjs +440 -0
  74. package/main/team/memory-cloud.mjs +247 -0
  75. package/main/team/projects.mjs +162 -0
  76. package/main/team/session.mjs +225 -0
  77. package/main/team/sign-in.mjs +106 -0
  78. package/main/team/supabase-backend.mjs +231 -0
  79. package/main/team/sync.mjs +163 -0
  80. package/main/updater.mjs +15 -5
  81. package/main/write-before-reload.mjs +50 -0
  82. package/mcp/core/page.mjs +10 -12
  83. package/mcp/core/work.mjs +123 -26
  84. package/mcp/tools.mjs +13 -6
  85. package/package.json +21 -9
  86. package/renderer/dist/assets/agentbox-icon-F1oN9nj_.svg +15 -0
  87. package/renderer/dist/assets/index-ChKsQkRw.js +1169 -0
  88. package/renderer/dist/assets/index-ClBXnq3B.css +32 -0
  89. package/renderer/dist/index.html +2 -2
  90. package/scripts/after-install.mjs +14 -0
  91. package/scripts/approval-server.sh +7 -2
  92. package/scripts/memory-gate-hook.sh +59 -0
  93. package/shared/agent-import.mjs +39 -16
  94. package/shared/agents.d.mts +26 -8
  95. package/shared/agents.mjs +100 -108
  96. package/shared/analytics-events.mjs +19 -2
  97. package/shared/answers.mjs +12 -0
  98. package/shared/bridge-map.d.mts +12 -0
  99. package/shared/bridge-map.mjs +38 -1
  100. package/shared/claude-commands.generated.mjs +2 -2
  101. package/shared/claude-models.generated.mjs +6 -6
  102. package/shared/claude-usage.mjs +6 -6
  103. package/shared/codex-import.mjs +7 -7
  104. package/shared/codex-modes.d.mts +50 -0
  105. package/shared/codex-modes.mjs +1 -1
  106. package/shared/codex-usage.mjs +7 -8
  107. package/shared/demo-world.mjs +6 -6
  108. package/shared/engines.mjs +27 -10
  109. package/shared/feedback.d.mts +14 -0
  110. package/shared/feedback.mjs +44 -0
  111. package/shared/first-run-examples.mjs +4 -4
  112. package/shared/first-run-practice.d.mts +16 -0
  113. package/shared/first-run-practice.mjs +176 -70
  114. package/shared/fresh-user-home.mjs +34 -4
  115. package/shared/instruction-defaults.json +2 -2
  116. package/shared/product-name.mjs +4 -2
  117. package/shared/project-folder-check.mjs +9 -3
  118. package/shared/rank.d.mts +1 -0
  119. package/shared/rank.mjs +22 -0
  120. package/shared/referenced-files.mjs +1 -1
  121. package/shared/runs-on.d.mts +20 -0
  122. package/shared/runs-on.mjs +79 -0
  123. package/shared/schemes.d.mts +11 -0
  124. package/shared/schemes.mjs +1 -1
  125. package/shared/shortcuts.d.mts +41 -0
  126. package/shared/shortcuts.mjs +236 -0
  127. package/shared/spawn-trouble.d.mts +2 -0
  128. package/shared/spawn-trouble.mjs +40 -19
  129. package/shared/team-rules.d.mts +17 -0
  130. package/shared/team-rules.mjs +230 -0
  131. package/shared/team-status.d.mts +11 -0
  132. package/shared/team-status.mjs +90 -0
  133. package/shared/terminal-links.d.mts +2 -0
  134. package/shared/terminal-links.mjs +55 -0
  135. package/shared/terminal-state.d.mts +1 -0
  136. package/shared/terminal-state.mjs +16 -0
  137. package/shared/thread-cards.d.mts +19 -0
  138. package/shared/thread-cards.mjs +254 -0
  139. package/shared/usage.d.mts +1 -1
  140. package/shared/usage.mjs +6 -7
  141. package/shared/work-items.mjs +258 -28
  142. package/shared/work-lines.mjs +393 -16
  143. package/worker-permissions.json +1 -1
  144. package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
  145. package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
  146. package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
  147. package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
  148. package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
  149. package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
  150. package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
  151. package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
  152. package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
  153. package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
  154. package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
  155. package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
  156. package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
  157. package/renderer/dist/assets/index-B7hhfHrT.js +0 -684
  158. package/renderer/dist/assets/index-CwiDv5bL.css +0 -32
  159. package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
  160. package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
  161. package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
  162. package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
  163. package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
  164. package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
  165. package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
  166. package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
  167. package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
  168. package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
  169. package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
  170. package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
  171. package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
  172. package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
  173. package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
  174. package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
  175. package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
  176. package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
  177. package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
  178. package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
  179. package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
  180. package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
  181. package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
  182. package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
  183. package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
  184. package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
  185. package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
  186. package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
  187. package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
  188. package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
  189. package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
  190. package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
  191. package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
  192. package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
  193. package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
  194. package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
  195. package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
  196. package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
  197. package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
  198. package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
  199. package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
  200. package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
  201. package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
  202. package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
  203. package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
  204. package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
  205. package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
  206. package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
  207. package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
  208. package/renderer/dist/emulator/audio-rules.mjs +0 -74
  209. package/renderer/dist/emulator/data/compression/README.md +0 -11
  210. package/renderer/dist/emulator/data/compression/extract7z.js +0 -1
  211. package/renderer/dist/emulator/data/compression/extractzip.js +0 -1
  212. package/renderer/dist/emulator/data/compression/libunrar.js +0 -519
  213. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  214. package/renderer/dist/emulator/data/cores/README.md +0 -22
  215. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  216. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  217. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  218. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  219. package/renderer/dist/emulator/data/cores/package.json +0 -65
  220. package/renderer/dist/emulator/data/cores/reports/mgba.json +0 -1
  221. package/renderer/dist/emulator/data/emulator.css +0 -1614
  222. package/renderer/dist/emulator/data/loader.js +0 -170
  223. package/renderer/dist/emulator/data/localization/README.md +0 -63
  224. package/renderer/dist/emulator/data/localization/af-FR.json +0 -339
  225. package/renderer/dist/emulator/data/localization/ar-AR.json +0 -301
  226. package/renderer/dist/emulator/data/localization/ben-BEN.json +0 -301
  227. package/renderer/dist/emulator/data/localization/de-GER.json +0 -301
  228. package/renderer/dist/emulator/data/localization/el-GR.json +0 -301
  229. package/renderer/dist/emulator/data/localization/en-US.json +0 -339
  230. package/renderer/dist/emulator/data/localization/es-ES.json +0 -310
  231. package/renderer/dist/emulator/data/localization/fa-AF.json +0 -310
  232. package/renderer/dist/emulator/data/localization/hi-HI.json +0 -301
  233. package/renderer/dist/emulator/data/localization/it-IT.json +0 -302
  234. package/renderer/dist/emulator/data/localization/ja-JA.json +0 -301
  235. package/renderer/dist/emulator/data/localization/jv-JV.json +0 -301
  236. package/renderer/dist/emulator/data/localization/ko-KO.json +0 -336
  237. package/renderer/dist/emulator/data/localization/pt-BR.json +0 -361
  238. package/renderer/dist/emulator/data/localization/retroarch.json +0 -617
  239. package/renderer/dist/emulator/data/localization/ro-RO.json +0 -310
  240. package/renderer/dist/emulator/data/localization/ru-RU.json +0 -301
  241. package/renderer/dist/emulator/data/localization/tr-TR.json +0 -310
  242. package/renderer/dist/emulator/data/localization/vi-VN.json +0 -302
  243. package/renderer/dist/emulator/data/localization/zh-CN.json +0 -351
  244. package/renderer/dist/emulator/data/src/GameManager.js +0 -464
  245. package/renderer/dist/emulator/data/src/compression.js +0 -142
  246. package/renderer/dist/emulator/data/src/emulator.js +0 -6158
  247. package/renderer/dist/emulator/data/src/gamepad.js +0 -167
  248. package/renderer/dist/emulator/data/src/nipplejs.js +0 -1
  249. package/renderer/dist/emulator/data/src/shaders.js +0 -236
  250. package/renderer/dist/emulator/data/src/socket.io.min.js +0 -7
  251. package/renderer/dist/emulator/data/src/storage.js +0 -125
  252. package/renderer/dist/emulator/data/version.json +0 -5
  253. package/renderer/dist/emulator/host.js +0 -299
  254. package/renderer/dist/emulator/index.html +0 -28
  255. package/renderer/dist/emulator/provenance.json +0 -6
@@ -1,14 +1,16 @@
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';
8
10
  import { taskCommand } from './task-commands.mjs';
9
11
  import { providerCommand, reviewTarget, nativeCommandNames } from '../shared/provider-commands.mjs';
10
12
  import { compactCodexThread } from './codex-compaction.mjs';
11
- import {readSystemTemplate} from './instruction-settings.mjs';
13
+ import {agentInstructionsUnified, readSystemTemplate, writeInstruction} from './instruction-settings.mjs';
12
14
  import {fillName} from '../shared/product-name.mjs';
13
15
  // The supervisor: deterministic, boring, and the only part of the app that talks
14
16
  // to the brain. It spawns headless claude sessions on the founder's plan, one
@@ -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
@@ -84,6 +96,7 @@ import {
84
96
  summarizeCodexEvent, traceCodexEvent, SAYING_CAP,
85
97
  } from './codex.mjs';
86
98
  import { createCodexAppServer } from './codex-app-server.mjs';
99
+ import { codexLaunchEnv } from './codex-launch-env.mjs';
87
100
  import { CodexUsage } from './codex-usage.mjs';
88
101
  import { createCodexWorker, codexTranscriptFile, mcpServerNames, workerThreadParams } from './codex-session.mjs';
89
102
  import { CODEX_DEFAULT_MODE, isCodexMode } from '../shared/codex-modes.mjs';
@@ -91,7 +104,16 @@ import { createCodexApprovals } from './codex-approvals.mjs';
91
104
  import { approvalPublicKey } from './approvals.mjs';
92
105
  import { codexDefaultModel, codexHome, codexKnownSlugs, codexModelLevels, codexModels } from './codex-models.mjs';
93
106
  import { nameRow, wantsName } from './row-label.mjs';
107
+ import { LEVELS, latestMessage, sortMessage, wantsPriority } from './message-priority.mjs';
94
108
  import { NAME, Name, envName, nameSlug, isOurSlug } from '../shared/product-name.mjs';
109
+ import { signInFiles, signInStamp } from './sign-in-files.mjs';
110
+ import { runsOn } from './runs-on.mjs';
111
+ import { MemoryGateServer, defaultSocketPath as memoryGateSocketPath } from './memory-gate-server.mjs';
112
+ import { autoSlots, DEFAULTS as MEMORY_GATE } from './memory-gate.mjs';
113
+ import { LeftoverCleaner } from './leftovers.mjs';
114
+ import { syncCodexMemoryGate } from './codex-memory-gate.mjs';
115
+ import { autoAgents, perAccountAgents } from './machine.mjs';
116
+ import { runEndedProps } from './analytics.mjs';
95
117
 
96
118
  const POLL_MS = 15_000;
97
119
  // HOW LONG SHE WAITS AFTER PRESSING THE BUTTON, and until now it was the line
@@ -126,11 +148,21 @@ const DIGEST_MIN_MS = 12 * 3_600_000;
126
148
  // invariant is self-healing, which without this would mean re-spawning every
127
149
  // fifteen seconds against whatever made the first attempt fail.
128
150
  const DIGEST_RETRY_MS = 30 * 60_000;
151
+ // How long a row the namer could not name waits before it is asked about again,
152
+ // so it cannot hold the rows behind it (nameTheRows).
153
+ const NAME_RETRY_MS = 10 * 60_000;
129
154
  // A spawn that exits faster than this did no real work (limit hit, bad flag,
130
155
  // untrusted workspace); consecutive fast exits back spawning off exponentially.
131
156
  const FAST_EXIT_MS = 45_000;
132
157
  const BACKOFF_BASE_MS = 60_000;
133
158
  const BACKOFF_CAP_MS = 30 * 60_000;
159
+ // HOW LONG A SIGNED-OUT LOGIN SITS OUT WHEN NOTHING SAYS IT CAME BACK. It was
160
+ // the same half hour as an ambiguous death, and that half hour is exactly how
161
+ // long her agents stayed "Queued" after she had signed in again (2026-10-04).
162
+ // The app now watches for the login itself (`_noticeSignIns`); this is only the
163
+ // fallback for a login that lands somewhere it cannot see. A try on a login
164
+ // that is still out costs a two-second run and is charged to nobody.
165
+ const SIGNED_OUT_REST_MS = 5 * 60_000;
134
166
  // How long past a limit's printed reset hour the fleet waits before trying.
135
167
  const LIMIT_RESET_GRACE_MS = 30_000;
136
168
  // HOW LONG NOTHING MAY WORK BEFORE THE APP SAYS SO OUT LOUD.
@@ -143,10 +175,13 @@ const TROUBLE_QUIET_MS = 20 * 60_000;
143
175
  // A RUNNING SESSION THAT HAS GONE SILENT, AND WHOSE CLAIM HAS LAPSED, IS DEAD.
144
176
  // w-3a14ab56d6, 2026-09-23: a Codex turn logged its last line at 19:10:56, its
145
177
  // 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
178
+ // exit fired and the row sat in In progress for 50 minutes until the user
179
+ // noticed it was stuck. Both signals together, never one: a long render is silent but its
148
180
  // store server keeps beating, and a session that never claimed has no lease.
149
181
  const HUNG_QUIET_MS = 10 * 60_000;
182
+ // A pass this late was not late, the Mac was asleep (forgiveSleep). Eight
183
+ // missed passes: past any slow pass, well under the ten minutes above.
184
+ const SLEPT_GAP_MS = 2 * 60_000;
150
185
  // Two hangs on one row inside this window and the app stops restarting it and
151
186
  // tells her instead, so a row that hangs every time is not killed forever.
152
187
  const HUNG_REPEAT_WINDOW_MS = 2 * 3_600_000;
@@ -211,9 +246,9 @@ const FIRST_RUN_HOLD_MS = 20 * 60_000;
211
246
  // tool prefix.
212
247
  //
213
248
  // 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.
249
+ // for one round. It is gone: configs were corrected in the same step, and
250
+ // keeping the old word in the code so as to keep reading the old word in the
251
+ // config would keep alive exactly the reference the rename was meant to remove.
217
252
  const STORE_SERVER = nameSlug;
218
253
 
219
254
  /**
@@ -227,13 +262,13 @@ const STORE_SERVER = nameSlug;
227
262
  * and holding nothing: no claim, no checkpoint, no result, and no error either,
228
263
  * because a grant for a tool that does not exist is not a failure of anything.
229
264
  *
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.
265
+ * THE CONFIG FILE IS NOT REWRITTEN. This is what a session READS, the same way
266
+ * `buildBrief` fills the name into a brief kept in tokens; the config keeps
267
+ * whatever the user typed, and the day they correct it nothing here changes.
233
268
  *
234
269
  * 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
270
+ * A worker also sees the user's own MCP servers (there is no `--strict-mcp-config`
271
+ * on the spawn), so `mcp__playwright` in the args is a live grant for a live
237
272
  * server and must survive untouched. `isOurSlug` is the whole of the rule: a
238
273
  * name this app has actually answered to, and nothing else.
239
274
  */
@@ -287,20 +322,33 @@ export class Supervisor {
287
322
  // the user's data folder inside a packaged app, because a signed bundle
288
323
  // cannot be written into without breaking its own signature.
289
324
  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.
325
+ // AND THE TWO FILES THAT ARE ONLY EVER THE USER'S COME FROM userDir, WHICH
326
+ // IS NEVER THE CHECKOUT (w-3dc46f3a67). The standing instructions and the
327
+ // writing rules used to live at `dataDir/briefs`, which running from source
328
+ // IS the git checkout every worker fast-forwards, stashes and checks out, so
329
+ // agent commits could silently replace what the user had written there. A
330
+ // rule the user wrote and lost is worse than no box at all.
298
331
  //
299
332
  // These are the same two files package.json excludes from the download
300
333
  // (`!briefs/founder.md`, `!briefs/writing-rules.md`), and that is the line:
301
334
  // a file we SHIP is a default and belongs in the checkout, a file only she
302
335
  // writes belongs in her own folder. main.mjs carries the text across once.
303
336
  this.userDir = userDir;
337
+ // THE APP SHIPS A TASK THE AGENT MARKED READY, for a project a person
338
+ // turned that on for in this app's own data folder (main/ship-queue.mjs).
339
+ // With no such setting it never runs anything.
340
+ this.onShipped = () => {};
341
+ this.shipQueue = new ShipQueue({
342
+ store,
343
+ userDir,
344
+ folderFor: (item, product) => { try { return taskFolderPath(this.productFolder(product), item.id); } catch { return null; } },
345
+ isLive: (item) => this.sessions.has(item.id),
346
+ handBack: (item, reply) => {
347
+ const fresh = this.store.readItem?.(item.product, item.id) ?? item;
348
+ this.spawnWorker(fresh, { continuation: true, shipFailure: reply });
349
+ },
350
+ afterShip: () => this.onShipped(),
351
+ });
304
352
  this._compactionJobs = new Map();
305
353
  this.sessions = new Map(); // itemId -> {child, product, startedAt, tail, itemId}
306
354
  // Spawns waiting on their row's folder, and folders made for the spawn that
@@ -318,6 +366,8 @@ export class Supervisor {
318
366
  // Persisted: an in-memory-only set re-spawned a continuation for every
319
367
  // still-open answered item on every app restart.
320
368
  this._handledAnswers = new Set(state.handledAnswers ?? []);
369
+ // Which conversation already holds each of her words (`_noteHeard`).
370
+ this._heardAnswers = state.heardAnswers ?? {};
321
371
  // `_personalSessions` used to sit here, itemId -> claude session id for a
322
372
  // personal project's threads. Personal projects are gone (w-d19d6d387c)
323
373
  // and `_rowSessions` below does that job for every row there is.
@@ -344,8 +394,7 @@ export class Supervisor {
344
394
  // and briefed a stranger with a transcript of the whole thread to catch
345
395
  // up on.
346
396
  //
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
397
+ // MEASURED across a few thousand runs: most were replies like that, and in the median one 65% of the first twenty files
349
398
  // it opened had already been opened by the run before it on the same row,
350
399
  // which then read for 206 seconds before it wrote anything.
351
400
  //
@@ -376,8 +425,8 @@ export class Supervisor {
376
425
  // WHAT IS WRONG WITH EACH ACCOUNT, KEPT ACROSS A RESTART (w-cf0e8821b3).
377
426
  //
378
427
  // 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.
428
+ // knowing the reason, and half a fact is worse than either the whole
429
+ // thing or silence.
381
430
  //
382
431
  // Restoring it cannot strand her, because nothing here is believed for
383
432
  // long: the first session that survives on an account clears it
@@ -389,7 +438,7 @@ export class Supervisor {
389
438
  // important first. Agents never write it. It replaced three buckets
390
439
  // (high/normal/low) because buckets left every tie inside a bucket
391
440
  // unanswered, and "which of these two comes first" is the actual
392
- // question the fleet needs settled (founder, 2026-08-06).
441
+ // question the fleet needs settled.
393
442
  this.productOrder = normalizeOrder(state.productOrder ?? orderFromTiers(state.productTiers));
394
443
  // Hidden from the composer's picker, and from nothing else. A hidden
395
444
  // product still ranks, still spawns workers, and its work still reaches
@@ -398,6 +447,7 @@ export class Supervisor {
398
447
  this.hiddenProducts = new Set(state.hiddenProducts ?? []);
399
448
  } catch {
400
449
  this._handledAnswers = new Set();
450
+ this._heardAnswers = {};
401
451
  this._liveSessions = {};
402
452
  this._rowSessions = {};
403
453
  this._compactions = {};
@@ -440,6 +490,15 @@ export class Supervisor {
440
490
  // slot we just made — which is a kill that cost her a session and bought
441
491
  // her nothing.
442
492
  this._preempted = new Map();
493
+ // TASKS SHE PUSHED WITH "RUN NOW", from the three-dot menu on a waiting
494
+ // task (`runNow` below). itemId -> { product, at, started }.
495
+ //
496
+ // In memory only, for the reason `_resumeQueue` gives: this is her intent
497
+ // right now, and a push that survived a restart would jump a task she
498
+ // asked for hours ago over everything filed since. The entry stays while
499
+ // the pushed task runs, so a second push cannot pause the first, and goes
500
+ // the moment that run ends (`_settleRunNow`).
501
+ this._runNow = new Map();
443
502
  // Rows the toast has already spoken about. In memory only: a restart is a
444
503
  // sweep she has not been told about yet, so it gets to speak once.
445
504
  this._announcedRecovery = new Set();
@@ -485,9 +544,8 @@ export class Supervisor {
485
544
  // `isPersonal` used to live here. A personal project ran its sessions on the
486
545
  // founder's message alone, with no brief, no store and no digest, and every
487
546
  // 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
547
+ // deleted (w-d19d6d387c), because there is no case where a project should
548
+ // run without its brief and store. Every project is now a project, and a reply on any row still goes back to the
491
549
  // session that wrote it through `_rowSessions`.
492
550
 
493
551
  /**
@@ -528,6 +586,8 @@ export class Supervisor {
528
586
  // the restart she ruled out. The sweep gets there first and hands each row
529
587
  // back to the session that was doing it.
530
588
  try { this.recoverInterrupted('startup'); } catch (e) { console.warn('zero: startup recovery:', e.message); }
589
+ if (this.config.memoryGate) this.setMemoryGate(true).catch((e) => console.warn('zero: memory gate:', e.message));
590
+ this.setLeftoverCleanup(!!this.config.cleanupLeftovers).catch((e) => console.warn('zero: leftovers:', e.message));
531
591
  this._timer = setInterval(() => this.tick().catch((e) => console.warn('supervisor:', e.message)), POLL_MS);
532
592
  this.tick().catch(() => {});
533
593
  }
@@ -543,18 +603,77 @@ export class Supervisor {
543
603
  // and app restart charged the item a failed delivery attempt.
544
604
  /** HER OWN HAND OUTRANKS A TIMER (2026-09-22).
545
605
  * 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. */
606
+ * Resume, reopening a row. The streak count is kept, so a real outage still
607
+ * escalates.
608
+ *
609
+ * AN ACCOUNT WAITING ON A PERSON (signed out, switched off by an admin) IS
610
+ * LIFTED TOO WHEN NOTHING ELSE ON ITS ENGINE COULD RUN (2026-10-04). It used
611
+ * to stay out on the theory that retrying cannot help, but she IS the person:
612
+ * she signs back in, presses Resume, and that is the only signal the app
613
+ * gets. With only one login, a park of half an hour meant every Resume and
614
+ * "continue" in between left the rows queued. If she is still signed out
615
+ * the trial dies in two seconds and parks it again on the spot. Where another
616
+ * login is working, the dead one stays out: the work is moving without it. */
550
617
  liftBrakeForHer() {
551
618
  this._spawnCooldownUntil = 0;
552
619
  for (const account of Object.keys(this._profileCooldown ?? {})) {
553
- if (needsHerHands(this._profileTrouble?.[account]?.cause)) continue;
620
+ if (needsHerHands(this._profileTrouble?.[account]?.cause) && this._anotherAccountCanRun(account)) continue;
554
621
  this._profileCooldown[account] = 0;
555
622
  }
556
623
  }
557
624
 
625
+ /**
626
+ * A LOGIN THAT LANDED PUTS ITS ACCOUNT STRAIGHT BACK.
627
+ *
628
+ * Trouble on an account is only cleared by a session surviving on it, and a
629
+ * signed-out account was benched so that no session could start on it. So
630
+ * nothing she did could bring it back, and the bench ran out on its own half
631
+ * an hour later (2026-10-04). Here every tick asks whether the files a login
632
+ * writes have moved since the account was found signed out. If they have,
633
+ * the account gets one try now. The trouble itself stays until that try
634
+ * survives, so a write that was not a login (another Claude window touching
635
+ * the same file) buys one two-second try and no more: the stamp moves with
636
+ * it, and the next lift needs the next write.
637
+ */
638
+ _noticeSignIns() {
639
+ let lifted = false;
640
+ for (const [key, trouble] of Object.entries(this._profileTrouble ?? {})) {
641
+ if (trouble?.cause !== 'signed-out') continue;
642
+ const stamp = signInStamp(this._signInFilesFor(key));
643
+ if (!stamp || stamp <= (trouble.stamp ?? trouble.at ?? 0)) continue;
644
+ trouble.stamp = stamp;
645
+ if (this._profileCooldown) this._profileCooldown[key] = 0;
646
+ if (!key.startsWith('codex:')) this._spawnCooldownUntil = 0;
647
+ lifted = true;
648
+ }
649
+ if (lifted) this._saveState();
650
+ return lifted;
651
+ }
652
+
653
+ /** Which files one account's login writes, by the key its trouble is kept under. */
654
+ _signInFilesFor(key) {
655
+ const home = this.config.home || os.homedir();
656
+ if (key.startsWith('codex:')) {
657
+ const profile = key.slice('codex:'.length);
658
+ return signInFiles({ engine: 'codex', folder: this._codexProfileHome(profile), home });
659
+ }
660
+ return signInFiles({ folder: key === 'default' ? null : key, home });
661
+ }
662
+
663
+ /** Whether one Codex login has a sign-in on disk at all. Only the file's time is read. */
664
+ _codexSignedIn(profile) {
665
+ return signInStamp(this._signInFilesFor(`codex:${profile}`)) > 0;
666
+ }
667
+
668
+ // Whether some OTHER login on this account's engine could run once her hand
669
+ // has lifted what time alone would lift, i.e. one not waiting on a person.
670
+ _anotherAccountCanRun(account) {
671
+ const engine = String(account).startsWith('codex:') ? 'codex' : DEFAULT_ENGINE;
672
+ return this._profilesFor(engine)
673
+ .map((p) => this._accountKey(engine, p))
674
+ .some((key) => key !== account && !needsHerHands(this._profileTrouble?.[key]?.cause));
675
+ }
676
+
558
677
  _kill(session) {
559
678
  if (!session) return false;
560
679
  session.stoppedByUs = true;
@@ -574,7 +693,15 @@ export class Supervisor {
574
693
  // a stopped fleet and spawn one more worker on the way out.
575
694
  clearTimeout(this._wakeTimer);
576
695
  this._wakeTimer = null;
577
- // And a spawn still waiting on its folder never starts.
696
+ // And a spawn still waiting on its folder never starts. ITS REPLY GOES BACK
697
+ // IN LINE: the tick saved the reply as delivered when it picked it, and a
698
+ // start that never happened delivered nothing. Left standing, the saved
699
+ // mark had the restarted app skip the reply for good, with no agent on it:
700
+ // an Urgent task sat over an hour while a Medium in its project ran
701
+ // (w-53a72e6e7f, tests/a-reply-waiting-for-its-folder-survives-a-restart).
702
+ for (const { item, opts } of this._preparing?.values() ?? []) {
703
+ if (opts?.continuation && item?.answer) this.redeliverAnswer(item, item.answer);
704
+ }
578
705
  this._preparing?.clear();
579
706
  for (const s of this.sessions.values()) this._kill(s);
580
707
  this.sessions.clear();
@@ -589,6 +716,18 @@ export class Supervisor {
589
716
  return this._kill(session);
590
717
  }
591
718
 
719
+ // SEND NOW, on a message of hers already waiting for the agent's current step
720
+ // to end: cut that step so the message is answered at once
721
+ // (`interrupt` in claude-input.mjs). The run goes on; only the step stops.
722
+ // An engine with no way to cut a step takes the message at its next one.
723
+ async sendNow(product, itemId) {
724
+ const session = this.sessions.get(itemId);
725
+ if (!session || session.product !== product) return { ok: false, interrupted: false };
726
+ if (typeof session.child?.interrupt !== 'function') return { ok: true, interrupted: false };
727
+ const { interrupted } = await session.child.interrupt();
728
+ return { ok: true, interrupted };
729
+ }
730
+
592
731
  // App quit: take the workers down with the supervisor. Killing the app used
593
732
  // to orphan every running claude session; six orphans from before a restart
594
733
  // kept working under stale permission rules and flooded the founder with
@@ -597,6 +736,8 @@ export class Supervisor {
597
736
  killAll() {
598
737
  for (const session of this.sessions.values()) this._kill(session);
599
738
  this._closeCodex(`${Name} is quitting`);
739
+ this.stopMemoryGate();
740
+ clearInterval(this._leftoverTimer);
600
741
  }
601
742
 
602
743
  // PUT A WORKER BACK ON THESE ROWS, WHATEVER STATE THEY ARE IN.
@@ -623,10 +764,19 @@ export class Supervisor {
623
764
  const out = { resumed: 0, queued: 0, working: 0, missing: 0 };
624
765
  if (!only.size) return out;
625
766
  this.liftBrakeForHer();
767
+ // RESUMING IS STILL ASKING TO RUN ON THIS MAC, so it obeys the same team
768
+ // rule the tick does (w-7fc38861be). It did not: on 2026-10-05 "Resume
769
+ // Agents" over a handful of ticked rows reached two conversations with
770
+ // teammates, and an agent turned up in the middle of each, reading the
771
+ // last thread reply as an instruction. Asking by name outranks a delivery
772
+ // mark and the attempt cap below; it does not make a row this Mac's to run.
773
+ const productBySlug = new Map((this.store.listProducts?.() ?? []).map((p) => [p.slug, p]));
774
+ const me = process.env.AGENTBOX_PERSON_ID || null;
626
775
 
627
776
  for (const item of this.store.listItems(Date.now())) {
628
777
  if (!only.has(item.id)) continue;
629
778
  only.delete(item.id);
779
+ if (!mayRunHere(item, productBySlug.get(item.product), me)) continue;
630
780
  // A row with a worker on it is already resumed. Saying so is the honest
631
781
  // answer; killing the session to restart it would throw away the work in
632
782
  // flight, which is never what "resume" meant.
@@ -729,11 +879,12 @@ export class Supervisor {
729
879
  // worker is on it, and that promise is exactly what sleep breaks. So a
730
880
  // stranded row is a row she cannot see.
731
881
  //
732
- // HER RULE, and the only reason this is not three lines: resume, never
882
+ // THE RULE, and the only reason this is not three lines: resume, never
733
883
  // restart. A row whose session is still on disk gets that session back, with
734
884
  // everything it had read and written. A row whose session is not gets NOTHING
735
885
  // 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.
886
+ // zero. It waits for the user, which is deliberately preferred over
887
+ // restarting it.
737
888
  recoverInterrupted(reason = 'startup') {
738
889
  const out = { reason, resumed: 0, waiting: 0, queued: 0, running: 0, resting: 0, missing: 0, resumedIds: [], waitingIds: [], missingIds: [] };
739
890
  this._recoverPending = false;
@@ -784,12 +935,12 @@ export class Supervisor {
784
935
  // Resume forces the row's own profile, and it has to: the session lives
785
936
  // in that account's home and in no other, so a resume is only a resume on
786
937
  // 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.
938
+ // quarantine `_pickProfile` respects, and that once cost seven and a
939
+ // half hours. A second subscription's login expired; two rows had their
940
+ // sessions on it; every sweep resumed both, both died in two seconds,
941
+ // both were still stranded for the next sweep. Four dead spawns a minute,
942
+ // two of three slots gone to them on every pass, and the healthy account
943
+ // never got to start anything.
793
944
  //
794
945
  // Resting is not `waiting`. Waiting is her word for a session that is
795
946
  // gone off disk and needs a person; this session is intact and this
@@ -878,14 +1029,11 @@ export class Supervisor {
878
1029
  //
879
1030
  // Ordering was already fixed and was not enough. put every spawnable row into
880
1031
  // 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.
1032
+ // one. Measured over the five days after that fix landed: about a third of
1033
+ // answers on Urgent rows waited longer than five minutes to start, most of
1034
+ // them behind a lower-priority session that was already running, the worst
1035
+ // single wait an hour, and the sessions in front of them ran a median of 21
1036
+ // minutes.
889
1037
  //
890
1038
  // WHAT MAKES THIS SAFE IS THAT THE WORK IS KEPT, and it is kept by machinery
891
1039
  // that already existed rather than anything invented here. `_kill` marks the
@@ -910,7 +1058,15 @@ export class Supervisor {
910
1058
  // she would have lost a running worker for nothing. So the candidate list is
911
1059
  // scoped to the engine the urgent row is going to spawn on, and a full fleet
912
1060
  // on the OTHER engine is simply not this row's problem.
1061
+ //
1062
+ // A ROW SHE PUSHED WITH "RUN NOW" TAKES A SLOT TOO, by the same door and with
1063
+ // the same promise that what it pauses comes back as itself. Two things are
1064
+ // different, both because she asked for this row by name, this minute: it
1065
+ // may pause anything that is not itself pushed, whatever its project or tag,
1066
+ // and the empty-runs rule below does not apply to it.
913
1067
  _preemptFor(item, items, engine = DEFAULT_ENGINE) {
1068
+ const pushed = this._runNow.has(item.id);
1069
+ if (pushed) return this._takeSlotFor(item, items, engine, (victim) => !this._runNow.has(victim.id));
914
1070
  if (!isUrgent(item)) return null;
915
1071
  // A ROW WORKERS KEEP LEAVING ALONE MAY NOT KILL ANYTHING.
916
1072
  //
@@ -929,12 +1085,24 @@ export class Supervisor {
929
1085
  // answer restores the interrupt immediately — which is the case that
930
1086
  // matters most, an Urgent row she has just replied to.
931
1087
  if (this._emptyRuns(item) > 1) return null;
1088
+ // STRICTLY lower. Equal scores do not interrupt each other: two Urgent
1089
+ // rows in the same product would otherwise take turns killing each
1090
+ // other for as long as they both existed. Nor does it pause a row she
1091
+ // pushed with Run now, which she asked for by name.
1092
+ const want = this._score(item);
1093
+ return this._takeSlotFor(item, items, engine,
1094
+ (victim) => this._score(victim) < want && !this._runNow.has(victim.id));
1095
+ }
1096
+
1097
+ // The half of an interruption both doors share: pick the session to pause,
1098
+ // pause it, and hold it out of the queue until `item` is running.
1099
+ // `mayTake(victim)` is the door's own rule about whom it may pause.
1100
+ _takeSlotFor(item, items, engine, mayTake) {
932
1101
  // A slot taken for a row that cannot take it is a kill that buys her
933
1102
  // nothing. The queue's own guards cover the answered case; this covers a
934
1103
  // lease somebody else still holds, which would refuse this worker the
935
1104
  // claim whatever we killed to make room for it.
936
1105
  if (this.claimHeldElsewhere(item)) return null;
937
- const want = this._score(item);
938
1106
  const candidates = [];
939
1107
  for (const session of this.sessions.values()) {
940
1108
  const id = session.itemId;
@@ -949,10 +1117,7 @@ export class Supervisor {
949
1117
  // since been archived. We cannot score it and we cannot resume it onto
950
1118
  // anything, so it is left alone.
951
1119
  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;
1120
+ if (!mayTake(victim)) continue;
956
1121
  const rec = this._liveSessions[id];
957
1122
  // The two halves of "the work is kept". Without the record there is no
958
1123
  // session id to resume; without the transcript the CLI's --resume exits
@@ -998,12 +1163,60 @@ export class Supervisor {
998
1163
  const done = this.sessions.has(rec.forItem)
999
1164
  || !target
1000
1165
  || (target.status !== 'open' && target.status !== 'claimed')
1001
- || !isUrgent(target)
1166
+ || (!isUrgent(target) && !this._runNow.has(target.id))
1002
1167
  || now - rec.at > PREEMPTION_HOLD_MS;
1003
1168
  if (done) this._preempted.delete(id);
1004
1169
  }
1005
1170
  }
1006
1171
 
1172
+ /**
1173
+ * RUN THIS ONE NOW: the three-dot menu's row on a waiting task.
1174
+ *
1175
+ * Her tag on a task could not do this. A project's place in her order is
1176
+ * worth a hundred times any tag (shared/rank.mjs), so an Urgent task in a
1177
+ * lower project waited behind a Low one in a higher project and could not
1178
+ * interrupt it either; lowering the other tasks only reordered the waiting
1179
+ * line, never a running one. So this is not a priority at all. It is a
1180
+ * one-time push: the task goes to the front of the line, and if every slot
1181
+ * on its engine is full the tick pauses the least important running task to
1182
+ * make room (`_preemptFor`), which comes back as its own session afterwards.
1183
+ * The task's tag and everything else's order are left exactly as they were.
1184
+ */
1185
+ runNow(product, id) {
1186
+ const item = this.store.listItems(Date.now()).find((i) => i.id === id && i.product === product);
1187
+ if (!item) return { ok: false, reason: 'missing' };
1188
+ if (this.sessions.has(id)) return { ok: false, reason: 'running' };
1189
+ // A PUSH NOTHING WILL ACT ON IS REFUSED OUT LOUD. The menu now offers Run
1190
+ // now on anything In progress (renderer/src/run-now.ts), which takes in
1191
+ // rows the tick is not going to start. Accepting those would read as Up
1192
+ // next while nothing moved, so each says why instead.
1193
+ if (this.paused) return { ok: false, reason: 'paused' };
1194
+ if (!this.store.isDue(item, Date.now())) return { ok: false, reason: 'scheduled' };
1195
+ if (this.claimHeldElsewhere(item)) return { ok: false, reason: 'held' };
1196
+ this._runNow.set(id, { product, at: Date.now(), started: false });
1197
+ // Asking for a row by name outranks a rest the fleet earned on it, the
1198
+ // same way `resumeItems` treats it.
1199
+ if (this._fruitless[`${product}:${id}`]) {
1200
+ delete this._fruitless[`${product}:${id}`];
1201
+ this._saveState();
1202
+ }
1203
+ this.onChange?.();
1204
+ this.wake();
1205
+ return { ok: true };
1206
+ }
1207
+
1208
+ // A push lasts while its task runs, so a second push cannot pause the first,
1209
+ // and ends with that run. One that never starts ends when the row stops being
1210
+ // open work: finished, archived, or gone.
1211
+ _settleRunNow(items) {
1212
+ for (const [id, rec] of [...this._runNow]) {
1213
+ const item = items.find((i) => i.id === id && i.product === rec.product);
1214
+ if (!item || (item.status !== 'open' && item.status !== 'claimed')) { this._runNow.delete(id); continue; }
1215
+ if (this.sessions.has(id)) rec.started = true;
1216
+ else if (rec.started) this._runNow.delete(id);
1217
+ }
1218
+ }
1219
+
1007
1220
  // AN ACCOUNT THAT IS OUT OF THE ROTATION RIGHT NOW.
1008
1221
  //
1009
1222
  // The cooldown and nothing else, deliberately. `_liveProfiles` falls back to
@@ -1020,14 +1233,13 @@ export class Supervisor {
1020
1233
  * AN ACCOUNT ONLY A PERSON CAN MEND HOLDS NO CHAT.
1021
1234
  *
1022
1235
  * 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
1236
+ * which, and every reply the user writes goes back there, because a transcript
1024
1237
  * sits under one profile's home and under no other.
1025
1238
  *
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
1239
+ * It was. Chats remembered on an account whose organization had Claude Code
1240
+ * switched off died again and again, each death about twenty seconds after a
1241
+ * reply on the row, while another subscription worked throughout. Some of
1242
+ * those spawns went out INSIDE the thirty-minute
1031
1243
  * quarantine this file had already put that account in: `_recoverPending`'s
1032
1244
  * sweep checks the quarantine and the reply path never did.
1033
1245
  *
@@ -1042,11 +1254,11 @@ export class Supervisor {
1042
1254
  * Losing a chat costs one fresh brief, which is what every row got before
1043
1255
  * chats existed, so the worst case of this guard is the old normal.
1044
1256
  *
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
1257
+ * A USAGE LIMIT IS NOW A THIRD HALF, and a PER-ENGINE one (w-ca48e69535).
1258
+ * "Worth waiting out" was written about a five-hour limit. A Codex login
1259
+ * that hits a WEEKLY one can be days from resetting, and a reply sat on it
1260
+ * while the other login was at 0%. A user who has switched accounts expects
1261
+ * the work to run on the new one. So an account resting on its
1050
1262
  * limit gives the chat up too, when another account on the SAME engine can
1051
1263
  * run. This used to read `_healthyProfiles` and an unkeyed trouble entry,
1052
1264
  * both Claude-only, so it could never fire for a Codex row at all. */
@@ -1103,11 +1315,11 @@ export class Supervisor {
1103
1315
 
1104
1316
  /**
1105
1317
  * 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. */
1318
+ * from whether it has a free slot. Claude Code answers yes for itself unless
1319
+ * this Mac runs on Codex alone: with neither, the app refuses to open an
1320
+ * inbox and says so in its own words, so a second sentence here is noise. */
1109
1321
  _engineCanRun(engine) {
1110
- return engineOf(engine) === DEFAULT_ENGINE ? true : !!this.config.codexBin;
1322
+ return engineOf(engine) === DEFAULT_ENGINE ? this._homeEngine() === DEFAULT_ENGINE : !!this.config.codexBin;
1111
1323
  }
1112
1324
 
1113
1325
  /**
@@ -1124,7 +1336,7 @@ export class Supervisor {
1124
1336
  const word = engineLabel(engine);
1125
1337
  try {
1126
1338
  this.store.recordSessionResult(item.product, item.id, {
1127
- result: `This task was being worked on in ${word}, which is not installed on this Mac, so ${NAME} has not put a session back on it. It is not lost: install ${word} and the same session picks up where it stopped.`,
1339
+ result: `This task was being worked on in ${word}, which is not installed on this computer, so ${NAME} has not put a session back on it. It is not lost: install ${word} and the same session picks up where it stopped.`,
1128
1340
  status: 'open',
1129
1341
  });
1130
1342
  } catch (e) { console.warn('zero: could not say the harness is missing:', e.message); }
@@ -1239,15 +1451,15 @@ export class Supervisor {
1239
1451
  //
1240
1452
  // Until this, every session on a product ran in the one checkout the product
1241
1453
  // 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.
1454
+ // brief rather than anything the app did. Measured: 25 leftover copies of
1455
+ // this repository holding 26 GB, twelve of them carrying edits committed
1456
+ // nowhere, because the rule's first half (make a copy) is kept by almost
1457
+ // every session and its second half (fold it back, delete it) by almost
1458
+ // none. So the app makes the folder, for both engines. main/task-folders.mjs
1459
+ // is the whole of how.
1248
1460
  //
1249
1461
  // 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
1462
+ // repository, for a personal chat, and any time making the
1251
1463
  // folder fails. Nothing here may ever stop a spawn: a session that runs in
1252
1464
  // the shared checkout is what happened yesterday, and a session that does not
1253
1465
  // run is a row she never hears back on.
@@ -1353,6 +1565,39 @@ export class Supervisor {
1353
1565
  }
1354
1566
  }
1355
1567
 
1568
+ /**
1569
+ * A SESSION LETS GO OF ITS ROW, AND ONLY OF ITS OWN ROW.
1570
+ *
1571
+ * One entry in the session map per row, so two sessions on one row share it,
1572
+ * and an exit used to delete that entry whoever it belonged to. Measured on
1573
+ * w-4d722ecf92, 2026-09-30: the row's folder had been built at the path a
1574
+ * session runs in, a tick mid-build started a worker in it, the finished
1575
+ * build started a second worker on the same row, and when the first exited it
1576
+ * deleted the entry belonging to the one still running and handed its folder
1577
+ * back. A folder freshly made at main is clean and already in main, which is
1578
+ * exactly the case `releaseTaskFolder` DELETES rather than keeps, so the
1579
+ * ground went out from under a live agent about a minute in.
1580
+ *
1581
+ * The build is atomic now (`buildingFolderPath`) so two sessions should not
1582
+ * reach one row at all. This is the second lock on the same door, because the
1583
+ * cost of being wrong here is an agent's work deleted mid run.
1584
+ */
1585
+ endSession(item, session, product) {
1586
+ if (session && this.sessions.get(item.id) !== session) {
1587
+ console.warn(`zero: a later session is on ${item.id}, so this one leaves its folder alone`);
1588
+ return Promise.resolve(null);
1589
+ }
1590
+ this.sessions.delete(item.id);
1591
+ // The run is over, said by the only app that runs this row; what it left
1592
+ // running may now be stopped on the cleaner's clock (main/leftovers.mjs).
1593
+ this._leftoverCleaner?.ended(item.id);
1594
+ // The row's folder goes back. It is only deleted when losing it costs
1595
+ // nothing, so the usual answer is that it stays, holding work she has not
1596
+ // approved. The same call sweeps the folders whose work has since landed in
1597
+ // main, which is what stops them piling up unseen.
1598
+ return Promise.resolve(this.releaseWorkFolder(item, product));
1599
+ }
1600
+
1356
1601
  /**
1357
1602
  * THE ROW'S FOLDER, MADE BEFORE THE SPAWN AND NOT ON THIS THREAD.
1358
1603
  *
@@ -1379,9 +1624,18 @@ export class Supervisor {
1379
1624
  const base = this.productFolder(product);
1380
1625
  let folder = null;
1381
1626
  try { folder = taskFolderPath(base, item.id); } catch { return false; }
1382
- if (fs.existsSync(folder)) return false;
1627
+ // WHO IS ALREADY MAKING ONE IS ASKED BEFORE THE DISK IS, and that order is
1628
+ // the whole of it. A build takes about ten seconds, and it used to leave a
1629
+ // directory at this path for all of them, so a tick arriving mid-build read
1630
+ // "the folder is there" and started a session in a folder holding almost
1631
+ // none of the code. The build then finished and started its own session on
1632
+ // the same row: two workers, and the second one's claim refused
1633
+ // (2026-09-30, w-4d722ecf92). The folder is built elsewhere and moved in
1634
+ // now, so the disk no longer lies here, and this answers first anyway
1635
+ // because a row somebody is already preparing is never a row to spawn on.
1383
1636
  const held = this._preparing.get(item.id);
1384
1637
  if (held) { held.item = item; held.opts = opts; return true; }
1638
+ if (fs.existsSync(folder)) return false;
1385
1639
  const entry = { item, opts, engine };
1386
1640
  this._preparing.set(item.id, entry);
1387
1641
  folderJob('restoreTaskFolder', base, item.id)
@@ -1393,6 +1647,13 @@ export class Supervisor {
1393
1647
  // Stopped, or the app quit, while the folder was being made.
1394
1648
  if (this._preparing.get(item.id) !== entry) return;
1395
1649
  this._preparing.delete(item.id);
1650
+ // AND A SESSION THAT ARRIVED WHILE WE WERE BUILDING ALREADY HAS THIS
1651
+ // ROW. Starting a second one here is what gave w-4d722ecf92 two workers
1652
+ // and had the second one's claim refused. A spawn cannot reach this
1653
+ // point through `_folderFirst` any more, but a remote-control spawn
1654
+ // skips it entirely, so the one place that starts a worker without
1655
+ // anybody having just checked asks for itself.
1656
+ if (this.sessions.has(item.id)) { this.onChange?.(); return; }
1396
1657
  this._madeFolders.set(item.id, cwd);
1397
1658
  try { this.spawnWorker(entry.item, entry.opts); }
1398
1659
  catch (error) { console.warn(`zero: could not start ${item.id}:`, error.message); }
@@ -1402,6 +1663,45 @@ export class Supervisor {
1402
1663
  return true;
1403
1664
  }
1404
1665
 
1666
+ /**
1667
+ * THE CHECKOUT, PHOTOGRAPHED OFF THE MAIN THREAD BEFORE THE RUN STARTS
1668
+ * (2026-10-01, main/git-change-offthread.mjs says why). The same pending
1669
+ * entry `_folderFirst` uses, so a row being photographed counts against its
1670
+ * slot and Stop cancels it exactly as it cancels a folder being made.
1671
+ */
1672
+ _photoFirst(item, product, engine, opts) {
1673
+ if (opts.remoteOnly) return false;
1674
+ this._photos ??= new Map();
1675
+ if (this._photos.has(item.id)) return false;
1676
+ const held = this._preparing?.get(item.id);
1677
+ if (held) { held.item = item; held.opts = opts; return true; }
1678
+ let cwd = null;
1679
+ try { cwd = this.workFolderFor(item, product); } catch { return false; }
1680
+ // Only a folder that is itself a checkout (a task folder carries a `.git`
1681
+ // file, a repository a `.git` folder). Anything else has nothing to
1682
+ // photograph, and keeps starting in the same call as before.
1683
+ if (!cwd || !fs.existsSync(path.join(cwd, '.git'))) return false;
1684
+ this._preparing ??= new Map();
1685
+ const entry = { item, opts, engine, madeFolder: this._madeFolders?.get(item.id) };
1686
+ this._preparing.set(item.id, entry);
1687
+ gitJob('snapshotRepo', cwd)
1688
+ .then((photo) => photo ?? null, () => null)
1689
+ .then((photo) => {
1690
+ if (this._preparing.get(item.id) !== entry) return;
1691
+ this._preparing.delete(item.id);
1692
+ this._photos.set(item.id, photo);
1693
+ if (entry.madeFolder !== undefined) { this._madeFolders ??= new Map(); this._madeFolders.set(item.id, entry.madeFolder); }
1694
+ try { this.spawnWorker(entry.item, entry.opts); }
1695
+ catch (error) { console.warn(`zero: could not start ${item.id}:`, error.message); }
1696
+ finally {
1697
+ this._photos.delete(item.id);
1698
+ if (entry.madeFolder !== undefined) this._madeFolders?.delete(item.id);
1699
+ }
1700
+ this.onChange?.();
1701
+ });
1702
+ return true;
1703
+ }
1704
+
1405
1705
  /**
1406
1706
  * THE FOLDERS OF ROWS THAT ARE FINISHED, PUT AWAY.
1407
1707
  *
@@ -1464,6 +1764,15 @@ export class Supervisor {
1464
1764
  if (item) break;
1465
1765
  }
1466
1766
  if (item && item.status !== 'done') continue;
1767
+ // AND NEVER A ROW SOMEBODY IS STILL HOLDING, which is the rule the bug
1768
+ // report asked for: whatever cleans folders up must not remove one whose
1769
+ // row is claimed by a live session. The session map above is only
1770
+ // the sessions THIS process started, so a worker the app has lost track
1771
+ // of, or one whose lease has not run out yet, is invisible to it. The
1772
+ // claim in the ledger is the fact that outlives this process. The cost of
1773
+ // reading it is that a finished folder waits out a lease; the cost of not
1774
+ // reading it is an agent's folder deleted while it works (w-4d722ecf92).
1775
+ if (item?.claim && !item.claimExpired) continue;
1467
1776
  if (busy(folder.id)) continue;
1468
1777
  if ((await folderJob('parkTaskFolder', base, folder.id)).parked) put += 1;
1469
1778
  }
@@ -1497,7 +1806,7 @@ export class Supervisor {
1497
1806
  // But a bigger cap is not what makes it safe either, because CLAUDE CODE
1498
1807
  // DELETES ITS OWN TRANSCRIPTS AT THIRTY DAYS and an entry whose transcript
1499
1808
  // 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
1809
+ // across a real set of transcripts: 3,808 files, oldest 31.0 days, exactly
1501
1810
  // one over 30. So thirty days is the true life of a chat whatever we set
1502
1811
  // here, and the honest prune is by age, with the count left as a backstop
1503
1812
  // far above where it can bite: replaying her 1,605 real replies through this
@@ -1507,7 +1816,7 @@ export class Supervisor {
1507
1816
  // at 500. Re-run it all with `scripts/replay-the-forgetting-rule.mjs`.
1508
1817
  static get ROW_SESSION_CAP() { return 2000; }
1509
1818
 
1510
- // Claude Code's own `cleanupPeriodDays`, which neither of her settings files
1819
+ // Claude Code's own `cleanupPeriodDays`, which a settings file rarely
1511
1820
  // overrides. Past this the transcript is gone and the entry is dead weight.
1512
1821
  static get ROW_SESSION_MAX_AGE_MS() { return 30 * 24 * 60 * 60 * 1000; }
1513
1822
 
@@ -1595,6 +1904,20 @@ export class Supervisor {
1595
1904
  // throw away every chat she is in the middle of.
1596
1905
  const folders = product ? this.workFolders(item, product) : [];
1597
1906
  if (folders.length && rec.cwd && !folders.includes(rec.cwd)) return null;
1907
+ // A CHAT ON THE WRONG LOGIN IS NOT RESUMED IN A TIED PROJECT, and this is
1908
+ // the one question asked ahead of the health of the account. A chat started
1909
+ // before the project was tied lives on whatever account the rotation gave
1910
+ // it, and resuming it there would send the next reply -- and everything the
1911
+ // transcript already holds -- to exactly the login the tie exists to keep it
1912
+ // off. Dropped here, the row is briefed fresh on the right account.
1913
+ //
1914
+ // AND A TIED PROJECT KEEPS ITS CHAT WHILE THE PINNED ACCOUNT IS ILL, which
1915
+ // is why the test below is not simply "also check `_profileCannotHoldAChat`".
1916
+ // That guard moves a chat to a HEALTHIER account; a tied project has none to
1917
+ // move to, so firing it would throw the thread away and buy nothing.
1918
+ if (this._projectProfile(item.product, rec.engine)) {
1919
+ return this._profileFitsProject(rec.profile, item.product, rec.engine) ? rec : null;
1920
+ }
1598
1921
  // The account this chat lives on cannot run anything until somebody acts.
1599
1922
  // Null here is the fresh brief, which is exactly what this row needs: it
1600
1923
  // goes to a working account carrying its own thread.
@@ -1620,14 +1943,18 @@ export class Supervisor {
1620
1943
  const parts = [
1621
1944
  'The founder has replied on the work item you were just working on. This is',
1622
1945
  '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.',
1946
+ 'back over the files or the item to catch up.',
1947
+ '',
1948
+ 'Their newest message is the last one below, and it outranks anything',
1949
+ 'before it. Do what it asks first, before you go back to anything you were',
1950
+ 'doing; if it says to keep going afterwards, keep going then.',
1624
1951
  '',
1625
1952
  'Two things did change while you were stopped. Your claim on the row was',
1626
1953
  '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',
1954
+ 'And the row itself may have been rewritten since, by them or by another',
1628
1955
  'session, so read it back before you trust what you remember of it.',
1629
1956
  '',
1630
- 'What she said:',
1957
+ 'What they said:',
1631
1958
  '',
1632
1959
  String(item?.answer ?? ''),
1633
1960
  ];
@@ -1635,9 +1962,39 @@ export class Supervisor {
1635
1962
  // attached to the reply that woke it.
1636
1963
  const files = product ? this.attachmentBlock({ ...item, body: '', result: '', note: '' }, product) : '';
1637
1964
  if (files) parts.push(files);
1965
+ // A resumed session never saw the full brief's ship section if it began
1966
+ // before the project shipped through the app, and her "merge it" is
1967
+ // exactly when it needs it.
1968
+ if (product && this.shipsThroughTheApp(product)) parts.push(this.shipBrief());
1638
1969
  return parts.join('\n');
1639
1970
  }
1640
1971
 
1972
+ /** Whether this Mac ships this project's tasks itself (main/ship-queue.mjs). */
1973
+ shipsThroughTheApp(product) {
1974
+ return !!shipScriptFor(product, readShipSettings(this.userDir));
1975
+ }
1976
+
1977
+ // HOW A TASK SHIPS, when the app does it. The agent's last step is a label,
1978
+ // not a push: a push or a move of another checkout from inside an agent
1979
+ // session is what Claude Code's safety check refuses, and a refusal asks
1980
+ // nobody, so the task just stopped.
1981
+ shipBrief() {
1982
+ return [
1983
+ '',
1984
+ '# How this task ships',
1985
+ '',
1986
+ `${Name} ships this project's work itself. When the work is done and its tests`,
1987
+ 'pass, commit it on your branch in your task folder, then add the label',
1988
+ `\`${SHIP_LABEL}\` to this work item (update_work_item, keeping its other labels)`,
1989
+ 'and end your turn as usual. Whether to wait for approval first is set by the',
1990
+ 'instructions you were given, not by this.',
1991
+ '',
1992
+ `Do NOT push, do NOT merge into main, and do NOT touch any other folder. ${Name}`,
1993
+ 'merges the latest main into your branch, runs the tests, pushes, and updates',
1994
+ 'the app folder. If that fails you will be woken with its exact words.',
1995
+ ].join('\n');
1996
+ }
1997
+
1641
1998
  // WHAT A RESUMED WORKER IS TOLD, and why it is six lines rather than a brief.
1642
1999
  // Everything it knew is still in the session it is being handed back: the
1643
2000
  // work item, her instructions, every file it read. A second copy of
@@ -1654,13 +2011,15 @@ export class Supervisor {
1654
2011
  'and re-read the work item if you need to.',
1655
2012
  ];
1656
2013
  if (continuation && item?.answer && item.answer !== '(withdrawn)') {
1657
- parts.push('', 'The founder has answered since you stopped:', '', String(item.answer));
2014
+ parts.push('', 'The founder has answered since you stopped. Their newest message is the',
2015
+ 'last one below: do what it asks before you carry on with anything else.', '', String(item.answer));
1658
2016
  // A resumed session has everything it read still in it, EXCEPT anything
1659
2017
  // she attached to the reply that woke it. That is new, and it is the one
1660
2018
  // thing a six-line wake-up can genuinely be missing.
1661
2019
  const files = product ? this.attachmentBlock({ ...item, body: '', result: '', note: '' }, product) : '';
1662
2020
  if (files) parts.push(files);
1663
2021
  }
2022
+ if (product && this.shipsThroughTheApp(product)) parts.push(this.shipBrief());
1664
2023
  return parts.join('\n');
1665
2024
  }
1666
2025
 
@@ -1668,12 +2027,10 @@ export class Supervisor {
1668
2027
  * THE FIRST WORDS A FORK HEARS, AND WHY IT NEEDS DIFFERENT ONES.
1669
2028
  *
1670
2029
  * 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?"
2030
+ * remembers everything, INCLUDING which work item it was on. That is exactly
2031
+ * what it did: the fork of `w-5ebf7bf7bb` kept writing its results onto
2032
+ * `w-5ebf7bf7bb`, so one thread in the inbox had two agents talking in it and
2033
+ * the fork's own row sat silent, which looked as if forking did not work.
1677
2034
  *
1678
2035
  * It was working. Nobody had told it that it had moved. So the fork's first
1679
2036
  * message says which row it is on now, that the row it remembers belongs to
@@ -1689,7 +2046,7 @@ export class Supervisor {
1689
2046
  `Your work item is now ${item.id}: ${item.title}`,
1690
2047
  ...(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
2048
  '',
1692
- 'What she asked this fork to try:',
2049
+ 'What they asked this fork to try:',
1693
2050
  '',
1694
2051
  String(item.body ?? '').trim() || '(nothing beyond the fork itself)',
1695
2052
  ];
@@ -1698,8 +2055,8 @@ export class Supervisor {
1698
2055
  return parts.join('\n');
1699
2056
  }
1700
2057
 
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
2058
+ // Which delivery this is. Keyed on WHEN the user wrote, not only on what
2059
+ // they wrote: two identical nudges four minutes apart are two asks, and a key made
1703
2060
  // of the words alone made the second one already-handled, silently and
1704
2061
  // permanently. The old text-only key is still honoured on read so the marks
1705
2062
  // already on disk keep suppressing the spawns they were written for; they age
@@ -1717,6 +2074,24 @@ export class Supervisor {
1717
2074
  || this._handledAnswers.has(this._legacyAnswerKey(item, answer));
1718
2075
  }
1719
2076
 
2077
+ // WHICH CONVERSATION ALREADY HOLDS THESE WORDS, which is not whether a run
2078
+ // finished carrying them. A stop releases the delivery mark on purpose, so
2079
+ // the row gets another run, and the resume used to read that release as
2080
+ // "never heard" and hand the same conversation everything again: on
2081
+ // w-f37a34def6 a 2,000-character round of feedback it had acted on for forty
2082
+ // minutes, ahead of the one short ask it had not seen, which it then missed.
2083
+ // Written when a run starts on the words or takes them mid-run, and never
2084
+ // released, because a stop does not unhear anything.
2085
+ _noteHeard(item, sessionId, answer = item?.answer) {
2086
+ if (!sessionId || !item?.wrote?.answer) return;
2087
+ (this._heardAnswers ??= {})[this._answerKey(item, answer)] = sessionId;
2088
+ this._saveState();
2089
+ }
2090
+
2091
+ _heardIn(item, sessionId, answer = item?.answer) {
2092
+ return !!sessionId && this._heardAnswers?.[this._answerKey(item, answer)] === sessionId;
2093
+ }
2094
+
1720
2095
  // SOMEONE ELSE IS STILL HOLDING THIS ROW, so a worker sent to it now would be
1721
2096
  // refused it and could do nothing at all.
1722
2097
  //
@@ -1748,14 +2123,13 @@ export class Supervisor {
1748
2123
  // session exits, and the reason "stopped · 5h" rows existed at all.
1749
2124
  //
1750
2125
  // 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 / /).
2126
+ // the user's words, not a record that one did. Releasing it was already
2127
+ // understood to be necessary and was wired to the wrong predicate: sessions
2128
+ // dying inside 45 seconds. Items sat stranded for hours against that, because
2129
+ // they died at two to four minutes with `error_during_execution`, past the
2130
+ // window, so the mark stood, so no tick would ever look at them again. Nothing
2131
+ // retried and nothing said so. The user's words were in the ledger and no
2132
+ // worker would ever read them.
1759
2133
  //
1760
2134
  // Duration was never the question. A SESSION THAT DID NOT FINISH DELIVERED
1761
2135
  // NOTHING, however long it took not to finish, so the predicate is the
@@ -1769,7 +2143,7 @@ export class Supervisor {
1769
2143
  //
1770
2144
  // `session.stoppedByUs` is how that is known, and the signal is not. The CLI
1771
2145
  // 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
2146
+ // and this branch never once ran in production; three ordinary user actions
1773
2147
  // on one row exhausted its retries and stranded it for good. The signal is
1774
2148
  // still honoured for a child that dies without handling one.
1775
2149
  //
@@ -1781,7 +2155,7 @@ export class Supervisor {
1781
2155
  // AND "FINISHED" IS ABOUT THE ROW, NOT ABOUT THE PROCESS. A session that
1782
2156
  // exits with a result and no error has told us nothing about her item; the
1783
2157
  // 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
2158
+ // the user wrote. Without that check the mark was written on sessions that touched
1785
2159
  // nothing, and the row came straight back to her inbox saying what it said
1786
2160
  // before she answered (five times in two days; 22 seconds on). Treated as a
1787
2161
  // delivery that did not happen, which is what the retry counter below is
@@ -1793,7 +2167,7 @@ export class Supervisor {
1793
2167
  } catch { return true; }
1794
2168
  }
1795
2169
 
1796
- // WHICH OF HER WORDS THIS SESSION ACTUALLY RECEIVED, which is not always the
2170
+ // WHICH OF THE USER'S WORDS THIS SESSION ACTUALLY RECEIVED, which is not always the
1797
2171
  // one it was spawned for.
1798
2172
  //
1799
2173
  // A reply typed at a session that is RUNNING is steered straight into it
@@ -1805,15 +2179,14 @@ export class Supervisor {
1805
2179
  // "stopped" on the row, and nothing coming, because the live reply is marked
1806
2180
  // delivered and no continuation will ever be spawned for it.
1807
2181
  //
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.
2182
+ // MEASURED on real ledgers: a few percent of all settlements recorded an older
2183
+ // answer than the newest one on the row at that moment. On one row
2184
+ // (w-72aa8c0b8d) three rounds in a row settled the answer before the one just
2185
+ // sent, so a finished round with the result written on it never once reached
2186
+ // the inbox.
1814
2187
  //
1815
2188
  // 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.
2189
+ // last one steered into it, whichever was written later.
1817
2190
  carriedThrough(item, session) {
1818
2191
  return Math.max(answerTs(item), answerTs(session?.lastLiveReply));
1819
2192
  }
@@ -1826,7 +2199,7 @@ export class Supervisor {
1826
2199
  if (reply) this.redeliverAnswer(reply, reply.answer);
1827
2200
  }
1828
2201
 
1829
- // A REPLY SHE TYPED INTO A RUN THAT WAS NOT CARRYING AN ANSWER AT ALL.
2202
+ // A REPLY THE USER TYPED INTO A RUN THAT WAS NOT CARRYING AN ANSWER AT ALL.
1830
2203
  //
1831
2204
  // `settleDelivery` is only reached on a continuation, a session spawned
1832
2205
  // BECAUSE she had spoken. The other half of the same day is ordinary fresh
@@ -1836,7 +2209,7 @@ export class Supervisor {
1836
2209
  //
1837
2210
  // Same three outcomes as a continuation, without the retry counter: this
1838
2211
  // 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
2212
+ // give up on. Either the run spoke after the user's words, or the word goes back in
1840
2213
  // the queue for the next one.
1841
2214
  settleLiveReply(item, session) {
1842
2215
  const carried = answerTs(session?.lastLiveReply);
@@ -1855,7 +2228,7 @@ export class Supervisor {
1855
2228
  * @param {{ command?: boolean }} [how] `command` means her reply WAS one of
1856
2229
  * Claude Code's own commands and the run was that command, not a worker. Such
1857
2230
  * 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 —
2231
+ * the ordinary test below (did an agent speak on the row since the user wrote)
1859
2232
  * is one it can never pass. Without this it would be redelivered to the retry
1860
2233
  * cap and she would read the same context table three times.
1861
2234
  */
@@ -1875,7 +2248,7 @@ export class Supervisor {
1875
2248
  catch (e) { console.warn('zero: could not record the settled answer:', e.message); }
1876
2249
  return 'delivered';
1877
2250
  }
1878
- // THE WORD SHE TYPED INTO THE RUNNING SESSION IS NOT SETTLED EITHER, so it
2251
+ // THE WORD THE USER TYPED INTO THE RUNNING SESSION IS NOT SETTLED EITHER, so it
1879
2252
  // must go back in the queue with the one this session was spawned for.
1880
2253
  // Steering it in marks it delivered (live-replies.mjs), and a mark nothing
1881
2254
  // releases is a word no worker will ever be sent to carry.
@@ -1889,7 +2262,10 @@ export class Supervisor {
1889
2262
  // answered read "stopped", which CLAUDE.md says must never mean "and
1890
2263
  // nothing will retry". The account health of the engine is still charged
1891
2264
  // for it, in `noteExitForBackoff`; only the delivery is spared.
1892
- if (signal || session?.stoppedByUs || session?.transportFault) {
2265
+ // AND A FOURTH: the ACCOUNT refused it (signed out, out of usage, switched
2266
+ // off). Her words never reached a worker, so they wait for the account
2267
+ // rather than being spent on it (`accountFault`, noteExitForBackoff).
2268
+ if (signal || session?.stoppedByUs || session?.transportFault || session?.accountFault) {
1893
2269
  this.redeliverAnswer(item, answerAtSpawn); // saves state
1894
2270
  return 'interrupted';
1895
2271
  }
@@ -1906,16 +2282,15 @@ export class Supervisor {
1906
2282
  /**
1907
2283
  * SETTLE THE DELIVERY, AND WHEN WE STOP TRYING TO CARRY HER ANSWER, SAY SO.
1908
2284
  *
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
2285
+ * This is the worst shape of silence, because the user did something
2286
+ * first. They answer a row, a session is spawned to carry the
1911
2287
  * answer, it dies, three attempts go by and `settleDelivery` returns 'given
1912
2288
  * up' having written nothing anywhere she can see. The row goes on showing
1913
2289
  * the message she was replying to, so it reads exactly like a row nobody has
1914
2290
  * got to yet, and it reads that way forever.
1915
2291
  *
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.
2292
+ * MEASURED by scripts/what-her-answers-got-back.mjs: it is rare, but it
2293
+ * happens, and a row it happens to has several dead runs and nothing said back.
1919
2294
  *
1920
2295
  * ONLY ON 'given up'. A retry that is still coming may well work, and a
1921
2296
  * sentence written before then would be undone by the next run.
@@ -1989,13 +2364,36 @@ export class Supervisor {
1989
2364
  return seen.runs;
1990
2365
  }
1991
2366
 
2367
+ /**
2368
+ * THE LAST WORDS ON THIS ROW ARE OURS, ABOUT AN ACCOUNT, NOT AN AGENT'S.
2369
+ *
2370
+ * A fresh row whose run died on a signed-out login (or a usage limit) gets
2371
+ * our own sentence written onto it ("Claude Code could not sign in... it
2372
+ * picks straight up"), so she knows. That write is an agent-sourced result,
2373
+ * and `awaitingHer` reads any such result as an agent having answered her,
2374
+ * so the fresh-work pass walked past the row for good: the account came back
2375
+ * and the row never ran, the opposite of what the sentence promised. Proved
2376
+ * with real processes in
2377
+ * tests/a-signed-out-claude-comes-back-on-its-own-end-to-end (no run at all
2378
+ * after signing in, before this). True only when what we said was about the
2379
+ * account and nothing has been written on the row since, so a real answer
2380
+ * still waits for her. When the row may run is still `restingUntil`'s call.
2381
+ */
2382
+ _onlyWeSpokeForAnAccount(item) {
2383
+ const seen = this._fruitless[`${item.product}:${item.id}`];
2384
+ if (!seen?.saidTs || !(needsHerHands(seen.saidWhy) || seen.saidWhy === 'at-limit')) return false;
2385
+ return (item.wrote?.result?.ts ?? 0) <= seen.saidTs;
2386
+ }
2387
+
1992
2388
  restingUntil(item) {
1993
2389
  const seen = this._fruitless[`${item.product}:${item.id}`];
1994
2390
  const runs = this._emptyRuns(item);
1995
2391
  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."
2392
+ // Rested because its ACCOUNT refused it: awake as soon as the account is.
2393
+ if (seen?.account && !this._profileResting(seen.account)) return 0;
2394
+ // AN URGENT ROW NEVER CLIMBS THE LADDER. Urgent outranks High, and is
2395
+ // even meant to INTERRUPT High and below, so it must never end up waiting
2396
+ // behind them.
1999
2397
  //
2000
2398
  // Both fixes she already has are about the ORDER of the queue: one scored
2001
2399
  // list, then an Urgent row taking a slot off a lesser session. Neither
@@ -2004,11 +2402,10 @@ export class Supervisor {
2004
2402
  // row is never ranked against anything at all. Priority only ever decided
2005
2403
  // who wins among the awake.
2006
2404
  //
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.
2405
+ // Measured: an Urgent row with three empty runs slept for four hours and
2406
+ // another with five slept a full twenty-four, while dozens of sessions
2407
+ // started on other rows inside that sleep, every one of them High, Medium
2408
+ // or Low.
2012
2409
  //
2013
2410
  // The cap is the FIRST RUNG rather than nothing at all, and it is the same
2014
2411
  // answer this file already gives a spawn that died on arrival: fifteen
@@ -2037,7 +2434,7 @@ export class Supervisor {
2037
2434
  // blocked it, finished it, or drew an answer out of the founder has moved it,
2038
2435
  // and the count is dropped.
2039
2436
  /**
2040
- * PUT THE FAILURE ON THE ROW, IN HER WORDS.
2437
+ * PUT THE FAILURE ON THE ROW, IN PLAIN WORDS.
2041
2438
  *
2042
2439
  * Whether a run needs this said for it is `saidNothingSheCanUse` at the foot
2043
2440
  * of this file, which is the rule that decides between the two writers.
@@ -2071,6 +2468,26 @@ export class Supervisor {
2071
2468
  * says so on the row and parks it with her, because a third try is unlikely
2072
2469
  * to go differently and a silent loop is the thing being fixed.
2073
2470
  */
2471
+ /**
2472
+ * TIME THE MAC SPENT ASLEEP IS NOT SILENCE.
2473
+ *
2474
+ * Both of the hang check's signals are made by a sleep: no output, because
2475
+ * nothing ran, and a lapsed claim, because the heartbeats were frozen too.
2476
+ * So the first pass after the lid opened stopped every worker that had been
2477
+ * waiting on its helpers (the landing page thread, 2026-10-03: asleep 03:36
2478
+ * to 03:52, stopped two seconds after the wake;
2479
+ * tests/a-worker-whose-helpers-are-still-working-does-not-come-back-to-you).
2480
+ *
2481
+ * A pass normally comes every fifteen seconds, so a gap of minutes means the
2482
+ * process was not running. Every worker's quiet clock starts again from
2483
+ * now, which gives a genuinely hung one ten more minutes, not a pass.
2484
+ */
2485
+ forgiveSleep(now = Date.now()) {
2486
+ if (!this._lastTickAt || now - this._lastTickAt < SLEPT_GAP_MS) return false;
2487
+ for (const session of this.sessions.values()) session.lastOutputAt = Math.max(session.lastOutputAt ?? 0, now);
2488
+ return true;
2489
+ }
2490
+
2074
2491
  reapHungSessions(items, now = Date.now()) {
2075
2492
  if (!this.sessions.size) return [];
2076
2493
  const byId = new Map((items ?? []).map((i) => [i.id, i]));
@@ -2146,7 +2563,7 @@ export class Supervisor {
2146
2563
  runs,
2147
2564
  resetsAt,
2148
2565
  });
2149
- this.store.recordSessionResult(item.product, item.id, { result, status: 'open' });
2566
+ const written = this.store.recordSessionResult(item.product, item.id, { result, status: 'open' });
2150
2567
  // REMEMBER THAT WE SAID IT, keyed on the run we said it about. The pass
2151
2568
  // below re-reads every resting row on every tick, and without a
2152
2569
  // watermark it would write the same sentence onto the same row every
@@ -2154,6 +2571,9 @@ export class Supervisor {
2154
2571
  const seen = this._fruitless[`${item.product}:${item.id}`];
2155
2572
  if (seen) {
2156
2573
  seen.saidAt = seen.endedAt ?? Date.now();
2574
+ // The stamp of OUR write, so `_onlyWeSpokeForAnAccount` can tell it
2575
+ // from anything written on the row afterwards.
2576
+ seen.saidTs = written?.wrote?.result?.ts ?? Date.now();
2157
2577
  // AND WHY, WHICH IS THE HALF THE COUNT ABOVE HER LIST READS. Writing
2158
2578
  // the watermark alone told the pass below "this row has been dealt
2159
2579
  // with" and nothing else, so the row went silently uncounted and the
@@ -2180,10 +2600,9 @@ export class Supervisor {
2180
2600
  *
2181
2601
  * That covers exactly one moment and nothing either side of it:
2182
2602
  *
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.
2603
+ * - A row that died BEFORE this code existed is never revisited. Codex rows
2604
+ * that died for the last time shortly before the fix went in still carried
2605
+ * not one word an hour after it.
2187
2606
  * - A row whose death was noticed while the app was closing loses it with the
2188
2607
  * process, because the only record was the handler that never finished.
2189
2608
  *
@@ -2222,22 +2641,123 @@ export class Supervisor {
2222
2641
  * binary, a bad exit, a timeout or a reply that is not a name, and `nameItem`
2223
2642
  * treats '' as a no-op. A row that cannot be named keeps her own title, which
2224
2643
  * is what every row in her store had before this existed.
2644
+ *
2645
+ * A MISS WAITS TEN MINUTES. Newest first with no memory of a failure meant
2646
+ * one row that could not be named was picked again every tick, and every
2647
+ * row behind it waited for good (tests/a-row-is-named-on-an-account-that-has-room).
2225
2648
  */
2226
- nameTheRows(items) {
2227
- if (this._naming) return;
2649
+ nameTheRows(items, now = Date.now()) {
2650
+ if (this._naming) return undefined;
2651
+ this._nameMisses ??= new Map();
2652
+ const key = (i) => `${i.product}:${i.id}`;
2228
2653
  const next = items
2229
- .filter((i) => wantsName(i))
2654
+ .filter((i) => wantsName(i) && !(now - (this._nameMisses.get(key(i)) ?? -Infinity) < NAME_RETRY_MS))
2230
2655
  .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
2231
- if (!next) return;
2656
+ if (!next) return undefined;
2232
2657
  this._naming = true;
2233
- nameRow(next, { claudeBin: this.config?.claudeBin })
2658
+ return this._askSmall((opts) => nameRow(next, opts), { product: next.product })
2234
2659
  .then((label) => {
2235
- if (label) this.store.nameItem(next.product, next.id, label);
2660
+ if (!label) { this._nameMisses.set(key(next), now); return; }
2661
+ this._nameMisses.delete(key(next));
2662
+ this.store.nameItem(next.product, next.id, label);
2236
2663
  })
2237
2664
  .catch((e) => console.warn('zero: could not name a row:', e.message))
2238
2665
  .finally(() => { this._naming = false; });
2239
2666
  }
2240
2667
 
2668
+ /**
2669
+ * ONE SMALL-MODEL QUESTION, ASKED ON AN ACCOUNT THAT HAS ROOM.
2670
+ *
2671
+ * The namer and the message sorter used to spawn with the app's own
2672
+ * environment, which bills the default Claude account and nothing else. On
2673
+ * 2026-10-06 that account hit its weekly limit; agents moved to the second
2674
+ * account and the namer did not, so every call died in two seconds and no
2675
+ * row was named for a day.
2676
+ *
2677
+ * So every account is tried in turn, the ones not resting first, and the
2678
+ * first answer wins: the home engine's accounts, then the other engine's when
2679
+ * this Mac offers it (`engineChoices`, so a Codex nobody opted into is never
2680
+ * billed). Each is asked once, so a Mac with every account at its limit costs
2681
+ * one call per account and then waits for the next pass. The environment is
2682
+ * a worker's (`_workerEnv`), so no API key from the app's own shell reaches
2683
+ * the call, and the account rides it the way it does on a worker's spawn.
2684
+ *
2685
+ * A PROJECT TIED TO ONE ACCOUNT IS NOT WALKED, AND THAT IS THE WHOLE POINT OF
2686
+ * THE TIE. The prompt here carries the row's own text -- its title, the first
2687
+ * lines of its body, a teammate's message -- so a walk would put an
2688
+ * employer's row in front of a personal subscription, which is the one thing
2689
+ * the setting promises will not happen. So a tied project gets ONE call on
2690
+ * ONE account: the pinned login, resting or not, and no fallback to the other
2691
+ * engine either, because Codex is a different vendor's account and a walk
2692
+ * onto it breaks the same promise the walk across logins does. The cost of
2693
+ * getting nothing is the row keeping its own title, which is what every row
2694
+ * had before the namer existed.
2695
+ */
2696
+ async _askSmall(ask, { product = null } = {}) {
2697
+ const home = this._homeEngine();
2698
+ const other = home === 'codex' ? DEFAULT_ENGINE : 'codex';
2699
+ const engines = this._projectProfile(product)
2700
+ ? [DEFAULT_ENGINE]
2701
+ : [home, ...(this.engineChoices().some((e) => e.id === other) ? [other] : [])];
2702
+ for (const engine of engines) {
2703
+ for (const env of this._smallModelEnvs(engine, product)) {
2704
+ const answer = await ask({ claudeBin: this.config?.claudeBin, codexBin: this.config?.codexBin, engine, env });
2705
+ if (answer) return answer;
2706
+ }
2707
+ }
2708
+ return '';
2709
+ }
2710
+
2711
+ /**
2712
+ * One environment per account on an engine, the accounts not resting first --
2713
+ * or the one account a tied project named, which is a list of one.
2714
+ */
2715
+ _smallModelEnvs(engine, product = null) {
2716
+ const env = this._workerEnv(engine);
2717
+ const now = Date.now();
2718
+ const resting = (p) => {
2719
+ const key = this._accountKey(engine, p);
2720
+ return (this._profileCooldown?.[key] ?? 0) >= now || !!this._profileTrouble?.[key];
2721
+ };
2722
+ const pinned = this._projectProfile(product, engine);
2723
+ const all = pinned ? [pinned] : this._profilesFor(engine);
2724
+ return [...all.filter((p) => !resting(p)), ...all.filter(resting)].map((p) => {
2725
+ if (engine === 'codex') return { ...env, CODEX_HOME: this._codexProfileHome(p) };
2726
+ return p === 'default' ? env : { ...env, CLAUDE_CONFIG_DIR: p };
2727
+ });
2728
+ }
2729
+
2730
+ /**
2731
+ * SORT ONE TEAMMATE'S MESSAGE A TICK by how urgent it is
2732
+ * (main/message-priority.mjs). Paced like the namer above: one call at a
2733
+ * time, newest first, not awaited by the tick. Each message is asked about
2734
+ * once per run of the app, so a write that does not take is not retried
2735
+ * every fifteen seconds.
2736
+ */
2737
+ sortTheMessages(items, me) {
2738
+ if (this._sortingMessages || !me) return undefined;
2739
+ this._sortedMessages ??= new Set();
2740
+ const products = new Map((this.store.listProducts?.() ?? []).map((p) => [p.slug, p]));
2741
+ const next = items
2742
+ .filter((i) => wantsPriority(i, products.get(i.product), me))
2743
+ .map((i) => ({ item: i, latest: latestMessage(i) }))
2744
+ .filter(({ item, latest }) => !this._sortedMessages.has(`${item.product}:${item.id}:${latest.ts}`))
2745
+ .sort((a, b) => b.latest.ts - a.latest.ts)[0];
2746
+ if (!next) return undefined;
2747
+ this._sortingMessages = true;
2748
+ this._sortedMessages.add(`${next.item.product}:${next.item.id}:${next.latest.ts}`);
2749
+ return this._askPriority(next.latest, next.item.product)
2750
+ .then((level) => {
2751
+ if (level) this.store.prioritizeItem(next.item.product, next.item.id, LEVELS[level]);
2752
+ })
2753
+ .catch((e) => console.warn('zero: could not sort a message:', e.message))
2754
+ .finally(() => { this._sortingMessages = false; });
2755
+ }
2756
+
2757
+ _askPriority(latest, product = null) {
2758
+ return this._askSmall((opts) => sortMessage(latest, opts), { product });
2759
+ }
2760
+
2241
2761
  sayItOnEveryStrandedRow(items, now = Date.now()) {
2242
2762
  let dirty = false;
2243
2763
  // AND THE SAME PASS COUNTS THEM, for the line above her list. The count
@@ -2313,7 +2833,7 @@ export class Supervisor {
2313
2833
  // (`stderr` in shared/dead-run-trace.mjs is stderr AND the error
2314
2834
  // result), so the hour was there to be read the whole time.
2315
2835
  const resetsAt = cause === 'at-limit' ? limitResetsAt(trace.stderr) : null;
2316
- this.store.recordSessionResult(product, id, {
2836
+ const written = this.store.recordSessionResult(product, id, {
2317
2837
  result: deadRunSentence({
2318
2838
  engineWord: 'Claude Code',
2319
2839
  cause,
@@ -2323,6 +2843,7 @@ export class Supervisor {
2323
2843
  status: 'open',
2324
2844
  });
2325
2845
  seen.saidAt = seen.endedAt ?? now;
2846
+ seen.saidTs = written?.wrote?.result?.ts ?? now;
2326
2847
  seen.saidWhy = cause;
2327
2848
  // KEPT, because the line above the list wants the same hour and the
2328
2849
  // trace it came off is not read again after this pass.
@@ -2380,7 +2901,7 @@ export class Supervisor {
2380
2901
  * on arrival: it never got as far as being a session, so what it proves is
2381
2902
  * that the machine is broken, not that this row has nothing in it.
2382
2903
  */
2383
- noteFreshRun(item, { everRan = true } = {}) {
2904
+ noteFreshRun(item, { everRan = true, account = null } = {}) {
2384
2905
  const key = `${item.product}:${item.id}`;
2385
2906
  // Re-read: `item` is the snapshot taken at spawn, and the whole question is
2386
2907
  // what the session did to the row since.
@@ -2404,16 +2925,21 @@ export class Supervisor {
2404
2925
  // worth less than the first. It is wrong for a worker that never started,
2405
2926
  // because nobody has looked at the row at all.
2406
2927
  //
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.
2928
+ // Measured the day this landed: Codex tasks could not start, and two of
2929
+ // them earned five strikes each and went to sleep for twenty-four hours.
2930
+ // Once the engine was fixed, those two rows would still not have been
2931
+ // tried again until the following afternoon, serving a sentence earned
2932
+ // entirely by a bug that no longer exists.
2412
2933
  //
2413
2934
  // The first rung still applies, so a broken engine is not hammered: fifteen
2414
2935
  // minutes between attempts, forever, until it works or she is told.
2415
2936
  if (!everRan) runs = Math.min(runs, 1);
2416
- this._fruitless[key] = { runs, endedAt: Date.now(), founderAt };
2937
+ // NOR A SPAWN ITS ACCOUNT REFUSED. Nobody looked at the row; the account
2938
+ // was out. The rest is remembered against that account and ends the
2939
+ // moment it is back (`restingUntil`), rather than fifteen minutes after
2940
+ // she has signed in again.
2941
+ if (account) runs = Math.min(runs, 1);
2942
+ this._fruitless[key] = { runs, endedAt: Date.now(), founderAt, ...(account ? { account } : {}) };
2417
2943
  this._saveState();
2418
2944
  return 'rested';
2419
2945
  }
@@ -2450,22 +2976,22 @@ export class Supervisor {
2450
2976
  }
2451
2977
 
2452
2978
  /* ----------------------- 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
2979
+ // A user may hold more than one Claude subscription; workers round-robin
2980
+ // across them (CLAUDE_CONFIG_DIR per spawn), which roughly doubles throughput
2981
+ // with two. A profile whose spawns keep dying instantly (expired plan, broken
2982
+ // login) quarantines itself for 30 minutes, and the fleet behaves as if only
2983
+ // the healthy subscriptions exist; a resubscribe simply starts working again.
2984
+ // THE CONFIGURED ONES FIRST, THEN WHATEVER IS SIGNED IN ON THE DISK. It used
2985
+ // to be only the config key, and an account someone logged into themselves
2986
+ // could therefore sit unused while the fleet starved on one that was at its
2987
+ // limit (main/account-discovery.mjs has what that costs). One copy of the
2462
2988
  // rule, there, so the fleet and the Agents page can never disagree about
2463
2989
  // which accounts exist.
2464
2990
  _profiles() {
2465
2991
  return effectiveProfiles(this.config.authProfiles, { home: this.config.home });
2466
2992
  }
2467
2993
 
2468
- // HER CODEX LOGIN, WHICH IS NOT ONE OF HER CLAUDE ONES.
2994
+ // THE CODEX LOGIN, WHICH IS NOT ONE OF THE CLAUDE ONES.
2469
2995
  //
2470
2996
  // Every entry in `authProfiles` is a CLAUDE_CONFIG_DIR: a folder holding a
2471
2997
  // Claude login and no `auth.json` Codex could ever read. Handing one of them
@@ -2541,6 +3067,50 @@ export class Supervisor {
2541
3067
  return all.includes(chosen) ? [chosen] : all;
2542
3068
  }
2543
3069
 
3070
+ /**
3071
+ * THE ONE CLAUDE ACCOUNT A PROJECT IS TIED TO, when it is tied to one.
3072
+ *
3073
+ * `activeAccount` narrows the WHOLE app to one login, which is no use to
3074
+ * somebody who genuinely uses two: with a personal subscription and an
3075
+ * employer's on one Mac, round-robin runs the employer's code on the personal
3076
+ * plan about half the time. `projectAccounts` answers per project instead, and
3077
+ * a tied project runs there and waits rather than borrowing another login.
3078
+ *
3079
+ * CLAUDE ONLY, said here once. A Codex login is a different subscription on a
3080
+ * different binary and this setting says nothing about it, so a Codex run
3081
+ * reads null and rotates exactly as it did.
3082
+ *
3083
+ * IT IS IGNORED THE MOMENT IT STOPS NAMING A LOGIN THAT IS ON THIS MAC,
3084
+ * for the reason `_narrowToChosen` gives about `activeAccount` a few lines
3085
+ * down: a pin left over from an account since removed would otherwise hand
3086
+ * every worker a CLAUDE_CONFIG_DIR pointing at a folder with no login in it,
3087
+ * and the project would fail in silence while the app looked fine. A pin
3088
+ * nobody can honour is no pin, and the project rotates as it did before
3089
+ * anybody pinned it.
3090
+ *
3091
+ * AND A PIN BEATS `activeAccount`, DELIBERATELY. The two can disagree -- pick
3092
+ * one account for the app, tie a project to the other -- and one of them has
3093
+ * to win. It is the pin, because it is the narrower statement and the one made
3094
+ * about THIS project on its own page, and because the other way round is the
3095
+ * silent stop this file minds most: `activeAccount` would empty the tied
3096
+ * project's pool of one and nothing would ever start in it, with every screen
3097
+ * reading normal. So the check below is against the logins on the Mac
3098
+ * (`_profiles`) and not the pool `activeAccount` has narrowed.
3099
+ */
3100
+ _projectProfile(product, engine = DEFAULT_ENGINE) {
3101
+ if (engineOf(engine) !== DEFAULT_ENGINE) return null;
3102
+ const slug = typeof product === 'string' ? product : product?.slug;
3103
+ const pinned = slug ? this.config.projectAccounts?.[slug] : null;
3104
+ if (typeof pinned !== 'string' || !pinned) return null;
3105
+ return this._profiles().includes(pinned) ? pinned : null;
3106
+ }
3107
+
3108
+ /** Whether a session on `profile` is allowed to carry this project's work. */
3109
+ _profileFitsProject(profile, product, engine = DEFAULT_ENGINE) {
3110
+ const pinned = this._projectProfile(product, engine);
3111
+ return !pinned || (profile || 'default') === pinned;
3112
+ }
3113
+
2544
3114
  // THE ACCOUNTS ON ONE ENGINE THAT ARE NOT RESTING. Keyed through
2545
3115
  // `_accountKey`, which is the whole point: both engines call their primary
2546
3116
  // login 'default', and reading one raw name for both would let a quarantined
@@ -2549,8 +3119,8 @@ export class Supervisor {
2549
3119
  // IT IS ALLOWED TO COME BACK EMPTY, and that is the change. It used to end
2550
3120
  // `return live.length ? live: ['default']`, which is right for PICKING --
2551
3121
  // 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
3122
+ // was doing both. With two Claude logins the fallback hid at worst half the
3123
+ // truth. On a CODEX setup with a single login, it inverted the entire
2554
3124
  // mechanism: quarantining the one account it has put that same account
2555
3125
  // straight back in the pool on the next line, so a login that had run out was
2556
3126
  // struck, cooled down for thirty minutes, and then handed the very next
@@ -2571,7 +3141,7 @@ export class Supervisor {
2571
3141
 
2572
3142
  // ROUND-ROBIN WITHIN ONE ENGINE. The cursor is per engine because the pools
2573
3143
  // 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
3144
+ // across the Claude subscriptions, so a run of Codex tasks could leave
2575
3145
  // one of them doing all the Claude work.
2576
3146
  _pickProfile(engine = DEFAULT_ENGINE) {
2577
3147
  const which = engineOf(engine);
@@ -2582,7 +3152,11 @@ export class Supervisor {
2582
3152
  // every account resting, and a pick of `undefined` there would hand a spawn
2583
3153
  // a CLAUDE_CONFIG_DIR of undefined rather than her default login.
2584
3154
  const live = this._liveProfilesFor(which);
2585
- const pool = live.length ? live : ['default'];
3155
+ // A CODEX ACCOUNT STILL BEING ADDED IS NOT WHERE WORK GOES. Add account
3156
+ // lists the new home before its sign-in finishes, and the round-robin
3157
+ // handed it the very first thread while the signed-in one sat idle.
3158
+ const signedIn = which === 'codex' ? live.filter((p) => this._codexSignedIn(p)) : live;
3159
+ const pool = signedIn.length ? signedIn : live.length ? live : ['default'];
2586
3160
  this._rr = this._rr ?? {};
2587
3161
  this._rr[which] = ((this._rr[which] ?? 0) + 1) % pool.length;
2588
3162
  return pool[this._rr[which]];
@@ -2593,11 +3167,10 @@ export class Supervisor {
2593
3167
  *
2594
3168
  * `profile` is a folder name and BOTH ENGINES HAVE A FOLDER CALLED 'default',
2595
3169
  * 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.
3170
+ * subscription and quarantine it for thirty minutes. That is exactly an
3171
+ * earlier bug -- a fact about one account applied to another, where one
3172
+ * signed-out subscription stopped the one that was working, again and again
3173
+ * -- and the second engine would have reintroduced it for free.
2601
3174
  *
2602
3175
  * An engine nobody recorded is Claude Code, so every key written before the
2603
3176
  * second engine existed still names the account it always named.
@@ -2626,7 +3199,7 @@ export class Supervisor {
2626
3199
  // WHAT IS WRONG WITH ONE ACCOUNT, remembered until a session on it survives.
2627
3200
  //
2628
3201
  // 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,
3202
+ // fact as an app-shaped one: with a second Claude subscription signed out,
2630
3203
  // half of every spawn round-robined onto it and died in two seconds, and the
2631
3204
  // screen said all agents were failing while the other half worked the whole
2632
3205
  // time. A fact about one account now stays on that account.
@@ -2636,6 +3209,12 @@ export class Supervisor {
2636
3209
  this._profileTrouble = this._profileTrouble ?? {};
2637
3210
  const had = this._profileTrouble[profile];
2638
3211
  this._profileTrouble[profile] = { since: had?.since ?? Date.now(), at: Date.now(), cause, raw: String(raw ?? '').slice(0, 300) };
3212
+ // What the login files looked like when it was found out, so that
3213
+ // `_noticeSignIns` can tell a login landing afterwards from one before.
3214
+ if (cause === 'signed-out') {
3215
+ const stamp = signInStamp(this._signInFilesFor(profile));
3216
+ this._profileTrouble[profile].stamp = Math.max(stamp, had?.stamp ?? 0);
3217
+ }
2639
3218
  return cause;
2640
3219
  }
2641
3220
 
@@ -2654,7 +3233,7 @@ export class Supervisor {
2654
3233
  * both engines call their primary login 'default'; `status` exports that book
2655
3234
  * verbatim as `accountTrouble`; and its one reader, the Accounts page in
2656
3235
  * main/settings.mjs, indexes it by a bare Claude folder name and therefore
2657
- * could never match `codex:default`. Her Codex subscription could lapse,
3236
+ * could never match `codex:default`. A Codex subscription could lapse,
2658
3237
  * every Codex task stop, and Settings show a green Claude row and nothing
2659
3238
  * else -- the fleet brake being deliberately Claude-only, so "No agents can
2660
3239
  * start" cannot be true about Codex either.
@@ -2684,23 +3263,23 @@ export class Supervisor {
2684
3263
  // next spawn might well work. A login that has run out is not ambiguous, and
2685
3264
  // the two spawns it takes to reach three strikes are two real pieces of her
2686
3265
  // work handed to an account that cannot do them.
2687
- _strikeProfile(profile, { hard = false } = {}) {
3266
+ _strikeProfile(profile, { hard = false, rest = 30 * 60_000 } = {}) {
2688
3267
  if (!profile) return;
2689
3268
  this._profileStrikes = this._profileStrikes ?? {};
2690
3269
  this._profileCooldown = this._profileCooldown ?? {};
2691
3270
  this._profileStrikes[profile] = (this._profileStrikes[profile] ?? 0) + 1;
2692
3271
  if (hard || this._profileStrikes[profile] >= 3) {
2693
- this._profileCooldown[profile] = Date.now() + 30 * 60_000;
3272
+ this._profileCooldown[profile] = Date.now() + rest;
2694
3273
  this._profileStrikes[profile] = 0;
2695
3274
  }
2696
3275
  }
2697
3276
 
2698
3277
  /*
2699
- * CAPACITY IS PER SUBSCRIPTION, AND SHE HAS TWO KINDS OF SUBSCRIPTION NOW.
3278
+ * CAPACITY IS PER SUBSCRIPTION, AND THERE ARE TWO KINDS OF SUBSCRIPTION NOW.
2700
3279
  *
2701
3280
  * IT DID NOT. This was one number for the whole Mac and it was built out of
2702
3281
  * `_liveProfiles`, which is the CLAUDE auth-profile pool -- so a Codex task
2703
- * queued behind three Claude workers while her OpenAI subscription did
3282
+ * queued behind three Claude workers while the OpenAI subscription did
2704
3283
  * nothing, and logging into Codex could never widen anything, because the
2705
3284
  * multiplier counted Claude logins.
2706
3285
  *
@@ -2719,8 +3298,8 @@ export class Supervisor {
2719
3298
  * words that the binary "is the same fact `_engineFor` refuses on". That was
2720
3299
  * false. `_engineFor` refuses on the capability gate FIRST and the binary
2721
3300
  * 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
3301
+ * no `engineChoice` moment written -- which is every Mac before the user opts
3302
+ * in -- counted three Codex slots that `engineFor` will never
2724
3303
  * hand a row to, and printed six directly above a row saying three run
2725
3304
  * together.
2726
3305
  *
@@ -2775,8 +3354,41 @@ export class Supervisor {
2775
3354
  * used when nobody has said, which is also the number it already gets on
2776
3355
  * every Mac whose Claude plan is Max or unreadable.
2777
3356
  */
3357
+ /**
3358
+ * THE MACHINE AUTOMATIC SIZES FOR. Empty means this one: `autoAgents` reads
3359
+ * the memory and the cores itself. A test names a machine here, so what it
3360
+ * proves does not change with the computer the suite runs on (GitHub's
3361
+ * runner came out at one agent and turned three claims red, 2026-10-05). */
3362
+ _machine() {
3363
+ return {};
3364
+ }
3365
+
2778
3366
  _slotsPerAccount(engine = DEFAULT_ENGINE) {
2779
3367
  const planSpokeForClaude = engineOf(engine) !== DEFAULT_ENGINE && !!this.config.planSlotsFrom;
3368
+ // AUTOMATIC ASKS THE MACHINE (w-e5225b62ba), and the whole of the change is
3369
+ // where the number comes from: this still answers PER ACCOUNT and
3370
+ // `_capacityFor` still multiplies it by the live accounts, so nothing
3371
+ // downstream of it moved.
3372
+ //
3373
+ // THE DIVISION IS THE POINT. The number used to be per account, so
3374
+ // connecting a second login silently asked the Mac to carry twice as much
3375
+ // while the page still read 3. Automatic answers for the machine and shares
3376
+ // that out, so what the Mac is asked to do does not change when an account
3377
+ // is added.
3378
+ //
3379
+ // A SMALL PLAN STILL WINS. `slotsForPlans` answers one at a time for a
3380
+ // subscription that is not Max; that is a fact about the subscription and
3381
+ // not about this Mac, so Automatic may only ever come out at or under it.
3382
+ if (this.config.agentsAuto && !planSpokeForClaude) {
3383
+ const total = autoAgents({
3384
+ ...this._machine(),
3385
+ gated: !!this.config.memoryGate,
3386
+ nudge: this.config.agentsNudge,
3387
+ });
3388
+ const per = perAccountAgents(total, this._liveProfilesFor(engineOf(engine)).length);
3389
+ const plan = this.config.planSlotsFrom ? Number(this.config.maxConcurrentSessions) : null;
3390
+ return Number.isFinite(plan) && plan >= 1 ? Math.min(Math.floor(plan), per) : per;
3391
+ }
2780
3392
  const set = Number(planSpokeForClaude ? DEFAULT_SESSIONS_AT_ONCE : this.config.maxConcurrentSessions);
2781
3393
  return Number.isFinite(set) && set >= 1 ? Math.floor(set) : 1;
2782
3394
  }
@@ -2792,8 +3404,8 @@ export class Supervisor {
2792
3404
  }
2793
3405
 
2794
3406
  // 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
3407
+ // decision in this file is measured against. A command the user typed is not a
3408
+ // worker and is not counted as one -- the reason and the measurement are under
2797
3409
  // `_load` below, which is the sum of these. The engine is read off the
2798
3410
  // SESSION, because that is where `_engineFor`'s single answer was written
2799
3411
  // down; reading the row again here would be a second decision.
@@ -2809,6 +3421,33 @@ export class Supervisor {
2809
3421
  return n;
2810
3422
  }
2811
3423
 
3424
+ /**
3425
+ * HOW MANY SESSIONS ONE ACCOUNT IS ALREADY CARRYING. `_loadFor` above is the
3426
+ * same count for a whole engine, and a tied project needs the narrower one:
3427
+ * the engine's cap is the per-account number times the live accounts, so with
3428
+ * two logins a project tied to one of them would be let through at twice its
3429
+ * account's share and spend one subscription's rate limit on both halves.
3430
+ *
3431
+ * A ROW STILL BEING PREPARED COUNTS, the way it does in `_loadFor`, but only
3432
+ * when its own project is tied to this same account: that is the one case
3433
+ * where the account is already decided before the spawn. An untied row's
3434
+ * account is picked at the spawn and counting it here would charge it to a
3435
+ * login it may never reach.
3436
+ */
3437
+ _loadOnProfile(profile, engine = DEFAULT_ENGINE) {
3438
+ const key = this._accountKey(engine, profile);
3439
+ let n = 0;
3440
+ for (const s of this.sessions.values()) {
3441
+ if (s.command || s.remoteIdle) continue;
3442
+ if (this._accountKey(s.engine, s.profile || 'default') === key) n += 1;
3443
+ }
3444
+ for (const p of this._preparing?.values() ?? []) {
3445
+ const pin = this._projectProfile(p.item?.product, p.engine);
3446
+ if (pin && this._accountKey(p.engine, pin) === key) n += 1;
3447
+ }
3448
+ return n;
3449
+ }
3450
+
2812
3451
  // IS THERE ROOM FOR ONE MORE SESSION ON THIS ENGINE?
2813
3452
  //
2814
3453
  // THREE QUESTIONS, AND THE SECOND ONE WAS DELETED AS UNREACHABLE WHEN IT IS
@@ -2860,15 +3499,14 @@ export class Supervisor {
2860
3499
  * became per subscription. What a spawn is measured against is `_loadFor`
2861
3500
  * against `_capacityFor`, one engine at a time; this is the whole Mac's
2862
3501
  * 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.
3502
+ * here, and `_loadFor` applies the same exclusion for the same reason.
2865
3503
  *
2866
3504
  * So one of Claude Code's own eight commands is not a worker and is not
2867
3505
  * counted as one. It carries no brief, claims no row, writes no files, and
2868
3506
  * exits as soon as the CLI has printed its table; the runs measured on this
2869
3507
  * row took 2.8, 4.0 and 4.8 seconds. Holding a slot for something that short
2870
3508
  * 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.
3509
+ * session that has been going twenty minutes is a wait nobody should have.
2872
3510
  *
2873
3511
  * The cap still means what it meant for everything else. It exists because
2874
3512
  * her subscription's rate limit is split between whatever is running, and
@@ -2902,9 +3540,9 @@ export class Supervisor {
2902
3540
  if (session.stoppedByUs) return;
2903
3541
  // A CODEX RUN CANNOT STRIKE A CLAUDE ACCOUNT, and until this slice the only
2904
3542
  // 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
3543
+ // `_strikeProfile` took `session.profile`, which was one of the Claude
3544
+ // logins whatever engine had died on it, so a Codex failure quarantined a
3545
+ // working Claude subscription -- the old shape, where one signed-out
2908
3546
  // account halted the one that was fine.
2909
3547
  //
2910
3548
  // THAT GUARANTEE IS NOW IN `_accountKey` INSTEAD, which is a better place
@@ -2933,17 +3571,24 @@ export class Supervisor {
2933
3571
  // the fleet-wide brake, because `_healthyProfiles` excludes an account
2934
3572
  // carrying trouble and zero healthy accounts is what the brake is for; and
2935
3573
  // 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".
3574
+ // hour. That is the failure CLAUDE.md already records, agents stopping for
3575
+ // no apparent reason, wearing a new hat.
2938
3576
  const quickButReal = session.result != null && !session.resultIsError;
2939
3577
  // 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';
3578
+ // When a Codex login hits its weekly limit, every reply after that works
3579
+ // for about two minutes, then fails with "You've hit your usage limit".
3580
+ // Two minutes is not a fast exit, so each one fell to the branch below
3581
+ // that CLEARS the account's trouble, and with a second account idle the
3582
+ // row still went straight back to the capped login, again and again.
3583
+ //
3584
+ // AND SO DOES A RUN THE ACCOUNT REFUSED IN ANY OTHER WAY (2026-10-04). A
3585
+ // signed-out Codex retries ten times, five over WebSocket and five over
3586
+ // HTTPS, before its turn fails with the 401, so the run can outlast
3587
+ // FAST_EXIT_MS. Past it, the branch below CLEARED the account's trouble:
3588
+ // no bench, no "Signed out" on the row, and her reply was charged one of
3589
+ // its three tries. The error result names its cause whatever the length.
3590
+ const refusedBy = session.resultIsError ? troubleCause(session.result) : null;
3591
+ const limitHit = refusedBy === 'at-limit' || needsHerHands(refusedBy);
2947
3592
  if ((Date.now() - session.startedAt < FAST_EXIT_MS && !quickButReal) || limitHit) {
2948
3593
  // The last words of a fast-dead session are the diagnosis. They are kept,
2949
3594
  // but as EVIDENCE now, not as copy: shared/spawn-trouble.mjs turns them
@@ -2964,8 +3609,17 @@ export class Supervisor {
2964
3609
  this._profileCooldown = this._profileCooldown ?? {};
2965
3610
  this._profileCooldown[account] = resetAt + LIMIT_RESET_GRACE_MS;
2966
3611
  } else {
2967
- this._strikeProfile(account, { hard: needsHerHands(cause) });
3612
+ this._strikeProfile(account, {
3613
+ hard: needsHerHands(cause),
3614
+ rest: cause === 'signed-out' ? SIGNED_OUT_REST_MS : undefined,
3615
+ });
2968
3616
  }
3617
+ // A DEATH THE ACCOUNT CAUSED IS NOT THE ROW'S. Read by `settleDelivery`
3618
+ // (her reply is handed back rather than spending one of its three tries)
3619
+ // and by `noteFreshRun` (the row's rest ends when the account is back).
3620
+ // Measured 2026-10-04: her "continue" on a row, sent while signed out,
3621
+ // died in two seconds and counted against the reply.
3622
+ if (needsHerHands(cause) || cause === 'at-limit') session.accountFault = { cause, account };
2969
3623
 
2970
3624
  // ONLY THE ENGINE SHE IS ACTUALLY RUNNING ON MAY BRAKE THE FLEET.
2971
3625
  //
@@ -2983,10 +3637,9 @@ export class Supervisor {
2983
3637
  // ONLY A FLEET WITH NOWHERE LEFT TO RUN GETS THE FLEET-WIDE BRAKE.
2984
3638
  //
2985
3639
  // 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
3640
+ // signed-out subscription stopped a working one for a minute, then two,
3641
+ // then four, doubling to thirty: every dead spawn on the signed-out
3642
+ // account halted the account that was working. The per-account cooldown above
2990
3643
  // already keeps a bad account out of the rotation; the brake is only for
2991
3644
  // when there is no good account left to be kept out of anything.
2992
3645
  const healthy = this._healthyProfiles().length;
@@ -2994,13 +3647,13 @@ export class Supervisor {
2994
3647
  this._fastExits = (this._fastExits ?? 0) + 1;
2995
3648
  const delay = resetAt
2996
3649
  ? Math.max(0, resetAt + LIMIT_RESET_GRACE_MS - Date.now())
2997
- : Math.min(BACKOFF_BASE_MS * 2 ** (this._fastExits - 1), BACKOFF_CAP_MS);
3650
+ : Math.min(BACKOFF_BASE_MS * 2 ** (this._fastExits - 1), cause === 'signed-out' ? SIGNED_OUT_REST_MS : BACKOFF_CAP_MS);
2998
3651
  this._spawnCooldownUntil = Date.now() + delay;
2999
3652
  this._fleetTroubleSince = this._fleetTroubleSince || Date.now();
3000
3653
  this._lastFastExit = { at: Date.now(), cause, raw };
3001
3654
  onLine(`fast exit #${this._fastExits}; next spawn in ${Math.round(delay / 60000)}m`);
3002
3655
  } else {
3003
- // Her account is fine and it stays fine. Nothing is said on screen and
3656
+ // The working account is fine and it stays fine. Nothing is said on screen and
3004
3657
  // nothing is slowed down; the trace log has the whole story.
3005
3658
  this._fastExits = 0;
3006
3659
  this._spawnCooldownUntil = 0;
@@ -3033,6 +3686,7 @@ export class Supervisor {
3033
3686
  try {
3034
3687
  fs.writeFileSync(this._stateFile, JSON.stringify({
3035
3688
  handledAnswers: [...this._handledAnswers].slice(-500),
3689
+ heardAnswers: Object.fromEntries(Object.entries(this._heardAnswers ?? {}).slice(-500)),
3036
3690
  productOrder: this.productOrder,
3037
3691
  hiddenProducts: [...this.hiddenProducts],
3038
3692
  liveSessions: this._liveSessions,
@@ -3077,12 +3731,24 @@ export class Supervisor {
3077
3731
  return !!this._firstRunUntil && now < this._firstRunUntil;
3078
3732
  }
3079
3733
 
3734
+ /**
3735
+ * A THREAD THEY SENT THEMSELVES ENDS THE HOLD. The walk's own thread carries
3736
+ * FIRST_RUN_LABEL and is answered by the app, so the hold has nothing left
3737
+ * to protect once a real one arrives. Without this, a thread sent while the
3738
+ * walk was still up, or left half finished, read "Queued" for up to twenty
3739
+ * minutes with nothing running anywhere.
3740
+ */
3741
+ sentByThem(labels = []) {
3742
+ if (!this.firstRunHolding()) return;
3743
+ if ((labels ?? []).includes(FIRST_RUN_LABEL)) return;
3744
+ this._firstRunUntil = 0;
3745
+ this.onChange?.();
3746
+ }
3747
+
3080
3748
  /* `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
3749
+ It is gone (w-d19d6d387c): "paused" did not say whether it stopped the
3750
+ session, nobody knew the feature existed, and nothing had ever used it.
3751
+ Pausing the whole fleet is still a switch in Settings and a row in
3086
3752
  ⌘K, and stopping one task is still S on the row. */
3087
3753
 
3088
3754
  status() {
@@ -3132,12 +3798,22 @@ export class Supervisor {
3132
3798
  try {
3133
3799
  const nowTs = Date.now();
3134
3800
  const autonomous = new Set(this.config.autonomousProducts ?? []);
3801
+ // THE TEAM VERSION: a shared row is queued only on its runner's Mac, and
3802
+ // a row a person holds on nobody's, by the same rule the tick obeys.
3803
+ const productBySlug = new Map((this.store.listProducts?.() ?? []).map((p) => [p.slug, p]));
3804
+ const me = process.env.AGENTBOX_PERSON_ID;
3135
3805
  queued = this.store.listItems(nowTs)
3806
+ .filter((i) => mayRunHere(i, productBySlug.get(i.product), me))
3807
+ // NOTHING RUNS IN THE PRACTICE PROJECT, so nothing there is waiting
3808
+ // its turn. The tick skips these (`noWorkHere`) and `spawnWorker`
3809
+ // refuses them; this list did neither, so a thread sent there read
3810
+ // "Queued. An agent starts on it as soon as one is free" for ever.
3811
+ .filter((i) => productBySlug.get(i.product)?.practice !== true)
3136
3812
  .filter((i) => !this.sessions.has(i.id) && i.status === 'open'
3137
3813
  && this.store.isDue(i, nowTs))
3138
3814
  // 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
3815
+ // on it, which is the whole of FIRST_RUN_LABEL. The bug was a row
3816
+ // reading "queued" under a sentence saying it was reading, and
3141
3817
  // this list promises exactly the opposite of that.
3142
3818
  .filter((i) => !(i.labels ?? []).includes(FIRST_RUN_LABEL))
3143
3819
  // Leased to someone else, so NOT queued, for the same reason resting is
@@ -3156,8 +3832,19 @@ export class Supervisor {
3156
3832
  .map((i) => i.id);
3157
3833
  } catch {}
3158
3834
  if (this.paused) queued = [];
3835
+ let signInNeeded = {};
3836
+ try {
3837
+ const productBySlug = new Map((this.store.listProducts?.() ?? []).map((p) => [p.slug, p]));
3838
+ const me = process.env.AGENTBOX_PERSON_ID;
3839
+ signInNeeded = this._waitingOnSignIn(this.store.listItems(Date.now())
3840
+ .filter((i) => mayRunHere(i, productBySlug.get(i.product), me)));
3841
+ } catch {}
3159
3842
  return {
3160
3843
  paused: this.paused,
3844
+ // ROWS NOTHING CAN START BECAUSE THEIR TOOL IS SIGNED OUT, by id, with the
3845
+ // tool's name. The row says so instead of "Queued", which promised an
3846
+ // agent "as soon as one is free" while none could be (2026-10-04).
3847
+ signInNeeded,
3161
3848
  running: [...this.sessions.values()].filter(s => !s.remoteIdle).map((s) => ({
3162
3849
  itemId: s.itemId,
3163
3850
  product: s.product,
@@ -3184,9 +3871,12 @@ export class Supervisor {
3184
3871
  })),
3185
3872
  stalled,
3186
3873
  queued,
3874
+ // Waiting tasks she pushed with Run now that have not started yet, so
3875
+ // the task can say it is next rather than merely queued.
3876
+ runNow: [...this._runNow].filter(([id, r]) => !r.started && !this.sessions.has(id)).map(([id]) => id),
3187
3877
  scheduled,
3188
- // ROWS WHOSE LAST RUN ENDED WITHOUT SAYING ANYTHING. Her pick
3189
- // 2026-08-31: "Say it on the row." See `_silentRows`.
3878
+ // ROWS WHOSE LAST RUN ENDED WITHOUT SAYING ANYTHING. The row says so.
3879
+ // See `_silentRows`.
3190
3880
  silent: this._silentRows(),
3191
3881
  productOrder: this.productOrder,
3192
3882
  hiddenProducts: [...this.hiddenProducts],
@@ -3200,8 +3890,8 @@ export class Supervisor {
3200
3890
  // THE ONE LINE ABOVE HER LIST, and only when it is really true.
3201
3891
  //
3202
3892
  // 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,
3893
+ // are not equally likely to be the one the user needs. First: are there
3894
+ // tasks nothing has been able to run on? That is theirs to act on. Second,
3205
3895
  // and only when there are none: the older fleet sentence, which says
3206
3896
  // nothing can start ANYWHERE.
3207
3897
  //
@@ -3214,8 +3904,8 @@ export class Supervisor {
3214
3904
  /**
3215
3905
  * A RUN ENDED AND SAID NOTHING, AND NOW THE ROW ADMITS IT.
3216
3906
  *
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."
3907
+ * When a run ends having written nothing, the row says so, in the same voice
3908
+ * the four live words use.
3219
3909
  *
3220
3910
  * WHAT THIS IS REPAIRING, and it came from outside.
3221
3911
  *
@@ -3277,8 +3967,8 @@ export class Supervisor {
3277
3967
  * The line it fills already existed and it could never have fired for her.
3278
3968
  * `_spawnTrouble` below wants `_fleetTroubleSince`, which is only ever set
3279
3969
  * 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
3970
+ * Claude agents can work all day while Codex tasks and tasks on a
3971
+ * signed-out second login sit there, so both gates were open the whole time
3282
3972
  * and the app had no way at all to say what she could plainly see. The
3283
3973
  * subject is the tasks now, not the fleet, and a working engine beside a
3284
3974
  * broken one no longer buys the broken one silence.
@@ -3356,6 +4046,30 @@ export class Supervisor {
3356
4046
  // is only ever set when no healthy account remains; twenty unbroken minutes;
3357
4047
  // and something it is actually stopping, so there is work waiting and nothing
3358
4048
  // running.
4049
+ /**
4050
+ * WHICH OPEN ROWS ARE WAITING ON A SIGN-IN, and in which tool. True of a row
4051
+ * when every account its engine could run it on is sitting out and at least
4052
+ * one of them is signed out. One sitting-out account beside a working one is
4053
+ * not this: the work goes to the working one.
4054
+ */
4055
+ _waitingOnSignIn(rows) {
4056
+ const out = {};
4057
+ const blocked = new Map();
4058
+ const isBlocked = (engine) => {
4059
+ if (!blocked.has(engine)) {
4060
+ blocked.set(engine, !this._liveProfilesFor(engine).length
4061
+ && this._profilesFor(engine).some((p) => this._profileTrouble?.[this._accountKey(engine, p)]?.cause === 'signed-out'));
4062
+ }
4063
+ return blocked.get(engine);
4064
+ };
4065
+ for (const i of rows ?? []) {
4066
+ if (i.status !== 'open' || this.sessions.has(i.id)) continue;
4067
+ const engine = engineOf(this._engineFor(i));
4068
+ if (isBlocked(engine)) out[i.id] = engineLabel(engine);
4069
+ }
4070
+ return out;
4071
+ }
4072
+
3359
4073
  _spawnTrouble(queued) {
3360
4074
  const since = this._fleetTroubleSince ?? 0;
3361
4075
  if (!since) return null;
@@ -3384,6 +4098,7 @@ export class Supervisor {
3384
4098
  // fifteen second wait back again for exactly the row that asked for it.
3385
4099
  if (this._ticking) { this._tickAgain = true; return; }
3386
4100
  this._ticking = true;
4101
+ this.forgiveSleep(Date.now());
3387
4102
  this._lastTickAt = Date.now();
3388
4103
  try {
3389
4104
  return await this._tick();
@@ -3398,6 +4113,9 @@ export class Supervisor {
3398
4113
  async _tick() {
3399
4114
  if (this.paused) return;
3400
4115
  if (this.firstRunHolding()) return;
4116
+ // Has a login landed since an account was found signed out? Ahead of the
4117
+ // brake below, which would otherwise hold the fleet past the sign-in.
4118
+ try { this._noticeSignIns(); } catch (e) { console.warn('zero: could not look for a sign-in:', e.message); }
3401
4119
  // A COOLDOWN STOPS THE SPAWNING AND IT MUST NOT STOP THE TELLING. The brake
3402
4120
  // goes on when there is no healthy account left anywhere, for up to half an
3403
4121
  // hour, which is precisely the half hour her tasks are all stuck and the
@@ -3426,6 +4144,8 @@ export class Supervisor {
3426
4144
  // is exactly when no session exit is coming to do this.
3427
4145
  this.parkOnATimer(now);
3428
4146
  const items = this.store.listItems(now);
4147
+ // A task its agent marked ready goes out, one at a time, beside the tick.
4148
+ try { this.shipQueue.tick(items, this.store.listProducts()); } catch (e) { console.warn('zero: the ship queue:', e.message); }
3429
4149
 
3430
4150
  // A session that died without exiting still holds its row. Stopping it
3431
4151
  // here lets the passes below put a fresh worker on the row.
@@ -3441,10 +4161,14 @@ export class Supervisor {
3441
4161
  // And is there a row still wearing the first line of what she dictated?
3442
4162
  // One a tick, never awaited, silent on every failure (nameTheRows above).
3443
4163
  try { this.nameTheRows(items); } catch (e) { console.warn('zero: could not name a row:', e.message); }
4164
+ // And has a teammate written something that needs sorting by urgency?
4165
+ try { this.sortTheMessages(items, process.env.AGENTBOX_PERSON_ID); } catch (e) { console.warn('zero: could not sort a message:', e.message); }
3444
4166
 
3445
4167
  // Before anything is resumed or spawned: has the urgent row we interrupted
3446
4168
  // 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.
4169
+ // becomes resumable again on the passes below, in this same tick. A push
4170
+ // whose run has ended is settled first, since it no longer holds anything.
4171
+ this._settleRunNow(items);
3448
4172
  this._settlePreemptions(items, now);
3449
4173
 
3450
4174
  // Interrupted sessions the last sweep could not fit in a slot. Ahead of
@@ -3484,14 +4208,13 @@ export class Supervisor {
3484
4208
  // pass, so an Urgent task could not outrank a Medium answer, ever. It was
3485
4209
  // not a close call either; it was absolute precedence dressed as ordering.
3486
4210
  //
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.
4211
+ // Measured: an Urgent task waited 85 minutes before anything started on
4212
+ // it. In that window Agentbox started 36 sessions on that one product and
4213
+ // every single one was a continuation; 20 of them were Medium. Not one
4214
+ // fresh item spawned until the answer backlog ran dry, and then two
4215
+ // started in the same second. That is starvation, and it is also why
4216
+ // stopping a task did not help: the freed slot went straight to the next
4217
+ // answer in line.
3495
4218
  //
3496
4219
  // Continuations still WIN A TIE, which keeps the old intent (an answer she
3497
4220
  // has already given beats new work of equal standing) without letting it
@@ -3514,6 +4237,11 @@ export class Supervisor {
3514
4237
  // behind the clause outlives them: blocked means a session stopped until
3515
4238
  // something outside it changed, and her reply IS that change.
3516
4239
  if (i.status !== 'open' && i.status !== 'blocked') return false;
4240
+ // BUT NOT A ROW SHE STOPPED, on the reply she sent before stopping it.
4241
+ // The kill hands that reply back to the queue, and this used to start a
4242
+ // fresh run on it ten seconds after her stop, on a row sitting in her
4243
+ // inbox. Her stop is the newest thing she said; a later reply wakes it.
4244
+ if (stoppedByHer(i)) return false;
3517
4245
  return true;
3518
4246
  };
3519
4247
 
@@ -3543,18 +4271,18 @@ export class Supervisor {
3543
4271
  // the next worker arrives at a question that is already answered, has
3544
4272
  // nothing new to do, and writes the answer a second time.
3545
4273
  //
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
4274
+ // Measured: hundreds of results were written with no reply since the
4275
+ // previous one, many opening on a character-identical bold line, and
4276
+ // several rows were carrying three answers to one unanswered ask: three
3551
4277
  // sessions, three near-identical results, no reply between any of them.
3552
4278
  //
3553
4279
  // She loses nothing by the wait. `rowSummary` already shows a result
3554
4280
  // newer than the body on an OPEN row, so the answer is in her inbox
3555
4281
  // being read; the only thing that stops is writing it again. Any word
3556
4282
  // from her moves `lastFounderWrite` past it and the row is fresh again.
3557
- && !awaitingHer(i)
4283
+ // EXCEPT when the words are OURS, about an account that refused the run
4284
+ // (`_onlyWeSpokeForAnAccount`): those are not an answer to her.
4285
+ && (!awaitingHer(i) || this._onlyWeSpokeForAnAccount(i))
3558
4286
  // A row a worker already took and gave nothing back rests before it is
3559
4287
  // handed to another one. This pass had no memory whatsoever, which is
3560
4288
  // the whole of the every-tick loop (FRUITLESS_RESTS_MS).
@@ -3568,13 +4296,22 @@ export class Supervisor {
3568
4296
  // and cannot be got round by writing a new one. `worksHere` is that rule
3569
4297
  // and it is the same one `spawnWorker` refuses on; this set only saves the
3570
4298
  // queue the work of considering rows it would refuse anyway.
4299
+ const products = this.store.listProducts();
3571
4300
  const noWorkHere = new Set(
3572
- this.store.listProducts().filter((p) => !this.worksHere(p)).map((p) => p.slug),
4301
+ products.filter((p) => !this.worksHere(p)).map((p) => p.slug),
3573
4302
  );
4303
+ // A SHARED PROJECT'S ROW RUNS ON ITS RUNNER'S MAC AND NOWHERE ELSE, and a
4304
+ // row a person has been given runs nowhere until they hand it back. Every
4305
+ // teammate's supervisor sees every shared row; without this each would
4306
+ // start its own worker on it. shared/team-rules.mjs is the rule, and the
4307
+ // window reads the same one to decide whose inbox a row is in.
4308
+ const productBySlug = new Map(products.map((p) => [p.slug, p]));
4309
+ const me = process.env.AGENTBOX_PERSON_ID || null;
3574
4310
 
3575
4311
  const queue = [];
3576
4312
  for (const item of items) {
3577
4313
  if (noWorkHere.has(item.product)) continue;
4314
+ if (!mayRunHere(item, productBySlug.get(item.product), me)) continue;
3578
4315
  if (this.sessions.has(item.id)) continue;
3579
4316
  // Interrupted a moment ago so an Urgent row could have its slot. Putting
3580
4317
  // a worker back on it here — as a stranger, on the fresh-work or
@@ -3616,6 +4353,14 @@ export class Supervisor {
3616
4353
  && this._engineFor(item) === DEFAULT_ENGINE,
3617
4354
  });
3618
4355
  }
4356
+ // THE SLOT AN INTERRUPTION FREED BELONGS TO THE ROW IT WAS FREED FOR.
4357
+ // Without this the slot went to the top of the queue on the next tick,
4358
+ // and the top is often a higher project's row that could never have
4359
+ // interrupted anything itself: the session was paused for nothing and the
4360
+ // Urgent row went on waiting. Rows ranked above it lose nothing by it,
4361
+ // since the session it paused scored below it and held that slot anyway.
4362
+ const heldFor = new Set([...this._preempted.values()].map((r) => `${r.forProduct}:${r.forItem}`));
4363
+ const holds = (row) => (heldFor.has(`${row.item.product}:${row.item.id}`) ? 1 : 0);
3619
4364
  queue.sort((a, b) =>
3620
4365
  // A COMMAND GOES TO THE FRONT, ahead of the priority she set on anything
3621
4366
  // else, because it is not competing with them for anything. It takes no
@@ -3627,6 +4372,12 @@ export class Supervisor {
3627
4372
  // two answered rows on a full fleet was never reached at all. Measured as
3628
4373
  // a failing test before this line existed.
3629
4374
  (b.command ? 1 : 0) - (a.command ? 1 : 0)
4375
+ // THEN WHAT SHE PUSHED WITH RUN NOW, ahead of every project and tag,
4376
+ // the earliest push first. Sorting first is also what gives it this
4377
+ // tick's one interruption on its engine (`spent` below).
4378
+ || (this._runNow.has(b.item.id) ? 1 : 0) - (this._runNow.has(a.item.id) ? 1 : 0)
4379
+ || (this._runNow.get(a.item.id)?.at ?? 0) - (this._runNow.get(b.item.id)?.at ?? 0)
4380
+ || holds(b) - holds(a)
3630
4381
  || this._score(b.item) - this._score(a.item)
3631
4382
  // The tie-break, and the only thing left of "continuations go first".
3632
4383
  || (b.continuation ? 1 : 0) - (a.continuation ? 1 : 0)
@@ -3642,17 +4393,23 @@ export class Supervisor {
3642
4393
  * to break out of the queue entirely. That break was correct while there
3643
4394
  * was one cap -- nothing further down could fit either -- and it is a
3644
4395
  * 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
4396
+ * unfittable Claude rows would never be reached at all, so the Codex
3646
4397
  * subscription would go on doing nothing with a Codex task in the queue.
3647
4398
  * That is the same shape as the bug the slash-command sort-to-front was
3648
4399
  * written for, and the deleted 2026-08-25 build shipped with it.
3649
4400
  *
3650
4401
  * 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.
4402
+ * session is interrupted per engine per tick.
4403
+ *
4404
+ * AN ENGINE GOES IN HERE ONLY WHEN SOMETHING WAS ACTUALLY INTERRUPTED. It
4405
+ * used to go in on the first row that did not fit, whatever that row was,
4406
+ * and the first row is usually a higher project's Medium, which may not
4407
+ * interrupt anything. The Urgent row below it, which may, was never
4408
+ * asked: an Astral Video Urgent waited 49 minutes while the Medium in its
4409
+ * own project ran (w-53a72e6e7f,
4410
+ * tests/an-urgent-task-takes-its-own-projects-slot-even-behind-a-higher-project).
4411
+ * The slot that interruption frees is held for the row that made it
4412
+ * (`heldFor` above), so the rows above it do not take it instead.
3656
4413
  */
3657
4414
  const spent = new Set();
3658
4415
 
@@ -3675,7 +4432,7 @@ export class Supervisor {
3675
4432
  // A session already finished on this answer, and said so on the item
3676
4433
  // rather than only in this process's memory. The mark below is the same
3677
4434
  // 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
4435
+ // handed a worker a word the user said days ago, which it read, recognised as
3679
4436
  // done, and exited on inside a minute. Twenty-three times, on one row.
3680
4437
  //
3681
4438
  // A row still leased to someone else would refuse this worker the claim,
@@ -3684,7 +4441,7 @@ export class Supervisor {
3684
4441
  if (continuation
3685
4442
  && (answerSettled(item) || this._answerDelivered(item) || this.claimHeldElsewhere(item))) continue;
3686
4443
  /*
3687
- * A COMMAND SHE TYPED NEVER QUEUES, EVEN WITH EVERY SLOT FULL.
4444
+ * A COMMAND THE USER TYPED NEVER QUEUES, EVEN WITH EVERY SLOT FULL.
3688
4445
  *
3689
4446
  * Asked above the cap, and sorted to the front of the queue above that,
3690
4447
  * because the cap breaks rather than skips. The guards over this line
@@ -3711,10 +4468,7 @@ export class Supervisor {
3711
4468
  // exit before its slot is really free, and an exit nudges a tick, so
3712
4469
  // the ordinary cost of this is seconds rather than the fifteen-second
3713
4470
  // poll.
3714
- if (!spent.has(engine)) {
3715
- spent.add(engine);
3716
- this._preemptFor(item, items, engine);
3717
- }
4471
+ if (!spent.has(engine) && this._preemptFor(item, items, engine)) spent.add(engine);
3718
4472
  // AND ON DOWN THE QUEUE, because the next row may be on the engine
3719
4473
  // that is still idle. See `spent` above for what this used to be.
3720
4474
  continue;
@@ -3730,9 +4484,10 @@ export class Supervisor {
3730
4484
  // next tick: the row we interrupted becomes resumable in the same pass
3731
4485
  // that spawns the row we interrupted it for, and waiting a tick to notice
3732
4486
  // would leave a slot standing empty for fifteen seconds.
4487
+ this._settleRunNow(items);
3733
4488
  this._settlePreemptions(items, now);
3734
4489
 
3735
- // The digest: NOT a schedule, an INVARIANT. (the founder, 2026-08-06). So
4490
+ // The digest: NOT a schedule, an INVARIANT. So
3736
4491
  // nothing fires on a clock. Every tick asks whether the invariant holds
3737
4492
  // and, where it does not, spawns the one session that makes it hold.
3738
4493
  //
@@ -3754,10 +4509,10 @@ export class Supervisor {
3754
4509
  if (!autonomous.has(product.slug)) continue;
3755
4510
  if ([...this.sessions.values()].some((s) => s.product === product.slug)) continue;
3756
4511
  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;
4512
+ // A DIGEST RUNS ON THIS MAC'S HOME ENGINE: Claude Code, or Codex on a Mac
4513
+ // with nothing else. It carries a synthetic row with no engine on it, so
4514
+ // the slot it checks and the engine `spawnDigest` forces are one answer.
4515
+ if (!this._hasSlotFor(this._homeEngine())) break;
3761
4516
  const owed = this._digestOwed(items, product.slug, now);
3762
4517
  if (!owed) continue;
3763
4518
  this.lastDigestTry[product.slug] = now;
@@ -3765,14 +4520,13 @@ export class Supervisor {
3765
4520
  this.spawnDigest(product, owed);
3766
4521
  }
3767
4522
 
3768
- // THE DRIVE RAN HERE AND IT IS GONE (w-d19d6d387c, 2026-09-22).
4523
+ // THE DRIVE RAN HERE AND IT IS GONE (w-d19d6d387c).
3769
4524
  // 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.
4525
+ // a product went quiet, and spending the user's plan on the answer. An app
4526
+ // that spends someone's plan on work they did not ask for should not exist,
4527
+ // so the code was removed entirely. It had been off by default since
4528
+ // w-8cb4d3ffc3, and the machinery was kept for a paid tier that no longer
4529
+ // justifies it.
3776
4530
  //
3777
4531
  // So nothing in this app spawns work the founder did not ask for. What
3778
4532
  // spawns now, and the whole list: her own tasks, her answers, the tasks
@@ -3897,7 +4651,7 @@ export class Supervisor {
3897
4651
  'was spent and what went live, if anything. Where nothing happened in',
3898
4652
  'some lane that matters, saying so plainly is worth more than padding.',
3899
4653
  ].join('\n'),
3900
- }, { engine: DEFAULT_ENGINE });
4654
+ }, { engine: this._homeEngine() });
3901
4655
  }
3902
4656
 
3903
4657
  // A founder reply that lands while a session runs must not die with it: the
@@ -3934,13 +4688,13 @@ export class Supervisor {
3934
4688
  // it did. It is here for the two resume paths only: a session belongs to the
3935
4689
  // harness that wrote it, and a thread id handed to the wrong CLI dies on
3936
4690
  // arrival with "No conversation found with session ID".
3937
- spawnPlan(item, product, { continuation = false, resumeSessionId = null, engine = DEFAULT_ENGINE } = {}) {
4691
+ spawnPlan(item, product, { continuation = false, resumeSessionId = null, engine = DEFAULT_ENGINE, shipFailure = null } = {}) {
3938
4692
  // A ROW'S OWN CHAT. Her reply on a row goes back to the session that wrote
3939
4693
  // what she is replying to. Four guards, and each one falls back to the old
3940
4694
  // behaviour rather than to anything worse:
3941
4695
  //
3942
4696
  // `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.
4697
+ // BECAUSE the user wrote on the row. Fresh work is briefed as it always was.
3944
4698
  //
3945
4699
  // `!resumeSessionId`, so the wake sweep still wins outright. That path
3946
4700
  // knows which session it interrupted and is resuming a run that never
@@ -3965,8 +4719,8 @@ export class Supervisor {
3965
4719
  const resumeIdOnDisk = resumeSessionId ?? rowChat?.sessionId ?? forkFrom?.sessionId ?? null;
3966
4720
  // AND WHICH SUBSCRIPTION IT LIVES ON. A resume is only a resume on the
3967
4721
  // 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
4722
+ // and under no other. With two Claude logins, spawns round-robin across
4723
+ // them, so half of the replies handed `--resume` an id the other
3970
4724
  // home had never heard of. The CLI printed "No conversation found with
3971
4725
  // session ID" and exited in under a second. spawnWorker uses this instead
3972
4726
  // of picking, and null still means pick.
@@ -3983,7 +4737,14 @@ export class Supervisor {
3983
4737
  // round robin lands on is an id that home has never heard of, the CLI
3984
4738
  // prints "No conversation found" and dies in a second. So this drops both,
3985
4739
  // and the thread is briefed fresh instead.
3986
- const strandedThread = !!walkedHome && this._profileCannotHoldAChat(walkedHome, engine);
4740
+ //
4741
+ // IN A TIED PROJECT THE QUESTION IS NOT HEALTH BUT WHOSE ACCOUNT IT IS, the
4742
+ // same swap `rowSessionFor` makes above and for the same reason: a thread
4743
+ // found on the wrong login is stranded whatever that login's health, and a
4744
+ // thread on the right one is kept however poorly it is faring.
4745
+ const strandedThread = !!walkedHome && (this._projectProfile(product?.slug ?? item.product, engine)
4746
+ ? !this._profileFitsProject(walkedHome, product?.slug ?? item.product, engine)
4747
+ : this._profileCannotHoldAChat(walkedHome, engine));
3987
4748
  const resumeId = strandedThread ? null : resumeIdOnDisk;
3988
4749
  // The fork's account is the source conversation's account, for the reason
3989
4750
  // every line above gives: a resume is only a resume on the login whose home
@@ -3993,8 +4754,12 @@ export class Supervisor {
3993
4754
  : (forkFrom ? forkFrom.profile ?? null : (strandedThread ? null : walkedHome));
3994
4755
  // Preserve multiple replies queued before a worker starts, including on a
3995
4756
  // resumed session whose native history has not seen them yet.
4757
+ // A CONVERSATION GOING BACK TO WORK IS NOT TOLD AGAIN WHAT IT ALREADY HEARD
4758
+ // (`_noteHeard`). Only that conversation: a fork or a fresh session needs
4759
+ // every word.
3996
4760
  if (continuation && item.answer && this.store?.readHistory) {
3997
- const answer = queuedReplyText(item, this.store.readHistory(item.product, item.id), (row) => this._answerDelivered(row));
4761
+ const conversation = forkFrom ? null : resumeId;
4762
+ const answer = queuedReplyText(item, this.store.readHistory(item.product, item.id), (row) => this._answerDelivered(row) || this._heardIn(row, conversation));
3998
4763
  if (answer !== item.answer) item = { ...item, answer };
3999
4764
  }
4000
4765
  let prompt;
@@ -4007,13 +4772,22 @@ export class Supervisor {
4007
4772
  // cannot work out for itself is that it has moved.
4008
4773
  else if (forkFrom) prompt = this.forkBrief(item, { product, from: forkFrom.sourceId });
4009
4774
  else prompt = resumeId ? this.resumeBrief(item, { continuation, product }) : this.buildBrief(item, product, { continuation, engine });
4775
+ // A BRANCH THE APP COULD NOT SHIP (main/ship-queue.mjs) goes back to the
4776
+ // session that wrote it, and that is all it is told: it has everything
4777
+ // else already. A fresh session gets its brief first.
4778
+ if (shipFailure) prompt = resumeId || rowChat ? shipFailure : `${prompt}\n\n${shipFailure}`;
4779
+ // WHAT HAPPENS TO WHAT IT LEAVES RUNNING, when that switch is on
4780
+ // (main/leftovers.mjs): the rule, and the programs of this task still
4781
+ // shutting down, which it must not reuse.
4782
+ const leftoverNote = this.leftoverBriefNote(item);
4783
+ if (leftoverNote) prompt = `${prompt}\n\n${leftoverNote}`;
4010
4784
  // A project may override what its own sessions may do. Absent (the normal
4011
4785
  // case) means the workspace default, which is why this is a lookup with no
4012
4786
  // entry rather than a copy of the default written onto every project: a
4013
4787
  // copy is a grant that stops tracking the one place grants are reviewed.
4014
4788
  const override = this.projectSessionArgs(item.product);
4015
4789
  const base = override ?? this.defaultSessionArgs();
4016
- // WHAT SHE SAID THIS ONE REPLY MAY DO, which outranks the project and the
4790
+ // WHAT THE USER SAID THIS ONE REPLY MAY DO, which outranks the project and the
4017
4791
  // workspace for exactly this run.
4018
4792
  //
4019
4793
  // It rewrites only the --permission-mode flag; every other grant in her
@@ -4021,12 +4795,12 @@ export class Supervisor {
4021
4795
  // everything else through untouched. A value that is not one of Claude
4022
4796
  // Code's six is ignored rather than passed to the CLI.
4023
4797
  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
4798
+ // THE USER'S CONFIG GOES TO THE CLI AS WRITTEN, and only a one-off rewrites
4025
4799
  // it. This was briefly the other way round, running every spawn through
4026
4800
  // buildSessionArgs so that exactly one permission flag always reached the
4027
4801
  // CLI, and that was the wrong trade: the parser cannot know the arity of
4028
4802
  // 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
4803
+ // read as one and its owner is left dangling. Rewriting a config the user typed
4030
4804
  // by hand, on every spawn, to fix a display problem is not worth that.
4031
4805
  //
4032
4806
  // The display problem is fixed at the reading end instead: permissionMode
@@ -4034,10 +4808,10 @@ export class Supervisor {
4034
4808
  // names whatever her flags really add up to. Caught in review, 2026-08-23.
4035
4809
  const grants = oneOff ? buildSessionArgs(base, oneOff) : base;
4036
4810
  /*
4037
- * SHE TYPED ONE OF CLAUDE CODE'S OWN COMMANDS, so the command is the whole
4038
- * prompt and the brief is skipped.
4811
+ * THE USER TYPED ONE OF CLAUDE CODE'S OWN COMMANDS, so the command is the
4812
+ * whole prompt and the brief is skipped.
4039
4813
  *
4040
- * The eight she then picked are in the reply box's slash menu;
4814
+ * The eight supported ones are in the reply box's slash menu;
4041
4815
  * shared/claude-commands.mjs is the list and the reason.
4042
4816
  *
4043
4817
  * IT HAS TO REPLACE THE PROMPT RATHER THAN RIDE IN FRONT OF IT, and that is
@@ -4049,7 +4823,7 @@ export class Supervisor {
4049
4823
  *
4050
4824
  * THREE GUARDS, AND EACH IS A DIFFERENT WAY OF NOT DOING THIS BY ACCIDENT:
4051
4825
  *
4052
- * `continuation` means this spawn exists BECAUSE she wrote on the row.
4826
+ * `continuation` means this spawn exists BECAUSE the user wrote on the row.
4053
4827
  * Without it, `item.answer` sits on the row for good and an ordinary wake
4054
4828
  * three hours later would run /context instead of the work.
4055
4829
  *
@@ -4121,7 +4895,7 @@ export class Supervisor {
4121
4895
  const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--include-partial-messages', ...claudeGrants];
4122
4896
  if (claudeEffort) args.push('--effort', claudeEffort);
4123
4897
  // Her standing instructions, on every session there is: workers, digests,
4124
- // and every resumed turn of a thread that started before she wrote the
4898
+ // and every resumed turn of a thread that started before the user wrote the
4125
4899
  // rule. This is the ONE place a session's arguments are built, which is why
4126
4900
  // they cannot miss a path.
4127
4901
  //
@@ -4138,7 +4912,8 @@ export class Supervisor {
4138
4912
  // belongs: it is the only block here she did not necessarily write, so her
4139
4913
  // standing instructions and her project instructions both sit above it and
4140
4914
  // 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.
4915
+ // in settings drops the user's own words; the app's rules stay
4916
+ // (w-3ec9f07978), because the inbox reads every message by them.
4142
4917
  //
4143
4918
  // SINCE w-3dc46f3a67 THIS BLOCK IS BOTH MESSAGE DOCUMENTS, not just the
4144
4919
  // finishing half. The writing rules used to be spliced into the worker
@@ -4148,8 +4923,8 @@ export class Supervisor {
4148
4923
  const project = this.projectInstructions(item.product, product);
4149
4924
  const messages = this.messageRules();
4150
4925
  // 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."
4926
+ // (w-5737fe67cf): its text is appended while the mode is on and removed
4927
+ // when it is turned off.
4153
4928
  const adhd = this.adhdRules();
4154
4929
  // {{name}} and {{Name}} become the app's name here too, on the same rule as
4155
4930
  // buildBrief: these blocks are markdown files that cannot import it, and
@@ -4165,7 +4940,7 @@ export class Supervisor {
4165
4940
  * `command` GOES BACK WITH THE ARGUMENTS, because the exit handler has to
4166
4941
  know what this run was. A command run does not write on the row and is
4167
4942
  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
4943
+ ordinary test is "did an agent speak on the row since the answer", which
4169
4944
  a command run fails, and failing it means her reply is delivered again —
4170
4945
  so /context would run three times and she would read the same table
4171
4946
  three times. */
@@ -4209,16 +4984,14 @@ export class Supervisor {
4209
4984
  /**
4210
4985
  * THE ANSWER A COMMAND LEFT, WHILE IT IS STILL THE NEWEST THING ON THE ROW.
4211
4986
  *
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.
4987
+ * A command's answer must not sit at the bottom of the thread forever. One
4988
+ * result per task is published here and the pane draws it at the foot of the
4989
+ * thread, and nothing ever took it down. It is saved with the session state,
4990
+ * so a restart brought it back too, and a `/fork` answer sat under messages
4991
+ * written hours later, reading as though the command had just run again.
4219
4992
  *
4220
4993
  * 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
4994
+ * that expires. The answer belongs to the moment the command ran, so it
4222
4995
  * shows until the row has moved on, and the row's own `updatedAt` is what says
4223
4996
  * that. Nothing fires and nothing sweeps, and a restart cannot resurrect it
4224
4997
  * because the comparison is made every time it is read. A command still
@@ -4357,25 +5130,22 @@ export class Supervisor {
4357
5130
  // tests/the-state-page-is-gone.test.mjs has the measurements and keeps it
4358
5131
  // out. Nothing replaced it: a row carries its own history.
4359
5132
 
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.
5133
+ // THE RULE ABOUT WHEN WORK GETS ITS OWN ROW had a switch here, read off
5134
+ // `subagentRule: false`. It is always on now (w-5737fe67cf), because it is a
5135
+ // rule and not a preference. Off only took one paragraph out of the brief,
5136
+ // and the paragraph is what keeps one piece of work from reaching the user as
5137
+ // several rows. A config that still says false is ignored.
4366
5138
 
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).
5139
+ // HOW AGENTS WRITE TO THE USER: the titles, the opening lines, the questions,
5140
+ // and the last message a run sends. ONE document (w-3dc46f3a67).
4369
5141
  //
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.
5142
+ // IT WAS TWO AND THE SPLIT WAS OURS, NOT THE USER'S. `writing-rules.md` rode
5143
+ // inside the worker brief and shaped the messages during a task;
5144
+ // `finishing.md` rode in the system prompt and shaped the final one. Five
5145
+ // editable layers of prompts was too many, so some were merged. A person's
5146
+ // rules file tends to be mostly about how agents write to them rather than
5147
+ // what agents do. That is what somebody types when they sit down; the seam
5148
+ // between the two files is an implementation detail.
4379
5149
  //
4380
5150
  // WHERE IT RIDES, AND WHY THAT IS THE WHOLE DOCUMENT NOW. The writing half
4381
5151
  // used to be spliced into the worker brief, which is the one thing some runs
@@ -4395,32 +5165,56 @@ export class Supervisor {
4395
5165
  return path.join(this.userDir, 'briefs', 'message-rules.md');
4396
5166
  }
4397
5167
 
5168
+ // OURS AND THEIRS ARE TWO LAYERS, NOT ONE FILE (w-3ec9f07978). The shipped
5169
+ // rules are what the inbox reads every message by: the bold first line the
5170
+ // list clips at, the Options section the picker draws, the last message
5171
+ // being the answer. A person editing those breaks their own inbox, so they
5172
+ // ride from the checkout on every run and are never in the box. The box is
5173
+ // only what the user wrote, and it rides above ours so their words win.
5174
+ shippedMessageRules() {
5175
+ try { return fs.readFileSync(this.messageRulesDefaultFile(), 'utf8').trim(); } catch { return ''; }
5176
+ }
5177
+
4398
5178
  // 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".
5179
+ // while the fleet is running. Emptying the box takes the user's words out,
5180
+ // never ours.
4402
5181
  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 {}
5182
+ let theirs = '';
5183
+ if (!agentInstructionsUnified(this.userDir) && this.messageRulesFile() !== this.messageRulesDefaultFile()) {
5184
+ try { theirs = fs.readFileSync(this.messageRulesFile(), 'utf8').trim(); } catch {}
4407
5185
  }
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
5186
+ const ours = this.shippedMessageRules();
5187
+ // ONE framing line each, for the same reason her project instructions carry
5188
+ // one: a session has no other way to know whose words these are, or which
4414
5189
  // wins where the two disagree.
4415
- return [
5190
+ const blocks = [];
5191
+ if (theirs) blocks.push([
5192
+ 'How the person reading this wants agents to write to them, in their own',
5193
+ 'words. Where these and the app\'s rules below disagree, these win.',
5194
+ '',
5195
+ '---',
5196
+ '',
5197
+ theirs,
5198
+ ].join('\n'));
5199
+ // THE APP'S KEYS RIDE WITH OUR RULES (w-7ec8553e23). An agent told the user
5200
+ // to "Press S" to schedule three days after S became the summary, going by
5201
+ // a memory its own session saved before the key moved. They ride inside
5202
+ // this block, never alone, so a run the app hands no rules still gets no
5203
+ // flag at all.
5204
+ if (ours) blocks.push([
4416
5205
  'How to write to the person reading this, and how to finish, from the app',
4417
5206
  'they read it in. These are its defaults, so any instruction above this one',
4418
5207
  'outranks them.',
4419
5208
  '',
4420
5209
  '---',
4421
5210
  '',
4422
- body,
4423
- ].join('\n');
5211
+ ours,
5212
+ '',
5213
+ '---',
5214
+ '',
5215
+ keysForAgents(),
5216
+ ].join('\n'));
5217
+ return blocks.length ? blocks.join('\n\n') : null;
4424
5218
  }
4425
5219
 
4426
5220
  // ADHD MODE (w-5737fe67cf, 2026-09-25): a short set of writing rules that ride
@@ -4439,16 +5233,12 @@ export class Supervisor {
4439
5233
  return ['ADHD mode is on. Follow these rules as well.', '', body].join('\n');
4440
5234
  }
4441
5235
 
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.
5236
+ // What the settings box shows: the user's own words and nothing of ours
5237
+ // (w-3ec9f07978), so it opens empty until they write something.
4444
5238
  readMessageRules() {
5239
+ if (agentInstructionsUnified(this.userDir)) return this.readStanding();
4445
5240
  try {
4446
5241
  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
5242
  } catch (err) {
4453
5243
  if (err.code === 'ENOENT') return '';
4454
5244
  throw err;
@@ -4461,6 +5251,7 @@ export class Supervisor {
4461
5251
  // a state on disk that nothing later mistakes for a fresh install and
4462
5252
  // re-seeds.
4463
5253
  writeMessageRules(text) {
5254
+ if (agentInstructionsUnified(this.userDir)) return this.writeStanding(text);
4464
5255
  const file = this.messageRulesFile();
4465
5256
  const tmp = `${file}.tmp-${process.pid}`;
4466
5257
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -4504,11 +5295,7 @@ export class Supervisor {
4504
5295
  // Written whole, through a rename, because the fleet reads this file on
4505
5296
  // every spawn: a half-written save is a session briefed with half a rule.
4506
5297
  writeStanding(text) {
4507
- const file = this.standingFile();
4508
- const tmp = `${file}.tmp-${process.pid}`;
4509
- fs.mkdirSync(path.dirname(file), { recursive: true });
4510
- fs.writeFileSync(tmp, typeof text === 'string' ? text : '', 'utf8');
4511
- fs.renameSync(tmp, file);
5298
+ return writeInstruction(this.userDir, 'rules', typeof text === 'string' ? text : '');
4512
5299
  }
4513
5300
 
4514
5301
  /* ---------------------------- the second engine ------------------------- */
@@ -4575,7 +5362,7 @@ export class Supervisor {
4575
5362
  : this.config;
4576
5363
  const ranOn = engineFor(item, {
4577
5364
  config,
4578
- found: { codex: !!this.config.codexBin },
5365
+ found: this._enginesFound(),
4579
5366
  // THE OPT-IN, AND THE ONLY EXPRESSION IN THE APP THAT PRODUCES THE TOKEN.
4580
5367
  // Derived from the same value the staleness rule reads, so the permission
4581
5368
  // and the moment cannot come apart: there is no way to open the gate
@@ -4617,8 +5404,33 @@ export class Supervisor {
4617
5404
  * no second boolean anywhere that could disagree with it.
4618
5405
  */
4619
5406
  engineChoices() {
5407
+ // A Mac with only Codex is offered Codex whether or not the gate is open:
5408
+ // one engine is not a choice, so there is nothing for the gate to hold.
5409
+ if (this._homeEngine() !== DEFAULT_ENGINE) return availableEngines(this._enginesFound());
4620
5410
  if (!this.engineChoiceOpened()) return availableEngines();
4621
- return availableEngines({ codex: !!this.config.codexBin });
5411
+ return availableEngines(this._enginesFound());
5412
+ }
5413
+
5414
+ /**
5415
+ * WHICH CODING AGENTS ARE ON THIS MAC, as the shape `shared/engines.mjs`
5416
+ * reads. `claude` is false only when the search said it is not here
5417
+ * (main/config.mjs `claudeFound`); a config that never said leaves it true,
5418
+ * which is what it always meant. */
5419
+ _enginesFound() {
5420
+ const codex = !!this.config.codexBin;
5421
+ // A CLAUDE CODE NOBODY IS SIGNED INTO, BESIDE A CODEX SOMEBODY IS, COUNTS
5422
+ // AS NOT HERE (w-d5d632e503). The Claude app keeps a copy of Claude Code
5423
+ // whether or not its owner pays for it, so a person who only uses Codex
5424
+ // had every task sent to a Claude Code that could not run. Both answers
5425
+ // come from the sign-in check (main/engine-setup.mjs); until it has
5426
+ // answered, nothing moves.
5427
+ const claudeSignedOut = this.config.claudeSignedIn === false && codex && this.config.codexSignedIn === true;
5428
+ return { claude: this.config.claudeFound !== false && !claudeSignedOut, codex };
5429
+ }
5430
+
5431
+ /** What runs when nobody chose: Claude Code, or Codex on a Mac without it. */
5432
+ _homeEngine() {
5433
+ return homeEngine(this._enginesFound());
4622
5434
  }
4623
5435
 
4624
5436
  /**
@@ -4679,6 +5491,34 @@ export class Supervisor {
4679
5491
  return { choices, workspace, byItem };
4680
5492
  }
4681
5493
 
5494
+ /**
5495
+ * WHICH SUBSCRIPTION THE FLEET IS SPENDING, for the one line that says so
5496
+ * (w-e217e577e5, 2026-10-07). Null on a Mac where nothing readable is signed
5497
+ * in, and then nothing is drawn anywhere.
5498
+ *
5499
+ * IT IS ANSWERED HERE FOR THE REASON `engineFacts` IS. Which engine a row runs
5500
+ * on needs the capability gate, and which ACCOUNT it runs on needs
5501
+ * `_narrowToChosen`, the rule that one account picked in Settings is the only
5502
+ * one the fleet uses. A corner that worked either out for itself could name
5503
+ * the subscription this app is deliberately not spending, which is the one
5504
+ * mistake this line must never make: somebody reads it to find out whose money
5505
+ * is going.
5506
+ *
5507
+ * THE FIRST OF THE POOL, which is the account the next spawn goes to on a Mac
5508
+ * with nothing picked (`_pickProfile` round-robins from there). Two accounts
5509
+ * of the same plan read the same either way; two different plans and a chosen
5510
+ * account make this exact, which is the case somebody would ask about.
5511
+ */
5512
+ runsOnAccount(engine = this._engineFor(null)) {
5513
+ const home = this.config.home;
5514
+ if (engine === 'codex') {
5515
+ const pool = this._narrowToChosen('codex', this._codexProfiles());
5516
+ return runsOn({ engine, home, codexHome: this._codexProfileHome(pool[0] ?? 'default') });
5517
+ }
5518
+ const pool = this._narrowToChosen('claude', this._profiles());
5519
+ return runsOn({ engine: 'claude', home, claudeProfile: pool[0] ?? 'default' });
5520
+ }
5521
+
4682
5522
  /**
4683
5523
  * THE ENGINE ON A TASK SHE IS COMPOSING, OR NULL, and the door writes nothing
4684
5524
  * else (`zero:compose` in main/ipc.mjs).
@@ -4854,7 +5694,7 @@ export class Supervisor {
4854
5694
  * "before EVERY spawn", with "never pass an API key into a worker's env"
4855
5695
  * beside it, so a Codex worker is not handed her `ANTHROPIC_API_KEY` either.
4856
5696
  * 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
5697
+ * that is already settled, and the OpenAI one was added by the Codex slice
4858
5698
  * and never asked for. Symmetry is not worth weakening the older of the two.
4859
5699
  *
4860
5700
  * The branch is over the engine the spawn is FOR, which is the one fact this
@@ -5008,8 +5848,8 @@ export class Supervisor {
5008
5848
  *
5009
5849
  * STILL NEVER TRANSLATED, and that half is unchanged. There is no honest map
5010
5850
  * 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.
5851
+ * about cost and capability that belongs to the user, and guessing it would
5852
+ * run their work on a model they did not pick.
5013
5853
  *
5014
5854
  * AND RUNNING ON CODEX'S DEFAULT IS THAT SAME GUESS. That is the whole
5015
5855
  * argument for throwing rather than dropping the word and carrying on. A run
@@ -5017,8 +5857,8 @@ export class Supervisor {
5017
5857
  * worker's stderr only becomes a sentence on her row through `sayTheRunDied`,
5018
5858
  * which fires on a run that DIED, so on the ordinary successful run the
5019
5859
  * 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.
5860
+ * that when the system swallows something the user said, they cannot tell it
5861
+ * from the work not happening. Stopping is the only outcome they can see.
5022
5862
  *
5023
5863
  * A THROW HERE COSTS THE ROW NOTHING. `threadParams` is called inside
5024
5864
  * `createCodexWorker`'s promise chain, so this lands in the catch at the end
@@ -5140,7 +5980,7 @@ export class Supervisor {
5140
5980
  const levels = codexModelLevels(slug, { home });
5141
5981
  if (!levels || levels.includes(level)) return null;
5142
5982
  const offers = levels.length ? `It offers ${levels.join(', ')}.` : 'It advertises no levels at all.';
5143
- return `this row asks "${slug}" to think at "${level}", which is not a level that model offers on this Mac. ${offers} Nothing here will pick a stand-in for her, so this run is stopping rather than doing her work at a level she did not choose`;
5983
+ return `this row asks "${slug}" to think at "${level}", which is not a level that model offers on this computer. ${offers} Nothing here will pick a stand-in for her, so this run is stopping rather than doing her work at a level she did not choose`;
5144
5984
  }
5145
5985
 
5146
5986
  /**
@@ -5220,7 +6060,7 @@ export class Supervisor {
5220
6060
  const asked = from === 'workspace'
5221
6061
  ? `every Codex agent here is set to run on "${word}"`
5222
6062
  : `this row asks to run on "${word}"`;
5223
- return `${asked}, which is not a model the Codex on this Mac knows.${instead} Nothing here will pick a stand-in for her, so this run is stopping rather than doing her work on a model she did not choose`;
6063
+ return `${asked}, which is not a model the Codex on this computer knows.${instead} Nothing here will pick a stand-in for her, so this run is stopping rather than doing her work on a model she did not choose`;
5224
6064
  }
5225
6065
 
5226
6066
  /**
@@ -5255,6 +6095,7 @@ export class Supervisor {
5255
6095
  env: {
5256
6096
  STORE_ACCOUNT_ID: this.config.accountId,
5257
6097
  ...storeRootEnv(this.config.storeRoot),
6098
+ ...teamPersonEnv(),
5258
6099
  ZERO_PRODUCT: item.product,
5259
6100
  ZERO_ITEM: item.id,
5260
6101
  },
@@ -5294,8 +6135,16 @@ export class Supervisor {
5294
6135
  if (live && !live.client.isClosed()) return live;
5295
6136
  const client = createCodexAppServer({
5296
6137
  spawn: () => spawn(this.config.codexBin, ['app-server'], {
5297
- cwd: this.appDir,
5298
- env: { ...this._workerEnv('codex'), CODEX_HOME: home },
6138
+ // HER HOME, NEVER THE APP'S OWN FOLDER. In an installed build that is
6139
+ // `app.asar`, a file, and Node throws `spawn ENOTDIR` for a cwd that is
6140
+ // not a folder: every Codex thread on every downloaded copy sat at
6141
+ // Queued (2026-10-06). Each thread names its own cwd at thread/start,
6142
+ // so this one is only somewhere real to stand.
6143
+ cwd: this.config.home || os.homedir(),
6144
+ // The socket its threads' commands ask on, and no row: see
6145
+ // `codexMemoryGateEnv`. One app-server is every Codex thread of this
6146
+ // login, so a row named here would be the wrong row for all but one.
6147
+ env: codexLaunchEnv(this.config.codexBin, { ...this._workerEnv('codex'), CODEX_HOME: home, ...this.codexMemoryGateEnv() }),
5299
6148
  stdio: ['pipe', 'pipe', 'pipe'],
5300
6149
  }),
5301
6150
  // WHAT IS LEFT OF THIS LOGIN'S LIMIT, ARRIVING UNBIDDEN. `onNotification`
@@ -5315,6 +6164,24 @@ export class Supervisor {
5315
6164
  handshake.catch(() => { /* every worker chains its own catch off this */ });
5316
6165
  const entry = { client, handshake, home };
5317
6166
  this._codexServers.set(home, entry);
6167
+ // AND THE MEMORY HOOK IS TRUSTED HERE, ON THE WAY UP (w-e5225b62ba).
6168
+ //
6169
+ // It has to happen on a server that is starting anyway. Codex will not run an
6170
+ // untrusted hook, and the trust needs `hooks/list` to report the hash Codex
6171
+ // computed -- which cannot be worked out locally, it is not a hash of the
6172
+ // command text. So the one place that has both an app-server and no reason to
6173
+ // start one is right here.
6174
+ //
6175
+ // The alternative was for the switch to start an app-server itself just to
6176
+ // ask, and on the Mac this was written on that is a few hundred megabytes
6177
+ // spent to protect against spending memory. Chained off the handshake, it
6178
+ // costs two calls on a process that was being started regardless, and it runs
6179
+ // before any thread of this server exists.
6180
+ if (this.config.memoryGate) {
6181
+ handshake
6182
+ .then(() => this.setCodexMemoryGate(true, home, entry))
6183
+ .catch((e) => console.warn(`zero: codex memory gate: ${e.message}`));
6184
+ }
5318
6185
  return entry;
5319
6186
  }
5320
6187
 
@@ -5341,8 +6208,8 @@ export class Supervisor {
5341
6208
  * WHOSE LIMIT THE CARD IS ABOUT: the login she picked, else her primary one.
5342
6209
  *
5343
6210
  * 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%
6211
+ * The pick moved the work onto a second account and left the figure behind,
6212
+ * so switching off an account at 99% left the card still saying 99%
5346
6213
  * (w-cad7e3e509). With no pick every login runs, and the primary one stays
5347
6214
  * the one the card speaks for, as before.
5348
6215
  */
@@ -5467,7 +6334,7 @@ export class Supervisor {
5467
6334
  this._codexServers?.clear();
5468
6335
  }
5469
6336
 
5470
- spawnWorker(item, { continuation = false, resumeSessionId = null, profile: forcedProfile = null, engine: forcedEngine = null, remoteOnly = false } = {}) {
6337
+ spawnWorker(item, { continuation = false, resumeSessionId = null, profile: forcedProfile = null, engine: forcedEngine = null, remoteOnly = false, shipFailure = null } = {}) {
5471
6338
  if (this._compactionJobs?.has(JSON.stringify([item.product, item.id]))) return;
5472
6339
  // ASKED ONCE, HERE, AND ANSWERED CLAUDE CODE ON EVERY MACHINE TODAY. See
5473
6340
  // `_engineFor` for the two independent reasons why. It is asked ahead of
@@ -5490,20 +6357,79 @@ export class Supervisor {
5490
6357
  // and check a Claude Code slot; this is how that one answer reaches the
5491
6358
  // spawn instead of being derived again down here and disagreeing.
5492
6359
  const engine = forcedEngine ? engineOf(forcedEngine) : this._engineFor(item);
6360
+ // THIS TASK'S LEFTOVERS WERE ASKED TO STOP SECONDS AGO (main/leftovers.mjs)
6361
+ // and may still be on their way out, so the agent waits out the rest of
6362
+ // those 30 seconds rather than finding a dying server and reusing it. It
6363
+ // waits as a pending entry, the same one a folder being built uses, so it
6364
+ // counts against its slot, Stop cancels it, and a quit puts its reply back
6365
+ // in line (Codex's review, 2026-10-05). The latest call's options win.
6366
+ const hold = this._leftoverCleaner?.holdMs?.(item.id) ?? 0;
6367
+ if (hold > 0) {
6368
+ this._preparing ??= new Map();
6369
+ const opts = { continuation, resumeSessionId, profile: forcedProfile, engine: forcedEngine, remoteOnly, shipFailure };
6370
+ const held = this._preparing.get(item.id);
6371
+ if (held) { held.item = item; held.opts = opts; return; }
6372
+ const entry = { item, opts, engine };
6373
+ this._preparing.set(item.id, entry);
6374
+ const t = setTimeout(() => {
6375
+ if (this._preparing?.get(item.id) !== entry) return; // stopped, or the app quit
6376
+ this._preparing.delete(item.id);
6377
+ if (this.sessions.has(item.id)) return;
6378
+ try { this.spawnWorker(entry.item, entry.opts); }
6379
+ catch (error) { console.warn(`zero: could not start ${item.id}:`, error.message); }
6380
+ this.onChange?.();
6381
+ }, hold + 100);
6382
+ t.unref?.();
6383
+ return;
6384
+ }
5493
6385
  // A ROW WHOSE MODEL BELONGS TO THE OTHER HARNESS DOES NOT RUN, AND SAYS SO.
5494
6386
  // Asked here rather than inside `spawnPlan` because the answer is not a
5495
6387
  // narrower plan, it is no run at all -- and asked before the slot check and
5496
6388
  // before the product lookup, so a refusal costs the fleet nothing.
5497
6389
  if (item?.model && !modelForEngine(item, engine)) {
5498
6390
  this._sayTheModelIsForAnotherEngine(item, engine);
5499
- // HER WORDS ARE NOT DELIVERED BY A WORKER THAT NEVER STARTED. The queue
6391
+ // THE USER'S WORDS ARE NOT DELIVERED BY A WORKER THAT NEVER STARTED. The queue
5500
6392
  // 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
6393
+ // persisted, so leaving it standing would record the reply as handed over
5502
6394
  // for good. `redeliverAnswer` is the sanctioned way back.
5503
6395
  if (continuation && item.answer) this.redeliverAnswer(item, item.answer);
5504
6396
  return;
5505
6397
  }
5506
6398
  if (!this._hasSlotFor(engine) && !continuation) return;
6399
+ // A TIED PROJECT WAITS RATHER THAN BORROWING A LOGIN, and this is where
6400
+ // "tied" stops being a word on a settings page. The engine's own slot check
6401
+ // above has already passed, and it is a check about the whole engine: with
6402
+ // two logins it says yes while the ONE account this project may use is full.
6403
+ // So the pinned account answers for itself, and when it cannot take the work
6404
+ // nothing starts. Waiting is the promise; running it on the other
6405
+ // subscription is the bug the setting exists to stop.
6406
+ //
6407
+ // A REPLY IS HANDED BACK RATHER THAN DROPPED. A continuation skips the slot
6408
+ // check by design, so without the `redeliverAnswer` here the queue's
6409
+ // delivery mark would stand on a reply no worker ever carried, which records
6410
+ // it as handed over for good.
6411
+ //
6412
+ // AND A FORCED PROFILE LOSES. `forcedProfile` is the wake sweep saying which
6413
+ // account it interrupted; if that is not the pinned one, the session being
6414
+ // resumed lives on a login this project may no longer use, so the id goes
6415
+ // with it and the row is briefed fresh on the right account.
6416
+ const pinned = this._projectProfile(item.product, engine);
6417
+ if (pinned) {
6418
+ if (forcedProfile && forcedProfile !== pinned) { forcedProfile = null; resumeSessionId = null; }
6419
+ const full = !continuation && this._loadOnProfile(pinned, engine) >= this._slotsPerAccount(engine);
6420
+ if (full || this._profileResting(this._accountKey(engine, pinned))) {
6421
+ if (continuation && item.answer) this.redeliverAnswer(item, item.answer);
6422
+ return;
6423
+ }
6424
+ }
6425
+ // HER REPLY WAITS FOR AN ACCOUNT THAT CAN CARRY IT. A continuation skips the
6426
+ // slot check by design, so with every account on this engine sitting out it
6427
+ // would go to one anyway, die in two seconds, and go again next tick. Held
6428
+ // here and handed back, it goes out the tick the account returns.
6429
+ if (continuation && !pinned && !this._liveProfilesFor(engine).length) {
6430
+ if (item.answer) this.redeliverAnswer(item, item.answer);
6431
+ return;
6432
+ }
5507
6433
  const product = this.store.listProducts().find((p) => p.slug === item.product);
5508
6434
  if (!product) return;
5509
6435
  // AND THIS IS WHERE "nothing ever runs in the practice project" IS ACTUALLY
@@ -5519,8 +6445,9 @@ export class Supervisor {
5519
6445
  return;
5520
6446
  }
5521
6447
  if (this._folderFirst(item, product, engine, { continuation, resumeSessionId, profile: forcedProfile, engine: forcedEngine, remoteOnly })) return;
6448
+ if (this._photoFirst(item, product, engine, { continuation, resumeSessionId, profile: forcedProfile, engine: forcedEngine, remoteOnly })) return;
5522
6449
 
5523
- const plan = this.spawnPlan(item, product, { continuation, resumeSessionId, engine });
6450
+ const plan = this.spawnPlan(item, product, { continuation, resumeSessionId, engine, shipFailure });
5524
6451
  const { args } = plan;
5525
6452
  // ONE RUN, ONE PAIR OF FILES. The MCP config and the settings used to be a
5526
6453
  // fixed name each in the temp folder, fine while every spawn wrote the
@@ -5545,12 +6472,17 @@ export class Supervisor {
5545
6472
  // round-robin pick here would hand `--resume` an id the CLI cannot find and
5546
6473
  // the session would die on arrival.
5547
6474
  //
5548
- // THAT WAS TRUE OF THE WAKE SWEEP ONLY, AND IT IS THE BUG SHE FILED.
6475
+ // THAT WAS TRUE OF THE WAKE SWEEP ONLY, AND THAT WAS THE BUG.
5549
6476
  // `forcedProfile` is passed by _resumeInterrupted and by nothing else, so
5550
- // the one path that resumes on every reply she writes, a personal
6477
+ // the one path that resumes on every reply the user writes, a personal
5551
6478
  // continuation, fell through to the pick and lost the account half of the
5552
6479
  // time. plan.resumeProfile is that same answer for that path.
5553
- const profile = forcedProfile ?? plan.resumeProfile ?? this._pickProfile(engine);
6480
+ //
6481
+ // A TIE OUTRANKS ALL THREE, and it can because of the two lines above: a
6482
+ // `forcedProfile` or a `resumeProfile` that disagreed with the tie has
6483
+ // already had its session id taken off it, so this is not a resume pointed
6484
+ // at the wrong home, it is a fresh brief on the account the project named.
6485
+ const profile = pinned ?? forcedProfile ?? plan.resumeProfile ?? this._pickProfile(engine);
5554
6486
  // A SECOND ACCOUNT ARRIVES EMPTY, AND THIS IS WHERE IT STOPS BEING EMPTY.
5555
6487
  // Our own Accounts page tells a person to log in with a brand new folder,
5556
6488
  // and Claude Code reads a session's skills, commands and subagents out of
@@ -5566,6 +6498,11 @@ export class Supervisor {
5566
6498
  // symlinks made into a Claude home on behalf of a session that will never
5567
6499
  // open them.
5568
6500
  const tooling = engine === 'codex' ? null : linkAccountTooling(profile);
6501
+ // A RUN IS STARTING ON THIS ROW, said before the worker exists and in the
6502
+ // same turn of the event loop as the spawn, so the cleaner can never stop
6503
+ // this task's programs between its last check and this agent arriving
6504
+ // (main/leftovers.mjs, agreed with Codex).
6505
+ this._leftoverCleaner?.starting(item.id);
5569
6506
  const env = this._workerEnv(engine);
5570
6507
  const child = engine === 'codex'
5571
6508
  ? this._spawnCodexWorker(plan, { cwd, item, profile })
@@ -5584,6 +6521,8 @@ export class Supervisor {
5584
6521
  // So approval cards can say who is asking and about what.
5585
6522
  ZERO_PRODUCT: item.product,
5586
6523
  ZERO_ITEM: item.id,
6524
+ // Where its shell commands ask about memory, when that is on.
6525
+ ...this.memoryGateEnv(item),
5587
6526
  },
5588
6527
  stdio: ['pipe', 'pipe', 'pipe'],
5589
6528
  });
@@ -5609,6 +6548,11 @@ export class Supervisor {
5609
6548
  spawnFiles,
5610
6549
  };
5611
6550
  this.sessions.set(item.id, session);
6551
+ // THAT an agent started work, and on which engine; nothing about the task.
6552
+ // `count` is set by main.mjs and is absent wherever nothing is sent.
6553
+ this.count?.('run_started', { engine });
6554
+ // Its worker, so a run left "running" by a crash can later be told ended.
6555
+ if (Number.isInteger(child?.pid)) this._leftoverCleaner?.worker(item.id, child.pid);
5612
6556
  if (engine !== 'codex') {
5613
6557
  attachClaudeInput(child);
5614
6558
  if (remoteOnly) { child.holdInput(true); session.remoteIdle = true; session.remoteHeld = true; }
@@ -5646,23 +6590,24 @@ export class Supervisor {
5646
6590
  //
5647
6591
  // Never allowed to stop a spawn. A product with no repository gets null
5648
6592
  // and the conversation carries the card exactly as it did before.
5649
- try { session.repoBefore = snapshotRepo(cwd); } catch { session.repoBefore = null; }
6593
+ // Taken off the main thread a moment ago by `_photoFirst`, before the
6594
+ // process existed; read here inline only when that step did not run.
6595
+ if (this._photos?.has(item.id)) session.repoBefore = this._photos.get(item.id);
6596
+ else { try { session.repoBefore = snapshotRepo(cwd); } catch { session.repoBefore = null; } }
5650
6597
 
5651
6598
  // A MODE BELONGS TO THE THREAD, NOT TO ONE SEND, AND THIS IS WHERE THAT
5652
6599
  // CHANGED (w-34b7b861b6, 2026-09-24).
5653
6600
  //
5654
6601
  // What stood here spent the mode the moment the child process came up:
5655
6602
  // `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."
6603
+ // row went out in the fleet's mode again. An agent put in Manual produced
6604
+ // the expected approval cards on its first run, and then every in-progress
6605
+ // row's footer read "In Auto Mode" -- which was not a display bug. The grant
6606
+ // really was gone. A mode chosen for a thread should hold for every run in
6607
+ // that thread until it is switched back.
5663
6608
  //
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
6609
+ // So nothing is spent here any more. The grant sits on the row until the
6610
+ // user moves it or clears it, `answerModes()` keeps returning it, and the reply
5666
6611
  // box re-seeds from it on every open, which is what makes the footer tell
5667
6612
  // the truth on run two.
5668
6613
  //
@@ -5670,16 +6615,16 @@ export class Supervisor {
5670
6615
  // still NOT on the ledger. It lives in the app's own storage, written by
5671
6616
  // main and by nothing else (main/answer-modes.mjs), because a ledger line
5672
6617
  // 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.
6618
+ // worker could once have appended one line claiming to be the user and
6619
+ // handed its own next run bypassPermissions. Only the user grants one,
6620
+ // through the reply box. What this commit widens is the BLAST RADIUS of a
6621
+ // grant made deliberately, which is the point; it does not widen who can
6622
+ // make one.
5678
6623
  //
5679
6624
  // What is left here is a touch rather than a clear: it restamps the grant's
5680
6625
  // 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
6626
+ // moment the mode was picked, and a thread being worked in therefore keeps
6627
+ // its mode for as long as it is worked in. Still on the child's own
5683
6628
  // `spawn` event, and the reason is the reason the clear was there: `spawn`
5684
6629
  // is the only signal the process exists. `spawn()` returns a ChildProcess
5685
6630
  // before an ENOENT arrives asynchronously on 'error'.
@@ -5767,6 +6712,12 @@ export class Supervisor {
5767
6712
  const absorb = (...frame) => {
5768
6713
  session.lastOutputAt = Date.now();
5769
6714
  capture(session, ...frame);
6715
+ // The conversation is up, so the words it was started with are in it
6716
+ // (`_noteHeard`). Here rather than at exit: an app quit may not wait for one.
6717
+ if (continuation && item.answer && session.sessionId && !session.heardNoted) {
6718
+ session.heardNoted = true;
6719
+ this._noteHeard(item, session.sessionId);
6720
+ }
5770
6721
  if (activity(session, ...frame)) this.onChange?.();
5771
6722
  if (session.remoteHeld && engine === DEFAULT_ENGINE) {
5772
6723
  try {
@@ -5777,7 +6728,9 @@ export class Supervisor {
5777
6728
  this.store.recordSessionResult(item.product,item.id,{result:String(event.result || '(the session ended with an empty reply)')});
5778
6729
  session.remoteResultRecorded = true;
5779
6730
  if (session.lastLiveReply) this._handledAnswers.add(this._answerKey(session.lastLiveReply));
5780
- } else {
6731
+ } else if (!(Date.now() - (session.child?.lastInterruptAt ?? 0) < 30000)) {
6732
+ // A step she cut with Send now ends in an error result too; that
6733
+ // is her message being taken, not the turn failing.
5781
6734
  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
6735
  }
5783
6736
  this._saveState(); this.onChange?.();
@@ -5872,10 +6825,11 @@ export class Supervisor {
5872
6825
  exited = true;
5873
6826
  if (how?.transportFault) session.transportFault = true;
5874
6827
  session.exitFailed = !!(signal || code !== 0 || session.transportFault || session.resultIsError || session.result == null);
6828
+ this.count?.('run_finished', runEndedProps(session, Date.now()));
5875
6829
  onLine(`session exited (${code})`);
5876
6830
  // WHAT THE RUN CHANGED, SAVED WHERE SHE CAN OPEN IT.
5877
6831
  //
5878
- // David could not see code on his onboarding call because there was no
6832
+ // A tester could not see code on their onboarding call because there was no
5879
6833
  // artifact to open. This is the line that makes one exist: the moment a
5880
6834
  // worker exits, its own transcript is read for the edits it made, the
5881
6835
  // checkout is read for what actually moved on the disk, and the two are
@@ -5929,12 +6883,7 @@ export class Supervisor {
5929
6883
  } catch (e) { console.warn('zero: could not save what the run changed:', e.message); }
5930
6884
  try { trace?.write(`\n# exited (${code}) ${new Date().toISOString()}\n`); trace?.end(); } catch {}
5931
6885
  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);
6886
+ this.endSession(item, session, product);
5938
6887
  if (session.remoteIdle) {
5939
6888
  delete this._liveSessions[item.id];
5940
6889
  this._saveState(); this.onChange?.(); return;
@@ -5984,42 +6933,33 @@ export class Supervisor {
5984
6933
  this.store.recordSessionResult(item.product, item.id, { result: session.result, status: null });
5985
6934
  } catch (e) { console.warn('zero: writing the closing message as the answer failed:', e.message); }
5986
6935
  }
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
- //
6936
+ this.releaseFinishedClaim(item, session);
5994
6937
  // NOTHING HERE TOUCHES `_rowSessions`, and that is the whole change of. A
5995
6938
  // finished session has nothing left to RESCUE, which is what this line is
5996
6939
  // about, but it is exactly the session her next reply should go back to.
5997
6940
  // `pruneRowSessions` is what eventually forgets it.
5998
- if (session.result != null && !session.resultIsError) {
5999
- delete this._liveSessions[item.id];
6000
- this._saveState();
6001
- }
6941
+ this.forgetFinishedSession(item, session);
6002
6942
  this.noteExitForBackoff(session, { onLine });
6003
6943
  const carryingAnswer = continuation && answerAtSpawn;
6004
6944
  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
6945
+ // AND THE REPLY THE USER TYPED INTO A RUN THAT WAS NOT CARRYING ONE. Ordinary
6006
6946
  // fresh work never reached the line above, so a correction steered into a
6007
6947
  // working session was acted on, answered, and then remembered as owed
6008
6948
  // 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.
6949
+ // statement rather than a branch of the chain below: this settles the
6950
+ // user's words, and the chain below is about what the RUN did.
6011
6951
  if (!carryingAnswer && session.lastLiveReply) this.settleLiveReply(item, session);
6012
6952
  // Fresh work only. A continuation already has its own memory (one spawn
6013
6953
  // per answer, capped), and a session WE killed is not evidence the row
6014
6954
  // 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
6955
+ // own `if`: the settle above is about the user's words and this is about the
6016
6956
  // run, and a run that carried a live reply still has to be noted here.
6017
6957
  if (!continuation && !session.stoppedByUs) {
6018
6958
  // A session that never got an id and never said a word never started:
6019
6959
  // Claude Code announces itself with `session_id` on its first line of
6020
6960
  // output, so a session with neither died before the CLI was running.
6021
6961
  const everRan = session.sessionId != null || session.result != null;
6022
- const outcome = this.noteFreshRun(item, { everRan });
6962
+ const outcome = this.noteFreshRun(item, { everRan, account: session.accountFault?.account ?? null });
6023
6963
  if (outcome === 'rested') {
6024
6964
  const until = this.restingUntil(item);
6025
6965
  if (until > Date.now()) onLine(`nothing moved on this row; resting ${Math.round((until - Date.now()) / 60_000)}m`);
@@ -6068,16 +7008,15 @@ export class Supervisor {
6068
7008
  /* --------------------- what she attached, and the thread ---------------- */
6069
7009
  // THE FILES A TASK CARRIES, AS PATHS A STRANGER PROCESS CAN ACTUALLY OPEN.
6070
7010
  //
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.
7011
+ // Attached screenshots were reaching the ledger and stopping there. A pasted
7012
+ // image lands in the body as `![shot.png](attachments/x-shot.png)`, which is
7013
+ // right for the reading pane and useless to a worker: the path is relative to
7014
+ // the product dir, the worker's cwd is the code repo when one is registered,
7015
+ // and NOTHING IN THE BRIEF EVER SAID TO OPEN IT. Measured by folding each row
7016
+ // to the moment each session was spawned and then looking for the file's name
7017
+ // anywhere in that session's log: about half the sessions handed a task
7018
+ // carrying an attached picture never opened it. The instruction was half in
7019
+ // words and half in the picture, and only the words were delivered.
6081
7020
  //
6082
7021
  // Absolute, because relative was the bug. Existence-checked, because a path
6083
7022
  // that 404s teaches a worker to distrust the whole section. Pictures first
@@ -6163,7 +7102,7 @@ export class Supervisor {
6163
7102
  pictures.length === 1
6164
7103
  ? 'THE FOUNDER ATTACHED A PICTURE. READ IT BEFORE YOU DO ANYTHING ELSE.'
6165
7104
  : `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.',
7105
+ 'Half of what they asked for is in the image and none of it is in the text.',
6167
7106
  // THE TRUE SENTENCE ON EACH ENGINE. `Read` is Claude Code's tool and
6168
7107
  // Codex has nothing by that name, so naming it there sent a worker
6169
7108
  // looking for something that does not exist. Codex is handed the
@@ -6187,10 +7126,10 @@ export class Supervisor {
6187
7126
  if (pictures.length || others.length) lines.push('');
6188
7127
  lines.push(
6189
7128
  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.',
7129
+ ? 'THEY ATTACHED A PICTURE THAT IS NO LONGER ON DISK, so you cannot see it.'
7130
+ : `THEY ATTACHED ${lost.length} PICTURES THAT ARE NO LONGER ON DISK, so you cannot see them.`,
7131
+ 'Part of what they asked for was in it and that part did not reach you.',
7132
+ 'Do not guess at what it showed. Say plainly that it is missing and ask them.',
6194
7133
  '',
6195
7134
  ...lost.map((rel) => `- ${rel}`),
6196
7135
  );
@@ -6228,7 +7167,7 @@ export class Supervisor {
6228
7167
  const stamp = (ts) => new Date(ts).toISOString().slice(0, 16).replace('T', ' ');
6229
7168
  const who = (turn) => {
6230
7169
  if (turn.source === 'founder') return 'THE FOUNDER';
6231
- if (turn.field === 'note') return 'a previous session, as its checkpoint to her';
7170
+ if (turn.field === 'note') return 'a previous session, as its checkpoint to them';
6232
7171
  if (turn.field === 'result') return 'a previous session, as its result';
6233
7172
  return 'a previous session, rewriting the task';
6234
7173
  };
@@ -6236,13 +7175,13 @@ export class Supervisor {
6236
7175
  '',
6237
7176
  '# The conversation on this row so far',
6238
7177
  '',
6239
- 'Oldest first. You did not write any of this: earlier sessions did, and she',
7178
+ 'Oldest first. You did not write any of this: earlier sessions did, and they',
6240
7179
  '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',
7180
+ 'because it is the same thing. Their latest answer is at the end of this brief',
6242
7181
  'and the task as it stands now is above; everything between them is here.',
6243
7182
  '',
6244
7183
  '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.',
7184
+ 'and a decision they have already made in it is settled, not reopened.',
6246
7185
  ];
6247
7186
  if (dropped) {
6248
7187
  head.push(
@@ -6311,6 +7250,7 @@ export class Supervisor {
6311
7250
  `Product: ${product.name} (slug: ${item.product})`,
6312
7251
  `Product docs live in: ${product.dir}`,
6313
7252
  product.repoPath ? `Product code repo (your cwd): ${product.repoPath}` : `No code repo is registered for this product.`,
7253
+ this.shipsThroughTheApp(product) ? this.shipBrief() : '',
6314
7254
  `Work item id: ${item.id}`,
6315
7255
  `Title: ${item.title}`,
6316
7256
  item.body ? `\n${item.body}` : '',
@@ -6446,7 +7386,36 @@ export class Supervisor {
6446
7386
  // says why this had to change and what it cost her.
6447
7387
  speaksForTheSession(session, { command = false } = {}) {
6448
7388
  if (this.storeMcpCommand() && !command) return false;
6449
- return session?.result != null && !session.resultIsError;
7389
+ return this.runFinished(session);
7390
+ }
7391
+
7392
+ // A CLEAN RESULT IS NOT A FINISHED RUN WHEN THE RUN WAS STOPPED PART-WAY.
7393
+ //
7394
+ // A result arrives at the end of every turn, and a turn can end with helpers
7395
+ // still out: "three builders are working, I'll stack them once they're
7396
+ // done". The session stays open for them (main/claude-input.mjs). If it is
7397
+ // then stopped, by a hang check fooled by a sleep or an app restart, its last
7398
+ // result is a promise, and reading it as the end of the run dropped the
7399
+ // record it resumes from and landed the promise on her row as the answer
7400
+ // (tests/a-worker-whose-helpers-are-still-working-does-not-come-back-to-you).
7401
+ // The input pipe knows: it only closes at a result with nothing out. A child
7402
+ // that cannot say (a Codex turn) keeps the old reading.
7403
+ runFinished(session) {
7404
+ if (session?.result == null || session.resultIsError) return false;
7405
+ return session.child?.ranToTheEnd?.() !== false;
7406
+ }
7407
+
7408
+ // A session that finished under its own power has nothing left to resume.
7409
+ // ANYTHING ELSE STAYS REMEMBERED, and that includes a kill of ours: an app
7410
+ // quit is the commonest way her fleet dies, and it is the case where going
7411
+ // back to the same session rather than briefing a stranger is worth the
7412
+ // most. What keeps that honest is the sweep's own guard: it only ever puts a
7413
+ // worker back on a row that is still open or claimed.
7414
+ forgetFinishedSession(item, session) {
7415
+ if (!this.runFinished(session)) return false;
7416
+ delete this._liveSessions[item.id];
7417
+ this._saveState();
7418
+ return true;
6450
7419
  }
6451
7420
 
6452
7421
  // A WORKER'S CLOSING MESSAGE IS ITS ANSWER, ON EVERY INSTALL.
@@ -6457,7 +7426,7 @@ export class Supervisor {
6457
7426
  // Code session, it ended the way every session ends: by saying its answer.
6458
7427
  // Two surfaces, one answer, drawn one under the other. A fold in the thread
6459
7428
  // 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
7429
+ // fact; one run had "the answer is written once" in its
6461
7430
  // own brief and wrote it twice anyway, three seconds apart.
6462
7431
  //
6463
7432
  // So the second surface goes. A downloaded copy with no store tools has
@@ -6475,7 +7444,7 @@ export class Supervisor {
6475
7444
  // line would land on a row another session is holding.
6476
7445
  closingMessageIsTheAnswer(session) {
6477
7446
  if (!this.storeMcpCommand() || session?.command) return false;
6478
- if (session?.result == null || session.resultIsError || !String(session.result).trim()) return false;
7447
+ if (!this.runFinished(session) || !String(session.result).trim()) return false;
6479
7448
  if (session.claimRefused) return false;
6480
7449
  try {
6481
7450
  const fresh = this.store.readItem?.(session.product, session.itemId);
@@ -6485,6 +7454,19 @@ export class Supervisor {
6485
7454
  } catch { return false; }
6486
7455
  }
6487
7456
 
7457
+ // A clean terminal result ends the run, even if its shared tool server
7458
+ // stays alive for other turns. Release the run's claim without choosing a
7459
+ // status for the conversation. Failed runs retain the existing retry path.
7460
+ releaseFinishedClaim(item, session) {
7461
+ if (session.exitFailed || !this.runFinished(session) || session.claimRefused || session.stoppedByUs
7462
+ || !String(session.result ?? '').trim()) return;
7463
+ try {
7464
+ this.store.releaseRunClaim?.(item.product, item.id, {
7465
+ afterEpoch: item.epoch ?? 0, startedAt: session.startedAt,
7466
+ });
7467
+ } catch (error) { console.warn('zero: could not release the finished run:', error.message); }
7468
+ }
7469
+
6488
7470
  // The permission rules handed to a worker, generated fresh at every spawn for
6489
7471
  // the same reason the MCP config is: the folders in them are THIS person's
6490
7472
  // folders. The file we ship carries the rules and an EMPTY
@@ -6512,6 +7494,8 @@ export class Supervisor {
6512
7494
  add(this.config.storeRoot);
6513
7495
  if (product?.dir) add(product.dir);
6514
7496
  rules.permissions = { ...(rules.permissions ?? {}), additionalDirectories: dirs };
7497
+ const gateHooks = this.memoryGateHooks();
7498
+ if (gateHooks) rules.hooks = { ...(rules.hooks ?? {}), ...gateHooks };
6515
7499
  // Beside the MCP config, and written the same way: a real file on disk,
6516
7500
  // because the CLI reading --settings is not this process. Named by the
6517
7501
  // run, because two spawns with different rules must never share a file.
@@ -6531,6 +7515,226 @@ export class Supervisor {
6531
7515
  if (rules) { spawnFiles.push(rules); args.push('--settings', rules); }
6532
7516
  }
6533
7517
 
7518
+ /* ------------------ heavy work waits when memory is short ----------------- */
7519
+ // w-3958c3753d. With the switch on, every shell command a Claude Code worker
7520
+ // runs asks main/memory-gate-server.mjs first, through a PreToolUse hook on
7521
+ // Bash, and reports back after it. Reading and editing files never ask. With
7522
+ // the switch off a worker carries none of it. The rules and the measurements
7523
+ // behind them are in main/memory-gate.mjs.
7524
+
7525
+ /** The hooks for the per-spawn settings file, or null when it is off. */
7526
+ memoryGateHooks() {
7527
+ if (!this.config.memoryGate) return null;
7528
+ const script = unpacked(path.join(this.appDir, 'scripts', 'memory-gate-hook.sh'));
7529
+ const quoted = `'${script.replace(/'/g, `'\\''`)}'`;
7530
+ // Above the longest wait by ten minutes: a hook that times out lets its
7531
+ // command run (measured), so the app's own refusal must always come first.
7532
+ const timeout = Math.ceil(MEMORY_GATE.maxWaitMs / 1000) + 600;
7533
+ const on = (mode, extra = {}) => [{ matcher: 'Bash', hooks: [{ type: 'command', command: `${quoted} ${mode}`, ...extra }] }];
7534
+ return {
7535
+ PreToolUse: on('pre', { timeout }),
7536
+ PostToolUse: on('post', { timeout: 10 }),
7537
+ PostToolUseFailure: on('post', { timeout: 10 }),
7538
+ };
7539
+ }
7540
+
7541
+ /** Where a worker's hook asks, and who it is asking for. Empty when off. */
7542
+ memoryGateEnv(item) {
7543
+ if (!this.config.memoryGate || !item) return {};
7544
+ return {
7545
+ AGENTBOX_GATE_SOCK: this.config.memoryGateSocket || memoryGateSocketPath(),
7546
+ AGENTBOX_GATE_ITEM: String(item.id ?? ''),
7547
+ AGENTBOX_GATE_PRODUCT: String(item.product ?? ''),
7548
+ AGENTBOX_GATE_SCORE: String(Math.max(0, Math.round(this._score(item)) || 0)),
7549
+ };
7550
+ }
7551
+
7552
+ /**
7553
+ * THE SAME SOCKET, AND DELIBERATELY NO ROW (w-e5225b62ba). One
7554
+ * `codex app-server` serves every Codex thread of a login, so this environment
7555
+ * is shared by all of them: an AGENTBOX_GATE_ITEM here would label every Codex
7556
+ * thread as whichever row started first. Each thread names itself instead, by
7557
+ * the `session_id` its hook payload carries, which `memoryGateOwner` turns back
7558
+ * into a row.
7559
+ *
7560
+ * It is also what keeps the user's own `codex` out of this. The hook sits in a
7561
+ * hooks.json their terminal reads too, and it exits on its first line when
7562
+ * AGENTBOX_GATE_SOCK is absent — which it is everywhere but here.
7563
+ */
7564
+ codexMemoryGateEnv() {
7565
+ if (!this.config.memoryGate) return {};
7566
+ return { AGENTBOX_GATE_SOCK: this.config.memoryGateSocket || memoryGateSocketPath() };
7567
+ }
7568
+
7569
+ /**
7570
+ * WHICH ROW A CODEX THREAD IS DOING, asked by the gate on every command a
7571
+ * Codex worker runs. `session.sessionId` is set from `thread/started` in
7572
+ * main/codex.mjs, and the hook's `session_id` was measured to be that same
7573
+ * string. A thread this app is not running answers null, and its command is
7574
+ * let through.
7575
+ */
7576
+ memoryGateOwner(sessionId) {
7577
+ if (!sessionId) return null;
7578
+ for (const [itemId, session] of this.sessions) {
7579
+ if (session?.engine === 'codex' && session.sessionId === sessionId) {
7580
+ return { item: itemId, product: session.product ?? null };
7581
+ }
7582
+ }
7583
+ return null;
7584
+ }
7585
+
7586
+ /** The live rank of one of this app's own tasks, so a raise counts at once. */
7587
+ memoryGateScore(product, itemId) {
7588
+ if (!product || !itemId) return null;
7589
+ try {
7590
+ const item = this.store.readItem?.(product, itemId);
7591
+ return item ? this._score(item) : null;
7592
+ } catch { return null; }
7593
+ }
7594
+
7595
+ memoryGateSlots() {
7596
+ const n = Number(this.config.memoryGateSlots);
7597
+ return Number.isFinite(n) && n >= 1 ? Math.round(n) : autoSlots(os.totalmem());
7598
+ }
7599
+
7600
+ /**
7601
+ * PUT OUR HOOK IN A CODEX HOME, OR TAKE IT OUT, AND TRUST ONLY OURS.
7602
+ *
7603
+ * Codex reads its hooks from `hooks.json` in the Codex home, which may be the
7604
+ * user's own `~/.codex` and may already hold hooks of theirs, so the file is
7605
+ * merged and never replaced (main/codex-memory-gate.mjs).
7606
+ *
7607
+ * A HOOK ONLY RUNS ONCE TRUSTED, and the trust is satisfied for one key rather
7608
+ * than switched off for everybody: `hooks/list` names our hook and its hash,
7609
+ * and `config/batchWrite` writes `hooks.state."<key>".trusted_hash` for that
7610
+ * key alone. Codex does the editing of config.toml, so nothing of theirs is
7611
+ * rewritten by us. Measured on this Mac: a second hook in the same file stayed
7612
+ * untrusted, and an unrelated `[projects."..."]` table survived untouched.
7613
+ *
7614
+ * IT RUNS AT EVERY START, not once at install, because the key carries the
7615
+ * absolute path of the script: a new app version is a new hash, which Codex
7616
+ * calls "modified" and will not run until it is trusted again.
7617
+ *
7618
+ * NOTHING HERE IS ALLOWED TO STOP THE APP. A Codex home we cannot write, an
7619
+ * app-server that will not answer, a `config/batchWrite` that is refused: all
7620
+ * of them mean Codex workers go ungated, which is exactly where they were
7621
+ * before this existed.
7622
+ */
7623
+ async setCodexMemoryGate(on, home = this._codexHome(), server = null) {
7624
+ // IT NEVER STARTS AN APP-SERVER, and that is the point. The trust needs one
7625
+ // to ask `hooks/list` with, so it uses the one it was handed (`_codexServer`
7626
+ // chains this onto its own handshake) or one already live for this home, and
7627
+ // otherwise writes the file and stops. A process started here would be a few
7628
+ // hundred megabytes spent by the feature whose whole job is to save memory,
7629
+ // and the next Codex worker starts a server anyway and trusts it then.
7630
+ const live = server ?? (on ? this._codexServers?.get(home) : null);
7631
+ const request = live
7632
+ ? async (method, params) => { await live.handshake; return live.client.request(method, params); }
7633
+ : null;
7634
+ return syncCodexMemoryGate({
7635
+ home,
7636
+ scriptPath: unpacked(path.join(this.appDir, 'scripts', 'memory-gate-hook.sh')),
7637
+ on,
7638
+ request,
7639
+ cwds: [this.appDir],
7640
+ log: (line) => console.log(`zero: ${line}`),
7641
+ });
7642
+ }
7643
+
7644
+ async setMemoryGate(on) {
7645
+ if (!on) {
7646
+ await this.stopMemoryGate();
7647
+ await this.setCodexMemoryGate(false).catch(() => {});
7648
+ return;
7649
+ }
7650
+ if (this._memoryGate) { this._memoryGate.setSlots(this.memoryGateSlots()); return; }
7651
+ const server = new MemoryGateServer({
7652
+ socketPath: this.config.memoryGateSocket || memoryGateSocketPath(),
7653
+ // A test runs its own coordinator beside the real one; it names a port.
7654
+ ...(Number.isInteger(this.config.memoryGateLockPort) ? { lockPort: this.config.memoryGateLockPort } : {}),
7655
+ historyFile: path.join(this.userDir, 'memory-gate-history.json'),
7656
+ gate: { slots: this.memoryGateSlots() },
7657
+ scoreFor: (product, itemId) => this.memoryGateScore(product, itemId),
7658
+ ownerOf: (sessionId) => this.memoryGateOwner(sessionId),
7659
+ log: (line) => console.log(`zero: ${line}`),
7660
+ });
7661
+ this._memoryGate = server;
7662
+ await server.start();
7663
+ // The hook goes in after the socket is listening, so the first Codex command
7664
+ // to ask has something to ask. It never blocks the switch taking effect.
7665
+ this.setCodexMemoryGate(true).catch((e) => console.warn(`zero: codex memory gate: ${e.message}`));
7666
+ }
7667
+
7668
+ async stopMemoryGate() {
7669
+ const server = this._memoryGate;
7670
+ this._memoryGate = null;
7671
+ if (server) await server.stop();
7672
+ }
7673
+
7674
+ /* ------------- what finished agents leave running (main/leftovers.mjs) ------------- */
7675
+ // Its own switch, off until somebody turns it on. This app tells the cleaner
7676
+ // when each run starts (in spawnWorker, before the worker exists) and ends
7677
+ // (endSession), because only this app runs this store's tasks and so only
7678
+ // it can say for certain that a task's run is over.
7679
+
7680
+ // THE CLEANER ALWAYS RUNS; THE SWITCH DECIDES WHETHER IT MAY SIGNAL. Off, it
7681
+ // looks every five minutes and reports, so the Agents page can say what
7682
+ // turning it on would stop before anybody turns it on, and every run is
7683
+ // recorded either way, so turning it on later finds real records rather than
7684
+ // guesses (Codex's review, 2026-10-05). On, it looks every minute.
7685
+ async setLeftoverCleanup(on) {
7686
+ if (!this._leftoverCleaner) {
7687
+ this._leftoverCleaner = new LeftoverCleaner({
7688
+ file: path.join(this.userDir, 'leftovers.json'),
7689
+ enabled: !!on,
7690
+ enabledSince: Number(this.config.cleanupLeftoversSince) || Date.now(),
7691
+ live: (itemId) => this.sessions.has(itemId),
7692
+ owns: (itemId, product) => {
7693
+ if (!product) return false;
7694
+ try { return !!this.store.readItem?.(product, itemId); } catch { return false; }
7695
+ },
7696
+ log: (line) => console.log(`zero: ${line}`),
7697
+ });
7698
+ } else if (on) {
7699
+ this._leftoverCleaner.enable(Number(this.config.cleanupLeftoversSince) || Date.now());
7700
+ } else {
7701
+ this._leftoverCleaner.disable();
7702
+ }
7703
+ clearInterval(this._leftoverTimer);
7704
+ const tick = () => this._leftoverCleaner?.tick().catch((e) => console.warn('zero: leftovers:', e.message));
7705
+ this._leftoverTimer = setInterval(tick, on ? 60_000 : 5 * 60_000);
7706
+ this._leftoverTimer.unref?.();
7707
+ tick();
7708
+ }
7709
+
7710
+ leftoverStatus() {
7711
+ return this._leftoverCleaner ? this._leftoverCleaner.status() : null;
7712
+ }
7713
+
7714
+ /**
7715
+ * What an agent is told about this, or null. A fresh brief carries the rule
7716
+ * and the way to keep something; any brief carries the programs of this task
7717
+ * still shutting down, which the agent must not reuse.
7718
+ */
7719
+ leftoverBriefNote(item) {
7720
+ const cleaner = this._leftoverCleaner;
7721
+ if (!cleaner || !item?.id) return null;
7722
+ const parts = [];
7723
+ // EVERY RUN, not only a fresh one: a conversation resumed after the switch
7724
+ // went on, and a chat, never heard it otherwise (Codex's review).
7725
+ if (cleaner.enabled) {
7726
+ 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 computer runs short of memory. '
7727
+ + '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.');
7728
+ }
7729
+ const stopping = cleaner.stoppingNote(item.id);
7730
+ if (stopping) parts.push(stopping);
7731
+ return parts.length ? parts.join('\n\n') : null;
7732
+ }
7733
+
7734
+ memoryGateStatus() {
7735
+ return this._memoryGate ? this._memoryGate.status() : null;
7736
+ }
7737
+
6534
7738
  // The MCP config handed to a worker, generated fresh so the paths always track
6535
7739
  // zero.config.json. The approvals channel is Agentbox's own and always rides;
6536
7740
  // the store server rides only when one is configured. Returns null when there
@@ -6576,6 +7780,7 @@ export class Supervisor {
6576
7780
  env: {
6577
7781
  STORE_ACCOUNT_ID: this.config.accountId,
6578
7782
  ...storeRootEnv(this.config.storeRoot),
7783
+ ...teamPersonEnv(),
6579
7784
  },
6580
7785
  };
6581
7786
  }
@@ -6610,9 +7815,19 @@ export function unpacked(p) {
6610
7815
  // compared against a stored watermark and an undefined would make every
6611
7816
  // comparison false, so a row she has never touched would rest forever the
6612
7817
  // moment she did touch it.
7818
+ //
7819
+ // FILING IS NOT SPEAKING (2026-10-01). Marking a thread Private, moving its
7820
+ // priority, or editing a summary line or a link is the founder writing to the
7821
+ // row, and it used to count as a word: a persona test switched a finished
7822
+ // thread to Private and the agent ran again on its own, unasked and paid for.
7823
+ // Those fields describe the thread; they never ask it anything.
7824
+ // Moving an answer out of the inbox is not asking the agent to answer again.
7825
+ // runAt also covers snoozes written before the inbox reminder had its own field.
7826
+ const NOT_A_WORD = new Set(['visibility', 'priority', 'problem', 'progress', 'solution', 'blockedBy', 'blocks', 'runAt', 'snoozedUntil']);
6613
7827
  function lastFounderWrite(item) {
6614
7828
  let latest = 0;
6615
- for (const w of Object.values(item?.wrote ?? {})) {
7829
+ for (const [field, w] of Object.entries(item?.wrote ?? {})) {
7830
+ if (NOT_A_WORD.has(field)) continue;
6616
7831
  if (w?.source === 'founder' && w.ts > latest) latest = w.ts;
6617
7832
  }
6618
7833
  return latest;
@@ -6644,8 +7859,10 @@ function captureStream(session, line) {
6644
7859
  session.result = String(obj.result ?? '');
6645
7860
  session.resultIsError = !!obj.is_error;
6646
7861
  // 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();
7862
+ // exit so the number can never outlive the thing it counts. Unless
7863
+ // something is still out in the background: then this was a turn
7864
+ // ending, not the run, and the session stays open for it.
7865
+ if (!session.child?.waitingOn?.().length) session.helperIds?.clear();
6649
7866
  }
6650
7867
  noteClaimAnswer(session, obj);
6651
7868
  countHelpers(session, obj);
@@ -6757,7 +7974,7 @@ export function streamingText(session, line) {
6757
7974
  // to move with it. DID THIS RUN LEAVE HER ANYTHING TO READ?
6758
7975
  //
6759
7976
  // 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:
7977
+ // getting it wrong is silence, and silence is the failure that matters most:
6761
7978
  //
6762
7979
  // - said nothing at all -> the supervisor speaks for it (sayTheRunDied)
6763
7980
  // - said something wrong -> the supervisor speaks for it, in OUR words