@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,21 @@
1
+ /**
2
+ * Explains a current run that never reached either of its two output streams.
3
+ *
4
+ * The existing events file is enough to distinguish this shape: an archived folder has no
5
+ * events stream at all, while a folder with an empty stream and silent stderr proves the CLI
6
+ * never started.
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { readText } from './paths.mjs';
11
+
12
+ export function startupGap(runDir, events) {
13
+ if (!events?.hasStream || events.hasEvents) return null;
14
+ // Existence before emptiness: a stderr.log that is not there is a missing witness, not a
15
+ // silent one, and the two mean opposite things. Reading an absent file as "" would let a run
16
+ // whose evidence was deleted after the fact pass for a run that never started — the damaged
17
+ // -artifacts check below this one exists precisely to name that case instead.
18
+ const stderr = path.join(runDir, 'stderr.log');
19
+ if (!fs.existsSync(stderr) || readText(stderr)) return null;
20
+ return 'run abandoned at startup: events.jsonl has no events and stderr.log is empty, Codex did not run';
21
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Where a run's transport errors live, and whether that evidence can be trusted.
3
+ *
4
+ * Quota exhaustion is the one signal that tells the orchestrator not to retry, so it is read
5
+ * only from structured events the CLI itself writes. Text outside that protocol is diagnostic
6
+ * evidence, not a verdict.
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { readJson } from './paths.mjs';
11
+ import { readEvents } from './events.mjs';
12
+
13
+ const stderrPath = (runDir) => path.join(runDir, 'stderr.log');
14
+ const hasJsonContract = (runDir) => {
15
+ const args = readJson(path.join(runDir, 'worker.json'))?.args;
16
+ return Array.isArray(args) && args.includes('--json');
17
+ };
18
+
19
+ /**
20
+ * Artifacts that contradict each other about which contract the run was written under.
21
+ *
22
+ * A current run whose worker reached the end of runCodex() has an events.jsonl file. One whose
23
+ * own args promise `--json` but lacks that file lost evidence after the fact; calling it an
24
+ * archived run would make a quota refusal indistinguishable from a quoted one. The deadline
25
+ * marker remains a second consistency check because the same worker also creates stderr.log.
26
+ */
27
+ export function transportGap(runDir, events = readEvents(runDir)) {
28
+ if (hasJsonContract(runDir) && !events.hasStream) {
29
+ return 'artifacts disagree: worker.json requests --json, but events.jsonl is missing; the evidence was damaged, so a quota refusal cannot be told apart from a quoted one';
30
+ }
31
+ const status = readJson(path.join(runDir, 'status.json'));
32
+ if (!status || !('stopped_on_deadline' in status)) return null;
33
+ if (fs.existsSync(stderrPath(runDir))) return null;
34
+ return 'artifacts disagree: this run recorded a deadline watch but has no stderr.log, so a quota signal cannot be told apart from a quoted one';
35
+ }
@@ -22,17 +22,12 @@ import {
22
22
  } from './paths.mjs';
23
23
  import { chainBaseline } from './chain.mjs';
24
24
  import { splitRunChanges } from './environment.mjs';
25
+ import { readEvents } from './events.mjs';
25
26
  import { outcomeGap } from './outcome.mjs';
26
-
27
- /**
28
- * Quota exhaustion is a transport error, not a word. Both markers must sit on the same
29
- * line: a run whose review text merely discusses "quota exhaustion" is not a run that
30
- * hit the quota, and reporting LIMIT there tells the orchestrator not to retry when it
31
- * should. "try again later" is deliberately absent — it is a transient-failure phrase,
32
- * not a quota one.
33
- */
34
- const LIMIT_RE = /rate.?limit|usage limit|usage_limit|quota exceeded|quota exhausted|too many requests|\b429\b/i;
35
- const ERROR_RE = /\bERROR\b|error[:=]|stream error|"status"\s*:\s*429|rejected|refused|failed/i;
27
+ import { deadlineReason } from './deadline.mjs';
28
+ import { startupGap } from './startup.mjs';
29
+ import { transportGap } from './transport.mjs';
30
+ import { reasonFrom } from './reason.mjs';
36
31
 
37
32
  // How much prose an answer must carry once coordinates and paths are subtracted. Measured
38
33
  // on the scout run of 2026-07-30 that replied with a table of `file.ts:60-79` rows: every
@@ -42,24 +37,6 @@ const ERROR_RE = /\bERROR\b|error[:=]|stream error|"status"\s*:\s*429|rejected|r
42
37
  const MIN_SUBSTANCE_CHARS = 80;
43
38
  const MIN_SINGLE_SUBSTANCE_CHARS = 200;
44
39
 
45
- /**
46
- * The honest reason for a failure. Codex prints API errors as pretty-printed JSON, so
47
- * the literally-last line is often just `}` — useless in a one-line reply. Prefer the
48
- * error message field, then any error line, then the last line that carries text.
49
- */
50
- const reasonFrom = (log) => {
51
- const messages = [...log.matchAll(/"message"\s*:\s*"((?:[^"\\]|\\.)*)"/g)];
52
- if (messages.length) return line(messages[messages.length - 1][1].replace(/\\"/g, '"'));
53
- const lines = log.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
54
- const errorLine = [...lines].reverse().find((l) => /error|failed|denied|refused/i.test(l));
55
- return line(errorLine || lines[lines.length - 1] || '');
56
- };
57
-
58
- const limitSignal = (log) => {
59
- const hit = log.split(/\r?\n/).find((l) => LIMIT_RE.test(l) && ERROR_RE.test(l));
60
- return hit ? line(hit) : null;
61
- };
62
-
63
40
  /**
64
41
  * Which touched paths the task never authorised. The scope exists because prose does not
65
42
  * bind: one build run was told in words to keep away from `!Plans/*.md` and edited them
@@ -271,17 +248,18 @@ function branchDuringRun(runDir) {
271
248
  }
272
249
 
273
250
  /**
274
- * Status comes from artifacts, never from intent. An abandoned run leaves raw.log at
275
- * zero bytes, and that is a FAIL with its own reason — not a retry hint. An empty
276
- * result with a quota signal in the log is LIMIT; without one it is FAIL.
251
+ * Status comes from artifacts, never from intent. A current run with an events stream but no
252
+ * events and silent stderr is abandoned at startup; an empty result with no structured
253
+ * transport error is FAIL, because diagnostic text cannot turn a quoted limit into LIMIT.
277
254
  */
278
- export function resolveStatus({ log, logBytes, resultOk, exit, agent, result, runDir }) {
279
- if (!logBytes) {
280
- return {
281
- status: 'FAIL',
282
- reason: 'run abandoned at startup: raw.log is empty, Codex did not run',
283
- };
284
- }
255
+ export function resolveStatus({ resultOk, exit, agent, result, runDir, events }) {
256
+ const eventData = events ?? readEvents(runDir);
257
+ // First, as the empty raw.log check was before it: a run that never started cannot be graded
258
+ // for what happened in its window. Below this line sit accusations about the run's conduct —
259
+ // a commit, a moved branch — and HEAD moving while Codex was absent is somebody else's doing.
260
+ // Blaming a run that did not exist is the same class of lie every other check here prevents.
261
+ const startup = startupGap(runDir, eventData);
262
+ if (startup) return { status: 'FAIL', reason: startup };
285
263
  // Ahead of everything else, including LIMIT: a commit under an explicit ban is a broken
286
264
  // contract, not a grade for the work. Whatever else the run produced, the orchestrator has
287
265
  // to learn first that history moved under it.
@@ -291,13 +269,33 @@ export function resolveStatus({ log, logBytes, resultOk, exit, agent, result, ru
291
269
  const branched = branchDuringRun(runDir);
292
270
  if (branched) return { status: 'FAIL', reason: branched };
293
271
  }
272
+ const deadline = deadlineReason(runDir);
273
+ if (deadline) return { status: 'FAIL', reason: deadline };
274
+ // Damaged transport evidence is not archived transport evidence. Checked before LIMIT, since
275
+ // the fallback it guards is what LIMIT would otherwise use.
276
+ const transport = transportGap(runDir, eventData);
277
+ if (transport) return { status: 'FAIL', reason: transport };
278
+ // Only a run with nothing to show is judged by its transport errors. The CLI prints an error
279
+ // event for a dropped stream and then retries it, so a run that recovered, filled its result
280
+ // and exited zero did the job — turning that survived error into a verdict would fail honest
281
+ // work, the mirror image of the false LIMIT this whole module exists to remove.
282
+ const transportError =
283
+ eventData.hasEvents && (!resultOk || exit !== 0) ? eventData.transport_error : null;
284
+ if (transportError) {
285
+ // This branch already knows why the run died: the CLI said so itself, with a status. Letting
286
+ // the ordered reason speak here would answer a transport question with the model's older
287
+ // complaint about the task — a LIMIT that never mentions quota is the same "verdict says the
288
+ // wrong thing" defect this module exists to remove.
289
+ return {
290
+ status: transportError.quota ? 'LIMIT' : 'FAIL',
291
+ reason: transportError.reason,
292
+ };
293
+ }
294
294
  if (!resultOk) {
295
- const limit = limitSignal(log);
296
- if (limit) return { status: 'LIMIT', reason: limit };
297
- return { status: 'FAIL', reason: reasonFrom(log) || `result is empty, exit=${exit}` };
295
+ return { status: 'FAIL', reason: reasonFrom(runDir, eventData) || `result is empty, exit=${exit}` };
298
296
  }
299
297
  if (exit !== 0) {
300
- return { status: 'FAIL', reason: `result exists, but exit=${exit}: ${reasonFrom(log)}` };
298
+ return { status: 'FAIL', reason: `result exists, but exit=${exit}: ${reasonFrom(runDir, eventData)}` };
301
299
  }
302
300
  // A red verification is a failed job, not an OK with a footnote. The orchestrator
303
301
  // reads the first word; burying "fail" in line three is how a broken build passes.
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Keeps the dispatcher's first rule identical across all three prompts.
3
+ * On 2026-08-06 codex-build wrote 19 files using the Claude quota, which is why a failed
4
+ * delegation must end in an explicit failure instead of inviting the dispatcher to continue.
5
+ */
6
+ const NO_SELF_EXECUTION = `## No self-execution
7
+
8
+ Your only job is to start a run through \`run-codex.mjs\` and report its result; you are not the one who performs the task.
9
+
10
+ - If \`run-codex.mjs\` is missing, does not start, exits without \`RUN=\`/\`ATTACH=\`, or the run fails to begin for any reason, immediately return \`FAIL — could not start the Codex run: <reason>\` and stop.
11
+ - Performing the task yourself when the run could not be started is prohibited: do not read code, write files, or fix tests. Doing it yourself spends the Claude Max quota on exactly what the delegation existed to avoid, and does so silently.
12
+ - Claiming "files were created" without a run that actually happened is prohibited under all circumstances. No run, no result.
13
+
14
+ On 2026-08-06, codex-build wrote 19 files using the Claude quota; no quota was saved and no Codex verification happened.`;
15
+
16
+ export function renderNoSelfExecution() {
17
+ return NO_SELF_EXECUTION;
18
+ }
@@ -0,0 +1,120 @@
1
+ /** Owns the transport list and age decision shared by automatic and manual cleanup. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { liveRuns, normalizePath } from './hooks/live-runs.mjs';
5
+
6
+ // Plan_17 step 3 found one order's copied list and age default diverging on the same day. Keeping
7
+ // the transport boundary here prevents automatic cleanup from eating accounting files.
8
+ export const TRANSPORT_FILES = Object.freeze(['events.jsonl', 'stderr.log', 'raw.log']);
9
+
10
+ function ageDate(name) {
11
+ const match = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})(\d{2})(\d{2})(?:_|$)/.exec(name);
12
+ if (!match) return null;
13
+ const [, year, month, day, hour, minute, second] = match;
14
+ const timestamp = Date.UTC(
15
+ Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second),
16
+ );
17
+ const date = new Date(timestamp);
18
+ if (date.getUTCFullYear() !== Number(year)
19
+ || date.getUTCMonth() !== Number(month) - 1
20
+ || date.getUTCDate() !== Number(day)
21
+ || date.getUTCHours() !== Number(hour)
22
+ || date.getUTCMinutes() !== Number(minute)
23
+ || date.getUTCSeconds() !== Number(second)) return null;
24
+ return timestamp;
25
+ }
26
+
27
+ function nowTimestamp(value) {
28
+ if (value instanceof Date) return value.getTime();
29
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
30
+ const parsed = Date.parse(value ?? '');
31
+ return Number.isNaN(parsed) ? Date.now() : parsed;
32
+ }
33
+
34
+ function ageCutoff(olderThan, now) {
35
+ if (!olderThan) return null;
36
+ if (olderThan.kind === 'duration') {
37
+ const unit = olderThan.unit === 'h' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000;
38
+ return now - olderThan.amount * unit;
39
+ }
40
+ return Date.parse(`${olderThan.date}T00:00:00.000Z`);
41
+ }
42
+
43
+ /** Returns whether a run is older than a parsed threshold, using only its folder name. */
44
+ export function runIsOlderThan(runName, olderThan, now = Date.now()) {
45
+ const timestamp = ageDate(runName);
46
+ if (timestamp === null || !olderThan) return false;
47
+ return timestamp < ageCutoff(olderThan, nowTimestamp(now));
48
+ }
49
+
50
+ /**
51
+ * Removes only regular transport files from old, non-live runs.
52
+ *
53
+ * Plan_17 step 4 makes cleanup deliberately best effort: a full disk must not stop a new run, and
54
+ * deleting one named file cannot justify recursive removal of a run folder or accounting artifacts.
55
+ */
56
+ export function cleanupRetention(runsDir, config, options = {}) {
57
+ // Do not inspect days when disabled. A stale or malformed day value must not re-enable cleanup.
58
+ if (!config?.enabled) return null;
59
+ const days = config.days;
60
+ if (typeof days !== 'number' || !Number.isFinite(days) || days <= 0) return null;
61
+
62
+ const filesystem = options.fs || fs;
63
+ const now = options.now ?? Date.now();
64
+ let entries;
65
+ try {
66
+ entries = filesystem.readdirSync(runsDir, { withFileTypes: true });
67
+ } catch {
68
+ return null;
69
+ }
70
+
71
+ let live = new Set();
72
+ try {
73
+ // Normalized like every other path comparison in the package: a case difference between the
74
+ // scan and the probe would make a live run look like a stale one, and this deletes files.
75
+ live = new Set(liveRuns(runsDir).map(({ dir }) => normalizePath(dir)));
76
+ } catch {
77
+ // The launcher wraps the whole pass too; when the shared probe cannot answer, retain every
78
+ // run rather than repeating the abandoned-run incident with a live folder as a target.
79
+ return null;
80
+ }
81
+
82
+ const removedRuns = new Set();
83
+ let bytesFreed = 0;
84
+ for (const entry of entries) {
85
+ let isDirectory;
86
+ try {
87
+ isDirectory = entry.isDirectory();
88
+ } catch {
89
+ continue;
90
+ }
91
+ if (!isDirectory) continue;
92
+
93
+ const runDir = path.join(runsDir, entry.name);
94
+ if (live.has(normalizePath(runDir)) || !runIsOlderThan(entry.name, {
95
+ kind: 'duration',
96
+ amount: days,
97
+ unit: 'd',
98
+ }, now)) continue;
99
+
100
+ for (const filename of TRANSPORT_FILES) {
101
+ const target = path.join(runDir, filename);
102
+ let stat;
103
+ try {
104
+ stat = filesystem.lstatSync(target);
105
+ } catch {
106
+ continue;
107
+ }
108
+ if (!stat.isFile()) continue;
109
+ try {
110
+ filesystem.unlinkSync(target);
111
+ } catch {
112
+ continue;
113
+ }
114
+ removedRuns.add(entry.name);
115
+ if (Number.isFinite(stat.size) && stat.size > 0) bytesFreed += stat.size;
116
+ }
117
+ }
118
+
119
+ return removedRuns.size ? { bytes_freed: bytesFreed, runs: removedRuns.size, days } : null;
120
+ }
package/src/run-codex.mjs CHANGED
@@ -29,7 +29,7 @@
29
29
  * tree and writes the verdict.
30
30
  *
31
31
  * The split exists because the caller dies long before a 20-25 minute run does. Run
32
- * `2026-07-31_114736_*` has no raw.log at all while 11+ files were written into the tree: the
32
+ * `2026-07-31_114736_*` has no events.jsonl at all while 11+ files were written into the tree: the
33
33
  * dispatcher's Bash call hit its two-minute timeout, node was killed, spawnSync's buffered
34
34
  * output died with it — and Codex kept going, unwatched and unrecorded. Killing that orphan
35
35
  * is not the fix either: its edits were the work, and later runs finished on top of them. So
@@ -84,9 +84,9 @@ if (invokedDirectly) {
84
84
  const { dir: currentRun, agent: currentAgent } = getRun();
85
85
  if (currentRun) {
86
86
  try {
87
- fs.appendFileSync(path.join(currentRun, 'raw.log'), `\nrun-codex crash: ${err.stack}\n`);
87
+ fs.appendFileSync(path.join(currentRun, 'stderr.log'), `\nrun-codex crash: ${err.stack}\n`);
88
88
  const { reply } = writeFailure(currentRun, currentAgent, `Codex runner crashed: ${err.message}`, [
89
- `Log: ${path.join(currentRun, 'raw.log')}`,
89
+ `Log: codex-bridge read ${currentRun}`,
90
90
  ]);
91
91
  emitReply(reply);
92
92
  process.exit(1);
@@ -1,4 +1,8 @@
1
1
  {
2
2
  "hooks": false,
3
- "plugins": false
3
+ "plugins": false,
4
+ "retention": {
5
+ "enabled": true,
6
+ "days": 30
7
+ }
4
8
  }
@@ -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);