@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.
- package/CHANGELOG.md +48 -0
- package/README.md +25 -4
- package/bin/codex-bridge.mjs +37 -0
- package/cli/doctor.mjs +20 -2
- package/cli/install.mjs +18 -4
- package/cli/manifest.mjs +3 -1
- 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/stop.mjs +4 -37
- package/cli/sweep.mjs +144 -0
- package/cli/table.mjs +110 -0
- package/package.json +1 -1
- package/src/agents/codex-build.md +7 -6
- package/src/agents/codex-review.md +8 -8
- package/src/agents/codex-scout.md +7 -6
- package/src/commands/usage.md +4 -3
- package/src/heartbeat.mjs +80 -0
- package/src/hook-definitions.mjs +11 -0
- package/src/hooks/live-runs.mjs +13 -6
- package/src/hooks/prune-guard.mjs +62 -0
- package/src/hooks/reply-guard.mjs +2 -2
- package/src/hooks/worktree-lock.mjs +8 -1
- 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 +25 -2
- package/src/meta/startup.mjs +21 -0
- package/src/meta/transport.mjs +35 -0
- package/src/meta/verdict.mjs +40 -42
- package/src/no-self-execution.mjs +18 -0
- 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 +158 -31
- package/src/runner/launcher.mjs +11 -0
- package/src/runner/worker.mjs +14 -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/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/sweep.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Closes dead-pid running records without deleting any run artifacts. */
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { formatSilence, heartbeatAge, isHeartbeatFresh } from '../src/heartbeat.mjs';
|
|
6
|
+
import { markAbandoned, pidAlive } from '../src/meta/run-state.mjs';
|
|
7
|
+
import { runsRoot } from '../src/runner/runs-root.mjs';
|
|
8
|
+
import { readJson } from '../src/write-meta.mjs';
|
|
9
|
+
import { renderTable } from './table.mjs';
|
|
10
|
+
|
|
11
|
+
const CLOSED_COLUMNS = [
|
|
12
|
+
{ key: 'project', label: 'project' },
|
|
13
|
+
{ key: 'run', label: 'run' },
|
|
14
|
+
{ key: 'state', label: 'state' },
|
|
15
|
+
];
|
|
16
|
+
const STALE_COLUMNS = [
|
|
17
|
+
{ key: 'project', label: 'project' },
|
|
18
|
+
{ key: 'run', label: 'run' },
|
|
19
|
+
{ key: 'silence', label: 'silent for' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function result(exitCode, output) {
|
|
23
|
+
return { exitCode, output };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseArgs(argv) {
|
|
27
|
+
const positional = [];
|
|
28
|
+
for (const arg of argv) {
|
|
29
|
+
if (typeof arg !== 'string' || arg.startsWith('-')) {
|
|
30
|
+
return { error: `codex-bridge sweep: unknown option "${arg}".` };
|
|
31
|
+
}
|
|
32
|
+
positional.push(arg);
|
|
33
|
+
}
|
|
34
|
+
if (positional.length > 1) {
|
|
35
|
+
return { error: 'codex-bridge sweep accepts at most one project name.' };
|
|
36
|
+
}
|
|
37
|
+
const project = positional[0] || null;
|
|
38
|
+
if (project && (path.isAbsolute(project) || path.dirname(project) !== '.')) {
|
|
39
|
+
return { error: 'codex-bridge sweep requires a bare project name.' };
|
|
40
|
+
}
|
|
41
|
+
return { project };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function directories(root) {
|
|
45
|
+
try {
|
|
46
|
+
return fs.readdirSync(root, { withFileTypes: true })
|
|
47
|
+
.filter((entry) => entry.isDirectory())
|
|
48
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
49
|
+
} catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function selectedProjects(root, projectName) {
|
|
55
|
+
const entries = directories(root);
|
|
56
|
+
if (!projectName) {
|
|
57
|
+
return entries.map((entry) => ({ name: entry.name, dir: path.join(root, entry.name) }));
|
|
58
|
+
}
|
|
59
|
+
const entry = entries.find((candidate) => candidate.name === projectName);
|
|
60
|
+
if (!entry) return { error: `codex-bridge sweep: unknown project "${projectName}".` };
|
|
61
|
+
return [{ name: entry.name, dir: path.join(root, entry.name) }];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function staleLiveRuns(project) {
|
|
65
|
+
// The 2026-08-06 incident showed why stale heartbeat is report-only: a live worker remains
|
|
66
|
+
// the sole writer until stop kills it and records the outcome.
|
|
67
|
+
const stale = [];
|
|
68
|
+
for (const entry of directories(project.dir)) {
|
|
69
|
+
const dir = path.join(project.dir, entry.name);
|
|
70
|
+
const status = readJson(path.join(dir, 'status.json'));
|
|
71
|
+
if (!status || status.state !== 'running' || !pidAlive(status.pid) || isHeartbeatFresh(dir)) continue;
|
|
72
|
+
stale.push({
|
|
73
|
+
project: project.name,
|
|
74
|
+
run: entry.name,
|
|
75
|
+
silence: formatSilence(heartbeatAge(dir)),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return stale;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function sweepPlan(argv = [], options = {}) {
|
|
82
|
+
const parsed = parseArgs(argv);
|
|
83
|
+
if (parsed.error) return parsed;
|
|
84
|
+
const root = path.resolve(options.runsRootPath || runsRoot());
|
|
85
|
+
const projects = selectedProjects(root, parsed.project);
|
|
86
|
+
if (projects.error) return projects;
|
|
87
|
+
return { root, project: parsed.project, projects };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function closeDeadRuns(plan) {
|
|
91
|
+
const closed = [];
|
|
92
|
+
const failures = [];
|
|
93
|
+
for (const project of plan.projects) {
|
|
94
|
+
try {
|
|
95
|
+
// No worktree snapshot is passed, and that is the choice rather than an omission: `stop`
|
|
96
|
+
// closes one named run and knows which repository to look at, while a sweep of the whole
|
|
97
|
+
// store would have to run git in every repository every project ever pointed at. The runs
|
|
98
|
+
// it closes died long ago, so that list would describe today's tree, not their work — the
|
|
99
|
+
// reason text already says a file list is unavailable, which is the honest answer here.
|
|
100
|
+
for (const change of markAbandoned(project.dir)) {
|
|
101
|
+
closed.push({ project: project.name, run: change.run, state: change.state });
|
|
102
|
+
}
|
|
103
|
+
} catch (error) {
|
|
104
|
+
failures.push({ project: project.name, message: error.message });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { closed, failures };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function renderReport(plan, closed, stale, failures, options) {
|
|
111
|
+
const scope = plan.project ? `project "${plan.project}"` : 'all projects';
|
|
112
|
+
const lines = [`Sweep of ${scope} completed.`];
|
|
113
|
+
if (closed.length) {
|
|
114
|
+
lines.push(`Closed ${closed.length} running record${closed.length === 1 ? '' : 's'}:`);
|
|
115
|
+
lines.push(renderTable(CLOSED_COLUMNS, closed, options));
|
|
116
|
+
} else {
|
|
117
|
+
lines.push('Nothing to close; the store is unchanged.');
|
|
118
|
+
}
|
|
119
|
+
if (stale.length) {
|
|
120
|
+
lines.push(`Left ${stale.length} live-pid run${stale.length === 1 ? '' : 's'} with stale heartbeats untouched:`);
|
|
121
|
+
lines.push(renderTable(STALE_COLUMNS, stale, options));
|
|
122
|
+
lines.push('End each one explicitly:');
|
|
123
|
+
for (const run of stale) lines.push(` codex-bridge stop ${run.run}`);
|
|
124
|
+
} else {
|
|
125
|
+
lines.push('Live-pid runs with stale heartbeats left untouched: none.');
|
|
126
|
+
}
|
|
127
|
+
if (failures.length) {
|
|
128
|
+
lines.push('Some project records could not be swept:');
|
|
129
|
+
for (const failure of failures) lines.push(` ${failure.project}: ${failure.message}`);
|
|
130
|
+
}
|
|
131
|
+
return lines.join('\n');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function sweep(argv = [], options = {}) {
|
|
135
|
+
const plan = sweepPlan(argv, options);
|
|
136
|
+
if (plan.error) return result(2, plan.error);
|
|
137
|
+
const { closed, failures } = closeDeadRuns(plan);
|
|
138
|
+
// This is a report-only probe. markAbandoned owns the closure decision and is the only writer.
|
|
139
|
+
const stale = plan.projects.flatMap((project) => staleLiveRuns(project));
|
|
140
|
+
return result(
|
|
141
|
+
failures.length ? 1 : 0,
|
|
142
|
+
renderReport(plan, closed, stale, failures, options),
|
|
143
|
+
);
|
|
144
|
+
}
|
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.3.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",
|
|
@@ -5,9 +5,10 @@ model: haiku
|
|
|
5
5
|
tools: Bash
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
and its output is your
|
|
8
|
+
{{CODEX_NO_SELF_EXECUTION}}
|
|
9
|
+
|
|
10
|
+
You are the build dispatcher. One command starts Codex with write access, and its output is your
|
|
11
|
+
answer.
|
|
11
12
|
|
|
12
13
|
**Why you exist.** The orchestrator runs on a Claude Max subscription, while Codex runs on a
|
|
13
14
|
ChatGPT subscription. Implementation must burn someone else's quota, not yours. Every extra line you
|
|
@@ -154,8 +155,8 @@ orchestrator.
|
|
|
154
155
|
## The script determines status, not you
|
|
155
156
|
|
|
156
157
|
- `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,
|
|
158
|
-
abandoned at startup:
|
|
158
|
+
- `FAIL` — the result is empty, the return code is nonzero, the run left no event and a silent
|
|
159
|
+
`stderr.log` (abandoned at startup:
|
|
159
160
|
there was no Codex process), or **the wrong work was done**: no file in `changes[]` matches
|
|
160
161
|
what actually changed between the worktree snapshots. The schema cannot catch this — a report
|
|
161
162
|
about
|
|
@@ -204,7 +205,7 @@ OK — Added retry to the fetch helper and a timeout test
|
|
|
204
205
|
Files: 2 changed · src/net/fetch.ts, src/net/fetch.test.ts
|
|
205
206
|
Verification: npm test — pass
|
|
206
207
|
Flags: none
|
|
207
|
-
Report: ...\report.md · Log: ...\
|
|
208
|
+
Report: ...\report.md · Log: codex-bridge read ...\2026-08-05_120000_slug
|
|
208
209
|
```
|
|
209
210
|
|
|
210
211
|
Incorrect — "Codex failed, so I finished it myself," followed by a retelling of the diff.
|
|
@@ -5,9 +5,10 @@ model: haiku
|
|
|
5
5
|
tools: Bash
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
{{CODEX_NO_SELF_EXECUTION}}
|
|
9
|
+
|
|
10
|
+
You are the independent review dispatcher. Forming an opinion about the diff yourself is the shape
|
|
11
|
+
self-execution takes here: one command starts a review by Codex, and its output is your answer.
|
|
11
12
|
|
|
12
13
|
**Why you exist.** A model that wrote code is bad at seeing its own mistakes, so a view
|
|
13
14
|
from another model is needed. Codex runs on a ChatGPT subscription, so its opinion costs nothing
|
|
@@ -129,9 +130,8 @@ and the wait can be repeated for free, so neither has any reason to restart anyt
|
|
|
129
130
|
## The script determines status, not you
|
|
130
131
|
|
|
131
132
|
- `OK` — `review.json` is filled, and the return code is zero.
|
|
132
|
-
- `FAIL` — the result is empty, the return code is nonzero, or
|
|
133
|
-
abandoned
|
|
134
|
-
at startup: there was no Codex process).
|
|
133
|
+
- `FAIL` — the result is empty, the return code is nonzero, or the run left no event and a
|
|
134
|
+
silent `stderr.log` (abandoned at startup: there was no Codex process).
|
|
135
135
|
- `LIMIT` — the result is empty and the log signals a limit. The ChatGPT quota is exhausted, and the
|
|
136
136
|
review was not
|
|
137
137
|
completed; this is not a review failure and not a reason to restart.
|
|
@@ -163,7 +163,7 @@ ATTACH=<artifact root>\myproject\2026-07-30_1412_review-auth started=2026-07-30T
|
|
|
163
163
|
OK — verdict needs-attention
|
|
164
164
|
Findings: critical 0 · high 1 · medium 2 · low 3
|
|
165
165
|
Top: high src/api/auth.ts:88 — The promise is not awaited, and the error is lost
|
|
166
|
-
Report: ...\review.json · Log: ...\
|
|
166
|
+
Report: ...\review.json · Log: codex-bridge read ...\2026-08-05_120000_slug
|
|
167
167
|
```
|
|
168
168
|
|
|
169
169
|
Incorrect — "I analyzed the changes manually because Codex did not perform the review correctly,"
|
|
@@ -175,4 +175,4 @@ the status from the runner and stop.
|
|
|
175
175
|
|
|
176
176
|
Incorrect — "The review started in the background; I will notify you when it finishes." There will
|
|
177
177
|
be no notification: the agent
|
|
178
|
-
terminates with the response, and an abandoned run leaves
|
|
178
|
+
terminates with the response, and an abandoned run leaves no event at all — this is FAIL.
|
|
@@ -5,8 +5,10 @@ model: haiku
|
|
|
5
5
|
tools: Bash
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
{{CODEX_NO_SELF_EXECUTION}}
|
|
9
|
+
|
|
10
|
+
You are the scout dispatcher. Investigating anything yourself is the shape self-execution takes
|
|
11
|
+
here: one command starts Codex, and its output is your answer.
|
|
10
12
|
|
|
11
13
|
**Why you exist.** The orchestrator runs on a Claude Max subscription, while Codex runs on a
|
|
12
14
|
ChatGPT subscription. All heavy work (reading files, reasoning, generating the report) must happen
|
|
@@ -137,9 +139,8 @@ An empty `answer` is FAIL with a path to the log, not a reason to choose differe
|
|
|
137
139
|
## The script determines status, not you
|
|
138
140
|
|
|
139
141
|
- `OK` — `result.json` is filled, and the return code is zero.
|
|
140
|
-
- `FAIL` — the result is empty, the return code is nonzero, or
|
|
141
|
-
abandoned
|
|
142
|
-
at startup: there was no Codex process).
|
|
142
|
+
- `FAIL` — the result is empty, the return code is nonzero, or the run left no event and a
|
|
143
|
+
silent `stderr.log` (abandoned at startup: there was no Codex process).
|
|
143
144
|
- `LIMIT` — the result is empty and the log signals a limit. The ChatGPT quota is exhausted, and the
|
|
144
145
|
task was not
|
|
145
146
|
completed; this is not a task failure and not a reason to restart.
|
|
@@ -172,7 +173,7 @@ ATTACH=<artifact root>\myproject\2026-07-30_1412_hooks started=2026-07-30T14:12:
|
|
|
172
173
|
OK — The settings.json hook loads twice: from the plugin and the local config
|
|
173
174
|
Key finding: duplicate loader (src/hooks/loader.ts:42)
|
|
174
175
|
Unresolved: why the second load is needed
|
|
175
|
-
Report: ...\report.md · Log: ...\
|
|
176
|
+
Report: ...\report.md · Log: codex-bridge read ...\2026-08-05_120000_slug
|
|
176
177
|
```
|
|
177
178
|
|
|
178
179
|
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.
|