@lyupro/codex-bridge 0.1.4 → 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.
@@ -0,0 +1,173 @@
1
+ /** Reads run-store directories into inventory data. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { liveRuns } from '../src/hooks/live-runs.mjs';
5
+ import { runsRoot } from '../src/runner/runs-root.mjs';
6
+
7
+ const record = (value) => value && typeof value === 'object' && !Array.isArray(value);
8
+
9
+ function readRecord(file) {
10
+ try {
11
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
12
+ return record(value) ? value : null;
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ function directoryEntries(dir) {
19
+ try {
20
+ return fs.readdirSync(dir, { withFileTypes: true })
21
+ .filter((entry) => entry.isDirectory())
22
+ .sort((left, right) => left.name.localeCompare(right.name));
23
+ } catch {
24
+ return [];
25
+ }
26
+ }
27
+
28
+ function measure(target) {
29
+ let stat;
30
+ try {
31
+ stat = fs.lstatSync(target);
32
+ } catch {
33
+ return { bytes: null, complete: false };
34
+ }
35
+ if (!stat.isDirectory()) return { bytes: stat.isFile() ? stat.size : 0, complete: true };
36
+
37
+ let entries;
38
+ try {
39
+ entries = fs.readdirSync(target, { withFileTypes: true });
40
+ } catch {
41
+ return { bytes: null, complete: false };
42
+ }
43
+
44
+ let bytes = 0;
45
+ let complete = true;
46
+ for (const entry of entries) {
47
+ const child = measure(path.join(target, entry.name));
48
+ if (child.bytes !== null) bytes += child.bytes;
49
+ if (!child.complete) complete = false;
50
+ }
51
+ return { bytes: complete ? bytes : null, complete };
52
+ }
53
+
54
+ /** Measures every readable regular file beneath a path. */
55
+ export function recursiveSize(target) {
56
+ return measure(target).bytes;
57
+ }
58
+
59
+ const normalizeRoot = (value) => typeof value === 'string' && value.trim() ? value : runsRoot();
60
+
61
+ const text = (value) => typeof value === 'string' && value.trim() ? value.trim() : null;
62
+
63
+ const number = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null;
64
+
65
+ function runTimestamp(name, meta, status) {
66
+ return text(meta?.finished_at)
67
+ || text(status?.finished_at)
68
+ || text(status?.started_at)
69
+ || /^\d{4}-\d{2}-\d{2}_\d{6}/.exec(name)?.[0]
70
+ || null;
71
+ }
72
+
73
+ function timestampOrder(value) {
74
+ const candidate = String(value).replace(
75
+ /^(\d{4}-\d{2}-\d{2})_(\d{2})(\d{2})(\d{2})$/,
76
+ '$1T$2:$3:$4',
77
+ );
78
+ const parsed = Date.parse(candidate);
79
+ return Number.isNaN(parsed) ? candidate : parsed;
80
+ }
81
+
82
+ function newer(left, right) {
83
+ if (!left) return right;
84
+ if (!right) return left;
85
+ const leftOrder = timestampOrder(left);
86
+ const rightOrder = timestampOrder(right);
87
+ if (typeof leftOrder === 'number' && typeof rightOrder === 'number') {
88
+ return rightOrder > leftOrder ? right : left;
89
+ }
90
+ if (typeof leftOrder === 'number') return left;
91
+ if (typeof rightOrder === 'number') return right;
92
+ return rightOrder > leftOrder ? right : left;
93
+ }
94
+
95
+ function pathKey(value) {
96
+ const resolved = path.resolve(value);
97
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
98
+ }
99
+
100
+ function readRun(runDir, livePaths) {
101
+ // A damaged folder still gets its row. An inventory that hides what it cannot parse is at its
102
+ // least useful exactly when something is wrong, which is when the operator opens it.
103
+ const meta = readRecord(path.join(runDir, 'meta.json'));
104
+ const status = readRecord(path.join(runDir, 'status.json'));
105
+ const facts = meta || status;
106
+ const live = livePaths.has(pathKey(runDir));
107
+ return {
108
+ run: path.basename(runDir),
109
+ agent: text(facts?.agent),
110
+ verdict: live ? 'running' : text(meta ? meta.status : status?.state),
111
+ tokens: meta ? number(meta.tokens) : null,
112
+ size: recursiveSize(runDir),
113
+ timestamp: runTimestamp(path.basename(runDir), meta, status),
114
+ live,
115
+ };
116
+ }
117
+
118
+ function publicRun(run) {
119
+ return {
120
+ run: run.run,
121
+ agent: run.agent,
122
+ verdict: run.verdict,
123
+ tokens: run.tokens,
124
+ size: run.size,
125
+ // Liveness travels with the row because a caller must not re-derive it: an abandoned run also
126
+ // says `running` in its status.json, and only the pid probe tells the two apart.
127
+ live: run.live,
128
+ };
129
+ }
130
+
131
+ function buildProject(projectDir) {
132
+ const runDirs = directoryEntries(projectDir).map((entry) => path.join(projectDir, entry.name));
133
+ // live-runs owns PID and status interpretation after the 2026-08-05 live probe incident;
134
+ // this module only maps its result back to the corresponding inventory row.
135
+ const livePaths = new Set(liveRuns(projectDir).map(({ dir }) => pathKey(dir)));
136
+ const details = runDirs.map((runDir) => readRun(runDir, livePaths));
137
+ // Sum what is known instead of blanking the column. One archived run without accounting used to
138
+ // erase the total of every run beside it, which reads as "spent nothing" — the opposite of true.
139
+ const counted = details.filter((run) => run.tokens !== null);
140
+ const latest = details.reduce((current, run) => newer(current, run.timestamp), null);
141
+ return {
142
+ summary: {
143
+ project: path.basename(projectDir),
144
+ runs: details.length,
145
+ size: recursiveSize(projectDir),
146
+ totalTokens: counted.length ? counted.reduce((sum, run) => sum + run.tokens, 0) : null,
147
+ liveNow: details.filter((run) => run.live).length,
148
+ lastRun: latest,
149
+ },
150
+ details,
151
+ };
152
+ }
153
+
154
+ function projectDirectory(root, name) {
155
+ return directoryEntries(root)
156
+ .find((entry) => entry.name === name)
157
+ ? path.join(root, name)
158
+ : null;
159
+ }
160
+
161
+ /** Lists one summary row per project directory. */
162
+ export function listProjects(runsRootPath = runsRoot()) {
163
+ const root = normalizeRoot(runsRootPath);
164
+ return directoryEntries(root).map((entry) => buildProject(path.join(root, entry.name)).summary);
165
+ }
166
+
167
+ /** Lists one detail row per run directory in a named project, or null when there is no such project. */
168
+ export function listProjectRuns(runsRootPath = runsRoot(), projectName) {
169
+ const root = normalizeRoot(runsRootPath);
170
+ const projectDir = projectDirectory(root, projectName);
171
+ if (!projectDir) return null;
172
+ return buildProject(projectDir).details.map(publicRun);
173
+ }
package/cli/stop.mjs CHANGED
@@ -1,35 +1,16 @@
1
1
  /** Stops a live run and records the same abandoned FAIL artifacts as the normal sweep. */
2
- import fs from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import { stopCodex } from '../src/runner/codex-cmd.mjs';
5
4
  import { git, worktreeSnapshot } from '../src/runner/git-state.mjs';
6
5
  import { markAbandoned, readJson } from '../src/write-meta.mjs';
7
- import { resolveProjectRunsDir } from '../src/runner/project-dir.mjs';
8
6
  import { runsRoot } from '../src/runner/runs-root.mjs';
7
+ import { resolveRunFolder } from './run-lookup.mjs';
9
8
 
10
9
  const EXIT_POLL_MS = 25;
11
10
  const EXIT_WAIT_MS = 2000;
12
11
 
13
12
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
14
13
 
15
- function projectRoot(cwd) {
16
- const result = git(cwd, ['rev-parse', '--show-toplevel']);
17
- return result.status === 0 && result.stdout.trim() ? result.stdout.trim() : cwd;
18
- }
19
-
20
- function resolveRunFolder(run, cwd, runsRootPath) {
21
- const value = String(run ?? '').trim();
22
- if (!value || value === '.' || value === '..') {
23
- throw new Error('stop requires a run folder (full path or bare folder name)');
24
- }
25
- if (path.isAbsolute(value)) return path.resolve(value);
26
- if (path.dirname(value) !== '.') {
27
- throw new Error('a bare run folder name or a full path is required');
28
- }
29
- const projectRuns = resolveProjectRunsDir(runsRootPath, projectRoot(cwd), { create: false });
30
- return path.join(projectRuns.dir, value);
31
- }
32
-
33
14
  /** The stopped run's worktree as it stands now, or undefined when the repo is not a git one. */
34
15
  function treeOf(repo) {
35
16
  const root = String(repo ?? '').trim();
@@ -72,23 +53,9 @@ function result(exitCode, output) {
72
53
  }
73
54
 
74
55
  export async function stop({ run, cwd = process.cwd(), runsRootPath = runsRoot() } = {}) {
75
- let runDir;
76
- try {
77
- runDir = resolveRunFolder(run, cwd, runsRootPath);
78
- } catch (err) {
79
- return result(1, `codex-bridge stop: ${err.message}`);
80
- }
81
-
82
- let isDirectory = false;
83
- try {
84
- isDirectory = fs.statSync(runDir).isDirectory();
85
- } catch {}
86
- if (!isDirectory) {
87
- return result(
88
- 1,
89
- `Run folder not found: ${runDir}. Pass a full path or a bare run folder name from the current project's runs directory.`,
90
- );
91
- }
56
+ const lookup = resolveRunFolder({ command: 'stop', run, cwd, runsRootPath });
57
+ if (lookup.error) return result(1, lookup.error);
58
+ const { runDir } = lookup;
92
59
 
93
60
  const status = readJson(path.join(runDir, 'status.json'));
94
61
  const meta = readJson(path.join(runDir, 'meta.json'));
package/cli/table.mjs ADDED
@@ -0,0 +1,110 @@
1
+ /** Renders generic rows within terminal width. */
2
+
3
+ const asText = (value) => {
4
+ if (value === null || value === undefined || value === '') return '-';
5
+ if (typeof value === 'object') {
6
+ try {
7
+ return JSON.stringify(value);
8
+ } catch {
9
+ return String(value);
10
+ }
11
+ }
12
+ return String(value);
13
+ };
14
+
15
+ /** Counts printable code points without imposing a dependency on terminal libraries. */
16
+ export const displayWidth = (value) => Array.from(String(value)).length;
17
+
18
+ /** Keeps the right side of a name when a flexible column must shrink. */
19
+ export function truncateStart(value, width) {
20
+ const text = String(value);
21
+ if (displayWidth(text) <= width) return text;
22
+ if (width <= 1) return '…';
23
+ return `…${Array.from(text).slice(-(width - 1)).join('')}`;
24
+ }
25
+
26
+ function fit(value, width, mode) {
27
+ if (displayWidth(value) <= width) return value;
28
+ if (mode === 'start') return truncateStart(value, width);
29
+ if (mode === 'end') return `${Array.from(value).slice(0, Math.max(0, width - 1)).join('')}…`;
30
+ return value;
31
+ }
32
+
33
+ function normalizeColumn(column) {
34
+ const kind = column.kind || 'text';
35
+ const fixed = column.fixed === true || kind === 'number' || kind === 'date';
36
+ return {
37
+ ...column,
38
+ key: String(column.key ?? column.label ?? column.header),
39
+ header: String(column.header ?? column.label ?? column.key),
40
+ kind,
41
+ align: column.align || (kind === 'number' ? 'right' : 'left'),
42
+ mode: column.truncate === undefined ? (fixed ? null : 'start') : column.truncate,
43
+ };
44
+ }
45
+
46
+ function optionsWidth(options) {
47
+ if (typeof options === 'number') return options;
48
+ const value = options?.width ?? options?.terminalWidth ?? process.stdout.columns;
49
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 80;
50
+ }
51
+
52
+ function rowValue(row, column) {
53
+ return asText(typeof column.value === 'function' ? column.value(row) : row?.[column.key]);
54
+ }
55
+
56
+ function columnWidths(columns, rows, width) {
57
+ const values = columns.map((column) => [
58
+ column.header,
59
+ ...rows.map((row) => rowValue(row, column)),
60
+ ]);
61
+ const widths = values.map((items) => Math.max(...items.map(displayWidth), 1));
62
+ const gaps = Math.max(0, columns.length - 1);
63
+ let spare = width - gaps - widths.reduce((sum, item) => sum + item, 0);
64
+ if (spare >= 0) return widths;
65
+
66
+ const flexible = columns
67
+ .map((column, index) => ({ column, index }))
68
+ .filter(({ column }) => column.mode === 'start' || column.mode === 'end');
69
+ while (spare < 0) {
70
+ let changed = false;
71
+ for (const { column, index } of flexible) {
72
+ const minimum = Math.max(1, Number(column.minWidth) || 1);
73
+ if (widths[index] <= minimum) continue;
74
+ widths[index] -= 1;
75
+ spare += 1;
76
+ changed = true;
77
+ if (spare === 0) break;
78
+ }
79
+ if (!changed) break;
80
+ }
81
+ return widths;
82
+ }
83
+
84
+ function renderRow(row, columns, widths, header = false) {
85
+ return columns.map((column, index) => {
86
+ const value = header ? column.header : rowValue(row, column);
87
+ const rendered = fit(value, widths[index], header ? null : column.mode);
88
+ const padding = Math.max(0, widths[index] - displayWidth(rendered));
89
+ if (index === columns.length - 1) {
90
+ return column.align === 'right' ? `${' '.repeat(padding)}${rendered}` : rendered;
91
+ }
92
+ return column.align === 'right'
93
+ ? `${' '.repeat(padding)}${rendered}`
94
+ : `${rendered}${' '.repeat(padding)}`;
95
+ }).join(' ');
96
+ }
97
+
98
+ // Columns first, rows second, always. Sniffing which argument is which would let a caller with an
99
+ // empty column list silently render the wrong shape instead of failing where the mistake is.
100
+ /** Renders column descriptors plus object rows as a plain table. */
101
+ export function renderTable(columns, rows, options = {}) {
102
+ const normalized = (columns || []).map(normalizeColumn);
103
+ if (!normalized.length) return '';
104
+ const values = Array.isArray(rows) ? rows : [];
105
+ const widths = columnWidths(normalized, values, optionsWidth(options));
106
+ return [
107
+ renderRow(null, normalized, widths, true),
108
+ ...values.map((row) => renderRow(row, normalized, widths)),
109
+ ].join('\n');
110
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyupro/codex-bridge",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Delegate implementation, reconnaissance and code review from Claude Code to the Codex CLI: dispatcher agents, a runner that survives a dropped call, and a verdict that checks the report against the worktree.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -154,8 +154,8 @@ orchestrator.
154
154
  ## The script determines status, not you
155
155
 
156
156
  - `OK` — `result.json` is filled, the return code is zero, and the report matches the worktree.
157
- - `FAIL` — the result is empty, the return code is nonzero, `raw.log` is empty (the run was
158
- abandoned at startup:
157
+ - `FAIL` — the result is empty, the return code is nonzero, the run left no event and a silent
158
+ `stderr.log` (abandoned at startup:
159
159
  there was no Codex process), or **the wrong work was done**: no file in `changes[]` matches
160
160
  what actually changed between the worktree snapshots. The schema cannot catch this — a report
161
161
  about
@@ -204,7 +204,7 @@ OK — Added retry to the fetch helper and a timeout test
204
204
  Files: 2 changed · src/net/fetch.ts, src/net/fetch.test.ts
205
205
  Verification: npm test — pass
206
206
  Flags: none
207
- Report: ...\report.md · Log: ...\raw.log
207
+ Report: ...\report.md · Log: codex-bridge read ...\2026-08-05_120000_slug
208
208
  ```
209
209
 
210
210
  Incorrect — "Codex failed, so I finished it myself," followed by a retelling of the diff.
@@ -129,9 +129,8 @@ and the wait can be repeated for free, so neither has any reason to restart anyt
129
129
  ## The script determines status, not you
130
130
 
131
131
  - `OK` — `review.json` is filled, and the return code is zero.
132
- - `FAIL` — the result is empty, the return code is nonzero, or `raw.log` is empty (the run was
133
- abandoned
134
- at startup: there was no Codex process).
132
+ - `FAIL` — the result is empty, the return code is nonzero, or the run left no event and a
133
+ silent `stderr.log` (abandoned at startup: there was no Codex process).
135
134
  - `LIMIT` — the result is empty and the log signals a limit. The ChatGPT quota is exhausted, and the
136
135
  review was not
137
136
  completed; this is not a review failure and not a reason to restart.
@@ -163,7 +162,7 @@ ATTACH=<artifact root>\myproject\2026-07-30_1412_review-auth started=2026-07-30T
163
162
  OK — verdict needs-attention
164
163
  Findings: critical 0 · high 1 · medium 2 · low 3
165
164
  Top: high src/api/auth.ts:88 — The promise is not awaited, and the error is lost
166
- Report: ...\review.json · Log: ...\raw.log
165
+ Report: ...\review.json · Log: codex-bridge read ...\2026-08-05_120000_slug
167
166
  ```
168
167
 
169
168
  Incorrect — "I analyzed the changes manually because Codex did not perform the review correctly,"
@@ -175,4 +174,4 @@ the status from the runner and stop.
175
174
 
176
175
  Incorrect — "The review started in the background; I will notify you when it finishes." There will
177
176
  be no notification: the agent
178
- terminates with the response, and an abandoned run leaves an empty `raw.log` — this is FAIL.
177
+ terminates with the response, and an abandoned run leaves no event at all — this is FAIL.
@@ -137,9 +137,8 @@ An empty `answer` is FAIL with a path to the log, not a reason to choose differe
137
137
  ## The script determines status, not you
138
138
 
139
139
  - `OK` — `result.json` is filled, and the return code is zero.
140
- - `FAIL` — the result is empty, the return code is nonzero, or `raw.log` is empty (the run was
141
- abandoned
142
- at startup: there was no Codex process).
140
+ - `FAIL` — the result is empty, the return code is nonzero, or the run left no event and a
141
+ silent `stderr.log` (abandoned at startup: there was no Codex process).
143
142
  - `LIMIT` — the result is empty and the log signals a limit. The ChatGPT quota is exhausted, and the
144
143
  task was not
145
144
  completed; this is not a task failure and not a reason to restart.
@@ -172,7 +171,7 @@ ATTACH=<artifact root>\myproject\2026-07-30_1412_hooks started=2026-07-30T14:12:
172
171
  OK — The settings.json hook loads twice: from the plugin and the local config
173
172
  Key finding: duplicate loader (src/hooks/loader.ts:42)
174
173
  Unresolved: why the second load is needed
175
- Report: ...\report.md · Log: ...\raw.log
174
+ Report: ...\report.md · Log: codex-bridge read ...\2026-08-05_120000_slug
176
175
  ```
177
176
 
178
177
  Incorrect — "Codex started in the background (PID recorded). Waiting for scouting to finish — it
@@ -16,9 +16,10 @@ The root of the run folders comes from `CODEX_RUNS_ROOT` when it is set, otherwi
16
16
  would stop seeing runs whenever the root is overridden.
17
17
 
18
18
  The source is the `meta.json` in each run folder: it holds the spending, the model and the sandbox.
19
- The `raw.log` itself is not kept in git and may be deleted, so parsing it is not allowed — the
20
- accounting has to survive a transcript cleanup. For older runs without `meta.json` there is a
21
- fallback log parser.
19
+ Transport files are not kept in git and may be deleted, so parsing them is not allowed — the
20
+ accounting has to survive a transcript cleanup. Runs from before `meta.json` existed keep a
21
+ fallback parser for the `raw.log` those folders still contain; current runs no longer write that
22
+ file, and their spending comes from the CLI's own `turn.completed` events.
22
23
 
23
24
  `codex exec review` prints no spending at all, so such runs land in the "unaccounted" line. Counting
24
25
  them as zero is not allowed — that would silently understate the total.
@@ -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
  ]);
@@ -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
  );
@@ -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
+ }