@lyupro/codex-bridge 0.1.3 → 0.2.0
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/CHANGELOG.md +44 -0
- package/README.md +18 -4
- package/bin/codex-bridge.mjs +29 -0
- package/cli/doctor.mjs +26 -3
- package/cli/install.mjs +18 -4
- package/cli/manifest.mjs +6 -4
- package/cli/projects.mjs +92 -0
- package/cli/prune-args.mjs +133 -0
- package/cli/prune-plan.mjs +205 -0
- package/cli/prune.mjs +278 -0
- package/cli/read.mjs +103 -0
- package/cli/run-lookup.mjs +59 -0
- package/cli/runs-inventory.mjs +173 -0
- package/cli/settings-merge.mjs +18 -4
- package/cli/stop.mjs +4 -37
- package/cli/table.mjs +110 -0
- package/package.json +1 -1
- package/src/agents/codex-build.md +12 -10
- package/src/agents/codex-review.md +11 -5
- package/src/agents/codex-scout.md +11 -10
- package/src/commands/usage.md +4 -3
- package/src/hook-definitions.mjs +22 -0
- package/src/hooks/live-runs.mjs +98 -0
- package/src/hooks/prune-guard.mjs +62 -0
- package/src/hooks/reply-guard.mjs +45 -22
- package/src/hooks/worktree-lock.mjs +70 -0
- package/src/meta/deadline.mjs +19 -0
- package/src/meta/events.mjs +166 -0
- package/src/meta/reason.mjs +29 -0
- package/src/meta/reply.mjs +40 -7
- package/src/meta/run-state.mjs +7 -1
- package/src/meta/startup.mjs +21 -0
- package/src/meta/transport.mjs +35 -0
- package/src/meta/verdict.mjs +40 -42
- package/src/required-inputs.mjs +49 -6
- package/src/retention.mjs +120 -0
- package/src/run-codex.mjs +3 -3
- package/src/run-config.json +5 -1
- package/src/run-config.mjs +54 -1
- package/src/runner/attach.mjs +1 -1
- package/src/runner/codex-cmd.mjs +119 -31
- package/src/runner/continuation.mjs +107 -0
- package/src/runner/launcher.mjs +23 -40
- package/src/runner/worker.mjs +11 -3
- package/src/write-meta.mjs +31 -22
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, fail-open knowledge of live Codex runs.
|
|
3
|
+
*
|
|
4
|
+
* The reply guard and the worktree lock must answer the same question: a status.json is live
|
|
5
|
+
* only while it says `running` and its runner pid accepts signal 0. Keeping that probe and the
|
|
6
|
+
* directory scan here prevents the two protections from disagreeing about a run, which would
|
|
7
|
+
* recreate the incident where the dispatcher reported while another run still edited the tree.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
/** Signal 0 tests existence without stopping the process; EPERM still means that it exists. */
|
|
13
|
+
export const isPidAlive = (pid) => {
|
|
14
|
+
if (typeof pid !== 'number') return false;
|
|
15
|
+
try {
|
|
16
|
+
process.kill(pid, 0);
|
|
17
|
+
return true;
|
|
18
|
+
} catch (err) {
|
|
19
|
+
return err.code === 'EPERM';
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Match the package's repository comparison without resolving symlinks: Windows realpath adds
|
|
25
|
+
* `\\?\\` and UNC spellings, turning equal paths into unequal ones. A null result is uncertainty,
|
|
26
|
+
* and callers must pass rather than deny on it.
|
|
27
|
+
*/
|
|
28
|
+
export function normalizePath(value) {
|
|
29
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
30
|
+
try {
|
|
31
|
+
let normalized = path.resolve(value).replaceAll('\\', '/').replace(/\/+$/, '');
|
|
32
|
+
if (!normalized) normalized = '/';
|
|
33
|
+
if (process.platform === 'win32') normalized = normalized.toLowerCase();
|
|
34
|
+
return normalized;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isNonEmptyString(value) {
|
|
41
|
+
return typeof value === 'string' && Boolean(value.trim());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function recognizedStatus(status) {
|
|
45
|
+
return Boolean(status)
|
|
46
|
+
&& typeof status === 'object'
|
|
47
|
+
&& !Array.isArray(status)
|
|
48
|
+
&& status.state === 'running'
|
|
49
|
+
&& typeof status.pid === 'number'
|
|
50
|
+
&& isNonEmptyString(status.agent)
|
|
51
|
+
&& isNonEmptyString(status.slug)
|
|
52
|
+
&& isNonEmptyString(status.repo)
|
|
53
|
+
&& isPidAlive(status.pid);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readStatus(runDir) {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(fs.readFileSync(path.join(runDir, 'status.json'), 'utf8'));
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Scan one project's direct run folders; unreadable or unfamiliar entries are ignored. */
|
|
65
|
+
export function liveRuns(runsDir) {
|
|
66
|
+
if (typeof runsDir !== 'string' || !runsDir.trim()) return [];
|
|
67
|
+
let entries;
|
|
68
|
+
try {
|
|
69
|
+
entries = fs.readdirSync(runsDir, { withFileTypes: true });
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
const result = [];
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (!entry.isDirectory()) continue;
|
|
76
|
+
const dir = path.join(runsDir, entry.name);
|
|
77
|
+
const status = readStatus(dir);
|
|
78
|
+
if (recognizedStatus(status)) result.push({ dir, status });
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Scan every project under the configured runs root for worktree ownership. */
|
|
84
|
+
export function allLiveRuns(runsRoot) {
|
|
85
|
+
if (typeof runsRoot !== 'string' || !runsRoot.trim()) return [];
|
|
86
|
+
let projects;
|
|
87
|
+
try {
|
|
88
|
+
projects = fs.readdirSync(runsRoot, { withFileTypes: true });
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
const result = [];
|
|
93
|
+
for (const project of projects) {
|
|
94
|
+
if (!project.isDirectory()) continue;
|
|
95
|
+
result.push(...liveRuns(path.join(runsRoot, project.name)));
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse guard that keeps `codex-bridge prune` out of agent-issued shell commands.
|
|
4
|
+
*
|
|
5
|
+
* Deletion is an operator action. The command already refuses without a terminal, but a guard in
|
|
6
|
+
* front of it is the second rubezh Plan_17 asks for: the TTY check lives inside the very process an
|
|
7
|
+
* agent is asking to start, and a package that trusts one check has trusted prose twice before
|
|
8
|
+
* (Plan_13, Plan_14) and been wrong both times. Denying the call means no deleting process is
|
|
9
|
+
* created at all.
|
|
10
|
+
*
|
|
11
|
+
* Anything unrecognised passes. A guard that misreads its input must not break unrelated host work.
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import { SHELL_TOOLS } from '../hook-definitions.mjs';
|
|
15
|
+
|
|
16
|
+
const SHELL_TOOL_NAMES = new Set(SHELL_TOOLS);
|
|
17
|
+
const pass = () => process.exit(0);
|
|
18
|
+
|
|
19
|
+
// The binary, then the verb, with any path in front of it: an agent reaches the CLI as
|
|
20
|
+
// `codex-bridge`, as `node bin/codex-bridge.mjs`, or through an absolute path, and a guard that
|
|
21
|
+
// only knew the bare name would be bypassed by the spelling everyone uses inside a clone. Matching
|
|
22
|
+
// a lone `prune` is not an option either — `git prune` and `npm prune` are not this package's
|
|
23
|
+
// business, and a guard that breaks unrelated work is a guard that gets uninstalled.
|
|
24
|
+
const PRUNE_CALL = /(^|[\s;&|(`])(\S*[\\/])?codex-bridge(\.mjs)?\s+prune(\s|$)/;
|
|
25
|
+
|
|
26
|
+
let input;
|
|
27
|
+
try {
|
|
28
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
29
|
+
} catch {
|
|
30
|
+
pass();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
|
|
34
|
+
if (!SHELL_TOOL_NAMES.has(input.tool_name)) pass();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Strips the parts of a command line that are data rather than instructions: heredoc bodies and
|
|
38
|
+
* quoted strings. Found the moment this guard existed — it denied the commit that introduced it,
|
|
39
|
+
* because the message text quoted the command name. Nothing was going to be deleted; the words
|
|
40
|
+
* were cargo. A guard that cannot tell an invocation from a mention refuses honest work, and a
|
|
41
|
+
* guard that refuses honest work is removed, after which it guards nothing.
|
|
42
|
+
*/
|
|
43
|
+
function withoutQuotedText(command) {
|
|
44
|
+
return command
|
|
45
|
+
.replace(/<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[\s\S]*?^\s*\2\s*$/gm, ' ')
|
|
46
|
+
.replace(/'[^']*'/g, ' ')
|
|
47
|
+
.replace(/"[^"]*"/g, ' ');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const command = input.tool_input?.command;
|
|
51
|
+
if (typeof command !== 'string' || !command.trim()) pass();
|
|
52
|
+
if (!PRUNE_CALL.test(withoutQuotedText(command))) pass();
|
|
53
|
+
|
|
54
|
+
process.stdout.write(JSON.stringify({
|
|
55
|
+
hookSpecificOutput: {
|
|
56
|
+
hookEventName: 'PreToolUse',
|
|
57
|
+
permissionDecision: 'deny',
|
|
58
|
+
permissionDecisionReason: 'codex-bridge prune deletes run artifacts and is an operator action, '
|
|
59
|
+
+ 'not an agent one. There is deliberately no bypass flag. Ask the operator to run it in '
|
|
60
|
+
+ 'their own terminal; `codex-bridge projects` shows the same store read-only.',
|
|
61
|
+
},
|
|
62
|
+
}));
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import fs from 'node:fs';
|
|
30
30
|
import os from 'node:os';
|
|
31
31
|
import path from 'node:path';
|
|
32
|
+
import { isPidAlive, liveRuns, normalizePath } from './live-runs.mjs';
|
|
32
33
|
|
|
33
34
|
const HOME = os.homedir();
|
|
34
35
|
const LOG_DIR = path.join(HOME, '.claude', 'logs');
|
|
@@ -168,8 +169,9 @@ const blockState = (reason, stopReason) => {
|
|
|
168
169
|
* verdict is the stdout of the attaching call. Matching only `RUN=` would block every honest
|
|
169
170
|
* answer given under the new contract as "did not delegate at all".
|
|
170
171
|
*/
|
|
171
|
-
const
|
|
172
|
-
const
|
|
172
|
+
const cleanRunDir = (value) => value.trim().replace(/\s+started=\S*$/, '').replace(/[`"'*]+$/g, '');
|
|
173
|
+
const runDirs = [...reply.matchAll(/(?:RUN|ATTACH)=(.+?)(?:\r?\n|$)/g)].map((match) => cleanRunDir(match[1]));
|
|
174
|
+
const runDir = runDirs[0] || null;
|
|
173
175
|
|
|
174
176
|
if (!runDir || !fs.existsSync(runDir)) {
|
|
175
177
|
blockForm(
|
|
@@ -180,22 +182,6 @@ if (!runDir || !fs.existsSync(runDir)) {
|
|
|
180
182
|
);
|
|
181
183
|
}
|
|
182
184
|
|
|
183
|
-
/**
|
|
184
|
-
* Best-effort liveness probe: signal 0 sends nothing, it only tests that the pid exists.
|
|
185
|
-
* EPERM means the process is there and owned by someone else — alive for our purposes, and
|
|
186
|
-
* the same reading write-meta.mjs uses. Two answers to "is this run still going" would be
|
|
187
|
-
* one answer too many.
|
|
188
|
-
*/
|
|
189
|
-
const isPidAlive = (pid) => {
|
|
190
|
-
if (typeof pid !== 'number') return false;
|
|
191
|
-
try {
|
|
192
|
-
process.kill(pid, 0);
|
|
193
|
-
return true;
|
|
194
|
-
} catch (err) {
|
|
195
|
-
return err.code === 'EPERM';
|
|
196
|
-
}
|
|
197
|
-
};
|
|
198
|
-
|
|
199
185
|
/**
|
|
200
186
|
* The text the operator reads when the state budget is gone. Everything in it is quoted
|
|
201
187
|
* from status.json — the operator is being told to stop, and a stop justified by guesswork
|
|
@@ -203,12 +189,12 @@ const isPidAlive = (pid) => {
|
|
|
203
189
|
*/
|
|
204
190
|
const fact = (value) => (value === undefined || value === null || value === '' ? 'not recorded' : String(value));
|
|
205
191
|
|
|
206
|
-
const stopText = (headline, observed, runStatus) =>
|
|
192
|
+
const stopText = (headline, observed, runStatus, folder = runDir) =>
|
|
207
193
|
[
|
|
208
194
|
headline,
|
|
209
195
|
`Run: slug ${fact(runStatus?.slug)}, agent ${fact(runStatus?.agent)}, repository ` +
|
|
210
196
|
`${fact(runStatus?.repo)}, started ${fact(runStatus?.started_at)}.`,
|
|
211
|
-
`Run folder: ${
|
|
197
|
+
`Run folder: ${folder}. Read state from ${path.join(folder, 'status.json')}; the verdict ` +
|
|
212
198
|
'will appear in the adjacent meta.json.',
|
|
213
199
|
observed,
|
|
214
200
|
`Worktree ${fact(runStatus?.repo)} is busy with this run: do not build, test, or commit until ` +
|
|
@@ -220,6 +206,43 @@ const stopText = (headline, observed, runStatus) =>
|
|
|
220
206
|
'command with the same --order-id attaches to this run rather than starting another.',
|
|
221
207
|
].join(' ');
|
|
222
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The dispatcher must name every WRITING run the project has live, not just the one it chose to
|
|
211
|
+
* quote. The sibling scan is disk-only, so silence cannot hide a second runner; a scan failure
|
|
212
|
+
* remains fail-open because an uncertain diagnostic must never deny unrelated work.
|
|
213
|
+
*
|
|
214
|
+
* Only codex-build counts. Scout and review run in a read-only sandbox and leave the worktree
|
|
215
|
+
* alone, and launching them alongside other work is deliberate practice here — blocking a reply
|
|
216
|
+
* over a live reader would spend the state budget, and possibly the session, on a run that
|
|
217
|
+
* threatens nothing. What the 2026-08-05 incident cost was a hidden WRITER: the orchestrator
|
|
218
|
+
* edited files that landed in its before/after snapshot.
|
|
219
|
+
*/
|
|
220
|
+
const namedRunDirs = new Set(runDirs.map((dir) => normalizePath(dir)).filter(Boolean));
|
|
221
|
+
let silentLiveSibling = null;
|
|
222
|
+
try {
|
|
223
|
+
silentLiveSibling = liveRuns(path.dirname(runDir)).find((run) => {
|
|
224
|
+
if (run.status.agent !== 'codex-build') return false;
|
|
225
|
+
const normalized = normalizePath(run.dir);
|
|
226
|
+
return normalized && !namedRunDirs.has(normalized);
|
|
227
|
+
});
|
|
228
|
+
} catch {
|
|
229
|
+
silentLiveSibling = null;
|
|
230
|
+
}
|
|
231
|
+
if (silentLiveSibling) {
|
|
232
|
+
const { dir, status } = silentLiveSibling;
|
|
233
|
+
blockState(
|
|
234
|
+
`Contract violated: the response names ${runDir} but omits live run folder ${dir}; report every `
|
|
235
|
+
+ `live run with its agent ${status.agent}, slug ${status.slug}, and repository ${status.repo}.`,
|
|
236
|
+
stopText(
|
|
237
|
+
`The reply guard stopped the session: the dispatcher responded ${MAX_STATE_BLOCKS} times `
|
|
238
|
+
+ 'while a live sibling Codex run was omitted from its reply.',
|
|
239
|
+
`status.json in ${dir} says state=running; process pid ${fact(status.pid)} is alive.`,
|
|
240
|
+
status,
|
|
241
|
+
dir,
|
|
242
|
+
),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
223
246
|
/**
|
|
224
247
|
* status.json is written by run-codex.mjs before meta.json exists, so it catches the
|
|
225
248
|
* dispatcher that reports before its own run is done — meta.json alone cannot, because a
|
|
@@ -261,7 +284,7 @@ if (fs.existsSync(statusPath)) {
|
|
|
261
284
|
`status.json currently says state=running, but process pid ${fact(runStatus.pid)} is ` +
|
|
262
285
|
'dead and meta.json is missing: an interrupted Bash call killed the runner. Codex ' +
|
|
263
286
|
'survives the runner and keeps editing the tree — in run 2026-07-31_114736, changes ' +
|
|
264
|
-
'in 11+ files survived while
|
|
287
|
+
'in 11+ files survived while no run artifact and no meta.json were ever recorded.',
|
|
265
288
|
runStatus,
|
|
266
289
|
),
|
|
267
290
|
);
|
|
@@ -277,7 +300,7 @@ if (fs.existsSync(statusPath)) {
|
|
|
277
300
|
`status.json currently says state=abandoned (${fact(runStatus.abandoned_reason)}, ` +
|
|
278
301
|
`${fact(runStatus.abandoned_at)}): the runner died without recording a verdict. Codex ` +
|
|
279
302
|
'survives the runner and keeps editing the tree — in run 2026-07-31_114736, changes in ' +
|
|
280
|
-
'11+ files survived while
|
|
303
|
+
'11+ files survived while no run artifact and no meta.json were ever recorded.',
|
|
281
304
|
runStatus,
|
|
282
305
|
),
|
|
283
306
|
);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse lock for file-writing tools in the Claude Code host.
|
|
4
|
+
*
|
|
5
|
+
* A live codex-build runner snapshots its repository before and after editing. Letting the
|
|
6
|
+
* orchestrator write during that window folds unrelated work into the runner's result, exactly
|
|
7
|
+
* as the 2026-08-05 incident did. This hook denies only a recognised file path inside a live
|
|
8
|
+
* build run's repository; every malformed or unreadable input passes so diagnostics never break
|
|
9
|
+
* unrelated host work.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { WRITE_TOOLS } from '../hook-definitions.mjs';
|
|
14
|
+
import { runsRoot } from '../runner/runs-root.mjs';
|
|
15
|
+
import { allLiveRuns, normalizePath } from './live-runs.mjs';
|
|
16
|
+
|
|
17
|
+
const WRITE_TOOL_NAMES = new Set(WRITE_TOOLS);
|
|
18
|
+
const pass = () => process.exit(0);
|
|
19
|
+
|
|
20
|
+
let input;
|
|
21
|
+
try {
|
|
22
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
23
|
+
} catch {
|
|
24
|
+
pass();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
|
|
28
|
+
if (!WRITE_TOOL_NAMES.has(input.tool_name)) pass();
|
|
29
|
+
|
|
30
|
+
const toolInput = input.tool_input;
|
|
31
|
+
if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
|
|
32
|
+
const pathField = input.tool_name === 'NotebookEdit' ? 'notebook_path' : 'file_path';
|
|
33
|
+
const rawPath = toolInput[pathField];
|
|
34
|
+
if (typeof rawPath !== 'string' || !rawPath.trim()) pass();
|
|
35
|
+
|
|
36
|
+
let targetPath;
|
|
37
|
+
try {
|
|
38
|
+
if (path.isAbsolute(rawPath)) targetPath = rawPath;
|
|
39
|
+
else if (typeof input.cwd === 'string' && input.cwd.trim()) targetPath = path.resolve(input.cwd, rawPath);
|
|
40
|
+
else pass();
|
|
41
|
+
} catch {
|
|
42
|
+
pass();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const target = normalizePath(targetPath);
|
|
46
|
+
if (!target) pass();
|
|
47
|
+
|
|
48
|
+
let liveRuns;
|
|
49
|
+
try {
|
|
50
|
+
liveRuns = allLiveRuns(runsRoot());
|
|
51
|
+
} catch {
|
|
52
|
+
pass();
|
|
53
|
+
}
|
|
54
|
+
const owner = liveRuns.find(({ status }) => {
|
|
55
|
+
if (status.agent !== 'codex-build') return false;
|
|
56
|
+
const repository = normalizePath(status.repo);
|
|
57
|
+
return Boolean(repository) && (target === repository || target.startsWith(`${repository}/`));
|
|
58
|
+
});
|
|
59
|
+
if (!owner) pass();
|
|
60
|
+
|
|
61
|
+
const { status, dir } = owner;
|
|
62
|
+
process.stdout.write(JSON.stringify({
|
|
63
|
+
hookSpecificOutput: {
|
|
64
|
+
hookEventName: 'PreToolUse',
|
|
65
|
+
permissionDecision: 'deny',
|
|
66
|
+
permissionDecisionReason: `Worktree lock denied ${input.tool_name}: ${targetPath} is inside repository `
|
|
67
|
+
+ `${status.repo}, held by live run folder ${dir} (agent ${status.agent}, slug ${status.slug}). `
|
|
68
|
+
+ 'Wait for status.json to change state before editing this repository.',
|
|
69
|
+
},
|
|
70
|
+
}));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the runner-owned deadline fact from status.json.
|
|
3
|
+
*
|
|
4
|
+
* stderr.log is diagnostic text and can contain a quoted copy of the runner's own message,
|
|
5
|
+
* so it cannot prove that the runner actually stopped the process (Plan_15).
|
|
6
|
+
*/
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { readJson } from './paths.mjs';
|
|
9
|
+
|
|
10
|
+
export function deadlineReason(runDir) {
|
|
11
|
+
const status = readJson(path.join(runDir, 'status.json'));
|
|
12
|
+
if (status?.stopped_on_deadline !== true) return null;
|
|
13
|
+
// How long the run lived, not the budget it was given: a field named for the budget and
|
|
14
|
+
// holding the elapsed time is read wrong by whoever opens the folder months later.
|
|
15
|
+
const elapsed = status.elapsed_ms;
|
|
16
|
+
return Number.isFinite(elapsed)
|
|
17
|
+
? `run stopped on its deadline after ${elapsed} ms`
|
|
18
|
+
: 'run stopped on its deadline';
|
|
19
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the runner's structured Codex transport and turns it into facts for metadata and
|
|
3
|
+
* verdicts. This is the only module that knows the file is JSONL: a model can quote a limit in
|
|
4
|
+
* its own item text, but only these runner-written error events are allowed to stop a retry.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Quota exhaustion, and nothing else that merely says "limit". A bare `limit` also appears in
|
|
11
|
+
* "context length limit" — an error about the content of the order, which the orchestrator is
|
|
12
|
+
* supposed to fix and re-send. Calling that LIMIT would forbid the one retry that would work.
|
|
13
|
+
*/
|
|
14
|
+
const LIMIT_RE = /rate[\s_-]*limit|usage[\s_-]*limit|quota|too many requests|\b429\b/i;
|
|
15
|
+
|
|
16
|
+
const record = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
|
|
18
|
+
const compact = (value, max = 300) =>
|
|
19
|
+
String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A broken final line is expected when the runner is stopped while Codex is writing.
|
|
23
|
+
*
|
|
24
|
+
* Whether the file was there at all is reported separately from what it held: a run killed
|
|
25
|
+
* before the CLI said one word leaves an empty stream, and telling the operator that such a
|
|
26
|
+
* run "predates the change" sends them looking for the wrong thing entirely.
|
|
27
|
+
*/
|
|
28
|
+
function readLines(runDir) {
|
|
29
|
+
if (!runDir) return { exists: false, events: [] };
|
|
30
|
+
let text;
|
|
31
|
+
try {
|
|
32
|
+
text = fs.readFileSync(path.join(runDir, 'events.jsonl'), 'utf8');
|
|
33
|
+
} catch {
|
|
34
|
+
return { exists: false, events: [] };
|
|
35
|
+
}
|
|
36
|
+
const events = [];
|
|
37
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
38
|
+
const source = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
39
|
+
if (!source.trim()) continue;
|
|
40
|
+
try {
|
|
41
|
+
const event = JSON.parse(source);
|
|
42
|
+
if (record(event)) events.push(event);
|
|
43
|
+
} catch {
|
|
44
|
+
// A line cut short by MAX_LOG or a killed process is not a transport verdict.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { exists: true, events };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function usageOf(events) {
|
|
51
|
+
const completed = events.filter((event) => event.type === 'turn.completed' && record(event.usage));
|
|
52
|
+
if (!completed.length) return { tokens: null, usage: null };
|
|
53
|
+
|
|
54
|
+
const usage = {};
|
|
55
|
+
let tokens = 0;
|
|
56
|
+
for (const event of completed) {
|
|
57
|
+
for (const [key, value] of Object.entries(event.usage)) {
|
|
58
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
59
|
+
usage[key] = (usage[key] || 0) + value;
|
|
60
|
+
} else if (!(key in usage)) {
|
|
61
|
+
usage[key] = value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (typeof event.usage.input_tokens === 'number' && Number.isFinite(event.usage.input_tokens)) {
|
|
65
|
+
tokens += event.usage.input_tokens;
|
|
66
|
+
}
|
|
67
|
+
if (typeof event.usage.output_tokens === 'number' && Number.isFinite(event.usage.output_tokens)) {
|
|
68
|
+
tokens += event.usage.output_tokens;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { tokens, usage };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Finds the last content error emitted by the model.
|
|
76
|
+
*
|
|
77
|
+
* The 2026-08-05 live probe put policy rejections in stderr on a healthy run, so the
|
|
78
|
+
* model's own complaint must stay separate from that noisy diagnostic stream.
|
|
79
|
+
*
|
|
80
|
+
* Only what follows the first `turn.started` is the model's. codex-cli 0.146.0 announces its
|
|
81
|
+
* own configuration warnings through the same `item.completed` error shape before the turn
|
|
82
|
+
* begins: on 2026-08-06 every run of this host carried "`[features].codex_hooks` is
|
|
83
|
+
* deprecated" as its first item, and a failed run would have named that as the reason it
|
|
84
|
+
* failed. A complaint about the order cannot predate the model's first thought — so a run
|
|
85
|
+
* that never opened a turn has no content error at all, and falls back to the transport
|
|
86
|
+
* error or stderr the way it did before this stream existed.
|
|
87
|
+
*/
|
|
88
|
+
export function contentErrorOf(events) {
|
|
89
|
+
const turn = events.findIndex((event) => event?.type === 'turn.started');
|
|
90
|
+
if (turn < 0) return null;
|
|
91
|
+
for (let index = events.length - 1; index > turn; index -= 1) {
|
|
92
|
+
const event = events[index];
|
|
93
|
+
if (event?.type !== 'item.completed' || !record(event.item) || event.item.type !== 'error') continue;
|
|
94
|
+
const value = typeof event.item.message === 'string' ? event.item.message : event.item.text;
|
|
95
|
+
return typeof value === 'string' ? compact(value) : '';
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function payloadOf(event) {
|
|
101
|
+
const source =
|
|
102
|
+
event.type === 'error'
|
|
103
|
+
? event.message ?? event.error
|
|
104
|
+
: event.error?.message ?? event.error ?? event.message;
|
|
105
|
+
if (typeof source === 'string') {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(source);
|
|
108
|
+
return record(parsed) ? parsed : { message: source };
|
|
109
|
+
} catch {
|
|
110
|
+
return { message: source };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return record(source) ? source : {};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function transportErrorOf(event) {
|
|
117
|
+
if (event.type !== 'error' && event.type !== 'turn.failed') return null;
|
|
118
|
+
const payload = payloadOf(event);
|
|
119
|
+
const nested = record(payload.error) ? payload.error : {};
|
|
120
|
+
const errorType =
|
|
121
|
+
(typeof nested.type === 'string' && nested.type) ||
|
|
122
|
+
(typeof payload.type === 'string' && payload.type !== 'error' && payload.type) ||
|
|
123
|
+
null;
|
|
124
|
+
const message =
|
|
125
|
+
(typeof nested.message === 'string' && nested.message) ||
|
|
126
|
+
(typeof payload.message === 'string' && payload.message) ||
|
|
127
|
+
null;
|
|
128
|
+
const status = payload.status ?? nested.status ?? null;
|
|
129
|
+
const marker = [errorType, message].join(' ');
|
|
130
|
+
const quota = Number(status) === 429 || LIMIT_RE.test(marker);
|
|
131
|
+
const reason = compact(
|
|
132
|
+
[errorType, message, status === null || status === undefined ? '' : `status ${status}`]
|
|
133
|
+
.filter(Boolean)
|
|
134
|
+
.join(': ') ||
|
|
135
|
+
(typeof event.message === 'string' ? event.message : '') ||
|
|
136
|
+
(typeof event.error?.message === 'string' ? event.error.message : '') ||
|
|
137
|
+
event.type,
|
|
138
|
+
);
|
|
139
|
+
return { event, payload, status, error_type: errorType, message, quota, reason };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Reads all usable events and derives accounting without letting unreadable JSONL poison a run.
|
|
144
|
+
* Numeric usage fields are summed across turns; with one turn this preserves the CLI object
|
|
145
|
+
* and its fields exactly, while tokens intentionally count input plus output only.
|
|
146
|
+
*/
|
|
147
|
+
export function readEvents(runDir) {
|
|
148
|
+
const { exists, events } = readLines(runDir);
|
|
149
|
+
const accounting = usageOf(events);
|
|
150
|
+
const started = events.find((event) => event.type === 'thread.started' && event.thread_id);
|
|
151
|
+
const errors = events.map(transportErrorOf).filter(Boolean);
|
|
152
|
+
const transport_error = errors.find((error) => error.quota) || errors[0] || null;
|
|
153
|
+
return {
|
|
154
|
+
events,
|
|
155
|
+
// The file was written, whatever it holds. Pass 2 judges a run by this: a stream the
|
|
156
|
+
// runner promised in its own arguments and did not leave behind is damaged evidence,
|
|
157
|
+
// not an archived run.
|
|
158
|
+
hasStream: exists,
|
|
159
|
+
hasEvents: events.length > 0,
|
|
160
|
+
tokens: accounting.tokens,
|
|
161
|
+
usage: accounting.usage,
|
|
162
|
+
session_id: started ? String(started.thread_id) : null,
|
|
163
|
+
content_error: contentErrorOf(events),
|
|
164
|
+
transport_error,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves the ordered reason for a failed run.
|
|
3
|
+
*
|
|
4
|
+
* The 2026-08-05 live probe showed that stderr can contain policy noise during a healthy run,
|
|
5
|
+
* so diagnostic text is the final fallback rather than the primary verdict evidence.
|
|
6
|
+
*/
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { contentErrorOf } from './events.mjs';
|
|
9
|
+
import { line, readText } from './paths.mjs';
|
|
10
|
+
|
|
11
|
+
/** Keeps the archived stderr heuristic intact for runs without a useful event message. */
|
|
12
|
+
const stderrReason = (runDir) => {
|
|
13
|
+
const stderr = readText(path.join(runDir, 'stderr.log'));
|
|
14
|
+
const messages = [...stderr.matchAll(/"message"\s*:\s*"((?:[^"\\]|\\.)*)"/g)];
|
|
15
|
+
if (messages.length) return line(messages[messages.length - 1][1].replace(/\\"/g, '"'));
|
|
16
|
+
const lines = stderr.split(/\r?\n/).map((value) => value.trim()).filter(Boolean);
|
|
17
|
+
const errorLine = [...lines].reverse().find((value) => /error|failed|denied|refused/i.test(value));
|
|
18
|
+
return line(errorLine || lines[lines.length - 1] || '');
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// eventData is required, not defaulted: a caller that forgets it would silently fall back to
|
|
22
|
+
// stderr — the exact behaviour this module was written to replace, and a default would hide it.
|
|
23
|
+
export function reasonFrom(runDir, eventData) {
|
|
24
|
+
const content = line(eventData.content_error ?? contentErrorOf(eventData.events || []));
|
|
25
|
+
if (content) return content;
|
|
26
|
+
const transport = line(eventData.transport_error?.reason || eventData.transport_error?.message);
|
|
27
|
+
if (transport) return transport;
|
|
28
|
+
return stderrReason(runDir);
|
|
29
|
+
}
|