@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/LICENSE +21 -0
  3. package/README.md +77 -0
  4. package/bin/codex-bridge.mjs +105 -0
  5. package/cli/copy.mjs +38 -0
  6. package/cli/doctor.mjs +170 -0
  7. package/cli/hosts.mjs +50 -0
  8. package/cli/install.mjs +109 -0
  9. package/cli/manifest.mjs +210 -0
  10. package/cli/settings-merge.mjs +101 -0
  11. package/cli/stop.mjs +125 -0
  12. package/cli/uninstall.mjs +62 -0
  13. package/cli/update.mjs +156 -0
  14. package/package.json +48 -0
  15. package/scripts/check-file-size.mjs +193 -0
  16. package/scripts/run-tests.mjs +26 -0
  17. package/src/agents/codex-build.md +224 -0
  18. package/src/agents/codex-review.md +166 -0
  19. package/src/agents/codex-scout.md +188 -0
  20. package/src/commands/env.md +38 -0
  21. package/src/commands/usage.md +88 -0
  22. package/src/hooks/reply-guard.mjs +311 -0
  23. package/src/meta/chain.mjs +87 -0
  24. package/src/meta/environment.mjs +36 -0
  25. package/src/meta/paths.mjs +193 -0
  26. package/src/meta/reply.mjs +133 -0
  27. package/src/meta/run-state.mjs +197 -0
  28. package/src/meta/verdict.mjs +362 -0
  29. package/src/rules/codex-bridge.rules +173 -0
  30. package/src/run-codex.mjs +113 -0
  31. package/src/run-config.json +4 -0
  32. package/src/run-config.mjs +302 -0
  33. package/src/runner/args.mjs +111 -0
  34. package/src/runner/attach.mjs +111 -0
  35. package/src/runner/codex-cmd.mjs +265 -0
  36. package/src/runner/git-state.mjs +144 -0
  37. package/src/runner/launcher.mjs +374 -0
  38. package/src/runner/project-dir.mjs +92 -0
  39. package/src/runner/prompts.mjs +97 -0
  40. package/src/runner/run-context.mjs +52 -0
  41. package/src/runner/run-env.mjs +38 -0
  42. package/src/runner/runs-root.mjs +16 -0
  43. package/src/runner/schemas.mjs +135 -0
  44. package/src/runner/worker.mjs +62 -0
  45. package/src/write-meta.mjs +135 -0
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Reads and edits the environment switches of a delegated Codex run:
4
+ * node run-config.mjs # show current state
5
+ * node run-config.mjs hooks on # let the operator's hooks run
6
+ * node run-config.mjs plugins off # keep plugins out of the run
7
+ * node run-config.mjs reset # back to defaults (both off)
8
+ *
9
+ * Why a switch at all: hooks and plugins from ~/.codex are the operator's interactive
10
+ * setup, and a delegated run has no business inheriting it — a failing oh-my-codex `Stop`
11
+ * hook once made Codex quarantine .omx/state/session.json instead of doing the job. But
12
+ * "never" is the wrong contract too: some plugin may turn out to be exactly what a run
13
+ * needs. So the default is off, and turning it back on is one command rather than an edit
14
+ * to run-codex.mjs.
15
+ *
16
+ * The file is optional. Its absence is the default, not an error; a malformed one is an
17
+ * error, because a typo must not silently change how runs behave.
18
+ */
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
24
+ export const CONFIG_PATH = path.join(HERE, 'run-config.json');
25
+
26
+ const BUDGET_KEY = 'budgets';
27
+ const BUDGET_MODES = ['scout', 'build', 'review'];
28
+ const DEFAULT_BUDGETS = { scout: 15, build: 25, review: 20 };
29
+
30
+ /**
31
+ * Files the surrounding tooling writes on its own, in any repository a run touches.
32
+ *
33
+ * A run is graded by comparing the worktree either side of it, which silently assumes the
34
+ * run is the only writer. It is not: OMC rewrites .omc/ and Claude Code rewrites its own
35
+ * caches while Codex works, and on 2026-08-02 an honest run was failed for
36
+ * “out-of-scope changes: .omc/project-memory.json” — a file Codex never opened. These paths are
37
+ * therefore attributed to the environment rather than to the run. They are not hidden: the
38
+ * reply and meta.json list them separately, so a run that really did edit them is still visible.
39
+ */
40
+ export const DEFAULT_ENVIRONMENT_PATHS = [
41
+ '.omc/**',
42
+ '.claude/settings.local.json',
43
+ // Claude Code's own state, which only exists when the repository under a run is ~/.claude.
44
+ 'mcp-needs-auth-cache.json',
45
+ 'plugins/installed_plugins.json',
46
+ 'plugins/known_marketplaces.json',
47
+ ];
48
+
49
+ /** Off means the flag is passed to Codex as `--disable <key>`. */
50
+ export const DEFAULTS = {
51
+ hooks: false,
52
+ plugins: false,
53
+ models: {},
54
+ budgets: DEFAULT_BUDGETS,
55
+ environmentPaths: DEFAULT_ENVIRONMENT_PATHS,
56
+ answerLanguage: 'English',
57
+ };
58
+
59
+ /** Booleans the operator flips from the command line; structured keys are edited in the file. */
60
+ const SWITCH_KEYS = ['hooks', 'plugins'];
61
+ const LIST_KEYS = ['environmentPaths'];
62
+ const OBJECT_KEYS = ['models'];
63
+ const MODEL_KEYS = ['scout', 'build', 'review'];
64
+ /**
65
+ * A mode is configured as a pair, not as a model alone: reasoning depth is half of what a
66
+ * model is worth. A cheap model at its default depth is a different worker from the same
67
+ * model at "max", and pinning only the name would have silently kept every run at the
68
+ * fallback depth the dispatcher happens to pass.
69
+ */
70
+ const PROFILE_KEYS = ['model', 'effort'];
71
+ export const ALLOWED_EFFORTS = Object.freeze(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
72
+ /**
73
+ * The language a run answers in. Left to the model it followed the task, the surrounding docs or
74
+ * its own default: an English order came back in Russian, and artifacts of one project ended up in
75
+ * two languages. English is the default because the package is read by strangers.
76
+ */
77
+ const STRING_KEYS = ['answerLanguage'];
78
+ const KEYS = [...SWITCH_KEYS, ...LIST_KEYS, ...OBJECT_KEYS, ...STRING_KEYS, BUDGET_KEY];
79
+
80
+ /**
81
+ * A run gets a hard wall-clock budget, because the caller's timeout is not a run contract:
82
+ * on 2026-08-03 one order restarted six times and spent 170,293 accounted tokens while four
83
+ * killed callers left their Codex processes and token spend unrecorded.
84
+ */
85
+ function readBudgets(file, value) {
86
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
87
+ throw new Error(
88
+ `${file}: key “${BUDGET_KEY}” must be an object keyed by ${BUDGET_MODES.join(', ')}, ` +
89
+ `each holding a positive number of minutes, not ${JSON.stringify(value)}`,
90
+ );
91
+ }
92
+ const budgets = { ...DEFAULT_BUDGETS };
93
+ for (const [mode, minutes] of Object.entries(value)) {
94
+ if (!BUDGET_MODES.includes(mode)) {
95
+ throw new Error(
96
+ `${file}: key “${BUDGET_KEY}” has unknown mode “${mode}”. ` +
97
+ `Only ${BUDGET_MODES.join(', ')} are allowed`,
98
+ );
99
+ }
100
+ if (typeof minutes === 'string' && !minutes.trim()) {
101
+ throw new Error(
102
+ `${file}: key “${BUDGET_KEY}.${mode}” is empty; remove the field to use the default ` +
103
+ `(${DEFAULT_BUDGETS[mode]} minutes), or give it a positive number of minutes`,
104
+ );
105
+ }
106
+ if (typeof minutes !== 'number' || !Number.isFinite(minutes) || minutes <= 0) {
107
+ throw new Error(
108
+ `${file}: key “${BUDGET_KEY}.${mode}” must be a positive number of minutes, ` +
109
+ `not ${JSON.stringify(minutes)}`,
110
+ );
111
+ }
112
+ budgets[mode] = minutes;
113
+ }
114
+ return budgets;
115
+ }
116
+
117
+ export function readRunConfig(file = CONFIG_PATH) {
118
+ let raw;
119
+ try {
120
+ raw = fs.readFileSync(file, 'utf8');
121
+ } catch {
122
+ return { ...DEFAULTS };
123
+ }
124
+ let parsed;
125
+ try {
126
+ parsed = JSON.parse(raw);
127
+ } catch (err) {
128
+ throw new Error(
129
+ `${file} cannot be parsed as JSON (${err.message}). Fix or delete the file — without it, ` +
130
+ 'the default mode applies: hooks and plugins are disabled.',
131
+ );
132
+ }
133
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
134
+ throw new Error(`${file} must contain an object like {"hooks": false, "plugins": false}`);
135
+ }
136
+ const config = { ...DEFAULTS };
137
+ for (const [key, value] of Object.entries(parsed)) {
138
+ if (!KEYS.includes(key)) {
139
+ throw new Error(`${file}: unknown key “${key}”. Only ${KEYS.join(', ')} are allowed`);
140
+ }
141
+ if (STRING_KEYS.includes(key)) {
142
+ if (typeof value !== 'string' || !value.trim()) {
143
+ throw new Error(
144
+ `${file}: key “${key}” must be a non-empty string, not ${JSON.stringify(value)}. ` +
145
+ `Remove the key to keep the default (${DEFAULTS[key]}).`,
146
+ );
147
+ }
148
+ config[key] = value.trim();
149
+ continue;
150
+ }
151
+ if (key === BUDGET_KEY) {
152
+ config[key] = readBudgets(file, value);
153
+ continue;
154
+ }
155
+ if (LIST_KEYS.includes(key)) {
156
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
157
+ throw new Error(
158
+ `${file}: key “${key}” must be a list of string patterns, not ${JSON.stringify(value)}. ` +
159
+ 'An empty list means “the environment writes nothing”; an absent key uses the default.',
160
+ );
161
+ }
162
+ config[key] = value.map((item) => item.trim()).filter(Boolean);
163
+ continue;
164
+ }
165
+ if (OBJECT_KEYS.includes(key)) {
166
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
167
+ throw new Error(
168
+ `${file}: key “${key}” must be an object keyed by ${MODEL_KEYS.join(', ')}, ` +
169
+ `each holding {"model": "...", "effort": "..."}, not ${JSON.stringify(value)}`,
170
+ );
171
+ }
172
+ const models = {};
173
+ for (const [mode, profile] of Object.entries(value)) {
174
+ if (!MODEL_KEYS.includes(mode)) {
175
+ throw new Error(
176
+ `${file}: key “${key}” has unknown mode “${mode}”. Only ${MODEL_KEYS.join(', ')} are allowed`,
177
+ );
178
+ }
179
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
180
+ throw new Error(
181
+ `${file}: key “${key}.${mode}” must be an object like ` +
182
+ `{"model": "...", "effort": "..."}, not ${JSON.stringify(profile)}`,
183
+ );
184
+ }
185
+ const resolved = {};
186
+ for (const [field, fieldValue] of Object.entries(profile)) {
187
+ if (!PROFILE_KEYS.includes(field)) {
188
+ throw new Error(
189
+ `${file}: key “${key}.${mode}” has unknown field “${field}”. ` +
190
+ `Only ${PROFILE_KEYS.join(', ')} are allowed`,
191
+ );
192
+ }
193
+ if (typeof fieldValue !== 'string') {
194
+ throw new Error(
195
+ `${file}: key “${key}.${mode}.${field}” must be a string, not ${JSON.stringify(fieldValue)}`,
196
+ );
197
+ }
198
+ const trimmed = fieldValue.trim();
199
+ // A field written as an empty string is a mistake, not an absence: dropping it silently
200
+ // would send the run to another profile's depth past every check below.
201
+ if (!trimmed) {
202
+ throw new Error(
203
+ `${file}: key “${key}.${mode}.${field}” is empty; remove the field to fall back, ` +
204
+ 'or give it a value',
205
+ );
206
+ }
207
+ resolved[field] = trimmed;
208
+ }
209
+ if (resolved.effort && /\s/.test(resolved.effort)) {
210
+ throw new Error(
211
+ `${file}: key “${key}.${mode}.effort” must be a single word; ` +
212
+ `allowed values: ${ALLOWED_EFFORTS.join(', ')}`,
213
+ );
214
+ }
215
+ if (resolved.effort && !ALLOWED_EFFORTS.includes(resolved.effort)) {
216
+ throw new Error(
217
+ `${file}: key “${key}.${mode}.effort” must be one of: ` +
218
+ `${ALLOWED_EFFORTS.join(', ')}; got ${JSON.stringify(resolved.effort)}`,
219
+ );
220
+ }
221
+ if (Object.keys(resolved).length) models[mode] = resolved;
222
+ }
223
+ config[key] = models;
224
+ continue;
225
+ }
226
+ if (typeof value !== 'boolean') {
227
+ throw new Error(`${file}: key “${key}” must be true or false, not ${JSON.stringify(value)}`);
228
+ }
229
+ config[key] = value;
230
+ }
231
+ return config;
232
+ }
233
+
234
+ export function writeRunConfig(config, file = CONFIG_PATH) {
235
+ fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
236
+ }
237
+
238
+ /** Flags for `codex exec`: a switch that is off becomes an explicit --disable. */
239
+ export const disableFlags = (config) =>
240
+ SWITCH_KEYS.filter((key) => !config[key]).flatMap((key) => ['--disable', key]);
241
+
242
+ const state = (config) => [
243
+ ...SWITCH_KEYS.map(
244
+ (key) => `${key}: ${config[key] ? 'on — operator extensions enabled' : 'off — disabled for the run'}`,
245
+ ),
246
+ `environmentPaths: ${(config.environmentPaths || []).length} patterns — changes in them are ` +
247
+ 'treated as environment work, not run work',
248
+ `models: ${MODEL_KEYS.map((key) => {
249
+ const profile = config.models?.[key];
250
+ if (!profile?.model && !profile?.effort) return `${key}: default — chosen by Codex`;
251
+ const model = profile.model || 'default model';
252
+ return `${key}: ${model}${profile.effort ? ` at ${profile.effort} effort` : ''}`;
253
+ }).join('; ')}`,
254
+ `budgets: ${BUDGET_MODES.map((mode) => `${mode}: ${config.budgets?.[mode] ?? DEFAULT_BUDGETS[mode]} minutes`).join('; ')}`,
255
+ ];
256
+
257
+ function main(argv) {
258
+ const [key, value] = argv;
259
+
260
+ if (!key) {
261
+ const config = readRunConfig();
262
+ console.log([...state(config), `File: ${CONFIG_PATH}`].join('\n'));
263
+ return 0;
264
+ }
265
+
266
+ if (key === 'reset') {
267
+ writeRunConfig({ ...DEFAULTS });
268
+ console.log([...state(DEFAULTS), `Reset to defaults · ${CONFIG_PATH}`].join('\n'));
269
+ return 0;
270
+ }
271
+
272
+ if (!SWITCH_KEYS.includes(key)) {
273
+ const known = LIST_KEYS.includes(key)
274
+ ? `“${key}” is a pattern list, not a switch: edit it directly in ${CONFIG_PATH}`
275
+ : OBJECT_KEYS.includes(key)
276
+ ? `“${key}” is configured in the file, not as a switch: edit it directly in ${CONFIG_PATH}`
277
+ : `unknown switch “${key}”. Allowed: ${SWITCH_KEYS.join(', ')}, reset`;
278
+ console.error(`run-config: ${known}`);
279
+ return 2;
280
+ }
281
+ if (value !== 'on' && value !== 'off') {
282
+ console.error(`run-config: value must be on or off, received “${value ?? '(empty)'}”`);
283
+ return 2;
284
+ }
285
+
286
+ const config = { ...readRunConfig(), [key]: value === 'on' };
287
+ writeRunConfig(config);
288
+ console.log([...state(config), `File: ${CONFIG_PATH}`].join('\n'));
289
+ return 0;
290
+ }
291
+
292
+ const invokedDirectly =
293
+ process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
294
+
295
+ if (invokedDirectly) {
296
+ try {
297
+ process.exit(main(process.argv.slice(2)));
298
+ } catch (err) {
299
+ console.error(`run-config: ${err.message}`);
300
+ process.exit(2);
301
+ }
302
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Reads the command line of a run and refuses it before anything is spent.
3
+ *
4
+ * Every check here happens before the run folder exists and before a single token of someone
5
+ * else's quota is touched, and every refusal is an exit code rather than a message: a
6
+ * dispatcher branches on it.
7
+ */
8
+ import path from 'node:path';
9
+ import { AGENTS } from '../write-meta.mjs';
10
+ import { ALLOWED_EFFORTS } from '../run-config.mjs';
11
+
12
+ export function die(message) {
13
+ console.error(`run-codex: ${message}`);
14
+ process.exit(2);
15
+ }
16
+
17
+ // Flags that carry no value. A value is accepted only in its explicit yes/no spellings;
18
+ // anything else stops the run. The permissive reading this replaces — "not 0/false/no means
19
+ // yes" — turned the prompt's own placeholder text (`--continue "<only if the orchestrator provided
20
+ // continue>"`) into a silent opt-in, and a real run started on someone else's quota. A flag
21
+ // whose whole point is that a human decided it must never be switched on by a leftover
22
+ // template.
23
+ const BOOLEAN_FLAGS = new Set(['continue']);
24
+ const REPEATABLE_FLAGS = new Set(['question']);
25
+ const BOOLEAN_YES = /^(1|true|yes)$/i;
26
+ const BOOLEAN_NO = /^(0|false|no)$/i;
27
+
28
+ export function parseArgs(argv) {
29
+ const opts = {};
30
+ for (let i = 0; i < argv.length; i += 1) {
31
+ const key = argv[i];
32
+ if (!key.startsWith('--')) die(`unexpected argument: ${key}`);
33
+ const name = key.slice(2);
34
+ const value = argv[i + 1];
35
+ if (BOOLEAN_FLAGS.has(name)) {
36
+ if (value !== undefined && !value.startsWith('--')) {
37
+ if (!BOOLEAN_YES.test(value) && !BOOLEAN_NO.test(value)) {
38
+ die(
39
+ `--${name} takes no value, or one of 1/true/yes/0/false/no; got ${JSON.stringify(value)}. ` +
40
+ 'A placeholder left in from the prompt template is not consent.',
41
+ );
42
+ }
43
+ opts[name] = BOOLEAN_YES.test(value);
44
+ i += 1;
45
+ } else {
46
+ opts[name] = true;
47
+ }
48
+ continue;
49
+ }
50
+ // A flag name where a value belongs means the value was left out: `--question --continue`
51
+ // otherwise records `--continue` as the question and passes every later check.
52
+ if (value === undefined || value.startsWith('--')) die(`missing value for ${key}`);
53
+ if (REPEATABLE_FLAGS.has(name)) {
54
+ if (!opts.questions) opts.questions = [];
55
+ opts.questions.push(value);
56
+ i += 1;
57
+ continue;
58
+ }
59
+ opts[name] = value;
60
+ i += 1;
61
+ }
62
+ if (!opts.agent) die('--agent is required');
63
+ if (!AGENTS[opts.agent]) die(`unknown --agent ${opts.agent}`);
64
+ opts.orderId = String(opts['order-id'] ?? '').trim();
65
+ if (!opts.orderId) {
66
+ die(
67
+ '--order-id is required: the orchestrator issues the order label and the runner will not invent one. ' +
68
+ 'A repeat of the same order should come back with --continue. If this appeared right after a package update, ' +
69
+ 'the installed dispatcher prompts are stale and need npm run dev:install (local dev) or codex-bridge update.',
70
+ );
71
+ }
72
+ if (opts.agent === 'codex-scout') {
73
+ if (!opts.questions?.length) {
74
+ die(
75
+ '--question is required for codex-scout: repeat it once for every sub-question the orchestrator gave. ' +
76
+ 'The runner will not infer questions from the task text; no quota was spent.',
77
+ );
78
+ }
79
+ if (opts.questions.some((question) => !String(question).trim())) {
80
+ die('--question must not be empty for codex-scout; no quota was spent');
81
+ }
82
+ }
83
+ const allowedEfforts = ALLOWED_EFFORTS.join(', ');
84
+ // Left unset when not given, rather than defaulted here: the mode's configured profile is
85
+ // what fills the gap, and a default applied this early would always win over it.
86
+ if (opts.effort !== undefined && /\s/.test(opts.effort)) {
87
+ die(`--effort must be a single word; allowed values: ${allowedEfforts}`);
88
+ }
89
+ if (opts.effort !== undefined && !ALLOWED_EFFORTS.includes(opts.effort)) {
90
+ die(`--effort must be one of: ${allowedEfforts}; got ${JSON.stringify(opts.effort)}`);
91
+ }
92
+ opts.repo = path.resolve(opts.repo || process.cwd());
93
+ opts.slug = (opts.slug || opts.agent.replace(/^codex-/, '')).replace(/[^A-Za-z0-9._-]+/g, '-');
94
+ opts.mode = opts.mode || 'uncommitted';
95
+ // A writing run without a declared scope is how `!Plans/*.md` got edited by a run that was
96
+ // told in prose not to touch them: prose does not bind, a file list does. Required rather
97
+ // than defaulted to "everything", and checked here — before the folder exists and before a
98
+ // single token of someone else's quota is spent.
99
+ opts.scopePatterns = String(opts.scope || '')
100
+ .split(',')
101
+ .map((p) => p.trim())
102
+ .filter(Boolean);
103
+ if (opts.agent === 'codex-build' && !opts.scopePatterns.length) {
104
+ die(
105
+ '--scope is required for codex-build: comma-separated globs relative to the repo root, ' +
106
+ 'e.g. --scope "packages/event-calendar/**,apps/orchestrator/src/maestro/calendar-refresh.ts". ' +
107
+ 'Anything outside the list is off limits and fails the run.',
108
+ );
109
+ }
110
+ return opts;
111
+ }
@@ -0,0 +1,111 @@
1
+ /** Finds a live run for an order and waits for its verdict when a repeated launcher call attaches. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { exitCodeFor, writeFailure, chainRuns } from '../write-meta.mjs';
5
+
6
+ // The attach call is the only process that waits for a worker it did not spawn. Keeping that
7
+ // wait here leaves the launcher free to return before a caller's time ceiling kills it.
8
+ const POLL_MS = 500;
9
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
+
11
+ export const readJsonFile = (file) => {
12
+ try {
13
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
14
+ } catch {
15
+ return null;
16
+ }
17
+ };
18
+
19
+ /**
20
+ * Is this pid still running? EPERM means the process exists and belongs to someone else,
21
+ * which for our purposes is alive. Same reading write-meta.mjs uses — two answers to "is
22
+ * this run still going" would be one answer too many.
23
+ */
24
+ export const alive = (pid) => {
25
+ if (!Number.isInteger(pid) || pid <= 0) return false;
26
+ try {
27
+ process.kill(pid, 0);
28
+ return true;
29
+ } catch (err) {
30
+ return err.code === 'EPERM';
31
+ }
32
+ };
33
+
34
+ /** Blocks until the worker's reply exists, or until the worker is provably gone without one. */
35
+ export async function waitForReply(runDir, workerPid) {
36
+ const replyPath = path.join(runDir, 'reply.txt');
37
+ let pollsSinceDeath = 0;
38
+ for (;;) {
39
+ if (fs.existsSync(replyPath)) return fs.readFileSync(replyPath, 'utf8').replace(/\s+$/, '');
40
+ // A dead worker will never write it. Two extra polls before believing that: the pid is
41
+ // released around the same moment as the last write, and the order is not guaranteed.
42
+ if (alive(workerPid)) {
43
+ pollsSinceDeath = 0;
44
+ } else {
45
+ pollsSinceDeath += 1;
46
+ if (pollsSinceDeath > 2) return null;
47
+ }
48
+ await sleep(POLL_MS);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Attach to the run this order already has, returning null when this call must start one.
54
+ *
55
+ * What counts as "not finished yet" is the absence of reply.txt, not the absence of meta.json.
56
+ * The artifact order is meta.json first, reply.txt last, so between the two there is a moment
57
+ * where the verdict exists and the run is not closed — judged by meta.json, a repeat arriving in
58
+ * that window would be refused and sent to the --continue gate instead of being answered. The
59
+ * promise being kept here is that repeating the command is always safe, and a promise with a
60
+ * window in it is not one. markAbandoned() closes dead runs with FAIL before the gate, so this
61
+ * path only covers a live run that has not recorded a verdict yet.
62
+ *
63
+ * A repeat that arrives after the verdict is answered from disk rather than refused, for the same
64
+ * reason. `--continue` is the one case that must not attach: it is the orchestrator saying it read
65
+ * the previous reply and wants another pass.
66
+ */
67
+ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, isContinue } = {}) {
68
+ if (isContinue) return null;
69
+ const runs = chain || chainRuns(runsRoot, repo, slug, taskHash, orderId);
70
+ const sameOrder = runs
71
+ .map((run) => ({ run, status: readJsonFile(path.join(runsRoot, run, 'status.json')) }))
72
+ .filter(({ status }) => status && String(status.order_id ?? '') === String(orderId ?? ''));
73
+
74
+ const answered = sameOrder.find(({ run }) => fs.existsSync(path.join(runsRoot, run, 'reply.txt')));
75
+ if (answered) {
76
+ const runDir = path.join(runsRoot, answered.run);
77
+ console.log(`ATTACH=${runDir} started=${answered.status.started_at}`);
78
+ console.log(fs.readFileSync(path.join(runDir, 'reply.txt'), 'utf8').replace(/\s+$/, ''));
79
+ return exitCodeFor(readJsonFile(path.join(runDir, 'meta.json'))?.status);
80
+ }
81
+
82
+ const candidate = sameOrder.find(({ status }) => alive(status.pid));
83
+ if (!candidate) return null;
84
+
85
+ const runDir = path.join(runsRoot, candidate.run);
86
+ console.log(`ATTACH=${runDir} started=${candidate.status.started_at}`);
87
+ const replyText = await waitForReply(runDir, candidate.status.pid);
88
+ if (replyText !== null) {
89
+ console.log(replyText);
90
+ return exitCodeFor(readJsonFile(path.join(runDir, 'meta.json'))?.status);
91
+ }
92
+
93
+ // Preserve the existing outcome if a worker dies after this attach begins. Dead runs are
94
+ // excluded above; assigning an abandoned verdict is pass 2 of Plan_11 and remains separate.
95
+ const meta = readJsonFile(path.join(runDir, 'meta.json'));
96
+ if (meta) {
97
+ console.log(
98
+ [
99
+ `${meta.status} — ${meta.reason || 'verdict recorded, worker process response not saved'}`,
100
+ `Run: ${runDir}`,
101
+ ].join('\n'),
102
+ );
103
+ return exitCodeFor(meta.status);
104
+ }
105
+ const { reply } = writeFailure(runDir, candidate.status.agent, 'run worker process died without recording a verdict', [
106
+ `Log: ${path.join(runDir, 'raw.log')}`,
107
+ 'Any Codex changes remain in the tree — check them with git status',
108
+ ]);
109
+ console.log(reply);
110
+ return 1;
111
+ }