@lyupro/codex-bridge 0.1.4 → 0.3.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +25 -4
  3. package/bin/codex-bridge.mjs +37 -0
  4. package/cli/doctor.mjs +20 -2
  5. package/cli/install.mjs +18 -4
  6. package/cli/manifest.mjs +3 -1
  7. package/cli/projects.mjs +92 -0
  8. package/cli/prune-args.mjs +133 -0
  9. package/cli/prune-plan.mjs +205 -0
  10. package/cli/prune.mjs +278 -0
  11. package/cli/read.mjs +103 -0
  12. package/cli/run-lookup.mjs +59 -0
  13. package/cli/runs-inventory.mjs +173 -0
  14. package/cli/stop.mjs +4 -37
  15. package/cli/sweep.mjs +144 -0
  16. package/cli/table.mjs +110 -0
  17. package/package.json +1 -1
  18. package/src/agents/codex-build.md +7 -6
  19. package/src/agents/codex-review.md +8 -8
  20. package/src/agents/codex-scout.md +7 -6
  21. package/src/commands/usage.md +4 -3
  22. package/src/heartbeat.mjs +80 -0
  23. package/src/hook-definitions.mjs +11 -0
  24. package/src/hooks/live-runs.mjs +13 -6
  25. package/src/hooks/prune-guard.mjs +62 -0
  26. package/src/hooks/reply-guard.mjs +2 -2
  27. package/src/hooks/worktree-lock.mjs +8 -1
  28. package/src/meta/deadline.mjs +19 -0
  29. package/src/meta/events.mjs +166 -0
  30. package/src/meta/reason.mjs +29 -0
  31. package/src/meta/reply.mjs +40 -7
  32. package/src/meta/run-state.mjs +25 -2
  33. package/src/meta/startup.mjs +21 -0
  34. package/src/meta/transport.mjs +35 -0
  35. package/src/meta/verdict.mjs +40 -42
  36. package/src/no-self-execution.mjs +18 -0
  37. package/src/retention.mjs +120 -0
  38. package/src/run-codex.mjs +3 -3
  39. package/src/run-config.json +5 -1
  40. package/src/run-config.mjs +54 -1
  41. package/src/runner/attach.mjs +1 -1
  42. package/src/runner/codex-cmd.mjs +158 -31
  43. package/src/runner/launcher.mjs +11 -0
  44. package/src/runner/worker.mjs +14 -3
  45. package/src/write-meta.mjs +31 -22
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The one definition of run progress: a heartbeat file in the run folder, its age, and the
3
+ * stale threshold. On 2026-08-06 run 2026-08-06_204007_build left a live worker waiting on
4
+ * Codex's grandchild; a worker timer alone would have kept that dead run looking alive, so
5
+ * the timer stops at exit.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ export const HEARTBEAT_FILE = 'heartbeat';
11
+ // Ten seconds bounds metadata writes when a 300KB stream arrives as many chunks; output still
12
+ // proves movement immediately in memory, while the periodic worker stamp keeps silence fresh.
13
+ export const HEARTBEAT_WRITE_INTERVAL_MS = 10_000;
14
+ export const HEARTBEAT_INTERVAL_MS = 30_000;
15
+ // Five minutes leaves a wide margin over the longest model-thinking silence seen in healthy
16
+ // runs. The active-child timer refreshes below it; the margin is safer than unlocking a tree
17
+ // while a working build is silent, which is the failure this heartbeat exists to prevent.
18
+ export const HEARTBEAT_STALE_MS = 5 * 60 * 1000;
19
+
20
+ export function heartbeatPath(runDir) {
21
+ return path.join(runDir, HEARTBEAT_FILE);
22
+ }
23
+
24
+ /** A failed stamp is deliberately swallowed: the verdict must not depend on this hint file. */
25
+ export function createHeartbeat(runDir) {
26
+ let lastStamp = Number.NEGATIVE_INFINITY;
27
+ return {
28
+ stamp(now = Date.now()) {
29
+ if (!Number.isFinite(now)) return;
30
+ if (now >= lastStamp && now - lastStamp < HEARTBEAT_WRITE_INTERVAL_MS) return;
31
+ lastStamp = now;
32
+ try {
33
+ fs.writeFileSync(heartbeatPath(runDir), `${now}\n`);
34
+ } catch {
35
+ // A read-only or full run folder must still produce its normal verdict and artifacts.
36
+ }
37
+ },
38
+ };
39
+ }
40
+
41
+ /** Missing is an explicit legacy state: old live records keep their lock until their pid dies. */
42
+ export function heartbeatAge(runDir, now = Date.now()) {
43
+ try {
44
+ const stat = fs.statSync(heartbeatPath(runDir));
45
+ if (!stat.isFile() || !Number.isFinite(now)) return Number.POSITIVE_INFINITY;
46
+ return Math.max(0, now - stat.mtimeMs);
47
+ } catch (err) {
48
+ return err.code === 'ENOENT' ? null : Number.POSITIVE_INFINITY;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * How long a run has been silent, in words. It lives beside the age it formats because both the
54
+ * worktree lock's refusal and `sweep`'s report have to name the same silence to the same operator;
55
+ * two copies of this drifted apart the moment they were written.
56
+ */
57
+ export function formatSilence(age) {
58
+ if (age === null || !Number.isFinite(age)) return 'an unknown duration';
59
+ const seconds = Math.floor(Math.max(0, age) / 1000);
60
+ if (seconds < 60) return `${seconds} second${seconds === 1 ? '' : 's'}`;
61
+ const minutes = Math.floor(seconds / 60);
62
+ const remainingSeconds = seconds % 60;
63
+ if (minutes < 60) {
64
+ return `${minutes} minute${minutes === 1 ? '' : 's'}`
65
+ + (remainingSeconds ? ` ${remainingSeconds} second${remainingSeconds === 1 ? '' : 's'}` : '');
66
+ }
67
+ const hours = Math.floor(minutes / 60);
68
+ const remainingMinutes = minutes % 60;
69
+ return `${hours} hour${hours === 1 ? '' : 's'}`
70
+ + (remainingMinutes ? ` ${remainingMinutes} minute${remainingMinutes === 1 ? '' : 's'}` : '');
71
+ }
72
+
73
+ /**
74
+ * A running record is live when its heartbeat is fresh. Missing heartbeats remain live only for
75
+ * pre-Plan_20 records, an explicit fail-closed compatibility choice that preserves their lock.
76
+ */
77
+ export function isHeartbeatFresh(runDir, now = Date.now()) {
78
+ const age = heartbeatAge(runDir, now);
79
+ return age === null || age <= HEARTBEAT_STALE_MS;
80
+ }
@@ -29,8 +29,19 @@ export const WRITE_TOOLS = Object.freeze(['Write', 'Edit', 'MultiEdit', 'Noteboo
29
29
  /** A PreToolUse matcher is a regular expression over tool names, so alternation covers all four. */
30
30
  export const WRITE_TOOL_MATCHER = WRITE_TOOLS.join('|');
31
31
 
32
+ /**
33
+ * Shell tools, listed here for the same reason as the two lists above: the prune guard must see the
34
+ * command whichever name a host gives its shell, and a name known only to the hook would leave that
35
+ * host's agents able to delete run artifacts.
36
+ */
37
+ export const SHELL_TOOLS = Object.freeze(['Bash', 'PowerShell']);
38
+
39
+ /** A PreToolUse matcher is a regular expression over tool names, so alternation covers both. */
40
+ export const SHELL_TOOL_MATCHER = SHELL_TOOLS.join('|');
41
+
32
42
  export const HOOK_DEFINITIONS = Object.freeze([
33
43
  Object.freeze({ event: 'SubagentStop', matcher: '*', file: 'reply-guard.mjs' }),
34
44
  Object.freeze({ event: 'PreToolUse', matcher: SUBAGENT_TOOL_MATCHER, file: 'order-gate.mjs' }),
35
45
  Object.freeze({ event: 'PreToolUse', matcher: WRITE_TOOL_MATCHER, file: 'worktree-lock.mjs' }),
46
+ Object.freeze({ event: 'PreToolUse', matcher: SHELL_TOOL_MATCHER, file: 'prune-guard.mjs' }),
36
47
  ]);
@@ -2,12 +2,18 @@
2
2
  * Shared, fail-open knowledge of live Codex runs.
3
3
  *
4
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.
5
+ * only while it says `running`, its runner pid accepts signal 0, and its progress heartbeat is
6
+ * fresh. Keeping those probes and the directory scan here prevents the two protections from
7
+ * disagreeing about a run, which would recreate the 2026-08-06 incident where a dead Codex
8
+ * child left its worker and lock looking live through a grandchild-held stdio pipe.
9
+ *
10
+ * The heartbeat is required here and deliberately NOT in meta/run-state.mjs: releasing a lock
11
+ * early costs an operator one knowing edit, while closing a record early makes a second writer
12
+ * of its meta.json. See the comment above pidAlive() there.
8
13
  */
9
14
  import fs from 'node:fs';
10
15
  import path from 'node:path';
16
+ import { isHeartbeatFresh } from '../heartbeat.mjs';
11
17
 
12
18
  /** Signal 0 tests existence without stopping the process; EPERM still means that it exists. */
13
19
  export const isPidAlive = (pid) => {
@@ -41,7 +47,7 @@ function isNonEmptyString(value) {
41
47
  return typeof value === 'string' && Boolean(value.trim());
42
48
  }
43
49
 
44
- function recognizedStatus(status) {
50
+ function recognizedStatus(runDir, status) {
45
51
  return Boolean(status)
46
52
  && typeof status === 'object'
47
53
  && !Array.isArray(status)
@@ -50,7 +56,8 @@ function recognizedStatus(status) {
50
56
  && isNonEmptyString(status.agent)
51
57
  && isNonEmptyString(status.slug)
52
58
  && isNonEmptyString(status.repo)
53
- && isPidAlive(status.pid);
59
+ && isPidAlive(status.pid)
60
+ && isHeartbeatFresh(runDir);
54
61
  }
55
62
 
56
63
  function readStatus(runDir) {
@@ -75,7 +82,7 @@ export function liveRuns(runsDir) {
75
82
  if (!entry.isDirectory()) continue;
76
83
  const dir = path.join(runsDir, entry.name);
77
84
  const status = readStatus(dir);
78
- if (recognizedStatus(status)) result.push({ dir, status });
85
+ if (recognizedStatus(dir, status)) result.push({ dir, status });
79
86
  }
80
87
  return result;
81
88
  }
@@ -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
+ }));
@@ -284,7 +284,7 @@ if (fs.existsSync(statusPath)) {
284
284
  `status.json currently says state=running, but process pid ${fact(runStatus.pid)} is ` +
285
285
  'dead and meta.json is missing: an interrupted Bash call killed the runner. Codex ' +
286
286
  'survives the runner and keeps editing the tree — in run 2026-07-31_114736, changes ' +
287
- 'in 11+ files survived while raw.log and meta.json were never recorded.',
287
+ 'in 11+ files survived while no run artifact and no meta.json were ever recorded.',
288
288
  runStatus,
289
289
  ),
290
290
  );
@@ -300,7 +300,7 @@ if (fs.existsSync(statusPath)) {
300
300
  `status.json currently says state=abandoned (${fact(runStatus.abandoned_reason)}, ` +
301
301
  `${fact(runStatus.abandoned_at)}): the runner died without recording a verdict. Codex ` +
302
302
  'survives the runner and keeps editing the tree — in run 2026-07-31_114736, changes in ' +
303
- '11+ files survived while raw.log and meta.json were never recorded.',
303
+ '11+ files survived while no run artifact and no meta.json were ever recorded.',
304
304
  runStatus,
305
305
  ),
306
306
  );
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
+ import { formatSilence, heartbeatAge } from '../heartbeat.mjs';
13
14
  import { WRITE_TOOLS } from '../hook-definitions.mjs';
14
15
  import { runsRoot } from '../runner/runs-root.mjs';
15
16
  import { allLiveRuns, normalizePath } from './live-runs.mjs';
@@ -65,6 +66,12 @@ process.stdout.write(JSON.stringify({
65
66
  permissionDecision: 'deny',
66
67
  permissionDecisionReason: `Worktree lock denied ${input.tool_name}: ${targetPath} is inside repository `
67
68
  + `${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
+ // "Working, last progress X ago" rather than "silent for X": this hook only ever denies a
70
+ // run whose heartbeat is still fresh — a stale one is not live here and the edit passes. The
71
+ // first live probe printed "silent for 10 seconds" next to a stop command on a perfectly
72
+ // healthy run, which reads as "it hung, kill it" and would cost the operator 20 minutes of
73
+ // work they meant to keep.
74
+ + `It is working; last progress ${formatSilence(heartbeatAge(dir))} ago. `
75
+ + `To take the repository back before it finishes: codex-bridge stop ${path.basename(dir)}.`,
69
76
  },
70
77
  }));
@@ -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
+ }
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
14
- import { changedPaths, line, readText } from './paths.mjs';
14
+ import { changedPaths, line, readJson, readText } from './paths.mjs';
15
15
  import { splitRunChanges } from './environment.mjs';
16
16
  import { scoutCoverage } from './verdict.mjs';
17
17
 
@@ -22,6 +22,29 @@ const runChanges = (runDir) =>
22
22
  changedPaths(readText(path.join(runDir, 'state-before.txt')), readText(path.join(runDir, 'state-after.txt'))),
23
23
  );
24
24
 
25
+ const readCommand = (runDir) => `codex-bridge read ${runDir}`;
26
+
27
+ function formatBytes(bytes) {
28
+ const units = ['B', 'KB', 'MB', 'GB'];
29
+ let value = bytes;
30
+ let index = 0;
31
+ while (value >= 1024 && index < units.length - 1) {
32
+ value /= 1024;
33
+ index += 1;
34
+ }
35
+ return `${value.toFixed(1)} ${units[index]}`;
36
+ }
37
+
38
+ function retentionReply(runDir) {
39
+ const retention = readJson(path.join(runDir, 'status.json'))?.retention;
40
+ const bytes = Number(retention?.bytes_freed);
41
+ const runs = Number(retention?.runs);
42
+ const days = Number(retention?.days);
43
+ if (!Number.isFinite(bytes) || bytes <= 0 || !Number.isFinite(runs) || runs <= 0
44
+ || !Number.isFinite(days) || days <= 0) return null;
45
+ return `Retention: freed ${formatBytes(bytes)} from ${runs} runs older than ${days} days`;
46
+ }
47
+
25
48
  export const AGENTS = {
26
49
  'codex-scout': {
27
50
  result: 'result.json',
@@ -45,13 +68,15 @@ function scoutReply(ctx) {
45
68
  const top = (r.findings || [])[0];
46
69
  const unknowns = (r.unknowns || []).filter(Boolean);
47
70
  const coverage = scoutCoverage(ctx.runDir, r);
71
+ const retention = retentionReply(ctx.runDir);
48
72
  return [
49
73
  `OK — ${line(r.answer, 160)}`,
50
74
  // Any explicit question gets a coverage line, including a valid one-question order.
51
75
  ...(coverage ? [`Coverage: ${coverage}`] : []),
52
76
  `Key finding: ${top ? `${line(top.fact, 130)} (${line(top.where, 60)})` : 'no findings listed'}`,
53
77
  `Unresolved: ${unknowns.length ? line(unknowns.join('; '), 160) : 'none'}`,
54
- `Report: ${ctx.file('report.md')} · Log: ${ctx.file('raw.log')}`,
78
+ ...(retention ? [retention] : []),
79
+ `Report: ${ctx.file('report.md')} · Log: ${readCommand(ctx.runDir)}`,
55
80
  ];
56
81
  }
57
82
 
@@ -81,6 +106,7 @@ function buildReply(ctx) {
81
106
  const files = ctx.carried
82
107
  ? `${paths ? `${paths} · ` : ''}changes were made by an earlier run of this task`
83
108
  : paths || 'worktree untouched';
109
+ const retention = retentionReply(ctx.runDir);
84
110
  return [
85
111
  `OK — ${line(r.summary, 160)}`,
86
112
  `Files: ${touchedPaths.length} changed · ${files}`,
@@ -91,7 +117,8 @@ function buildReply(ctx) {
91
117
  : []),
92
118
  `Verification: ${verify} — ${verdict}`,
93
119
  `Flags: ${flags.length ? `${flags.length} TODO/skip — ${line(flags.slice(0, 3).join(' | '), 140)}` : 'none'}`,
94
- `Report: ${ctx.file('report.md')} · Log: ${ctx.file('raw.log')}`,
120
+ ...(retention ? [retention] : []),
121
+ `Report: ${ctx.file('report.md')} · Log: ${readCommand(ctx.runDir)}`,
95
122
  ];
96
123
  }
97
124
 
@@ -102,24 +129,28 @@ function reviewReply(ctx) {
102
129
  if (counts[f.severity] !== undefined) counts[f.severity] += 1;
103
130
  });
104
131
  const top = (r.findings || []).find((f) => f.severity === 'critical' || f.severity === 'high');
132
+ const retention = retentionReply(ctx.runDir);
105
133
  return [
106
134
  `OK — verdict ${line(r.verdict, 40)}`,
107
135
  `Findings: critical ${counts.critical} · high ${counts.high} · medium ${counts.medium} · low ${counts.low}`,
108
136
  `Top: ${top ? `${top.severity} ${line(top.file, 80)}:${top.line_start} — ${line(top.title, 90)}` : 'no critical or high findings'}`,
109
- `Report: ${ctx.file(path.basename(ctx.resultPath))} · Log: ${ctx.file('raw.log')}`,
137
+ ...(retention ? [retention] : []),
138
+ `Report: ${ctx.file(path.basename(ctx.resultPath))} · Log: ${readCommand(ctx.runDir)}`,
110
139
  ];
111
140
  }
112
141
 
113
142
  export function failReply(ctx, meta) {
143
+ const retention = retentionReply(ctx.runDir);
114
144
  return [
115
145
  `FAIL — ${line(meta.reason, 170)}`,
116
- `Artifacts: raw.log ${meta.log_bytes} B · ${path.basename(ctx.resultPath)} ${meta.result_ok ? 'filled' : 'empty or missing'} · exit=${meta.exit}`,
146
+ `Artifacts: events.jsonl ${meta.events_bytes} B · stderr.log ${meta.stderr_bytes} B · ${path.basename(ctx.resultPath)} ${meta.result_ok ? 'filled' : 'empty or missing'} · exit=${meta.exit}`,
117
147
  // A failed build says what it left behind, exactly as a LIMIT does. "The work was not
118
148
  // done" is not "the tree is clean": a run can write half a change and then declare fail,
119
149
  // and the orchestrator has to know whether there is something to revert before it decides
120
150
  // anything else.
121
151
  ...(ctx.agent === 'codex-build' ? [`Worktree: ${worktreeState(ctx.runDir)}`] : []),
122
- `Log: ${ctx.file('raw.log')}`,
152
+ ...(retention ? [retention] : []),
153
+ `Log: ${readCommand(ctx.runDir)}`,
123
154
  ];
124
155
  }
125
156
 
@@ -141,6 +172,7 @@ function worktreeState(runDir) {
141
172
  }
142
173
 
143
174
  export function limitReply(ctx, meta) {
175
+ const retention = retentionReply(ctx.runDir);
144
176
  const rows = [
145
177
  'LIMIT — ChatGPT quota exhausted, work not completed',
146
178
  `Signal: ${line(meta.reason, 170)}`,
@@ -151,6 +183,7 @@ export function limitReply(ctx, meta) {
151
183
  `Worktree: ${touched.length ? `has unfinished changes (${touched.length}), see git-after.txt` : 'no new changes'}`,
152
184
  );
153
185
  }
154
- rows.push(`Log: ${ctx.file('raw.log')}`);
186
+ if (retention) rows.push(retention);
187
+ rows.push(`Log: ${readCommand(ctx.runDir)}`);
155
188
  return rows;
156
189
  }
@@ -25,10 +25,31 @@ export const pidAlive = (pid) => {
25
25
  }
26
26
  };
27
27
 
28
+ /**
29
+ * This module deliberately judges by the pid alone, while hooks/live-runs.mjs also requires a
30
+ * fresh progress heartbeat. The two answer different questions, and merging them broke both:
31
+ *
32
+ * - a hook asks "may the operator edit this tree", and a stalled run must stop holding that
33
+ * lock — the operator is editing knowingly, and a stale run should not cost them 25 minutes;
34
+ * - this module asks "may a second paid run write into this tree" and "is this folder finished".
35
+ * Closing a run whose pid still lives makes markAbandoned the second writer of its meta.json:
36
+ * the live worker reaches collect() afterwards and overwrites the verdict, so the documented
37
+ * artifact order stops being a contract. Letting activeRun() past a live pid starts a second
38
+ * writing run in one worktree, which is the 2026-08-05 incident this whole lock exists for.
39
+ *
40
+ * A stalled-but-live run is closed by `stop`, which kills the process first and only then
41
+ * records the verdict — one writer, in the right order.
42
+ */
43
+
28
44
  /**
29
45
  * Run state on disk, merged over whatever is already there. A killed runner leaves no
30
46
  * report and no meta.json, and four abandoned folders from one order were indistinguishable
31
47
  * from four runs still working — status.json is what makes that difference visible.
48
+ *
49
+ * It writes status.json and nothing else. Housekeeping facts such as `retention` live here and
50
+ * are read from here; an earlier attempt also appended them to meta.json, which would have made
51
+ * two functions write the same artifact and put the documented artifact order (meta.json before
52
+ * status.json is closed) at the mercy of whoever ran last.
32
53
  */
33
54
  export function writeStatus(runDir, patch) {
34
55
  const current = readJson(path.join(runDir, 'status.json')) || {};
@@ -126,7 +147,8 @@ export function activeRun(runsRoot, repo, agent = 'codex-build') {
126
147
  const wanted = normalizePath(repo);
127
148
  for (const entry of entries) {
128
149
  if (!entry.isDirectory()) continue;
129
- const status = readJson(path.join(runsRoot, entry.name, 'status.json'));
150
+ const runDir = path.join(runsRoot, entry.name);
151
+ const status = readJson(path.join(runDir, 'status.json'));
130
152
  if (!status || status.state !== 'running' || status.agent !== agent) continue;
131
153
  if (normalizePath(status.repo) !== wanted) continue;
132
154
  if (pidAlive(status.pid)) return entry.name;
@@ -182,7 +204,8 @@ export function writeFailure(runDir, agent, reason, extraLines = []) {
182
204
  status: 'FAIL',
183
205
  reason: line(reason, 300),
184
206
  result_ok: false,
185
- log_bytes: size(path.join(runDir, 'raw.log')),
207
+ events_bytes: size(path.join(runDir, 'events.jsonl')),
208
+ stderr_bytes: size(path.join(runDir, 'stderr.log')),
186
209
  tokens: null,
187
210
  tokens_reported: false,
188
211
  model: null,