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.
- package/main/acp.mjs +296 -0
- package/main/agent-homes.mjs +158 -0
- package/main/agent-sessions.mjs +57 -20
- package/main/agent-updates.mjs +9 -22
- package/main/analytics.mjs +30 -0
- package/main/claude-bin.mjs +45 -311
- package/main/claude-input.mjs +9 -3
- package/main/claude-launch-env.mjs +54 -0
- package/main/claude-models.mjs +39 -7
- package/main/claude-plan.mjs +16 -16
- package/main/claude-usage.mjs +82 -25
- package/main/codex-bin.mjs +51 -249
- package/main/codex-session.mjs +38 -3
- package/main/codex-threads.mjs +41 -8
- package/main/codex-watch.mjs +67 -3
- package/main/config.mjs +26 -4
- package/main/corner-tag.mjs +489 -0
- package/main/crash-report.mjs +26 -3
- package/main/engine-bin.mjs +595 -0
- package/main/engine-setup.mjs +4 -15
- package/main/feedback.mjs +11 -6
- package/main/harnesses/claude-stream.mjs +208 -0
- package/main/harnesses/claude.mjs +84 -0
- package/main/harnesses/codex.mjs +65 -0
- package/main/harnesses/common.mjs +78 -0
- package/main/harnesses/instructions-file.mjs +116 -0
- package/main/harnesses/opencode.mjs +234 -0
- package/main/harnesses.mjs +65 -0
- package/main/importable-threads.mjs +38 -0
- package/main/ipc.mjs +165 -31
- package/main/local-app-frames.mjs +82 -0
- package/main/main.mjs +100 -4
- package/main/memory-gate-history.mjs +51 -12
- package/main/memory-gate-server.mjs +67 -11
- package/main/memory-gate.mjs +49 -8
- package/main/memory-log.mjs +124 -0
- package/main/notify.mjs +47 -4
- package/main/opencode-server.mjs +271 -0
- package/main/opencode.mjs +287 -0
- package/main/phone-link.mjs +551 -0
- package/main/relay-address.mjs +28 -0
- package/main/relay-client.mjs +246 -0
- package/main/row-label.mjs +6 -6
- package/main/runs-on.mjs +32 -35
- package/main/serve.mjs +113 -9
- package/main/settings.mjs +148 -10
- package/main/shell-path.mjs +86 -0
- package/main/ship-queue.mjs +24 -6
- package/main/sign-in-files.mjs +3 -7
- package/main/source-updater.mjs +27 -1
- package/main/start-hold.mjs +73 -0
- package/main/store.mjs +1 -1
- package/main/supervisor.mjs +832 -428
- package/main/task-commands.mjs +13 -17
- package/main/task-folders-offthread.mjs +6 -1
- package/main/task-folders.mjs +490 -98
- package/main/task-terminals.mjs +37 -7
- package/main/updater.mjs +71 -9
- package/main/worktree-include.mjs +494 -0
- package/package.json +10 -1
- package/renderer/dist/assets/abnfDiagram-YUKMZFIV-DPPuMWt-.js +1 -0
- package/renderer/dist/assets/arc-HmFfdseH.js +1 -0
- package/renderer/dist/assets/architectureDiagram-47P4ROYG-DxJ3zHrH.js +36 -0
- package/renderer/dist/assets/blockDiagram-E7TT5TSB-CbTXzOqQ.js +140 -0
- package/renderer/dist/assets/c4Diagram-CRA5TL53-X5ItFyLn.js +38 -0
- package/renderer/dist/assets/channel-BlT2g9Sz.js +1 -0
- package/renderer/dist/assets/chunk-2Q5K7J3B-DC12Ho_L.js +1 -0
- package/renderer/dist/assets/chunk-5VM5RSS4-ClVtfPd0.js +15 -0
- package/renderer/dist/assets/chunk-7TKQ45FW-DgcOm0HT.js +1 -0
- package/renderer/dist/assets/chunk-AW2ZBBNX-C01s4yoF.js +88 -0
- package/renderer/dist/assets/chunk-FVRAUYC3-Bj0zxhR4.js +2 -0
- package/renderer/dist/assets/chunk-HTAEGDNF-DWDeEhLU.js +1 -0
- package/renderer/dist/assets/chunk-JWPE2WC7-Dsqwv3eW.js +1 -0
- package/renderer/dist/assets/chunk-KQW6MTUR-CE86ANL2.js +1 -0
- package/renderer/dist/assets/chunk-XXDRQBXY-CoNTASfs.js +1 -0
- package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-2wprLqtw.js +217 -0
- package/renderer/dist/assets/cornerTag-BLrxiDE5.css +1 -0
- package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +1 -0
- package/renderer/dist/assets/cose-bilkent-JH36ORCC-enqMiYbn.js +1 -0
- package/renderer/dist/assets/cynefin-EF2NZ3EQ-DHngLxvQ.js +141 -0
- package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-C0rZZIl3.js +62 -0
- package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
- package/renderer/dist/assets/dagre-W4DXFKR2-DQnaEWO7.js +4 -0
- package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/renderer/dist/assets/diagram-2UJZ2QOL-CS8AlGPx.js +200 -0
- package/renderer/dist/assets/diagram-OVF4WLC6-yiDMHIkY.js +24 -0
- package/renderer/dist/assets/diagram-PFPMY2P6-XbQKzbBA.js +30 -0
- package/renderer/dist/assets/diagram-UMYRVEAY-dhVvlfl_.js +24 -0
- package/renderer/dist/assets/diagram-YEKJPTXX-DZHe8SCf.js +41 -0
- package/renderer/dist/assets/diagram-ZIFT7M5P-B5h6aXVR.js +3 -0
- package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-PR9ScDco.js +1 -0
- package/renderer/dist/assets/elk-IJKZMXRS-Cg-yQXPd.js +27 -0
- package/renderer/dist/assets/erDiagram-O2IAPWRE-omOrJMxu.js +99 -0
- package/renderer/dist/assets/flowDiagram-OXPTDLAJ-aOX2hzvT.js +213 -0
- package/renderer/dist/assets/ganttDiagram-R7TSEDQI-B1H3pWyI.js +292 -0
- package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-Cvw304HC.js +106 -0
- package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
- package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-_ZXcNViH.js +2 -0
- package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
- package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js +70 -0
- package/renderer/dist/assets/journeyDiagram-COXZFDF6-fr62QoAt.js +139 -0
- package/renderer/dist/assets/kanban-definition-P3RFRI5V-J8zsabCe.js +89 -0
- package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
- package/renderer/dist/assets/linear-BmNdEtIi.js +1 -0
- package/renderer/dist/assets/main-ChNdnUvt.js +1132 -0
- package/renderer/dist/assets/main-nj0QR5kg.css +32 -0
- package/renderer/dist/assets/mermaid.core-CnYzvFkz.js +314 -0
- package/renderer/dist/assets/mindmap-definition-LZFPQGKD-Bfcxc7PM.js +96 -0
- package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/renderer/dist/assets/pegDiagram-BGZESJAR-CwUJOq6X.js +1 -0
- package/renderer/dist/assets/pieDiagram-CAPJLFHJ-BrwMmqfC.js +39 -0
- package/renderer/dist/assets/product-name-CTKLYNWY.js +40 -0
- package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-BN4Stoj-.js +7 -0
- package/renderer/dist/assets/railroadDiagram-SM67HX2B-CPZ5bbeR.js +1 -0
- package/renderer/dist/assets/requirementDiagram-X7JNWC4A-CgRnimVe.js +84 -0
- package/renderer/dist/assets/sankeyDiagram-UM26HJRW-Bt2rej__.js +40 -0
- package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js +169 -0
- package/renderer/dist/assets/sizeCapture-INFHLROL-usyNyXyh.js +1 -0
- package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-CRzUBb0u.js +291 -0
- package/renderer/dist/assets/swimlanes-N4OXWK64-C1GJvMS0.js +1 -0
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +8 -0
- package/renderer/dist/assets/timeline-definition-YOQAKGHF-caeyPlcc.js +120 -0
- package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-BN_SV6PC.js +370 -0
- package/renderer/dist/assets/vennDiagram-BLWOH2XV-BLGuaUX-.js +34 -0
- package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-Da0cJeNc.js +78 -0
- package/renderer/dist/assets/xychartDiagram-TAQBALBS-cGfUffMk.js +7 -0
- package/renderer/dist/corner-tag.html +13 -0
- package/renderer/dist/emulator/audio-rules.mjs +74 -0
- package/renderer/dist/emulator/data/compression/README.md +11 -0
- package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
- package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
- package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
- package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
- package/renderer/dist/emulator/data/cores/README.md +22 -0
- package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/package.json +65 -0
- package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
- package/renderer/dist/emulator/data/emulator.css +1614 -0
- package/renderer/dist/emulator/data/loader.js +170 -0
- package/renderer/dist/emulator/data/localization/README.md +63 -0
- package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
- package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
- package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
- package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
- package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
- package/renderer/dist/emulator/data/localization/en-US.json +339 -0
- package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
- package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
- package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
- package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
- package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
- package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
- package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
- package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
- package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
- package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
- package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
- package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
- package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
- package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
- package/renderer/dist/emulator/data/src/GameManager.js +464 -0
- package/renderer/dist/emulator/data/src/compression.js +142 -0
- package/renderer/dist/emulator/data/src/emulator.js +6158 -0
- package/renderer/dist/emulator/data/src/gamepad.js +167 -0
- package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
- package/renderer/dist/emulator/data/src/shaders.js +236 -0
- package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
- package/renderer/dist/emulator/data/src/storage.js +125 -0
- package/renderer/dist/emulator/data/version.json +5 -0
- package/renderer/dist/emulator/host.js +297 -0
- package/renderer/dist/emulator/index.html +28 -0
- package/renderer/dist/emulator/provenance.json +6 -0
- package/renderer/dist/index.html +16 -3
- package/scripts/approval-server.sh +24 -5
- package/scripts/before-publish.mjs +12 -10
- package/shared/agent-import.mjs +2 -0
- package/shared/agents.d.mts +1 -0
- package/shared/bridge-map.mjs +18 -0
- package/shared/claude-commands.generated.mjs +6 -4
- package/shared/claude-models.generated.mjs +6 -4
- package/shared/corner-tag.d.mts +14 -0
- package/shared/corner-tag.mjs +141 -0
- package/shared/engines.mjs +20 -5
- package/shared/harness-definitions.mjs +103 -0
- package/shared/notify-rules.d.mts +25 -0
- package/shared/notify-rules.mjs +19 -0
- package/shared/provider-commands.mjs +10 -2
- package/shared/rank.d.mts +2 -0
- package/shared/rank.mjs +50 -0
- package/shared/relay-address.mjs +6 -0
- package/shared/relay-crypto.mjs +165 -0
- package/shared/runs-on.d.mts +0 -10
- package/shared/runs-on.mjs +25 -49
- package/shared/shortcuts.mjs +10 -4
- package/shared/spawn-trouble.mjs +57 -6
- package/shared/terminal-input.d.mts +1 -0
- package/shared/terminal-input.mjs +30 -0
- package/shared/terminal-keys.d.mts +1 -0
- package/shared/terminal-keys.mjs +16 -0
- package/shared/terminal-size.d.mts +4 -0
- package/shared/terminal-size.mjs +29 -0
- package/shared/usage.d.mts +3 -0
- package/shared/usage.mjs +7 -0
- package/shared/where-it-runs-from.d.mts +43 -0
- package/shared/where-it-runs-from.mjs +155 -0
- package/shared/work-items.mjs +25 -1
- package/shared/work-lines.mjs +1 -1
- package/shared/xterm-invalid-range.d.mts +2 -0
- package/shared/xterm-invalid-range.mjs +58 -0
- package/renderer/dist/assets/index-ChKsQkRw.js +0 -1169
- package/renderer/dist/assets/index-ClBXnq3B.css +0 -32
package/main/supervisor.mjs
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
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
|
-
.
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, ...
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
3159
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
const per =
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4848
|
-
const command = continuation && engine
|
|
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
|
|
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
|
-
|
|
4896
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5496
|
-
* (w-e217e577e5, 2026-10-07). Null on a Mac where nothing readable is
|
|
5497
|
-
* in, and then nothing is
|
|
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
|
|
5505
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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 (
|
|
6557
|
-
|
|
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 } =
|
|
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 &&
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|