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