@lyupro/codex-bridge 0.1.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 +39 -0
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/bin/codex-bridge.mjs +105 -0
- package/cli/copy.mjs +38 -0
- package/cli/doctor.mjs +170 -0
- package/cli/hosts.mjs +50 -0
- package/cli/install.mjs +109 -0
- package/cli/manifest.mjs +210 -0
- package/cli/settings-merge.mjs +101 -0
- package/cli/stop.mjs +125 -0
- package/cli/uninstall.mjs +62 -0
- package/cli/update.mjs +156 -0
- package/package.json +48 -0
- package/scripts/check-file-size.mjs +193 -0
- package/scripts/run-tests.mjs +26 -0
- package/src/agents/codex-build.md +224 -0
- package/src/agents/codex-review.md +166 -0
- package/src/agents/codex-scout.md +188 -0
- package/src/commands/env.md +38 -0
- package/src/commands/usage.md +88 -0
- package/src/hooks/reply-guard.mjs +311 -0
- package/src/meta/chain.mjs +87 -0
- package/src/meta/environment.mjs +36 -0
- package/src/meta/paths.mjs +193 -0
- package/src/meta/reply.mjs +133 -0
- package/src/meta/run-state.mjs +197 -0
- package/src/meta/verdict.mjs +362 -0
- package/src/rules/codex-bridge.rules +173 -0
- package/src/run-codex.mjs +113 -0
- package/src/run-config.json +4 -0
- package/src/run-config.mjs +302 -0
- package/src/runner/args.mjs +111 -0
- package/src/runner/attach.mjs +111 -0
- package/src/runner/codex-cmd.mjs +265 -0
- package/src/runner/git-state.mjs +144 -0
- package/src/runner/launcher.mjs +374 -0
- package/src/runner/project-dir.mjs +92 -0
- package/src/runner/prompts.mjs +97 -0
- package/src/runner/run-context.mjs +52 -0
- package/src/runner/run-env.mjs +38 -0
- package/src/runner/runs-root.mjs +16 -0
- package/src/runner/schemas.mjs +135 -0
- package/src/runner/worker.mjs +62 -0
- package/src/write-meta.mjs +135 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The half the caller can kill: every preparation and every refusal that costs no quota,
|
|
3
|
+
* then the spawn of the worker and the immediate return; a repeated call waits for the reply.
|
|
4
|
+
*
|
|
5
|
+
* The order it leaves for the worker is worker.json in the run folder — after the split
|
|
6
|
+
* that file is the only connection between the two halves. It is written here with
|
|
7
|
+
* `agent`, `slug`, `order_id`, `repo`, `is_git_repo`, `launcher_pid`, `budget_minutes` and `args`;
|
|
8
|
+
* worker.mjs reads `repo`, `agent`, `args`, `is_git_repo` and `budget_minutes`. The three extra fields are deliberate: `slug`,
|
|
9
|
+
* `order_id` and `launcher_pid` are what a run folder read back months later needs in order to
|
|
10
|
+
* explain itself — which order it belonged to included. Nothing may be dropped from this shape
|
|
11
|
+
* without changing worker.mjs and saying so out loud.
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import {
|
|
18
|
+
writeFailure,
|
|
19
|
+
writeStatus,
|
|
20
|
+
markAbandoned,
|
|
21
|
+
abandonedBranchDrift,
|
|
22
|
+
activeRun,
|
|
23
|
+
chainRuns,
|
|
24
|
+
taskFingerprint,
|
|
25
|
+
readJson,
|
|
26
|
+
} from '../write-meta.mjs';
|
|
27
|
+
import { attach } from './attach.mjs';
|
|
28
|
+
import { setRun } from './run-context.mjs';
|
|
29
|
+
import { loadRunEnv, RUN_ENV } from './run-env.mjs';
|
|
30
|
+
import { parseArgs, die } from './args.mjs';
|
|
31
|
+
import { SCHEMAS } from './schemas.mjs';
|
|
32
|
+
import { INSTRUCTIONS } from './prompts.mjs';
|
|
33
|
+
import { git, headSha, branchName, worktreeSnapshot, reviewScope } from './git-state.mjs';
|
|
34
|
+
import { codexArgs, requireCodex, runMode, unsafeForCmd } from './codex-cmd.mjs';
|
|
35
|
+
import { runsRoot } from './runs-root.mjs';
|
|
36
|
+
import { resolveProjectRunsDir } from './project-dir.mjs';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The worker is this same program re-invoked as `--worker <runDir>`, so the path spawned
|
|
40
|
+
* below is the CLI entry one level up — not this module, which has no command line of its own.
|
|
41
|
+
*/
|
|
42
|
+
const RUNNER_ENTRY = fileURLToPath(new URL('../run-codex.mjs', import.meta.url));
|
|
43
|
+
|
|
44
|
+
export const questionsFromFlags = (questionTexts = []) =>
|
|
45
|
+
questionTexts.map((text, i) => ({ id: `Q${i + 1}`, text }));
|
|
46
|
+
|
|
47
|
+
const stamp = () => {
|
|
48
|
+
const d = new Date();
|
|
49
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
50
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Never reuse a run folder. Two runs with the same slug inside the same second would
|
|
55
|
+
* otherwise overwrite each other's task.md, log and result — and a status could then be
|
|
56
|
+
* computed from another run's artifacts.
|
|
57
|
+
*/
|
|
58
|
+
function makeRunDir(base) {
|
|
59
|
+
fs.mkdirSync(path.dirname(base), { recursive: true });
|
|
60
|
+
for (let n = 2, dir = base; ; n += 1) {
|
|
61
|
+
try {
|
|
62
|
+
fs.mkdirSync(dir);
|
|
63
|
+
return dir;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err.code !== 'EEXIST') throw err;
|
|
66
|
+
dir = `${base}-${n}`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Everything a run needs before a single token of someone else's quota is spent, and every
|
|
73
|
+
* refusal that costs nothing: bad arguments, an open chain without --continue, a busy
|
|
74
|
+
* worktree, a missing Codex CLI. All of it happens here, in the process the caller is free
|
|
75
|
+
* to kill — so a killed caller can only ever interrupt a run that was already paid for.
|
|
76
|
+
*/
|
|
77
|
+
export async function launcher() {
|
|
78
|
+
loadRunEnv();
|
|
79
|
+
|
|
80
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
81
|
+
const taskText = fs.readFileSync(0, 'utf8').trim();
|
|
82
|
+
if (!taskText) die('task text on stdin is empty');
|
|
83
|
+
|
|
84
|
+
const topLevel = git(opts.repo, ['rev-parse', '--show-toplevel']);
|
|
85
|
+
const isGitRepo = topLevel.status === 0;
|
|
86
|
+
const repoRoot = isGitRepo ? topLevel.stdout.trim() : opts.repo;
|
|
87
|
+
const projectRunsRoot = resolveProjectRunsDir(runsRoot(), repoRoot).dir;
|
|
88
|
+
|
|
89
|
+
// Folders left behind by a runner that was killed mid-run get an explicit state before
|
|
90
|
+
// anything else happens. One order produced four of them, and without this pass an
|
|
91
|
+
// abandoned folder is indistinguishable from a run still working: neither has meta.json.
|
|
92
|
+
// The snapshot is passed in because meta/ makes no git calls of its own: it is the only way an
|
|
93
|
+
// abandoned run can be closed with the files it left behind rather than with a bare label.
|
|
94
|
+
markAbandoned(projectRunsRoot, isGitRepo ? worktreeSnapshot(repoRoot) : undefined);
|
|
95
|
+
|
|
96
|
+
if (isGitRepo) {
|
|
97
|
+
const drift = abandonedBranchDrift(projectRunsRoot, repoRoot, branchName(repoRoot));
|
|
98
|
+
if (drift) {
|
|
99
|
+
die(
|
|
100
|
+
`repository is detached after abandoned run ${drift.run}, which recorded branch ${drift.branch}. ` +
|
|
101
|
+
`Return with: git checkout ${drift.branch}. No run folder was created and no quota was spent.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A second pass at the same task is a real need — after a timeout or a LIMIT the work has
|
|
107
|
+
// to be finished — so it is allowed and made visible rather than forbidden. What is
|
|
108
|
+
// forbidden is repeating by accident: the orchestrator saw the previous reply, the runner
|
|
109
|
+
// did not, so only the orchestrator can decide there is something left to finish.
|
|
110
|
+
//
|
|
111
|
+
// The task is identified by its text as well as by its slug, because the slug is chosen by
|
|
112
|
+
// whoever repeats the run: on 2026-08-02 a dispatcher whose launcher was killed restarted
|
|
113
|
+
// the identical order as `<slug>-v2` and spent 46k on it. Refused before the folder exists,
|
|
114
|
+
// like --scope.
|
|
115
|
+
const taskHash = taskFingerprint(taskText);
|
|
116
|
+
const chain = chainRuns(projectRunsRoot, repoRoot, opts.slug, taskHash, opts.orderId);
|
|
117
|
+
|
|
118
|
+
// One order produced six Codex runs on 2026-08-03 because the caller's time ceiling made it
|
|
119
|
+
// restart the synchronous launcher. A live same-order run is now the repeat target: attach
|
|
120
|
+
// before creating a folder, probing Codex or spending another token.
|
|
121
|
+
const attachedExitCode = await attach({
|
|
122
|
+
runsRoot: projectRunsRoot,
|
|
123
|
+
repo: repoRoot,
|
|
124
|
+
slug: opts.slug,
|
|
125
|
+
taskHash,
|
|
126
|
+
orderId: opts.orderId,
|
|
127
|
+
chain,
|
|
128
|
+
isContinue: opts.continue,
|
|
129
|
+
});
|
|
130
|
+
if (attachedExitCode !== null) process.exit(attachedExitCode);
|
|
131
|
+
|
|
132
|
+
// markAbandoned() ran before this gate, so a dead runner already has meta.json with FAIL.
|
|
133
|
+
// A run without a verdict can therefore only still be in flight; ordinary repeats go through
|
|
134
|
+
// attach(), while --continue must refuse to overlap that live work.
|
|
135
|
+
const continuationError = continuationRefusal(projectRunsRoot, chain, opts.continue, opts.orderId);
|
|
136
|
+
if (continuationError) die(continuationError);
|
|
137
|
+
|
|
138
|
+
if (chain.length && !opts.continue) {
|
|
139
|
+
const last = chain[chain.length - 1];
|
|
140
|
+
const lastSlug = String(readJson(path.join(projectRunsRoot, last, 'status.json'))?.slug || '');
|
|
141
|
+
const renamed = lastSlug && lastSlug.toLowerCase() !== String(opts.slug).toLowerCase();
|
|
142
|
+
die(
|
|
143
|
+
`--continue is required: ${
|
|
144
|
+
renamed
|
|
145
|
+
? `this task already ran in this repository under the name “${lastSlug}”`
|
|
146
|
+
: `runs for task “${opts.slug}” already exist in this repository`
|
|
147
|
+
} (${chain.length}), latest: ${path.join(projectRunsRoot, last)}. ` +
|
|
148
|
+
'A repeat run is allowed, but the orchestrator decides, not the runner: it read the ' +
|
|
149
|
+
'previous response and knows whether work remains. Add --continue if you are finishing ' +
|
|
150
|
+
'the same task; changing --slug with the same task text does not stop it being a repeat. ' +
|
|
151
|
+
'The run folder was not created; quota was not spent.',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Asked before this run registers itself, so it cannot find itself. Two writing runs share
|
|
156
|
+
// one worktree with no isolation: the second one's before/after snapshot picks up the
|
|
157
|
+
// first one's edits, and an honest run gets failed for work it never did.
|
|
158
|
+
const busy = opts.agent === 'codex-build' ? activeRun(projectRunsRoot, repoRoot) : null;
|
|
159
|
+
|
|
160
|
+
const runDir = makeRunDir(path.join(projectRunsRoot, `${stamp()}_${opts.slug}`));
|
|
161
|
+
setRun(runDir, opts.agent);
|
|
162
|
+
|
|
163
|
+
// Written before Codex is even probed. From here on a killed runner leaves a folder that
|
|
164
|
+
// says what it was and whose pid to check, instead of a folder that says nothing — the run
|
|
165
|
+
// itself takes 20-25 minutes, far longer than the caller's default timeout, so being killed
|
|
166
|
+
// mid-run is the normal way for this to end, not the exotic one.
|
|
167
|
+
//
|
|
168
|
+
// `pid` is the launcher's only until the worker exists, and the worker's from then on:
|
|
169
|
+
// activeRun(), markAbandoned() and the reply guard all read `pid` as "the process whose
|
|
170
|
+
// death means this run is abandoned", and after the spawn that process is the worker.
|
|
171
|
+
writeStatus(runDir, {
|
|
172
|
+
state: 'running',
|
|
173
|
+
pid: process.pid,
|
|
174
|
+
launcher_pid: process.pid,
|
|
175
|
+
agent: opts.agent,
|
|
176
|
+
slug: opts.slug,
|
|
177
|
+
order_id: opts.orderId,
|
|
178
|
+
// Fingerprint of the order, so a later run of the same task finds this one whatever it
|
|
179
|
+
// calls itself. Written here, before Codex is probed, like everything the chain reads.
|
|
180
|
+
task_hash: taskHash,
|
|
181
|
+
repo: repoRoot,
|
|
182
|
+
started_at: new Date().toISOString(),
|
|
183
|
+
// Which run of this task started the chain — the base every later pass is measured
|
|
184
|
+
// against. Absent means this is the first pass.
|
|
185
|
+
...(chain.length ? { continues: chain[0] } : {}),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Printed before anything can go wrong: even a dispatcher that dies mid-run leaves the
|
|
189
|
+
// orchestrator with a folder to look into.
|
|
190
|
+
console.log(`RUN=${runDir}`);
|
|
191
|
+
|
|
192
|
+
// Before requireCodex, so a blocked run costs nothing at all.
|
|
193
|
+
if (busy) {
|
|
194
|
+
const { reply } = writeFailure(
|
|
195
|
+
runDir,
|
|
196
|
+
opts.agent,
|
|
197
|
+
`run ${busy} is already active for this repository; two writing runs in one tree are prohibited`,
|
|
198
|
+
[`Active run: ${path.join(projectRunsRoot, busy)}`, 'Codex was not started; quota was not spent'],
|
|
199
|
+
);
|
|
200
|
+
console.log(reply);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
requireCodex(runDir, opts.agent);
|
|
205
|
+
|
|
206
|
+
const scope = opts.agent === 'codex-review' ? reviewScope(repoRoot, opts.mode) : null;
|
|
207
|
+
if (scope) {
|
|
208
|
+
fs.writeFileSync(
|
|
209
|
+
path.join(runDir, 'scope.txt'),
|
|
210
|
+
`${scope.label}\n${scope.diffCommand}\n${scope.files.join('\n')}\n`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The sub-questions this run will be graded against come only from the orchestrator's
|
|
215
|
+
// repeatable flags. They are written before the task is assembled so the prompt and verdict
|
|
216
|
+
// read the same ordered list, including a valid one-question order.
|
|
217
|
+
const questions = opts.agent === 'codex-scout' ? questionsFromFlags(opts.questions) : [];
|
|
218
|
+
if (opts.agent === 'codex-scout') {
|
|
219
|
+
fs.writeFileSync(path.join(runDir, 'questions.json'), `${JSON.stringify(questions, null, 2)}\n`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (opts.agent === 'codex-build') {
|
|
223
|
+
fs.writeFileSync(path.join(runDir, 'scope.txt'), `${opts.scopePatterns.join('\n')}\n`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Which environment this run actually got. Without it a replay months later cannot tell
|
|
227
|
+
// whether the operator's hooks were in play, and that is the first question a run that
|
|
228
|
+
// wandered off task raises.
|
|
229
|
+
fs.writeFileSync(path.join(runDir, 'env.json'), `${JSON.stringify(RUN_ENV, null, 2)}\n`);
|
|
230
|
+
|
|
231
|
+
// The extra sections carry the two things prose could not enforce: what has to be answered,
|
|
232
|
+
// and what may be edited. Both also go to disk as questions.json / scope.txt, so the verdict
|
|
233
|
+
// is computed from the same list Codex was handed, not from a second reading of the wording.
|
|
234
|
+
const sections = [`## Operator task (verbatim)\n\n${taskText}`];
|
|
235
|
+
if (questions.length) {
|
|
236
|
+
sections.push(
|
|
237
|
+
[
|
|
238
|
+
'## Sub-questions, each requires a separate response',
|
|
239
|
+
'',
|
|
240
|
+
questions.map((q) => `${q.id}: ${q.text}`).join('\n'),
|
|
241
|
+
'',
|
|
242
|
+
'A missed sub-question fails the run; a response containing only coordinates counts as missed.',
|
|
243
|
+
].join('\n'),
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
if (opts.agent === 'codex-build') {
|
|
247
|
+
sections.push(
|
|
248
|
+
[
|
|
249
|
+
'## Scope (hard boundary)',
|
|
250
|
+
'',
|
|
251
|
+
'Only these may be changed:',
|
|
252
|
+
opts.scopePatterns.map((p) => `- ${p}`).join('\n'),
|
|
253
|
+
'',
|
|
254
|
+
'Do not touch any file outside this list — even if it blocks the work or looks broken.',
|
|
255
|
+
'Put the obstacle in leftovers instead of changing the file. The touched worktree is',
|
|
256
|
+
'checked against this list after the run.',
|
|
257
|
+
].join('\n'),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
sections.push(`## Instructions for Codex\n\n${INSTRUCTIONS[opts.agent](opts, scope, questions)}`);
|
|
261
|
+
fs.writeFileSync(path.join(runDir, 'task.md'), `${sections.join('\n\n')}\n`);
|
|
262
|
+
fs.writeFileSync(
|
|
263
|
+
path.join(runDir, 'schema.json'),
|
|
264
|
+
`${JSON.stringify(SCHEMAS[opts.agent], null, 2)}\n`,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
if (opts.agent === 'codex-build') {
|
|
268
|
+
fs.writeFileSync(path.join(runDir, 'head-before.txt'), `${isGitRepo ? headSha(repoRoot) : ''}\n`);
|
|
269
|
+
fs.writeFileSync(path.join(runDir, 'branch-before.txt'), `${isGitRepo ? branchName(repoRoot) : ''}\n`);
|
|
270
|
+
fs.writeFileSync(path.join(runDir, 'git-before.txt'), git(repoRoot, ['status', '--porcelain']).stdout || '');
|
|
271
|
+
fs.writeFileSync(path.join(runDir, 'state-before.txt'), `${worktreeSnapshot(repoRoot)}\n`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// The worker's entire order, on disk. Not passed as arguments: the launcher may be gone
|
|
275
|
+
// when the worker needs to know what it is doing, and a folder that explains itself is
|
|
276
|
+
// also the only way to read a run back months later.
|
|
277
|
+
const codexArgv = codexArgs({ ...opts, repo: repoRoot }, runDir, isGitRepo);
|
|
278
|
+
const unsafe = process.platform === 'win32' ? unsafeForCmd(codexArgv) : undefined;
|
|
279
|
+
if (unsafe) {
|
|
280
|
+
const { reply } = writeFailure(
|
|
281
|
+
runDir,
|
|
282
|
+
opts.agent,
|
|
283
|
+
`argument cannot be passed through cmd.exe (contains % or "): ${unsafe}`,
|
|
284
|
+
['Codex was not started; quota was not spent'],
|
|
285
|
+
);
|
|
286
|
+
console.log(reply);
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
fs.writeFileSync(
|
|
290
|
+
path.join(runDir, 'worker.json'),
|
|
291
|
+
`${JSON.stringify(
|
|
292
|
+
{
|
|
293
|
+
agent: opts.agent,
|
|
294
|
+
slug: opts.slug,
|
|
295
|
+
order_id: opts.orderId,
|
|
296
|
+
repo: repoRoot,
|
|
297
|
+
is_git_repo: isGitRepo,
|
|
298
|
+
launcher_pid: process.pid,
|
|
299
|
+
// The mode's wall-clock budget, resolved here and never re-read by the worker: a run
|
|
300
|
+
// that consulted run-config.json twice could end up honouring two different limits.
|
|
301
|
+
budget_minutes: RUN_ENV?.budgets?.[runMode(opts.agent)],
|
|
302
|
+
args: codexArgv,
|
|
303
|
+
},
|
|
304
|
+
null,
|
|
305
|
+
2,
|
|
306
|
+
)}\n`,
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
// detached + unref + no stdio: the worker leaves the caller's process group, so a Ctrl+C
|
|
310
|
+
// or a timeout kill aimed at the dispatcher's shell does not reach it, and it holds no
|
|
311
|
+
// pipe that could fill up and stall once nobody is reading.
|
|
312
|
+
const worker = spawn(process.execPath, [RUNNER_ENTRY, '--worker', runDir], {
|
|
313
|
+
detached: true,
|
|
314
|
+
stdio: 'ignore',
|
|
315
|
+
windowsHide: true,
|
|
316
|
+
cwd: repoRoot,
|
|
317
|
+
});
|
|
318
|
+
worker.on('error', (err) => {
|
|
319
|
+
const { reply } = writeFailure(runDir, opts.agent, `run worker process failed to start: ${err.message}`, [
|
|
320
|
+
'Codex was not started; quota was not spent',
|
|
321
|
+
]);
|
|
322
|
+
console.log(reply);
|
|
323
|
+
process.exit(1);
|
|
324
|
+
});
|
|
325
|
+
worker.unref();
|
|
326
|
+
writeStatus(runDir, { pid: worker.pid, runner_pid: worker.pid });
|
|
327
|
+
|
|
328
|
+
console.log(
|
|
329
|
+
`STARTED agent=${opts.agent} slug=${opts.slug} order-id=${opts.orderId} worker-pid=${worker.pid}`,
|
|
330
|
+
);
|
|
331
|
+
console.log(
|
|
332
|
+
'To get the verdict, repeat the identical command with the same --order-id; it will attach to this run and will not start a second run.',
|
|
333
|
+
);
|
|
334
|
+
process.exit(0);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Applies the one-continuation limit and the in-flight safety gate before a folder is created.
|
|
339
|
+
*
|
|
340
|
+
* The limit is counted over the runs carrying THIS order id, not over the whole chain. The chain
|
|
341
|
+
* also ties runs together by slug and by the fingerprint of the task text, which is what catches a
|
|
342
|
+
* repeat that renamed itself — but counting continuations that way makes the escape hatch
|
|
343
|
+
* unreachable: the operator's rule is "more passes than one need a new order id", and a new order
|
|
344
|
+
* id lands in the same chain through the task hash. A task would then be refused with --continue
|
|
345
|
+
* for having spent its continuation and refused without it for already having runs — permanently
|
|
346
|
+
* unrunnable, which is a worse failure than the retry storm this limit exists to stop.
|
|
347
|
+
*/
|
|
348
|
+
export function continuationRefusal(runsRootPath, chain, isContinue, orderId) {
|
|
349
|
+
if (!isContinue || chain.length === 0) return null;
|
|
350
|
+
const wanted = String(orderId ?? '').trim();
|
|
351
|
+
const ofThisOrder = chain.filter(
|
|
352
|
+
(run) => String(readJson(path.join(runsRootPath, run, 'status.json'))?.order_id ?? '').trim() === wanted,
|
|
353
|
+
);
|
|
354
|
+
if (ofThisOrder.length === 0) return null;
|
|
355
|
+
if (ofThisOrder.length > 1) {
|
|
356
|
+
const spent = ofThisOrder.map((run) => path.join(runsRootPath, run)).join(', ');
|
|
357
|
+
return (
|
|
358
|
+
`--continue is refused: order “${wanted}” already spent its allowed continuation on ${spent}. ` +
|
|
359
|
+
'A further pass needs a new order id from the orchestrator. The run folder was not ' +
|
|
360
|
+
'created; quota was not spent.'
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
const previous = path.join(runsRootPath, ofThisOrder[0]);
|
|
364
|
+
const status = readJson(path.join(previous, 'status.json'));
|
|
365
|
+
const meta = readJson(path.join(previous, 'meta.json'));
|
|
366
|
+
if (!meta?.status || status?.state === 'running') {
|
|
367
|
+
return (
|
|
368
|
+
`--continue is refused: previous run ${previous} has no finished verdict and may still ` +
|
|
369
|
+
'be editing the worktree. Repeat without --continue to attach to it. The run folder was ' +
|
|
370
|
+
'not created; quota was not spent.'
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Assigns a stable runs directory to one repository. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { projectFolder } from '../write-meta.mjs';
|
|
5
|
+
|
|
6
|
+
export const PROJECT_MARKER = '.project.json';
|
|
7
|
+
|
|
8
|
+
const MAX_CANDIDATES = 100;
|
|
9
|
+
|
|
10
|
+
export function normalizeRepoPath(repoPath) {
|
|
11
|
+
let normalized = path.resolve(repoPath).replaceAll('\\', '/').replace(/\/$/, '');
|
|
12
|
+
if (process.platform === 'win32') normalized = normalized.toLowerCase();
|
|
13
|
+
return normalized;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readMarker(dir) {
|
|
17
|
+
const markerPath = path.join(dir, PROJECT_MARKER);
|
|
18
|
+
if (!fs.existsSync(markerPath)) return null;
|
|
19
|
+
let marker;
|
|
20
|
+
try {
|
|
21
|
+
marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
|
|
22
|
+
} catch (err) {
|
|
23
|
+
throw new Error(`Cannot read project marker ${markerPath}: ${err.message}`);
|
|
24
|
+
}
|
|
25
|
+
if (!marker || typeof marker.repo !== 'string' || !marker.repo) {
|
|
26
|
+
throw new Error(`Project marker ${markerPath} must contain a non-empty repo path`);
|
|
27
|
+
}
|
|
28
|
+
return marker;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeMarker(dir, repo) {
|
|
32
|
+
const marker = { repo, created: new Date().toISOString() };
|
|
33
|
+
fs.writeFileSync(path.join(dir, PROJECT_MARKER), `${JSON.stringify(marker, null, 2)}\n`, { flag: 'wx' });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function legacyOwner(dir) {
|
|
37
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
38
|
+
.filter((entry) => entry.isDirectory())
|
|
39
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
40
|
+
const statuses = entries.map((entry) => {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(fs.readFileSync(path.join(dir, entry.name, 'status.json'), 'utf8'));
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
return statuses.find((status) => typeof status?.repo === 'string' && status.repo)?.repo ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function candidateName(base, index) {
|
|
51
|
+
return index === 1 ? base : `${base}-${index}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function resolveProjectRunsDir(runsRootPath, repoRoot, { create = true } = {}) {
|
|
55
|
+
const base = projectFolder(repoRoot);
|
|
56
|
+
const wantedRepo = normalizeRepoPath(repoRoot);
|
|
57
|
+
if (create) fs.mkdirSync(runsRootPath, { recursive: true });
|
|
58
|
+
|
|
59
|
+
for (let index = 1; index <= MAX_CANDIDATES; index += 1) {
|
|
60
|
+
const name = candidateName(base, index);
|
|
61
|
+
const dir = path.join(runsRootPath, name);
|
|
62
|
+
if (!fs.existsSync(dir)) {
|
|
63
|
+
if (create) {
|
|
64
|
+
fs.mkdirSync(dir);
|
|
65
|
+
writeMarker(dir, repoRoot);
|
|
66
|
+
}
|
|
67
|
+
return { dir, name, reason: 'created' };
|
|
68
|
+
}
|
|
69
|
+
if (!fs.statSync(dir).isDirectory()) {
|
|
70
|
+
throw new Error(`Project runs candidate is not a directory: ${dir}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const marker = readMarker(dir);
|
|
74
|
+
if (marker) {
|
|
75
|
+
if (normalizeRepoPath(marker.repo) === wantedRepo) return { dir, name, reason: 'marker' };
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const owner = legacyOwner(dir);
|
|
80
|
+
if (!owner || normalizeRepoPath(owner) === wantedRepo) {
|
|
81
|
+
if (create) writeMarker(dir, repoRoot);
|
|
82
|
+
return { dir, name, reason: 'adopted' };
|
|
83
|
+
}
|
|
84
|
+
// Recording the discovered owner prevents later runs from reinterpreting mixed history.
|
|
85
|
+
if (create) writeMarker(dir, owner);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
throw new Error(
|
|
89
|
+
`No project runs directory is available for ${repoRoot} after ${MAX_CANDIDATES} candidates. ` +
|
|
90
|
+
`Remove or relocate an obsolete ${base} directory, or choose a different CODEX_RUNS_ROOT.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each agent is told to do, one instruction text per agent.
|
|
3
|
+
*
|
|
4
|
+
* These are data, not comments: the wording IS the contract a run is graded against, and
|
|
5
|
+
* every rule in it was added by an incident. Keyed by agent name, next to the matching
|
|
6
|
+
* schema in schemas.mjs — one lookup picks both halves of an agent's strategy.
|
|
7
|
+
*/
|
|
8
|
+
import { DEFAULTS } from '../run-config.mjs';
|
|
9
|
+
import { RUN_ENV } from './run-env.mjs';
|
|
10
|
+
const BODIES = {
|
|
11
|
+
'codex-scout': (opts, scope, questions = []) => `Response rules:
|
|
12
|
+
- You are working in read-only mode: do not try to write or edit anything.
|
|
13
|
+
- Answer from your own reading: do not spawn or delegate to other agents.
|
|
14
|
+
- The response is an analysis, not a map. Explain in prose: how it works, which fields,
|
|
15
|
+
signatures, and contracts are involved, how the areas differ, and what follows from this.
|
|
16
|
+
A list of coordinates instead of an analysis does NOT count and will be rejected as empty.
|
|
17
|
+
- Coordinates in \`path:line\` form belong in evidence, not in place of the response: each
|
|
18
|
+
response needs at least one reference. If removing paths and coordinates leaves no several
|
|
19
|
+
sentences of coherent text, there is no response.
|
|
20
|
+
${
|
|
21
|
+
questions.length
|
|
22
|
+
? `- The sub-questions are listed above. Each Q1..Q${questions.length} needs its own object in
|
|
23
|
+
answers: question_id, a separate analysis in answer, and its own references in evidence.
|
|
24
|
+
One common response to all sub-questions is not acceptable — a missed sub-question fails the run.
|
|
25
|
+
- The top-level answer is a 2-4 sentence summary of the whole task, on top of the analyses.`
|
|
26
|
+
: `- Put one object in answers with question_id "Q1": analysis in answer, references in evidence.
|
|
27
|
+
- The top-level answer is the same response summarized in 2-4 sentences.`
|
|
28
|
+
}
|
|
29
|
+
- Base the response on what you read; support every fact with a \`path:line\` reference in where.
|
|
30
|
+
- If facts are missing, list them in unknowns; do not make them up.
|
|
31
|
+
- report_markdown is a full markdown report with sections: “Response” (2-4 sentences),
|
|
32
|
+
“How it works” (required: prose analysis ${questions.length ? 'for each sub-question' : 'of the mechanism'},
|
|
33
|
+
grounded in the code), “Findings” (fact / location / confidence table), “Missing information”.`,
|
|
34
|
+
'codex-build': (opts) => `Definition of done: exactly as stated in the task above.
|
|
35
|
+
|
|
36
|
+
Working rules:
|
|
37
|
+
- Change only what the task requires. Do not refactor adjacent code or reformat files.
|
|
38
|
+
- Prefer the smallest correct change and the patterns this project already uses over new
|
|
39
|
+
abstractions of your own.
|
|
40
|
+
- Do this work yourself: do not spawn or delegate to other agents. Their edits would land in
|
|
41
|
+
this worktree as yours, outside the scope you are graded against. If the task needs authority
|
|
42
|
+
you do not have, or a file is being changed by someone else, put that in leftovers.
|
|
43
|
+
- Work strictly within the scope from the section above. Do not touch a file outside the list
|
|
44
|
+
under any circumstances — even if it blocks you, looks broken, or is a “one-line fix”. Put the
|
|
45
|
+
obstacle in leftovers. Touched files are checked against the worktree scope after the run,
|
|
46
|
+
and an out-of-scope change fails the entire run.
|
|
47
|
+
- Do not commit or prepare a commit: \`git commit\`, \`git add\`, \`git stash\`, new branches and
|
|
48
|
+
tags, and any changes inside \`.git/\` are prohibited. The orchestrator performs acceptance
|
|
49
|
+
and commits; it must see your changes uncommitted. If a commit seems unavoidable, put that
|
|
50
|
+
in leftovers instead of running a command. A changed HEAD fails the run.
|
|
51
|
+
- Do not leave stubs, TODOs, \`test.skip\`/\`.only\`, or unimplemented branches. If something
|
|
52
|
+
cannot be done, do not pretend it is done; list it in leftovers.
|
|
53
|
+
- After making changes, run verification: ${opts.verify || 'determine the command from the project'}.
|
|
54
|
+
Put the command actually run in verify_command and whether it passed in verify_passed.
|
|
55
|
+
- summary and changes describe only changes in this repository for this task. Do not include
|
|
56
|
+
anything unrelated in them.
|
|
57
|
+
- Each change is a separate changes[] entry, exactly one file per entry. The file field contains
|
|
58
|
+
one repository-root-relative path exactly as git prints it. Globs (\`*\`, \`?\`), brace expansion
|
|
59
|
+
(\`dir/{a,b}.ts\`), and lists separated by \`;\` or \`,\` are prohibited: a collapsed entry will
|
|
60
|
+
match no file when the report is checked against the worktree and will fail the run even if
|
|
61
|
+
the work was done honestly.
|
|
62
|
+
- Do not fix the environment. Service directories (\`.omx/\`, \`.claude/\`, \`.codex/\`, \`.git/\`,
|
|
63
|
+
\`node_modules/\`) and other sessions are not your task, even if they prevent you from working
|
|
64
|
+
or finishing. Put the obstacle in leftovers instead of changing them.
|
|
65
|
+
- report_markdown is a full markdown report with sections: “What was done”, “Changes”
|
|
66
|
+
(file / what changed / why table), “Verification” (command and its actual output),
|
|
67
|
+
“Remaining work / risks”.`,
|
|
68
|
+
'codex-review': (opts, scope) => `Review the changes as an independent second opinion. The author is another AI model.
|
|
69
|
+
|
|
70
|
+
Review scope: ${scope.label}
|
|
71
|
+
Get the exact diff with: ${scope.diffCommand}
|
|
72
|
+
Affected files:
|
|
73
|
+
${scope.files.length ? scope.files.map((f) => `- ${f}`).join('\n') : '- (empty: git showed no changes in this scope)'}
|
|
74
|
+
|
|
75
|
+
Priorities, in order: correctness bugs, security holes, data loss, broken error handling,
|
|
76
|
+
race conditions, silently swallowed failures, unimplemented branches left as stubs.
|
|
77
|
+
|
|
78
|
+
Rules:
|
|
79
|
+
- Review it yourself: do not spawn or delegate to other agents.
|
|
80
|
+
- Report only defects you can point at with file and line numbers.
|
|
81
|
+
- Do not report style preferences, formatting, or naming unless they change meaning.
|
|
82
|
+
- Do not invent problems to look useful. An empty findings list is a valid answer.
|
|
83
|
+
- Set confidence honestly: "low" when you are guessing about intent or missing context.`,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The language of the answer, appended to every agent rather than repeated in three places.
|
|
88
|
+
* Left unsaid, the model picked it from the task, the surrounding docs or its own default: an
|
|
89
|
+
* English order once came back in Russian, and one project's artifacts ended up in two languages.
|
|
90
|
+
*/
|
|
91
|
+
const languageRule = () =>
|
|
92
|
+
`- Write the answer, report_markdown and every text field of result.json in ` +
|
|
93
|
+
`${RUN_ENV?.answerLanguage || DEFAULTS.answerLanguage}, whatever language the task is written in.`;
|
|
94
|
+
|
|
95
|
+
export const INSTRUCTIONS = Object.fromEntries(
|
|
96
|
+
Object.entries(BODIES).map(([agent, body]) => [agent, (...args) => `${body(...args)}\n${languageRule()}`]),
|
|
97
|
+
);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Holds the state of the run in progress and is the only way to answer whoever asked for it.
|
|
3
|
+
*
|
|
4
|
+
* Both halves of the program write here — launcher() and worker() each announce the run they
|
|
5
|
+
* took over — and the crash handler in run-codex.mjs reads it to name the run that just died.
|
|
6
|
+
* The state lives behind functions rather than exported variables because an ES module cannot
|
|
7
|
+
* reassign a binding it imported: as a plain `let` shared across files the crash handler would
|
|
8
|
+
* always have read null and lost the one report a crash is supposed to leave behind.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
let workerDir = null;
|
|
14
|
+
let currentRun = null;
|
|
15
|
+
let currentAgent = null;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Marks this process as the worker half and points it at its run folder. Called once from the
|
|
19
|
+
* CLI entry, before anything else runs: from here on emitReply() answers into reply.txt rather
|
|
20
|
+
* than to a console the worker does not have.
|
|
21
|
+
*/
|
|
22
|
+
export function setWorkerDir(dir) {
|
|
23
|
+
workerDir = dir;
|
|
24
|
+
currentRun = dir;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The run this process is working on, for anything that has to report it. */
|
|
28
|
+
export function setRun(dir, agent) {
|
|
29
|
+
currentRun = dir;
|
|
30
|
+
currentAgent = agent;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const getRun = () => ({ dir: currentRun, agent: currentAgent });
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where the reply goes. The launcher prints it, because its stdout IS the dispatcher's
|
|
37
|
+
* contract. The worker has no stdout at all — it is spawned with stdio 'ignore', since a
|
|
38
|
+
* pipe nobody drains fills up and stalls the run it was supposed to outlive — so it leaves
|
|
39
|
+
* the same text in reply.txt and the launcher prints that. Identical bytes either way.
|
|
40
|
+
*/
|
|
41
|
+
export function emitReply(text) {
|
|
42
|
+
if (!workerDir) {
|
|
43
|
+
console.log(text);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
fs.writeFileSync(path.join(workerDir, 'reply.txt'), `${text}\n`);
|
|
48
|
+
} catch {
|
|
49
|
+
// Disk gone: status.json and meta.json already carry the verdict, and the launcher
|
|
50
|
+
// falls back to them when reply.txt never appears.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decides which environment a delegated run gets, and freezes that answer for the whole run.
|
|
3
|
+
*
|
|
4
|
+
* The operator's hooks and plugins belong to interactive work; by default a delegated run
|
|
5
|
+
* does not inherit them. A failing oh-my-codex `Stop` hook would not let Codex end its
|
|
6
|
+
* session, so it quarantined `.omx/state/session.json` to get out — and reported that as
|
|
7
|
+
* the job. It happened twice in production and reproduced on a sterile fixture, 44k tokens
|
|
8
|
+
* of someone else's quota spent on work nobody asked for; with both disabled the same
|
|
9
|
+
* fixture ran the task and cost 23k.
|
|
10
|
+
*
|
|
11
|
+
* Not hardcoded, because "never" is the wrong contract: `node run-config.mjs hooks on`
|
|
12
|
+
* turns either of them back on. A broken run-config.json stops the run instead of quietly
|
|
13
|
+
* falling back — a typo must not decide what environment a run gets.
|
|
14
|
+
*/
|
|
15
|
+
import { readRunConfig, disableFlags } from '../run-config.mjs';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The switches as read (they go to env.json) and the flags they turn into (they go to
|
|
19
|
+
* `codex exec`). Assigned only by loadRunEnv() below, in this module: importers read them
|
|
20
|
+
* through the live binding, which is the only way they could see the loaded value at all.
|
|
21
|
+
*/
|
|
22
|
+
export let RUN_ENV;
|
|
23
|
+
export let CLEAN_ENV;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Launcher-only: the worker gets the finished argument list in worker.json and never reads
|
|
27
|
+
* the config again, so a config edited mid-run cannot change the environment of a run that
|
|
28
|
+
* is already going.
|
|
29
|
+
*/
|
|
30
|
+
export function loadRunEnv() {
|
|
31
|
+
try {
|
|
32
|
+
RUN_ENV = readRunConfig();
|
|
33
|
+
CLEAN_ENV = disableFlags(RUN_ENV);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.log(`FAIL — ${err.message}\nRun was not started; quota was not spent`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Says where run artifacts live.
|
|
3
|
+
*
|
|
4
|
+
* The path used to be built from os.homedir() in two places, which taught the package that it
|
|
5
|
+
* lives inside ~/.claude — outside that folder it had nowhere to write. The value is read on
|
|
6
|
+
* every call, not frozen into a constant, because tests override the variable per case.
|
|
7
|
+
*/
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
export function runsRoot() {
|
|
12
|
+
const configured = process.env.CODEX_RUNS_ROOT;
|
|
13
|
+
// Trimmed, not taken literally: a path with a leading or trailing space creates a folder
|
|
14
|
+
// Windows tooling cannot address, and the value usually arrives from a shell or an .env line.
|
|
15
|
+
return configured?.trim() ? configured.trim() : path.join(os.homedir(), '.claude', 'codex-runs');
|
|
16
|
+
}
|