agentbox-app 0.1.12 → 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 (214) hide show
  1. package/main/acp.mjs +296 -0
  2. package/main/agent-homes.mjs +158 -0
  3. package/main/agent-sessions.mjs +57 -20
  4. package/main/agent-updates.mjs +9 -22
  5. package/main/analytics.mjs +30 -0
  6. package/main/claude-bin.mjs +45 -311
  7. package/main/claude-input.mjs +9 -3
  8. package/main/claude-launch-env.mjs +54 -0
  9. package/main/claude-models.mjs +39 -7
  10. package/main/claude-plan.mjs +16 -16
  11. package/main/claude-usage.mjs +82 -25
  12. package/main/codex-bin.mjs +51 -249
  13. package/main/codex-session.mjs +38 -3
  14. package/main/codex-threads.mjs +41 -8
  15. package/main/codex-watch.mjs +67 -3
  16. package/main/config.mjs +26 -4
  17. package/main/corner-tag.mjs +489 -0
  18. package/main/crash-report.mjs +26 -3
  19. package/main/engine-bin.mjs +595 -0
  20. package/main/engine-setup.mjs +4 -15
  21. package/main/feedback.mjs +11 -6
  22. package/main/harnesses/claude-stream.mjs +208 -0
  23. package/main/harnesses/claude.mjs +84 -0
  24. package/main/harnesses/codex.mjs +65 -0
  25. package/main/harnesses/common.mjs +78 -0
  26. package/main/harnesses/instructions-file.mjs +116 -0
  27. package/main/harnesses/opencode.mjs +234 -0
  28. package/main/harnesses.mjs +65 -0
  29. package/main/importable-threads.mjs +38 -0
  30. package/main/ipc.mjs +165 -31
  31. package/main/local-app-frames.mjs +82 -0
  32. package/main/main.mjs +100 -4
  33. package/main/memory-gate-history.mjs +51 -12
  34. package/main/memory-gate-server.mjs +67 -11
  35. package/main/memory-gate.mjs +49 -8
  36. package/main/memory-log.mjs +124 -0
  37. package/main/notify.mjs +47 -4
  38. package/main/opencode-server.mjs +271 -0
  39. package/main/opencode.mjs +287 -0
  40. package/main/phone-link.mjs +551 -0
  41. package/main/relay-address.mjs +28 -0
  42. package/main/relay-client.mjs +246 -0
  43. package/main/row-label.mjs +6 -6
  44. package/main/runs-on.mjs +32 -35
  45. package/main/serve.mjs +113 -9
  46. package/main/settings.mjs +148 -10
  47. package/main/shell-path.mjs +86 -0
  48. package/main/ship-queue.mjs +24 -6
  49. package/main/sign-in-files.mjs +3 -7
  50. package/main/source-updater.mjs +27 -1
  51. package/main/start-hold.mjs +73 -0
  52. package/main/store.mjs +1 -1
  53. package/main/supervisor.mjs +832 -428
  54. package/main/task-commands.mjs +13 -17
  55. package/main/task-folders-offthread.mjs +6 -1
  56. package/main/task-folders.mjs +490 -98
  57. package/main/task-terminals.mjs +37 -7
  58. package/main/updater.mjs +71 -9
  59. package/main/worktree-include.mjs +494 -0
  60. package/package.json +10 -1
  61. package/renderer/dist/assets/abnfDiagram-YUKMZFIV-DPPuMWt-.js +1 -0
  62. package/renderer/dist/assets/arc-HmFfdseH.js +1 -0
  63. package/renderer/dist/assets/architectureDiagram-47P4ROYG-DxJ3zHrH.js +36 -0
  64. package/renderer/dist/assets/blockDiagram-E7TT5TSB-CbTXzOqQ.js +140 -0
  65. package/renderer/dist/assets/c4Diagram-CRA5TL53-X5ItFyLn.js +38 -0
  66. package/renderer/dist/assets/channel-BlT2g9Sz.js +1 -0
  67. package/renderer/dist/assets/chunk-2Q5K7J3B-DC12Ho_L.js +1 -0
  68. package/renderer/dist/assets/chunk-5VM5RSS4-ClVtfPd0.js +15 -0
  69. package/renderer/dist/assets/chunk-7TKQ45FW-DgcOm0HT.js +1 -0
  70. package/renderer/dist/assets/chunk-AW2ZBBNX-C01s4yoF.js +88 -0
  71. package/renderer/dist/assets/chunk-FVRAUYC3-Bj0zxhR4.js +2 -0
  72. package/renderer/dist/assets/chunk-HTAEGDNF-DWDeEhLU.js +1 -0
  73. package/renderer/dist/assets/chunk-JWPE2WC7-Dsqwv3eW.js +1 -0
  74. package/renderer/dist/assets/chunk-KQW6MTUR-CE86ANL2.js +1 -0
  75. package/renderer/dist/assets/chunk-XXDRQBXY-CoNTASfs.js +1 -0
  76. package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-2wprLqtw.js +217 -0
  77. package/renderer/dist/assets/cornerTag-BLrxiDE5.css +1 -0
  78. package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +1 -0
  79. package/renderer/dist/assets/cose-bilkent-JH36ORCC-enqMiYbn.js +1 -0
  80. package/renderer/dist/assets/cynefin-EF2NZ3EQ-DHngLxvQ.js +141 -0
  81. package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-C0rZZIl3.js +62 -0
  82. package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
  83. package/renderer/dist/assets/dagre-W4DXFKR2-DQnaEWO7.js +4 -0
  84. package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  85. package/renderer/dist/assets/diagram-2UJZ2QOL-CS8AlGPx.js +200 -0
  86. package/renderer/dist/assets/diagram-OVF4WLC6-yiDMHIkY.js +24 -0
  87. package/renderer/dist/assets/diagram-PFPMY2P6-XbQKzbBA.js +30 -0
  88. package/renderer/dist/assets/diagram-UMYRVEAY-dhVvlfl_.js +24 -0
  89. package/renderer/dist/assets/diagram-YEKJPTXX-DZHe8SCf.js +41 -0
  90. package/renderer/dist/assets/diagram-ZIFT7M5P-B5h6aXVR.js +3 -0
  91. package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-PR9ScDco.js +1 -0
  92. package/renderer/dist/assets/elk-IJKZMXRS-Cg-yQXPd.js +27 -0
  93. package/renderer/dist/assets/erDiagram-O2IAPWRE-omOrJMxu.js +99 -0
  94. package/renderer/dist/assets/flowDiagram-OXPTDLAJ-aOX2hzvT.js +213 -0
  95. package/renderer/dist/assets/ganttDiagram-R7TSEDQI-B1H3pWyI.js +292 -0
  96. package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-Cvw304HC.js +106 -0
  97. package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
  98. package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-_ZXcNViH.js +2 -0
  99. package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
  100. package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js +70 -0
  101. package/renderer/dist/assets/journeyDiagram-COXZFDF6-fr62QoAt.js +139 -0
  102. package/renderer/dist/assets/kanban-definition-P3RFRI5V-J8zsabCe.js +89 -0
  103. package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
  104. package/renderer/dist/assets/linear-BmNdEtIi.js +1 -0
  105. package/renderer/dist/assets/main-ChNdnUvt.js +1132 -0
  106. package/renderer/dist/assets/main-nj0QR5kg.css +32 -0
  107. package/renderer/dist/assets/mermaid.core-CnYzvFkz.js +314 -0
  108. package/renderer/dist/assets/mindmap-definition-LZFPQGKD-Bfcxc7PM.js +96 -0
  109. package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  110. package/renderer/dist/assets/pegDiagram-BGZESJAR-CwUJOq6X.js +1 -0
  111. package/renderer/dist/assets/pieDiagram-CAPJLFHJ-BrwMmqfC.js +39 -0
  112. package/renderer/dist/assets/product-name-CTKLYNWY.js +40 -0
  113. package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-BN4Stoj-.js +7 -0
  114. package/renderer/dist/assets/railroadDiagram-SM67HX2B-CPZ5bbeR.js +1 -0
  115. package/renderer/dist/assets/requirementDiagram-X7JNWC4A-CgRnimVe.js +84 -0
  116. package/renderer/dist/assets/sankeyDiagram-UM26HJRW-Bt2rej__.js +40 -0
  117. package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js +169 -0
  118. package/renderer/dist/assets/sizeCapture-INFHLROL-usyNyXyh.js +1 -0
  119. package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-CRzUBb0u.js +291 -0
  120. package/renderer/dist/assets/swimlanes-N4OXWK64-C1GJvMS0.js +1 -0
  121. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +8 -0
  122. package/renderer/dist/assets/timeline-definition-YOQAKGHF-caeyPlcc.js +120 -0
  123. package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-BN_SV6PC.js +370 -0
  124. package/renderer/dist/assets/vennDiagram-BLWOH2XV-BLGuaUX-.js +34 -0
  125. package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-Da0cJeNc.js +78 -0
  126. package/renderer/dist/assets/xychartDiagram-TAQBALBS-cGfUffMk.js +7 -0
  127. package/renderer/dist/corner-tag.html +13 -0
  128. package/renderer/dist/emulator/audio-rules.mjs +74 -0
  129. package/renderer/dist/emulator/data/compression/README.md +11 -0
  130. package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
  131. package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
  132. package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
  133. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  134. package/renderer/dist/emulator/data/cores/README.md +22 -0
  135. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  136. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  137. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  138. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  139. package/renderer/dist/emulator/data/cores/package.json +65 -0
  140. package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
  141. package/renderer/dist/emulator/data/emulator.css +1614 -0
  142. package/renderer/dist/emulator/data/loader.js +170 -0
  143. package/renderer/dist/emulator/data/localization/README.md +63 -0
  144. package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
  145. package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
  146. package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
  147. package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
  148. package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
  149. package/renderer/dist/emulator/data/localization/en-US.json +339 -0
  150. package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
  151. package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
  152. package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
  153. package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
  154. package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
  155. package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
  156. package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
  157. package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
  158. package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
  159. package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
  160. package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
  161. package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
  162. package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
  163. package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
  164. package/renderer/dist/emulator/data/src/GameManager.js +464 -0
  165. package/renderer/dist/emulator/data/src/compression.js +142 -0
  166. package/renderer/dist/emulator/data/src/emulator.js +6158 -0
  167. package/renderer/dist/emulator/data/src/gamepad.js +167 -0
  168. package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
  169. package/renderer/dist/emulator/data/src/shaders.js +236 -0
  170. package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
  171. package/renderer/dist/emulator/data/src/storage.js +125 -0
  172. package/renderer/dist/emulator/data/version.json +5 -0
  173. package/renderer/dist/emulator/host.js +297 -0
  174. package/renderer/dist/emulator/index.html +28 -0
  175. package/renderer/dist/emulator/provenance.json +6 -0
  176. package/renderer/dist/index.html +16 -3
  177. package/scripts/approval-server.sh +24 -5
  178. package/scripts/before-publish.mjs +12 -10
  179. package/shared/agent-import.mjs +2 -0
  180. package/shared/agents.d.mts +1 -0
  181. package/shared/bridge-map.mjs +18 -0
  182. package/shared/claude-commands.generated.mjs +6 -4
  183. package/shared/claude-models.generated.mjs +6 -4
  184. package/shared/corner-tag.d.mts +14 -0
  185. package/shared/corner-tag.mjs +141 -0
  186. package/shared/engines.mjs +20 -5
  187. package/shared/harness-definitions.mjs +103 -0
  188. package/shared/notify-rules.d.mts +25 -0
  189. package/shared/notify-rules.mjs +19 -0
  190. package/shared/provider-commands.mjs +10 -2
  191. package/shared/rank.d.mts +2 -0
  192. package/shared/rank.mjs +50 -0
  193. package/shared/relay-address.mjs +6 -0
  194. package/shared/relay-crypto.mjs +165 -0
  195. package/shared/runs-on.d.mts +0 -10
  196. package/shared/runs-on.mjs +25 -49
  197. package/shared/shortcuts.mjs +10 -4
  198. package/shared/spawn-trouble.mjs +57 -6
  199. package/shared/terminal-input.d.mts +1 -0
  200. package/shared/terminal-input.mjs +30 -0
  201. package/shared/terminal-keys.d.mts +1 -0
  202. package/shared/terminal-keys.mjs +16 -0
  203. package/shared/terminal-size.d.mts +4 -0
  204. package/shared/terminal-size.mjs +29 -0
  205. package/shared/usage.d.mts +3 -0
  206. package/shared/usage.mjs +7 -0
  207. package/shared/where-it-runs-from.d.mts +43 -0
  208. package/shared/where-it-runs-from.mjs +155 -0
  209. package/shared/work-items.mjs +25 -1
  210. package/shared/work-lines.mjs +1 -1
  211. package/shared/xterm-invalid-range.d.mts +2 -0
  212. package/shared/xterm-invalid-range.mjs +58 -0
  213. package/renderer/dist/assets/index-ChKsQkRw.js +0 -1169
  214. package/renderer/dist/assets/index-ClBXnq3B.css +0 -32
@@ -1,15 +1,15 @@
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
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
@@ -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,19 +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';
99
104
  import { codexLaunchEnv } from './codex-launch-env.mjs';
100
105
  import { CodexUsage } from './codex-usage.mjs';
101
- import { createCodexWorker, codexTranscriptFile, mcpServerNames, workerThreadParams } from './codex-session.mjs';
106
+ import { createCodexWorker, mcpServerNames, workerThreadParams } from './codex-session.mjs';
102
107
  import { CODEX_DEFAULT_MODE, isCodexMode } from '../shared/codex-modes.mjs';
103
108
  import { createCodexApprovals } from './codex-approvals.mjs';
104
109
  import { approvalPublicKey } from './approvals.mjs';
@@ -108,11 +113,13 @@ import { LEVELS, latestMessage, sortMessage, wantsPriority } from './message-pri
108
113
  import { NAME, Name, envName, nameSlug, isOurSlug } from '../shared/product-name.mjs';
109
114
  import { signInFiles, signInStamp } from './sign-in-files.mjs';
110
115
  import { runsOn } from './runs-on.mjs';
111
- import { MemoryGateServer, defaultSocketPath as memoryGateSocketPath } from './memory-gate-server.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';
112
119
  import { autoSlots, DEFAULTS as MEMORY_GATE } from './memory-gate.mjs';
113
120
  import { LeftoverCleaner } from './leftovers.mjs';
114
121
  import { syncCodexMemoryGate } from './codex-memory-gate.mjs';
115
- import { autoAgents, perAccountAgents } from './machine.mjs';
122
+ import { autoAgents } from './machine.mjs';
116
123
  import { runEndedProps } from './analytics.mjs';
117
124
 
118
125
  const POLL_MS = 15_000;
@@ -225,6 +232,17 @@ const PREEMPTION_HOLD_MS = 15 * 60_000;
225
232
  // hundred and sixty.
226
233
  const FRUITLESS_RESTS_MS = [15 * 60_000, 60 * 60_000, 4 * 3_600_000, 12 * 3_600_000, 24 * 3_600_000];
227
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
+
228
246
  // THE FIRST RUN'S TWO MARKS (w-0b60195a14). The label is on the walk's own
229
247
  // example task and means one thing: no session, ever. It is answered by the app
230
248
  // itself out of the folder she just chose, so a worker picking it up would be a
@@ -308,7 +326,7 @@ export function storeGrantUnderAnyOfOurNames(args, storeServer = STORE_SERVER) {
308
326
  // engine, because of a name nobody recognises is worse than one that treats an
309
327
  // unknown name as the engine she is actually running.
310
328
  function engineOf(engine) {
311
- return engine === 'codex' ? 'codex' : DEFAULT_ENGINE;
329
+ return isEngine(engine) ? engine : DEFAULT_ENGINE;
312
330
  }
313
331
 
314
332
  export class Supervisor {
@@ -366,6 +384,14 @@ export class Supervisor {
366
384
  // Persisted: an in-memory-only set re-spawned a continuation for every
367
385
  // still-open answered item on every app restart.
368
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);
369
395
  // Which conversation already holds each of her words (`_noteHeard`).
370
396
  this._heardAnswers = state.heardAnswers ?? {};
371
397
  // `_personalSessions` used to sit here, itemId -> claude session id for a
@@ -445,8 +471,14 @@ export class Supervisor {
445
471
  // the inbox: this is a filing decision about a list that had grown to
446
472
  // twenty two chips, not an off switch. Pausing is the off switch.
447
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));
448
478
  } catch {
449
479
  this._handledAnswers = new Set();
480
+ this._resumedWaits = new Set();
481
+ this._waitsSince = Date.now();
450
482
  this._heardAnswers = {};
451
483
  this._liveSessions = {};
452
484
  this._rowSessions = {};
@@ -458,6 +490,8 @@ export class Supervisor {
458
490
  this._profileCooldown = {};
459
491
  this.productOrder = [];
460
492
  this.hiddenProducts = new Set();
493
+ this.threadPlaces = {};
494
+ this.threadPins = {};
461
495
  }
462
496
  // WHICH ROWS NOTHING HAS BEEN ABLE TO RUN ON, as of the last tick, for the
463
497
  // line above her list. Deliberately NOT persisted: it is derived from
@@ -585,11 +619,29 @@ export class Supervisor {
585
619
  // the moment their lease lapses and puts a FRESH session on them, which is
586
620
  // the restart she ruled out. The sweep gets there first and hands each row
587
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();
588
626
  try { this.recoverInterrupted('startup'); } catch (e) { console.warn('zero: startup recovery:', e.message); }
589
627
  if (this.config.memoryGate) this.setMemoryGate(true).catch((e) => console.warn('zero: memory gate:', e.message));
590
628
  this.setLeftoverCleanup(!!this.config.cleanupLeftovers).catch((e) => console.warn('zero: leftovers:', e.message));
591
629
  this._timer = setInterval(() => this.tick().catch((e) => console.warn('supervisor:', e.message)), POLL_MS);
592
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?.();
593
645
  }
594
646
 
595
647
  // EVERY KILL OF OURS GOES THROUGH HERE, because the exit that follows cannot
@@ -683,6 +735,7 @@ export class Supervisor {
683
735
 
684
736
  stop() {
685
737
  clearInterval(this._timer);
738
+ clearInterval(this._memoryLogTimer);
686
739
  // The shared `codex app-server` goes with them. An orphaned one keeps every
687
740
  // thread it is holding alive after the supervisor that answers their
688
741
  // approvals has gone, which is the 2026-08-06 orphan incident in the other
@@ -734,10 +787,14 @@ export class Supervisor {
734
787
  // approval cards no new spawn would produce (2026-08-06). Items respawn on
735
788
  // next boot; an orphan just misbehaves invisibly.
736
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;
737
793
  for (const session of this.sessions.values()) this._kill(session);
738
794
  this._closeCodex(`${Name} is quitting`);
739
795
  this.stopMemoryGate();
740
796
  clearInterval(this._leftoverTimer);
797
+ clearInterval(this._memoryLogTimer);
741
798
  }
742
799
 
743
800
  // PUT A WORKER BACK ON THESE ROWS, WHATEVER STATE THEY ARE IN.
@@ -773,9 +830,11 @@ export class Supervisor {
773
830
  const productBySlug = new Map((this.store.listProducts?.() ?? []).map((p) => [p.slug, p]));
774
831
  const me = process.env.AGENTBOX_PERSON_ID || null;
775
832
 
776
- for (const item of this.store.listItems(Date.now())) {
777
- if (!only.has(item.id)) continue;
778
- 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)) {
779
838
  if (!mayRunHere(item, productBySlug.get(item.product), me)) continue;
780
839
  // A row with a worker on it is already resumed. Saying so is the honest
781
840
  // answer; killing the session to restart it would throw away the work in
@@ -790,7 +849,7 @@ export class Supervisor {
790
849
  // rest the fleet earned on it exactly as it outranks the attempt cap.
791
850
  delete this._fruitless[`${item.product}:${item.id}`];
792
851
 
793
- if (this._hasSlotFor(this._engineFor(item))) {
852
+ if (this._hasSlotFor(this._engineFor(item)) && this._memoryLetsStart(item, { asked: true })) {
794
853
  this._spawnResume(item);
795
854
  out.resumed += 1;
796
855
  } else {
@@ -806,6 +865,72 @@ export class Supervisor {
806
865
  return out;
807
866
  }
808
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
+
809
934
  // A resumed row spawns as a continuation when she has words on it that the
810
935
  // worker has not answered: that is what puts "the founder has answered" in
811
936
  // the brief instead of briefing a stranger about a thread she is mid-way
@@ -891,7 +1016,15 @@ export class Supervisor {
891
1016
  let items;
892
1017
  try { items = this.store.listItems(Date.now()); } catch { return out; }
893
1018
  let dirty = false;
894
- 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) {
895
1028
  // A worker is on it. Nothing is stranded and killing it to resume it
896
1029
  // would throw away the work in flight, which is never what resume meant.
897
1030
  // A resume that STUCK also re-arms the announcement: the next time this
@@ -969,7 +1102,7 @@ export class Supervisor {
969
1102
  // nothing again. Only one of the two facts ever changes on its own, so
970
1103
  // only one of them is worth queueing for.
971
1104
  const engine = engineOf(rec.engine);
972
- if (this._engineCanRun(engine) && !this._hasSlotFor(engine)) {
1105
+ if (this._engineCanRun(engine) && !(this._hasSlotFor(engine) && this._memoryLetsStart(item))) {
973
1106
  out.queued += 1;
974
1107
  this._recoverPending = true;
975
1108
  continue;
@@ -1395,6 +1528,41 @@ export class Supervisor {
1395
1528
  } catch (e) { console.warn('zero: could not say the model is for another engine:', e.message); }
1396
1529
  }
1397
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
+
1398
1566
  // WHERE THE SESSION ACTUALLY IS, checked rather than assumed. The CLI keeps
1399
1567
  // one transcript per session under its profile's home, in a directory named
1400
1568
  // after the working directory with every character that is not a letter or a
@@ -1421,24 +1589,7 @@ export class Supervisor {
1421
1589
  // login was searched for in the first and answered null -- and a null here
1422
1590
  // is `resumeStopped` writing the row off as a session that no longer
1423
1591
  // exists.
1424
- if (rec.engine === 'codex') {
1425
- return codexTranscriptFile(rec.sessionId, { home: this._codexProfileHome(rec.profile) });
1426
- }
1427
- const home = rec.profile && rec.profile !== 'default' ? rec.profile : path.join(os.homedir(), '.claude');
1428
- const projects = path.join(home, 'projects');
1429
- const slug = String(rec.cwd ?? '').replace(/[^a-zA-Z0-9]/g, '-');
1430
- const direct = path.join(projects, slug, `${rec.sessionId}.jsonl`);
1431
- if (fs.existsSync(direct)) return direct;
1432
- // The slug rule is the CLI's and it may change under us, so a miss is
1433
- // answered by looking rather than by writing off a session that is plainly
1434
- // sitting on disk.
1435
- try {
1436
- for (const dir of fs.readdirSync(projects)) {
1437
- const candidate = path.join(projects, dir, `${rec.sessionId}.jsonl`);
1438
- if (fs.existsSync(candidate)) return candidate;
1439
- }
1440
- } catch {}
1441
- return null;
1592
+ return harnessFor(engineOf(rec.engine)).transcript(this, rec);
1442
1593
  }
1443
1594
 
1444
1595
  /* `rememberPersonalSession` and `personalSessionFor` were a pair here: one
@@ -1510,15 +1661,35 @@ export class Supervisor {
1510
1661
  */
1511
1662
  workFolderFor(item, product) {
1512
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.
1513
1667
  if (!this._folderRow(item, product)) return base;
1514
1668
  // Already made, off this thread, by the spawn that is asking (`_folderFirst`).
1515
1669
  const made = this._madeFolders?.get(item.id);
1516
1670
  if (made) return made;
1517
- try { return restoreTaskFolder(base, item.id)?.path ?? base; }
1518
- catch (error) {
1519
- console.warn(`zero: ${item.id} is running in the shared checkout:`, error.message);
1520
- return base;
1521
- }
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;
1522
1693
  }
1523
1694
 
1524
1695
  /**
@@ -1634,19 +1805,50 @@ export class Supervisor {
1634
1805
  // now, so the disk no longer lies here, and this answers first anyway
1635
1806
  // because a row somebody is already preparing is never a row to spawn on.
1636
1807
  const held = this._preparing.get(item.id);
1637
- if (held) { held.item = item; held.opts = opts; return true; }
1808
+ if (held) { held.item = item; held.opts = opts; this._saveState(); return true; }
1638
1809
  if (fs.existsSync(folder)) return false;
1639
1810
  const entry = { item, opts, engine };
1640
1811
  this._preparing.set(item.id, entry);
1812
+ this._saveState();
1641
1813
  folderJob('restoreTaskFolder', base, item.id)
1642
- .then((made) => made?.path ?? base, (error) => {
1643
- console.warn(`zero: ${item.id} is running in the shared checkout:`, error.message);
1644
- return base;
1645
- })
1646
- .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 }) => {
1647
1837
  // Stopped, or the app quit, while the folder was being made.
1648
1838
  if (this._preparing.get(item.id) !== entry) return;
1649
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
+ }
1650
1852
  // AND A SESSION THAT ARRIVED WHILE WE WERE BUILDING ALREADY HAS THIS
1651
1853
  // ROW. Starting a second one here is what gave w-4d722ecf92 two workers
1652
1854
  // and had the second one's claim refused. A spawn cannot reach this
@@ -1654,6 +1856,7 @@ export class Supervisor {
1654
1856
  // skips it entirely, so the one place that starts a worker without
1655
1857
  // anybody having just checked asks for itself.
1656
1858
  if (this.sessions.has(item.id)) { this.onChange?.(); return; }
1859
+ this._gotAFolderAfterAll(item.id);
1657
1860
  this._madeFolders.set(item.id, cwd);
1658
1861
  try { this.spawnWorker(entry.item, entry.opts); }
1659
1862
  catch (error) { console.warn(`zero: could not start ${item.id}:`, error.message); }
@@ -1674,7 +1877,7 @@ export class Supervisor {
1674
1877
  this._photos ??= new Map();
1675
1878
  if (this._photos.has(item.id)) return false;
1676
1879
  const held = this._preparing?.get(item.id);
1677
- if (held) { held.item = item; held.opts = opts; return true; }
1880
+ if (held) { held.item = item; held.opts = opts; this._saveState(); return true; }
1678
1881
  let cwd = null;
1679
1882
  try { cwd = this.workFolderFor(item, product); } catch { return false; }
1680
1883
  // Only a folder that is itself a checkout (a task folder carries a `.git`
@@ -1684,6 +1887,7 @@ export class Supervisor {
1684
1887
  this._preparing ??= new Map();
1685
1888
  const entry = { item, opts, engine, madeFolder: this._madeFolders?.get(item.id) };
1686
1889
  this._preparing.set(item.id, entry);
1890
+ this._saveState();
1687
1891
  gitJob('snapshotRepo', cwd)
1688
1892
  .then((photo) => photo ?? null, () => null)
1689
1893
  .then((photo) => {
@@ -1747,6 +1951,11 @@ export class Supervisor {
1747
1951
  if (!product?.repoPath || base !== product.repoPath) return 0;
1748
1952
  const busy = (id) => this.sessions.has(id) || !!this._preparing?.has(id);
1749
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);
1750
1959
  for (const folder of await folderJob('listTaskFolders', base)) {
1751
1960
  // OUR OWN PID IS NOT SOMEBODY ELSE. Every folder is locked by the process
1752
1961
  // that made it, which is this one, so treating any lock as a live holder
@@ -1774,11 +1983,52 @@ export class Supervisor {
1774
1983
  // reading it is an agent's folder deleted while it works (w-4d722ecf92).
1775
1984
  if (item?.claim && !item.claimExpired) continue;
1776
1985
  if (busy(folder.id)) continue;
1777
- 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;
1778
1990
  }
1779
1991
  return put;
1780
1992
  }
1781
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
+
1782
2032
  profileHoldingSession(sessionId, itemId, product) {
1783
2033
  if (!sessionId) return null;
1784
2034
  const remembered = this._rowSessions?.[itemId]?.profile;
@@ -2381,7 +2631,10 @@ export class Supervisor {
2381
2631
  */
2382
2632
  _onlyWeSpokeForAnAccount(item) {
2383
2633
  const seen = this._fruitless[`${item.product}:${item.id}`];
2384
- 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;
2385
2638
  return (item.wrote?.result?.ts ?? 0) <= seen.saidTs;
2386
2639
  }
2387
2640
 
@@ -2391,6 +2644,12 @@ export class Supervisor {
2391
2644
  if (!runs) return 0;
2392
2645
  // Rested because its ACCOUNT refused it: awake as soon as the account is.
2393
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;
2394
2653
  // AN URGENT ROW NEVER CLIMBS THE LADDER. Urgent outranks High, and is
2395
2654
  // even meant to INTERRUPT High and below, so it must never end up waiting
2396
2655
  // behind them.
@@ -2562,6 +2821,7 @@ export class Supervisor {
2562
2821
  cause,
2563
2822
  runs,
2564
2823
  resetsAt,
2824
+ elsewhere: session.accountFault?.cause === 'model-not-on-plan',
2565
2825
  });
2566
2826
  const written = this.store.recordSessionResult(item.product, item.id, { result, status: 'open' });
2567
2827
  // REMEMBER THAT WE SAID IT, keyed on the run we said it about. The pass
@@ -2695,10 +2955,9 @@ export class Supervisor {
2695
2955
  */
2696
2956
  async _askSmall(ask, { product = null } = {}) {
2697
2957
  const home = this._homeEngine();
2698
- const other = home === 'codex' ? DEFAULT_ENGINE : 'codex';
2699
2958
  const engines = this._projectProfile(product)
2700
2959
  ? [DEFAULT_ENGINE]
2701
- : [home, ...(this.engineChoices().some((e) => e.id === other) ? [other] : [])];
2960
+ : [home, ...this.engineChoices().map(e => e.id).filter(id => id !== home)];
2702
2961
  for (const engine of engines) {
2703
2962
  for (const env of this._smallModelEnvs(engine, product)) {
2704
2963
  const answer = await ask({ claudeBin: this.config?.claudeBin, codexBin: this.config?.codexBin, engine, env });
@@ -2722,8 +2981,7 @@ export class Supervisor {
2722
2981
  const pinned = this._projectProfile(product, engine);
2723
2982
  const all = pinned ? [pinned] : this._profilesFor(engine);
2724
2983
  return [...all.filter((p) => !resting(p)), ...all.filter(resting)].map((p) => {
2725
- if (engine === 'codex') return { ...env, CODEX_HOME: this._codexProfileHome(p) };
2726
- return p === 'default' ? env : { ...env, CLAUDE_CONFIG_DIR: p };
2984
+ return harnessFor(engine).profileEnv(env, p, this);
2727
2985
  });
2728
2986
  }
2729
2987
 
@@ -2971,8 +3229,62 @@ export class Supervisor {
2971
3229
  // One product's medium outranks another's urgent when it sits above it.
2972
3230
  // Ties break oldest-first so nothing starves. The item's own number is hers
2973
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).
2974
3234
  _score(item) {
2975
- 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
+ });
2976
3288
  }
2977
3289
 
2978
3290
  /* ----------------------- auth profiles (subscriptions) ------------------ */
@@ -3038,7 +3350,7 @@ export class Supervisor {
3038
3350
  }
3039
3351
 
3040
3352
  _profilesFor(engine) {
3041
- const all = engineOf(engine) === 'codex' ? this._codexProfiles() : this._profiles();
3353
+ const all = harnessFor(engineOf(engine)).profiles(this);
3042
3354
  return this._narrowToChosen(engine, all);
3043
3355
  }
3044
3356
 
@@ -3143,7 +3455,7 @@ export class Supervisor {
3143
3455
  // are: one shared counter would have a Codex spawn advance the rotation
3144
3456
  // across the Claude subscriptions, so a run of Codex tasks could leave
3145
3457
  // one of them doing all the Claude work.
3146
- _pickProfile(engine = DEFAULT_ENGINE) {
3458
+ _pickProfile(engine = DEFAULT_ENGINE, { model = null } = {}) {
3147
3459
  const which = engineOf(engine);
3148
3460
  // SOMEWHERE HAS TO BE TRIED. `_liveProfilesFor` is allowed to answer
3149
3461
  // nothing now, which is the honest answer to "what could run" and no answer
@@ -3155,13 +3467,45 @@ export class Supervisor {
3155
3467
  // A CODEX ACCOUNT STILL BEING ADDED IS NOT WHERE WORK GOES. Add account
3156
3468
  // lists the new home before its sign-in finishes, and the round-robin
3157
3469
  // handed it the very first thread while the signed-in one sat idle.
3158
- const signedIn = which === 'codex' ? live.filter((p) => this._codexSignedIn(p)) : live;
3159
- const pool = signedIn.length ? signedIn : live.length ? live : ['default'];
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;
3160
3478
  this._rr = this._rr ?? {};
3161
3479
  this._rr[which] = ((this._rr[which] ?? 0) + 1) % pool.length;
3162
3480
  return pool[this._rr[which]];
3163
3481
  }
3164
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
+
3165
3509
  /**
3166
3510
  * WHICH ACCOUNT AN EXIT, A STRIKE OR A COOLDOWN IS EVIDENCE ABOUT.
3167
3511
  *
@@ -3176,7 +3520,7 @@ export class Supervisor {
3176
3520
  * second engine existed still names the account it always named.
3177
3521
  */
3178
3522
  _accountKey(engine, profile) {
3179
- return engineOf(engine) === 'codex' ? `codex:${profile}` : profile;
3523
+ return harnessFor(engineOf(engine)).accountKey(profile);
3180
3524
  }
3181
3525
 
3182
3526
  // ACCOUNTS THAT CAN ACTUALLY RUN SOMETHING RIGHT NOW, and unlike
@@ -3191,6 +3535,19 @@ export class Supervisor {
3191
3535
  // `codex:` key. That is the right scope for its two callers -- the fleet-wide
3192
3536
  // brake and the sentence above her list -- because both are about having
3193
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
+
3194
3551
  _healthyProfiles() {
3195
3552
  const now = Date.now();
3196
3553
  return this._profiles().filter((p) => (this._profileCooldown?.[p] ?? 0) < now && !this._profileTrouble?.[p]);
@@ -3205,7 +3562,10 @@ export class Supervisor {
3205
3562
  // time. A fact about one account now stays on that account.
3206
3563
  _noteProfileTrouble(profile, raw) {
3207
3564
  if (!profile) return 'unknown';
3208
- 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);
3209
3569
  this._profileTrouble = this._profileTrouble ?? {};
3210
3570
  const had = this._profileTrouble[profile];
3211
3571
  this._profileTrouble[profile] = { since: had?.since ?? Date.now(), at: Date.now(), cause, raw: String(raw ?? '').slice(0, 300) };
@@ -3215,9 +3575,61 @@ export class Supervisor {
3215
3575
  const stamp = signInStamp(this._signInFilesFor(profile));
3216
3576
  this._profileTrouble[profile].stamp = Math.max(stamp, had?.stamp ?? 0);
3217
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
+ }
3218
3583
  return cause;
3219
3584
  }
3220
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
+
3221
3633
  _clearProfileTrouble(profile) {
3222
3634
  if (!profile) return;
3223
3635
  if (this._profileTrouble) delete this._profileTrouble[profile];
@@ -3312,7 +3724,28 @@ export class Supervisor {
3312
3724
  _capacityFor(engine) {
3313
3725
  const which = engineOf(engine);
3314
3726
  if (!this.engineChoices().some((e) => e.id === which)) return 0;
3315
- 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;
3316
3749
  }
3317
3750
 
3318
3751
  /**
@@ -3379,14 +3812,14 @@ export class Supervisor {
3379
3812
  // A SMALL PLAN STILL WINS. `slotsForPlans` answers one at a time for a
3380
3813
  // subscription that is not Max; that is a fact about the subscription and
3381
3814
  // not about this Mac, so Automatic may only ever come out at or under it.
3382
- if (this.config.agentsAuto && !planSpokeForClaude) {
3383
- const total = autoAgents({
3384
- ...this._machine(),
3385
- gated: !!this.config.memoryGate,
3386
- nudge: this.config.agentsNudge,
3387
- });
3388
- const per = perAccountAgents(total, this._liveProfilesFor(engineOf(engine)).length);
3389
- const plan = this.config.planSlotsFrom ? Number(this.config.maxConcurrentSessions) : null;
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;
3390
3823
  return Number.isFinite(plan) && plan >= 1 ? Math.min(Math.floor(plan), per) : per;
3391
3824
  }
3392
3825
  const set = Number(planSpokeForClaude ? DEFAULT_SESSIONS_AT_ONCE : this.config.maxConcurrentSessions);
@@ -3478,6 +3911,67 @@ export class Supervisor {
3478
3911
  return this._loadFor(which) < this._capacityFor(which) && this._load() < this._capacity();
3479
3912
  }
3480
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
+
3481
3975
  /** The fleet-wide brake, as a clock rather than a state. */
3482
3976
  _spawnBraked() {
3483
3977
  return !!this._spawnCooldownUntil && Date.now() < this._spawnCooldownUntil;
@@ -3587,16 +4081,42 @@ export class Supervisor {
3587
4081
  // FAST_EXIT_MS. Past it, the branch below CLEARED the account's trouble:
3588
4082
  // no bench, no "Signed out" on the row, and her reply was charged one of
3589
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];
3590
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
+ }
3591
4105
  const limitHit = refusedBy === 'at-limit' || needsHerHands(refusedBy);
3592
- 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) {
3593
4113
  // The last words of a fast-dead session are the diagnosis. They are kept,
3594
4114
  // but as EVIDENCE now, not as copy: shared/spawn-trouble.mjs turns them
3595
4115
  // into a cause, and only our own sentence for that cause ever reaches a
3596
4116
  // screen.
3597
- const lastWords = limitHit
4117
+ const lastWords = (limitHit || connectionLost) && session.resultIsError && session.result != null
3598
4118
  ? String(session.result)
3599
- : (session.tail ?? []).filter((l) => l && !l.startsWith('session exited') && !l.startsWith('fast exit')).slice(-1)[0];
4119
+ : lastFailure;
3600
4120
  const raw = lastWords ? lastWords.replace(/^stderr: /, '').slice(0, 300) : '';
3601
4121
  const account = this._accountKey(engine, session.profile);
3602
4122
  const cause = this._noteProfileTrouble(account, raw);
@@ -3686,9 +4206,18 @@ export class Supervisor {
3686
4206
  try {
3687
4207
  fs.writeFileSync(this._stateFile, JSON.stringify({
3688
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)),
3689
4216
  heardAnswers: Object.fromEntries(Object.entries(this._heardAnswers ?? {}).slice(-500)),
3690
4217
  productOrder: this.productOrder,
3691
4218
  hiddenProducts: [...this.hiddenProducts],
4219
+ threadPlaces: this.threadPlaces,
4220
+ threadPins: this.threadPins,
3692
4221
  liveSessions: this._liveSessions,
3693
4222
  rowSessions: this._rowSessions,
3694
4223
  compactions: this._compactions,
@@ -3839,12 +4368,30 @@ export class Supervisor {
3839
4368
  signInNeeded = this._waitingOnSignIn(this.store.listItems(Date.now())
3840
4369
  .filter((i) => mayRunHere(i, productBySlug.get(i.product), me)));
3841
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 {}
3842
4383
  return {
3843
4384
  paused: this.paused,
4385
+ shipping,
3844
4386
  // ROWS NOTHING CAN START BECAUSE THEIR TOOL IS SIGNED OUT, by id, with the
3845
4387
  // tool's name. The row says so instead of "Queued", which promised an
3846
4388
  // agent "as soon as one is free" while none could be (2026-10-04).
3847
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,
3848
4395
  running: [...this.sessions.values()].filter(s => !s.remoteIdle).map((s) => ({
3849
4396
  itemId: s.itemId,
3850
4397
  product: s.product,
@@ -4070,6 +4617,36 @@ export class Supervisor {
4070
4617
  return out;
4071
4618
  }
4072
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
+
4073
4650
  _spawnTrouble(queued) {
4074
4651
  const since = this._fleetTroubleSince ?? 0;
4075
4652
  if (!since) return null;
@@ -4112,10 +4689,13 @@ export class Supervisor {
4112
4689
 
4113
4690
  async _tick() {
4114
4691
  if (this.paused) return;
4692
+ this._refreshMemoryReading();
4115
4693
  if (this.firstRunHolding()) return;
4116
4694
  // Has a login landed since an account was found signed out? Ahead of the
4117
4695
  // brake below, which would otherwise hold the fleet past the sign-in.
4118
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); }
4119
4699
  // A COOLDOWN STOPS THE SPAWNING AND IT MUST NOT STOP THE TELLING. The brake
4120
4700
  // goes on when there is no healthy account left anywhere, for up to half an
4121
4701
  // hour, which is precisely the half hour her tasks are all stuck and the
@@ -4178,27 +4758,14 @@ export class Supervisor {
4178
4758
  try { this.recoverInterrupted('capacity'); } catch (e) { console.warn('zero: recovery:', e.message); }
4179
4759
  }
4180
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
+
4181
4764
  // Rows she resumed by name that had no slot at the time. First call on the
4182
4765
  // next free slot, ahead of everything: she asked for these out loud, and a
4183
4766
  // resume that quietly never happens is the failure this whole path exists
4184
4767
  // to end.
4185
- for (const id of [...this._resumeQueue]) {
4186
- const item = items.find((i) => i.id === id);
4187
- // A row that has gone or is already running is not waiting for a slot; it
4188
- // leaves the queue whatever capacity says.
4189
- if (!item || this.sessions.has(id)) {
4190
- this._resumeQueue.delete(id);
4191
- continue;
4192
- }
4193
- // NO SLOT ON ITS ENGINE MEANS IT KEEPS ITS PLACE, and the scan goes on to
4194
- // the next one, which may be on the engine that is idle. This used to
4195
- // break out of the loop, which was the same starvation the main queue's
4196
- // `spent` note describes: she asked for these out loud, so a resume that
4197
- // waits behind the OTHER subscription is a resume that never happens.
4198
- if (!this._hasSlotFor(this._engineFor(item))) continue;
4199
- this._resumeQueue.delete(id);
4200
- this._spawnResume(item);
4201
- }
4768
+ this._drainResumeQueue(items);
4202
4769
 
4203
4770
  // ONE QUEUE, ONE ORDER. Everything spawnable this tick is scored by the
4204
4771
  // same rule and the slots go to the top of that one list.
@@ -4461,7 +5028,9 @@ export class Supervisor {
4461
5028
  // reading `item.engine` here instead would be a second one that could
4462
5029
  // disagree with it.
4463
5030
  const engine = this._engineFor(item);
4464
- 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)) {
4465
5034
  // an Urgent row takes a slot rather than queueing behind work that
4466
5035
  // matters less. At most ONE session is interrupted per engine per
4467
5036
  // tick, and the urgent row spawns on the next one — the child has to
@@ -4512,7 +5081,7 @@ export class Supervisor {
4512
5081
  // A DIGEST RUNS ON THIS MAC'S HOME ENGINE: Claude Code, or Codex on a Mac
4513
5082
  // with nothing else. It carries a synthetic row with no engine on it, so
4514
5083
  // the slot it checks and the engine `spawnDigest` forces are one answer.
4515
- if (!this._hasSlotFor(this._homeEngine())) break;
5084
+ if (!this._hasSlotFor(this._homeEngine()) || !this._memoryLetsStart(null)) break;
4516
5085
  const owed = this._digestOwed(items, product.slug, now);
4517
5086
  if (!owed) continue;
4518
5087
  this.lastDigestTry[product.slug] = now;
@@ -4844,8 +5413,8 @@ export class Supervisor {
4844
5413
  * `assistant` text event carrying the table and then result/success, so the
4845
5414
  * trace this app already
4846
5415
  * keeps puts it in the thread with nothing new written to show it. */
4847
- const review = continuation && engine === 'codex' && providerCommand(item.answer, engine)?.name === 'review' ? providerCommand(item.answer, engine) : null;
4848
- 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;
4849
5418
  if (command) prompt = command;
4850
5419
  // WHICH MODEL. A model named on the task wins; absent that, the workspace
4851
5420
  // setting, which lives in the `--model` flag inside sessionArgs. A PERSONAL
@@ -4877,7 +5446,7 @@ export class Supervisor {
4877
5446
  // `codexEffortRefusal` judges it against the model at `thread/start`, the
4878
5447
  // same place and the same shape as the model refusal.
4879
5448
  const effort = isEffortWord(item?.effort) ? item.effort : null;
4880
- const claudeEffort = engine === DEFAULT_ENGINE && isEffort(effort) ? effort : null;
5449
+ const claudeEffort = harnessFor(engine).capabilities.claudeEffort && isEffort(effort) ? effort : null;
4881
5450
  // Without it the CLI emits ONE `assistant` event per finished message, so
4882
5451
  // a sentence exists nowhere until the model has written the last word of
4883
5452
  // it. Measured over her 1,664 traces on 2026-08-25: the median wait before
@@ -4892,8 +5461,12 @@ export class Supervisor {
4892
5461
  // from the finished `assistant` event only (`traceStreamLine`), so what is
4893
5462
  // kept is whole sentences and never a half-typed one; the pieces live on
4894
5463
  // the session in memory and die with it, which is what `saying` below is.
4895
- const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--include-partial-messages', ...claudeGrants];
4896
- 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.
4897
5470
  // Her standing instructions, on every session there is: workers, digests,
4898
5471
  // and every resumed turn of a thread that started before the user wrote the
4899
5472
  // rule. This is the ONE place a session's arguments are built, which is why
@@ -4930,12 +5503,13 @@ export class Supervisor {
4930
5503
  // buildBrief: these blocks are markdown files that cannot import it, and
4931
5504
  // her own copies keep the tokens so a rename reaches her edits as well.
4932
5505
  const system = fillName([standing, project, messages, adhd].filter(Boolean).join('\n\n'));
4933
- if (system) args.push('--append-system-prompt', system);
4934
- if (resumeId) args.push('--resume', resumeId);
4935
- // 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
4936
5507
  // resumed conversation is written on under a NEW id, so the row it came
4937
- // from keeps its own thread untouched (MP-08).
4938
- 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
+ });
4939
5513
  /*
4940
5514
  * `command` GOES BACK WITH THE ARGUMENTS, because the exit handler has to
4941
5515
  know what this run was. A command run does not write on the row and is
@@ -4974,7 +5548,7 @@ export class Supervisor {
4974
5548
 
4975
5549
  commandCatalog(product, id) {
4976
5550
  const item = this.store.readItem(product, id);
4977
- return item && this._engineFor(item) !== 'codex' ? this._nativeCommands?.[id] ?? [] : [];
5551
+ return item && harnessFor(this._engineFor(item)).capabilities.nativeCommands ? this._nativeCommands?.[id] ?? [] : [];
4978
5552
  }
4979
5553
 
4980
5554
  remoteControl(product, id, action) { return taskRemoteControl(this, product, id, action); }
@@ -5019,7 +5593,9 @@ export class Supervisor {
5019
5593
  return value;
5020
5594
  };
5021
5595
  const item = this.store.readItem(productSlug, id);
5022
- 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);
5023
5599
  if (this.sessions.has(id)) return publish('busy');
5024
5600
  const product = this.store.listProducts().find(p => p.slug === productSlug);
5025
5601
  if (!product) return publish('missing');
@@ -5030,16 +5606,13 @@ export class Supervisor {
5030
5606
  this._compactionJobs.set(key, { threadId: rec.sessionId, profile: rec.profile });
5031
5607
  const initial = publish('running');
5032
5608
  Promise.resolve().then(async () => {
5033
- const { client, handshake } = this._codexServer(this._codexProfileHome(rec.profile ?? 'default'));
5034
- const mcpServers = await this._codexIsolation(client, handshake);
5035
5609
  const cwd = product.repoPath && fs.existsSync(product.repoPath) ? product.repoPath : product.dir;
5036
5610
  // Same isolated configuration as a worker, with no store tool needed:
5037
5611
  // compaction summarizes the existing thread and never starts an agent turn.
5038
- const params = workerThreadParams({ cwd, mcpServers, storeServer: null });
5039
- return compactCodexThread({ server: client, threadId: rec.sessionId, threadParams: params });
5612
+ return harness.compact(this, rec, cwd);
5040
5613
  }).then(publish, () => publish('failed')).finally(() => {
5041
5614
  this._compactionJobs.delete(key);
5042
- this._releaseIdleCodex(rec.profile ?? 'default');
5615
+ harness.release(this, rec.profile ?? 'default');
5043
5616
  });
5044
5617
  return initial;
5045
5618
  }
@@ -5492,17 +6065,17 @@ export class Supervisor {
5492
6065
  }
5493
6066
 
5494
6067
  /**
5495
- * WHICH SUBSCRIPTION THE FLEET IS SPENDING, for the one line that says so
5496
- * (w-e217e577e5, 2026-10-07). Null on a Mac where nothing readable is signed
5497
- * in, and then nothing is drawn anywhere.
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.
5498
6071
  *
5499
6072
  * IT IS ANSWERED HERE FOR THE REASON `engineFacts` IS. Which engine a row runs
5500
6073
  * on needs the capability gate, and which ACCOUNT it runs on needs
5501
6074
  * `_narrowToChosen`, the rule that one account picked in Settings is the only
5502
6075
  * one the fleet uses. A corner that worked either out for itself could name
5503
6076
  * the subscription this app is deliberately not spending, which is the one
5504
- * mistake this line must never make: somebody reads it to find out whose money
5505
- * is going.
6077
+ * mistake this line must never make: somebody reads it to find out whether any
6078
+ * account of theirs got connected at all.
5506
6079
  *
5507
6080
  * THE FIRST OF THE POOL, which is the account the next spawn goes to on a Mac
5508
6081
  * with nothing picked (`_pickProfile` round-robins from there). Two accounts
@@ -5519,6 +6092,20 @@ export class Supervisor {
5519
6092
  return runsOn({ engine: 'claude', home, claudeProfile: pool[0] ?? 'default' });
5520
6093
  }
5521
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
+
5522
6109
  /**
5523
6110
  * THE ENGINE ON A TASK SHE IS COMPOSING, OR NULL, and the door writes nothing
5524
6111
  * else (`zero:compose` in main/ipc.mjs).
@@ -5703,13 +6290,7 @@ export class Supervisor {
5703
6290
  * second engine reads exactly as it did.
5704
6291
  */
5705
6292
  _workerEnv(engine = DEFAULT_ENGINE) {
5706
- const anthropic = (k) => /^ANTHROPIC_/.test(k) || /^CLAUDE_CODE_/.test(k)
5707
- || k === 'CLAUDECODE' || k === 'CLAUDE_PID' || k === 'CLAUDE_EFFORT' || k === 'CLAUDE_CONFIG_DIR';
5708
- const openai = (k) => /^OPENAI_/.test(k) || /^CODEX_/.test(k);
5709
- const forCodex = engineOf(engine) !== DEFAULT_ENGINE;
5710
- const env = { ...process.env };
5711
- for (const k of Object.keys(env)) if (anthropic(k) || (forCodex && openai(k))) delete env[k];
5712
- return env;
6293
+ return harnessFor(engineOf(engine)).workerEnv(process.env);
5713
6294
  }
5714
6295
 
5715
6296
  /**
@@ -5725,7 +6306,8 @@ export class Supervisor {
5725
6306
  // is recorded under and the same one `transcriptFile` will look for its
5726
6307
  // rollout with. One app-server per home, so a second Codex login is a
5727
6308
  // second process rather than a bigger number on the Settings screen.
5728
- const { client, handshake } = this._codexServer(this._codexProfileHome(profile));
6309
+ const home = this._codexProfileHome(profile);
6310
+ const { client, handshake } = this._codexServer(home);
5729
6311
  const storeServer = this._codexStoreServer(item);
5730
6312
  // THIS WORKER'S OWN CARDS, SO THEY CAN DIE WITH IT.
5731
6313
  //
@@ -5754,7 +6336,7 @@ export class Supervisor {
5754
6336
  // thread already running cannot un-launch them. Read fresh for THIS start
5755
6337
  // rather than once per app-server, and never caught: see `_codexIsolation`.
5756
6338
  ready: this._codexIsolation(client, handshake),
5757
- threadParams: (mcpServers) => this.codexThreadParamsFor(plan, cwd, mcpServers, storeServer),
6339
+ threadParams: (mcpServers) => this.codexThreadParamsFor(plan, cwd, mcpServers, storeServer, home),
5758
6340
  turnParams: this.codexTurnParamsFor(plan),
5759
6341
  resumeThreadId: plan.resumeId,
5760
6342
  // The card, which is the other half of what a worker needs to be able to
@@ -5892,25 +6474,26 @@ export class Supervisor {
5892
6474
  * did, and so her terminal and her fleet follow the same file when she
5893
6475
  * changes it.
5894
6476
  */
5895
- 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()) {
5896
6480
  // ASKED HERE RATHER THAN CARRIED ON THE PLAN. A precomputed field would be
5897
6481
  // a second copy of this decision that a caller can forget to set, and the
5898
6482
  // way it fails is a word Codex cannot read reaching `thread/start` -- which
5899
6483
  // is exactly the bug. The one place that builds the params is the one place
5900
6484
  // that decides.
5901
6485
  const onRow = typeof plan?.model === 'string' ? plan.model.trim() : '';
5902
- const workspace = this._codexWorkspaceModel();
5903
- const word = onRow || workspace || '';
6486
+ const word = this._codexModelWord(plan) ?? '';
5904
6487
  // WHICH SETTING IS BEING REFUSED, because the sentence is the only thing
5905
6488
  // she can act on and it has to send her to the right screen.
5906
- const refusal = this.codexModelRefusal(word, onRow ? 'row' : 'workspace');
6489
+ const refusal = this.codexModelRefusal(word, onRow ? 'row' : 'workspace', home);
5907
6490
  if (refusal) throw new Error(refusal);
5908
6491
  // AND THE LEVEL IS JUDGED HERE TOO, against the model that will actually
5909
6492
  // run, for the same reason and with the same outcome: a level the model
5910
6493
  // does not advertise stops the run before the thread, with the sentence on
5911
6494
  // the pipe `troubleCause` reads, rather than reaching `turn/start` as a
5912
6495
  // protocol error or running at a level she did not pick.
5913
- const level = this.codexEffortRefusal(word, plan?.effort);
6496
+ const level = this.codexEffortRefusal(word, plan?.effort, home);
5914
6497
  if (level) throw new Error(level);
5915
6498
  return workerThreadParams({
5916
6499
  cwd,
@@ -5971,10 +6554,9 @@ export class Supervisor {
5971
6554
  * for that word. We refuse on a fact or not at all; with no list, the word
5972
6555
  * goes to Codex as written and Codex answers for it in its own words.
5973
6556
  */
5974
- codexEffortRefusal(model, effort) {
6557
+ codexEffortRefusal(model, effort, home = this._codexHome()) {
5975
6558
  const level = typeof effort === 'string' ? effort.trim() : '';
5976
6559
  if (!level) return null;
5977
- const home = this._codexHome();
5978
6560
  const slug = (typeof model === 'string' && model.trim()) || this._codexWorkspaceModel() || codexDefaultModel({ home }) || '';
5979
6561
  if (!slug) return null;
5980
6562
  const levels = codexModelLevels(slug, { home });
@@ -6000,6 +6582,13 @@ export class Supervisor {
6000
6582
  return word || null;
6001
6583
  }
6002
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
+
6003
6592
  /**
6004
6593
  * WHAT A CODEX WORKER ON THIS PRODUCT MAY DO, resolved in the same order as
6005
6594
  * the Claude Code side: the project's own setting if it has one, else the
@@ -6049,10 +6638,9 @@ export class Supervisor {
6049
6638
  * word that is not on it. Where to go and fix it is the other half of what
6050
6639
  * she can act on.
6051
6640
  */
6052
- codexModelRefusal(model, from = 'row') {
6641
+ codexModelRefusal(model, from = 'row', home = this._codexHome()) {
6053
6642
  const word = typeof model === 'string' ? model.trim() : '';
6054
6643
  if (!word) return null;
6055
- const home = this._codexHome();
6056
6644
  const known = codexKnownSlugs({ home });
6057
6645
  if (!known.size || known.has(word)) return null;
6058
6646
  const offered = codexModels({ home }).map((m) => m.id);
@@ -6060,6 +6648,14 @@ export class Supervisor {
6060
6648
  const asked = from === 'workspace'
6061
6649
  ? `every Codex agent here is set to run on "${word}"`
6062
6650
  : `this row asks to run on "${word}"`;
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`;
6063
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`;
6064
6660
  }
6065
6661
 
@@ -6368,9 +6964,10 @@ export class Supervisor {
6368
6964
  this._preparing ??= new Map();
6369
6965
  const opts = { continuation, resumeSessionId, profile: forcedProfile, engine: forcedEngine, remoteOnly, shipFailure };
6370
6966
  const held = this._preparing.get(item.id);
6371
- if (held) { held.item = item; held.opts = opts; return; }
6967
+ if (held) { held.item = item; held.opts = opts; this._saveState(); return; }
6372
6968
  const entry = { item, opts, engine };
6373
6969
  this._preparing.set(item.id, entry);
6970
+ this._saveState();
6374
6971
  const t = setTimeout(() => {
6375
6972
  if (this._preparing?.get(item.id) !== entry) return; // stopped, or the app quit
6376
6973
  this._preparing.delete(item.id);
@@ -6447,6 +7044,26 @@ export class Supervisor {
6447
7044
  if (this._folderFirst(item, product, engine, { continuation, resumeSessionId, profile: forcedProfile, engine: forcedEngine, remoteOnly })) return;
6448
7045
  if (this._photoFirst(item, product, engine, { continuation, resumeSessionId, profile: forcedProfile, engine: forcedEngine, remoteOnly })) return;
6449
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
+
6450
7067
  const plan = this.spawnPlan(item, product, { continuation, resumeSessionId, engine, shipFailure });
6451
7068
  const { args } = plan;
6452
7069
  // ONE RUN, ONE PAIR OF FILES. The MCP config and the settings used to be a
@@ -6458,11 +7075,6 @@ export class Supervisor {
6458
7075
  const spawnFiles = [];
6459
7076
  this.prepareClaudePermissions(plan, product, runId, spawnFiles);
6460
7077
 
6461
- // THE ROW'S OWN FOLDER, and the shared checkout only when it cannot have
6462
- // one. Everything downstream follows this one word: the snapshot the change
6463
- // card is built from, the built-in terminal, /diff, and the folder the next
6464
- // reply resumes into.
6465
- const cwd = this.workFolderFor(item, product);
6466
7078
  // Workers bill to the founder's Claude subscription (the CLI's own OAuth
6467
7079
  // login), never an API key. An ANTHROPIC_API_KEY in the inherited env
6468
7080
  // would silently override that, so strip anything that could redirect
@@ -6482,7 +7094,8 @@ export class Supervisor {
6482
7094
  // `forcedProfile` or a `resumeProfile` that disagreed with the tie has
6483
7095
  // already had its session id taken off it, so this is not a resume pointed
6484
7096
  // at the wrong home, it is a fresh brief on the account the project named.
6485
- const profile = pinned ?? forcedProfile ?? plan.resumeProfile ?? this._pickProfile(engine);
7097
+ const model = engineOf(engine) === 'codex' ? this._codexModelWord(plan) : null;
7098
+ const profile = pinned ?? forcedProfile ?? plan.resumeProfile ?? this._pickProfile(engine, { model });
6486
7099
  // A SECOND ACCOUNT ARRIVES EMPTY, AND THIS IS WHERE IT STOPS BEING EMPTY.
6487
7100
  // Our own Accounts page tells a person to log in with a brand new folder,
6488
7101
  // and Claude Code reads a session's skills, commands and subagents out of
@@ -6497,35 +7110,15 @@ export class Supervisor {
6497
7110
  // account folder to put them in, so running it there would be three
6498
7111
  // symlinks made into a Claude home on behalf of a session that will never
6499
7112
  // open them.
6500
- const tooling = engine === 'codex' ? null : linkAccountTooling(profile);
7113
+ const harness = harnessFor(engine);
7114
+ const tooling = harness.tooling(profile);
6501
7115
  // A RUN IS STARTING ON THIS ROW, said before the worker exists and in the
6502
7116
  // same turn of the event loop as the spawn, so the cleaner can never stop
6503
7117
  // this task's programs between its last check and this agent arriving
6504
7118
  // (main/leftovers.mjs, agreed with Codex).
6505
7119
  this._leftoverCleaner?.starting(item.id);
6506
7120
  const env = this._workerEnv(engine);
6507
- const child = engine === 'codex'
6508
- ? this._spawnCodexWorker(plan, { cwd, item, profile })
6509
- : spawn(this.config.claudeBin, claudeStreamArgs(args), {
6510
- cwd,
6511
- env: {
6512
- ...env,
6513
- STORE_ACCOUNT_ID: this.config.accountId,
6514
- // This app's own store, and the only one there is. Exported under
6515
- // every name this app has had, so a store server or a script written
6516
- // against an older name still finds it.
6517
- ...storeRootEnv(this.config.storeRoot),
6518
- // Which subscription bills this worker. 'default' means the CLI's
6519
- // own home; anything else is a second logged-in profile.
6520
- ...(profile !== 'default' ? { CLAUDE_CONFIG_DIR: profile } : {}),
6521
- // So approval cards can say who is asking and about what.
6522
- ZERO_PRODUCT: item.product,
6523
- ZERO_ITEM: item.id,
6524
- // Where its shell commands ask about memory, when that is on.
6525
- ...this.memoryGateEnv(item),
6526
- },
6527
- stdio: ['pipe', 'pipe', 'pipe'],
6528
- });
7121
+ const child = harness.spawn(this, plan, { cwd, item, profile, env });
6529
7122
 
6530
7123
  // `command` RIDES ON THE SESSION so the cap can leave it out (`_load`).
6531
7124
  // The exit handler already needed the same fact from `plan`; this is that
@@ -6540,6 +7133,8 @@ export class Supervisor {
6540
7133
  const session = {
6541
7134
  child, itemId: item.id, product: item.product, startedAt: Date.now(), tail: [], profile,
6542
7135
  command: !!plan.command, engine,
7136
+ // Which model, so a plan that refuses it strikes that model off this login.
7137
+ ...(model ? { model } : {}),
6543
7138
  // AND THE FOLDER IT IS RUNNING IN, which is now the row's own rather than
6544
7139
  // the product's checkout. Everything that asks a live session where it
6545
7140
  // works reads it from here rather than deriving it from the product a
@@ -6548,13 +7143,14 @@ export class Supervisor {
6548
7143
  spawnFiles,
6549
7144
  };
6550
7145
  this.sessions.set(item.id, session);
7146
+ if (!session.command) this._lastStartAt = session.startedAt;
6551
7147
  // THAT an agent started work, and on which engine; nothing about the task.
6552
7148
  // `count` is set by main.mjs and is absent wherever nothing is sent.
6553
7149
  this.count?.('run_started', { engine });
6554
7150
  // Its worker, so a run left "running" by a crash can later be told ended.
6555
7151
  if (Number.isInteger(child?.pid)) this._leftoverCleaner?.worker(item.id, child.pid);
6556
- if (engine !== 'codex') {
6557
- attachClaudeInput(child);
7152
+ if (harness.capabilities.remoteControl) {
7153
+ harness.attachInput(child);
6558
7154
  if (remoteOnly) { child.holdInput(true); session.remoteIdle = true; session.remoteHeld = true; }
6559
7155
  child.on('input-turn', () => {
6560
7156
  if (!session.remoteHeld) return;
@@ -6705,8 +7301,7 @@ export class Supervisor {
6705
7301
  // the real camelCase/snake_case divergence between app-server and the
6706
7302
  // `codex exec` stream the removed August build parsed, which is exactly the
6707
7303
  // difference a future reader has to be able to see.
6708
- const { capture, stream, summarize, trace: traceOne } = readersFor(engine);
6709
- const activity = engine === 'codex' ? codexActivity : claudeActivity;
7304
+ const { capture, stream, summarize, trace: traceOne, activity } = harness.readers();
6710
7305
  // One frame of a session, whatever a frame is on this engine: a line of
6711
7306
  // stdout, or a method and its params.
6712
7307
  const absorb = (...frame) => {
@@ -6719,7 +7314,7 @@ export class Supervisor {
6719
7314
  this._noteHeard(item, session.sessionId);
6720
7315
  }
6721
7316
  if (activity(session, ...frame)) this.onChange?.();
6722
- if (session.remoteHeld && engine === DEFAULT_ENGINE) {
7317
+ if (session.remoteHeld && harness.capabilities.remoteControl) {
6723
7318
  try {
6724
7319
  const event = JSON.parse(frame[0]);
6725
7320
  if (event.type === 'result') {
@@ -6737,7 +7332,7 @@ export class Supervisor {
6737
7332
  }
6738
7333
  } catch(error) { console.warn('zero: remote turn writeback failed:',error.message); }
6739
7334
  }
6740
- if (engine === DEFAULT_ENGINE) {
7335
+ if (harness.capabilities.nativeCommands) {
6741
7336
  try {
6742
7337
  const names = nativeCommandNames(JSON.parse(frame[0]));
6743
7338
  if (names) { this._nativeCommands ??= {}; this._nativeCommands[item.id] = names; this._saveState(); }
@@ -6784,29 +7379,7 @@ export class Supervisor {
6784
7379
  // A Codex worker has no stdout of ours to read: main/codex-session.mjs
6785
7380
  // emits the server's own notifications rather than faking a pipe. See the
6786
7381
  // note at the top of that file for why.
6787
- if (engine === 'codex') {
6788
- child.on('event', (method, params) => {
6789
- // THE CONVERSATION HALF OF THE ARTIFACT, KEPT AS IT GOES PAST. The
6790
- // other engine's is on disk and read once at the exit; this one is
6791
- // delivered as notifications and is gone the moment they are handled,
6792
- // so a Codex card had only the disk half until this line existed.
6793
- rememberCodexChange(session, method, params);
6794
- absorb(method, params);
6795
- });
6796
- }
6797
- else {
6798
- let buffer = '';
6799
- child.stdout.on('data', (d) => {
6800
- buffer += d.toString();
6801
- let idx;
6802
- while ((idx = buffer.indexOf('\n')) >= 0) {
6803
- const line = buffer.slice(0, idx);
6804
- buffer = buffer.slice(idx + 1);
6805
- if (!line.trim()) continue;
6806
- absorb(line);
6807
- }
6808
- });
6809
- }
7382
+ harness.subscribe(child, session, absorb);
6810
7383
  child.stderr.on('data', (d) => {
6811
7384
  onLine(`stderr: ${String(d).slice(0, 500)}`);
6812
7385
  try { trace?.write(`stderr: ${String(d).slice(0, 1000)}\n`); } catch {}
@@ -6820,10 +7393,9 @@ export class Supervisor {
6820
7393
  // item. Written onto the session because that is what `settleDelivery`
6821
7394
  // reads, and read there rather than here so the decision stays in the one
6822
7395
  // method that owns it. See `onClosed` in main/codex-session.mjs.
6823
- child.on('exit', (code, signal, how) => {
6824
- if (exited) return;
6825
- exited = true;
7396
+ const settleExit = (code, signal, how) => {
6826
7397
  if (how?.transportFault) session.transportFault = true;
7398
+ this.memoryGateTurnEnded(session);
6827
7399
  session.exitFailed = !!(signal || code !== 0 || session.transportFault || session.resultIsError || session.result == null);
6828
7400
  this.count?.('run_finished', runEndedProps(session, Date.now()));
6829
7401
  onLine(`session exited (${code})`);
@@ -6866,9 +7438,7 @@ export class Supervisor {
6866
7438
  // the agent's own sentences beside them. The frames were kept as they
6867
7439
  // went past (`rememberCodexChange`), with the same roots this call
6868
7440
  // uses, so the two halves are read against one set of spellings.
6869
- change: engine === 'codex'
6870
- ? changeFromCodexTurn(session.codexChange ?? [], { roots, since: session.startedAt ?? 0 })
6871
- : null,
7441
+ change: harness.change(session, { roots, since: session.startedAt ?? 0 }),
6872
7442
  docsDir: product.dir,
6873
7443
  itemId: item.id,
6874
7444
  roots,
@@ -6998,9 +7568,31 @@ export class Supervisor {
6998
7568
  if (session.lastLiveReply && session.exitFailed)
6999
7569
  this.redeliverAnswer(session.lastLiveReply, session.lastLiveReply.answer);
7000
7570
  if (!session.stoppedByUs) this.deliverMidflightReply(item, answerAtSpawn);
7001
- if (engine === 'codex') this._releaseIdleCodex(profile);
7571
+ harness.release(this, profile);
7002
7572
  // The item's own status was written by the worker through the MCP; if it
7003
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);
7004
7596
  });
7005
7597
  this.onChange?.();
7006
7598
  }
@@ -7108,9 +7700,7 @@ export class Supervisor {
7108
7700
  // looking for something that does not exist. Codex is handed the
7109
7701
  // picture in the turn instead (`codexTurnParamsFor`), so the honest
7110
7702
  // instruction is that it is already in front of it.
7111
- engine === 'codex'
7112
- ? 'Each one is attached to this message as an image, and is also on disk at the absolute path below.'
7113
- : 'Open each one with the Read tool, at the absolute path below.',
7703
+ harnessFor(engine).imageInstruction,
7114
7704
  '',
7115
7705
  ...pictures.map((f) => `- ${f.abs}`),
7116
7706
  );
@@ -7592,6 +8182,29 @@ export class Supervisor {
7592
8182
  } catch { return null; }
7593
8183
  }
7594
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
+
7595
8208
  memoryGateSlots() {
7596
8209
  const n = Number(this.config.memoryGateSlots);
7597
8210
  return Number.isFinite(n) && n >= 1 ? Math.round(n) : autoSlots(os.totalmem());
@@ -7656,6 +8269,7 @@ export class Supervisor {
7656
8269
  gate: { slots: this.memoryGateSlots() },
7657
8270
  scoreFor: (product, itemId) => this.memoryGateScore(product, itemId),
7658
8271
  ownerOf: (sessionId) => this.memoryGateOwner(sessionId),
8272
+ urgentFor: (product, itemId) => this.memoryGateUrgent(product, itemId),
7659
8273
  log: (line) => console.log(`zero: ${line}`),
7660
8274
  });
7661
8275
  this._memoryGate = server;
@@ -7771,6 +8385,13 @@ export class Supervisor {
7771
8385
  // server denies everything rather than believing a file, which is the
7772
8386
  // right way round. main/approvals.mjs holds the argument.
7773
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,
7774
8395
  },
7775
8396
  },
7776
8397
  };
@@ -7847,212 +8468,6 @@ export function awaitingHer(item) {
7847
8468
  return wrote.ts > lastFounderWrite(item);
7848
8469
  }
7849
8470
 
7850
- // The stream's own metadata, kept on the session object: the session id (what
7851
- // a personal reply resumes) and the final result message (what the founder
7852
- // reads). Recorded for every session because it is one parse we are already
7853
- // paying for; acted on where someone needs it.
7854
- function captureStream(session, line) {
7855
- try {
7856
- const obj = JSON.parse(line);
7857
- if (obj.session_id) session.sessionId = obj.session_id;
7858
- if (obj.type === 'result') {
7859
- session.result = String(obj.result ?? '');
7860
- session.resultIsError = !!obj.is_error;
7861
- // A run cannot end with helpers still out. Cleared here as well as on
7862
- // exit so the number can never outlive the thing it counts. Unless
7863
- // something is still out in the background: then this was a turn
7864
- // ending, not the run, and the session stays open for it.
7865
- if (!session.child?.waitingOn?.().length) session.helperIds?.clear();
7866
- }
7867
- noteClaimAnswer(session, obj);
7868
- countHelpers(session, obj);
7869
- } catch {}
7870
- }
7871
-
7872
- // Whether this run was refused its own row, off the claim call and its reply.
7873
- // `closingMessageIsTheAnswer` reads it: a refused worker is told to end saying
7874
- // nothing, and whatever it does say must not land on a row someone else holds.
7875
- // A later claim that succeeds clears it.
7876
- function noteClaimAnswer(session, obj) {
7877
- const blocks = Array.isArray(obj?.message?.content) ? obj.message.content : [];
7878
- for (const b of blocks) {
7879
- if (obj.type === 'assistant' && b?.type === 'tool_use' && /claim_work_item$/.test(b.name ?? '')
7880
- && (!b.input?.id || b.input.id === session.itemId)) {
7881
- (session.claimCalls ??= new Set()).add(b.id);
7882
- }
7883
- if (obj.type === 'user' && b?.type === 'tool_result' && session.claimCalls?.has(b.tool_use_id)) {
7884
- const text = typeof b.content === 'string' ? b.content
7885
- : (b.content ?? []).map((c) => c?.text ?? '').join('');
7886
- if (/"claimed"\s*:\s*false/.test(text)) session.claimRefused = true;
7887
- else if (/"claimed"\s*:\s*true/.test(text)) session.claimRefused = false;
7888
- }
7889
- }
7890
- }
7891
-
7892
- /**
7893
- * `--include-partial-messages` puts `stream_event` frames on the same stdout
7894
- * the finished messages come down. This keeps the CURRENT text block on the
7895
- * session as it grows, and nothing else: no thinking, no tool arguments, and
7896
- * nothing written to disk. It dies with the process, exactly like `tail`.
7897
- *
7898
- * ONE BLOCK AT A TIME, AND THAT IS THE POINT. A finished message becomes ONE
7899
- * trace line per text block (`traceStreamLine`), so holding exactly one block
7900
- * here means the live string and the traced string are the same string, and
7901
- * the thread can drop the live copy the moment the traced one arrives without
7902
- * guessing (`itemThread`, renderer/src/item-thread.ts).
7903
- *
7904
- * IT IS NOT CLEARED WHEN THE MESSAGE FINISHES, and that is deliberate. The
7905
- * trace is written on one channel and read on another, so clearing it here
7906
- * would blink the sentence off her screen for however long the read takes and
7907
- * then put it back. The renderer suppresses it the moment the thread has it,
7908
- * which is a comparison rather than a race.
7909
- *
7910
- * Returns true when the string moved, which is what earns a push.
7911
- */
7912
- export function streamingText(session, line) {
7913
- try {
7914
- const obj = JSON.parse(line);
7915
- if (obj.type !== 'stream_event') return false;
7916
- const event = obj.event ?? {};
7917
- // A new message. Whatever the last one ended on is the last one's, and the
7918
- // trace has it by now.
7919
- if (event.type === 'message_start') {
7920
- session.saying = '';
7921
- session.sayingAt = 0;
7922
- return true;
7923
- }
7924
- if (event.type === 'content_block_start') {
7925
- // Only a text block resets it. A tool call or a thought starting means
7926
- // this message has moved on from prose, and the block already typed
7927
- // stays on screen until the trace delivers the same words.
7928
- if (event.content_block?.type !== 'text') return false;
7929
- session.saying = '';
7930
- session.sayingAt = Date.now();
7931
- return true;
7932
- }
7933
- if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
7934
- const piece = String(event.delta.text ?? '');
7935
- if (!piece) return false;
7936
- if (!session.sayingAt) session.sayingAt = Date.now();
7937
- const next = (session.saying ?? '') + piece;
7938
- // Past the cap this stops being a sentence she is reading. Dropped
7939
- // rather than cut, because a cut string never matches the traced one and
7940
- // would sit under it on her screen forever.
7941
- session.saying = next.length > SAYING_CAP ? '' : next;
7942
- return true;
7943
- }
7944
- return false;
7945
- } catch {
7946
- return false;
7947
- }
7948
- }
7949
-
7950
- // HOW MANY HELPERS THIS SESSION HAS OUT RIGHT NOW.
7951
- //
7952
- // THE ONLY TWO EVENTS THAT MATTER, and they bracket a helper exactly. Measured
7953
- // on Claude Code 2.1.246 over a real four-helper run (the probe is written up
7954
- // in decisions.md, 08-26): `task_started` carries a fresh `task_id`, and
7955
- // `task_updated` carries a `patch.status` of `completed` for that same id. Four
7956
- // starts, four completions, no id seen twice and none left open. So a set of
7957
- // live ids, and its size is the number.
7958
- //
7959
- // A SET AND NOT A COUNTER, because a counter cannot survive a duplicate: the
7960
- // stream is read line by line off a pipe, and one repeated line would leave the
7961
- // page saying five helpers are working for the rest of the run with nothing
7962
- // able to correct it. Adding an id twice is free.
7963
- //
7964
- // NESTED HELPERS ARE COUNTED THE SAME. `spawn_depth` says how deep a helper
7965
- // sits, and this deliberately ignores it: a helper's helper is still a
7966
- // subagent working on her task.
7967
- //
7968
- // THE LEAD IS NOT IN THIS NUMBER AND NEVER HAS BEEN, which is what the line
7969
- // drawn off it was getting wrong until 2026-08-27. Only a `task_started` id is
7970
- // ever added, and the lead has no `task_started` of its own.It is, so
7971
- // `shortWord` in renderer/src/live-line.ts now says Subagents, unhyphenated on
7972
- // her call of 2026-08-27 and on a count of the spelling in the Claude Code
7973
- // build she runs. If this ever starts counting the lead as well, that word has
7974
- // to move with it. DID THIS RUN LEAVE HER ANYTHING TO READ?
7975
- //
7976
- // The rule that divides the two writers, kept in one named place because
7977
- // getting it wrong is silence, and silence is the failure that matters most:
7978
- //
7979
- // - said nothing at all -> the supervisor speaks for it (sayTheRunDied)
7980
- // - said something wrong -> the supervisor speaks for it, in OUR words
7981
- // - said something real -> it speaks for itself, untouched
7982
- //
7983
- // The middle line is the one that was missing. Both writers used to exclude an
7984
- // error result: `speaksForTheSession` because raw CLI text must never reach her
7985
- // row, and this one because it only looked for a result that was absent. Each
7986
- // was right on its own and together they left the case that matters most with
7987
- // no writer at all.
7988
- export function saidNothingSheCanUse(session) {
7989
- return session?.result == null || !!session.resultIsError;
7990
- }
7991
-
7992
- export function countHelpers(session, obj) {
7993
- if (obj?.type !== 'system') return;
7994
- if (obj.subtype === 'task_started' && obj.task_id) {
7995
- (session.helperIds ??= new Set()).add(obj.task_id);
7996
- } else if (obj.subtype === 'task_updated' && obj.task_id) {
7997
- // Anything that is not still running ends it. The measured value is
7998
- // `completed`, and a helper that fails or is cancelled has equally stopped
7999
- // working, so the test is what it is NOT rather than a list of endings we
8000
- // would have to keep in step with the CLI.
8001
- const status = obj.patch?.status;
8002
- if (status && status !== 'in_progress' && status !== 'running') session.helperIds?.delete(obj.task_id);
8003
- }
8004
- }
8005
-
8006
- // The persisted trace line: fuller than the tail. Tool calls carry their key
8007
- // input (the file written, the command run), because "tool: Bash" answers
8008
- // nothing when the founder asks what a session actually did.
8009
- function traceStreamLine(line) {
8010
- try {
8011
- const obj = JSON.parse(line);
8012
- const at = new Date().toISOString().slice(11, 19);
8013
- const hook = hookFailure(obj);
8014
- if (hook) return `${at} ${hook}`;
8015
- if (obj.type === 'assistant' && obj.message?.content) {
8016
- const parts = [];
8017
- for (const c of obj.message.content) {
8018
- if (c.type === 'text' && c.text?.trim()) parts.push(c.text.trim());
8019
- if (c.type === 'tool_use') {
8020
- const input = c.input ?? {};
8021
- const hint = input.file_path ?? input.path ?? input.command ?? input.title ?? input.description ?? input.id ?? '';
8022
- parts.push(`[${c.name}] ${String(hint).slice(0, 200)}`);
8023
- }
8024
- }
8025
- return parts.length ? parts.map((p) => `${at} ${p}`).join('\n') : null;
8026
- }
8027
- if (obj.type === 'result') {
8028
- const flavor = `${obj.subtype ?? 'ok'}${obj.is_error ? ' ERROR' : ''} · ${obj.num_turns ?? '?'} turns`;
8029
- return `\n${at} == RESULT (${flavor}) ==\n${String(obj.result ?? '').slice(0, 4000)}`;
8030
- }
8031
- return null;
8032
- } catch {
8033
- return null;
8034
- }
8035
- }
8036
-
8037
- // Stream-json lines are verbose; the In Progress tail wants the gist.
8038
- function summarizeStreamLine(line) {
8039
- try {
8040
- const obj = JSON.parse(line);
8041
- const hook = hookFailure(obj);
8042
- if (hook) return hook;
8043
- if (obj.type === 'assistant' && obj.message?.content) {
8044
- const text = obj.message.content.filter((c) => c.type === 'text').map((c) => c.text).join(' ');
8045
- const tools = obj.message.content.filter((c) => c.type === 'tool_use').map((c) => c.name);
8046
- if (tools.length) return `tool: ${tools.join(', ')}`;
8047
- if (text) return text.slice(0, 300);
8048
- }
8049
- if (obj.type === 'result') return `done: ${String(obj.result ?? '').slice(0, 200)}`;
8050
- return null;
8051
- } catch {
8052
- return line.slice(0, 200);
8053
- }
8054
- }
8055
-
8056
8471
  /**
8057
8472
  * THE FOUR READERS OF ONE ENGINE'S OUTPUT, PICKED ONCE PER SESSION.
8058
8473
  *
@@ -8076,17 +8491,6 @@ function summarizeStreamLine(line) {
8076
8491
  * worse than one that reads a stream with the default engine's eyes.
8077
8492
  */
8078
8493
  export function readersFor(engine) {
8079
- return engine === 'codex'
8080
- ? {
8081
- capture: captureCodexEvent,
8082
- stream: codexStreamingText,
8083
- summarize: summarizeCodexEvent,
8084
- trace: traceCodexEvent,
8085
- }
8086
- : {
8087
- capture: captureStream,
8088
- stream: streamingText,
8089
- summarize: summarizeStreamLine,
8090
- trace: traceStreamLine,
8091
- };
8494
+ const { capture, stream, summarize, trace } = harnessFor(engineOf(engine)).readers();
8495
+ return { capture, stream, summarize, trace };
8092
8496
  }