@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +18 -4
  3. package/bin/codex-bridge.mjs +29 -0
  4. package/cli/doctor.mjs +26 -3
  5. package/cli/install.mjs +18 -4
  6. package/cli/manifest.mjs +6 -4
  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/settings-merge.mjs +18 -4
  15. package/cli/stop.mjs +4 -37
  16. package/cli/table.mjs +110 -0
  17. package/package.json +1 -1
  18. package/src/agents/codex-build.md +12 -10
  19. package/src/agents/codex-review.md +11 -5
  20. package/src/agents/codex-scout.md +11 -10
  21. package/src/commands/usage.md +4 -3
  22. package/src/hook-definitions.mjs +22 -0
  23. package/src/hooks/live-runs.mjs +98 -0
  24. package/src/hooks/prune-guard.mjs +62 -0
  25. package/src/hooks/reply-guard.mjs +45 -22
  26. package/src/hooks/worktree-lock.mjs +70 -0
  27. package/src/meta/deadline.mjs +19 -0
  28. package/src/meta/events.mjs +166 -0
  29. package/src/meta/reason.mjs +29 -0
  30. package/src/meta/reply.mjs +40 -7
  31. package/src/meta/run-state.mjs +7 -1
  32. package/src/meta/startup.mjs +21 -0
  33. package/src/meta/transport.mjs +35 -0
  34. package/src/meta/verdict.mjs +40 -42
  35. package/src/required-inputs.mjs +49 -6
  36. package/src/retention.mjs +120 -0
  37. package/src/run-codex.mjs +3 -3
  38. package/src/run-config.json +5 -1
  39. package/src/run-config.mjs +54 -1
  40. package/src/runner/attach.mjs +1 -1
  41. package/src/runner/codex-cmd.mjs +119 -31
  42. package/src/runner/continuation.mjs +107 -0
  43. package/src/runner/launcher.mjs +23 -40
  44. package/src/runner/worker.mjs +11 -3
  45. package/src/write-meta.mjs +31 -22
@@ -26,6 +26,9 @@ export const CONFIG_PATH = path.join(HERE, 'run-config.json');
26
26
  const BUDGET_KEY = 'budgets';
27
27
  const BUDGET_MODES = ['scout', 'build', 'review'];
28
28
  const DEFAULT_BUDGETS = { scout: 15, build: 25, review: 20 };
29
+ const RETENTION_KEY = 'retention';
30
+ const RETENTION_FIELDS = ['enabled', 'days'];
31
+ const DEFAULT_RETENTION = { enabled: true, days: 30 };
29
32
 
30
33
  /**
31
34
  * Files the surrounding tooling writes on its own, in any repository a run touches.
@@ -52,6 +55,7 @@ export const DEFAULTS = {
52
55
  plugins: false,
53
56
  models: {},
54
57
  budgets: DEFAULT_BUDGETS,
58
+ retention: DEFAULT_RETENTION,
55
59
  environmentPaths: DEFAULT_ENVIRONMENT_PATHS,
56
60
  answerLanguage: 'English',
57
61
  };
@@ -75,7 +79,51 @@ export const ALLOWED_EFFORTS = Object.freeze(['none', 'minimal', 'low', 'medium'
75
79
  * two languages. English is the default because the package is read by strangers.
76
80
  */
77
81
  const STRING_KEYS = ['answerLanguage'];
78
- const KEYS = [...SWITCH_KEYS, ...LIST_KEYS, ...OBJECT_KEYS, ...STRING_KEYS, BUDGET_KEY];
82
+ const KEYS = [...SWITCH_KEYS, ...LIST_KEYS, ...OBJECT_KEYS, ...STRING_KEYS, BUDGET_KEY, RETENTION_KEY];
83
+
84
+ function readRetention(file, value) {
85
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
86
+ throw new Error(
87
+ `${file}: key “${RETENTION_KEY}” must be an object with enabled and days, not ${JSON.stringify(value)}`,
88
+ );
89
+ }
90
+ for (const key of Object.keys(value)) {
91
+ if (!RETENTION_FIELDS.includes(key)) {
92
+ throw new Error(
93
+ `${file}: key “${RETENTION_KEY}” has unknown field “${key}”. ` +
94
+ `Only ${RETENTION_FIELDS.join(', ')} are allowed`,
95
+ );
96
+ }
97
+ }
98
+ if (typeof value.enabled !== 'boolean') {
99
+ throw new Error(
100
+ `${file}: key “${RETENTION_KEY}.enabled” must be true or false, not ${JSON.stringify(value.enabled)}`,
101
+ );
102
+ }
103
+ // Plan_17 step 4 does not read days when disabled, so stale values cannot revive deletion after
104
+ // the step 3 incident where a copied default silently turned cleanup back on.
105
+ if (!value.enabled) return { enabled: false };
106
+ if (typeof value.days !== 'number' || !Number.isFinite(value.days) || value.days <= 0) {
107
+ throw new Error(
108
+ `${file}: key “${RETENTION_KEY}.days” must be a positive number of days, not ${JSON.stringify(value.days)}`,
109
+ );
110
+ }
111
+ return { enabled: true, days: value.days };
112
+ }
113
+
114
+ export function retentionNotice(config) {
115
+ if (config?.retention?.enabled) {
116
+ const days = config.retention.days;
117
+ return {
118
+ enabled: true,
119
+ text: `Automatic cleanup is ON — run transport older than ${days} days is removed to reclaim disk space. Accounting and reports are never touched. Change or disable: retention in run-config.json.`,
120
+ };
121
+ }
122
+ return {
123
+ enabled: false,
124
+ text: 'Automatic cleanup is OFF — run transport is retained until manually pruned.',
125
+ };
126
+ }
79
127
 
80
128
  /**
81
129
  * A run gets a hard wall-clock budget, because the caller's timeout is not a run contract:
@@ -152,6 +200,10 @@ export function readRunConfig(file = CONFIG_PATH) {
152
200
  config[key] = readBudgets(file, value);
153
201
  continue;
154
202
  }
203
+ if (key === RETENTION_KEY) {
204
+ config[key] = readRetention(file, value);
205
+ continue;
206
+ }
155
207
  if (LIST_KEYS.includes(key)) {
156
208
  if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
157
209
  throw new Error(
@@ -252,6 +304,7 @@ const state = (config) => [
252
304
  return `${key}: ${model}${profile.effort ? ` at ${profile.effort} effort` : ''}`;
253
305
  }).join('; ')}`,
254
306
  `budgets: ${BUDGET_MODES.map((mode) => `${mode}: ${config.budgets?.[mode] ?? DEFAULT_BUDGETS[mode]} minutes`).join('; ')}`,
307
+ `retention: ${config.retention?.enabled ? `on — transport older than ${config.retention.days} days` : 'off — automatic cleanup disabled'}`,
255
308
  ];
256
309
 
257
310
  function main(argv) {
@@ -112,7 +112,7 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
112
112
  return exitCodeFor(meta.status);
113
113
  }
114
114
  const { reply } = writeFailure(runDir, candidate.status.agent, 'run worker process died without recording a verdict', [
115
- `Log: ${path.join(runDir, 'raw.log')}`,
115
+ `Log: codex-bridge read ${runDir}`,
116
116
  'Any Codex changes remain in the tree — check them with git status',
117
117
  ]);
118
118
  console.log(reply);
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Everything about invoking the `codex` CLI: which flags a run gets, whether the CLI is
3
- * there at all, and how its output reaches raw.log.
3
+ * there at all, and how its JSONL stdout reaches events.jsonl while stderr stays separate.
4
4
  *
5
5
  * The flag sets below are the ones the three agent files carried before, verbatim.
6
6
  * Sandboxes especially must not drift: codex-scout is read-only through
@@ -47,36 +47,74 @@ export function stopCodex(child, onWindows = process.platform === 'win32') {
47
47
  * shell. One command line through cmd.exe keeps a single code path; every argument is
48
48
  * quoted here, and unsafeForCmd() has already refused the ones that cannot be.
49
49
  *
50
- * The log is streamed, not collected: spawnSync kept every byte in memory and wrote the file
51
- * only on return, so a worker killed at minute twelve left raw.log missing entirely and the
52
- * run became unexplainable (2026-07-31, run 114736). Now whatever Codex has already said is
53
- * on disk, and the task text is pushed into stdin by hand since there is no `input` option
54
- * outside spawnSync. MAX_LOG stays the ceiling; past it the log is cut with a note rather
55
- * than the run being killed — a huge log is a diagnosis, not a reason to lose the work.
50
+ * Stdout is streamed, not collected: spawnSync kept every byte in memory and wrote the file
51
+ * only on return, so a worker killed at minute twelve left events.jsonl missing entirely and
52
+ * the run became unexplainable (2026-07-31, run 114736). Now whatever Codex has already said
53
+ * is on disk, and the task text is pushed into stdin by hand since there is no `input` option
54
+ * outside spawnSync. MAX_LOG stays the ceiling; past it the stream is cut rather than the run
55
+ * being killed — a huge stream is a diagnosis, not a reason to lose the work. stderr has its
56
+ * own bounded file because text outside the JSONL protocol must remain distinguishable from
57
+ * protocol events, and a line cut in half cannot be parsed back safely.
56
58
  */
57
- export function runCodex(args, taskText, logPath, budgetMinutes) {
59
+ export function runCodex(args, taskText, eventsPath, budgetMinutes) {
58
60
  const onWindows = process.platform === 'win32';
59
61
  const deadlineMs =
60
62
  typeof budgetMinutes === 'number' && Number.isFinite(budgetMinutes) && budgetMinutes > 0
61
63
  ? budgetMinutes * 60 * 1000
62
64
  : null;
63
- const log = fs.createWriteStream(logPath, { flags: 'a' });
64
- let written = 0;
65
- let cut = false;
66
- const append = (chunk) => {
67
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
68
- if (written >= MAX_LOG) {
69
- if (!cut) {
70
- cut = true;
71
- log.write(`\nrun-codex: log truncated at ${MAX_LOG} bytes; further output is not saved\n`);
65
+ const stderrLog = fs.createWriteStream(path.join(path.dirname(eventsPath), 'stderr.log'), { flags: 'a' });
66
+ const eventsLog = fs.createWriteStream(eventsPath, { flags: 'a' });
67
+ const appendTo = (stream, label) => {
68
+ let written = 0;
69
+ let cut = false;
70
+ return (chunk) => {
71
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
72
+ if (written >= MAX_LOG) {
73
+ if (!cut) {
74
+ cut = true;
75
+ stream.write(`\nrun-codex: ${label} truncated at ${MAX_LOG} bytes; further output is not saved\n`);
76
+ }
77
+ return;
72
78
  }
73
- return;
74
- }
75
- const room = MAX_LOG - written;
76
- const slice = buf.length > room ? buf.subarray(0, room) : buf;
77
- written += slice.length;
78
- log.write(slice);
79
+ const room = MAX_LOG - written;
80
+ const slice = buf.length > room ? buf.subarray(0, room) : buf;
81
+ written += slice.length;
82
+ stream.write(slice);
83
+ };
79
84
  };
85
+ const appendStderr = appendTo(stderrLog, 'stderr log');
86
+ const appendEvents = (() => {
87
+ let pending = Buffer.alloc(0);
88
+ let written = 0;
89
+ let cut = false;
90
+ const append = (chunk) => {
91
+ if (cut) return;
92
+ const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
93
+ pending = pending.length ? Buffer.concat([pending, incoming]) : incoming;
94
+ let newline = pending.indexOf(0x0a);
95
+ while (newline !== -1) {
96
+ const line = pending.subarray(0, newline + 1);
97
+ pending = pending.subarray(newline + 1);
98
+ if (written + line.length > MAX_LOG) {
99
+ cut = true;
100
+ pending = Buffer.alloc(0);
101
+ return;
102
+ }
103
+ written += line.length;
104
+ eventsLog.write(line);
105
+ newline = pending.indexOf(0x0a);
106
+ }
107
+ if (pending.length > MAX_LOG - written) {
108
+ cut = true;
109
+ pending = Buffer.alloc(0);
110
+ }
111
+ };
112
+ const finish = () => {
113
+ if (!cut && pending.length && written + pending.length <= MAX_LOG) eventsLog.write(pending);
114
+ pending = Buffer.alloc(0);
115
+ };
116
+ return { append, finish };
117
+ })();
80
118
 
81
119
  let child;
82
120
  if (onWindows) {
@@ -97,12 +135,36 @@ export function runCodex(args, taskText, logPath, budgetMinutes) {
97
135
  return new Promise((resolve) => {
98
136
  let failed = false;
99
137
  let deadlineTimer;
138
+ let stoppedOnDeadline = false;
139
+ let exited = false;
140
+ let elapsedMs = 0;
100
141
  const startedAt = Date.now();
101
- child.stdout.on('data', append);
102
- child.stderr.on('data', append);
142
+ child.stdout.on('data', appendEvents.append);
143
+ child.stdout.on('end', appendEvents.finish);
144
+ child.stderr.on('data', (chunk) => {
145
+ appendStderr(chunk);
146
+ });
147
+ // A log file that cannot be opened must not leave Codex spending quota unattended. Without
148
+ // a listener the stream's 'error' event throws inside the worker, and the crash handler
149
+ // closes the run folder while the CLI keeps running — the orphan of 2026-07-31 with an
150
+ // extra step. The run dies, but it dies with its child: killing first, recording second.
151
+ const onStreamError = (which) => (err) => {
152
+ failed = true;
153
+ const note = `\nrun-codex: cannot write ${which}: ${err.message}\n`;
154
+ if (which !== 'stderr.log' && stderrLog.writable) stderrLog.write(note);
155
+ stopCodex(child, onWindows);
156
+ };
157
+ stderrLog.on('error', onStreamError('stderr.log'));
158
+ eventsLog.on('error', onStreamError('events.jsonl'));
103
159
  child.on('error', (err) => {
104
160
  failed = true;
105
- append(`\nrun-codex: ${err.message}\n`);
161
+ appendStderr(`\nrun-codex: ${err.message}\n`);
162
+ });
163
+ // 'exit' fires when the process is gone; 'close' waits for stdio, which a grandchild can
164
+ // hold open past the deadline. Judging by 'close' would let the timer brand a run that
165
+ // finished honestly at minute 24 as one the runner killed.
166
+ child.on('exit', () => {
167
+ exited = true;
106
168
  });
107
169
  // Codex that dies before reading the order leaves an EPIPE here; the exit code and the
108
170
  // log already say what happened, and an unhandled stream error would replace that with
@@ -112,15 +174,38 @@ export function runCodex(args, taskText, logPath, budgetMinutes) {
112
174
  if (deadlineMs !== null) {
113
175
  deadlineTimer = setTimeout(() => {
114
176
  const elapsed = Date.now() - startedAt;
115
- log.write(
116
- `\nrun-codex: run stopped on its deadline after ${elapsed} ms (budget ${deadlineMs} ms)\n`,
117
- );
177
+ // The tree is stopped either way — a grandchild still holding stdio is exactly what
178
+ // the budget exists to end — but only a process that was still alive was killed by
179
+ // the deadline, and only that run may say so.
180
+ if (!exited) {
181
+ stoppedOnDeadline = true;
182
+ elapsedMs = elapsed;
183
+ stderrLog.write(
184
+ `\nrun-codex: run stopped on its deadline after ${elapsed} ms (budget ${deadlineMs} ms)\n`,
185
+ );
186
+ }
118
187
  stopCodex(child, onWindows);
119
188
  }, deadlineMs);
120
189
  }
121
- child.on('close', (code) => {
190
+ // A stream that already failed never calls back from end(), so waiting on both callbacks in
191
+ // sequence hangs the worker until its own deadline — the run then costs a full budget and
192
+ // answers nothing. Closing is best-effort; the verdict is not.
193
+ const close = (stream) =>
194
+ new Promise((done) => {
195
+ if (stream.destroyed || stream.errored) {
196
+ stream.destroy();
197
+ done();
198
+ return;
199
+ }
200
+ stream.end(done);
201
+ });
202
+ child.on('close', async (code) => {
122
203
  if (deadlineTimer) clearTimeout(deadlineTimer);
123
- log.end(() => resolve(failed || code === null ? 1 : code));
204
+ if (!stoppedOnDeadline) elapsedMs = Date.now() - startedAt;
205
+ const exit = failed || code === null ? 1 : code;
206
+ await close(stderrLog);
207
+ await close(eventsLog);
208
+ resolve({ exit, stoppedOnDeadline, elapsedMs });
124
209
  });
125
210
  });
126
211
  }
@@ -193,6 +278,7 @@ export function codexArgs(opts, runDir, isGitRepo) {
193
278
  if (opts.agent === 'codex-scout') {
194
279
  return [
195
280
  'exec',
281
+ '--json',
196
282
  ...CLEAN_ENV,
197
283
  '--ignore-user-config',
198
284
  '-c',
@@ -216,6 +302,7 @@ export function codexArgs(opts, runDir, isGitRepo) {
216
302
  // every edit is rejected ("writing is blocked by read-only sandbox").
217
303
  return [
218
304
  'exec',
305
+ '--json',
219
306
  ...CLEAN_ENV,
220
307
  '-c',
221
308
  effort,
@@ -246,6 +333,7 @@ export function codexArgs(opts, runDir, isGitRepo) {
246
333
  // --ignore-user-config, same as scout: review never needs write access.
247
334
  return [
248
335
  'exec',
336
+ '--json',
249
337
  ...CLEAN_ENV,
250
338
  '--ignore-user-config',
251
339
  '-c',
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Owns the zero-quota decision about whether a requested continuation was ordered.
3
+ *
4
+ * The 2026-08-05 incident showed why the runner must not infer a second pass from a verdict:
5
+ * only the orchestrator read that verdict and can name the run and reason it is willing to fund.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { CONTINUATION_INPUT, parseContinuationGrant } from '../required-inputs.mjs';
10
+ import { readJson } from '../write-meta.mjs';
11
+
12
+ const sameRun = (runsRootPath, left, right) => {
13
+ const a = path.resolve(runsRootPath, left);
14
+ const b = path.resolve(runsRootPath, right);
15
+ return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
16
+ };
17
+
18
+ function namedRunDirectory(runsRootPath, run) {
19
+ if (!run || run === '.' || run === '..' || path.basename(run) !== run) return null;
20
+ const directory = path.join(runsRootPath, run);
21
+ try {
22
+ return fs.statSync(directory).isDirectory() ? directory : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function normalizeGrant(grant) {
29
+ if (typeof grant === 'string') return parseContinuationGrant(grant);
30
+ if (!grant || typeof grant !== 'object') return null;
31
+ const run = String(grant.run ?? '').trim();
32
+ const reason = String(grant.reason ?? '').trim();
33
+ return run && reason ? { run, reason } : null;
34
+ }
35
+
36
+ const grantExample = `Example: \`continue: ${CONTINUATION_INPUT.example}\`.`;
37
+ const grantAction =
38
+ 'Action: ask the orchestrator for an explicit grant naming the run and reason; do not invent or reuse a continuation.';
39
+
40
+ /**
41
+ * Applies the one-continuation limit and all continuation safety gates before a folder is created.
42
+ *
43
+ * The limit is counted over the runs carrying THIS order id, not over the whole chain. The chain
44
+ * also ties runs together by slug and by the fingerprint of the task text, which is what catches a
45
+ * repeat that renamed itself — but counting continuations that way makes the escape hatch
46
+ * unreachable: the operator's rule is "more passes than one need a new order id", and a new order
47
+ * id lands in the same chain through the task hash. A task would then be refused with --continue
48
+ * for having spent its continuation and refused without it for already having runs — permanently
49
+ * unrunnable, which is a worse failure than the retry storm this limit exists to stop.
50
+ */
51
+ export function continuationRefusal(runsRootPath, chain, isContinue, orderId, grant) {
52
+ if (!isContinue) return null;
53
+
54
+ const continuation = normalizeGrant(grant);
55
+ if (!continuation) {
56
+ return (
57
+ '--continue is refused: the orchestrator did not provide a `continue:` grant in the task text. ' +
58
+ `${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
59
+ );
60
+ }
61
+
62
+ const namedDirectory = namedRunDirectory(runsRootPath, continuation.run);
63
+ if (!namedDirectory) {
64
+ return (
65
+ `--continue is refused: the orchestrator named run “${continuation.run}”, but it does not ` +
66
+ `exist as a run folder in this project's runs directory ${runsRootPath}. ` +
67
+ `${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
68
+ );
69
+ }
70
+
71
+ const last = chain[chain.length - 1];
72
+ // Continuing the last run appends a later run, so the old grant stops matching by itself; this
73
+ // incident needs no counter or new state to make an orchestrator grant single-use.
74
+ if (!last || !sameRun(runsRootPath, continuation.run, last)) {
75
+ return (
76
+ `--continue is refused: grant ${continuation.run} is not the LAST run of this task's chain; ` +
77
+ `the current last run is ${last || 'none'}. A continuation is single-use: continuing the ` +
78
+ 'last run appends a later run, so the old grant stops matching by itself — no counter or new ' +
79
+ `state is used. ${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
80
+ );
81
+ }
82
+
83
+ const wanted = String(orderId ?? '').trim();
84
+ const ofThisOrder = chain.filter(
85
+ (run) => String(readJson(path.join(runsRootPath, run, 'status.json'))?.order_id ?? '').trim() === wanted,
86
+ );
87
+ if (ofThisOrder.length === 0) return null;
88
+ if (ofThisOrder.length > 1) {
89
+ const spent = ofThisOrder.map((run) => path.join(runsRootPath, run)).join(', ');
90
+ return (
91
+ `--continue is refused: order “${wanted}” already spent its allowed continuation on ${spent}. ` +
92
+ 'A further pass needs a new order id from the orchestrator. The run folder was not ' +
93
+ 'created; quota was not spent.'
94
+ );
95
+ }
96
+ const previous = path.join(runsRootPath, ofThisOrder[0]);
97
+ const status = readJson(path.join(previous, 'status.json'));
98
+ const meta = readJson(path.join(previous, 'meta.json'));
99
+ if (!meta?.status || status?.state === 'running') {
100
+ return (
101
+ `--continue is refused: previous run ${previous} has no finished verdict and may still ` +
102
+ 'be editing the worktree. Repeat without --continue to attach to it. The run folder was ' +
103
+ 'not created; quota was not spent.'
104
+ );
105
+ }
106
+ return null;
107
+ }
@@ -28,12 +28,15 @@ import { attach } from './attach.mjs';
28
28
  import { setRun } from './run-context.mjs';
29
29
  import { loadRunEnv, RUN_ENV } from './run-env.mjs';
30
30
  import { parseArgs, die } from './args.mjs';
31
+ import { parseContinuationGrant } from '../required-inputs.mjs';
32
+ import { continuationRefusal } from './continuation.mjs';
31
33
  import { SCHEMAS } from './schemas.mjs';
32
34
  import { INSTRUCTIONS } from './prompts.mjs';
33
35
  import { git, headSha, branchName, worktreeSnapshot, reviewScope } from './git-state.mjs';
34
36
  import { codexArgs, requireCodex, runMode, unsafeForCmd } from './codex-cmd.mjs';
35
37
  import { runsRoot } from './runs-root.mjs';
36
38
  import { resolveProjectRunsDir } from './project-dir.mjs';
39
+ import { cleanupRetention } from '../retention.mjs';
37
40
 
38
41
  /**
39
42
  * The worker is this same program re-invoked as `--worker <runDir>`, so the path spawned
@@ -114,6 +117,7 @@ export async function launcher() {
114
117
  // like --scope.
115
118
  const taskHash = taskFingerprint(taskText);
116
119
  const chain = chainRuns(projectRunsRoot, repoRoot, opts.slug, taskHash, opts.orderId);
120
+ const continuationGrant = opts.continue ? parseContinuationGrant(taskText) : null;
117
121
 
118
122
  // One order produced six Codex runs on 2026-08-03 because the caller's time ceiling made it
119
123
  // restart the synchronous launcher. A live same-order run is now the repeat target: attach
@@ -132,7 +136,13 @@ export async function launcher() {
132
136
  // markAbandoned() ran before this gate, so a dead runner already has meta.json with FAIL.
133
137
  // A run without a verdict can therefore only still be in flight; ordinary repeats go through
134
138
  // attach(), while --continue must refuse to overlap that live work.
135
- const continuationError = continuationRefusal(projectRunsRoot, chain, opts.continue, opts.orderId);
139
+ const continuationError = continuationRefusal(
140
+ projectRunsRoot,
141
+ chain,
142
+ opts.continue,
143
+ opts.orderId,
144
+ continuationGrant,
145
+ );
136
146
  if (continuationError) die(continuationError);
137
147
 
138
148
  if (chain.length && !opts.continue) {
@@ -157,6 +167,15 @@ export async function launcher() {
157
167
  // first one's edits, and an honest run gets failed for work it never did.
158
168
  const busy = opts.agent === 'codex-build' ? activeRun(projectRunsRoot, repoRoot) : null;
159
169
 
170
+ let retention = null;
171
+ try {
172
+ retention = cleanupRetention(projectRunsRoot, RUN_ENV?.retention);
173
+ } catch {
174
+ // Plan_17 step 4 makes retention advisory housekeeping: one broken filesystem call must never
175
+ // block a new run.
176
+ retention = null;
177
+ }
178
+
160
179
  const runDir = makeRunDir(path.join(projectRunsRoot, `${stamp()}_${opts.slug}`));
161
180
  setRun(runDir, opts.agent);
162
181
 
@@ -183,6 +202,9 @@ export async function launcher() {
183
202
  // Which run of this task started the chain — the base every later pass is measured
184
203
  // against. Absent means this is the first pass.
185
204
  ...(chain.length ? { continues: chain[0] } : {}),
205
+ // `continued_from` is the exact run the orchestrator named; `continues` above remains the chain base.
206
+ ...(continuationGrant ? { continued_from: continuationGrant.run } : {}),
207
+ ...(retention ? { retention } : {}),
186
208
  });
187
209
 
188
210
  // Printed before anything can go wrong: even a dispatcher that dies mid-run leaves the
@@ -333,42 +355,3 @@ export async function launcher() {
333
355
  );
334
356
  process.exit(0);
335
357
  }
336
-
337
- /**
338
- * Applies the one-continuation limit and the in-flight safety gate before a folder is created.
339
- *
340
- * The limit is counted over the runs carrying THIS order id, not over the whole chain. The chain
341
- * also ties runs together by slug and by the fingerprint of the task text, which is what catches a
342
- * repeat that renamed itself — but counting continuations that way makes the escape hatch
343
- * unreachable: the operator's rule is "more passes than one need a new order id", and a new order
344
- * id lands in the same chain through the task hash. A task would then be refused with --continue
345
- * for having spent its continuation and refused without it for already having runs — permanently
346
- * unrunnable, which is a worse failure than the retry storm this limit exists to stop.
347
- */
348
- export function continuationRefusal(runsRootPath, chain, isContinue, orderId) {
349
- if (!isContinue || chain.length === 0) return null;
350
- const wanted = String(orderId ?? '').trim();
351
- const ofThisOrder = chain.filter(
352
- (run) => String(readJson(path.join(runsRootPath, run, 'status.json'))?.order_id ?? '').trim() === wanted,
353
- );
354
- if (ofThisOrder.length === 0) return null;
355
- if (ofThisOrder.length > 1) {
356
- const spent = ofThisOrder.map((run) => path.join(runsRootPath, run)).join(', ');
357
- return (
358
- `--continue is refused: order “${wanted}” already spent its allowed continuation on ${spent}. ` +
359
- 'A further pass needs a new order id from the orchestrator. The run folder was not ' +
360
- 'created; quota was not spent.'
361
- );
362
- }
363
- const previous = path.join(runsRootPath, ofThisOrder[0]);
364
- const status = readJson(path.join(previous, 'status.json'));
365
- const meta = readJson(path.join(previous, 'meta.json'));
366
- if (!meta?.status || status?.state === 'running') {
367
- return (
368
- `--continue is refused: previous run ${previous} has no finished verdict and may still ` +
369
- 'be editing the worktree. Repeat without --continue to attach to it. The run folder was ' +
370
- 'not created; quota was not spent.'
371
- );
372
- }
373
- return null;
374
- }
@@ -13,6 +13,7 @@
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { collect, exitCodeFor, AGENTS } from '../write-meta.mjs';
16
+ import { writeStatus } from '../meta/run-state.mjs';
16
17
  import { setRun, emitReply } from './run-context.mjs';
17
18
  import { git, headSha, branchName, worktreeSnapshot, findFakeDone } from './git-state.mjs';
18
19
  import { runCodex } from './codex-cmd.mjs';
@@ -26,12 +27,19 @@ export async function worker(runDir) {
26
27
  const repoRoot = cfg.repo;
27
28
  setRun(runDir, cfg.agent);
28
29
 
29
- const exit = await runCodex(
30
+ const run = await runCodex(
30
31
  cfg.args,
31
32
  fs.readFileSync(path.join(runDir, 'task.md'), 'utf8'),
32
- path.join(runDir, 'raw.log'),
33
+ path.join(runDir, 'events.jsonl'),
33
34
  cfg.budget_minutes,
34
35
  );
36
+ // This runner-owned fact cannot be forged through events.jsonl, which Codex can write.
37
+ // Written even when no deadline fired: `false` is the marker that a runner was watching,
38
+ // and an archived run with no field at all must stay distinguishable from a run that lived.
39
+ writeStatus(runDir, {
40
+ stopped_on_deadline: run.stoppedOnDeadline,
41
+ elapsed_ms: run.elapsedMs,
42
+ });
35
43
 
36
44
  if (cfg.agent === 'codex-build') {
37
45
  fs.writeFileSync(path.join(runDir, 'head-after.txt'), `${cfg.is_git_repo ? headSha(repoRoot) : ''}\n`);
@@ -56,7 +64,7 @@ export async function worker(runDir) {
56
64
 
57
65
  // collect() writes meta.json and closes status.json; reply.txt comes last, so the launcher
58
66
  // seeing a reply is proof the verdict behind it is already on disk.
59
- const { meta, reply } = collect(runDir, cfg.agent, exit);
67
+ const { meta, reply } = collect(runDir, cfg.agent, run.exit);
60
68
  emitReply(reply);
61
69
  process.exit(exitCodeFor(meta.status));
62
70
  }
@@ -3,9 +3,9 @@
3
3
  * Turns one Codex run directory into accounting plus a verdict:
4
4
  * node write-meta.mjs <runDir> <agent> <exitCode>
5
5
  *
6
- * This is the only entry point that reads a run's artifacts. It writes meta.json — quota
7
- * accounting that survives deletion of the gitignored raw.log — and derives the run
8
- * status from what is actually on disk: exit code, log size, result file, git state.
6
+ * This is the only entry point that reads a run's artifacts. It writes meta.json — accounting
7
+ * for the two transport files — and derives the run status from what is actually on disk:
8
+ * exit code, structured events, result file, git state.
9
9
  * A dispatcher agent therefore cannot report an outcome its own run does not support.
10
10
  *
11
11
  * The reply lines printed here ARE the reply. Agents forward this stdout verbatim
@@ -24,6 +24,7 @@ import path from 'node:path';
24
24
  import { fileURLToPath } from 'node:url';
25
25
  import { changedPaths, readJson, readText, size } from './meta/paths.mjs';
26
26
  import { splitRunChanges } from './meta/environment.mjs';
27
+ import { readEvents } from './meta/events.mjs';
27
28
  import { writeStatus } from './meta/run-state.mjs';
28
29
  import { resolveStatus } from './meta/verdict.mjs';
29
30
  import { AGENTS, failReply, limitReply } from './meta/reply.mjs';
@@ -49,24 +50,30 @@ export function collect(runDir, agent, exitCode) {
49
50
  const cfg = AGENTS[agent];
50
51
  if (!cfg) throw new Error(`unknown agent: ${agent} (expected one of ${Object.keys(AGENTS).join(', ')})`);
51
52
 
52
- const logPath = path.join(runDir, 'raw.log');
53
+ const eventsPath = path.join(runDir, 'events.jsonl');
54
+ const stderrPath = path.join(runDir, 'stderr.log');
53
55
  const resultPath = path.join(runDir, cfg.result);
54
- const log = readText(logPath);
55
- const logBytes = size(logPath);
56
+ const eventsBytes = size(eventsPath);
57
+ const stderrBytes = size(stderrPath);
56
58
  const result = readJson(resultPath);
57
59
  const resultOk = Boolean(result) && cfg.filled(result);
58
60
  const exit = exitCode === undefined || exitCode === null ? null : Number(exitCode);
61
+ const events = readEvents(runDir);
62
+ const worker = readJson(path.join(runDir, 'worker.json'));
59
63
 
60
- // "tokens used" is followed by the count on the next line, with a non-breaking
61
- // thousands separator — strip every non-digit rather than trusting the spacing.
62
- const usage = [...log.matchAll(/tokens used[\r\n]+([^\r\n]+)/g)].pop();
63
-
64
- // `codex exec review` reports no usage at all. Null, never 0: a zero would silently
65
- // understate the total in /codex:usage.
66
- const tokens = usage ? parseInt(usage[1].replace(/\D/g, ''), 10) || null : null;
67
-
68
- const pick = (re) => (log.match(re) || [])[1] || null;
69
- const { status, reason, carried } = resolveStatus({ log, logBytes, resultOk, exit, agent, result, runDir });
64
+ const args = Array.isArray(worker?.args) ? worker.args : [];
65
+ const argValue = (flag) => {
66
+ const index = args.indexOf(flag);
67
+ return index >= 0 && typeof args[index + 1] === 'string' ? args[index + 1] : null;
68
+ };
69
+ const { status, reason, carried } = resolveStatus({
70
+ resultOk,
71
+ exit,
72
+ agent,
73
+ result,
74
+ runDir,
75
+ events,
76
+ });
70
77
 
71
78
  const meta = {
72
79
  agent,
@@ -88,14 +95,16 @@ export function collect(runDir, agent, exitCode) {
88
95
  changedPaths(readText(path.join(runDir, 'state-before.txt')), readText(path.join(runDir, 'state-after.txt'))),
89
96
  ).environment,
90
97
  result_ok: resultOk,
91
- log_bytes: logBytes,
92
- tokens,
93
- tokens_reported: tokens !== null,
94
- model: pick(/^model:\s*(\S+)/m),
95
- sandbox: pick(/^sandbox:\s*(\S+)/m),
98
+ events_bytes: eventsBytes,
99
+ stderr_bytes: stderrBytes,
100
+ tokens: events.tokens,
101
+ tokens_reported: events.tokens !== null,
102
+ usage: events.usage,
103
+ model: argValue('-m'),
104
+ sandbox: argValue('--sandbox'),
96
105
  // Null for runs made before env.json existed: absence is not the same as "both off".
97
106
  env: readJson(path.join(runDir, 'env.json')),
98
- session_id: pick(/^session id:\s*(\S+)/m),
107
+ session_id: events.session_id,
99
108
  };
100
109
 
101
110
  fs.writeFileSync(path.join(runDir, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`);