@lyupro/codex-bridge 0.2.0 → 0.3.1
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 +29 -0
- package/README.md +15 -1
- package/bin/codex-bridge.mjs +9 -0
- package/cli/doctor.mjs +36 -3
- package/cli/manifest.mjs +7 -4
- package/cli/projects.mjs +17 -4
- package/cli/sweep.mjs +144 -0
- package/package.json +3 -2
- package/src/agents/codex-build.md +4 -3
- package/src/agents/codex-review.md +4 -3
- package/src/agents/codex-scout.md +4 -2
- package/src/cli-names.mjs +6 -0
- package/src/conventions.md +44 -0
- package/src/heartbeat.mjs +80 -0
- package/src/hooks/live-runs.mjs +13 -6
- package/src/hooks/prune-guard.mjs +12 -4
- package/src/hooks/worktree-lock.mjs +8 -1
- package/src/meta/run-state.mjs +18 -1
- package/src/no-self-execution.mjs +18 -0
- package/src/runner/codex-cmd.mjs +49 -10
- package/src/runner/conventions.mjs +39 -0
- package/src/runner/launcher.mjs +3 -0
- package/src/runner/worker.mjs +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.3.1] - 2026-08-07
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- The CLI answers to `codexb` as well as `codex-bridge`, and README now documents the global install that puts both names on `PATH`. Every spelling that can reach deletion comes from one frozen list, and the prune guard builds its matcher from that list rather than repeating the names — two independent lists drift silently, which is exactly how the installer and test hook lists had already drifted. The full name stays primary: the docs, checklists and three agent prompts are written with it.
|
|
12
|
+
- Every run carries the rules it is judged by. A seeded `conventions.md` beside `run-config.json` and an optional `.codex-conventions.md` in the repository being worked on are pasted into `task.md` verbatim, under one `## Conventions` section. Either file missing is not an error, and a blank one produces no section at all — rules handed over as an empty heading read as rules ignored. Until now project rules reached a run only when the orchestrator remembered to write "read CLAUDE.md" into the task, and six acceptance passes in a row caught the same class of violation: the rule existed, applied, and had not travelled. Pasting rather than referencing is the point — "read file X" is hope, and a run is free not to.
|
|
13
|
+
- `doctor` opens with the path of the package copy answering, marked `clone` or `installed package`, and reports whether the host conventions file was found, warning when it exists but is empty. A global install puts a second copy of the package beside any clone, and `update` copies host files from whichever copy launched it — so two diagnoses of the same host were previously indistinguishable.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- `codex-bridge projects` printed the `last run` column on two clocks. Folder names are written in local time and `meta.finished_at` is an ISO stamp in UTC, and each reached the column through its own branch, so a run whose folder says `144243` was listed as `12:46` — the operator's own zone offset, indistinguishable from a different run. Which run is newest was never affected, only how it printed.
|
|
18
|
+
|
|
19
|
+
## [0.3.0] - 2026-08-07
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `codex-bridge sweep [<project>]` closes running records whose runner pid is dead, across the whole store or one project. It changes state but deletes no run folder or transport, never touches a live pid, and lists every stalled-but-live run with the exact `codex-bridge stop <run>` line that ends it. A dead record can therefore be cleared without paying for another run, while a live worker remains the sole writer until `stop` kills it first.
|
|
24
|
+
- Every new run folder carries a `heartbeat` file, and `status.json` carries `stdio_drained`. The heartbeat records movement from Codex output and the still-running child rather than a timer that would keep an orphan alive; `stdio_drained` says whether stdout/stderr closed normally or the bounded drain grace had to end the run.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- Hook liveness now requires `state: running`, a live runner pid and a heartbeat no older than five minutes. A stalled run therefore stops holding the worktree lock instead of holding it until the deadline; the refusal that remains — always about a working run — says how long ago it last made progress and prints `codex-bridge stop <run>` for taking the repository back early. Record cleanup and the second-writing-run check still judge by pid alone, so a stale but live run is released to the operator, not closed underneath its worker.
|
|
29
|
+
- The worker closes a run after `exit` with a bounded 30-second grace for stdio drain instead of waiting for `close`, which a grandchild can hold open forever. Healthy runs keep the event tail that carries their result and record `stdio_drained: true`; only the fallback records `false`.
|
|
30
|
+
- The three dispatcher prompts now open with one shared no-self-execution block rendered through `{{CODEX_NO_SELF_EXECUTION}}`. If `run-codex.mjs` does not start a run, the dispatcher must return `FAIL` and stop, and a reply with no run folder is blocked by the guard. The installed prompts change only when the host runs `codex-bridge update`; a host that does not update keeps the old prompts.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- A dispatcher that cannot start Codex can no longer read the code, write the files or fix the tests itself and claim a result. The guard also rejects a made-up reply with no `RUN=` or `ATTACH=` behind it: no run, no result.
|
|
35
|
+
|
|
7
36
|
## [0.2.0] - 2026-08-06
|
|
8
37
|
|
|
9
38
|
### Added
|
package/README.md
CHANGED
|
@@ -7,7 +7,10 @@ subscription instead of the Claude one.
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npx @lyupro/codex-bridge install # into ~/.claude
|
|
10
|
+
npx @lyupro/codex-bridge install # one-off, into ~/.claude
|
|
11
|
+
npm i -g @lyupro/codex-bridge # install both command names globally
|
|
12
|
+
codex-bridge install # full name, from any directory
|
|
13
|
+
codexb install # short name, same command
|
|
11
14
|
node bin/codex-bridge.mjs install # from a clone, same thing
|
|
12
15
|
node bin/codex-bridge.mjs install --scope project # into <repo>/.claude
|
|
13
16
|
node bin/codex-bridge.mjs update # bring an installation up to the current version
|
|
@@ -17,8 +20,13 @@ node bin/codex-bridge.mjs stop <run> # close a hanging run by hand, without h
|
|
|
17
20
|
node bin/codex-bridge.mjs read <run> # read a run: its events rendered as text, on demand
|
|
18
21
|
node bin/codex-bridge.mjs projects # what the run store holds: projects, runs, weight, tokens
|
|
19
22
|
node bin/codex-bridge.mjs prune <project> # reclaim disk space, transport only unless told otherwise
|
|
23
|
+
node bin/codex-bridge.mjs sweep [<project>] # close running records whose runner is gone
|
|
20
24
|
```
|
|
21
25
|
|
|
26
|
+
When a global install and a clone coexist, there are two copies of the package. `codex-bridge update`
|
|
27
|
+
and `codexb update` copy host files from whichever copy launched the command, so `codexb update`
|
|
28
|
+
from `PATH` can silently revert a host that `npm run dev:install` from the clone just updated.
|
|
29
|
+
|
|
22
30
|
`install` copies the package into the host's `agents/codex/`, its slash commands into
|
|
23
31
|
`commands/codex/`, and registers its four hooks in `settings.json` — **merged**, so hooks that
|
|
24
32
|
are already there survive, and the file is backed up before every write. Run it twice and the
|
|
@@ -54,6 +62,12 @@ accounting, reports and worktree snapshots alone; `--purge` is what takes a fold
|
|
|
54
62
|
The same transport is dropped automatically from runs older than 30 days when a new run starts,
|
|
55
63
|
which the reply says out loud; `retention` in `run-config.json` changes the age or turns it off.
|
|
56
64
|
|
|
65
|
+
`sweep` is housekeeping for records left by a dead runner. With no project it scans the whole
|
|
66
|
+
store; with one project it limits the scan to that folder. It uses the same dead-pid decision as
|
|
67
|
+
start-of-run cleanup, never closes a live pid even when its heartbeat is stale, and never deletes
|
|
68
|
+
run artifacts. A stale live-pid record is named with the exact `codex-bridge stop <run>` command
|
|
69
|
+
needed to close it.
|
|
70
|
+
|
|
57
71
|
## What it gives you
|
|
58
72
|
|
|
59
73
|
- **Three dispatcher agents** for Claude Code — reconnaissance (read-only), implementation
|
package/bin/codex-bridge.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { read } from '../cli/read.mjs';
|
|
|
10
10
|
import { packageInfo } from '../cli/manifest.mjs';
|
|
11
11
|
import { prune } from '../cli/prune.mjs';
|
|
12
12
|
import { stop } from '../cli/stop.mjs';
|
|
13
|
+
import { sweep } from '../cli/sweep.mjs';
|
|
13
14
|
import { uninstall } from '../cli/uninstall.mjs';
|
|
14
15
|
import { update } from '../cli/update.mjs';
|
|
15
16
|
|
|
@@ -23,8 +24,10 @@ Usage:
|
|
|
23
24
|
codex-bridge projects [<name>] [--json]
|
|
24
25
|
codex-bridge prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]
|
|
25
26
|
codex-bridge prune --all-projects [--older-than <age>] [-f] [--json]
|
|
27
|
+
codex-bridge sweep [<project>]
|
|
26
28
|
codex-bridge read <run>
|
|
27
29
|
codex-bridge stop <run>
|
|
30
|
+
codexb <same command forms as codex-bridge>
|
|
28
31
|
codex-bridge --help
|
|
29
32
|
codex-bridge --version
|
|
30
33
|
|
|
@@ -35,6 +38,7 @@ Commands:
|
|
|
35
38
|
doctor Diagnose the selected Claude Code host
|
|
36
39
|
projects List projects or runs from the run store
|
|
37
40
|
prune Remove archived transport, or purge selected run folders
|
|
41
|
+
sweep Close running records whose runner is gone
|
|
38
42
|
read Render a run's structured event stream
|
|
39
43
|
stop Stop a running Codex run and record FAIL`;
|
|
40
44
|
|
|
@@ -105,6 +109,11 @@ export async function main(argv, io = console) {
|
|
|
105
109
|
io.log(result.output);
|
|
106
110
|
return result.exitCode;
|
|
107
111
|
}
|
|
112
|
+
if (command === 'sweep') {
|
|
113
|
+
const result = sweep(rest);
|
|
114
|
+
io.log(result.output);
|
|
115
|
+
return result.exitCode;
|
|
116
|
+
}
|
|
108
117
|
if (command === 'install' || command === 'update' || command === 'uninstall') {
|
|
109
118
|
const options = commandOptions(command, rest);
|
|
110
119
|
const host = resolveHost(options);
|
package/cli/doctor.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Diagnoses a Claude Code host without modifying it. */
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { spawnSync } from 'node:child_process';
|
|
5
6
|
import {
|
|
6
7
|
fileFingerprint,
|
|
@@ -47,6 +48,34 @@ function retentionCheck(host) {
|
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
async function conventionsCheck(host) {
|
|
52
|
+
const file = path.join(host.agentsDir, 'conventions.md');
|
|
53
|
+
let content;
|
|
54
|
+
try {
|
|
55
|
+
content = await fs.readFile(file, 'utf8');
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (err.code === 'ENOENT') return check('conventions', 'ok', `${file} (not found; optional)`);
|
|
58
|
+
return check('conventions', 'fail', `cannot read ${file}: ${err.message}`);
|
|
59
|
+
}
|
|
60
|
+
return content.trim()
|
|
61
|
+
? check('conventions', 'ok', `${file} (found)`)
|
|
62
|
+
: check('conventions', 'warn', `${file} (found but empty)`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Which copy of the package is answering. Plan_19 gives the CLI a second name, and a global install
|
|
67
|
+
* puts a second copy of the package on the machine beside any clone. `update` copies host files
|
|
68
|
+
* from whichever copy launched it, so `codexb update` from PATH silently reverts a host that
|
|
69
|
+
* `npm run dev:install` from the clone had just refreshed — and every line below this one describes
|
|
70
|
+
* the host as seen by THIS copy. An operator comparing two diagnoses has no other way to tell them
|
|
71
|
+
* apart.
|
|
72
|
+
*/
|
|
73
|
+
function sourceCheck() {
|
|
74
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
75
|
+
const installed = root.split(/[\\/]/).includes('node_modules');
|
|
76
|
+
return check('source', 'ok', `${root} (${installed ? 'installed package' : 'clone'})`);
|
|
77
|
+
}
|
|
78
|
+
|
|
50
79
|
/**
|
|
51
80
|
* The runner asks git for the repository root before it picks a runs folder, so doctor has to
|
|
52
81
|
* ask the same question: run from `src/runner`, a plain cwd would name the folder `runner` and
|
|
@@ -134,7 +163,7 @@ async function rulesCheck(host, record) {
|
|
|
134
163
|
}
|
|
135
164
|
|
|
136
165
|
export async function diagnose({ host, codexProbe = probeCodex, currentPackage } = {}) {
|
|
137
|
-
const checks = [];
|
|
166
|
+
const checks = [sourceCheck()];
|
|
138
167
|
const hostExists = await exists(host.root);
|
|
139
168
|
checks.push(check('host', hostExists ? 'ok' : 'warn', `${host.root} (${host.scope}, ${hostExists ? 'exists' : 'absent'})`));
|
|
140
169
|
|
|
@@ -173,6 +202,8 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
173
202
|
checks.push(...await hookChecks(host, record));
|
|
174
203
|
const retention = retentionCheck(host);
|
|
175
204
|
checks.push(retention);
|
|
205
|
+
const conventions = await conventionsCheck(host);
|
|
206
|
+
checks.push(conventions);
|
|
176
207
|
|
|
177
208
|
const codex = codexProbe();
|
|
178
209
|
checks.push(check('codex', codex.available ? 'ok' : 'warn', codex.value || 'available'));
|
|
@@ -184,7 +215,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
184
215
|
|
|
185
216
|
return {
|
|
186
217
|
exitCode: !record || recordBroken || missingFiles.length || rules.status === 'fail'
|
|
187
|
-
|| retention.status === 'fail' || projectRuns.status === 'fail' ? 1 : 0,
|
|
218
|
+
|| retention.status === 'fail' || conventions.status === 'fail' || projectRuns.status === 'fail' ? 1 : 0,
|
|
188
219
|
checks,
|
|
189
220
|
record,
|
|
190
221
|
missingFiles,
|
|
@@ -194,6 +225,8 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
194
225
|
export function renderDoctor(result) {
|
|
195
226
|
return result.checks.map(({ key, status, value }) => {
|
|
196
227
|
const rendered = `[${status}] ${key}: ${value}`;
|
|
197
|
-
return
|
|
228
|
+
return ['retention', 'conventions'].includes(key) && status === 'warn'
|
|
229
|
+
? `${WARNING}${rendered}${RESET}`
|
|
230
|
+
: rendered;
|
|
198
231
|
}).join('\n');
|
|
199
232
|
}
|
package/cli/manifest.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
renderRequiredInputSummary,
|
|
11
11
|
renderRequiredInputs,
|
|
12
12
|
} from '../src/required-inputs.mjs';
|
|
13
|
+
import { renderNoSelfExecution } from '../src/no-self-execution.mjs';
|
|
13
14
|
|
|
14
15
|
export { HOOK_DEFINITIONS };
|
|
15
16
|
|
|
@@ -21,10 +22,11 @@ const RULES_SOURCE = `src/rules/${RULES_NAME}`;
|
|
|
21
22
|
/**
|
|
22
23
|
* Files the operator owns once they exist. Seeded on first install so the defaults are
|
|
23
24
|
* visible and editable, then never written, never compared, never removed: run-config.json
|
|
24
|
-
* is where a host states which model each mode runs on,
|
|
25
|
-
*
|
|
25
|
+
* is where a host states which model each mode runs on, conventions.md is the rule set it hands
|
|
26
|
+
* every run, and an installer that overwrites either would erase host decisions on the next
|
|
27
|
+
* update — the same way overwriting a .env would.
|
|
26
28
|
*/
|
|
27
|
-
export const SEEDED_SOURCES = Object.freeze(['src/run-config.json']);
|
|
29
|
+
export const SEEDED_SOURCES = Object.freeze(['src/run-config.json', 'src/conventions.md']);
|
|
28
30
|
const seededTargets = (host) =>
|
|
29
31
|
new Set(SEEDED_SOURCES.map((source) => posix(
|
|
30
32
|
path.relative(host.root, path.join(host.agentsDir, path.relative('src', source))),
|
|
@@ -42,7 +44,8 @@ export function replacePlaceholders(content, agentsDir) {
|
|
|
42
44
|
return content
|
|
43
45
|
.replaceAll('{{CODEX_BRIDGE_DIR}}', posix(path.resolve(agentsDir)))
|
|
44
46
|
.replaceAll('{{CODEX_REQUIRED_INPUTS_SUMMARY}}', renderRequiredInputSummary(agentType))
|
|
45
|
-
.replaceAll('{{CODEX_REQUIRED_INPUTS}}', renderRequiredInputs(agentType))
|
|
47
|
+
.replaceAll('{{CODEX_REQUIRED_INPUTS}}', renderRequiredInputs(agentType))
|
|
48
|
+
.replaceAll('{{CODEX_NO_SELF_EXECUTION}}', renderNoSelfExecution());
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
export async function packageInfo(packageRoot = PACKAGE_ROOT) {
|
package/cli/projects.mjs
CHANGED
|
@@ -15,13 +15,26 @@ function formatSize(bytes) {
|
|
|
15
15
|
return `${bytes} B`;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Both timestamp shapes in the store, printed on one clock. A run folder is named in local time
|
|
22
|
+
* (`2026-08-07_144243`); `meta.finished_at` is an ISO stamp in UTC. Reading the ISO one with a
|
|
23
|
+
* regular expression printed its UTC hours in the same column as local ones, and the 2026-08-07
|
|
24
|
+
* live check (Plan_17-1 §1) saw the run whose folder says `144243` listed as `12:46` — the
|
|
25
|
+
* operator's own zone offset, looking exactly like a different run. Slicing a timestamp is cheaper
|
|
26
|
+
* than parsing it only until the value carries a zone. The folder name stays text because it is
|
|
27
|
+
* already local; parsing it would invent a zone it never had.
|
|
28
|
+
*/
|
|
19
29
|
function formatStamp(value) {
|
|
20
30
|
if (typeof value !== 'string' || !value.trim()) return null;
|
|
21
|
-
const iso = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(value);
|
|
22
|
-
if (iso) return `${iso[1]} ${iso[2]}`;
|
|
23
31
|
const folder = /^(\d{4}-\d{2}-\d{2})_(\d{2})(\d{2})/.exec(value);
|
|
24
|
-
|
|
32
|
+
if (folder) return `${folder[1]} ${folder[2]}:${folder[3]}`;
|
|
33
|
+
const moment = Date.parse(value);
|
|
34
|
+
if (Number.isNaN(moment)) return value;
|
|
35
|
+
const at = new Date(moment);
|
|
36
|
+
return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`
|
|
37
|
+
+ ` ${pad(at.getHours())}:${pad(at.getMinutes())}`;
|
|
25
38
|
}
|
|
26
39
|
|
|
27
40
|
const PROJECT_COLUMNS = [
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lyupro/codex-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"homepage": "https://github.com/lyupro/codex-bridge#readme",
|
|
24
24
|
"type": "module",
|
|
25
25
|
"bin": {
|
|
26
|
-
"codex-bridge": "./bin/codex-bridge.mjs"
|
|
26
|
+
"codex-bridge": "./bin/codex-bridge.mjs",
|
|
27
|
+
"codexb": "./bin/codex-bridge.mjs"
|
|
27
28
|
},
|
|
28
29
|
"engines": {
|
|
29
30
|
"node": ">=24.0.0"
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Names this CLI answers to. */
|
|
2
|
+
|
|
3
|
+
// Plan_17 §5 found installer and test hook lists drifting; keep executable spellings here so a
|
|
4
|
+
// new alias cannot silently bypass the prune guard. Under src/ because the installed guard reads
|
|
5
|
+
// it too, and cli/ is not copied into the host.
|
|
6
|
+
export const CLI_NAMES = Object.freeze(['codex-bridge', 'codexb']);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Delegated-run conventions
|
|
2
|
+
|
|
3
|
+
These are the host conventions for every Codex run. Each block records a real acceptance
|
|
4
|
+
violation caught in this repository and turns it into a guard for the next run.
|
|
5
|
+
|
|
6
|
+
## Task boundaries - Plan_16 acceptance incident
|
|
7
|
+
|
|
8
|
+
- Do not refactor code the task did not ask to change.
|
|
9
|
+
- Do not delete code on your own initiative.
|
|
10
|
+
- Do not change behavior branches whose behavior the task does not mention, even inside an
|
|
11
|
+
allowed file.
|
|
12
|
+
|
|
13
|
+
The 2026-08-06 Plan_16 acceptance finding applied a new reason order to a transport branch that
|
|
14
|
+
already named its reason. Preserve existing branches unless the task explicitly changes them.
|
|
15
|
+
|
|
16
|
+
## Contracts without silent defaults - Plan_16 reasonFrom incident
|
|
17
|
+
|
|
18
|
+
- Require arguments that the contract needs instead of supplying a default.
|
|
19
|
+
- Fail loud when a required value is absent; do not silently fall back.
|
|
20
|
+
- Keep volatile identifiers such as model IDs and API versions in configuration, not runtime code.
|
|
21
|
+
|
|
22
|
+
The 2026-08-06 `reasonFrom(runDir, eventData = {})` finding replaced a required contract with a
|
|
23
|
+
silent default. Missing inputs must remain visible at the boundary that can explain them.
|
|
24
|
+
|
|
25
|
+
## Structure and size - 2026-08-06 projects reload incident
|
|
26
|
+
|
|
27
|
+
- Keep every source file at or below 400 lines.
|
|
28
|
+
- Give each module one responsibility.
|
|
29
|
+
- Do not create `utils.mjs` dumping grounds.
|
|
30
|
+
- Never split coherent pieces solely to reach the line limit.
|
|
31
|
+
|
|
32
|
+
The 2026-08-06 `projects` reload finding exposed pseudo-duplicate exports and just-in-case code.
|
|
33
|
+
Keep related code together, but remove a real responsibility or create a focused module when a
|
|
34
|
+
boundary is needed.
|
|
35
|
+
|
|
36
|
+
## Tests and comments - 2026-08-05 step 2b incident
|
|
37
|
+
|
|
38
|
+
- Add a guard test for every invariant accepted by the task.
|
|
39
|
+
- Splitting a file must delete no test case.
|
|
40
|
+
- Comments explain WHY and name the incident or decision that makes the rule necessary.
|
|
41
|
+
|
|
42
|
+
The 2026-08-05 step 2b acceptance found the abandoned-run check below the commit check. Test the
|
|
43
|
+
ordering invariant itself, and leave the incident in the comment so a later cleanup does not
|
|
44
|
+
silently undo it.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one definition of run progress: a heartbeat file in the run folder, its age, and the
|
|
3
|
+
* stale threshold. On 2026-08-06 run 2026-08-06_204007_build left a live worker waiting on
|
|
4
|
+
* Codex's grandchild; a worker timer alone would have kept that dead run looking alive, so
|
|
5
|
+
* the timer stops at exit.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
export const HEARTBEAT_FILE = 'heartbeat';
|
|
11
|
+
// Ten seconds bounds metadata writes when a 300KB stream arrives as many chunks; output still
|
|
12
|
+
// proves movement immediately in memory, while the periodic worker stamp keeps silence fresh.
|
|
13
|
+
export const HEARTBEAT_WRITE_INTERVAL_MS = 10_000;
|
|
14
|
+
export const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
15
|
+
// Five minutes leaves a wide margin over the longest model-thinking silence seen in healthy
|
|
16
|
+
// runs. The active-child timer refreshes below it; the margin is safer than unlocking a tree
|
|
17
|
+
// while a working build is silent, which is the failure this heartbeat exists to prevent.
|
|
18
|
+
export const HEARTBEAT_STALE_MS = 5 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
export function heartbeatPath(runDir) {
|
|
21
|
+
return path.join(runDir, HEARTBEAT_FILE);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A failed stamp is deliberately swallowed: the verdict must not depend on this hint file. */
|
|
25
|
+
export function createHeartbeat(runDir) {
|
|
26
|
+
let lastStamp = Number.NEGATIVE_INFINITY;
|
|
27
|
+
return {
|
|
28
|
+
stamp(now = Date.now()) {
|
|
29
|
+
if (!Number.isFinite(now)) return;
|
|
30
|
+
if (now >= lastStamp && now - lastStamp < HEARTBEAT_WRITE_INTERVAL_MS) return;
|
|
31
|
+
lastStamp = now;
|
|
32
|
+
try {
|
|
33
|
+
fs.writeFileSync(heartbeatPath(runDir), `${now}\n`);
|
|
34
|
+
} catch {
|
|
35
|
+
// A read-only or full run folder must still produce its normal verdict and artifacts.
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Missing is an explicit legacy state: old live records keep their lock until their pid dies. */
|
|
42
|
+
export function heartbeatAge(runDir, now = Date.now()) {
|
|
43
|
+
try {
|
|
44
|
+
const stat = fs.statSync(heartbeatPath(runDir));
|
|
45
|
+
if (!stat.isFile() || !Number.isFinite(now)) return Number.POSITIVE_INFINITY;
|
|
46
|
+
return Math.max(0, now - stat.mtimeMs);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return err.code === 'ENOENT' ? null : Number.POSITIVE_INFINITY;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* How long a run has been silent, in words. It lives beside the age it formats because both the
|
|
54
|
+
* worktree lock's refusal and `sweep`'s report have to name the same silence to the same operator;
|
|
55
|
+
* two copies of this drifted apart the moment they were written.
|
|
56
|
+
*/
|
|
57
|
+
export function formatSilence(age) {
|
|
58
|
+
if (age === null || !Number.isFinite(age)) return 'an unknown duration';
|
|
59
|
+
const seconds = Math.floor(Math.max(0, age) / 1000);
|
|
60
|
+
if (seconds < 60) return `${seconds} second${seconds === 1 ? '' : 's'}`;
|
|
61
|
+
const minutes = Math.floor(seconds / 60);
|
|
62
|
+
const remainingSeconds = seconds % 60;
|
|
63
|
+
if (minutes < 60) {
|
|
64
|
+
return `${minutes} minute${minutes === 1 ? '' : 's'}`
|
|
65
|
+
+ (remainingSeconds ? ` ${remainingSeconds} second${remainingSeconds === 1 ? '' : 's'}` : '');
|
|
66
|
+
}
|
|
67
|
+
const hours = Math.floor(minutes / 60);
|
|
68
|
+
const remainingMinutes = minutes % 60;
|
|
69
|
+
return `${hours} hour${hours === 1 ? '' : 's'}`
|
|
70
|
+
+ (remainingMinutes ? ` ${remainingMinutes} minute${remainingMinutes === 1 ? '' : 's'}` : '');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A running record is live when its heartbeat is fresh. Missing heartbeats remain live only for
|
|
75
|
+
* pre-Plan_20 records, an explicit fail-closed compatibility choice that preserves their lock.
|
|
76
|
+
*/
|
|
77
|
+
export function isHeartbeatFresh(runDir, now = Date.now()) {
|
|
78
|
+
const age = heartbeatAge(runDir, now);
|
|
79
|
+
return age === null || age <= HEARTBEAT_STALE_MS;
|
|
80
|
+
}
|
package/src/hooks/live-runs.mjs
CHANGED
|
@@ -2,12 +2,18 @@
|
|
|
2
2
|
* Shared, fail-open knowledge of live Codex runs.
|
|
3
3
|
*
|
|
4
4
|
* The reply guard and the worktree lock must answer the same question: a status.json is live
|
|
5
|
-
* only while it says `running
|
|
6
|
-
* directory scan here prevents the two protections from
|
|
7
|
-
*
|
|
5
|
+
* only while it says `running`, its runner pid accepts signal 0, and its progress heartbeat is
|
|
6
|
+
* fresh. Keeping those probes and the directory scan here prevents the two protections from
|
|
7
|
+
* disagreeing about a run, which would recreate the 2026-08-06 incident where a dead Codex
|
|
8
|
+
* child left its worker and lock looking live through a grandchild-held stdio pipe.
|
|
9
|
+
*
|
|
10
|
+
* The heartbeat is required here and deliberately NOT in meta/run-state.mjs: releasing a lock
|
|
11
|
+
* early costs an operator one knowing edit, while closing a record early makes a second writer
|
|
12
|
+
* of its meta.json. See the comment above pidAlive() there.
|
|
8
13
|
*/
|
|
9
14
|
import fs from 'node:fs';
|
|
10
15
|
import path from 'node:path';
|
|
16
|
+
import { isHeartbeatFresh } from '../heartbeat.mjs';
|
|
11
17
|
|
|
12
18
|
/** Signal 0 tests existence without stopping the process; EPERM still means that it exists. */
|
|
13
19
|
export const isPidAlive = (pid) => {
|
|
@@ -41,7 +47,7 @@ function isNonEmptyString(value) {
|
|
|
41
47
|
return typeof value === 'string' && Boolean(value.trim());
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
function recognizedStatus(status) {
|
|
50
|
+
function recognizedStatus(runDir, status) {
|
|
45
51
|
return Boolean(status)
|
|
46
52
|
&& typeof status === 'object'
|
|
47
53
|
&& !Array.isArray(status)
|
|
@@ -50,7 +56,8 @@ function recognizedStatus(status) {
|
|
|
50
56
|
&& isNonEmptyString(status.agent)
|
|
51
57
|
&& isNonEmptyString(status.slug)
|
|
52
58
|
&& isNonEmptyString(status.repo)
|
|
53
|
-
&& isPidAlive(status.pid)
|
|
59
|
+
&& isPidAlive(status.pid)
|
|
60
|
+
&& isHeartbeatFresh(runDir);
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
function readStatus(runDir) {
|
|
@@ -75,7 +82,7 @@ export function liveRuns(runsDir) {
|
|
|
75
82
|
if (!entry.isDirectory()) continue;
|
|
76
83
|
const dir = path.join(runsDir, entry.name);
|
|
77
84
|
const status = readStatus(dir);
|
|
78
|
-
if (recognizedStatus(status)) result.push({ dir, status });
|
|
85
|
+
if (recognizedStatus(dir, status)) result.push({ dir, status });
|
|
79
86
|
}
|
|
80
87
|
return result;
|
|
81
88
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* Anything unrecognised passes. A guard that misreads its input must not break unrelated host work.
|
|
12
12
|
*/
|
|
13
13
|
import fs from 'node:fs';
|
|
14
|
+
import { CLI_NAMES } from '../cli-names.mjs';
|
|
14
15
|
import { SHELL_TOOLS } from '../hook-definitions.mjs';
|
|
15
16
|
|
|
16
17
|
const SHELL_TOOL_NAMES = new Set(SHELL_TOOLS);
|
|
@@ -18,10 +19,17 @@ const pass = () => process.exit(0);
|
|
|
18
19
|
|
|
19
20
|
// The binary, then the verb, with any path in front of it: an agent reaches the CLI as
|
|
20
21
|
// `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.
|
|
22
|
-
// a
|
|
23
|
-
//
|
|
24
|
-
|
|
22
|
+
// only knew the bare name would be bypassed by the spelling everyone uses inside a clone. Plan_19
|
|
23
|
+
// adds a second executable spelling, so build this matcher from the shared list instead of
|
|
24
|
+
// repeating names here. Matching a lone `prune` is not an option either — `git prune` and
|
|
25
|
+
// `npm prune` are not this package's business, and a guard that breaks unrelated work is a guard
|
|
26
|
+
// that gets uninstalled.
|
|
27
|
+
const escapeRegex = (name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
28
|
+
const CLI_NAME_PATTERN = CLI_NAMES.map(escapeRegex).join('|');
|
|
29
|
+
const PRUNE_CALL = new RegExp(
|
|
30
|
+
'(^|[\\s;&|(`])(\\S*[\\\\/])?(?:' + CLI_NAME_PATTERN
|
|
31
|
+
+ ')(\\.mjs)?\\s+prune(\\s|$)',
|
|
32
|
+
);
|
|
25
33
|
|
|
26
34
|
let input;
|
|
27
35
|
try {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import fs from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
|
+
import { formatSilence, heartbeatAge } from '../heartbeat.mjs';
|
|
13
14
|
import { WRITE_TOOLS } from '../hook-definitions.mjs';
|
|
14
15
|
import { runsRoot } from '../runner/runs-root.mjs';
|
|
15
16
|
import { allLiveRuns, normalizePath } from './live-runs.mjs';
|
|
@@ -65,6 +66,12 @@ process.stdout.write(JSON.stringify({
|
|
|
65
66
|
permissionDecision: 'deny',
|
|
66
67
|
permissionDecisionReason: `Worktree lock denied ${input.tool_name}: ${targetPath} is inside repository `
|
|
67
68
|
+ `${status.repo}, held by live run folder ${dir} (agent ${status.agent}, slug ${status.slug}). `
|
|
68
|
-
|
|
69
|
+
// "Working, last progress X ago" rather than "silent for X": this hook only ever denies a
|
|
70
|
+
// run whose heartbeat is still fresh — a stale one is not live here and the edit passes. The
|
|
71
|
+
// first live probe printed "silent for 10 seconds" next to a stop command on a perfectly
|
|
72
|
+
// healthy run, which reads as "it hung, kill it" and would cost the operator 20 minutes of
|
|
73
|
+
// work they meant to keep.
|
|
74
|
+
+ `It is working; last progress ${formatSilence(heartbeatAge(dir))} ago. `
|
|
75
|
+
+ `To take the repository back before it finishes: codex-bridge stop ${path.basename(dir)}.`,
|
|
69
76
|
},
|
|
70
77
|
}));
|
package/src/meta/run-state.mjs
CHANGED
|
@@ -25,6 +25,22 @@ export const pidAlive = (pid) => {
|
|
|
25
25
|
}
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* This module deliberately judges by the pid alone, while hooks/live-runs.mjs also requires a
|
|
30
|
+
* fresh progress heartbeat. The two answer different questions, and merging them broke both:
|
|
31
|
+
*
|
|
32
|
+
* - a hook asks "may the operator edit this tree", and a stalled run must stop holding that
|
|
33
|
+
* lock — the operator is editing knowingly, and a stale run should not cost them 25 minutes;
|
|
34
|
+
* - this module asks "may a second paid run write into this tree" and "is this folder finished".
|
|
35
|
+
* Closing a run whose pid still lives makes markAbandoned the second writer of its meta.json:
|
|
36
|
+
* the live worker reaches collect() afterwards and overwrites the verdict, so the documented
|
|
37
|
+
* artifact order stops being a contract. Letting activeRun() past a live pid starts a second
|
|
38
|
+
* writing run in one worktree, which is the 2026-08-05 incident this whole lock exists for.
|
|
39
|
+
*
|
|
40
|
+
* A stalled-but-live run is closed by `stop`, which kills the process first and only then
|
|
41
|
+
* records the verdict — one writer, in the right order.
|
|
42
|
+
*/
|
|
43
|
+
|
|
28
44
|
/**
|
|
29
45
|
* Run state on disk, merged over whatever is already there. A killed runner leaves no
|
|
30
46
|
* report and no meta.json, and four abandoned folders from one order were indistinguishable
|
|
@@ -131,7 +147,8 @@ export function activeRun(runsRoot, repo, agent = 'codex-build') {
|
|
|
131
147
|
const wanted = normalizePath(repo);
|
|
132
148
|
for (const entry of entries) {
|
|
133
149
|
if (!entry.isDirectory()) continue;
|
|
134
|
-
const
|
|
150
|
+
const runDir = path.join(runsRoot, entry.name);
|
|
151
|
+
const status = readJson(path.join(runDir, 'status.json'));
|
|
135
152
|
if (!status || status.state !== 'running' || status.agent !== agent) continue;
|
|
136
153
|
if (normalizePath(status.repo) !== wanted) continue;
|
|
137
154
|
if (pidAlive(status.pid)) return entry.name;
|
|
@@ -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
|
+
}
|
package/src/runner/codex-cmd.mjs
CHANGED
|
@@ -11,10 +11,18 @@
|
|
|
11
11
|
import fs from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { spawn, spawnSync } from 'node:child_process';
|
|
14
|
+
import { createHeartbeat, HEARTBEAT_INTERVAL_MS } from '../heartbeat.mjs';
|
|
14
15
|
import { writeFailure } from '../write-meta.mjs';
|
|
15
16
|
import { MAX_LOG } from './git-state.mjs';
|
|
16
17
|
import { CLEAN_ENV, RUN_ENV } from './run-env.mjs';
|
|
17
18
|
|
|
19
|
+
// Thirty seconds, not a fraction of one: after `exit` the pipe still holds whatever Codex wrote
|
|
20
|
+
// last, and that tail carries `item.completed` — the result the verdict is read from. A quarter
|
|
21
|
+
// of a second is enough to lose it on a loaded Windows host, turning a finished run into "the run
|
|
22
|
+
// left no event". The grace exists to bound a grandchild holding stdio open forever (2026-08-06,
|
|
23
|
+
// run 2026-08-06_204007_build waited 25 minutes), and thirty seconds bounds that just as well.
|
|
24
|
+
const STDIO_DRAIN_GRACE_MS = 30_000;
|
|
25
|
+
|
|
18
26
|
/**
|
|
19
27
|
* An argument cmd.exe cannot be handed safely, or undefined. `%` is fatal because cmd
|
|
20
28
|
* expands variables even inside quotes. The launcher asks first, so the refusal costs
|
|
@@ -56,18 +64,24 @@ export function stopCodex(child, onWindows = process.platform === 'win32') {
|
|
|
56
64
|
* own bounded file because text outside the JSONL protocol must remain distinguishable from
|
|
57
65
|
* protocol events, and a line cut in half cannot be parsed back safely.
|
|
58
66
|
*/
|
|
59
|
-
export function runCodex(args, taskText, eventsPath, budgetMinutes) {
|
|
67
|
+
export function runCodex(args, taskText, eventsPath, budgetMinutes, graceMs = STDIO_DRAIN_GRACE_MS) {
|
|
60
68
|
const onWindows = process.platform === 'win32';
|
|
61
69
|
const deadlineMs =
|
|
62
70
|
typeof budgetMinutes === 'number' && Number.isFinite(budgetMinutes) && budgetMinutes > 0
|
|
63
71
|
? budgetMinutes * 60 * 1000
|
|
64
72
|
: null;
|
|
73
|
+
let exited = false;
|
|
74
|
+
const heartbeat = createHeartbeat(path.dirname(eventsPath));
|
|
75
|
+
const stampHeartbeat = () => {
|
|
76
|
+
if (!exited) heartbeat.stamp();
|
|
77
|
+
};
|
|
65
78
|
const stderrLog = fs.createWriteStream(path.join(path.dirname(eventsPath), 'stderr.log'), { flags: 'a' });
|
|
66
79
|
const eventsLog = fs.createWriteStream(eventsPath, { flags: 'a' });
|
|
67
80
|
const appendTo = (stream, label) => {
|
|
68
81
|
let written = 0;
|
|
69
82
|
let cut = false;
|
|
70
83
|
return (chunk) => {
|
|
84
|
+
stampHeartbeat();
|
|
71
85
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
|
|
72
86
|
if (written >= MAX_LOG) {
|
|
73
87
|
if (!cut) {
|
|
@@ -88,6 +102,7 @@ export function runCodex(args, taskText, eventsPath, budgetMinutes) {
|
|
|
88
102
|
let written = 0;
|
|
89
103
|
let cut = false;
|
|
90
104
|
const append = (chunk) => {
|
|
105
|
+
stampHeartbeat();
|
|
91
106
|
if (cut) return;
|
|
92
107
|
const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
|
|
93
108
|
pending = pending.length ? Buffer.concat([pending, incoming]) : incoming;
|
|
@@ -135,10 +150,15 @@ export function runCodex(args, taskText, eventsPath, budgetMinutes) {
|
|
|
135
150
|
return new Promise((resolve) => {
|
|
136
151
|
let failed = false;
|
|
137
152
|
let deadlineTimer;
|
|
153
|
+
let heartbeatTimer;
|
|
154
|
+
let stdioGraceTimer;
|
|
138
155
|
let stoppedOnDeadline = false;
|
|
139
|
-
let
|
|
156
|
+
let exitCode;
|
|
157
|
+
let settled = false;
|
|
140
158
|
let elapsedMs = 0;
|
|
141
159
|
const startedAt = Date.now();
|
|
160
|
+
stampHeartbeat();
|
|
161
|
+
heartbeatTimer = setInterval(stampHeartbeat, HEARTBEAT_INTERVAL_MS);
|
|
142
162
|
child.stdout.on('data', appendEvents.append);
|
|
143
163
|
child.stdout.on('end', appendEvents.finish);
|
|
144
164
|
child.stderr.on('data', (chunk) => {
|
|
@@ -160,12 +180,6 @@ export function runCodex(args, taskText, eventsPath, budgetMinutes) {
|
|
|
160
180
|
failed = true;
|
|
161
181
|
appendStderr(`\nrun-codex: ${err.message}\n`);
|
|
162
182
|
});
|
|
163
|
-
// 'exit' fires when the process is gone; 'close' waits for stdio, which a grandchild can
|
|
164
|
-
// hold open past the deadline. Judging by 'close' would let the timer brand a run that
|
|
165
|
-
// finished honestly at minute 24 as one the runner killed.
|
|
166
|
-
child.on('exit', () => {
|
|
167
|
-
exited = true;
|
|
168
|
-
});
|
|
169
183
|
// Codex that dies before reading the order leaves an EPIPE here; the exit code and the
|
|
170
184
|
// log already say what happened, and an unhandled stream error would replace that with
|
|
171
185
|
// a crash of the runner.
|
|
@@ -199,14 +213,39 @@ export function runCodex(args, taskText, eventsPath, budgetMinutes) {
|
|
|
199
213
|
}
|
|
200
214
|
stream.end(done);
|
|
201
215
|
});
|
|
202
|
-
|
|
216
|
+
|
|
217
|
+
// 'exit' fires when Codex is gone; 'close' waits for stdio, which the 2026-08-06 incident
|
|
218
|
+
// showed a grandchild can hold open forever. A bounded drain grace keeps normal logs intact,
|
|
219
|
+
// then closes the run with an explicit marker instead of waiting for an unbounded pipe.
|
|
220
|
+
const finish = async (code, stdioDrained) => {
|
|
221
|
+
if (settled) return;
|
|
222
|
+
settled = true;
|
|
203
223
|
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
224
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
225
|
+
if (stdioGraceTimer) clearTimeout(stdioGraceTimer);
|
|
204
226
|
if (!stoppedOnDeadline) elapsedMs = Date.now() - startedAt;
|
|
205
227
|
const exit = failed || code === null ? 1 : code;
|
|
228
|
+
appendEvents.finish();
|
|
229
|
+
if (!stdioDrained) {
|
|
230
|
+
try { child.stdout.destroy(); } catch {}
|
|
231
|
+
try { child.stderr.destroy(); } catch {}
|
|
232
|
+
}
|
|
206
233
|
await close(stderrLog);
|
|
207
234
|
await close(eventsLog);
|
|
208
|
-
resolve({ exit, stoppedOnDeadline, elapsedMs });
|
|
235
|
+
resolve({ exit, stoppedOnDeadline, elapsedMs, stdioDrained });
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
child.on('exit', (code) => {
|
|
239
|
+
exited = true;
|
|
240
|
+
exitCode = code;
|
|
241
|
+
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
242
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
243
|
+
// The grace is an argument only so tests can prove the fallback without waiting it out;
|
|
244
|
+
// a run never passes one, because a per-run grace is a second place to configure a
|
|
245
|
+
// contract that must be identical for every run.
|
|
246
|
+
stdioGraceTimer = setTimeout(() => finish(exitCode, false), graceMs);
|
|
209
247
|
});
|
|
248
|
+
child.on('close', (code) => finish(code, true));
|
|
210
249
|
});
|
|
211
250
|
}
|
|
212
251
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Renders the host and repository conventions that a run must carry in its task artifact. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { CONFIG_PATH } from '../run-config.mjs';
|
|
5
|
+
|
|
6
|
+
const HOST_FILE = path.join(path.dirname(CONFIG_PATH), 'conventions.md');
|
|
7
|
+
const REPOSITORY_FILE = '.codex-conventions.md';
|
|
8
|
+
|
|
9
|
+
function readOptional(file) {
|
|
10
|
+
try {
|
|
11
|
+
return fs.readFileSync(file, 'utf8');
|
|
12
|
+
} catch (err) {
|
|
13
|
+
if (err.code === 'ENOENT') return null;
|
|
14
|
+
throw err;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Reads both convention layers at the point of use. Plan_13 showed that a reference to a required
|
|
20
|
+
* file is not enforcement: the task must contain the bytes the run was given.
|
|
21
|
+
*
|
|
22
|
+
* A layer that is present but blank is dropped rather than rendered as an empty heading, for the
|
|
23
|
+
* same reason doctor warns about it — an empty rules file looks like a working mechanism and is
|
|
24
|
+
* not one, and in the artifact it would look like rules that were given and ignored.
|
|
25
|
+
*
|
|
26
|
+
* Duplication is impossible by construction and deliberately not guarded against: `sections`
|
|
27
|
+
* starts empty on every launch and task.md is written into a folder that is never reused, so
|
|
28
|
+
* there is nothing to append to. A guard reading the operator's task text for this heading would
|
|
29
|
+
* only find the word in a task that talks ABOUT conventions, and silently drop the section from
|
|
30
|
+
* the one run that most obviously needed it.
|
|
31
|
+
*/
|
|
32
|
+
export function renderConventions(repoRoot, hostFile = HOST_FILE) {
|
|
33
|
+
const layers = [];
|
|
34
|
+
const host = readOptional(hostFile);
|
|
35
|
+
if (host?.trim()) layers.push(`### Host conventions\n\n${host}`);
|
|
36
|
+
const repository = readOptional(path.join(repoRoot, REPOSITORY_FILE));
|
|
37
|
+
if (repository?.trim()) layers.push(`### Repository conventions\n\n${repository}`);
|
|
38
|
+
return layers.length ? `## Conventions\n\n${layers.join('\n\n')}` : '';
|
|
39
|
+
}
|
package/src/runner/launcher.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import { codexArgs, requireCodex, runMode, unsafeForCmd } from './codex-cmd.mjs'
|
|
|
37
37
|
import { runsRoot } from './runs-root.mjs';
|
|
38
38
|
import { resolveProjectRunsDir } from './project-dir.mjs';
|
|
39
39
|
import { cleanupRetention } from '../retention.mjs';
|
|
40
|
+
import { renderConventions } from './conventions.mjs';
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* The worker is this same program re-invoked as `--worker <runDir>`, so the path spawned
|
|
@@ -279,6 +280,8 @@ export async function launcher() {
|
|
|
279
280
|
].join('\n'),
|
|
280
281
|
);
|
|
281
282
|
}
|
|
283
|
+
const conventions = renderConventions(repoRoot);
|
|
284
|
+
if (conventions) sections.push(conventions);
|
|
282
285
|
sections.push(`## Instructions for Codex\n\n${INSTRUCTIONS[opts.agent](opts, scope, questions)}`);
|
|
283
286
|
fs.writeFileSync(path.join(runDir, 'task.md'), `${sections.join('\n\n')}\n`);
|
|
284
287
|
fs.writeFileSync(
|
package/src/runner/worker.mjs
CHANGED
|
@@ -36,9 +36,12 @@ export async function worker(runDir) {
|
|
|
36
36
|
// This runner-owned fact cannot be forged through events.jsonl, which Codex can write.
|
|
37
37
|
// Written even when no deadline fired: `false` is the marker that a runner was watching,
|
|
38
38
|
// and an archived run with no field at all must stay distinguishable from a run that lived.
|
|
39
|
+
// `stdio_drained: false` separately records an exit that needed the grace fallback because
|
|
40
|
+
// a grandchild kept a pipe open, the distinction the 2026-08-06 incident made necessary.
|
|
39
41
|
writeStatus(runDir, {
|
|
40
42
|
stopped_on_deadline: run.stoppedOnDeadline,
|
|
41
43
|
elapsed_ms: run.elapsedMs,
|
|
44
|
+
stdio_drained: run.stdioDrained,
|
|
42
45
|
});
|
|
43
46
|
|
|
44
47
|
if (cfg.agent === 'codex-build') {
|