@lyupro/codex-bridge 0.5.1 → 0.5.2

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 (83) hide show
  1. package/CHANGELOG.md +85 -1
  2. package/README.md +19 -2
  3. package/bin/codex-bridge.mjs +4 -0
  4. package/cli/doctor.mjs +118 -19
  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 +1 -1
  19. package/cli/stop.mjs +5 -5
  20. package/cli/unlock.mjs +7 -7
  21. package/package.json +1 -1
  22. package/scripts/check-file-size.mjs +1 -1
  23. package/src/agents/codex-build.md +265 -231
  24. package/src/agents/codex-review.md +219 -184
  25. package/src/agents/codex-scout.md +232 -200
  26. package/src/{hooks → home/hooks}/guard-tries.mjs +1 -1
  27. package/src/{hooks → home/hooks}/live-runs.mjs +43 -3
  28. package/src/home/hooks/order-gate.mjs +135 -0
  29. package/src/{hooks → home/hooks}/prune-guard.mjs +2 -2
  30. package/src/{hooks → home/hooks}/reply-guard.mjs +117 -97
  31. package/src/home/hooks/reply-parser.mjs +27 -0
  32. package/src/home/hooks/reply-verdicts.mjs +174 -0
  33. package/src/{hooks → home/hooks}/stop-guard.mjs +3 -3
  34. package/src/{hooks → home/hooks}/worktree-lock.mjs +3 -3
  35. package/src/home/lib/dispatcher-order.mjs +36 -0
  36. package/src/{hook-definitions.mjs → home/lib/hook-definitions.mjs} +1 -1
  37. package/src/home/lib/host-refusal.mjs +20 -0
  38. package/src/{json-file.mjs → home/lib/json-file.mjs} +4 -4
  39. package/src/{meta → home/lib/meta}/paths.mjs +20 -1
  40. package/src/{meta → home/lib/meta}/reply.mjs +1 -1
  41. package/src/{meta → home/lib/meta}/verdict.mjs +1 -1
  42. package/src/{no-self-execution.mjs → home/lib/no-self-execution.mjs} +7 -2
  43. package/src/{required-inputs.mjs → home/lib/required-inputs.mjs} +56 -5
  44. package/src/{retention.mjs → home/lib/retention.mjs} +1 -1
  45. package/src/{run-codex.mjs → home/lib/run-codex.mjs} +39 -11
  46. package/src/{run-config.mjs → home/lib/run-config.mjs} +2 -2
  47. package/src/{runner → home/lib/runner}/args.mjs +86 -10
  48. package/src/{runner → home/lib/runner}/attach.mjs +58 -9
  49. package/src/{runner → home/lib/runner}/launcher.mjs +33 -21
  50. package/src/home/lib/runner/order-owner.mjs +28 -0
  51. package/src/{runner → home/lib/runner}/run-env.mjs +1 -1
  52. package/src/home/lib/runner/task-file.mjs +92 -0
  53. package/src/home/lib/runner/task-input.mjs +26 -0
  54. package/src/{runner → home/lib/runner}/worker.mjs +1 -1
  55. package/src/home/lib/shell-unsafe.mjs +26 -0
  56. package/src/{write-meta.mjs → home/lib/write-meta.mjs} +12 -1
  57. package/src/hooks/order-gate.mjs +0 -73
  58. /package/src/{run-config.json → home/config.json} +0 -0
  59. /package/src/{conventions.md → home/conventions.md} +0 -0
  60. /package/src/{cli-names.mjs → home/lib/cli-names.mjs} +0 -0
  61. /package/src/{heartbeat.mjs → home/lib/heartbeat.mjs} +0 -0
  62. /package/src/{meta → home/lib/meta}/chain.mjs +0 -0
  63. /package/src/{meta → home/lib/meta}/deadline.mjs +0 -0
  64. /package/src/{meta → home/lib/meta}/environment.mjs +0 -0
  65. /package/src/{meta → home/lib/meta}/events.mjs +0 -0
  66. /package/src/{meta → home/lib/meta}/outcome.mjs +0 -0
  67. /package/src/{meta → home/lib/meta}/pre-start.mjs +0 -0
  68. /package/src/{meta → home/lib/meta}/reason.mjs +0 -0
  69. /package/src/{meta → home/lib/meta}/run-state.mjs +0 -0
  70. /package/src/{meta → home/lib/meta}/startup.mjs +0 -0
  71. /package/src/{meta → home/lib/meta}/transport.mjs +0 -0
  72. /package/src/{process-identity.mjs → home/lib/process-identity.mjs} +0 -0
  73. /package/src/{runner → home/lib/runner}/codex-cmd.mjs +0 -0
  74. /package/src/{runner → home/lib/runner}/continuation.mjs +0 -0
  75. /package/src/{runner → home/lib/runner}/conventions.mjs +0 -0
  76. /package/src/{runner → home/lib/runner}/git-state.mjs +0 -0
  77. /package/src/{runner → home/lib/runner}/project-dir.mjs +0 -0
  78. /package/src/{runner → home/lib/runner}/prompts.mjs +0 -0
  79. /package/src/{runner → home/lib/runner}/run-context.mjs +0 -0
  80. /package/src/{runner → home/lib/runner}/runs-root.mjs +0 -0
  81. /package/src/{runner → home/lib/runner}/schemas.mjs +0 -0
  82. /package/src/{runner → home/lib/runner}/scope-check.mjs +0 -0
  83. /package/src/{stop-contract.mjs → home/lib/stop-contract.mjs} +0 -0
@@ -10,9 +10,9 @@
10
10
  */
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
- import { formatSilence, heartbeatAge } from '../heartbeat.mjs';
14
- import { WRITE_TOOLS } from '../hook-definitions.mjs';
15
- import { runsRoot } from '../runner/runs-root.mjs';
13
+ import { formatSilence, heartbeatAge } from '../lib/heartbeat.mjs';
14
+ import { WRITE_TOOLS } from '../lib/hook-definitions.mjs';
15
+ import { runsRoot } from '../lib/runner/runs-root.mjs';
16
16
  import { allLiveRuns, normalizePath } from './live-runs.mjs';
17
17
 
18
18
  const WRITE_TOOL_NAMES = new Set(WRITE_TOOLS);
@@ -0,0 +1,36 @@
1
+ /** Reads and compares the order identity carried between dispatcher hook boundaries. */
2
+ import fs from 'node:fs';
3
+ import { parseJsonText } from './json-file.mjs';
4
+ import { extractValue } from './required-inputs.mjs';
5
+
6
+ /** The transcript is diagnostic evidence; malformed or unavailable evidence must fail open. */
7
+ export function transcriptOrderId(transcriptPath) {
8
+ try {
9
+ const firstEntry = fs.readFileSync(transcriptPath, 'utf8').split(/\r?\n/, 1)[0];
10
+ const transcriptEntry = parseJsonText(transcriptPath, firstEntry);
11
+ const content = transcriptEntry?.message?.content;
12
+ const promptText = typeof content === 'string'
13
+ ? content
14
+ : Array.isArray(content)
15
+ ? content.filter((part) => part?.type === 'text').map((part) => part.text).join('\n')
16
+ : '';
17
+ return String(extractValue(promptText, 'order id') || '').trim();
18
+ } catch {
19
+ return '';
20
+ }
21
+ }
22
+
23
+ /** On 2026-08-15 another order's saved reply was returned as the current run's verdict. */
24
+ export function runOrderMismatch(orderedOrderId, runStatus, runDir) {
25
+ const ordered = String(orderedOrderId ?? '').trim();
26
+ const recorded = String(runStatus?.order_id ?? '').trim();
27
+ if (!ordered || !recorded || ordered === recorded) return null;
28
+ return {
29
+ reason: `Contract violated: the dispatcher was ordered order id ${JSON.stringify(ordered)}, ` +
30
+ `but run folder ${runDir} records order_id ${JSON.stringify(recorded)}. Run the ordered ` +
31
+ 'order id and return that run\'s stdout verbatim.',
32
+ observed: `The dispatcher transcript orders ${JSON.stringify(ordered)}, but status.json in ` +
33
+ `${runDir} records order_id=${JSON.stringify(recorded)}. Run the ordered order id and ` +
34
+ `return that run's stdout verbatim.`,
35
+ };
36
+ }
@@ -5,7 +5,7 @@
5
5
  * subagent, and the first host that renames it would leave a registered hook listening for a
6
6
  * tool that never fires — silence indistinguishable from approval, which is the exact failure
7
7
  * the gate exists to end. `cli/manifest.mjs` builds the settings matcher from SUBAGENT_TOOLS
8
- * and `src/hooks/order-gate.mjs` recognises the same names, so a matcher can never drift away
8
+ * and `src/home/hooks/order-gate.mjs` recognises the same names, so a matcher can never drift away
9
9
  * from what the hook actually checks.
10
10
  */
11
11
 
@@ -0,0 +1,20 @@
1
+ /** Recognises whether a dispatcher reply contains every required part of a host refusal. */
2
+
3
+ const ORDER_ID_PATTERNS = [
4
+ /--order-id(?:=|\s+)[`"']?([a-z0-9][a-z0-9._-]*)/i,
5
+ /\border[\s_-]*id\s*(?::|=)\s*[`"']?([a-z0-9][a-z0-9._-]*)/i,
6
+ /\border[\s_-]*id\s+[`"']([^`"']+)[`"']/i,
7
+ ];
8
+
9
+ export function recognizeHostRefusal(reply) {
10
+ const text = String(reply ?? '');
11
+ const declaresFailure = /(^|\n)\s*`*FAIL\b/i.test(text);
12
+ const namesOrderId = ORDER_ID_PATTERNS.some((pattern) => pattern.test(text));
13
+ const namesInstallRemedy = /\bcodex-bridge\s+install\b/i.test(text);
14
+ const missing = [
15
+ ...(!declaresFailure ? ['FAIL declaration'] : []),
16
+ ...(!namesOrderId ? ['order id'] : []),
17
+ ...(!namesInstallRemedy ? ['codex-bridge install remedy'] : []),
18
+ ];
19
+ return { recognized: missing.length === 0, declaresFailure, namesOrderId, namesInstallRemedy, missing };
20
+ }
@@ -7,7 +7,7 @@
7
7
  import fs from 'node:fs';
8
8
  import fsp from 'node:fs/promises';
9
9
 
10
- function parseJson(file, raw) {
10
+ export function parseJsonText(file, raw) {
11
11
  const text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
12
12
  try {
13
13
  return JSON.parse(text);
@@ -17,14 +17,14 @@ function parseJson(file, raw) {
17
17
  }
18
18
 
19
19
  export async function readJsonFile(file) {
20
- return parseJson(file, await fsp.readFile(file, 'utf8'));
20
+ return parseJsonText(file, await fsp.readFile(file, 'utf8'));
21
21
  }
22
22
 
23
23
  export async function readJsonFileWithRaw(file) {
24
24
  const raw = await fsp.readFile(file, 'utf8');
25
- return { raw, value: parseJson(file, raw) };
25
+ return { raw, value: parseJsonText(file, raw) };
26
26
  }
27
27
 
28
28
  export function readJsonFileSync(file) {
29
- return parseJson(file, fs.readFileSync(file, 'utf8'));
29
+ return parseJsonText(file, fs.readFileSync(file, 'utf8'));
30
30
  }
@@ -10,7 +10,26 @@ import fs from 'node:fs';
10
10
  import { readJsonFileSync } from '../json-file.mjs';
11
11
 
12
12
  /** One line, no newlines, bounded length — a five-line reply must stay five lines. */
13
- export const line = (value, max = 200) => String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
13
+ export const line = (value, max = 200) => {
14
+ const text = String(value ?? '').replace(/\s+/g, ' ').trim();
15
+ if (text.length <= max) return text;
16
+
17
+ const ellipsis = '...';
18
+ if (max <= ellipsis.length) return text.slice(0, Math.max(0, max));
19
+
20
+ const contentLimit = max - ellipsis.length;
21
+ const boundary = text.lastIndexOf(' ', contentLimit);
22
+ // `All 298 tests pass` once became `All 29`; a reply must omit a whole word, not corrupt it.
23
+ // Retreating to a space is the whole fix, and it is enough on its own: what survives ends
24
+ // where a word ended. Dropping a trailing number here as well cost `All 298 12345678 rest`
25
+ // its 298 — a number that fitted whole — because the digit test compared the limit rather
26
+ // than the point the text was actually cut at.
27
+ if (boundary > 0) return `${text.slice(0, boundary)}${ellipsis}`;
28
+
29
+ // No space to retreat to: the limit lands inside a single long word. Only here can the cut
30
+ // land mid-number, so only here is a partial number dropped.
31
+ return `${text.slice(0, contentLimit).replace(/\d+$/, '')}${ellipsis}`;
32
+ };
14
33
 
15
34
  export const readText = (file) => {
16
35
  try {
@@ -108,7 +108,7 @@ function buildReply(ctx) {
108
108
  : paths || 'worktree untouched';
109
109
  const retention = retentionReply(ctx.runDir);
110
110
  return [
111
- `OK — ${line(r.summary, 160)}`,
111
+ `OK — ${line(r.summary, 300)}`,
112
112
  `Files: ${touchedPaths.length} changed · ${files}`,
113
113
  // Only when something outside the run wrote to the tree. Subtracting those paths from the
114
114
  // verdict without naming them would hide a real edit behind a pattern.
@@ -356,7 +356,7 @@ export function resolveStatus({ resultOk, exit, agent, result, runDir, events })
356
356
  // Hooks on is the known cause of this exact failure, so the reason says so instead
357
357
  // of leaving the operator to rediscover it from the log.
358
358
  const hooks = readJson(path.join(runDir, 'env.json'))?.hooks
359
- ? ' (hooks are enabled in run-config.json — a likely cause)'
359
+ ? ' (hooks are enabled in config.json — a likely cause)'
360
360
  : '';
361
361
  return { status: 'FAIL', reason: `${verdict.reason}${hooks}` };
362
362
  }
@@ -2,12 +2,17 @@
2
2
  * Keeps the dispatcher's first rule identical across all three prompts.
3
3
  * On 2026-08-06 codex-build wrote 19 files using the Claude quota, which is why a failed
4
4
  * delegation must end in an explicit failure instead of inviting the dispatcher to continue.
5
+ *
6
+ * The runner is named here by its command, never by its file. This block used to say "start a run
7
+ * through run-codex.mjs", and on 2026-08-15 a dispatcher refused by the host did exactly that:
8
+ * called the file by absolute path, then through PowerShell, then asked its operator to grant a
9
+ * permission rule on it. The prompt was not merely failing to forbid the detour — it was naming it.
5
10
  */
6
11
  const NO_SELF_EXECUTION = `## No self-execution
7
12
 
8
- Your only job is to start a run through \`run-codex.mjs\` and report its result; you are not the one who performs the task.
13
+ Your only job is to start a run with the \`codex-bridge run\` command and report its result; you are not the one who performs the task.
9
14
 
10
- - If \`run-codex.mjs\` is missing, does not start, exits without \`RUN=\`/\`ATTACH=\`, or the run fails to begin for any reason, immediately return \`FAIL — could not start the Codex run: <reason>\` and stop.
15
+ - If the command is missing, is refused, does not start, exits without \`RUN=\`/\`ATTACH=\`, or the run fails to begin for any reason, immediately return \`FAIL — could not start the Codex run: <reason>\` and stop. Never reach for the runner file by path, an interpreter, or another shell to get around it.
11
16
  - Performing the task yourself when the run could not be started is prohibited: do not read code, write files, or fix tests. Doing it yourself spends the Claude Max quota on exactly what the delegation existed to avoid, and does so silently.
12
17
  - Claiming "files were created" without a run that actually happened is prohibited under all circumstances. No run, no result.
13
18
 
@@ -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
  }