agentbox-app 0.1.11 → 0.1.13

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 (239) hide show
  1. package/README.md +24 -16
  2. package/bin/agentbox.mjs +18 -9
  3. package/briefs/message-rules.md +15 -4
  4. package/main/account-tooling.mjs +2 -1
  5. package/main/acp.mjs +296 -0
  6. package/main/agent-homes.mjs +158 -0
  7. package/main/agent-sessions.mjs +57 -20
  8. package/main/agent-updates.mjs +9 -20
  9. package/main/analytics.mjs +30 -0
  10. package/main/approvals.mjs +14 -5
  11. package/main/claude-bin.mjs +45 -310
  12. package/main/claude-input.mjs +9 -3
  13. package/main/claude-launch-env.mjs +54 -0
  14. package/main/claude-models.mjs +39 -7
  15. package/main/claude-plan.mjs +14 -2
  16. package/main/claude-usage.mjs +82 -25
  17. package/main/codex-bin.mjs +51 -248
  18. package/main/codex-launch-env.mjs +31 -0
  19. package/main/codex-session.mjs +38 -3
  20. package/main/codex-threads.mjs +41 -8
  21. package/main/codex-watch.mjs +67 -3
  22. package/main/config.mjs +40 -13
  23. package/main/corner-tag.mjs +489 -0
  24. package/main/crash-report.mjs +26 -3
  25. package/main/demo.mjs +17 -14
  26. package/main/engine-bin.mjs +595 -0
  27. package/main/engine-setup.mjs +27 -27
  28. package/main/feedback.mjs +11 -6
  29. package/main/fresh-user.mjs +1 -1
  30. package/main/git-change.mjs +8 -2
  31. package/main/harnesses/claude-stream.mjs +208 -0
  32. package/main/harnesses/claude.mjs +84 -0
  33. package/main/harnesses/codex.mjs +65 -0
  34. package/main/harnesses/common.mjs +78 -0
  35. package/main/harnesses/instructions-file.mjs +116 -0
  36. package/main/harnesses/opencode.mjs +234 -0
  37. package/main/harnesses.mjs +65 -0
  38. package/main/importable-threads.mjs +38 -0
  39. package/main/instruction-settings.mjs +47 -11
  40. package/main/ipc.mjs +175 -22
  41. package/main/local-app-frames.mjs +82 -0
  42. package/main/machine.mjs +1 -1
  43. package/main/main.mjs +120 -5
  44. package/main/memory-gate-history.mjs +51 -12
  45. package/main/memory-gate-server.mjs +67 -11
  46. package/main/memory-gate.mjs +49 -8
  47. package/main/memory-log.mjs +124 -0
  48. package/main/notify.mjs +48 -5
  49. package/main/opencode-server.mjs +271 -0
  50. package/main/opencode.mjs +287 -0
  51. package/main/phone-link.mjs +551 -0
  52. package/main/refresh-agent-models.mjs +2 -1
  53. package/main/relay-address.mjs +28 -0
  54. package/main/relay-client.mjs +246 -0
  55. package/main/row-label.mjs +8 -7
  56. package/main/runs-on.mjs +90 -0
  57. package/main/serve.mjs +124 -10
  58. package/main/settings.mjs +171 -11
  59. package/main/shell-path.mjs +86 -0
  60. package/main/ship-queue.mjs +25 -7
  61. package/main/sign-in-files.mjs +3 -7
  62. package/main/source-updater.mjs +27 -1
  63. package/main/start-hold.mjs +73 -0
  64. package/main/store/home.mjs +37 -5
  65. package/main/store/project.mjs +15 -19
  66. package/main/store.mjs +10 -4
  67. package/main/supervisor.mjs +1030 -447
  68. package/main/task-commands.mjs +13 -17
  69. package/main/task-folders-offthread.mjs +6 -1
  70. package/main/task-folders.mjs +491 -82
  71. package/main/task-terminals.mjs +49 -8
  72. package/main/team/index.mjs +2 -2
  73. package/main/team/projects.mjs +1 -1
  74. package/main/updater.mjs +71 -9
  75. package/main/worktree-include.mjs +494 -0
  76. package/mcp/core/page.mjs +1 -1
  77. package/mcp/core/work.mjs +34 -3
  78. package/mcp/tools.mjs +2 -1
  79. package/package.json +10 -1
  80. package/renderer/dist/assets/abnfDiagram-YUKMZFIV-DPPuMWt-.js +1 -0
  81. package/renderer/dist/assets/arc-HmFfdseH.js +1 -0
  82. package/renderer/dist/assets/architectureDiagram-47P4ROYG-DxJ3zHrH.js +36 -0
  83. package/renderer/dist/assets/blockDiagram-E7TT5TSB-CbTXzOqQ.js +140 -0
  84. package/renderer/dist/assets/c4Diagram-CRA5TL53-X5ItFyLn.js +38 -0
  85. package/renderer/dist/assets/channel-BlT2g9Sz.js +1 -0
  86. package/renderer/dist/assets/chunk-2Q5K7J3B-DC12Ho_L.js +1 -0
  87. package/renderer/dist/assets/chunk-5VM5RSS4-ClVtfPd0.js +15 -0
  88. package/renderer/dist/assets/chunk-7TKQ45FW-DgcOm0HT.js +1 -0
  89. package/renderer/dist/assets/chunk-AW2ZBBNX-C01s4yoF.js +88 -0
  90. package/renderer/dist/assets/chunk-FVRAUYC3-Bj0zxhR4.js +2 -0
  91. package/renderer/dist/assets/chunk-HTAEGDNF-DWDeEhLU.js +1 -0
  92. package/renderer/dist/assets/chunk-JWPE2WC7-Dsqwv3eW.js +1 -0
  93. package/renderer/dist/assets/chunk-KQW6MTUR-CE86ANL2.js +1 -0
  94. package/renderer/dist/assets/chunk-XXDRQBXY-CoNTASfs.js +1 -0
  95. package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-2wprLqtw.js +217 -0
  96. package/renderer/dist/assets/cornerTag-BLrxiDE5.css +1 -0
  97. package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +1 -0
  98. package/renderer/dist/assets/cose-bilkent-JH36ORCC-enqMiYbn.js +1 -0
  99. package/renderer/dist/assets/cynefin-EF2NZ3EQ-DHngLxvQ.js +141 -0
  100. package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-C0rZZIl3.js +62 -0
  101. package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
  102. package/renderer/dist/assets/dagre-W4DXFKR2-DQnaEWO7.js +4 -0
  103. package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  104. package/renderer/dist/assets/diagram-2UJZ2QOL-CS8AlGPx.js +200 -0
  105. package/renderer/dist/assets/diagram-OVF4WLC6-yiDMHIkY.js +24 -0
  106. package/renderer/dist/assets/diagram-PFPMY2P6-XbQKzbBA.js +30 -0
  107. package/renderer/dist/assets/diagram-UMYRVEAY-dhVvlfl_.js +24 -0
  108. package/renderer/dist/assets/diagram-YEKJPTXX-DZHe8SCf.js +41 -0
  109. package/renderer/dist/assets/diagram-ZIFT7M5P-B5h6aXVR.js +3 -0
  110. package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-PR9ScDco.js +1 -0
  111. package/renderer/dist/assets/elk-IJKZMXRS-Cg-yQXPd.js +27 -0
  112. package/renderer/dist/assets/erDiagram-O2IAPWRE-omOrJMxu.js +99 -0
  113. package/renderer/dist/assets/flowDiagram-OXPTDLAJ-aOX2hzvT.js +213 -0
  114. package/renderer/dist/assets/ganttDiagram-R7TSEDQI-B1H3pWyI.js +292 -0
  115. package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-Cvw304HC.js +106 -0
  116. package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
  117. package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-_ZXcNViH.js +2 -0
  118. package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
  119. package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js +70 -0
  120. package/renderer/dist/assets/journeyDiagram-COXZFDF6-fr62QoAt.js +139 -0
  121. package/renderer/dist/assets/kanban-definition-P3RFRI5V-J8zsabCe.js +89 -0
  122. package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
  123. package/renderer/dist/assets/linear-BmNdEtIi.js +1 -0
  124. package/renderer/dist/assets/main-ChNdnUvt.js +1132 -0
  125. package/renderer/dist/assets/main-nj0QR5kg.css +32 -0
  126. package/renderer/dist/assets/mermaid.core-CnYzvFkz.js +314 -0
  127. package/renderer/dist/assets/mindmap-definition-LZFPQGKD-Bfcxc7PM.js +96 -0
  128. package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  129. package/renderer/dist/assets/pegDiagram-BGZESJAR-CwUJOq6X.js +1 -0
  130. package/renderer/dist/assets/pieDiagram-CAPJLFHJ-BrwMmqfC.js +39 -0
  131. package/renderer/dist/assets/product-name-CTKLYNWY.js +40 -0
  132. package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-BN4Stoj-.js +7 -0
  133. package/renderer/dist/assets/railroadDiagram-SM67HX2B-CPZ5bbeR.js +1 -0
  134. package/renderer/dist/assets/requirementDiagram-X7JNWC4A-CgRnimVe.js +84 -0
  135. package/renderer/dist/assets/sankeyDiagram-UM26HJRW-Bt2rej__.js +40 -0
  136. package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js +169 -0
  137. package/renderer/dist/assets/sizeCapture-INFHLROL-usyNyXyh.js +1 -0
  138. package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-CRzUBb0u.js +291 -0
  139. package/renderer/dist/assets/swimlanes-N4OXWK64-C1GJvMS0.js +1 -0
  140. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +8 -0
  141. package/renderer/dist/assets/timeline-definition-YOQAKGHF-caeyPlcc.js +120 -0
  142. package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-BN_SV6PC.js +370 -0
  143. package/renderer/dist/assets/vennDiagram-BLWOH2XV-BLGuaUX-.js +34 -0
  144. package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-Da0cJeNc.js +78 -0
  145. package/renderer/dist/assets/xychartDiagram-TAQBALBS-cGfUffMk.js +7 -0
  146. package/renderer/dist/corner-tag.html +13 -0
  147. package/renderer/dist/emulator/audio-rules.mjs +74 -0
  148. package/renderer/dist/emulator/data/compression/README.md +11 -0
  149. package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
  150. package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
  151. package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
  152. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  153. package/renderer/dist/emulator/data/cores/README.md +22 -0
  154. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  155. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  156. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  157. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  158. package/renderer/dist/emulator/data/cores/package.json +65 -0
  159. package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
  160. package/renderer/dist/emulator/data/emulator.css +1614 -0
  161. package/renderer/dist/emulator/data/loader.js +170 -0
  162. package/renderer/dist/emulator/data/localization/README.md +63 -0
  163. package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
  164. package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
  165. package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
  166. package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
  167. package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
  168. package/renderer/dist/emulator/data/localization/en-US.json +339 -0
  169. package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
  170. package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
  171. package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
  172. package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
  173. package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
  174. package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
  175. package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
  176. package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
  177. package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
  178. package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
  179. package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
  180. package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
  181. package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
  182. package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
  183. package/renderer/dist/emulator/data/src/GameManager.js +464 -0
  184. package/renderer/dist/emulator/data/src/compression.js +142 -0
  185. package/renderer/dist/emulator/data/src/emulator.js +6158 -0
  186. package/renderer/dist/emulator/data/src/gamepad.js +167 -0
  187. package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
  188. package/renderer/dist/emulator/data/src/shaders.js +236 -0
  189. package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
  190. package/renderer/dist/emulator/data/src/storage.js +125 -0
  191. package/renderer/dist/emulator/data/version.json +5 -0
  192. package/renderer/dist/emulator/host.js +297 -0
  193. package/renderer/dist/emulator/index.html +28 -0
  194. package/renderer/dist/emulator/provenance.json +6 -0
  195. package/renderer/dist/index.html +16 -3
  196. package/scripts/after-install.mjs +14 -0
  197. package/scripts/approval-server.sh +31 -7
  198. package/scripts/before-publish.mjs +12 -10
  199. package/shared/agent-import.mjs +3 -1
  200. package/shared/agents.d.mts +1 -0
  201. package/shared/bridge-map.mjs +19 -0
  202. package/shared/claude-commands.generated.mjs +8 -6
  203. package/shared/claude-models.generated.mjs +10 -8
  204. package/shared/codex-modes.mjs +1 -1
  205. package/shared/corner-tag.d.mts +14 -0
  206. package/shared/corner-tag.mjs +141 -0
  207. package/shared/demo-world.mjs +1 -1
  208. package/shared/engines.mjs +20 -5
  209. package/shared/fresh-user-home.mjs +12 -1
  210. package/shared/harness-definitions.mjs +103 -0
  211. package/shared/instruction-defaults.json +1 -1
  212. package/shared/notify-rules.d.mts +25 -0
  213. package/shared/notify-rules.mjs +19 -0
  214. package/shared/project-folder-check.mjs +3 -3
  215. package/shared/provider-commands.mjs +10 -2
  216. package/shared/rank.d.mts +2 -0
  217. package/shared/rank.mjs +50 -0
  218. package/shared/relay-address.mjs +6 -0
  219. package/shared/relay-crypto.mjs +165 -0
  220. package/shared/runs-on.d.mts +10 -0
  221. package/shared/runs-on.mjs +55 -0
  222. package/shared/shortcuts.mjs +11 -5
  223. package/shared/spawn-trouble.mjs +58 -7
  224. package/shared/terminal-input.d.mts +1 -0
  225. package/shared/terminal-input.mjs +30 -0
  226. package/shared/terminal-keys.d.mts +1 -0
  227. package/shared/terminal-keys.mjs +16 -0
  228. package/shared/terminal-size.d.mts +4 -0
  229. package/shared/terminal-size.mjs +29 -0
  230. package/shared/usage.d.mts +3 -0
  231. package/shared/usage.mjs +7 -0
  232. package/shared/where-it-runs-from.d.mts +43 -0
  233. package/shared/where-it-runs-from.mjs +155 -0
  234. package/shared/work-items.mjs +28 -2
  235. package/shared/work-lines.mjs +1 -1
  236. package/shared/xterm-invalid-range.d.mts +2 -0
  237. package/shared/xterm-invalid-range.mjs +58 -0
  238. package/renderer/dist/assets/index-B-2Az952.css +0 -32
  239. package/renderer/dist/assets/index-D6qncgai.js +0 -1168
@@ -1,16 +1,16 @@
1
- import { claudeActivity, codexActivity, currentActivity } from './agent-activity.mjs';
1
+ import { currentActivity } from './agent-activity.mjs';
2
+ import { harnessFor, supportsHarnessOperation } from './harnesses.mjs';
3
+ import { streamingText, countHelpers, saidNothingSheCanUse } from './harnesses/claude-stream.mjs';
4
+ export { streamingText, countHelpers, saidNothingSheCanUse } from './harnesses/claude-stream.mjs';
2
5
  import { taskRemoteControl } from './task-remote-control.mjs';
3
6
  import { taskFolderPath, real, restoreTaskFolder } from './task-folders.mjs';
4
7
  import { SHIP_LABEL, ShipQueue, readShipSettings, shipScriptFor } from './ship-queue.mjs';
5
8
  import { folderJob } from './task-folders-offthread.mjs';
6
9
  import { gitJob } from './git-change-offthread.mjs';
7
10
  import { queuedReplyText } from './live-replies.mjs';
8
- import { attachClaudeInput, claudeStreamArgs } from './claude-input.mjs';
9
- import { hookFailure } from '../shared/hook-failure.mjs';
10
11
  import { taskCommand } from './task-commands.mjs';
11
12
  import { providerCommand, reviewTarget, nativeCommandNames } from '../shared/provider-commands.mjs';
12
- import { compactCodexThread } from './codex-compaction.mjs';
13
- import {readSystemTemplate} from './instruction-settings.mjs';
13
+ import {agentInstructionsUnified, readSystemTemplate, writeInstruction} from './instruction-settings.mjs';
14
14
  import {fillName} from '../shared/product-name.mjs';
15
15
  // The supervisor: deterministic, boring, and the only part of the app that talks
16
16
  // to the brain. It spawns headless claude sessions on the founder's plan, one
@@ -40,7 +40,7 @@ import { buildSessionArgs, CLAUDE_MODES, permissionMode } from './settings.mjs';
40
40
  import fs from 'node:fs';
41
41
  import path from 'node:path';
42
42
  import os from 'node:os';
43
- import { isUrgent, itemPriority, normalizeOrder, orderFromTiers, productRankScore } from '../shared/rank.mjs';
43
+ import { isUrgent, itemPriority, normalizeOrder, orderFromTiers, placedScore, productRankScore } from '../shared/rank.mjs';
44
44
  import { keysForAgents } from '../shared/shortcuts.mjs';
45
45
  import { isCleanRun, ruleIdOf } from '../shared/repeats.mjs';
46
46
  import { mayRunHere } from '../shared/team-rules.mjs';
@@ -52,15 +52,29 @@ import { mayRunHere } from '../shared/team-rules.mjs';
52
52
  function teamPersonEnv() {
53
53
  return process.env.AGENTBOX_PERSON_ID ? { AGENTBOX_PERSON_ID: process.env.AGENTBOX_PERSON_ID } : {};
54
54
  }
55
+
56
+ /**
57
+ * WHY A FOLDER WAS REFUSED, IN WORDS, when it was refused rather than failed.
58
+ * `ensureTaskFolder` answers `{ taken: false, reason, heldBy }` when somebody
59
+ * else owns the folder (main/task-folders.mjs), and that answer is the one
60
+ * thing the row cannot work out for itself: a pid naming the other copy of the
61
+ * app is the difference between "this machine is broken" and "something else is
62
+ * already working here".
63
+ */
64
+ function whoHasIt(made) {
65
+ if (made?.taken !== false) return '';
66
+ return `: its folder is ${made.reason}${made.heldBy ? ` by pid ${made.heldBy}` : ''}`;
67
+ }
55
68
  import { agentSpokeSince, answerSettled, answerTs, stoppedByHer } from '../shared/answers.mjs';
56
69
  import { DEFAULT_SESSIONS_AT_ONCE } from './config.mjs';
57
- import { linkAccountTooling, toolingLine } from './account-tooling.mjs';
70
+ import { toolingLine } from './account-tooling.mjs';
58
71
  import { effectiveProfiles } from './account-discovery.mjs';
59
72
  import { referencedFiles, isImagePath } from '../shared/referenced-files.mjs';
60
73
  import { writeChangeForRun, changePath } from './code-change.mjs';
61
74
  import { snapshotRepo, changeFromRepo } from './git-change.mjs';
62
75
  import { importedAgentBrief, importedAgentName } from '../shared/agent-import.mjs';
63
- import { troubleCause, needsHerHands, troubleSentence, troubleRemedy, deadRunSentence, deadRunCause, undeliveredAnswerSentence, limitResetsAt, limitResetMoment, strandedSentence, strandedRemedy, causeBreakdown } from '../shared/spawn-trouble.mjs';
76
+ import { readPlan } from './claude-plan.mjs';
77
+ import { troubleCause, refineCause, needsHerHands, troubleSentence, troubleRemedy, deadRunSentence, deadRunCause, undeliveredAnswerSentence, limitResetsAt, limitResetMoment, strandedSentence, strandedRemedy, causeBreakdown } from '../shared/spawn-trouble.mjs';
64
78
  import { readDeadRunTrace, traceShowsASilentDeath } from '../shared/dead-run-trace.mjs';
65
79
  import { commandPrompt } from '../shared/claude-commands.mjs';
66
80
  import { machineryPath, storeRootEnv } from './store/home.mjs';
@@ -86,18 +100,10 @@ import {
86
100
  availableEngines, isEngine, modelForEngine, homeEngine,
87
101
  ENGINE_CHOICE_ENABLED, DEFAULT_ENGINE, ENGINE_IDS,
88
102
  } from '../shared/engines.mjs';
89
- // SAYING_CAP LIVES THERE AND NOT HERE. It was a module-private const in this
90
- // file and a second copy in that one, because the slice that wrote the Codex
91
- // readers could not edit this file. Two engines capping the live sentence
92
- // differently would leave one of them sitting under its own traced copy on her
93
- // screen forever, so there is one number and this is the file that moved.
94
- import {
95
- captureCodexEvent, changeFromCodexTurn, codexStreamingText, rememberCodexChange,
96
- summarizeCodexEvent, traceCodexEvent, SAYING_CAP,
97
- } from './codex.mjs';
98
103
  import { createCodexAppServer } from './codex-app-server.mjs';
104
+ import { codexLaunchEnv } from './codex-launch-env.mjs';
99
105
  import { CodexUsage } from './codex-usage.mjs';
100
- import { createCodexWorker, codexTranscriptFile, mcpServerNames, workerThreadParams } from './codex-session.mjs';
106
+ import { createCodexWorker, mcpServerNames, workerThreadParams } from './codex-session.mjs';
101
107
  import { CODEX_DEFAULT_MODE, isCodexMode } from '../shared/codex-modes.mjs';
102
108
  import { createCodexApprovals } from './codex-approvals.mjs';
103
109
  import { approvalPublicKey } from './approvals.mjs';
@@ -106,11 +112,14 @@ import { nameRow, wantsName } from './row-label.mjs';
106
112
  import { LEVELS, latestMessage, sortMessage, wantsPriority } from './message-priority.mjs';
107
113
  import { NAME, Name, envName, nameSlug, isOurSlug } from '../shared/product-name.mjs';
108
114
  import { signInFiles, signInStamp } from './sign-in-files.mjs';
109
- import { MemoryGateServer, defaultSocketPath as memoryGateSocketPath } from './memory-gate-server.mjs';
115
+ import { runsOn } from './runs-on.mjs';
116
+ import { MemoryGateServer, defaultSocketPath as memoryGateSocketPath, readMacPressure } from './memory-gate-server.mjs';
117
+ import { startDecision, readMacPressureNow } from './start-hold.mjs';
118
+ import { MemoryLog } from './memory-log.mjs';
110
119
  import { autoSlots, DEFAULTS as MEMORY_GATE } from './memory-gate.mjs';
111
120
  import { LeftoverCleaner } from './leftovers.mjs';
112
121
  import { syncCodexMemoryGate } from './codex-memory-gate.mjs';
113
- import { autoAgents, perAccountAgents } from './machine.mjs';
122
+ import { autoAgents } from './machine.mjs';
114
123
  import { runEndedProps } from './analytics.mjs';
115
124
 
116
125
  const POLL_MS = 15_000;
@@ -223,6 +232,17 @@ const PREEMPTION_HOLD_MS = 15 * 60_000;
223
232
  // hundred and sixty.
224
233
  const FRUITLESS_RESTS_MS = [15 * 60_000, 60 * 60_000, 4 * 3_600_000, 12 * 3_600_000, 24 * 3_600_000];
225
234
 
235
+ // HOW LONG A WORKER'S DEATH BY A STOP SIGNAL WAITS TO BE JUDGED, so a Control-C
236
+ // that reached it first is still seen as the app being stopped. Half a second
237
+ // is far longer than the gap measured (the app's own handler runs within
238
+ // milliseconds) and far shorter than anything a person would notice.
239
+ export const QUIT_GRACE_MS = 500;
240
+
241
+ /** SIGINT, SIGTERM or SIGHUP, as a signal or as the CLI's own exit code for one (130, 143, 129). */
242
+ export function diedOfAStopSignal(code, signal) {
243
+ return ['SIGINT', 'SIGTERM', 'SIGHUP'].includes(signal) || [129, 130, 143].includes(code);
244
+ }
245
+
226
246
  // THE FIRST RUN'S TWO MARKS (w-0b60195a14). The label is on the walk's own
227
247
  // example task and means one thing: no session, ever. It is answered by the app
228
248
  // itself out of the folder she just chose, so a worker picking it up would be a
@@ -306,7 +326,7 @@ export function storeGrantUnderAnyOfOurNames(args, storeServer = STORE_SERVER) {
306
326
  // engine, because of a name nobody recognises is worse than one that treats an
307
327
  // unknown name as the engine she is actually running.
308
328
  function engineOf(engine) {
309
- return engine === 'codex' ? 'codex' : DEFAULT_ENGINE;
329
+ return isEngine(engine) ? engine : DEFAULT_ENGINE;
310
330
  }
311
331
 
312
332
  export class Supervisor {
@@ -364,6 +384,14 @@ export class Supervisor {
364
384
  // Persisted: an in-memory-only set re-spawned a continuation for every
365
385
  // still-open answered item on every app restart.
366
386
  this._handledAnswers = new Set(state.handledAnswers ?? []);
387
+ // Waits already picked back up, and when that rule first ran here
388
+ // (`resumeFinishedWaits`).
389
+ this._resumedWaits = new Set(state.resumedWaits ?? []);
390
+ this._waitsSince = Number.isFinite(state.waitsSince) ? state.waitsSince : Date.now();
391
+ // A crash cannot run stop() to return replies whose workers were still
392
+ // being prepared. Only these exact reply keys are owed again; a later
393
+ // delivered reply on the same task keeps its own mark.
394
+ for (const key of state.preparingAnswers ?? []) this._handledAnswers.delete(key);
367
395
  // Which conversation already holds each of her words (`_noteHeard`).
368
396
  this._heardAnswers = state.heardAnswers ?? {};
369
397
  // `_personalSessions` used to sit here, itemId -> claude session id for a
@@ -443,8 +471,14 @@ export class Supervisor {
443
471
  // the inbox: this is a filing decision about a list that had grown to
444
472
  // twenty two chips, not an off switch. Pausing is the off switch.
445
473
  this.hiddenProducts = new Set(state.hiddenProducts ?? []);
474
+ // Threads she dragged to a place of their own in the list (`_score`).
475
+ this.threadPlaces = Object.fromEntries(Object.entries(state.threadPlaces ?? {}).filter(([, v]) => Number.isFinite(v)));
476
+ // Threads she pinned to the top of the list (w-20323283e6).
477
+ this.threadPins = Object.fromEntries(Object.entries(state.threadPins ?? {}).filter(([, v]) => v === true));
446
478
  } catch {
447
479
  this._handledAnswers = new Set();
480
+ this._resumedWaits = new Set();
481
+ this._waitsSince = Date.now();
448
482
  this._heardAnswers = {};
449
483
  this._liveSessions = {};
450
484
  this._rowSessions = {};
@@ -456,6 +490,8 @@ export class Supervisor {
456
490
  this._profileCooldown = {};
457
491
  this.productOrder = [];
458
492
  this.hiddenProducts = new Set();
493
+ this.threadPlaces = {};
494
+ this.threadPins = {};
459
495
  }
460
496
  // WHICH ROWS NOTHING HAS BEEN ABLE TO RUN ON, as of the last tick, for the
461
497
  // line above her list. Deliberately NOT persisted: it is derived from
@@ -583,11 +619,29 @@ export class Supervisor {
583
619
  // the moment their lease lapses and puts a FRESH session on them, which is
584
620
  // the restart she ruled out. The sweep gets there first and hands each row
585
621
  // back to the session that was doing it.
622
+ // A READING BEFORE THE RESTART WAVE, so the sessions a restart brings back
623
+ // meet the memory hold too rather than all arriving before the first
624
+ // reading does (main/start-hold.mjs). One sysctl, a few milliseconds.
625
+ if (this.config.memoryGate) this._memReading = readMacPressureNow();
586
626
  try { this.recoverInterrupted('startup'); } catch (e) { console.warn('zero: startup recovery:', e.message); }
587
627
  if (this.config.memoryGate) this.setMemoryGate(true).catch((e) => console.warn('zero: memory gate:', e.message));
588
628
  this.setLeftoverCleanup(!!this.config.cleanupLeftovers).catch((e) => console.warn('zero: leftovers:', e.message));
589
629
  this._timer = setInterval(() => this.tick().catch((e) => console.warn('supervisor:', e.message)), POLL_MS);
590
630
  this.tick().catch(() => {});
631
+ this.startMemoryLog();
632
+ }
633
+
634
+ /** A line a minute of memory by task, kept a week in the app's own folder
635
+ * (main/memory-log.mjs), so the next time memory runs out it points at a
636
+ * task instead of leaving it to a guess. */
637
+ startMemoryLog() {
638
+ clearInterval(this._memoryLogTimer);
639
+ this._memoryLog ??= new MemoryLog({
640
+ dir: path.join(this.userDir, 'memory-log'),
641
+ facts: () => ({ running: this._load(), capacity: this._capacity(), holding: this.startHoldStatus().holding }),
642
+ });
643
+ this._memoryLogTimer = setInterval(() => this._memoryLog.tick(), 60_000);
644
+ this._memoryLogTimer.unref?.();
591
645
  }
592
646
 
593
647
  // EVERY KILL OF OURS GOES THROUGH HERE, because the exit that follows cannot
@@ -681,6 +735,7 @@ export class Supervisor {
681
735
 
682
736
  stop() {
683
737
  clearInterval(this._timer);
738
+ clearInterval(this._memoryLogTimer);
684
739
  // The shared `codex app-server` goes with them. An orphaned one keeps every
685
740
  // thread it is holding alive after the supervisor that answers their
686
741
  // approvals has gone, which is the 2026-08-06 orphan incident in the other
@@ -732,10 +787,14 @@ export class Supervisor {
732
787
  // approval cards no new spawn would produce (2026-08-06). Items respawn on
733
788
  // next boot; an orphan just misbehaves invisibly.
734
789
  killAll() {
790
+ // Read by a worker's exit that is still inside its grace (QUIT_GRACE_MS):
791
+ // the app is going, so that death was ours.
792
+ this._quitting = true;
735
793
  for (const session of this.sessions.values()) this._kill(session);
736
794
  this._closeCodex(`${Name} is quitting`);
737
795
  this.stopMemoryGate();
738
796
  clearInterval(this._leftoverTimer);
797
+ clearInterval(this._memoryLogTimer);
739
798
  }
740
799
 
741
800
  // PUT A WORKER BACK ON THESE ROWS, WHATEVER STATE THEY ARE IN.
@@ -762,10 +821,21 @@ export class Supervisor {
762
821
  const out = { resumed: 0, queued: 0, working: 0, missing: 0 };
763
822
  if (!only.size) return out;
764
823
  this.liftBrakeForHer();
824
+ // RESUMING IS STILL ASKING TO RUN ON THIS MAC, so it obeys the same team
825
+ // rule the tick does (w-7fc38861be). It did not: on 2026-10-05 "Resume
826
+ // Agents" over a handful of ticked rows reached two conversations with
827
+ // teammates, and an agent turned up in the middle of each, reading the
828
+ // last thread reply as an instruction. Asking by name outranks a delivery
829
+ // mark and the attempt cap below; it does not make a row this Mac's to run.
830
+ const productBySlug = new Map((this.store.listProducts?.() ?? []).map((p) => [p.slug, p]));
831
+ const me = process.env.AGENTBOX_PERSON_ID || null;
765
832
 
766
- for (const item of this.store.listItems(Date.now())) {
767
- if (!only.has(item.id)) continue;
768
- only.delete(item.id);
833
+ // In her running order, so the free slots go to the top of it rather
834
+ // than to whichever folder comes first on disk. The first row by an id
835
+ // is the one asked for, as it was when this walked the store directly.
836
+ const asked = this.store.listItems(Date.now()).filter((item) => only.delete(item.id));
837
+ for (const item of this._inRunningOrder(asked)) {
838
+ if (!mayRunHere(item, productBySlug.get(item.product), me)) continue;
769
839
  // A row with a worker on it is already resumed. Saying so is the honest
770
840
  // answer; killing the session to restart it would throw away the work in
771
841
  // flight, which is never what "resume" meant.
@@ -779,7 +849,7 @@ export class Supervisor {
779
849
  // rest the fleet earned on it exactly as it outranks the attempt cap.
780
850
  delete this._fruitless[`${item.product}:${item.id}`];
781
851
 
782
- if (this._hasSlotFor(this._engineFor(item))) {
852
+ if (this._hasSlotFor(this._engineFor(item)) && this._memoryLetsStart(item, { asked: true })) {
783
853
  this._spawnResume(item);
784
854
  out.resumed += 1;
785
855
  } else {
@@ -795,6 +865,72 @@ export class Supervisor {
795
865
  return out;
796
866
  }
797
867
 
868
+ // A WAITING THREAD PICKS ITSELF BACK UP (w-5244c90c33). A thread an agent
869
+ // parked behind others (`blockedBy`) sits in Later, and when the last thread
870
+ // it names finishes, nothing used to put a worker back on it: a blocked row
871
+ // is invisible to every spawn pass, so it fell into Needs you for a person
872
+ // to restart by hand. Their words: "something that's waiting should be
873
+ // logically in the backlog and then it should automatically resume without
874
+ // the user needing to know otherwise."
875
+ //
876
+ // So it resumes once per finish, reopened and queued exactly like a resume
877
+ // by name. Only when every named thread is visible and done, the last of
878
+ // them finished after the wait was written AND after this rule first ran on
879
+ // this Mac (`_waitsSince`, so the waits that cleared long ago do not all
880
+ // start at once on the first tick), and never on a row she stopped.
881
+ // Anything this refuses stays where the lists put it, which is Needs you.
882
+ resumeFinishedWaits(items) {
883
+ const byId = new Map(items.map((i) => [`${i.product}:${i.id}`, i]));
884
+ const productBySlug = new Map((this.store.listProducts?.() ?? []).map((p) => [p.slug, p]));
885
+ const me = process.env.AGENTBOX_PERSON_ID || null;
886
+ const resumed = [];
887
+ for (const item of items) {
888
+ if (item.status !== 'blocked' || !Array.isArray(item.blockedBy) || !item.blockedBy.length) continue;
889
+ if (this.sessions.has(item.id) || stoppedByHer(item)) continue;
890
+ const blockers = item.blockedBy.map((id) => byId.get(`${item.product}:${id}`));
891
+ if (blockers.some((b) => !b || b.status !== 'done')) continue;
892
+ const cleared = Math.max(...blockers.map((b) => b.wrote?.status?.ts ?? 0));
893
+ if (cleared <= (item.wrote?.blockedBy?.ts ?? 0) || cleared <= this._waitsSince) continue;
894
+ const key = `${item.product}:${item.id}:${cleared}`;
895
+ if (this._resumedWaits.has(key)) continue;
896
+ if (!mayRunHere(item, productBySlug.get(item.product), me)) continue;
897
+ this._resumedWaits.add(key);
898
+ this.store.answerItem(item.product, item.id, { status: 'open' });
899
+ this._resumeQueue.add(item.id);
900
+ resumed.push(item.id);
901
+ }
902
+ if (resumed.length) this._saveState();
903
+ return resumed;
904
+ }
905
+
906
+ // The rows resumed by name that had no slot, taken IN HER RUNNING ORDER as
907
+ // slots free. A Set drains in the order rows went in, which was the order
908
+ // they sat on disk (w-6165eb69b4).
909
+ _drainResumeQueue(items) {
910
+ const waiting = [];
911
+ for (const id of [...this._resumeQueue]) {
912
+ const item = items.find((i) => i.id === id);
913
+ // A row that has gone or is already running is not waiting for a slot; it
914
+ // leaves the queue whatever capacity says.
915
+ if (!item || this.sessions.has(id)) {
916
+ this._resumeQueue.delete(id);
917
+ continue;
918
+ }
919
+ waiting.push(item);
920
+ }
921
+ for (const item of this._inRunningOrder(waiting)) {
922
+ // NO SLOT ON ITS ENGINE MEANS IT KEEPS ITS PLACE, and the scan goes on to
923
+ // the next one, which may be on the engine that is idle. This used to
924
+ // break out of the loop, which was the same starvation the main queue's
925
+ // `spent` note describes: she asked for these out loud, so a resume that
926
+ // waits behind the OTHER subscription is a resume that never happens.
927
+ if (!this._hasSlotFor(this._engineFor(item))) continue;
928
+ if (!this._memoryLetsStart(item, { asked: true })) continue;
929
+ this._resumeQueue.delete(item.id);
930
+ this._spawnResume(item);
931
+ }
932
+ }
933
+
798
934
  // A resumed row spawns as a continuation when she has words on it that the
799
935
  // worker has not answered: that is what puts "the founder has answered" in
800
936
  // the brief instead of briefing a stranger about a thread she is mid-way
@@ -880,7 +1016,15 @@ export class Supervisor {
880
1016
  let items;
881
1017
  try { items = this.store.listItems(Date.now()); } catch { return out; }
882
1018
  let dirty = false;
883
- for (const [itemId, rec] of Object.entries(this._liveSessions)) {
1019
+ // IN HER RUNNING ORDER, so when slots are short after a restart the top of
1020
+ // it gets its session back first rather than whatever started first
1021
+ // (w-6165eb69b4). A record whose row is gone sorts anywhere; it is only
1022
+ // forgotten below.
1023
+ const rowOf = ([itemId, rec]) => items.find((i) => i.id === itemId && i.product === rec.product);
1024
+ const remembered = Object.entries(this._liveSessions).map((entry) => ({ entry, row: rowOf(entry) }));
1025
+ const ranked = new Map(this._inRunningOrder(remembered.filter((r) => r.row).map((r) => r.row)).map((row, at) => [row, at]));
1026
+ remembered.sort((a, b) => (ranked.get(a.row) ?? -1) - (ranked.get(b.row) ?? -1));
1027
+ for (const { entry: [itemId, rec] } of remembered) {
884
1028
  // A worker is on it. Nothing is stranded and killing it to resume it
885
1029
  // would throw away the work in flight, which is never what resume meant.
886
1030
  // A resume that STUCK also re-arms the announcement: the next time this
@@ -958,7 +1102,7 @@ export class Supervisor {
958
1102
  // nothing again. Only one of the two facts ever changes on its own, so
959
1103
  // only one of them is worth queueing for.
960
1104
  const engine = engineOf(rec.engine);
961
- if (this._engineCanRun(engine) && !this._hasSlotFor(engine)) {
1105
+ if (this._engineCanRun(engine) && !(this._hasSlotFor(engine) && this._memoryLetsStart(item))) {
962
1106
  out.queued += 1;
963
1107
  this._recoverPending = true;
964
1108
  continue;
@@ -1325,7 +1469,7 @@ export class Supervisor {
1325
1469
  const word = engineLabel(engine);
1326
1470
  try {
1327
1471
  this.store.recordSessionResult(item.product, item.id, {
1328
- 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.`,
1472
+ 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.`,
1329
1473
  status: 'open',
1330
1474
  });
1331
1475
  } catch (e) { console.warn('zero: could not say the harness is missing:', e.message); }
@@ -1384,6 +1528,41 @@ export class Supervisor {
1384
1528
  } catch (e) { console.warn('zero: could not say the model is for another engine:', e.message); }
1385
1529
  }
1386
1530
 
1531
+ /**
1532
+ * A ROW THAT COULD NOT GET ITS OWN FOLDER SAYS SO ON THE ROW, and stays open.
1533
+ *
1534
+ * Written the same way as the cross-engine model refusal above, for the same
1535
+ * reason: the answer is not a narrower plan, it is no run at all, and a
1536
+ * refusal nobody is told about is indistinguishable from the app being
1537
+ * broken. Before this, the three places that could not make a folder all
1538
+ * answered by running in the checkout the app is built from and printing one
1539
+ * line to a console nobody has open (w-5952e6de3e, 2026-10-07).
1540
+ *
1541
+ * Said once per row, because a tick that keeps asking must not keep writing.
1542
+ */
1543
+ _couldNotGetAFolder(item, error) {
1544
+ this._folderRefused ??= new Set();
1545
+ if (this._folderRefused.has(item?.id)) return;
1546
+ const why = String(error?.message ?? error ?? '').trim();
1547
+ try {
1548
+ this.store.recordSessionResult(item.product, item.id, {
1549
+ result: `This task did not start, because it could not be given a folder of its own to work in${why ? `: ${why}` : '.'}\n\nIt is not run in the checkout ${Name} itself is built from. Two agents in one folder cannot be told apart afterwards, so the change card for this task would show another task's edits as if they were this one's, and that is worse than waiting.\n\nThe usual causes are a full disk, a path the app cannot write to, a file named in .worktreeinclude that cannot be carried into a folder, and another copy of ${Name} over the same checkout standing in that folder already. Then reply here and it will try again.`,
1550
+ status: 'open',
1551
+ });
1552
+ // MARKED AS SAID ONLY ONCE IT HAS BEEN SAID. Marking first means a store
1553
+ // write that failed -- which is likeliest during exactly the disk trouble
1554
+ // that caused this -- would silence every later attempt as well.
1555
+ this._folderRefused.add(item?.id);
1556
+ } catch (e) { console.warn('zero: could not say the folder failed:', e.message); }
1557
+ }
1558
+
1559
+ /**
1560
+ * A folder that WAS made clears the refusal, so if this row ever fails again
1561
+ * it is told again. Only a repeat inside one unbroken run of failures is
1562
+ * swallowed, which is the tick asking over and over rather than news.
1563
+ */
1564
+ _gotAFolderAfterAll(id) { this._folderRefused?.delete(id); }
1565
+
1387
1566
  // WHERE THE SESSION ACTUALLY IS, checked rather than assumed. The CLI keeps
1388
1567
  // one transcript per session under its profile's home, in a directory named
1389
1568
  // after the working directory with every character that is not a letter or a
@@ -1410,24 +1589,7 @@ export class Supervisor {
1410
1589
  // login was searched for in the first and answered null -- and a null here
1411
1590
  // is `resumeStopped` writing the row off as a session that no longer
1412
1591
  // exists.
1413
- if (rec.engine === 'codex') {
1414
- return codexTranscriptFile(rec.sessionId, { home: this._codexProfileHome(rec.profile) });
1415
- }
1416
- const home = rec.profile && rec.profile !== 'default' ? rec.profile : path.join(os.homedir(), '.claude');
1417
- const projects = path.join(home, 'projects');
1418
- const slug = String(rec.cwd ?? '').replace(/[^a-zA-Z0-9]/g, '-');
1419
- const direct = path.join(projects, slug, `${rec.sessionId}.jsonl`);
1420
- if (fs.existsSync(direct)) return direct;
1421
- // The slug rule is the CLI's and it may change under us, so a miss is
1422
- // answered by looking rather than by writing off a session that is plainly
1423
- // sitting on disk.
1424
- try {
1425
- for (const dir of fs.readdirSync(projects)) {
1426
- const candidate = path.join(projects, dir, `${rec.sessionId}.jsonl`);
1427
- if (fs.existsSync(candidate)) return candidate;
1428
- }
1429
- } catch {}
1430
- return null;
1592
+ return harnessFor(engineOf(rec.engine)).transcript(this, rec);
1431
1593
  }
1432
1594
 
1433
1595
  /* `rememberPersonalSession` and `personalSessionFor` were a pair here: one
@@ -1499,15 +1661,35 @@ export class Supervisor {
1499
1661
  */
1500
1662
  workFolderFor(item, product) {
1501
1663
  const base = this.productFolder(product);
1664
+ // A PROJECT WITH NO REPOSITORY NEVER PROMISED ISOLATION, and has run in the
1665
+ // product folder since before folders existed. That is the absence of a
1666
+ // promise, not a broken one, and it stays exactly as it was.
1502
1667
  if (!this._folderRow(item, product)) return base;
1503
1668
  // Already made, off this thread, by the spawn that is asking (`_folderFirst`).
1504
1669
  const made = this._madeFolders?.get(item.id);
1505
1670
  if (made) return made;
1506
- try { return restoreTaskFolder(base, item.id)?.path ?? base; }
1507
- catch (error) {
1508
- console.warn(`zero: ${item.id} is running in the shared checkout:`, error.message);
1509
- return base;
1510
- }
1671
+ // AND A ROW THAT WAS PROMISED A FOLDER EITHER GETS ONE OR DOES NOT RUN.
1672
+ //
1673
+ // This used to answer a failure with the shared checkout and a
1674
+ // `console.warn`, which is the one back door under every other guarantee in
1675
+ // this file. main/git-change.mjs says the cost in its own words: "In a
1676
+ // checkout two agents share, a file the other one wrote inside this run's
1677
+ // window is inside this run's window, and no reading of the disk can tell
1678
+ // them apart." So the card somebody reads can carry another agent's edits,
1679
+ // and the only notice was printed into a terminal nobody has open. Running
1680
+ // is not better than not running when the result is a card that lies.
1681
+ // Found 2026-10-07 reviewing with Codex (w-5952e6de3e).
1682
+ //
1683
+ // A NULL IS THE SAME ANSWER AS A THROW. `restoreTaskFolder` returns null
1684
+ // when the repoPath is not a repository, and `?? base` turned that into
1685
+ // permission to use the checkout. A REFUSAL IS THE SAME ANSWER AGAIN:
1686
+ // taking a folder can be refused outright now (another app process over
1687
+ // this checkout is standing in it, or somebody locked it by hand), and
1688
+ // that refusal carries no path for exactly this reason.
1689
+ const asked = restoreTaskFolder(base, item.id);
1690
+ const folder = asked?.path ?? null;
1691
+ if (!folder) throw Error(`no folder could be made for ${item.id} in ${base}${whoHasIt(asked)}`);
1692
+ return folder;
1511
1693
  }
1512
1694
 
1513
1695
  /**
@@ -1623,19 +1805,50 @@ export class Supervisor {
1623
1805
  // now, so the disk no longer lies here, and this answers first anyway
1624
1806
  // because a row somebody is already preparing is never a row to spawn on.
1625
1807
  const held = this._preparing.get(item.id);
1626
- if (held) { held.item = item; held.opts = opts; return true; }
1808
+ if (held) { held.item = item; held.opts = opts; this._saveState(); return true; }
1627
1809
  if (fs.existsSync(folder)) return false;
1628
1810
  const entry = { item, opts, engine };
1629
1811
  this._preparing.set(item.id, entry);
1812
+ this._saveState();
1630
1813
  folderJob('restoreTaskFolder', base, item.id)
1631
- .then((made) => made?.path ?? base, (error) => {
1632
- console.warn(`zero: ${item.id} is running in the shared checkout:`, error.message);
1633
- return base;
1634
- })
1635
- .then((cwd) => {
1814
+ // A FAILURE HERE ENDS THE SPAWN. It used to hand back the shared checkout
1815
+ // and go on to start the worker in it, which is `workFolderFor`'s back
1816
+ // door with a session actually started behind it. `null` carries the
1817
+ // refusal through the same chain, so the bookkeeping below runs either
1818
+ // way: the slot goes back, their reply goes back in line, and the row
1819
+ // says what happened. Found 2026-10-07 reviewing with Codex.
1820
+ // THE FAILURE IS CARRIED, NOT ACTED ON HERE. Saying it in this handler
1821
+ // would say it about a row that may have been stopped or closed in the
1822
+ // meantime, and `recordSessionResult` writes `status: 'open'`, so a
1823
+ // refusal arriving late would REOPEN a row somebody had finished with.
1824
+ // It is reported below, after the one check that knows whether this
1825
+ // attempt is still the current one (Codex's review, 2026-10-07).
1826
+ // A REFUSAL IS A FAILURE WITH A NAME ON IT: the folder exists and belongs
1827
+ // to somebody else, so `whoHasIt` turns that answer into the words the
1828
+ // row carries rather than a bare "no folder could be made".
1829
+ .then(
1830
+ (made) => ({
1831
+ cwd: made?.path ?? null,
1832
+ failed: made?.taken === false ? Error(`no folder could be made for ${item.id}${whoHasIt(made)}`) : null,
1833
+ }),
1834
+ (error) => ({ cwd: null, failed: error }),
1835
+ )
1836
+ .then(({ cwd, failed }) => {
1636
1837
  // Stopped, or the app quit, while the folder was being made.
1637
1838
  if (this._preparing.get(item.id) !== entry) return;
1638
1839
  this._preparing.delete(item.id);
1840
+ if (!cwd) {
1841
+ // A null with no error behind it is the folder thread answering that
1842
+ // the repoPath is not a repository at all.
1843
+ this._couldNotGetAFolder(entry.item, failed ?? Error('no folder could be made'));
1844
+ // THEIR WORDS ARE NOT DELIVERED BY A WORKER THAT NEVER STARTED. The
1845
+ // queue writes the delivery mark before the spawn and that mark is
1846
+ // persisted, so leaving it standing records the reply as handed over
1847
+ // for good. Same accounting as the cross-engine model refusal.
1848
+ if (entry.opts?.continuation && entry.item?.answer) this.redeliverAnswer(entry.item, entry.item.answer);
1849
+ this.onChange?.();
1850
+ return;
1851
+ }
1639
1852
  // AND A SESSION THAT ARRIVED WHILE WE WERE BUILDING ALREADY HAS THIS
1640
1853
  // ROW. Starting a second one here is what gave w-4d722ecf92 two workers
1641
1854
  // and had the second one's claim refused. A spawn cannot reach this
@@ -1643,6 +1856,7 @@ export class Supervisor {
1643
1856
  // skips it entirely, so the one place that starts a worker without
1644
1857
  // anybody having just checked asks for itself.
1645
1858
  if (this.sessions.has(item.id)) { this.onChange?.(); return; }
1859
+ this._gotAFolderAfterAll(item.id);
1646
1860
  this._madeFolders.set(item.id, cwd);
1647
1861
  try { this.spawnWorker(entry.item, entry.opts); }
1648
1862
  catch (error) { console.warn(`zero: could not start ${item.id}:`, error.message); }
@@ -1663,7 +1877,7 @@ export class Supervisor {
1663
1877
  this._photos ??= new Map();
1664
1878
  if (this._photos.has(item.id)) return false;
1665
1879
  const held = this._preparing?.get(item.id);
1666
- if (held) { held.item = item; held.opts = opts; return true; }
1880
+ if (held) { held.item = item; held.opts = opts; this._saveState(); return true; }
1667
1881
  let cwd = null;
1668
1882
  try { cwd = this.workFolderFor(item, product); } catch { return false; }
1669
1883
  // Only a folder that is itself a checkout (a task folder carries a `.git`
@@ -1673,6 +1887,7 @@ export class Supervisor {
1673
1887
  this._preparing ??= new Map();
1674
1888
  const entry = { item, opts, engine, madeFolder: this._madeFolders?.get(item.id) };
1675
1889
  this._preparing.set(item.id, entry);
1890
+ this._saveState();
1676
1891
  gitJob('snapshotRepo', cwd)
1677
1892
  .then((photo) => photo ?? null, () => null)
1678
1893
  .then((photo) => {
@@ -1736,6 +1951,11 @@ export class Supervisor {
1736
1951
  if (!product?.repoPath || base !== product.repoPath) return 0;
1737
1952
  const busy = (id) => this.sessions.has(id) || !!this._preparing?.has(id);
1738
1953
  let put = 0;
1954
+ // AND WHILE WE ARE HERE, WHAT ELSE IS HOLDING THE DISK (w-330eea6c66). This
1955
+ // is the only place that already walks a project's folders on a clock, and
1956
+ // the strays are deliberately not in the list below: they are nobody's row,
1957
+ // so they are shown and never swept.
1958
+ await this.lookOverTheProjectsFolders(product, base);
1739
1959
  for (const folder of await folderJob('listTaskFolders', base)) {
1740
1960
  // OUR OWN PID IS NOT SOMEBODY ELSE. Every folder is locked by the process
1741
1961
  // that made it, which is this one, so treating any lock as a live holder
@@ -1763,11 +1983,52 @@ export class Supervisor {
1763
1983
  // reading it is an agent's folder deleted while it works (w-4d722ecf92).
1764
1984
  if (item?.claim && !item.claimExpired) continue;
1765
1985
  if (busy(folder.id)) continue;
1766
- if ((await folderJob('parkTaskFolder', base, folder.id)).parked) put += 1;
1986
+ // THE PATH WE WERE HANDED, not the row id alone. Parking used to rebuild
1987
+ // `.claude/worktrees/<id>` from the id, so the folder it committed in and
1988
+ // removed was not necessarily the one this loop looked at (w-330eea6c66).
1989
+ if ((await folderJob('parkTaskFolder', base, folder.id, { path: folder.path })).parked) put += 1;
1767
1990
  }
1768
1991
  return put;
1769
1992
  }
1770
1993
 
1994
+ /**
1995
+ * EVERY COPY OF A PROJECT ON THE DISK, INCLUDING THE ONES THE APP DID NOT MAKE
1996
+ * (w-330eea6c66).
1997
+ *
1998
+ * MEASURED 2026-10-07: thirteen worktrees of one repository sat outside the
1999
+ * app's own home holding about 740 MB, made by hand by sessions rather than by
2000
+ * the app, and nothing in the app could list, show or sweep one. This is the
2001
+ * showing half: the strays are remembered here so a page can name them without
2002
+ * reading the disk on the window's thread.
2003
+ *
2004
+ * IT ONLY EVER LOOKS. Removing a folder somebody made on purpose is not the
2005
+ * app's to do, and the folder is the only copy of whatever is uncommitted in
2006
+ * it. The one thing it does change is bookkeeping with nothing behind it: a
2007
+ * registration whose folder has gone, which is what refuses the next
2008
+ * `worktree add` at that path (one of the thirteen was under `/private/tmp`,
2009
+ * which macOS purges).
2010
+ */
2011
+ async lookOverTheProjectsFolders(product, base) {
2012
+ try {
2013
+ const dropped = await folderJob('pruneMissingWorktrees', base);
2014
+ if (dropped.length) console.warn(`zero: dropped ${dropped.length} worktree registration(s) in ${base} pointing at nothing`);
2015
+ const strays = await folderJob('strayWorktrees', base);
2016
+ this._strayFolders ??= new Map();
2017
+ // By checkout rather than by product, so two products registering the same
2018
+ // one do not count its folders twice.
2019
+ this._strayFolders.set(product.repoPath, strays.map((w) => ({ ...w, project: product.name || product.slug })));
2020
+ } catch (error) {
2021
+ console.warn('zero: could not look over the project´s folders:', error.message);
2022
+ }
2023
+ }
2024
+
2025
+ /** Copies of a project on this disk the app did not make, as the last sweep found them. */
2026
+ strayFolders() {
2027
+ const out = [];
2028
+ for (const list of this._strayFolders?.values() ?? []) out.push(...list);
2029
+ return out;
2030
+ }
2031
+
1771
2032
  profileHoldingSession(sessionId, itemId, product) {
1772
2033
  if (!sessionId) return null;
1773
2034
  const remembered = this._rowSessions?.[itemId]?.profile;
@@ -1893,6 +2154,20 @@ export class Supervisor {
1893
2154
  // throw away every chat she is in the middle of.
1894
2155
  const folders = product ? this.workFolders(item, product) : [];
1895
2156
  if (folders.length && rec.cwd && !folders.includes(rec.cwd)) return null;
2157
+ // A CHAT ON THE WRONG LOGIN IS NOT RESUMED IN A TIED PROJECT, and this is
2158
+ // the one question asked ahead of the health of the account. A chat started
2159
+ // before the project was tied lives on whatever account the rotation gave
2160
+ // it, and resuming it there would send the next reply -- and everything the
2161
+ // transcript already holds -- to exactly the login the tie exists to keep it
2162
+ // off. Dropped here, the row is briefed fresh on the right account.
2163
+ //
2164
+ // AND A TIED PROJECT KEEPS ITS CHAT WHILE THE PINNED ACCOUNT IS ILL, which
2165
+ // is why the test below is not simply "also check `_profileCannotHoldAChat`".
2166
+ // That guard moves a chat to a HEALTHIER account; a tied project has none to
2167
+ // move to, so firing it would throw the thread away and buy nothing.
2168
+ if (this._projectProfile(item.product, rec.engine)) {
2169
+ return this._profileFitsProject(rec.profile, item.product, rec.engine) ? rec : null;
2170
+ }
1896
2171
  // The account this chat lives on cannot run anything until somebody acts.
1897
2172
  // Null here is the fresh brief, which is exactly what this row needs: it
1898
2173
  // goes to a working account carrying its own thread.
@@ -2356,7 +2631,10 @@ export class Supervisor {
2356
2631
  */
2357
2632
  _onlyWeSpokeForAnAccount(item) {
2358
2633
  const seen = this._fruitless[`${item.product}:${item.id}`];
2359
- if (!seen?.saidTs || !(needsHerHands(seen.saidWhy) || seen.saidWhy === 'at-limit')) return false;
2634
+ // A plan without the model counts only when another login has it, which
2635
+ // is when `noteExitForBackoff` names the account (w-f1c00e679d).
2636
+ const planWithout = seen?.saidWhy === 'model-not-on-plan' && !!seen.account;
2637
+ if (!seen?.saidTs || !(needsHerHands(seen.saidWhy) || seen.saidWhy === 'at-limit' || planWithout)) return false;
2360
2638
  return (item.wrote?.result?.ts ?? 0) <= seen.saidTs;
2361
2639
  }
2362
2640
 
@@ -2366,6 +2644,12 @@ export class Supervisor {
2366
2644
  if (!runs) return 0;
2367
2645
  // Rested because its ACCOUNT refused it: awake as soon as the account is.
2368
2646
  if (seen?.account && !this._profileResting(seen.account)) return 0;
2647
+ // OR AS SOON AS ANOTHER LOGIN CAN TAKE IT. One login out of usage is not
2648
+ // the task's fault and says nothing about the others: with a second login
2649
+ // free, the task slept its 15-minute rest while its row promised the reset
2650
+ // hours away (tests/a-task-moves-to-a-free-login-when-its-login-runs-out).
2651
+ // Only fresh work rests here, so a run on another login loses nothing.
2652
+ if (seen?.account && this._anotherLoginIsFree(item, seen.account)) return 0;
2369
2653
  // AN URGENT ROW NEVER CLIMBS THE LADDER. Urgent outranks High, and is
2370
2654
  // even meant to INTERRUPT High and below, so it must never end up waiting
2371
2655
  // behind them.
@@ -2537,6 +2821,7 @@ export class Supervisor {
2537
2821
  cause,
2538
2822
  runs,
2539
2823
  resetsAt,
2824
+ elsewhere: session.accountFault?.cause === 'model-not-on-plan',
2540
2825
  });
2541
2826
  const written = this.store.recordSessionResult(item.product, item.id, { result, status: 'open' });
2542
2827
  // REMEMBER THAT WE SAID IT, keyed on the run we said it about. The pass
@@ -2630,7 +2915,7 @@ export class Supervisor {
2630
2915
  .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
2631
2916
  if (!next) return undefined;
2632
2917
  this._naming = true;
2633
- return this._askSmall((opts) => nameRow(next, opts))
2918
+ return this._askSmall((opts) => nameRow(next, opts), { product: next.product })
2634
2919
  .then((label) => {
2635
2920
  if (!label) { this._nameMisses.set(key(next), now); return; }
2636
2921
  this._nameMisses.delete(key(next));
@@ -2656,13 +2941,25 @@ export class Supervisor {
2656
2941
  * one call per account and then waits for the next pass. The environment is
2657
2942
  * a worker's (`_workerEnv`), so no API key from the app's own shell reaches
2658
2943
  * the call, and the account rides it the way it does on a worker's spawn.
2944
+ *
2945
+ * A PROJECT TIED TO ONE ACCOUNT IS NOT WALKED, AND THAT IS THE WHOLE POINT OF
2946
+ * THE TIE. The prompt here carries the row's own text -- its title, the first
2947
+ * lines of its body, a teammate's message -- so a walk would put an
2948
+ * employer's row in front of a personal subscription, which is the one thing
2949
+ * the setting promises will not happen. So a tied project gets ONE call on
2950
+ * ONE account: the pinned login, resting or not, and no fallback to the other
2951
+ * engine either, because Codex is a different vendor's account and a walk
2952
+ * onto it breaks the same promise the walk across logins does. The cost of
2953
+ * getting nothing is the row keeping its own title, which is what every row
2954
+ * had before the namer existed.
2659
2955
  */
2660
- async _askSmall(ask) {
2956
+ async _askSmall(ask, { product = null } = {}) {
2661
2957
  const home = this._homeEngine();
2662
- const other = home === 'codex' ? DEFAULT_ENGINE : 'codex';
2663
- const engines = [home, ...(this.engineChoices().some((e) => e.id === other) ? [other] : [])];
2958
+ const engines = this._projectProfile(product)
2959
+ ? [DEFAULT_ENGINE]
2960
+ : [home, ...this.engineChoices().map(e => e.id).filter(id => id !== home)];
2664
2961
  for (const engine of engines) {
2665
- for (const env of this._smallModelEnvs(engine)) {
2962
+ for (const env of this._smallModelEnvs(engine, product)) {
2666
2963
  const answer = await ask({ claudeBin: this.config?.claudeBin, codexBin: this.config?.codexBin, engine, env });
2667
2964
  if (answer) return answer;
2668
2965
  }
@@ -2670,18 +2967,21 @@ export class Supervisor {
2670
2967
  return '';
2671
2968
  }
2672
2969
 
2673
- /** One environment per account on an engine, the accounts not resting first. */
2674
- _smallModelEnvs(engine) {
2970
+ /**
2971
+ * One environment per account on an engine, the accounts not resting first --
2972
+ * or the one account a tied project named, which is a list of one.
2973
+ */
2974
+ _smallModelEnvs(engine, product = null) {
2675
2975
  const env = this._workerEnv(engine);
2676
2976
  const now = Date.now();
2677
2977
  const resting = (p) => {
2678
2978
  const key = this._accountKey(engine, p);
2679
2979
  return (this._profileCooldown?.[key] ?? 0) >= now || !!this._profileTrouble?.[key];
2680
2980
  };
2681
- const all = this._profilesFor(engine);
2981
+ const pinned = this._projectProfile(product, engine);
2982
+ const all = pinned ? [pinned] : this._profilesFor(engine);
2682
2983
  return [...all.filter((p) => !resting(p)), ...all.filter(resting)].map((p) => {
2683
- if (engine === 'codex') return { ...env, CODEX_HOME: this._codexProfileHome(p) };
2684
- return p === 'default' ? env : { ...env, CLAUDE_CONFIG_DIR: p };
2984
+ return harnessFor(engine).profileEnv(env, p, this);
2685
2985
  });
2686
2986
  }
2687
2987
 
@@ -2704,7 +3004,7 @@ export class Supervisor {
2704
3004
  if (!next) return undefined;
2705
3005
  this._sortingMessages = true;
2706
3006
  this._sortedMessages.add(`${next.item.product}:${next.item.id}:${next.latest.ts}`);
2707
- return this._askPriority(next.latest)
3007
+ return this._askPriority(next.latest, next.item.product)
2708
3008
  .then((level) => {
2709
3009
  if (level) this.store.prioritizeItem(next.item.product, next.item.id, LEVELS[level]);
2710
3010
  })
@@ -2712,8 +3012,8 @@ export class Supervisor {
2712
3012
  .finally(() => { this._sortingMessages = false; });
2713
3013
  }
2714
3014
 
2715
- _askPriority(latest) {
2716
- return this._askSmall((opts) => sortMessage(latest, opts));
3015
+ _askPriority(latest, product = null) {
3016
+ return this._askSmall((opts) => sortMessage(latest, opts), { product });
2717
3017
  }
2718
3018
 
2719
3019
  sayItOnEveryStrandedRow(items, now = Date.now()) {
@@ -2929,8 +3229,62 @@ export class Supervisor {
2929
3229
  // One product's medium outranks another's urgent when it sits above it.
2930
3230
  // Ties break oldest-first so nothing starves. The item's own number is hers
2931
3231
  // alone now (itemPriority); an agent's tag no longer moves anything.
3232
+ // A THREAD DRAGGED IN THE LIST RUNS WHERE IT WAS PUT (w-6e5b532a95): its
3233
+ // place replaces the score outright (`placedScore`, shared/rank.mjs).
2932
3234
  _score(item) {
2933
- return productRankScore(this.productOrder, item.product) + itemPriority(item);
3235
+ return placedScore(this.threadPlaces[item.id], productRankScore(this.productOrder, item.product) + itemPriority(item));
3236
+ }
3237
+
3238
+ // THE SAME ORDER FOR EVERY PATH THAT PUTS MANY ROWS BACK AT ONCE
3239
+ // (w-6165eb69b4). Resuming ticked rows, the queue of the ones that found no
3240
+ // slot, and the sweep after a restart each walked rows in the order they sat
3241
+ // on disk, so ⌘A and "Resume Agents" started Agentbox Team's Low and Medium
3242
+ // while Personal and an Urgent waited. Highest score first, oldest first in
3243
+ // a tie, as in the fresh-work queue.
3244
+ _inRunningOrder(rows) {
3245
+ return rows.slice().sort((a, b) =>
3246
+ this._score(b) - this._score(a) || (a.createdAt ?? 0) - (b.createdAt ?? 0));
3247
+ }
3248
+
3249
+ // Places as a patch: a number sets one, null forgets it. Kept here beside
3250
+ // the project order, on this Mac, because both are one person's running
3251
+ // order and a teammate's list must not move when you drag yours.
3252
+ setThreadPlaces(patch) {
3253
+ for (const [id, place] of Object.entries(patch ?? {})) {
3254
+ if (place === null) delete this.threadPlaces[id];
3255
+ else if (Number.isFinite(place)) this.threadPlaces[id] = place;
3256
+ }
3257
+ this._saveState();
3258
+ this.onChange?.();
3259
+ }
3260
+
3261
+ // PINNED THREADS (w-20323283e6), as a patch: true pins one, null forgets it.
3262
+ // Kept beside the places for the same reason: a pin is one person's list,
3263
+ // and a teammate's must not change when you pin yours. The fleet does not
3264
+ // read it; a pin is where a thread is drawn, not when it runs.
3265
+ setThreadPins(patch) {
3266
+ let changed = false;
3267
+ for (const [id, pin] of Object.entries(patch ?? {})) {
3268
+ const was = this.threadPins[id] === true;
3269
+ if (pin === true) this.threadPins[id] = true;
3270
+ else if (pin === null || pin === false) delete this.threadPins[id];
3271
+ changed ||= was !== (this.threadPins[id] === true);
3272
+ }
3273
+ // Every close asks to forget a pin, and almost none has one to forget.
3274
+ if (!changed) return;
3275
+ this._saveState();
3276
+ this.onChange?.();
3277
+ }
3278
+
3279
+ // The snapshot's items with each one's place and pin on it, which is all the
3280
+ // screen needs to sort by them. A row with neither is handed back untouched.
3281
+ withPlaces(items) {
3282
+ return items.map((i) => {
3283
+ const place = this.threadPlaces[i.id];
3284
+ const pinned = this.threadPins[i.id] === true;
3285
+ if (!Number.isFinite(place) && !pinned) return i;
3286
+ return { ...i, ...(pinned ? { pinned: true } : {}), ...(Number.isFinite(place) ? { place } : {}) };
3287
+ });
2934
3288
  }
2935
3289
 
2936
3290
  /* ----------------------- auth profiles (subscriptions) ------------------ */
@@ -2996,7 +3350,7 @@ export class Supervisor {
2996
3350
  }
2997
3351
 
2998
3352
  _profilesFor(engine) {
2999
- const all = engineOf(engine) === 'codex' ? this._codexProfiles() : this._profiles();
3353
+ const all = harnessFor(engineOf(engine)).profiles(this);
3000
3354
  return this._narrowToChosen(engine, all);
3001
3355
  }
3002
3356
 
@@ -3025,6 +3379,50 @@ export class Supervisor {
3025
3379
  return all.includes(chosen) ? [chosen] : all;
3026
3380
  }
3027
3381
 
3382
+ /**
3383
+ * THE ONE CLAUDE ACCOUNT A PROJECT IS TIED TO, when it is tied to one.
3384
+ *
3385
+ * `activeAccount` narrows the WHOLE app to one login, which is no use to
3386
+ * somebody who genuinely uses two: with a personal subscription and an
3387
+ * employer's on one Mac, round-robin runs the employer's code on the personal
3388
+ * plan about half the time. `projectAccounts` answers per project instead, and
3389
+ * a tied project runs there and waits rather than borrowing another login.
3390
+ *
3391
+ * CLAUDE ONLY, said here once. A Codex login is a different subscription on a
3392
+ * different binary and this setting says nothing about it, so a Codex run
3393
+ * reads null and rotates exactly as it did.
3394
+ *
3395
+ * IT IS IGNORED THE MOMENT IT STOPS NAMING A LOGIN THAT IS ON THIS MAC,
3396
+ * for the reason `_narrowToChosen` gives about `activeAccount` a few lines
3397
+ * down: a pin left over from an account since removed would otherwise hand
3398
+ * every worker a CLAUDE_CONFIG_DIR pointing at a folder with no login in it,
3399
+ * and the project would fail in silence while the app looked fine. A pin
3400
+ * nobody can honour is no pin, and the project rotates as it did before
3401
+ * anybody pinned it.
3402
+ *
3403
+ * AND A PIN BEATS `activeAccount`, DELIBERATELY. The two can disagree -- pick
3404
+ * one account for the app, tie a project to the other -- and one of them has
3405
+ * to win. It is the pin, because it is the narrower statement and the one made
3406
+ * about THIS project on its own page, and because the other way round is the
3407
+ * silent stop this file minds most: `activeAccount` would empty the tied
3408
+ * project's pool of one and nothing would ever start in it, with every screen
3409
+ * reading normal. So the check below is against the logins on the Mac
3410
+ * (`_profiles`) and not the pool `activeAccount` has narrowed.
3411
+ */
3412
+ _projectProfile(product, engine = DEFAULT_ENGINE) {
3413
+ if (engineOf(engine) !== DEFAULT_ENGINE) return null;
3414
+ const slug = typeof product === 'string' ? product : product?.slug;
3415
+ const pinned = slug ? this.config.projectAccounts?.[slug] : null;
3416
+ if (typeof pinned !== 'string' || !pinned) return null;
3417
+ return this._profiles().includes(pinned) ? pinned : null;
3418
+ }
3419
+
3420
+ /** Whether a session on `profile` is allowed to carry this project's work. */
3421
+ _profileFitsProject(profile, product, engine = DEFAULT_ENGINE) {
3422
+ const pinned = this._projectProfile(product, engine);
3423
+ return !pinned || (profile || 'default') === pinned;
3424
+ }
3425
+
3028
3426
  // THE ACCOUNTS ON ONE ENGINE THAT ARE NOT RESTING. Keyed through
3029
3427
  // `_accountKey`, which is the whole point: both engines call their primary
3030
3428
  // login 'default', and reading one raw name for both would let a quarantined
@@ -3057,7 +3455,7 @@ export class Supervisor {
3057
3455
  // are: one shared counter would have a Codex spawn advance the rotation
3058
3456
  // across the Claude subscriptions, so a run of Codex tasks could leave
3059
3457
  // one of them doing all the Claude work.
3060
- _pickProfile(engine = DEFAULT_ENGINE) {
3458
+ _pickProfile(engine = DEFAULT_ENGINE, { model = null } = {}) {
3061
3459
  const which = engineOf(engine);
3062
3460
  // SOMEWHERE HAS TO BE TRIED. `_liveProfilesFor` is allowed to answer
3063
3461
  // nothing now, which is the honest answer to "what could run" and no answer
@@ -3069,13 +3467,45 @@ export class Supervisor {
3069
3467
  // A CODEX ACCOUNT STILL BEING ADDED IS NOT WHERE WORK GOES. Add account
3070
3468
  // lists the new home before its sign-in finishes, and the round-robin
3071
3469
  // handed it the very first thread while the signed-in one sat idle.
3072
- const signedIn = which === 'codex' ? live.filter((p) => this._codexSignedIn(p)) : live;
3073
- const pool = signedIn.length ? signedIn : live.length ? live : ['default'];
3470
+ const signedIn = live.filter(p => harnessFor(which).signedIn(this, p));
3471
+ let pool = signedIn.length ? signedIn : live.length ? live : ['default'];
3472
+ // AND NOT A LOGIN WHOSE PLAN LACKS THE MODEL (w-f1c00e679d). A weekend's
3473
+ // sprint on gpt-6.1-sol was handed to a free-plan Codex login twice, died
3474
+ // in two seconds both times, and the Pro login sat idle all weekend. When
3475
+ // no login has it, the rotation stands and `codexModelRefusal` says why.
3476
+ const able = pool.filter((p) => !this._planLacksModel(which, p, model));
3477
+ if (able.length) pool = able;
3074
3478
  this._rr = this._rr ?? {};
3075
3479
  this._rr[which] = ((this._rr[which] ?? 0) + 1) % pool.length;
3076
3480
  return pool[this._rr[which]];
3077
3481
  }
3078
3482
 
3483
+ /**
3484
+ * WHETHER ONE LOGIN'S PLAN IS KNOWN NOT TO INCLUDE A MODEL.
3485
+ *
3486
+ * Two facts say so: Codex refused that model on that login this session
3487
+ * (`_modelRefused`, written by `noteExitForBackoff`), or the login's own model
3488
+ * list, which Codex keeps per CODEX_HOME and fills from the plan, does not
3489
+ * carry it. A login with no list cannot be judged and is not ruled out.
3490
+ * Claude Code keeps no per-login list, so it is never ruled out here.
3491
+ */
3492
+ _planLacksModel(engine, profile, model) {
3493
+ const word = typeof model === 'string' ? model.trim() : '';
3494
+ if (!word || engineOf(engine) !== 'codex') return false;
3495
+ if (this._modelRefused?.[this._accountKey('codex', profile)]?.includes(word)) return true;
3496
+ const known = codexKnownSlugs({ home: this._codexProfileHome(profile) });
3497
+ return known.size > 0 && !known.has(word);
3498
+ }
3499
+
3500
+ // Another Codex login the pick could take that has the model: the same pool
3501
+ // `_pickProfile` draws from, less the one that refused.
3502
+ _codexLoginWithModel(model, except) {
3503
+ const live = this._liveProfilesFor('codex');
3504
+ const signedIn = live.filter((p) => this._codexSignedIn(p));
3505
+ return (signedIn.length ? signedIn : live).some((p) => this._accountKey('codex', p) !== except
3506
+ && !this._planLacksModel('codex', p, model));
3507
+ }
3508
+
3079
3509
  /**
3080
3510
  * WHICH ACCOUNT AN EXIT, A STRIKE OR A COOLDOWN IS EVIDENCE ABOUT.
3081
3511
  *
@@ -3090,7 +3520,7 @@ export class Supervisor {
3090
3520
  * second engine existed still names the account it always named.
3091
3521
  */
3092
3522
  _accountKey(engine, profile) {
3093
- return engineOf(engine) === 'codex' ? `codex:${profile}` : profile;
3523
+ return harnessFor(engineOf(engine)).accountKey(profile);
3094
3524
  }
3095
3525
 
3096
3526
  // ACCOUNTS THAT CAN ACTUALLY RUN SOMETHING RIGHT NOW, and unlike
@@ -3105,6 +3535,19 @@ export class Supervisor {
3105
3535
  // `codex:` key. That is the right scope for its two callers -- the fleet-wide
3106
3536
  // brake and the sentence above her list -- because both are about having
3107
3537
  // nowhere left to run, and Claude Code is what every row falls back to.
3538
+ /**
3539
+ * WHETHER A LOGIN OTHER THAN `account`, ON THE TASK'S OWN ENGINE, COULD RUN IT
3540
+ * NOW: not resting, and carrying no trouble of its own (signed out, blocked).
3541
+ * A free Claude login says nothing about a Codex task, and the other way round.
3542
+ */
3543
+ _anotherLoginIsFree(item, account) {
3544
+ const engine = this._engineFor(item);
3545
+ return this._liveProfilesFor(engine).some((p) => {
3546
+ const key = this._accountKey(engine, p);
3547
+ return key !== account && !this._profileTrouble?.[key];
3548
+ });
3549
+ }
3550
+
3108
3551
  _healthyProfiles() {
3109
3552
  const now = Date.now();
3110
3553
  return this._profiles().filter((p) => (this._profileCooldown?.[p] ?? 0) < now && !this._profileTrouble?.[p]);
@@ -3119,7 +3562,10 @@ export class Supervisor {
3119
3562
  // time. A fact about one account now stays on that account.
3120
3563
  _noteProfileTrouble(profile, raw) {
3121
3564
  if (!profile) return 'unknown';
3122
- const cause = troubleCause(raw);
3565
+ // An ended subscription is refused in an admin's words; the login's own
3566
+ // file says which it is (refineCause). Codex keeps no such file.
3567
+ const plan = profile.startsWith('codex:') ? null : readPlan(profile, { home: this.config.home || os.homedir() });
3568
+ const cause = refineCause(troubleCause(raw), plan);
3123
3569
  this._profileTrouble = this._profileTrouble ?? {};
3124
3570
  const had = this._profileTrouble[profile];
3125
3571
  this._profileTrouble[profile] = { since: had?.since ?? Date.now(), at: Date.now(), cause, raw: String(raw ?? '').slice(0, 300) };
@@ -3129,9 +3575,61 @@ export class Supervisor {
3129
3575
  const stamp = signInStamp(this._signInFilesFor(profile));
3130
3576
  this._profileTrouble[profile].stamp = Math.max(stamp, had?.stamp ?? 0);
3131
3577
  }
3578
+ // When the limit says it resets, so `_forgetResetLimits` can let it go then.
3579
+ if (cause === 'at-limit') {
3580
+ const resetsAt = limitResetMoment(raw);
3581
+ if (resetsAt) this._profileTrouble[profile].resetsAt = resetsAt;
3582
+ }
3132
3583
  return cause;
3133
3584
  }
3134
3585
 
3586
+ /**
3587
+ * A LIMIT THAT HAS RESET IS NOT A LIMIT ANY MORE.
3588
+ *
3589
+ * Trouble is cleared by a session surviving on the account, and for a limit
3590
+ * that can never come when she has picked another account to run: nothing
3591
+ * starts on this one again. The Accounts page then said "At its limit" about
3592
+ * a login whose weekly limit had reset two days before. So a limit that named
3593
+ * its reset is believed until that moment and no longer. If it is in fact
3594
+ * still capped, the next run on it dies in seconds and files it again.
3595
+ *
3596
+ * A limit with no reset in it, and anything waiting on a person, is left
3597
+ * alone: the clock says nothing about those.
3598
+ */
3599
+ _forgetResetLimits(now = Date.now()) {
3600
+ let forgot = false;
3601
+ for (const [key, trouble] of Object.entries(this._profileTrouble ?? {})) {
3602
+ if (trouble?.cause !== 'at-limit') continue;
3603
+ // A record saved before `resetsAt` was kept is read from its own words,
3604
+ // at the moment it was last seen.
3605
+ const resetsAt = trouble.resetsAt ?? limitResetMoment(trouble.raw, trouble.at);
3606
+ if (!resetsAt || now < resetsAt + LIMIT_RESET_GRACE_MS) continue;
3607
+ delete this._profileTrouble[key];
3608
+ forgot = true;
3609
+ }
3610
+ if (forgot) this._saveState();
3611
+ return forgot;
3612
+ }
3613
+
3614
+ /**
3615
+ * A SUBSCRIPTION RENEWED IS LET BACK IN. An ended one leaves the rotation at
3616
+ * once, like anything waiting on a person, and nothing would start on it to
3617
+ * prove it works again. Its own file says so: once its billing is no longer
3618
+ * "none", the trouble goes and the next run on it is the proof.
3619
+ */
3620
+ _forgetRenewedSubscriptions() {
3621
+ let forgot = false;
3622
+ const home = this.config.home || os.homedir();
3623
+ for (const [key, trouble] of Object.entries(this._profileTrouble ?? {})) {
3624
+ if (trouble?.cause !== 'subscription-ended') continue;
3625
+ if (readPlan(key, { home }).ended) continue;
3626
+ this._clearProfileTrouble(key);
3627
+ forgot = true;
3628
+ }
3629
+ if (forgot) this._saveState();
3630
+ return forgot;
3631
+ }
3632
+
3135
3633
  _clearProfileTrouble(profile) {
3136
3634
  if (!profile) return;
3137
3635
  if (this._profileTrouble) delete this._profileTrouble[profile];
@@ -3226,7 +3724,28 @@ export class Supervisor {
3226
3724
  _capacityFor(engine) {
3227
3725
  const which = engineOf(engine);
3228
3726
  if (!this.engineChoices().some((e) => e.id === which)) return 0;
3229
- return this._slotsPerAccount(which) * this._liveProfilesFor(which).length;
3727
+ const perLogin = this._slotsPerAccount(which) * this._liveProfilesFor(which).length;
3728
+ // A NUMBER FOR THE WHOLE COMPUTER IS RUN EXACTLY (w-ec27ff1a51). Shared out
3729
+ // a login at a time it rounds up, so 7 on two logins is four and four, and
3730
+ // the computer's number is what stops it at 7 rather than 8.
3731
+ const total = this._machineTotalFor(which);
3732
+ return total === null ? perLogin : Math.min(total, perLogin);
3733
+ }
3734
+
3735
+ /**
3736
+ * THE NUMBER FOR THE WHOLE COMPUTER, or null when the number is still a
3737
+ * per-login one (a Claude plan that allows one at a time, or a config
3738
+ * written before `agentsAtOnce` existed and never loaded through
3739
+ * main/config.mjs). Automatic's own answer, or the one picked on the menu.
3740
+ */
3741
+ _machineTotalFor(engine = DEFAULT_ENGINE) {
3742
+ const planSpokeForClaude = engineOf(engine) !== DEFAULT_ENGINE && !!this.config.planSlotsFrom;
3743
+ if (this.config.agentsAuto && !planSpokeForClaude) {
3744
+ return autoAgents({ ...this._machine(), gated: !!this.config.memoryGate, nudge: this.config.agentsNudge });
3745
+ }
3746
+ const picked = Number(this.config.agentsAtOnce);
3747
+ if (!this.config.agentsAuto && Number.isFinite(picked) && picked >= 1) return Math.floor(picked);
3748
+ return null;
3230
3749
  }
3231
3750
 
3232
3751
  /**
@@ -3293,14 +3812,14 @@ export class Supervisor {
3293
3812
  // A SMALL PLAN STILL WINS. `slotsForPlans` answers one at a time for a
3294
3813
  // subscription that is not Max; that is a fact about the subscription and
3295
3814
  // not about this Mac, so Automatic may only ever come out at or under it.
3296
- if (this.config.agentsAuto && !planSpokeForClaude) {
3297
- const total = autoAgents({
3298
- ...this._machine(),
3299
- gated: !!this.config.memoryGate,
3300
- nudge: this.config.agentsNudge,
3301
- });
3302
- const per = perAccountAgents(total, this._liveProfilesFor(engineOf(engine)).length);
3303
- const plan = this.config.planSlotsFrom ? Number(this.config.maxConcurrentSessions) : null;
3815
+ //
3816
+ // AND THE NUMBER PICKED ON THE MENU IS THE COMPUTER'S TOO (w-ec27ff1a51),
3817
+ // shared out the same way. A login's share rounds UP here, so an odd number
3818
+ // is not rounded away; `_capacityFor` holds the total to the number itself.
3819
+ const total = this._machineTotalFor(engine);
3820
+ if (total !== null) {
3821
+ const per = Math.ceil(total / Math.max(1, this._liveProfilesFor(engineOf(engine)).length));
3822
+ const plan = this.config.agentsAuto && this.config.planSlotsFrom ? Number(this.config.maxConcurrentSessions) : null;
3304
3823
  return Number.isFinite(plan) && plan >= 1 ? Math.min(Math.floor(plan), per) : per;
3305
3824
  }
3306
3825
  const set = Number(planSpokeForClaude ? DEFAULT_SESSIONS_AT_ONCE : this.config.maxConcurrentSessions);
@@ -3335,6 +3854,33 @@ export class Supervisor {
3335
3854
  return n;
3336
3855
  }
3337
3856
 
3857
+ /**
3858
+ * HOW MANY SESSIONS ONE ACCOUNT IS ALREADY CARRYING. `_loadFor` above is the
3859
+ * same count for a whole engine, and a tied project needs the narrower one:
3860
+ * the engine's cap is the per-account number times the live accounts, so with
3861
+ * two logins a project tied to one of them would be let through at twice its
3862
+ * account's share and spend one subscription's rate limit on both halves.
3863
+ *
3864
+ * A ROW STILL BEING PREPARED COUNTS, the way it does in `_loadFor`, but only
3865
+ * when its own project is tied to this same account: that is the one case
3866
+ * where the account is already decided before the spawn. An untied row's
3867
+ * account is picked at the spawn and counting it here would charge it to a
3868
+ * login it may never reach.
3869
+ */
3870
+ _loadOnProfile(profile, engine = DEFAULT_ENGINE) {
3871
+ const key = this._accountKey(engine, profile);
3872
+ let n = 0;
3873
+ for (const s of this.sessions.values()) {
3874
+ if (s.command || s.remoteIdle) continue;
3875
+ if (this._accountKey(s.engine, s.profile || 'default') === key) n += 1;
3876
+ }
3877
+ for (const p of this._preparing?.values() ?? []) {
3878
+ const pin = this._projectProfile(p.item?.product, p.engine);
3879
+ if (pin && this._accountKey(p.engine, pin) === key) n += 1;
3880
+ }
3881
+ return n;
3882
+ }
3883
+
3338
3884
  // IS THERE ROOM FOR ONE MORE SESSION ON THIS ENGINE?
3339
3885
  //
3340
3886
  // THREE QUESTIONS, AND THE SECOND ONE WAS DELETED AS UNREACHABLE WHEN IT IS
@@ -3365,6 +3911,67 @@ export class Supervisor {
3365
3911
  return this._loadFor(which) < this._capacityFor(which) && this._load() < this._capacity();
3366
3912
  }
3367
3913
 
3914
+ /**
3915
+ * MAY ONE MORE AGENT START, AS FAR AS MEMORY GOES (w-ec27ff1a51). The rule
3916
+ * and every way out of it are in main/start-hold.mjs; this only gathers the
3917
+ * facts and remembers when the current hold began. `item` is the row that
3918
+ * would start (null for a digest), `asked` is a row she resumed by name.
3919
+ */
3920
+ _memoryLetsStart(item, { asked = false } = {}) {
3921
+ const now = Date.now();
3922
+ const swap = !!item && [...this._preempted.values()].some((p) => p.forItem === item.id);
3923
+ const d = startDecision({
3924
+ on: !!this.config.memoryGate,
3925
+ reading: this._memReading,
3926
+ now,
3927
+ running: this._load(),
3928
+ urgent: !!item && (isUrgent(item) || this._runNow.has(item.id)),
3929
+ asked,
3930
+ swap,
3931
+ lastStartAt: this._lastStartAt ?? 0,
3932
+ heldSince: this._memHeldSince ?? null,
3933
+ });
3934
+ if (d.start) {
3935
+ if (d.why === 'off' || d.why === 'normal' || d.why === 'no-reading') this._memHeldSince = null;
3936
+ // The one let through after ten minutes restarts the wait from now, even
3937
+ // if its spawn takes a while to register a session.
3938
+ if (d.why === 'overdue') this._lastStartAt = now;
3939
+ } else {
3940
+ this._memHeldSince ??= now;
3941
+ this._memHeldAt = now;
3942
+ this._memHeldWhy = d.why;
3943
+ }
3944
+ return d.start;
3945
+ }
3946
+
3947
+ /** What the Running page says about the hold: whether a start was held in
3948
+ * the last pass, how short memory is, and since when. */
3949
+ startHoldStatus() {
3950
+ const holding = !!this._memHeldSince && Date.now() - (this._memHeldAt ?? 0) < 2 * POLL_MS;
3951
+ return { holding, pressure: holding ? this._memHeldWhy : null, since: holding ? this._memHeldSince : null };
3952
+ }
3953
+
3954
+ /** The kernel's memory pressure, asked at most every ten seconds and never
3955
+ * awaited by the pass. A read that never answers is given up on after
3956
+ * thirty, and its silence ages the last reading out (STALE_MS), which
3957
+ * lifts the hold rather than freezing it. */
3958
+ _refreshMemoryReading() {
3959
+ if (!this.config.memoryGate) return;
3960
+ const now = Date.now();
3961
+ if (this._memReading && now - this._memReading.at < 10_000) return;
3962
+ if (this._memReadingSince && now - this._memReadingSince < 30_000) return;
3963
+ this._memReadingSince = now;
3964
+ const asked = now;
3965
+ Promise.resolve().then(() => this._readPressure()).then((r) => {
3966
+ if (this._memReadingSince !== asked) return;
3967
+ this._memReading = { level: r?.level ?? null, freePct: r?.freePct ?? null, at: Date.now() };
3968
+ }, () => {}).finally(() => {
3969
+ if (this._memReadingSince === asked) this._memReadingSince = null;
3970
+ });
3971
+ }
3972
+
3973
+ _readPressure() { return readMacPressure(); }
3974
+
3368
3975
  /** The fleet-wide brake, as a clock rather than a state. */
3369
3976
  _spawnBraked() {
3370
3977
  return !!this._spawnCooldownUntil && Date.now() < this._spawnCooldownUntil;
@@ -3474,16 +4081,42 @@ export class Supervisor {
3474
4081
  // FAST_EXIT_MS. Past it, the branch below CLEARED the account's trouble:
3475
4082
  // no bench, no "Signed out" on the row, and her reply was charged one of
3476
4083
  // its three tries. The error result names its cause whatever the length.
4084
+ const lastFailure = (session.tail ?? []).filter((l) => l && !l.startsWith('session exited') && !l.startsWith('fast exit')).slice(-1)[0];
3477
4085
  const refusedBy = session.resultIsError ? troubleCause(session.result) : null;
4086
+ // THE LOGIN'S PLAN DOES NOT INCLUDE THE MODEL (w-f1c00e679d). Nothing is
4087
+ // wrong with the login, which still runs every other model, so it is not
4088
+ // struck, benched or filed as trouble. The model is struck off it instead,
4089
+ // and when another login has the model the death is the account's, not the
4090
+ // row's: the row wakes at once and the pick takes it there.
4091
+ const planRefused = refusedBy === 'model-not-on-plan' || troubleCause(lastFailure) === 'model-not-on-plan';
4092
+ if (planRefused && engine === 'codex') {
4093
+ const account = this._accountKey(engine, session.profile);
4094
+ const said = `${session.result ?? ''}\n${lastFailure ?? ''}`;
4095
+ const model = session.model || /'([^']+)' model is not supported/i.exec(said)?.[1] || /on "([^"]+)"/.exec(said)?.[1] || null;
4096
+ if (model) {
4097
+ this._modelRefused = this._modelRefused ?? {};
4098
+ const struck = this._modelRefused[account] ?? [];
4099
+ if (!struck.includes(model)) this._modelRefused[account] = [...struck, model];
4100
+ if (this._codexLoginWithModel(model, account)) session.accountFault = { cause: 'model-not-on-plan', account };
4101
+ }
4102
+ this._saveState();
4103
+ return;
4104
+ }
3478
4105
  const limitHit = refusedBy === 'at-limit' || needsHerHands(refusedBy);
3479
- if ((Date.now() - session.startedAt < FAST_EXIT_MS && !quickButReal) || limitHit) {
4106
+ // A dropped provider connection is a transport failure too, even when
4107
+ // the local process and its pipe stayed up. It must not spend a reply's
4108
+ // attempts or clear the account's backoff just because it lasted 45s.
4109
+ const connectionLost = refusedBy === 'interrupted'
4110
+ || (session.result == null && troubleCause(lastFailure) === 'interrupted');
4111
+ if (connectionLost) session.transportFault = true;
4112
+ if ((Date.now() - session.startedAt < FAST_EXIT_MS && !quickButReal) || limitHit || connectionLost) {
3480
4113
  // The last words of a fast-dead session are the diagnosis. They are kept,
3481
4114
  // but as EVIDENCE now, not as copy: shared/spawn-trouble.mjs turns them
3482
4115
  // into a cause, and only our own sentence for that cause ever reaches a
3483
4116
  // screen.
3484
- const lastWords = limitHit
4117
+ const lastWords = (limitHit || connectionLost) && session.resultIsError && session.result != null
3485
4118
  ? String(session.result)
3486
- : (session.tail ?? []).filter((l) => l && !l.startsWith('session exited') && !l.startsWith('fast exit')).slice(-1)[0];
4119
+ : lastFailure;
3487
4120
  const raw = lastWords ? lastWords.replace(/^stderr: /, '').slice(0, 300) : '';
3488
4121
  const account = this._accountKey(engine, session.profile);
3489
4122
  const cause = this._noteProfileTrouble(account, raw);
@@ -3573,9 +4206,18 @@ export class Supervisor {
3573
4206
  try {
3574
4207
  fs.writeFileSync(this._stateFile, JSON.stringify({
3575
4208
  handledAnswers: [...this._handledAnswers].slice(-500),
4209
+ resumedWaits: [...this._resumedWaits].slice(-500),
4210
+ waitsSince: this._waitsSince,
4211
+ // The mark was made before setup, not before an agent actually ran.
4212
+ // Persist the distinction for a quit that never reaches stop().
4213
+ preparingAnswers: [...(this._preparing?.values() ?? [])]
4214
+ .filter(({ item, opts }) => opts?.continuation && item?.answer)
4215
+ .map(({ item }) => this._answerKey(item)),
3576
4216
  heardAnswers: Object.fromEntries(Object.entries(this._heardAnswers ?? {}).slice(-500)),
3577
4217
  productOrder: this.productOrder,
3578
4218
  hiddenProducts: [...this.hiddenProducts],
4219
+ threadPlaces: this.threadPlaces,
4220
+ threadPins: this.threadPins,
3579
4221
  liveSessions: this._liveSessions,
3580
4222
  rowSessions: this._rowSessions,
3581
4223
  compactions: this._compactions,
@@ -3726,12 +4368,30 @@ export class Supervisor {
3726
4368
  signInNeeded = this._waitingOnSignIn(this.store.listItems(Date.now())
3727
4369
  .filter((i) => mayRunHere(i, productBySlug.get(i.product), me)));
3728
4370
  } catch {}
4371
+ let atLimit = {};
4372
+ try {
4373
+ const productBySlug = new Map((this.store.listProducts?.() ?? []).map((p) => [p.slug, p]));
4374
+ const me = process.env.AGENTBOX_PERSON_ID;
4375
+ atLimit = this._waitingOnLimit(this.store.listItems(Date.now())
4376
+ .filter((i) => mayRunHere(i, productBySlug.get(i.product), me)));
4377
+ } catch {}
4378
+ // ROWS THE SHIP QUEUE STILL OWES A SHIP (w-0c1ba766eb). Waiting on the
4379
+ // app and not on a person, so the window keeps them in In progress until
4380
+ // they have shipped or gone back to their agent.
4381
+ let shipping = [];
4382
+ try { shipping = this.shipQueue.waitingIds(this.store.listItems(Date.now()), this.store.listProducts?.() ?? []); } catch {}
3729
4383
  return {
3730
4384
  paused: this.paused,
4385
+ shipping,
3731
4386
  // ROWS NOTHING CAN START BECAUSE THEIR TOOL IS SIGNED OUT, by id, with the
3732
4387
  // tool's name. The row says so instead of "Queued", which promised an
3733
4388
  // agent "as soon as one is free" while none could be (2026-10-04).
3734
4389
  signInNeeded,
4390
+ // ROWS NOTHING CAN START BECAUSE EVERY LOGIN OF THEIR TOOL IS OUT OF
4391
+ // USAGE, by id, with the tool's name and when it resets. Its sibling
4392
+ // above, for the same reason: "Queued" over a Codex task while Codex
4393
+ // had no slot until the reset (found by the pre-deploy breaker).
4394
+ atLimit,
3735
4395
  running: [...this.sessions.values()].filter(s => !s.remoteIdle).map((s) => ({
3736
4396
  itemId: s.itemId,
3737
4397
  product: s.product,
@@ -3957,6 +4617,36 @@ export class Supervisor {
3957
4617
  return out;
3958
4618
  }
3959
4619
 
4620
+ /**
4621
+ * Open rows held because EVERY login of their tool is resting on a usage
4622
+ * limit, id -> { tool, until }. `_capacityFor` counts only logins that are
4623
+ * not resting, so such a tool has no slots and nothing starts until the
4624
+ * earliest reset; `until` is that moment, or 0 when any login did not say.
4625
+ */
4626
+ _waitingOnLimit(rows) {
4627
+ const out = {};
4628
+ const held = new Map();
4629
+ const heldUntil = (engine) => {
4630
+ if (!held.has(engine)) {
4631
+ const profiles = this._profilesFor(engine);
4632
+ const keys = profiles.map((p) => this._accountKey(engine, p));
4633
+ const allOut = profiles.length > 0 && !this._liveProfilesFor(engine).length
4634
+ && keys.every((k) => this._profileTrouble?.[k]?.cause === 'at-limit');
4635
+ // The reset the tool printed, never the app's own retry clock: that is
4636
+ // when it tries again, which is not a time the limit promised.
4637
+ held.set(engine, allOut ? Math.min(...keys.map((k) => this._profileTrouble[k].resetsAt ?? 0)) : null);
4638
+ }
4639
+ return held.get(engine);
4640
+ };
4641
+ for (const i of rows ?? []) {
4642
+ if (i.status !== 'open' || this.sessions.has(i.id)) continue;
4643
+ const engine = engineOf(this._engineFor(i));
4644
+ const until = heldUntil(engine);
4645
+ if (until !== null) out[i.id] = { tool: engineLabel(engine), until };
4646
+ }
4647
+ return out;
4648
+ }
4649
+
3960
4650
  _spawnTrouble(queued) {
3961
4651
  const since = this._fleetTroubleSince ?? 0;
3962
4652
  if (!since) return null;
@@ -3999,10 +4689,13 @@ export class Supervisor {
3999
4689
 
4000
4690
  async _tick() {
4001
4691
  if (this.paused) return;
4692
+ this._refreshMemoryReading();
4002
4693
  if (this.firstRunHolding()) return;
4003
4694
  // Has a login landed since an account was found signed out? Ahead of the
4004
4695
  // brake below, which would otherwise hold the fleet past the sign-in.
4005
4696
  try { this._noticeSignIns(); } catch (e) { console.warn('zero: could not look for a sign-in:', e.message); }
4697
+ try { this._forgetResetLimits(); } catch (e) { console.warn('zero: could not let a reset limit go:', e.message); }
4698
+ try { this._forgetRenewedSubscriptions(); } catch (e) { console.warn('zero: could not let a renewed subscription back in:', e.message); }
4006
4699
  // A COOLDOWN STOPS THE SPAWNING AND IT MUST NOT STOP THE TELLING. The brake
4007
4700
  // goes on when there is no healthy account left anywhere, for up to half an
4008
4701
  // hour, which is precisely the half hour her tasks are all stuck and the
@@ -4065,27 +4758,14 @@ export class Supervisor {
4065
4758
  try { this.recoverInterrupted('capacity'); } catch (e) { console.warn('zero: recovery:', e.message); }
4066
4759
  }
4067
4760
 
4761
+ // Threads whose wait has just cleared go into the same queue (w-5244c90c33).
4762
+ try { this.resumeFinishedWaits(items); } catch (e) { console.warn('zero: could not resume a finished wait:', e.message); }
4763
+
4068
4764
  // Rows she resumed by name that had no slot at the time. First call on the
4069
4765
  // next free slot, ahead of everything: she asked for these out loud, and a
4070
4766
  // resume that quietly never happens is the failure this whole path exists
4071
4767
  // to end.
4072
- for (const id of [...this._resumeQueue]) {
4073
- const item = items.find((i) => i.id === id);
4074
- // A row that has gone or is already running is not waiting for a slot; it
4075
- // leaves the queue whatever capacity says.
4076
- if (!item || this.sessions.has(id)) {
4077
- this._resumeQueue.delete(id);
4078
- continue;
4079
- }
4080
- // NO SLOT ON ITS ENGINE MEANS IT KEEPS ITS PLACE, and the scan goes on to
4081
- // the next one, which may be on the engine that is idle. This used to
4082
- // break out of the loop, which was the same starvation the main queue's
4083
- // `spent` note describes: she asked for these out loud, so a resume that
4084
- // waits behind the OTHER subscription is a resume that never happens.
4085
- if (!this._hasSlotFor(this._engineFor(item))) continue;
4086
- this._resumeQueue.delete(id);
4087
- this._spawnResume(item);
4088
- }
4768
+ this._drainResumeQueue(items);
4089
4769
 
4090
4770
  // ONE QUEUE, ONE ORDER. Everything spawnable this tick is scored by the
4091
4771
  // same rule and the slots go to the top of that one list.
@@ -4348,7 +5028,9 @@ export class Supervisor {
4348
5028
  // reading `item.engine` here instead would be a second one that could
4349
5029
  // disagree with it.
4350
5030
  const engine = this._engineFor(item);
4351
- if (!this._hasSlotFor(engine)) {
5031
+ // MEMORY SHORT READS AS NO SLOT (main/start-hold.mjs), so an Urgent row
5032
+ // still takes a lesser session's slot below: a swap, which adds nothing.
5033
+ if (!this._hasSlotFor(engine) || !this._memoryLetsStart(item)) {
4352
5034
  // an Urgent row takes a slot rather than queueing behind work that
4353
5035
  // matters less. At most ONE session is interrupted per engine per
4354
5036
  // tick, and the urgent row spawns on the next one — the child has to
@@ -4399,7 +5081,7 @@ export class Supervisor {
4399
5081
  // A DIGEST RUNS ON THIS MAC'S HOME ENGINE: Claude Code, or Codex on a Mac
4400
5082
  // with nothing else. It carries a synthetic row with no engine on it, so
4401
5083
  // the slot it checks and the engine `spawnDigest` forces are one answer.
4402
- if (!this._hasSlotFor(this._homeEngine())) break;
5084
+ if (!this._hasSlotFor(this._homeEngine()) || !this._memoryLetsStart(null)) break;
4403
5085
  const owed = this._digestOwed(items, product.slug, now);
4404
5086
  if (!owed) continue;
4405
5087
  this.lastDigestTry[product.slug] = now;
@@ -4624,7 +5306,14 @@ export class Supervisor {
4624
5306
  // round robin lands on is an id that home has never heard of, the CLI
4625
5307
  // prints "No conversation found" and dies in a second. So this drops both,
4626
5308
  // and the thread is briefed fresh instead.
4627
- const strandedThread = !!walkedHome && this._profileCannotHoldAChat(walkedHome, engine);
5309
+ //
5310
+ // IN A TIED PROJECT THE QUESTION IS NOT HEALTH BUT WHOSE ACCOUNT IT IS, the
5311
+ // same swap `rowSessionFor` makes above and for the same reason: a thread
5312
+ // found on the wrong login is stranded whatever that login's health, and a
5313
+ // thread on the right one is kept however poorly it is faring.
5314
+ const strandedThread = !!walkedHome && (this._projectProfile(product?.slug ?? item.product, engine)
5315
+ ? !this._profileFitsProject(walkedHome, product?.slug ?? item.product, engine)
5316
+ : this._profileCannotHoldAChat(walkedHome, engine));
4628
5317
  const resumeId = strandedThread ? null : resumeIdOnDisk;
4629
5318
  // The fork's account is the source conversation's account, for the reason
4630
5319
  // every line above gives: a resume is only a resume on the login whose home
@@ -4724,8 +5413,8 @@ export class Supervisor {
4724
5413
  * `assistant` text event carrying the table and then result/success, so the
4725
5414
  * trace this app already
4726
5415
  * keeps puts it in the thread with nothing new written to show it. */
4727
- const review = continuation && engine === 'codex' && providerCommand(item.answer, engine)?.name === 'review' ? providerCommand(item.answer, engine) : null;
4728
- const command = continuation && engine === DEFAULT_ENGINE ? commandPrompt(item.answer, this._nativeCommands?.[item.id]) : review ? item.answer.trim() : null;
5416
+ const review = continuation && harnessFor(engine).capabilities.nativeReview && providerCommand(item.answer, engine)?.name === 'review' ? providerCommand(item.answer, engine) : null;
5417
+ const command = continuation && harnessFor(engine).capabilities.nativeCommands ? commandPrompt(item.answer, this._nativeCommands?.[item.id]) : review ? item.answer.trim() : null;
4729
5418
  if (command) prompt = command;
4730
5419
  // WHICH MODEL. A model named on the task wins; absent that, the workspace
4731
5420
  // setting, which lives in the `--model` flag inside sessionArgs. A PERSONAL
@@ -4757,7 +5446,7 @@ export class Supervisor {
4757
5446
  // `codexEffortRefusal` judges it against the model at `thread/start`, the
4758
5447
  // same place and the same shape as the model refusal.
4759
5448
  const effort = isEffortWord(item?.effort) ? item.effort : null;
4760
- const claudeEffort = engine === DEFAULT_ENGINE && isEffort(effort) ? effort : null;
5449
+ const claudeEffort = harnessFor(engine).capabilities.claudeEffort && isEffort(effort) ? effort : null;
4761
5450
  // Without it the CLI emits ONE `assistant` event per finished message, so
4762
5451
  // a sentence exists nowhere until the model has written the last word of
4763
5452
  // it. Measured over her 1,664 traces on 2026-08-25: the median wait before
@@ -4772,8 +5461,12 @@ export class Supervisor {
4772
5461
  // from the finished `assistant` event only (`traceStreamLine`), so what is
4773
5462
  // kept is whole sentences and never a half-typed one; the pieces live on
4774
5463
  // the session in memory and die with it, which is what `saying` below is.
4775
- const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--include-partial-messages', ...claudeGrants];
4776
- if (claudeEffort) args.push('--effort', claudeEffort);
5464
+ // AND THE LINE ITSELF IS THE ENGINE'S OWN, read off its `run` definition
5465
+ // rather than spelled out here (w-380b06bf53). This method used to name six
5466
+ // of Claude Code's flags, so an engine that speaks the same stream-json and
5467
+ // differs by a binary and one flag value still could not be added without
5468
+ // editing this file. It is built once, below, after the system block it
5469
+ // carries; an engine with no command line is handed none.
4777
5470
  // Her standing instructions, on every session there is: workers, digests,
4778
5471
  // and every resumed turn of a thread that started before the user wrote the
4779
5472
  // rule. This is the ONE place a session's arguments are built, which is why
@@ -4810,12 +5503,13 @@ export class Supervisor {
4810
5503
  // buildBrief: these blocks are markdown files that cannot import it, and
4811
5504
  // her own copies keep the tokens so a rename reaches her edits as well.
4812
5505
  const system = fillName([standing, project, messages, adhd].filter(Boolean).join('\n\n'));
4813
- if (system) args.push('--append-system-prompt', system);
4814
- if (resumeId) args.push('--resume', resumeId);
4815
- // AND THE ONE FLAG THAT MAKES IT A FORK RATHER THAN A CONTINUATION: the
5506
+ // `fork` IS THE FLAG THAT MAKES IT A FORK RATHER THAN A CONTINUATION: the
4816
5507
  // resumed conversation is written on under a NEW id, so the row it came
4817
- // from keeps its own thread untouched (MP-08).
4818
- if (forkFrom && resumeId) args.push('--fork-session');
5508
+ // from keeps its own thread untouched (MP-08). The builder drops it when
5509
+ // there is no resume to fork from, and when the engine cannot fork at all.
5510
+ const args = harnessFor(engine).workerArgs({
5511
+ prompt, grants: claudeGrants, effort: claudeEffort, system, resumeId, fork: !!forkFrom,
5512
+ });
4819
5513
  /*
4820
5514
  * `command` GOES BACK WITH THE ARGUMENTS, because the exit handler has to
4821
5515
  know what this run was. A command run does not write on the row and is
@@ -4854,7 +5548,7 @@ export class Supervisor {
4854
5548
 
4855
5549
  commandCatalog(product, id) {
4856
5550
  const item = this.store.readItem(product, id);
4857
- return item && this._engineFor(item) !== 'codex' ? this._nativeCommands?.[id] ?? [] : [];
5551
+ return item && harnessFor(this._engineFor(item)).capabilities.nativeCommands ? this._nativeCommands?.[id] ?? [] : [];
4858
5552
  }
4859
5553
 
4860
5554
  remoteControl(product, id, action) { return taskRemoteControl(this, product, id, action); }
@@ -4899,7 +5593,9 @@ export class Supervisor {
4899
5593
  return value;
4900
5594
  };
4901
5595
  const item = this.store.readItem(productSlug, id);
4902
- if (!item || this._engineFor(item) !== 'codex') return publish('unavailable');
5596
+ const engine = item ? this._engineFor(item) : null;
5597
+ if (!item || !supportsHarnessOperation(engine, 'compact')) return publish('unavailable');
5598
+ const harness = harnessFor(engine);
4903
5599
  if (this.sessions.has(id)) return publish('busy');
4904
5600
  const product = this.store.listProducts().find(p => p.slug === productSlug);
4905
5601
  if (!product) return publish('missing');
@@ -4910,16 +5606,13 @@ export class Supervisor {
4910
5606
  this._compactionJobs.set(key, { threadId: rec.sessionId, profile: rec.profile });
4911
5607
  const initial = publish('running');
4912
5608
  Promise.resolve().then(async () => {
4913
- const { client, handshake } = this._codexServer(this._codexProfileHome(rec.profile ?? 'default'));
4914
- const mcpServers = await this._codexIsolation(client, handshake);
4915
5609
  const cwd = product.repoPath && fs.existsSync(product.repoPath) ? product.repoPath : product.dir;
4916
5610
  // Same isolated configuration as a worker, with no store tool needed:
4917
5611
  // compaction summarizes the existing thread and never starts an agent turn.
4918
- const params = workerThreadParams({ cwd, mcpServers, storeServer: null });
4919
- return compactCodexThread({ server: client, threadId: rec.sessionId, threadParams: params });
5612
+ return harness.compact(this, rec, cwd);
4920
5613
  }).then(publish, () => publish('failed')).finally(() => {
4921
5614
  this._compactionJobs.delete(key);
4922
- this._releaseIdleCodex(rec.profile ?? 'default');
5615
+ harness.release(this, rec.profile ?? 'default');
4923
5616
  });
4924
5617
  return initial;
4925
5618
  }
@@ -5060,7 +5753,7 @@ export class Supervisor {
5060
5753
  // never ours.
5061
5754
  messageRules() {
5062
5755
  let theirs = '';
5063
- if (this.messageRulesFile() !== this.messageRulesDefaultFile()) {
5756
+ if (!agentInstructionsUnified(this.userDir) && this.messageRulesFile() !== this.messageRulesDefaultFile()) {
5064
5757
  try { theirs = fs.readFileSync(this.messageRulesFile(), 'utf8').trim(); } catch {}
5065
5758
  }
5066
5759
  const ours = this.shippedMessageRules();
@@ -5116,6 +5809,7 @@ export class Supervisor {
5116
5809
  // What the settings box shows: the user's own words and nothing of ours
5117
5810
  // (w-3ec9f07978), so it opens empty until they write something.
5118
5811
  readMessageRules() {
5812
+ if (agentInstructionsUnified(this.userDir)) return this.readStanding();
5119
5813
  try {
5120
5814
  return fs.readFileSync(this.messageRulesFile(), 'utf8');
5121
5815
  } catch (err) {
@@ -5130,6 +5824,7 @@ export class Supervisor {
5130
5824
  // a state on disk that nothing later mistakes for a fresh install and
5131
5825
  // re-seeds.
5132
5826
  writeMessageRules(text) {
5827
+ if (agentInstructionsUnified(this.userDir)) return this.writeStanding(text);
5133
5828
  const file = this.messageRulesFile();
5134
5829
  const tmp = `${file}.tmp-${process.pid}`;
5135
5830
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -5173,11 +5868,7 @@ export class Supervisor {
5173
5868
  // Written whole, through a rename, because the fleet reads this file on
5174
5869
  // every spawn: a half-written save is a session briefed with half a rule.
5175
5870
  writeStanding(text) {
5176
- const file = this.standingFile();
5177
- const tmp = `${file}.tmp-${process.pid}`;
5178
- fs.mkdirSync(path.dirname(file), { recursive: true });
5179
- fs.writeFileSync(tmp, typeof text === 'string' ? text : '', 'utf8');
5180
- fs.renameSync(tmp, file);
5871
+ return writeInstruction(this.userDir, 'rules', typeof text === 'string' ? text : '');
5181
5872
  }
5182
5873
 
5183
5874
  /* ---------------------------- the second engine ------------------------- */
@@ -5373,6 +6064,48 @@ export class Supervisor {
5373
6064
  return { choices, workspace, byItem };
5374
6065
  }
5375
6066
 
6067
+ /**
6068
+ * WHICH SUBSCRIPTION THE FLEET IS SPENDING, for the one line the walk says
6069
+ * about it (w-e217e577e5, 2026-10-07). Null on a Mac where nothing readable is
6070
+ * signed in, and then nothing is said anywhere.
6071
+ *
6072
+ * IT IS ANSWERED HERE FOR THE REASON `engineFacts` IS. Which engine a row runs
6073
+ * on needs the capability gate, and which ACCOUNT it runs on needs
6074
+ * `_narrowToChosen`, the rule that one account picked in Settings is the only
6075
+ * one the fleet uses. A corner that worked either out for itself could name
6076
+ * the subscription this app is deliberately not spending, which is the one
6077
+ * mistake this line must never make: somebody reads it to find out whether any
6078
+ * account of theirs got connected at all.
6079
+ *
6080
+ * THE FIRST OF THE POOL, which is the account the next spawn goes to on a Mac
6081
+ * with nothing picked (`_pickProfile` round-robins from there). Two accounts
6082
+ * of the same plan read the same either way; two different plans and a chosen
6083
+ * account make this exact, which is the case somebody would ask about.
6084
+ */
6085
+ runsOnAccount(engine = this._engineFor(null)) {
6086
+ const home = this.config.home;
6087
+ if (engine === 'codex') {
6088
+ const pool = this._narrowToChosen('codex', this._codexProfiles());
6089
+ return runsOn({ engine, home, codexHome: this._codexProfileHome(pool[0] ?? 'default') });
6090
+ }
6091
+ const pool = this._narrowToChosen('claude', this._profiles());
6092
+ return runsOn({ engine: 'claude', home, claudeProfile: pool[0] ?? 'default' });
6093
+ }
6094
+
6095
+ /**
6096
+ * THE CLAUDE LOGIN WHOSE LIMIT THE USAGE PANEL READS, and the environment to
6097
+ * read it in (main/claude-usage.mjs). The same pick as `runsOnAccount`: the
6098
+ * account she chose, or the first one when she has not chosen. The
6099
+ * environment is a worker's, scrubbed the same way, so the app's own
6100
+ * CLAUDE_CONFIG_DIR or an API key never decides which login answers.
6101
+ */
6102
+ usageAccount() {
6103
+ const pool = this._narrowToChosen('claude', this._profiles());
6104
+ const profile = pool[0] ?? 'default';
6105
+ const env = this._workerEnv('claude');
6106
+ return { profile, env: profile === 'default' ? env : { ...env, CLAUDE_CONFIG_DIR: profile } };
6107
+ }
6108
+
5376
6109
  /**
5377
6110
  * THE ENGINE ON A TASK SHE IS COMPOSING, OR NULL, and the door writes nothing
5378
6111
  * else (`zero:compose` in main/ipc.mjs).
@@ -5557,13 +6290,7 @@ export class Supervisor {
5557
6290
  * second engine reads exactly as it did.
5558
6291
  */
5559
6292
  _workerEnv(engine = DEFAULT_ENGINE) {
5560
- const anthropic = (k) => /^ANTHROPIC_/.test(k) || /^CLAUDE_CODE_/.test(k)
5561
- || k === 'CLAUDECODE' || k === 'CLAUDE_PID' || k === 'CLAUDE_EFFORT' || k === 'CLAUDE_CONFIG_DIR';
5562
- const openai = (k) => /^OPENAI_/.test(k) || /^CODEX_/.test(k);
5563
- const forCodex = engineOf(engine) !== DEFAULT_ENGINE;
5564
- const env = { ...process.env };
5565
- for (const k of Object.keys(env)) if (anthropic(k) || (forCodex && openai(k))) delete env[k];
5566
- return env;
6293
+ return harnessFor(engineOf(engine)).workerEnv(process.env);
5567
6294
  }
5568
6295
 
5569
6296
  /**
@@ -5579,7 +6306,8 @@ export class Supervisor {
5579
6306
  // is recorded under and the same one `transcriptFile` will look for its
5580
6307
  // rollout with. One app-server per home, so a second Codex login is a
5581
6308
  // second process rather than a bigger number on the Settings screen.
5582
- const { client, handshake } = this._codexServer(this._codexProfileHome(profile));
6309
+ const home = this._codexProfileHome(profile);
6310
+ const { client, handshake } = this._codexServer(home);
5583
6311
  const storeServer = this._codexStoreServer(item);
5584
6312
  // THIS WORKER'S OWN CARDS, SO THEY CAN DIE WITH IT.
5585
6313
  //
@@ -5608,7 +6336,7 @@ export class Supervisor {
5608
6336
  // thread already running cannot un-launch them. Read fresh for THIS start
5609
6337
  // rather than once per app-server, and never caught: see `_codexIsolation`.
5610
6338
  ready: this._codexIsolation(client, handshake),
5611
- threadParams: (mcpServers) => this.codexThreadParamsFor(plan, cwd, mcpServers, storeServer),
6339
+ threadParams: (mcpServers) => this.codexThreadParamsFor(plan, cwd, mcpServers, storeServer, home),
5612
6340
  turnParams: this.codexTurnParamsFor(plan),
5613
6341
  resumeThreadId: plan.resumeId,
5614
6342
  // The card, which is the other half of what a worker needs to be able to
@@ -5746,25 +6474,26 @@ export class Supervisor {
5746
6474
  * did, and so her terminal and her fleet follow the same file when she
5747
6475
  * changes it.
5748
6476
  */
5749
- codexThreadParamsFor(plan, cwd, mcpServers, storeServer) {
6477
+ // `home` is the login this thread runs on. Its own list is the one checked:
6478
+ // reading the main login's let a free-plan login be handed gpt-6.1-sol.
6479
+ codexThreadParamsFor(plan, cwd, mcpServers, storeServer, home = this._codexHome()) {
5750
6480
  // ASKED HERE RATHER THAN CARRIED ON THE PLAN. A precomputed field would be
5751
6481
  // a second copy of this decision that a caller can forget to set, and the
5752
6482
  // way it fails is a word Codex cannot read reaching `thread/start` -- which
5753
6483
  // is exactly the bug. The one place that builds the params is the one place
5754
6484
  // that decides.
5755
6485
  const onRow = typeof plan?.model === 'string' ? plan.model.trim() : '';
5756
- const workspace = this._codexWorkspaceModel();
5757
- const word = onRow || workspace || '';
6486
+ const word = this._codexModelWord(plan) ?? '';
5758
6487
  // WHICH SETTING IS BEING REFUSED, because the sentence is the only thing
5759
6488
  // she can act on and it has to send her to the right screen.
5760
- const refusal = this.codexModelRefusal(word, onRow ? 'row' : 'workspace');
6489
+ const refusal = this.codexModelRefusal(word, onRow ? 'row' : 'workspace', home);
5761
6490
  if (refusal) throw new Error(refusal);
5762
6491
  // AND THE LEVEL IS JUDGED HERE TOO, against the model that will actually
5763
6492
  // run, for the same reason and with the same outcome: a level the model
5764
6493
  // does not advertise stops the run before the thread, with the sentence on
5765
6494
  // the pipe `troubleCause` reads, rather than reaching `turn/start` as a
5766
6495
  // protocol error or running at a level she did not pick.
5767
- const level = this.codexEffortRefusal(word, plan?.effort);
6496
+ const level = this.codexEffortRefusal(word, plan?.effort, home);
5768
6497
  if (level) throw new Error(level);
5769
6498
  return workerThreadParams({
5770
6499
  cwd,
@@ -5825,16 +6554,15 @@ export class Supervisor {
5825
6554
  * for that word. We refuse on a fact or not at all; with no list, the word
5826
6555
  * goes to Codex as written and Codex answers for it in its own words.
5827
6556
  */
5828
- codexEffortRefusal(model, effort) {
6557
+ codexEffortRefusal(model, effort, home = this._codexHome()) {
5829
6558
  const level = typeof effort === 'string' ? effort.trim() : '';
5830
6559
  if (!level) return null;
5831
- const home = this._codexHome();
5832
6560
  const slug = (typeof model === 'string' && model.trim()) || this._codexWorkspaceModel() || codexDefaultModel({ home }) || '';
5833
6561
  if (!slug) return null;
5834
6562
  const levels = codexModelLevels(slug, { home });
5835
6563
  if (!levels || levels.includes(level)) return null;
5836
6564
  const offers = levels.length ? `It offers ${levels.join(', ')}.` : 'It advertises no levels at all.';
5837
- 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`;
6565
+ 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`;
5838
6566
  }
5839
6567
 
5840
6568
  /**
@@ -5854,6 +6582,13 @@ export class Supervisor {
5854
6582
  return word || null;
5855
6583
  }
5856
6584
 
6585
+ // The model a Codex run asks for: the row's, else the workspace's, else null
6586
+ // (her config.toml decides). `codexThreadParamsFor` says why in that order.
6587
+ _codexModelWord(plan) {
6588
+ const onRow = typeof plan?.model === 'string' ? plan.model.trim() : '';
6589
+ return onRow || this._codexWorkspaceModel() || null;
6590
+ }
6591
+
5857
6592
  /**
5858
6593
  * WHAT A CODEX WORKER ON THIS PRODUCT MAY DO, resolved in the same order as
5859
6594
  * the Claude Code side: the project's own setting if it has one, else the
@@ -5903,10 +6638,9 @@ export class Supervisor {
5903
6638
  * word that is not on it. Where to go and fix it is the other half of what
5904
6639
  * she can act on.
5905
6640
  */
5906
- codexModelRefusal(model, from = 'row') {
6641
+ codexModelRefusal(model, from = 'row', home = this._codexHome()) {
5907
6642
  const word = typeof model === 'string' ? model.trim() : '';
5908
6643
  if (!word) return null;
5909
- const home = this._codexHome();
5910
6644
  const known = codexKnownSlugs({ home });
5911
6645
  if (!known.size || known.has(word)) return null;
5912
6646
  const offered = codexModels({ home }).map((m) => m.id);
@@ -5914,7 +6648,15 @@ export class Supervisor {
5914
6648
  const asked = from === 'workspace'
5915
6649
  ? `every Codex agent here is set to run on "${word}"`
5916
6650
  : `this row asks to run on "${word}"`;
5917
- 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`;
6651
+ // ANOTHER LOGIN KNOWS IT: then the word is fine and this login's plan is
6652
+ // what lacks it, which is a different fix (another model, or another
6653
+ // login) and a different cause for `troubleCause` (w-f1c00e679d).
6654
+ const elsewhere = this._codexProfiles().some((p) => {
6655
+ const other = this._codexProfileHome(p);
6656
+ return other !== home && codexKnownSlugs({ home: other }).has(word);
6657
+ });
6658
+ if (elsewhere) return `${asked}, which is not a model the plan of the Codex account this run was given includes.${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`;
6659
+ 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`;
5918
6660
  }
5919
6661
 
5920
6662
  /**
@@ -5998,7 +6740,7 @@ export class Supervisor {
5998
6740
  // The socket its threads' commands ask on, and no row: see
5999
6741
  // `codexMemoryGateEnv`. One app-server is every Codex thread of this
6000
6742
  // login, so a row named here would be the wrong row for all but one.
6001
- env: { ...this._workerEnv('codex'), CODEX_HOME: home, ...this.codexMemoryGateEnv() },
6743
+ env: codexLaunchEnv(this.config.codexBin, { ...this._workerEnv('codex'), CODEX_HOME: home, ...this.codexMemoryGateEnv() }),
6002
6744
  stdio: ['pipe', 'pipe', 'pipe'],
6003
6745
  }),
6004
6746
  // WHAT IS LEFT OF THIS LOGIN'S LIMIT, ARRIVING UNBIDDEN. `onNotification`
@@ -6222,9 +6964,10 @@ export class Supervisor {
6222
6964
  this._preparing ??= new Map();
6223
6965
  const opts = { continuation, resumeSessionId, profile: forcedProfile, engine: forcedEngine, remoteOnly, shipFailure };
6224
6966
  const held = this._preparing.get(item.id);
6225
- if (held) { held.item = item; held.opts = opts; return; }
6967
+ if (held) { held.item = item; held.opts = opts; this._saveState(); return; }
6226
6968
  const entry = { item, opts, engine };
6227
6969
  this._preparing.set(item.id, entry);
6970
+ this._saveState();
6228
6971
  const t = setTimeout(() => {
6229
6972
  if (this._preparing?.get(item.id) !== entry) return; // stopped, or the app quit
6230
6973
  this._preparing.delete(item.id);
@@ -6250,11 +6993,37 @@ export class Supervisor {
6250
6993
  return;
6251
6994
  }
6252
6995
  if (!this._hasSlotFor(engine) && !continuation) return;
6996
+ // A TIED PROJECT WAITS RATHER THAN BORROWING A LOGIN, and this is where
6997
+ // "tied" stops being a word on a settings page. The engine's own slot check
6998
+ // above has already passed, and it is a check about the whole engine: with
6999
+ // two logins it says yes while the ONE account this project may use is full.
7000
+ // So the pinned account answers for itself, and when it cannot take the work
7001
+ // nothing starts. Waiting is the promise; running it on the other
7002
+ // subscription is the bug the setting exists to stop.
7003
+ //
7004
+ // A REPLY IS HANDED BACK RATHER THAN DROPPED. A continuation skips the slot
7005
+ // check by design, so without the `redeliverAnswer` here the queue's
7006
+ // delivery mark would stand on a reply no worker ever carried, which records
7007
+ // it as handed over for good.
7008
+ //
7009
+ // AND A FORCED PROFILE LOSES. `forcedProfile` is the wake sweep saying which
7010
+ // account it interrupted; if that is not the pinned one, the session being
7011
+ // resumed lives on a login this project may no longer use, so the id goes
7012
+ // with it and the row is briefed fresh on the right account.
7013
+ const pinned = this._projectProfile(item.product, engine);
7014
+ if (pinned) {
7015
+ if (forcedProfile && forcedProfile !== pinned) { forcedProfile = null; resumeSessionId = null; }
7016
+ const full = !continuation && this._loadOnProfile(pinned, engine) >= this._slotsPerAccount(engine);
7017
+ if (full || this._profileResting(this._accountKey(engine, pinned))) {
7018
+ if (continuation && item.answer) this.redeliverAnswer(item, item.answer);
7019
+ return;
7020
+ }
7021
+ }
6253
7022
  // HER REPLY WAITS FOR AN ACCOUNT THAT CAN CARRY IT. A continuation skips the
6254
7023
  // slot check by design, so with every account on this engine sitting out it
6255
7024
  // would go to one anyway, die in two seconds, and go again next tick. Held
6256
7025
  // here and handed back, it goes out the tick the account returns.
6257
- if (continuation && !this._liveProfilesFor(engine).length) {
7026
+ if (continuation && !pinned && !this._liveProfilesFor(engine).length) {
6258
7027
  if (item.answer) this.redeliverAnswer(item, item.answer);
6259
7028
  return;
6260
7029
  }
@@ -6275,6 +7044,26 @@ export class Supervisor {
6275
7044
  if (this._folderFirst(item, product, engine, { continuation, resumeSessionId, profile: forcedProfile, engine: forcedEngine, remoteOnly })) return;
6276
7045
  if (this._photoFirst(item, product, engine, { continuation, resumeSessionId, profile: forcedProfile, engine: forcedEngine, remoteOnly })) return;
6277
7046
 
7047
+ // THE ROW'S OWN FOLDER, RESOLVED BEFORE ANYTHING IS ALLOCATED FOR THE RUN.
7048
+ // Everything downstream follows this one word: the snapshot the change card
7049
+ // is built from, the built-in terminal, /diff, and the folder the next reply
7050
+ // resumes into. It is asked here, ahead of the plan and the per-run
7051
+ // permission files, because it can now REFUSE: a row promised a folder and
7052
+ // denied one does not run at all, and a refusal after those files are
7053
+ // written leaks a pair of them every time (Codex's review, 2026-10-07).
7054
+ //
7055
+ // `remoteOnly` arrives here too, having skipped both preparatory gates
7056
+ // above, so this is the one place every spawn passes through.
7057
+ let cwd;
7058
+ try { cwd = this.workFolderFor(item, product); }
7059
+ catch (error) {
7060
+ this._couldNotGetAFolder(item, error);
7061
+ if (continuation && item.answer) this.redeliverAnswer(item, item.answer);
7062
+ this.onChange?.();
7063
+ return;
7064
+ }
7065
+ this._gotAFolderAfterAll(item.id);
7066
+
6278
7067
  const plan = this.spawnPlan(item, product, { continuation, resumeSessionId, engine, shipFailure });
6279
7068
  const { args } = plan;
6280
7069
  // ONE RUN, ONE PAIR OF FILES. The MCP config and the settings used to be a
@@ -6286,11 +7075,6 @@ export class Supervisor {
6286
7075
  const spawnFiles = [];
6287
7076
  this.prepareClaudePermissions(plan, product, runId, spawnFiles);
6288
7077
 
6289
- // THE ROW'S OWN FOLDER, and the shared checkout only when it cannot have
6290
- // one. Everything downstream follows this one word: the snapshot the change
6291
- // card is built from, the built-in terminal, /diff, and the folder the next
6292
- // reply resumes into.
6293
- const cwd = this.workFolderFor(item, product);
6294
7078
  // Workers bill to the founder's Claude subscription (the CLI's own OAuth
6295
7079
  // login), never an API key. An ANTHROPIC_API_KEY in the inherited env
6296
7080
  // would silently override that, so strip anything that could redirect
@@ -6305,7 +7089,13 @@ export class Supervisor {
6305
7089
  // the one path that resumes on every reply the user writes, a personal
6306
7090
  // continuation, fell through to the pick and lost the account half of the
6307
7091
  // time. plan.resumeProfile is that same answer for that path.
6308
- const profile = forcedProfile ?? plan.resumeProfile ?? this._pickProfile(engine);
7092
+ //
7093
+ // A TIE OUTRANKS ALL THREE, and it can because of the two lines above: a
7094
+ // `forcedProfile` or a `resumeProfile` that disagreed with the tie has
7095
+ // already had its session id taken off it, so this is not a resume pointed
7096
+ // at the wrong home, it is a fresh brief on the account the project named.
7097
+ const model = engineOf(engine) === 'codex' ? this._codexModelWord(plan) : null;
7098
+ const profile = pinned ?? forcedProfile ?? plan.resumeProfile ?? this._pickProfile(engine, { model });
6309
7099
  // A SECOND ACCOUNT ARRIVES EMPTY, AND THIS IS WHERE IT STOPS BEING EMPTY.
6310
7100
  // Our own Accounts page tells a person to log in with a brand new folder,
6311
7101
  // and Claude Code reads a session's skills, commands and subagents out of
@@ -6320,35 +7110,15 @@ export class Supervisor {
6320
7110
  // account folder to put them in, so running it there would be three
6321
7111
  // symlinks made into a Claude home on behalf of a session that will never
6322
7112
  // open them.
6323
- const tooling = engine === 'codex' ? null : linkAccountTooling(profile);
7113
+ const harness = harnessFor(engine);
7114
+ const tooling = harness.tooling(profile);
6324
7115
  // A RUN IS STARTING ON THIS ROW, said before the worker exists and in the
6325
7116
  // same turn of the event loop as the spawn, so the cleaner can never stop
6326
7117
  // this task's programs between its last check and this agent arriving
6327
7118
  // (main/leftovers.mjs, agreed with Codex).
6328
7119
  this._leftoverCleaner?.starting(item.id);
6329
7120
  const env = this._workerEnv(engine);
6330
- const child = engine === 'codex'
6331
- ? this._spawnCodexWorker(plan, { cwd, item, profile })
6332
- : spawn(this.config.claudeBin, claudeStreamArgs(args), {
6333
- cwd,
6334
- env: {
6335
- ...env,
6336
- STORE_ACCOUNT_ID: this.config.accountId,
6337
- // This app's own store, and the only one there is. Exported under
6338
- // every name this app has had, so a store server or a script written
6339
- // against an older name still finds it.
6340
- ...storeRootEnv(this.config.storeRoot),
6341
- // Which subscription bills this worker. 'default' means the CLI's
6342
- // own home; anything else is a second logged-in profile.
6343
- ...(profile !== 'default' ? { CLAUDE_CONFIG_DIR: profile } : {}),
6344
- // So approval cards can say who is asking and about what.
6345
- ZERO_PRODUCT: item.product,
6346
- ZERO_ITEM: item.id,
6347
- // Where its shell commands ask about memory, when that is on.
6348
- ...this.memoryGateEnv(item),
6349
- },
6350
- stdio: ['pipe', 'pipe', 'pipe'],
6351
- });
7121
+ const child = harness.spawn(this, plan, { cwd, item, profile, env });
6352
7122
 
6353
7123
  // `command` RIDES ON THE SESSION so the cap can leave it out (`_load`).
6354
7124
  // The exit handler already needed the same fact from `plan`; this is that
@@ -6363,6 +7133,8 @@ export class Supervisor {
6363
7133
  const session = {
6364
7134
  child, itemId: item.id, product: item.product, startedAt: Date.now(), tail: [], profile,
6365
7135
  command: !!plan.command, engine,
7136
+ // Which model, so a plan that refuses it strikes that model off this login.
7137
+ ...(model ? { model } : {}),
6366
7138
  // AND THE FOLDER IT IS RUNNING IN, which is now the row's own rather than
6367
7139
  // the product's checkout. Everything that asks a live session where it
6368
7140
  // works reads it from here rather than deriving it from the product a
@@ -6371,13 +7143,14 @@ export class Supervisor {
6371
7143
  spawnFiles,
6372
7144
  };
6373
7145
  this.sessions.set(item.id, session);
7146
+ if (!session.command) this._lastStartAt = session.startedAt;
6374
7147
  // THAT an agent started work, and on which engine; nothing about the task.
6375
7148
  // `count` is set by main.mjs and is absent wherever nothing is sent.
6376
7149
  this.count?.('run_started', { engine });
6377
7150
  // Its worker, so a run left "running" by a crash can later be told ended.
6378
7151
  if (Number.isInteger(child?.pid)) this._leftoverCleaner?.worker(item.id, child.pid);
6379
- if (engine !== 'codex') {
6380
- attachClaudeInput(child);
7152
+ if (harness.capabilities.remoteControl) {
7153
+ harness.attachInput(child);
6381
7154
  if (remoteOnly) { child.holdInput(true); session.remoteIdle = true; session.remoteHeld = true; }
6382
7155
  child.on('input-turn', () => {
6383
7156
  if (!session.remoteHeld) return;
@@ -6528,8 +7301,7 @@ export class Supervisor {
6528
7301
  // the real camelCase/snake_case divergence between app-server and the
6529
7302
  // `codex exec` stream the removed August build parsed, which is exactly the
6530
7303
  // difference a future reader has to be able to see.
6531
- const { capture, stream, summarize, trace: traceOne } = readersFor(engine);
6532
- const activity = engine === 'codex' ? codexActivity : claudeActivity;
7304
+ const { capture, stream, summarize, trace: traceOne, activity } = harness.readers();
6533
7305
  // One frame of a session, whatever a frame is on this engine: a line of
6534
7306
  // stdout, or a method and its params.
6535
7307
  const absorb = (...frame) => {
@@ -6542,7 +7314,7 @@ export class Supervisor {
6542
7314
  this._noteHeard(item, session.sessionId);
6543
7315
  }
6544
7316
  if (activity(session, ...frame)) this.onChange?.();
6545
- if (session.remoteHeld && engine === DEFAULT_ENGINE) {
7317
+ if (session.remoteHeld && harness.capabilities.remoteControl) {
6546
7318
  try {
6547
7319
  const event = JSON.parse(frame[0]);
6548
7320
  if (event.type === 'result') {
@@ -6560,7 +7332,7 @@ export class Supervisor {
6560
7332
  }
6561
7333
  } catch(error) { console.warn('zero: remote turn writeback failed:',error.message); }
6562
7334
  }
6563
- if (engine === DEFAULT_ENGINE) {
7335
+ if (harness.capabilities.nativeCommands) {
6564
7336
  try {
6565
7337
  const names = nativeCommandNames(JSON.parse(frame[0]));
6566
7338
  if (names) { this._nativeCommands ??= {}; this._nativeCommands[item.id] = names; this._saveState(); }
@@ -6607,29 +7379,7 @@ export class Supervisor {
6607
7379
  // A Codex worker has no stdout of ours to read: main/codex-session.mjs
6608
7380
  // emits the server's own notifications rather than faking a pipe. See the
6609
7381
  // note at the top of that file for why.
6610
- if (engine === 'codex') {
6611
- child.on('event', (method, params) => {
6612
- // THE CONVERSATION HALF OF THE ARTIFACT, KEPT AS IT GOES PAST. The
6613
- // other engine's is on disk and read once at the exit; this one is
6614
- // delivered as notifications and is gone the moment they are handled,
6615
- // so a Codex card had only the disk half until this line existed.
6616
- rememberCodexChange(session, method, params);
6617
- absorb(method, params);
6618
- });
6619
- }
6620
- else {
6621
- let buffer = '';
6622
- child.stdout.on('data', (d) => {
6623
- buffer += d.toString();
6624
- let idx;
6625
- while ((idx = buffer.indexOf('\n')) >= 0) {
6626
- const line = buffer.slice(0, idx);
6627
- buffer = buffer.slice(idx + 1);
6628
- if (!line.trim()) continue;
6629
- absorb(line);
6630
- }
6631
- });
6632
- }
7382
+ harness.subscribe(child, session, absorb);
6633
7383
  child.stderr.on('data', (d) => {
6634
7384
  onLine(`stderr: ${String(d).slice(0, 500)}`);
6635
7385
  try { trace?.write(`stderr: ${String(d).slice(0, 1000)}\n`); } catch {}
@@ -6643,10 +7393,9 @@ export class Supervisor {
6643
7393
  // item. Written onto the session because that is what `settleDelivery`
6644
7394
  // reads, and read there rather than here so the decision stays in the one
6645
7395
  // method that owns it. See `onClosed` in main/codex-session.mjs.
6646
- child.on('exit', (code, signal, how) => {
6647
- if (exited) return;
6648
- exited = true;
7396
+ const settleExit = (code, signal, how) => {
6649
7397
  if (how?.transportFault) session.transportFault = true;
7398
+ this.memoryGateTurnEnded(session);
6650
7399
  session.exitFailed = !!(signal || code !== 0 || session.transportFault || session.resultIsError || session.result == null);
6651
7400
  this.count?.('run_finished', runEndedProps(session, Date.now()));
6652
7401
  onLine(`session exited (${code})`);
@@ -6689,9 +7438,7 @@ export class Supervisor {
6689
7438
  // the agent's own sentences beside them. The frames were kept as they
6690
7439
  // went past (`rememberCodexChange`), with the same roots this call
6691
7440
  // uses, so the two halves are read against one set of spellings.
6692
- change: engine === 'codex'
6693
- ? changeFromCodexTurn(session.codexChange ?? [], { roots, since: session.startedAt ?? 0 })
6694
- : null,
7441
+ change: harness.change(session, { roots, since: session.startedAt ?? 0 }),
6695
7442
  docsDir: product.dir,
6696
7443
  itemId: item.id,
6697
7444
  roots,
@@ -6821,9 +7568,31 @@ export class Supervisor {
6821
7568
  if (session.lastLiveReply && session.exitFailed)
6822
7569
  this.redeliverAnswer(session.lastLiveReply, session.lastLiveReply.answer);
6823
7570
  if (!session.stoppedByUs) this.deliverMidflightReply(item, answerAtSpawn);
6824
- if (engine === 'codex') this._releaseIdleCodex(profile);
7571
+ harness.release(this, profile);
6825
7572
  // The item's own status was written by the worker through the MCP; if it
6826
7573
  // died without writing, the lease expires and the next tick re-pulls it.
7574
+ };
7575
+ child.on('exit', (code, signal, how) => {
7576
+ if (exited) return;
7577
+ exited = true;
7578
+ // A WORKER THAT DIED OF A STOP SIGNAL WE DID NOT SEND is judged a moment
7579
+ // later, not at once. Control-C in a terminal reaches the whole process
7580
+ // group, so the worker can die of it before this process has even run
7581
+ // its own handler and marked it stopped by us; judged at once, that
7582
+ // death was a failed run ("it will keep failing the same way until this
7583
+ // is fixed") and the task rested 15 minutes after the app came back
7584
+ // (tests/a-worker-stopped-with-the-app-is-not-a-failed-run). If the app
7585
+ // starts quitting within the grace, the person stopped it, not the task.
7586
+ if (!session.stoppedByUs && !this._quitting && diedOfAStopSignal(code, signal)) {
7587
+ const t = setTimeout(() => {
7588
+ if (this._quitting) session.stoppedByUs = true;
7589
+ settleExit(code, signal, how);
7590
+ }, QUIT_GRACE_MS);
7591
+ t.unref?.();
7592
+ return;
7593
+ }
7594
+ if (this._quitting) session.stoppedByUs = true;
7595
+ settleExit(code, signal, how);
6827
7596
  });
6828
7597
  this.onChange?.();
6829
7598
  }
@@ -6931,9 +7700,7 @@ export class Supervisor {
6931
7700
  // looking for something that does not exist. Codex is handed the
6932
7701
  // picture in the turn instead (`codexTurnParamsFor`), so the honest
6933
7702
  // instruction is that it is already in front of it.
6934
- engine === 'codex'
6935
- ? 'Each one is attached to this message as an image, and is also on disk at the absolute path below.'
6936
- : 'Open each one with the Read tool, at the absolute path below.',
7703
+ harnessFor(engine).imageInstruction,
6937
7704
  '',
6938
7705
  ...pictures.map((f) => `- ${f.abs}`),
6939
7706
  );
@@ -7415,6 +8182,29 @@ export class Supervisor {
7415
8182
  } catch { return null; }
7416
8183
  }
7417
8184
 
8185
+ /**
8186
+ * Is this HER urgent row, for the memory gate (w-713aba0c89)? Read live, so a
8187
+ * row raised to Urgent jumps the queue on its next command. A row that cannot
8188
+ * be read is not urgent.
8189
+ */
8190
+ memoryGateUrgent(product, itemId) {
8191
+ if (!product || !itemId) return false;
8192
+ try {
8193
+ const item = this.store.readItem?.(product, itemId);
8194
+ return item ? isUrgent(item) : false;
8195
+ } catch { return false; }
8196
+ }
8197
+
8198
+ /**
8199
+ * A Codex worker's turn is over, so the gate lets go of every slot its thread
8200
+ * still holds (w-713aba0c89). Codex sends no "after" report for a command that
8201
+ * failed; a Claude worker's grants go with its own process instead.
8202
+ */
8203
+ memoryGateTurnEnded(session) {
8204
+ if (session?.engine !== 'codex' || !session.sessionId) return;
8205
+ try { this._memoryGate?.releaseSession(session.sessionId); } catch { /* the gate is going too */ }
8206
+ }
8207
+
7418
8208
  memoryGateSlots() {
7419
8209
  const n = Number(this.config.memoryGateSlots);
7420
8210
  return Number.isFinite(n) && n >= 1 ? Math.round(n) : autoSlots(os.totalmem());
@@ -7479,6 +8269,7 @@ export class Supervisor {
7479
8269
  gate: { slots: this.memoryGateSlots() },
7480
8270
  scoreFor: (product, itemId) => this.memoryGateScore(product, itemId),
7481
8271
  ownerOf: (sessionId) => this.memoryGateOwner(sessionId),
8272
+ urgentFor: (product, itemId) => this.memoryGateUrgent(product, itemId),
7482
8273
  log: (line) => console.log(`zero: ${line}`),
7483
8274
  });
7484
8275
  this._memoryGate = server;
@@ -7546,7 +8337,7 @@ export class Supervisor {
7546
8337
  // EVERY RUN, not only a fresh one: a conversation resumed after the switch
7547
8338
  // went on, and a chat, never heard it otherwise (Codex's review).
7548
8339
  if (cleaner.enabled) {
7549
- parts.push('Anything you leave running after your turn (a dev server, a preview, a background job) is stopped two hours after you finish, sooner if this Mac runs short of memory. '
8340
+ 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. '
7550
8341
  + 'If the person needs something to keep running, start it with AGENTBOX_KEEP=1 in its environment and say so in your answer. Stop anything else you started before you finish.');
7551
8342
  }
7552
8343
  const stopping = cleaner.stoppingNote(item.id);
@@ -7594,6 +8385,13 @@ export class Supervisor {
7594
8385
  // server denies everything rather than believing a file, which is the
7595
8386
  // right way round. main/approvals.mjs holds the argument.
7596
8387
  ZERO_APPROVALS_PUBKEY: approvalPublicKey(),
8388
+ // THE RUNTIME THAT SERVER IS STARTED ON, which is this app's own
8389
+ // binary. Run from source the launcher used to need node on PATH and
8390
+ // fell back to nvm only, so a Mac with Homebrew node, or none, lost
8391
+ // every approval card and with it every agent (issue 21, 2026-10-07).
8392
+ // Electron run with ELECTRON_RUN_AS_NODE=1 is a node, so handing the
8393
+ // path down asks nothing of the machine.
8394
+ ZERO_APPROVALS_RUNTIME: process.execPath,
7597
8395
  },
7598
8396
  },
7599
8397
  };
@@ -7644,7 +8442,9 @@ export function unpacked(p) {
7644
8442
  // row, and it used to count as a word: a persona test switched a finished
7645
8443
  // thread to Private and the agent ran again on its own, unasked and paid for.
7646
8444
  // Those fields describe the thread; they never ask it anything.
7647
- const NOT_A_WORD = new Set(['visibility', 'priority', 'problem', 'progress', 'solution', 'blockedBy', 'blocks']);
8445
+ // Moving an answer out of the inbox is not asking the agent to answer again.
8446
+ // runAt also covers snoozes written before the inbox reminder had its own field.
8447
+ const NOT_A_WORD = new Set(['visibility', 'priority', 'problem', 'progress', 'solution', 'blockedBy', 'blocks', 'runAt', 'snoozedUntil']);
7648
8448
  function lastFounderWrite(item) {
7649
8449
  let latest = 0;
7650
8450
  for (const [field, w] of Object.entries(item?.wrote ?? {})) {
@@ -7668,212 +8468,6 @@ export function awaitingHer(item) {
7668
8468
  return wrote.ts > lastFounderWrite(item);
7669
8469
  }
7670
8470
 
7671
- // The stream's own metadata, kept on the session object: the session id (what
7672
- // a personal reply resumes) and the final result message (what the founder
7673
- // reads). Recorded for every session because it is one parse we are already
7674
- // paying for; acted on where someone needs it.
7675
- function captureStream(session, line) {
7676
- try {
7677
- const obj = JSON.parse(line);
7678
- if (obj.session_id) session.sessionId = obj.session_id;
7679
- if (obj.type === 'result') {
7680
- session.result = String(obj.result ?? '');
7681
- session.resultIsError = !!obj.is_error;
7682
- // A run cannot end with helpers still out. Cleared here as well as on
7683
- // exit so the number can never outlive the thing it counts. Unless
7684
- // something is still out in the background: then this was a turn
7685
- // ending, not the run, and the session stays open for it.
7686
- if (!session.child?.waitingOn?.().length) session.helperIds?.clear();
7687
- }
7688
- noteClaimAnswer(session, obj);
7689
- countHelpers(session, obj);
7690
- } catch {}
7691
- }
7692
-
7693
- // Whether this run was refused its own row, off the claim call and its reply.
7694
- // `closingMessageIsTheAnswer` reads it: a refused worker is told to end saying
7695
- // nothing, and whatever it does say must not land on a row someone else holds.
7696
- // A later claim that succeeds clears it.
7697
- function noteClaimAnswer(session, obj) {
7698
- const blocks = Array.isArray(obj?.message?.content) ? obj.message.content : [];
7699
- for (const b of blocks) {
7700
- if (obj.type === 'assistant' && b?.type === 'tool_use' && /claim_work_item$/.test(b.name ?? '')
7701
- && (!b.input?.id || b.input.id === session.itemId)) {
7702
- (session.claimCalls ??= new Set()).add(b.id);
7703
- }
7704
- if (obj.type === 'user' && b?.type === 'tool_result' && session.claimCalls?.has(b.tool_use_id)) {
7705
- const text = typeof b.content === 'string' ? b.content
7706
- : (b.content ?? []).map((c) => c?.text ?? '').join('');
7707
- if (/"claimed"\s*:\s*false/.test(text)) session.claimRefused = true;
7708
- else if (/"claimed"\s*:\s*true/.test(text)) session.claimRefused = false;
7709
- }
7710
- }
7711
- }
7712
-
7713
- /**
7714
- * `--include-partial-messages` puts `stream_event` frames on the same stdout
7715
- * the finished messages come down. This keeps the CURRENT text block on the
7716
- * session as it grows, and nothing else: no thinking, no tool arguments, and
7717
- * nothing written to disk. It dies with the process, exactly like `tail`.
7718
- *
7719
- * ONE BLOCK AT A TIME, AND THAT IS THE POINT. A finished message becomes ONE
7720
- * trace line per text block (`traceStreamLine`), so holding exactly one block
7721
- * here means the live string and the traced string are the same string, and
7722
- * the thread can drop the live copy the moment the traced one arrives without
7723
- * guessing (`itemThread`, renderer/src/item-thread.ts).
7724
- *
7725
- * IT IS NOT CLEARED WHEN THE MESSAGE FINISHES, and that is deliberate. The
7726
- * trace is written on one channel and read on another, so clearing it here
7727
- * would blink the sentence off her screen for however long the read takes and
7728
- * then put it back. The renderer suppresses it the moment the thread has it,
7729
- * which is a comparison rather than a race.
7730
- *
7731
- * Returns true when the string moved, which is what earns a push.
7732
- */
7733
- export function streamingText(session, line) {
7734
- try {
7735
- const obj = JSON.parse(line);
7736
- if (obj.type !== 'stream_event') return false;
7737
- const event = obj.event ?? {};
7738
- // A new message. Whatever the last one ended on is the last one's, and the
7739
- // trace has it by now.
7740
- if (event.type === 'message_start') {
7741
- session.saying = '';
7742
- session.sayingAt = 0;
7743
- return true;
7744
- }
7745
- if (event.type === 'content_block_start') {
7746
- // Only a text block resets it. A tool call or a thought starting means
7747
- // this message has moved on from prose, and the block already typed
7748
- // stays on screen until the trace delivers the same words.
7749
- if (event.content_block?.type !== 'text') return false;
7750
- session.saying = '';
7751
- session.sayingAt = Date.now();
7752
- return true;
7753
- }
7754
- if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
7755
- const piece = String(event.delta.text ?? '');
7756
- if (!piece) return false;
7757
- if (!session.sayingAt) session.sayingAt = Date.now();
7758
- const next = (session.saying ?? '') + piece;
7759
- // Past the cap this stops being a sentence she is reading. Dropped
7760
- // rather than cut, because a cut string never matches the traced one and
7761
- // would sit under it on her screen forever.
7762
- session.saying = next.length > SAYING_CAP ? '' : next;
7763
- return true;
7764
- }
7765
- return false;
7766
- } catch {
7767
- return false;
7768
- }
7769
- }
7770
-
7771
- // HOW MANY HELPERS THIS SESSION HAS OUT RIGHT NOW.
7772
- //
7773
- // THE ONLY TWO EVENTS THAT MATTER, and they bracket a helper exactly. Measured
7774
- // on Claude Code 2.1.246 over a real four-helper run (the probe is written up
7775
- // in decisions.md, 08-26): `task_started` carries a fresh `task_id`, and
7776
- // `task_updated` carries a `patch.status` of `completed` for that same id. Four
7777
- // starts, four completions, no id seen twice and none left open. So a set of
7778
- // live ids, and its size is the number.
7779
- //
7780
- // A SET AND NOT A COUNTER, because a counter cannot survive a duplicate: the
7781
- // stream is read line by line off a pipe, and one repeated line would leave the
7782
- // page saying five helpers are working for the rest of the run with nothing
7783
- // able to correct it. Adding an id twice is free.
7784
- //
7785
- // NESTED HELPERS ARE COUNTED THE SAME. `spawn_depth` says how deep a helper
7786
- // sits, and this deliberately ignores it: a helper's helper is still a
7787
- // subagent working on her task.
7788
- //
7789
- // THE LEAD IS NOT IN THIS NUMBER AND NEVER HAS BEEN, which is what the line
7790
- // drawn off it was getting wrong until 2026-08-27. Only a `task_started` id is
7791
- // ever added, and the lead has no `task_started` of its own.It is, so
7792
- // `shortWord` in renderer/src/live-line.ts now says Subagents, unhyphenated on
7793
- // her call of 2026-08-27 and on a count of the spelling in the Claude Code
7794
- // build she runs. If this ever starts counting the lead as well, that word has
7795
- // to move with it. DID THIS RUN LEAVE HER ANYTHING TO READ?
7796
- //
7797
- // The rule that divides the two writers, kept in one named place because
7798
- // getting it wrong is silence, and silence is the failure that matters most:
7799
- //
7800
- // - said nothing at all -> the supervisor speaks for it (sayTheRunDied)
7801
- // - said something wrong -> the supervisor speaks for it, in OUR words
7802
- // - said something real -> it speaks for itself, untouched
7803
- //
7804
- // The middle line is the one that was missing. Both writers used to exclude an
7805
- // error result: `speaksForTheSession` because raw CLI text must never reach her
7806
- // row, and this one because it only looked for a result that was absent. Each
7807
- // was right on its own and together they left the case that matters most with
7808
- // no writer at all.
7809
- export function saidNothingSheCanUse(session) {
7810
- return session?.result == null || !!session.resultIsError;
7811
- }
7812
-
7813
- export function countHelpers(session, obj) {
7814
- if (obj?.type !== 'system') return;
7815
- if (obj.subtype === 'task_started' && obj.task_id) {
7816
- (session.helperIds ??= new Set()).add(obj.task_id);
7817
- } else if (obj.subtype === 'task_updated' && obj.task_id) {
7818
- // Anything that is not still running ends it. The measured value is
7819
- // `completed`, and a helper that fails or is cancelled has equally stopped
7820
- // working, so the test is what it is NOT rather than a list of endings we
7821
- // would have to keep in step with the CLI.
7822
- const status = obj.patch?.status;
7823
- if (status && status !== 'in_progress' && status !== 'running') session.helperIds?.delete(obj.task_id);
7824
- }
7825
- }
7826
-
7827
- // The persisted trace line: fuller than the tail. Tool calls carry their key
7828
- // input (the file written, the command run), because "tool: Bash" answers
7829
- // nothing when the founder asks what a session actually did.
7830
- function traceStreamLine(line) {
7831
- try {
7832
- const obj = JSON.parse(line);
7833
- const at = new Date().toISOString().slice(11, 19);
7834
- const hook = hookFailure(obj);
7835
- if (hook) return `${at} ${hook}`;
7836
- if (obj.type === 'assistant' && obj.message?.content) {
7837
- const parts = [];
7838
- for (const c of obj.message.content) {
7839
- if (c.type === 'text' && c.text?.trim()) parts.push(c.text.trim());
7840
- if (c.type === 'tool_use') {
7841
- const input = c.input ?? {};
7842
- const hint = input.file_path ?? input.path ?? input.command ?? input.title ?? input.description ?? input.id ?? '';
7843
- parts.push(`[${c.name}] ${String(hint).slice(0, 200)}`);
7844
- }
7845
- }
7846
- return parts.length ? parts.map((p) => `${at} ${p}`).join('\n') : null;
7847
- }
7848
- if (obj.type === 'result') {
7849
- const flavor = `${obj.subtype ?? 'ok'}${obj.is_error ? ' ERROR' : ''} · ${obj.num_turns ?? '?'} turns`;
7850
- return `\n${at} == RESULT (${flavor}) ==\n${String(obj.result ?? '').slice(0, 4000)}`;
7851
- }
7852
- return null;
7853
- } catch {
7854
- return null;
7855
- }
7856
- }
7857
-
7858
- // Stream-json lines are verbose; the In Progress tail wants the gist.
7859
- function summarizeStreamLine(line) {
7860
- try {
7861
- const obj = JSON.parse(line);
7862
- const hook = hookFailure(obj);
7863
- if (hook) return hook;
7864
- if (obj.type === 'assistant' && obj.message?.content) {
7865
- const text = obj.message.content.filter((c) => c.type === 'text').map((c) => c.text).join(' ');
7866
- const tools = obj.message.content.filter((c) => c.type === 'tool_use').map((c) => c.name);
7867
- if (tools.length) return `tool: ${tools.join(', ')}`;
7868
- if (text) return text.slice(0, 300);
7869
- }
7870
- if (obj.type === 'result') return `done: ${String(obj.result ?? '').slice(0, 200)}`;
7871
- return null;
7872
- } catch {
7873
- return line.slice(0, 200);
7874
- }
7875
- }
7876
-
7877
8471
  /**
7878
8472
  * THE FOUR READERS OF ONE ENGINE'S OUTPUT, PICKED ONCE PER SESSION.
7879
8473
  *
@@ -7897,17 +8491,6 @@ function summarizeStreamLine(line) {
7897
8491
  * worse than one that reads a stream with the default engine's eyes.
7898
8492
  */
7899
8493
  export function readersFor(engine) {
7900
- return engine === 'codex'
7901
- ? {
7902
- capture: captureCodexEvent,
7903
- stream: codexStreamingText,
7904
- summarize: summarizeCodexEvent,
7905
- trace: traceCodexEvent,
7906
- }
7907
- : {
7908
- capture: captureStream,
7909
- stream: streamingText,
7910
- summarize: summarizeStreamLine,
7911
- trace: traceStreamLine,
7912
- };
8494
+ const { capture, stream, summarize, trace } = harnessFor(engineOf(engine)).readers();
8495
+ return { capture, stream, summarize, trace };
7913
8496
  }