@lyupro/codex-bridge 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/README.md +18 -4
- package/bin/codex-bridge.mjs +29 -0
- package/cli/doctor.mjs +26 -3
- package/cli/install.mjs +18 -4
- package/cli/manifest.mjs +6 -4
- package/cli/projects.mjs +92 -0
- package/cli/prune-args.mjs +133 -0
- package/cli/prune-plan.mjs +205 -0
- package/cli/prune.mjs +278 -0
- package/cli/read.mjs +103 -0
- package/cli/run-lookup.mjs +59 -0
- package/cli/runs-inventory.mjs +173 -0
- package/cli/settings-merge.mjs +18 -4
- package/cli/stop.mjs +4 -37
- package/cli/table.mjs +110 -0
- package/package.json +1 -1
- package/src/agents/codex-build.md +12 -10
- package/src/agents/codex-review.md +11 -5
- package/src/agents/codex-scout.md +11 -10
- package/src/commands/usage.md +4 -3
- package/src/hook-definitions.mjs +22 -0
- package/src/hooks/live-runs.mjs +98 -0
- package/src/hooks/prune-guard.mjs +62 -0
- package/src/hooks/reply-guard.mjs +45 -22
- package/src/hooks/worktree-lock.mjs +70 -0
- package/src/meta/deadline.mjs +19 -0
- package/src/meta/events.mjs +166 -0
- package/src/meta/reason.mjs +29 -0
- package/src/meta/reply.mjs +40 -7
- package/src/meta/run-state.mjs +7 -1
- package/src/meta/startup.mjs +21 -0
- package/src/meta/transport.mjs +35 -0
- package/src/meta/verdict.mjs +40 -42
- package/src/required-inputs.mjs +49 -6
- package/src/retention.mjs +120 -0
- package/src/run-codex.mjs +3 -3
- package/src/run-config.json +5 -1
- package/src/run-config.mjs +54 -1
- package/src/runner/attach.mjs +1 -1
- package/src/runner/codex-cmd.mjs +119 -31
- package/src/runner/continuation.mjs +107 -0
- package/src/runner/launcher.mjs +23 -40
- package/src/runner/worker.mjs +11 -3
- package/src/write-meta.mjs +31 -22
|
@@ -0,0 +1,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/settings-merge.mjs
CHANGED
|
@@ -57,13 +57,24 @@ function groupHooks(group) {
|
|
|
57
57
|
return Array.isArray(group?.hooks) ? group.hooks : [];
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
const hasOwnCommand = (group, command) =>
|
|
61
|
+
groupHooks(group).some((hook) => hook?.type === 'command' && hook.command === command);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Presence is decided by the command, not by the matcher it currently sits under.
|
|
65
|
+
*
|
|
66
|
+
* The matcher is generated from the tool list in hook-definitions.mjs, so it changes whenever a
|
|
67
|
+
* host spelling is added — `Agent` became `Agent|Task` in one release, and the write-tool matcher
|
|
68
|
+
* will grow the same way. A host installed under the old matcher would then be invisible to the
|
|
69
|
+
* new lookup: update would register a duplicate and uninstall would leave a hook pointing at a
|
|
70
|
+
* deleted file. The command is this package's own absolute path, so it identifies our entry
|
|
71
|
+
* wherever the group ended up.
|
|
72
|
+
*/
|
|
60
73
|
export async function inspectHook(settingsPath, specOrPath) {
|
|
61
74
|
const state = await readSettings(settingsPath);
|
|
62
75
|
const spec = normalizeSpec(specOrPath);
|
|
63
76
|
const present = groups(state.settings, spec.event)
|
|
64
|
-
.
|
|
65
|
-
.some((group) => groupHooks(group)
|
|
66
|
-
.some((hook) => hook?.type === 'command' && hook.command === spec.command));
|
|
77
|
+
.some((group) => hasOwnCommand(group, spec.command));
|
|
67
78
|
return { ...state, ...spec, present };
|
|
68
79
|
}
|
|
69
80
|
|
|
@@ -110,9 +121,12 @@ export async function removeHook(settingsPath, specOrPath, { createdGroup = fals
|
|
|
110
121
|
if (!state.present) return { changed: false };
|
|
111
122
|
const settings = structuredClone(state.settings);
|
|
112
123
|
const eventGroups = groups(settings, spec.event);
|
|
124
|
+
// Removal follows the same rule as the lookup above: our command, whatever matcher it is
|
|
125
|
+
// filed under. A foreign hook is left alone because its command is not ours, not because it
|
|
126
|
+
// sits in another group.
|
|
113
127
|
for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
|
|
114
128
|
const group = eventGroups[index];
|
|
115
|
-
if (group
|
|
129
|
+
if (!hasOwnCommand(group, spec.command)) continue;
|
|
116
130
|
if (!Array.isArray(group?.hooks)) continue;
|
|
117
131
|
group.hooks = group.hooks.filter((hook) =>
|
|
118
132
|
!(hook?.type === 'command' && hook.command === spec.command));
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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.
|
|
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",
|
|
@@ -32,12 +32,13 @@ job is to report the run status honestly, including failure.
|
|
|
32
32
|
reuse an order id from another order — the runner chains runs by that label, and a made-up label
|
|
33
33
|
is how a repeat run hides. If the orchestrator did not give a required input, do not guess: start
|
|
34
34
|
the runner without its flag and return the runner's refusal exactly.
|
|
35
|
-
- `continue` — pass
|
|
36
|
-
add or guess it yourself.
|
|
37
|
-
|
|
38
|
-
`--
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
- `continue` — pass the `--continue` flag only when the orchestrator gave the matching `continue:`
|
|
36
|
+
grant explicitly; do not add or guess it yourself. A continuation is assigned by the
|
|
37
|
+
orchestrator, never chosen by you. After the verdict, return the exact attaching output and stop;
|
|
38
|
+
do not issue or invent another continuation. The contract is the same kind as `--scope`: if this
|
|
39
|
+
task (the same `slug` and the same repository) already had a run, the runner will refuse to start
|
|
40
|
+
without `--continue` — before starting Codex, so no quota is spent. If the orchestrator did not
|
|
41
|
+
grant a repeat, return the runner's refusal exactly.
|
|
41
42
|
- Optional: `effort: <none|minimal|low|medium|high|xhigh|max>`, `slug:`, `verify: <verification
|
|
42
43
|
command>` (for example `npm test`, `tsc --noEmit`).
|
|
43
44
|
|
|
@@ -57,7 +58,8 @@ Add `--effort "<value>"` only when the orchestrator named a depth, and only with
|
|
|
57
58
|
decides, which is the intended default — a placeholder copied from this template is refused before
|
|
58
59
|
Codex starts.
|
|
59
60
|
|
|
60
|
-
If the orchestrator gave `continue
|
|
61
|
+
If the orchestrator gave the matching `continue:` grant, add the bare `--continue` flag to the
|
|
62
|
+
command, with no value.
|
|
61
63
|
No value
|
|
62
64
|
is passed to it: the runner accepts only `1/true/yes/0/false/no`, while a placeholder string left in
|
|
63
65
|
the
|
|
@@ -152,8 +154,8 @@ orchestrator.
|
|
|
152
154
|
## The script determines status, not you
|
|
153
155
|
|
|
154
156
|
- `OK` — `result.json` is filled, the return code is zero, and the report matches the worktree.
|
|
155
|
-
- `FAIL` — the result is empty, the return code is nonzero,
|
|
156
|
-
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:
|
|
157
159
|
there was no Codex process), or **the wrong work was done**: no file in `changes[]` matches
|
|
158
160
|
what actually changed between the worktree snapshots. The schema cannot catch this — a report
|
|
159
161
|
about
|
|
@@ -202,7 +204,7 @@ OK — Added retry to the fetch helper and a timeout test
|
|
|
202
204
|
Files: 2 changed · src/net/fetch.ts, src/net/fetch.test.ts
|
|
203
205
|
Verification: npm test — pass
|
|
204
206
|
Flags: none
|
|
205
|
-
Report: ...\report.md · Log: ...\
|
|
207
|
+
Report: ...\report.md · Log: codex-bridge read ...\2026-08-05_120000_slug
|
|
206
208
|
```
|
|
207
209
|
|
|
208
210
|
Incorrect — "Codex failed, so I finished it myself," followed by a retelling of the diff.
|
|
@@ -31,6 +31,10 @@ opinion, not a verdict.
|
|
|
31
31
|
another order — the runner chains runs by that label, and a made-up label is how a repeat run
|
|
32
32
|
hides. If the orchestrator did not give a required input, do not guess — start the runner without
|
|
33
33
|
its flag and return the runner's refusal verbatim.
|
|
34
|
+
- `continue` is conditional — pass the `--continue` flag only when the orchestrator gave the
|
|
35
|
+
matching `continue:` grant explicitly; do not add or guess it yourself. A continuation is
|
|
36
|
+
assigned by the orchestrator, never chosen by you. After the verdict, return the exact attaching
|
|
37
|
+
output and stop; do not issue or invent another continuation.
|
|
34
38
|
- Optional: review focus as text ("look for races and error handling"), `slug:`, `effort:
|
|
35
39
|
<none|minimal|low|medium|high|xhigh|max>`.
|
|
36
40
|
|
|
@@ -54,6 +58,9 @@ line. To get the verdict, run the **identical command a second time** — same `
|
|
|
54
58
|
`--slug`, same flags. That second call does not start a second run and costs no quota: it attaches
|
|
55
59
|
to the run already in flight, prints `ATTACH=<path>`, blocks until the verdict exists and prints it.
|
|
56
60
|
|
|
61
|
+
If the orchestrator gave the matching `continue:` grant, add the bare `--continue` flag to the
|
|
62
|
+
command, with no value. If it was not given, the flag must not be present in the command at all.
|
|
63
|
+
|
|
57
64
|
Background execution (`run_in_background`, `&`, `nohup`) is prohibited. Interruption is no longer a
|
|
58
65
|
problem worth handling: if the attaching call is killed by a time ceiling, repeat it — every repeat
|
|
59
66
|
attaches to the same run. A real run takes 20-25 minutes, which is normal, not a hang. Give the
|
|
@@ -122,9 +129,8 @@ and the wait can be repeated for free, so neither has any reason to restart anyt
|
|
|
122
129
|
## The script determines status, not you
|
|
123
130
|
|
|
124
131
|
- `OK` — `review.json` is filled, and the return code is zero.
|
|
125
|
-
- `FAIL` — the result is empty, the return code is nonzero, or
|
|
126
|
-
abandoned
|
|
127
|
-
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).
|
|
128
134
|
- `LIMIT` — the result is empty and the log signals a limit. The ChatGPT quota is exhausted, and the
|
|
129
135
|
review was not
|
|
130
136
|
completed; this is not a review failure and not a reason to restart.
|
|
@@ -156,7 +162,7 @@ ATTACH=<artifact root>\myproject\2026-07-30_1412_review-auth started=2026-07-30T
|
|
|
156
162
|
OK — verdict needs-attention
|
|
157
163
|
Findings: critical 0 · high 1 · medium 2 · low 3
|
|
158
164
|
Top: high src/api/auth.ts:88 — The promise is not awaited, and the error is lost
|
|
159
|
-
Report: ...\review.json · Log: ...\
|
|
165
|
+
Report: ...\review.json · Log: codex-bridge read ...\2026-08-05_120000_slug
|
|
160
166
|
```
|
|
161
167
|
|
|
162
168
|
Incorrect — "I analyzed the changes manually because Codex did not perform the review correctly,"
|
|
@@ -168,4 +174,4 @@ the status from the runner and stop.
|
|
|
168
174
|
|
|
169
175
|
Incorrect — "The review started in the background; I will notify you when it finishes." There will
|
|
170
176
|
be no notification: the agent
|
|
171
|
-
terminates with the response, and an abandoned run leaves
|
|
177
|
+
terminates with the response, and an abandoned run leaves no event at all — this is FAIL.
|
|
@@ -32,11 +32,12 @@ files, do not run grep, do not retell the report, and do not reason about the ta
|
|
|
32
32
|
pass one `--question "<text>"` flag exactly as given. Never invent, merge, reword, or drop a
|
|
33
33
|
sub-question. If the orchestrator did not give one, do not guess — start the runner without
|
|
34
34
|
the flag and return its refusal verbatim.
|
|
35
|
-
- `continue` — pass
|
|
36
|
-
add or guess it yourself.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
- `continue` — pass the `--continue` flag only when the orchestrator gave the matching `continue:`
|
|
36
|
+
grant explicitly; do not add or guess it yourself. A continuation is assigned by the
|
|
37
|
+
orchestrator, never chosen by you. After the verdict, return the exact attaching output and stop;
|
|
38
|
+
do not issue or invent another continuation. The contract is strict, like `--scope` in
|
|
39
|
+
codex-build: if this task (the same `slug` and the same repository) already had a run, the runner
|
|
40
|
+
will refuse to start without `--continue` — before starting Codex, so no quota is spent.
|
|
40
41
|
|
|
41
42
|
## The only thing you do
|
|
42
43
|
|
|
@@ -57,7 +58,8 @@ Add `--effort "<value>"` only when the orchestrator named a depth, and only with
|
|
|
57
58
|
decides, which is the intended default — a placeholder copied from this template is refused before
|
|
58
59
|
Codex starts.
|
|
59
60
|
|
|
60
|
-
If the orchestrator gave `continue
|
|
61
|
+
If the orchestrator gave the matching `continue:` grant, add the bare `--continue` flag to the
|
|
62
|
+
command, with no value.
|
|
61
63
|
No value
|
|
62
64
|
is passed to it: the runner accepts only `1/true/yes/0/false/no`, while a placeholder string left in
|
|
63
65
|
the
|
|
@@ -135,9 +137,8 @@ An empty `answer` is FAIL with a path to the log, not a reason to choose differe
|
|
|
135
137
|
## The script determines status, not you
|
|
136
138
|
|
|
137
139
|
- `OK` — `result.json` is filled, and the return code is zero.
|
|
138
|
-
- `FAIL` — the result is empty, the return code is nonzero, or
|
|
139
|
-
abandoned
|
|
140
|
-
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).
|
|
141
142
|
- `LIMIT` — the result is empty and the log signals a limit. The ChatGPT quota is exhausted, and the
|
|
142
143
|
task was not
|
|
143
144
|
completed; this is not a task failure and not a reason to restart.
|
|
@@ -170,7 +171,7 @@ ATTACH=<artifact root>\myproject\2026-07-30_1412_hooks started=2026-07-30T14:12:
|
|
|
170
171
|
OK — The settings.json hook loads twice: from the plugin and the local config
|
|
171
172
|
Key finding: duplicate loader (src/hooks/loader.ts:42)
|
|
172
173
|
Unresolved: why the second load is needed
|
|
173
|
-
Report: ...\report.md · Log: ...\
|
|
174
|
+
Report: ...\report.md · Log: codex-bridge read ...\2026-08-05_120000_slug
|
|
174
175
|
```
|
|
175
176
|
|
|
176
177
|
Incorrect — "Codex started in the background (PID recorded). Waiting for scouting to finish — it
|
package/src/commands/usage.md
CHANGED
|
@@ -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
|
-
|
|
20
|
-
accounting has to survive a transcript cleanup.
|
|
21
|
-
fallback log
|
|
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.
|
package/src/hook-definitions.mjs
CHANGED
|
@@ -19,7 +19,29 @@ export const SUBAGENT_TOOLS = Object.freeze(['Agent', 'Task']);
|
|
|
19
19
|
/** A PreToolUse matcher is a regular expression over tool names, so alternation covers both. */
|
|
20
20
|
export const SUBAGENT_TOOL_MATCHER = SUBAGENT_TOOLS.join('|');
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* File-writing tools differ across Claude Code hosts just like subagent tools do. Register every
|
|
24
|
+
* host spelling here so the worktree lock receives the edit before it can enter a live runner's
|
|
25
|
+
* before/after snapshot; a list kept only in the hook would leave one host silently unlocked.
|
|
26
|
+
*/
|
|
27
|
+
export const WRITE_TOOLS = Object.freeze(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
|
|
28
|
+
|
|
29
|
+
/** A PreToolUse matcher is a regular expression over tool names, so alternation covers all four. */
|
|
30
|
+
export const WRITE_TOOL_MATCHER = WRITE_TOOLS.join('|');
|
|
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
|
+
|
|
22
42
|
export const HOOK_DEFINITIONS = Object.freeze([
|
|
23
43
|
Object.freeze({ event: 'SubagentStop', matcher: '*', file: 'reply-guard.mjs' }),
|
|
24
44
|
Object.freeze({ event: 'PreToolUse', matcher: SUBAGENT_TOOL_MATCHER, file: 'order-gate.mjs' }),
|
|
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' }),
|
|
25
47
|
]);
|