@lyupro/codex-bridge 0.5.1 → 0.5.3

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 (89) hide show
  1. package/CHANGELOG.md +420 -252
  2. package/README.md +21 -4
  3. package/bin/codex-bridge.mjs +4 -0
  4. package/cli/doctor.mjs +399 -293
  5. package/cli/frontmatter.mjs +137 -0
  6. package/cli/hook.mjs +2 -2
  7. package/cli/install-record.mjs +2 -2
  8. package/cli/install.mjs +14 -2
  9. package/cli/manifest.mjs +23 -22
  10. package/cli/permissions.mjs +2 -2
  11. package/cli/projects.mjs +3 -3
  12. package/cli/prune-plan.mjs +2 -2
  13. package/cli/prune.mjs +1 -1
  14. package/cli/read.mjs +2 -2
  15. package/cli/rules-owners.mjs +2 -2
  16. package/cli/run-lookup.mjs +3 -3
  17. package/cli/runs-inventory.mjs +3 -3
  18. package/cli/settings-merge.mjs +56 -11
  19. package/cli/stop.mjs +5 -5
  20. package/cli/unlock.mjs +7 -7
  21. package/package.json +2 -2
  22. package/scripts/check-file-size.mjs +1 -1
  23. package/scripts/run-tests.mjs +30 -1
  24. package/scripts/suite-count.mjs +65 -0
  25. package/src/agents/codex-build.md +265 -231
  26. package/src/agents/codex-review.md +219 -184
  27. package/src/agents/codex-scout.md +232 -200
  28. package/src/{hooks → home/hooks}/guard-tries.mjs +1 -1
  29. package/src/{hooks → home/hooks}/live-runs.mjs +43 -3
  30. package/src/home/hooks/order-gate.mjs +135 -0
  31. package/src/{hooks → home/hooks}/prune-guard.mjs +2 -2
  32. package/src/{hooks → home/hooks}/reply-guard.mjs +117 -97
  33. package/src/home/hooks/reply-parser.mjs +27 -0
  34. package/src/home/hooks/reply-verdicts.mjs +174 -0
  35. package/src/home/hooks/shell-write-intent.mjs +120 -0
  36. package/src/{hooks → home/hooks}/stop-guard.mjs +3 -3
  37. package/src/home/hooks/worktree-lock.mjs +109 -0
  38. package/src/home/hooks/worktree-witness.mjs +113 -0
  39. package/src/home/lib/dispatcher-order.mjs +36 -0
  40. package/src/{heartbeat.mjs → home/lib/heartbeat.mjs} +4 -1
  41. package/src/{hook-definitions.mjs → home/lib/hook-definitions.mjs} +11 -2
  42. package/src/home/lib/host-refusal.mjs +20 -0
  43. package/src/{json-file.mjs → home/lib/json-file.mjs} +4 -4
  44. package/src/{meta → home/lib/meta}/paths.mjs +20 -1
  45. package/src/{meta → home/lib/meta}/reply.mjs +1 -1
  46. package/src/{meta → home/lib/meta}/verdict.mjs +1 -1
  47. package/src/{no-self-execution.mjs → home/lib/no-self-execution.mjs} +7 -2
  48. package/src/{required-inputs.mjs → home/lib/required-inputs.mjs} +56 -5
  49. package/src/{retention.mjs → home/lib/retention.mjs} +1 -1
  50. package/src/{run-codex.mjs → home/lib/run-codex.mjs} +39 -11
  51. package/src/{run-config.mjs → home/lib/run-config.mjs} +2 -2
  52. package/src/{runner → home/lib/runner}/args.mjs +86 -10
  53. package/src/{runner → home/lib/runner}/attach.mjs +58 -9
  54. package/src/{runner → home/lib/runner}/codex-cmd.mjs +398 -392
  55. package/src/{runner → home/lib/runner}/launcher.mjs +33 -21
  56. package/src/home/lib/runner/order-owner.mjs +28 -0
  57. package/src/{runner → home/lib/runner}/run-env.mjs +1 -1
  58. package/src/home/lib/runner/sandbox-flags.mjs +27 -0
  59. package/src/home/lib/runner/task-file.mjs +92 -0
  60. package/src/home/lib/runner/task-input.mjs +26 -0
  61. package/src/{runner → home/lib/runner}/worker.mjs +1 -1
  62. package/src/home/lib/shell-unsafe.mjs +26 -0
  63. package/src/{write-meta.mjs → home/lib/write-meta.mjs} +12 -1
  64. package/src/hooks/order-gate.mjs +0 -73
  65. package/src/hooks/worktree-lock.mjs +0 -77
  66. /package/src/{run-config.json → home/config.json} +0 -0
  67. /package/src/{conventions.md → home/conventions.md} +0 -0
  68. /package/src/{cli-names.mjs → home/lib/cli-names.mjs} +0 -0
  69. /package/src/{meta → home/lib/meta}/chain.mjs +0 -0
  70. /package/src/{meta → home/lib/meta}/deadline.mjs +0 -0
  71. /package/src/{meta → home/lib/meta}/environment.mjs +0 -0
  72. /package/src/{meta → home/lib/meta}/events.mjs +0 -0
  73. /package/src/{meta → home/lib/meta}/outcome.mjs +0 -0
  74. /package/src/{meta → home/lib/meta}/pre-start.mjs +0 -0
  75. /package/src/{meta → home/lib/meta}/reason.mjs +0 -0
  76. /package/src/{meta → home/lib/meta}/run-state.mjs +0 -0
  77. /package/src/{meta → home/lib/meta}/startup.mjs +0 -0
  78. /package/src/{meta → home/lib/meta}/transport.mjs +0 -0
  79. /package/src/{process-identity.mjs → home/lib/process-identity.mjs} +0 -0
  80. /package/src/{runner → home/lib/runner}/continuation.mjs +0 -0
  81. /package/src/{runner → home/lib/runner}/conventions.mjs +0 -0
  82. /package/src/{runner → home/lib/runner}/git-state.mjs +0 -0
  83. /package/src/{runner → home/lib/runner}/project-dir.mjs +0 -0
  84. /package/src/{runner → home/lib/runner}/prompts.mjs +0 -0
  85. /package/src/{runner → home/lib/runner}/run-context.mjs +0 -0
  86. /package/src/{runner → home/lib/runner}/runs-root.mjs +0 -0
  87. /package/src/{runner → home/lib/runner}/schemas.mjs +0 -0
  88. /package/src/{runner → home/lib/runner}/scope-check.mjs +0 -0
  89. /package/src/{stop-contract.mjs → home/lib/stop-contract.mjs} +0 -0
@@ -1,3 +1,6 @@
1
+ import path from 'node:path';
2
+ import { firstShellUnsafeSequence } from './shell-unsafe.mjs';
3
+
1
4
  /**
2
5
  * Defines the inputs the orchestrator must give each Codex dispatcher.
3
6
  *
@@ -20,6 +23,26 @@ export const CONTINUATION_INPUT = Object.freeze({
20
23
  conditional: 'when --continue is passed',
21
24
  });
22
25
 
26
+ /**
27
+ * The task statement reaches the runner as a file because every other channel puts the invocation
28
+ * back into the multi-line form that no permission rule can cover — a heredoc on stdin, or a
29
+ * quoted argument the shell mangles. The orchestrator writes that file with its own file tool,
30
+ * which never crosses the shell at all.
31
+ *
32
+ * It is listed as a required input, not merely explained in the prompt body, because the rendered
33
+ * summary is the ONLY part of this contract the orchestrator ever reads. Left out of the list, a
34
+ * dispatcher that was given no path filled the gap itself: on 2026-08-15 codex-build wrote the file
35
+ * with `cat > … << 'EOF'` and earned exactly the permission window the file was introduced to end.
36
+ * The 2026-08-15 relative-path incident also made the repository cwd silently choose a different
37
+ * task.md, so both the producer gate and runner use this cross-platform absolute-path contract.
38
+ */
39
+ export const TASK_FILE_INPUT = Object.freeze({
40
+ label: 'task file',
41
+ source: 'the orchestrator',
42
+ explanation: 'Absolute path to a file holding the task statement verbatim, written by the orchestrator with its file tool. The dispatcher passes it as --task-file and never creates, reads or rewrites it: writing it from the shell reintroduces the permission prompt this flag exists to remove. Given no path, start the runner without the flag and return its refusal.',
43
+ example: 'C:/Users/me/AppData/Local/Temp/claude/<session>/scratchpad/task-plan-13.md',
44
+ });
45
+
23
46
  export const REQUIRED_INPUTS = Object.freeze({
24
47
  'codex-scout': freezeEntries([
25
48
  {
@@ -28,6 +51,7 @@ export const REQUIRED_INPUTS = Object.freeze({
28
51
  explanation: 'The label this order is known by. Repeating a call with the same label joins the run already in flight and costs no quota; a different piece of work needs a new label, never a reused one.',
29
52
  example: 'plan-13-scout-20260804',
30
53
  },
54
+ TASK_FILE_INPUT,
31
55
  CONTINUATION_INPUT,
32
56
  ]),
33
57
  'codex-build': freezeEntries([
@@ -41,8 +65,9 @@ export const REQUIRED_INPUTS = Object.freeze({
41
65
  label: 'scope',
42
66
  source: 'the orchestrator',
43
67
  explanation: 'Comma-separated globs relative to the repository root, listing every file the run may touch — including each caller of what changes, not only the file being edited. Anything outside the list fails the run.',
44
- example: 'src/runner/**,tests/runner/**',
68
+ example: 'src/home/lib/runner/**,tests/runner/**',
45
69
  },
70
+ TASK_FILE_INPUT,
46
71
  CONTINUATION_INPUT,
47
72
  ]),
48
73
  'codex-review': freezeEntries([
@@ -52,6 +77,7 @@ export const REQUIRED_INPUTS = Object.freeze({
52
77
  explanation: 'The label this order is known by. Repeating a call with the same label joins the run already in flight and costs no quota; a different piece of work needs a new label, never a reused one.',
53
78
  example: 'plan-13-review-20260804',
54
79
  },
80
+ TASK_FILE_INPUT,
55
81
  CONTINUATION_INPUT,
56
82
  ]),
57
83
  });
@@ -63,6 +89,11 @@ function cleanValue(value) {
63
89
  return value.trim().replace(/^([`'\"])(.*)\1$/, '$2').trim();
64
90
  }
65
91
 
92
+ export function isAbsoluteTaskFilePath(value) {
93
+ const cleaned = cleanValue(value);
94
+ return path.posix.isAbsolute(cleaned) || path.win32.isAbsolute(cleaned);
95
+ }
96
+
66
97
  export function isPlaceholder(value) {
67
98
  const cleaned = cleanValue(value);
68
99
  if (!cleaned) return true;
@@ -110,7 +141,12 @@ export function diagnoseInput(promptText, label) {
110
141
  if (candidateLine === undefined) return null;
111
142
 
112
143
  const value = extractValue(candidateLine, label);
113
- if (value !== null && !isPlaceholder(value)) return null;
144
+ if (value !== null && !isPlaceholder(value)) {
145
+ if (label === TASK_FILE_INPUT.label && !isAbsoluteTaskFilePath(value)) {
146
+ return { line: readableDiagnosisLine(candidateLine), reason: `value \`${value}\` is not an absolute path` };
147
+ }
148
+ return null;
149
+ }
114
150
 
115
151
  const line = readableDiagnosisLine(candidateLine);
116
152
  if (value !== null && isPlaceholder(value)) {
@@ -156,9 +192,24 @@ export function requiredInputsFor(agentType) {
156
192
  /** Returns required entries whose value is absent or is still an obvious template placeholder. */
157
193
  export function missingInputs(agentType, promptText) {
158
194
  const prompt = typeof promptText === 'string' ? promptText : '';
159
- return requiredInputsFor(agentType).filter(
160
- (entry) => !entry.conditional && isPlaceholder(extractValue(prompt, entry.label)),
161
- );
195
+ return requiredInputsFor(agentType).filter((entry) => {
196
+ if (entry.conditional) return false;
197
+ const value = extractValue(prompt, entry.label);
198
+ if (isPlaceholder(value)) return true;
199
+ return entry === TASK_FILE_INPUT && !isAbsoluteTaskFilePath(value);
200
+ });
201
+ }
202
+
203
+ /** Finds labelled command-line values that would disable the host's standing permission. */
204
+ export function shellUnsafeInputs(agentType, promptText) {
205
+ const prompt = typeof promptText === 'string' ? promptText : '';
206
+ return requiredInputsFor(agentType).flatMap((entry) => {
207
+ if (entry.conditional) return [];
208
+ const value = extractValue(prompt, entry.label);
209
+ if (value === null || isPlaceholder(value)) return [];
210
+ const sequence = firstShellUnsafeSequence(value);
211
+ return sequence === null ? [] : [{ entry, sequence }];
212
+ });
162
213
  }
163
214
 
164
215
  /** Renders the compact contract the orchestrator sees while choosing a dispatcher. */
@@ -1,7 +1,7 @@
1
1
  /** Owns the transport list and age decision shared by automatic and manual cleanup. */
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { liveRuns, normalizePath } from './hooks/live-runs.mjs';
4
+ import { liveRuns, normalizePath } from '../hooks/live-runs.mjs';
5
5
 
6
6
  // Plan_17 step 3 found one order's copied list and age default diverging on the same day. Keeping
7
7
  // the transport boundary here prevents automatic cleanup from eating accounting files.
@@ -2,6 +2,7 @@
2
2
  /**
3
3
  * Runs one delegated Codex job end to end:
4
4
  * node run-codex.mjs --agent <codex-scout|codex-build|codex-review> [options] < task.txt
5
+ * codex-bridge run --agent <codex-scout|codex-build|codex-review> [options] --task-file task.txt
5
6
  *
6
7
  * Options:
7
8
  * --agent codex-scout | codex-build | codex-review (required)
@@ -13,7 +14,7 @@
13
14
  * --mode uncommitted | base:<branch> | commit:<sha>, codex-review only
14
15
  * --continue acknowledge that this repo+slug already has runs (required if it does)
15
16
  *
16
- * The operator's task text arrives on stdin, verbatim.
17
+ * The operator's task text arrives verbatim through stdin or --task-file.
17
18
  *
18
19
  * Why this is a script and not a list of steps inside an agent prompt: a prompt can be
19
20
  * silently not followed, and twice it wasn't — one dispatcher reviewed the diff itself
@@ -51,6 +52,7 @@ import { writeFailure } from './write-meta.mjs';
51
52
  import { setWorkerDir, getRun, emitReply } from './runner/run-context.mjs';
52
53
  import { launcher } from './runner/launcher.mjs';
53
54
  import { worker } from './runner/worker.mjs';
55
+ import { RunnerUsageError } from './runner/args.mjs';
54
56
 
55
57
  export { parseArgs } from './runner/args.mjs';
56
58
  export { runsPrefixInside, worktreeSnapshot } from './runner/git-state.mjs';
@@ -58,8 +60,6 @@ export { runsPrefixInside, worktreeSnapshot } from './runner/git-state.mjs';
58
60
  // `--worker <runDir>` and nothing else: the worker takes its whole order from worker.json in
59
61
  // that folder, so a half-parsed command line cannot make the two halves disagree about which
60
62
  // run they are working on.
61
- const WORKER_DIR = process.argv[2] === '--worker' ? process.argv[3] : null;
62
-
63
63
  /**
64
64
  * Only a direct call is a run. Imported — which is how the tests reach parseArgs(),
65
65
  * runsPrefixInside() and worktreeSnapshot() — this file must do nothing at all: no argument
@@ -69,8 +69,6 @@ const WORKER_DIR = process.argv[2] === '--worker' ? process.argv[3] : null;
69
69
  const invokedDirectly =
70
70
  process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
71
71
 
72
- setWorkerDir(WORKER_DIR);
73
-
74
72
  // A crash of the runner itself still has to read as a FAIL, and that FAIL still has to
75
73
  // be backed by meta.json — otherwise there would be a second path by which a reply
76
74
  // exists without an artifact behind it, which is the whole failure mode this design
@@ -79,7 +77,7 @@ setWorkerDir(WORKER_DIR);
79
77
  // One handler, here, for both halves: it has to catch a crash whichever branch below is
80
78
  // running, and a second registration inside launcher.mjs or worker.mjs would be a second
81
79
  // answer to the same question.
82
- if (invokedDirectly) {
80
+ export function installRunnerCrashBoundary() {
83
81
  process.on('uncaughtException', (err) => {
84
82
  const { dir: currentRun, agent: currentAgent } = getRun();
85
83
  if (currentRun) {
@@ -104,10 +102,40 @@ if (invokedDirectly) {
104
102
  });
105
103
  }
106
104
 
107
- if (invokedDirectly) {
108
- if (WORKER_DIR) {
109
- worker(WORKER_DIR);
110
- } else {
111
- launcher();
105
+ /**
106
+ * The package command and the historical file entry both cross this boundary. Keeping argv
107
+ * explicit prevents the public command from reconstructing or normalizing runner flags, while
108
+ * the worker sentinel remains private to the direct file invocation spawned by launcher.mjs.
109
+ */
110
+ export async function runCodex(argv) {
111
+ const workerDir = argv[0] === '--worker' ? argv[1] : null;
112
+ setWorkerDir(workerDir);
113
+ try {
114
+ if (workerDir) return worker(workerDir);
115
+ return await launcher(argv);
116
+ } catch (err) {
117
+ if (err instanceof RunnerUsageError) return err.exitCode;
118
+ throw err;
112
119
  }
113
120
  }
121
+
122
+ /**
123
+ * Both public entry forms must terminate through the runner's FAIL reply. The 2026-08-15
124
+ * package-command incident let launcher rejections escape to the CLI usage handler, making a
125
+ * runner crash indistinguishable from a refusal with exit 2.
126
+ */
127
+ export async function runCodexCommand(argv) {
128
+ installRunnerCrashBoundary();
129
+ try {
130
+ return await runCodex(argv);
131
+ } catch (err) {
132
+ queueMicrotask(() => { throw err; });
133
+ return new Promise(() => {});
134
+ }
135
+ }
136
+
137
+ if (invokedDirectly) {
138
+ runCodexCommand(process.argv.slice(2)).then((exitCode) => {
139
+ if (exitCode !== undefined) process.exitCode = exitCode;
140
+ });
141
+ }
@@ -22,7 +22,7 @@ import { fileURLToPath } from 'node:url';
22
22
  import { readJsonFileSync } from './json-file.mjs';
23
23
 
24
24
  const HERE = path.dirname(fileURLToPath(import.meta.url));
25
- export const CONFIG_PATH = path.join(HERE, 'run-config.json');
25
+ export const CONFIG_PATH = path.join(HERE, '..', 'config.json');
26
26
 
27
27
  const BUDGET_KEY = 'budgets';
28
28
  const BUDGET_MODES = ['scout', 'build', 'review'];
@@ -117,7 +117,7 @@ export function retentionNotice(config) {
117
117
  const days = config.retention.days;
118
118
  return {
119
119
  enabled: true,
120
- text: `Automatic cleanup is ON — run transport older than ${days} days is removed to reclaim disk space. Accounting and reports are never touched. Change or disable: retention in run-config.json.`,
120
+ text: `Automatic cleanup is ON — run transport older than ${days} days is removed to reclaim disk space. Accounting and reports are never touched. Change or disable: retention in config.json.`,
121
121
  };
122
122
  }
123
123
  return {
@@ -5,14 +5,65 @@
5
5
  * else's quota is touched, and every refusal is an exit code rather than a message: a
6
6
  * dispatcher branches on it.
7
7
  */
8
+ import fs from 'node:fs';
8
9
  import path from 'node:path';
9
10
  import { AGENTS } from '../write-meta.mjs';
10
11
  import { ALLOWED_EFFORTS } from '../run-config.mjs';
11
- import { requiredInputsFor } from '../required-inputs.mjs';
12
+ import { isAbsoluteTaskFilePath, requiredInputsFor } from '../required-inputs.mjs';
13
+ import { firstShellUnsafeSequence } from '../shell-unsafe.mjs';
14
+ import { parseTaskDocument } from './task-file.mjs';
15
+
16
+ export class RunnerUsageError extends Error {
17
+ constructor(message) {
18
+ super(message);
19
+ this.exitCode = 2;
20
+ }
21
+ }
12
22
 
13
23
  export function die(message) {
14
24
  console.error(`run-codex: ${message}`);
15
- process.exit(2);
25
+ throw new RunnerUsageError(message);
26
+ }
27
+
28
+ export function readTaskDocument(opts) {
29
+ const stdinText = process.stdin.isTTY ? '' : fs.readFileSync(0, 'utf8');
30
+ if (opts['task-file'] !== undefined) {
31
+ if (stdinText.trim()) {
32
+ die('task text was supplied through both stdin and --task-file; choose exactly one channel');
33
+ }
34
+ const taskFile = opts['task-file'];
35
+ // The 2026-08-15 incident resolved a relative order against the repository cwd and ran an
36
+ // unrelated task.md. Refuse the supplied value instead of silently selecting another file.
37
+ if (!isAbsoluteTaskFilePath(taskFile)) {
38
+ die(`--task-file must be an absolute path; got ${JSON.stringify(taskFile)}`);
39
+ }
40
+ let fileText;
41
+ try {
42
+ fileText = fs.readFileSync(taskFile, 'utf8');
43
+ } catch (err) {
44
+ die(`task file from --task-file could not be read: ${err.message}`);
45
+ }
46
+ if (!fileText.trim()) die(`task file from --task-file is empty: ${taskFile}`);
47
+ let document;
48
+ try {
49
+ document = parseTaskDocument(fileText);
50
+ } catch (err) {
51
+ die(`${taskFile}: ${err.message}`);
52
+ }
53
+ if (document.questions.length && opts.questions?.length) {
54
+ die('questions were supplied through both --question and the task file; choose exactly one channel');
55
+ }
56
+ if (document.verify !== undefined && opts.verify !== undefined) {
57
+ die('verification command was supplied through both --verify and the task file; choose exactly one channel');
58
+ }
59
+ return document;
60
+ }
61
+ if (!stdinText.trim()) die('task text on stdin is empty');
62
+ try {
63
+ return parseTaskDocument(stdinText);
64
+ } catch (err) {
65
+ die(err.message);
66
+ }
16
67
  }
17
68
 
18
69
  function requiredInput(agentType, label) {
@@ -25,8 +76,21 @@ function requiredInput(agentType, label) {
25
76
  // continue>"`) into a silent opt-in, and a real run started on someone else's quota. A flag
26
77
  // whose whole point is that a human decided it must never be switched on by a leftover
27
78
  // template.
28
- const BOOLEAN_FLAGS = new Set(['continue']);
79
+ const BOOLEAN_FLAGS = new Set(['continue', 'no-wait']);
29
80
  const REPEATABLE_FLAGS = new Set(['question']);
81
+ // Plan_42 keeps free text in the task file because these command-line values otherwise disable
82
+ // the host's standing permission before the runner can spend quota.
83
+ const SHELL_CHECKED_FLAGS = Object.freeze([
84
+ 'order-id',
85
+ 'slug',
86
+ 'scope',
87
+ 'scope-new',
88
+ 'repo',
89
+ 'agent',
90
+ 'effort',
91
+ 'mode',
92
+ 'task-file',
93
+ ]);
30
94
  const BOOLEAN_YES = /^(1|true|yes)$/i;
31
95
  const BOOLEAN_NO = /^(0|false|no)$/i;
32
96
 
@@ -64,9 +128,23 @@ export function parseArgs(argv) {
64
128
  opts[name] = value;
65
129
  i += 1;
66
130
  }
131
+ for (const name of SHELL_CHECKED_FLAGS) {
132
+ if (opts[name] === undefined) continue;
133
+ const sequence = firstShellUnsafeSequence(opts[name]);
134
+ if (sequence !== null) {
135
+ die(
136
+ `--${name} contains forbidden shell sequence ${JSON.stringify(sequence)}; ` +
137
+ 'put free text in the task file and pass only a short command-line value.',
138
+ );
139
+ }
140
+ }
67
141
  if (!opts.agent) die('--agent is required');
68
142
  if (!AGENTS[opts.agent]) die(`unknown --agent ${opts.agent}`);
69
143
  opts.orderId = String(opts['order-id'] ?? '').trim();
144
+ opts.noWait = Boolean(opts['no-wait']);
145
+ if (opts.noWait && opts.continue) {
146
+ die('--no-wait cannot be combined with --continue: checking an existing run must never authorize a new one.');
147
+ }
70
148
  if (!opts.orderId) {
71
149
  const orderInput = requiredInput(opts.agent, 'order id');
72
150
  die(
@@ -77,13 +155,11 @@ export function parseArgs(argv) {
77
155
  );
78
156
  }
79
157
  if (opts.agent === 'codex-scout') {
80
- if (!opts.questions?.length) {
81
- die(
82
- '--question is required for codex-scout: repeat it once for every sub-question the orchestrator gave. ' +
83
- 'The runner will not infer questions from the task text; no quota was spent.',
84
- );
85
- }
86
- if (opts.questions.some((question) => !String(question).trim())) {
158
+ // The requirement itself moved to the launcher, which is the first point that has seen both
159
+ // sources: questions now arrive either as flags or as a section of the task file, and this
160
+ // check runs before either file or stdin has been read. Only the shape of a supplied flag is
161
+ // still judged here.
162
+ if (opts.questions?.some((question) => !String(question).trim())) {
87
163
  die('--question must not be empty for codex-scout; no quota was spent');
88
164
  }
89
165
  }
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { readJsonFileSync } from '../json-file.mjs';
5
5
  import { IDENTITY_DEAD, IDENTITY_FOREIGN, processAlive, processIdentity } from '../process-identity.mjs';
6
6
  import { exitCodeFor, writeFailure, chainRuns } from '../write-meta.mjs';
7
+ import { conflictingOrderOwner, runsForOrder } from './order-owner.mjs';
7
8
 
8
9
  // The attach call is the only process that waits for a worker it did not spawn. Keeping that
9
10
  // wait here leaves the launcher free to return before a caller's time ceiling kills it.
@@ -11,12 +12,38 @@ const POLL_MS = 500;
11
12
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
12
13
 
13
14
  // The 2026-08-10_220535_plan25-2-install-table-two-roots incident showed that ATTACH alone
14
- // can be mistaken for this pass; state the previous run and the fact that no work was started.
15
- function announceAttach(runDir, startedAt) {
16
- console.log(`ATTACH=${runDir} started=${startedAt}`);
15
+ // can be mistaken for this pass. Distinguish a saved answer from a live run whose verdict is
16
+ // still pending so provenance is truthful while preserving the guarantee that no work restarted.
17
+ function announceSavedReply(runDir, orderId, startedAt) {
18
+ console.log(`ATTACH=${runDir} order-id=${orderId} started=${startedAt}`);
17
19
  console.log(`This is the answer of the previous run started at ${startedAt}; no new work was started.`);
18
20
  }
19
21
 
22
+ function announceLiveAttach(runDir, orderId, startedAt) {
23
+ console.log(`ATTACH=${runDir} order-id=${orderId} started=${startedAt}`);
24
+ console.log(
25
+ `Attached to the run already in progress since ${startedAt}; no new work was started, and this invocation is waiting for its verdict.`,
26
+ );
27
+ }
28
+
29
+ function elapsedSince(startedAt) {
30
+ const elapsedSeconds = Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000));
31
+ if (!Number.isFinite(elapsedSeconds)) return 'unknown';
32
+ const hours = Math.floor(elapsedSeconds / 3600);
33
+ const minutes = Math.floor((elapsedSeconds % 3600) / 60);
34
+ const seconds = elapsedSeconds % 60;
35
+ if (hours) return `${hours}h ${minutes}m`;
36
+ if (minutes) return `${minutes}m ${seconds}s`;
37
+ return `${seconds}s`;
38
+ }
39
+
40
+ function announcePending(runDir, orderId, startedAt) {
41
+ console.log(`ATTACH=${runDir} order-id=${orderId} started=${startedAt}`);
42
+ console.log(
43
+ `Run is still in progress; elapsed ${elapsedSince(startedAt)}. Repeat the same command without --no-wait later to wait for its verdict.`,
44
+ );
45
+ }
46
+
20
47
  export const readJsonFile = (file) => {
21
48
  try {
22
49
  return readJsonFileSync(file);
@@ -77,12 +104,24 @@ export async function waitForReply(runDir, workerPid, status = {}) {
77
104
  * reason. `--continue` is the one case that must not attach: it is the orchestrator saying it read
78
105
  * the previous reply and wants another pass.
79
106
  */
80
- export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, isContinue } = {}) {
107
+ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, isContinue, noWait } = {}) {
81
108
  if (isContinue) return null;
82
109
  const runs = chain || chainRuns(runsRoot, repo, slug, taskHash, orderId);
83
- const sameOrder = runs
110
+ const runRecords = runs
84
111
  .map((run) => ({ run, status: readJsonFile(path.join(runsRoot, run, 'status.json')) }))
85
- .filter(({ status }) => status && String(status.order_id ?? '') === String(orderId ?? ''));
112
+ .filter(({ status }) => status);
113
+ const sameOrder = runsForOrder(runRecords, orderId);
114
+
115
+ const owner = conflictingOrderOwner(runRecords, orderId, taskHash);
116
+ if (owner) {
117
+ const ownerDir = path.join(runsRoot, owner.run);
118
+ console.log(
119
+ `Order id collision: ${JSON.stringify(String(orderId ?? ''))} already belongs to run folder ${ownerDir} ` +
120
+ `(slug ${owner.status.slug}, started_at ${owner.status.started_at}) with a different task. ` +
121
+ 'Pass a new --order-id, or pass --continue if this really is another pass of the same order.',
122
+ );
123
+ return 2;
124
+ }
86
125
 
87
126
  // The run an order is currently about is its newest one, and the chain arrives oldest first.
88
127
  // Reading it from the end is the whole point: `--continue` adds a second run under the same
@@ -93,7 +132,7 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
93
132
  const entry = sameOrder[i];
94
133
  const dir = path.join(runsRoot, entry.run);
95
134
  if (fs.existsSync(path.join(dir, 'reply.txt'))) {
96
- announceAttach(dir, entry.status.started_at);
135
+ announceSavedReply(dir, orderId, entry.status.started_at);
97
136
  console.log(fs.readFileSync(path.join(dir, 'reply.txt'), 'utf8').replace(/\s+$/, ''));
98
137
  return exitCodeFor(readJsonFile(path.join(dir, 'meta.json'))?.status);
99
138
  }
@@ -102,10 +141,20 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
102
141
  break;
103
142
  }
104
143
  }
105
- if (!candidate) return null;
144
+ if (!candidate) {
145
+ if (!noWait) return null;
146
+ console.log(`No run exists for order id ${JSON.stringify(String(orderId ?? ''))}; --no-wait never starts a new run.`);
147
+ return 4;
148
+ }
106
149
 
107
150
  const runDir = path.join(runsRoot, candidate.run);
108
- announceAttach(runDir, candidate.status.started_at);
151
+ // On 2026-08-13 a killed waiting call became an invented FAIL over a run already finished OK.
152
+ // A distinct call outcome lets the dispatcher inspect disk without mistaking pending for failure.
153
+ if (noWait) {
154
+ announcePending(runDir, orderId, candidate.status.started_at);
155
+ return 4;
156
+ }
157
+ announceLiveAttach(runDir, orderId, candidate.status.started_at);
109
158
  const replyText = await waitForReply(runDir, candidate.status.pid, candidate.status);
110
159
  if (replyText !== null) {
111
160
  console.log(replyText);