@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
@@ -31,6 +31,7 @@ import { attach } from './attach.mjs';
31
31
  import { setRun } from './run-context.mjs';
32
32
  import { loadRunEnv, RUN_ENV } from './run-env.mjs';
33
33
  import { parseArgs, die } from './args.mjs';
34
+ import { settleTaskInput } from './task-input.mjs';
34
35
  import { parseContinuationGrant } from '../required-inputs.mjs';
35
36
  import { continuationRefusal } from './continuation.mjs';
36
37
  import { SCHEMAS } from './schemas.mjs';
@@ -48,7 +49,6 @@ import { validateScope } from './scope-check.mjs';
48
49
  * below is the CLI entry one level up — not this module, which has no command line of its own.
49
50
  */
50
51
  const RUNNER_ENTRY = fileURLToPath(new URL('../run-codex.mjs', import.meta.url));
51
-
52
52
  export const questionsFromFlags = (questionTexts = []) =>
53
53
  questionTexts.map((text, i) => ({ id: `Q${i + 1}`, text }));
54
54
 
@@ -63,7 +63,7 @@ const stamp = () => {
63
63
  * otherwise overwrite each other's task.md, log and result — and a status could then be
64
64
  * computed from another run's artifacts.
65
65
  */
66
- function makeRunDir(base) {
66
+ export function makeRunDir(base) {
67
67
  fs.mkdirSync(path.dirname(base), { recursive: true });
68
68
  for (let n = 2, dir = base; ; n += 1) {
69
69
  try {
@@ -76,19 +76,26 @@ function makeRunDir(base) {
76
76
  }
77
77
  }
78
78
 
79
+ export const runDirPath = (root, slug, runStamp = stamp()) => {
80
+ // `2026-08-13_235525_2026-08-13_plan4-6g-ceiling-scope` duplicated the order date. Only the
81
+ // directory copy is stripped: the slug stored in status.json stays verbatim, because a run
82
+ // with neither order_id nor task_hash is found by its stored slug alone (meta/chain.mjs).
83
+ const stripped = String(slug).replace(/^\d{4}-\d{2}-\d{2}[_-]/, '');
84
+ // An order id that is nothing but a date would otherwise name a folder ending in `_`.
85
+ const directorySlug = stripped || String(slug);
86
+ return path.join(root, `${runStamp}_${directorySlug}`);
87
+ };
88
+
79
89
  /**
80
90
  * Everything a run needs before a single token of someone else's quota is spent, and every
81
91
  * refusal that costs nothing: bad arguments, an open chain without --continue, a busy
82
92
  * worktree, a missing Codex CLI. All of it happens here, in the process the caller is free
83
93
  * to kill — so a killed caller can only ever interrupt a run that was already paid for.
84
94
  */
85
- export async function launcher() {
95
+ export async function launcher(argv = process.argv.slice(2)) {
86
96
  loadRunEnv();
87
-
88
- const opts = parseArgs(process.argv.slice(2));
89
- const taskText = fs.readFileSync(0, 'utf8').trim();
90
- if (!taskText) die('task text on stdin is empty');
91
-
97
+ const opts = parseArgs(argv);
98
+ const taskText = settleTaskInput(opts);
92
99
  const topLevel = git(opts.repo, ['rev-parse', '--show-toplevel']);
93
100
  const isGitRepo = topLevel.status === 0;
94
101
  const repoRoot = isGitRepo ? topLevel.stdout.trim() : opts.repo;
@@ -162,8 +169,9 @@ export async function launcher() {
162
169
  orderId: opts.orderId,
163
170
  chain,
164
171
  isContinue: opts.continue,
172
+ noWait: opts.noWait,
165
173
  });
166
- if (attachedExitCode !== null) process.exit(attachedExitCode);
174
+ if (attachedExitCode !== null) return attachedExitCode;
167
175
 
168
176
  if (startedChain.length && !opts.continue) {
169
177
  const last = startedChain[startedChain.length - 1];
@@ -196,7 +204,7 @@ export async function launcher() {
196
204
  retention = null;
197
205
  }
198
206
 
199
- const runDir = makeRunDir(path.join(projectRunsRoot, `${stamp()}_${opts.slug}`));
207
+ const runDir = makeRunDir(runDirPath(projectRunsRoot, opts.slug));
200
208
  setRun(runDir, opts.agent);
201
209
 
202
210
  // Written before Codex is even probed. From here on a killed runner leaves a folder that
@@ -230,7 +238,7 @@ export async function launcher() {
230
238
 
231
239
  // Printed before anything can go wrong: even a dispatcher that dies mid-run leaves the
232
240
  // orchestrator with a folder to look into.
233
- console.log(`RUN=${runDir}`);
241
+ console.log(`RUN=${runDir} order-id=${opts.orderId}`);
234
242
 
235
243
  // Before requireCodex, so a blocked run costs nothing at all.
236
244
  if (busy) {
@@ -245,7 +253,7 @@ export async function launcher() {
245
253
  true,
246
254
  );
247
255
  console.log(reply);
248
- process.exit(1);
256
+ return 1;
249
257
  }
250
258
 
251
259
  requireCodex(runDir, opts.agent);
@@ -334,7 +342,7 @@ export async function launcher() {
334
342
  true,
335
343
  );
336
344
  console.log(reply);
337
- process.exit(1);
345
+ return 1;
338
346
  }
339
347
  fs.writeFileSync(
340
348
  path.join(runDir, 'worker.json'),
@@ -347,7 +355,7 @@ export async function launcher() {
347
355
  is_git_repo: isGitRepo,
348
356
  launcher_pid: process.pid,
349
357
  // The mode's wall-clock budget, resolved here and never re-read by the worker: a run
350
- // that consulted run-config.json twice could end up honouring two different limits.
358
+ // that consulted config.json twice could end up honouring two different limits.
351
359
  budget_minutes: RUN_ENV?.budgets?.[runMode(opts.agent)],
352
360
  scope_new: opts.scopeNewPatterns,
353
361
  args: codexArgv,
@@ -366,13 +374,17 @@ export async function launcher() {
366
374
  windowsHide: true,
367
375
  cwd: repoRoot,
368
376
  });
369
- worker.on('error', (err) => {
370
- const { reply } = writeFailure(runDir, opts.agent, `run worker process failed to start: ${err.message}`, [
371
- 'Codex was not started; quota was not spent',
372
- ], true);
373
- console.log(reply);
374
- process.exit(1);
377
+ const started = await new Promise((resolve) => {
378
+ worker.once('spawn', () => resolve(true));
379
+ worker.once('error', (err) => {
380
+ const { reply } = writeFailure(runDir, opts.agent, `run worker process failed to start: ${err.message}`, [
381
+ 'Codex was not started; quota was not spent',
382
+ ], true);
383
+ console.log(reply);
384
+ resolve(false);
385
+ });
375
386
  });
387
+ if (!started) return 1;
376
388
  worker.unref();
377
389
  writeStatus(runDir, { pid: worker.pid, runner_pid: worker.pid, process_started_at: null });
378
390
 
@@ -382,5 +394,5 @@ export async function launcher() {
382
394
  console.log(
383
395
  '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.',
384
396
  );
385
- process.exit(0);
397
+ return 0;
386
398
  }
@@ -0,0 +1,28 @@
1
+ /** Keeps the runner and producer hook on one definition of which task owns an order id. */
2
+
3
+ const byStartThenName = (left, right) => {
4
+ const leftAt = String(left.status.started_at || '');
5
+ const rightAt = String(right.status.started_at || '');
6
+ if (leftAt !== rightAt) return leftAt < rightAt ? -1 : 1;
7
+ if (String(left.run) === String(right.run)) return 0;
8
+ return String(left.run) < String(right.run) ? -1 : 1;
9
+ };
10
+
11
+ export function runsForOrder(runs, orderId) {
12
+ const wantedOrderId = String(orderId ?? '');
13
+ if (!wantedOrderId) return [];
14
+ return runs
15
+ .filter(({ status }) => String(status?.order_id ?? '') === wantedOrderId)
16
+ .sort(byStartThenName);
17
+ }
18
+
19
+ /**
20
+ * On 2026-08-15 plan42-run3 reused plan42-run2's order id and received run2's verdict.
21
+ * Compare only known fingerprints so runs predating task_hash remain fail-open.
22
+ */
23
+ export function conflictingOrderOwner(runs, orderId, taskHash) {
24
+ const owner = runsForOrder(runs, orderId).at(-1);
25
+ const ownerHash = String(owner?.status.task_hash ?? '').trim().toLowerCase();
26
+ const incomingHash = String(taskHash ?? '').trim().toLowerCase();
27
+ return ownerHash && incomingHash && ownerHash !== incomingHash ? owner : null;
28
+ }
@@ -9,7 +9,7 @@
9
9
  * fixture ran the task and cost 23k.
10
10
  *
11
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
12
+ * turns either of them back on. A broken config.json stops the run instead of quietly
13
13
  * falling back — a typo must not decide what environment a run gets.
14
14
  */
15
15
  import { readRunConfig, disableFlags } from '../run-config.mjs';
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Platform flags a delegated run needs before its sandbox can start a process at all.
3
+ *
4
+ * codex-cli 0.149.0 on Windows runs sandboxed commands under a restricted token, and which token
5
+ * it builds is `windows.sandbox` — `elevated` or `unelevated`. The unelevated form refuses
6
+ * CreateProcess outright: every command the model tries comes back as
7
+ * `Rejected("… rejected: blocked by policy")` before the process exists. That setting lives in the
8
+ * operator's own config.toml, which `--ignore-user-config` deliberately drops, so on 2026-08-23
9
+ * every codex-scout and codex-review run died without reading a single file while codex-build
10
+ * kept working — not because writing is exempt, but because it is the one agent that does not pass
11
+ * `--ignore-user-config` and so inherited the operator's key by luck. Four probes on the installed
12
+ * CLI, differing by this flag alone, separate the two: with the key a run prints its command output
13
+ * and exits 0, without it both read-only AND workspace-write are refused.
14
+ *
15
+ * Naming the flag here keeps the isolation intact — a run still ignores the operator's config —
16
+ * and stops the package from depending on a line in a personal file it does not own.
17
+ *
18
+ * The value carries no quotes on purpose: unsafeForCmd() refuses any argument containing `"`,
19
+ * because the whole command line goes through cmd.exe. Codex parses `elevated` as a bare TOML
20
+ * literal, and rejects an unknown variant loudly, so the unquoted form is checked, not assumed.
21
+ */
22
+ export const WINDOWS_SANDBOX = ['-c', 'windows.sandbox=elevated'];
23
+
24
+ /** Empty off Windows: no other platform has this setting, and passing it would be an error. */
25
+ export function platformSandboxArgs(platform = process.platform) {
26
+ return platform === 'win32' ? [...WINDOWS_SANDBOX] : [];
27
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Parses the task document without knowing how it reached the runner.
3
+ *
4
+ * The 2026-08-15 permission-rule incident showed that model-written questions and verification
5
+ * commands cannot safely travel through a shell command line. Recognised sections keep that free
6
+ * text in the file while the headingless form preserves every existing task file.
7
+ */
8
+
9
+ const SECTION_HEADING = /^ {0,3}(#{1,6})[ \t]+(.+?)[ \t]*$/;
10
+ const LIST_ITEM = /^\s*(?:[-*]|\d+\.)[ \t]+(.+?)\s*$/;
11
+ const SECTION_NAMES = new Map([
12
+ ['task', 'task'],
13
+ ['questions', 'questions'],
14
+ ['verify', 'verify'],
15
+ ]);
16
+
17
+ /**
18
+ * A heading, its level, and the section it opens when the runner recognises its text.
19
+ *
20
+ * The level is what closes a section. Treating every unrecognised heading as ordinary content
21
+ * made the first task file written against this format unreadable: a `## Constraints` heading
22
+ * after `## Verify` was swallowed into the verification command, and the document was refused
23
+ * for holding more than one command. A sibling or shallower heading ends the section it follows;
24
+ * only a deeper one belongs to it.
25
+ */
26
+ function headingFrom(line) {
27
+ const match = SECTION_HEADING.exec(line);
28
+ if (!match) return undefined;
29
+ const text = match[2].replace(/[ \t]+#+[ \t]*$/, '').trim().toLowerCase();
30
+ return { level: match[1].length, section: SECTION_NAMES.get(text) };
31
+ }
32
+
33
+ function sectionFrom(line) {
34
+ return headingFrom(line)?.section;
35
+ }
36
+
37
+ export function parseTaskDocument(text) {
38
+ const lines = String(text).split(/\r?\n/);
39
+ const hasSections = lines.some((line) => sectionFrom(line) !== undefined);
40
+ if (!hasSections) return { task: String(text).trim(), questions: [], verify: undefined };
41
+
42
+ const taskLines = [];
43
+ const questionLines = [];
44
+ const verifyLines = [];
45
+ let section = 'task';
46
+ let sectionLevel = 0;
47
+ let sawQuestions = false;
48
+ let sawVerify = false;
49
+
50
+ for (const line of lines) {
51
+ const heading = headingFrom(line);
52
+ if (heading?.section !== undefined) {
53
+ section = heading.section;
54
+ sectionLevel = heading.level;
55
+ if (section === 'questions') sawQuestions = true;
56
+ if (section === 'verify') sawVerify = true;
57
+ continue;
58
+ }
59
+ // An unrecognised heading at the section's own level or above ends it; the heading itself is
60
+ // part of the task statement from there on. A deeper heading stays inside the section.
61
+ if (heading && section !== 'task' && heading.level <= sectionLevel) {
62
+ section = 'task';
63
+ sectionLevel = 0;
64
+ }
65
+ if (section === 'questions') questionLines.push(line);
66
+ else if (section === 'verify') verifyLines.push(line);
67
+ else taskLines.push(line);
68
+ }
69
+
70
+ const questions = [];
71
+ for (const line of questionLines) {
72
+ if (!line.trim()) continue;
73
+ const item = LIST_ITEM.exec(line);
74
+ if (!item) {
75
+ throw new Error(`Questions section contains a non-list line: ${JSON.stringify(line.trim())}`);
76
+ }
77
+ questions.push(item[1].trim());
78
+ }
79
+ if (sawQuestions && !questions.length) throw new Error('Questions section is empty');
80
+
81
+ const verifyContent = verifyLines.filter((line) => line.trim());
82
+ if (sawVerify && !verifyContent.length) throw new Error('Verify section is empty');
83
+ if (verifyContent.length > 1) {
84
+ throw new Error('Verify section must contain exactly one non-empty line');
85
+ }
86
+
87
+ return {
88
+ task: taskLines.join('\n').trim(),
89
+ questions,
90
+ verify: sawVerify ? verifyContent[0].trim() : undefined,
91
+ };
92
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Settles the order's task, sub-questions and verification command from the two channels that
3
+ * can carry them, and refuses before a run folder or a paid process can exist.
4
+ *
5
+ * Kept apart from the launcher because it is the one place that has seen both channels: argument
6
+ * parsing runs before the task file has been read, and the file is read without knowing which
7
+ * flags were passed. Deciding it in either of them alone is how a scout order carrying its
8
+ * questions in the file got refused for not passing --question.
9
+ */
10
+ import { die, readTaskDocument } from './args.mjs';
11
+
12
+ export function settleTaskInput(opts) {
13
+ const { task, questions: fileQuestions, verify: fileVerify } = readTaskDocument(opts);
14
+ opts.questions ??= fileQuestions.length ? fileQuestions : undefined;
15
+ opts.verify ??= fileVerify;
16
+ // Still before the run folder exists and before a token of someone else's quota is touched,
17
+ // which is where every refusal of this kind belongs.
18
+ if (opts.agent === 'codex-scout' && !opts.questions?.length) {
19
+ die(
20
+ 'a sub-question is required for codex-scout: put one Markdown list item per sub-question ' +
21
+ 'under a `Questions` heading in the task file, or repeat --question for manual calls. ' +
22
+ 'The runner will not infer questions from the task text; no quota was spent.',
23
+ );
24
+ }
25
+ return task;
26
+ }
@@ -6,7 +6,7 @@
6
6
  * `repo`, `agent`, `args`, `is_git_repo`, `budget_minutes`. Written there: those five plus
7
7
  * `slug`, `launcher_pid` and `scope_new`, which nothing here needs but a run folder read back
8
8
  * months later does. The launcher must write the selected mode's budget into worker.json before
9
- * spawning this half, so the worker never re-reads run-config.json and one run cannot acquire two
9
+ * spawning this half, so the worker never re-reads config.json and one run cannot acquire two
10
10
  * configurations.
11
11
  * Neither side may change the shape alone.
12
12
  */
@@ -0,0 +1,26 @@
1
+ /** Plan_19 had to repair drift between duplicated CLI spellings, so shell-unsafe sequences must never be listed anywhere else. */
2
+
3
+ export const SHELL_UNSAFE_SEQUENCES = Object.freeze([
4
+ '`',
5
+ '$(',
6
+ '${',
7
+ '&&',
8
+ '||',
9
+ '|',
10
+ ';',
11
+ ]);
12
+
13
+ /** Returns the first forbidden sequence in reading order, preferring a compound operator at a tie. */
14
+ export function firstShellUnsafeSequence(value) {
15
+ const text = String(value);
16
+ let first = null;
17
+ let firstIndex = -1;
18
+ for (const sequence of SHELL_UNSAFE_SEQUENCES) {
19
+ const index = text.indexOf(sequence);
20
+ if (index !== -1 && (firstIndex === -1 || index < firstIndex)) {
21
+ first = sequence;
22
+ firstIndex = index;
23
+ }
24
+ }
25
+ return first;
26
+ }
@@ -30,7 +30,18 @@ import { writeStatus } from './meta/run-state.mjs';
30
30
  import { resolveStatus } from './meta/verdict.mjs';
31
31
  import { AGENTS, failReply, limitReply } from './meta/reply.mjs';
32
32
 
33
- const RUNNER_VERSION = readJsonFileSync(new URL('../package.json', import.meta.url)).version;
33
+ function readRunnerVersion() {
34
+ try {
35
+ return readJsonFileSync(new URL('../package.json', import.meta.url)).version;
36
+ } catch (error) {
37
+ // package.json is the one file npm requires outside the literal home image. The installed
38
+ // image has it beside lib/, while the repository has src/home between the module and manifest.
39
+ if (error.code !== 'ENOENT') throw error;
40
+ return readJsonFileSync(new URL('../../../package.json', import.meta.url)).version;
41
+ }
42
+ }
43
+
44
+ const RUNNER_VERSION = readRunnerVersion();
34
45
 
35
46
  export { expandDeclared, globToRegExp, readJson } from './meta/paths.mjs';
36
47
  export { chainBaseline, chainRuns, taskFingerprint } from './meta/chain.mjs';
@@ -1,73 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * PreToolUse gate for the Codex dispatchers (codex-scout / codex-build / codex-review).
4
- *
5
- * The runner rejects a dispatcher immediately when order id (and scope for codex-build) is
6
- * absent, but the old requirement lived only in the prompt read by the dispatcher. The caller
7
- * therefore had no enforced place to provide it and codex-build died before doing work. This
8
- * gate checks the producer's task text while it can still be corrected.
9
- *
10
- * Input is Claude Code's hook JSON on stdin. The last payload is retained for diagnostics so a
11
- * future host schema change can be inspected instead of guessed at. Any uncertain shape passes
12
- * silently because a diagnostic guard must never break an unrelated tool call.
13
- */
14
- import fs from 'node:fs';
15
- import os from 'node:os';
16
- import path from 'node:path';
17
- import { diagnoseInput, missingInputs } from '../required-inputs.mjs';
18
- import { SUBAGENT_TOOLS } from '../hook-definitions.mjs';
19
-
20
- const HOME = os.homedir();
21
- const LOG_DIR = path.join(HOME, '.claude', 'logs');
22
- const GUARDED = new Set(['codex-scout', 'codex-build', 'codex-review']);
23
- /**
24
- * Both spellings of the subagent-launching tool, from the same list the installer builds its
25
- * matcher from. Recognising only the name this host happens to use would make the gate silent
26
- * on every other host, and a gate that is silent for an unknown reason is worse than none.
27
- */
28
- const SUBAGENT_TOOL_NAMES = new Set(SUBAGENT_TOOLS);
29
-
30
- const pass = () => process.exit(0);
31
-
32
- let input;
33
- try {
34
- input = JSON.parse(fs.readFileSync(0, 'utf8'));
35
- } catch {
36
- pass();
37
- }
38
-
39
- try {
40
- fs.mkdirSync(LOG_DIR, { recursive: true });
41
- fs.writeFileSync(path.join(LOG_DIR, 'codex-order-gate.last.json'), `${JSON.stringify(input, null, 2)}\n`);
42
- } catch {
43
- // Diagnostics are a convenience, never a reason to fail the turn.
44
- }
45
-
46
- if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
47
- if (!SUBAGENT_TOOL_NAMES.has(input.tool_name)) pass();
48
-
49
- const toolInput = input.tool_input;
50
- if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
51
- if (!GUARDED.has(toolInput.subagent_type) || typeof toolInput.prompt !== 'string') pass();
52
-
53
- const missing = missingInputs(toolInput.subagent_type, toolInput.prompt);
54
- if (!missing.length) pass();
55
-
56
- const reason = [
57
- 'Order gate denied the Agent call because required dispatcher input(s) are missing or still placeholders.',
58
- 'Write each value in tool_input.prompt using `label: value` before launching the dispatcher:',
59
- ...missing.flatMap((entry) => {
60
- const lines = [`- ${entry.label}: ${entry.explanation} Example: \`${entry.example}\`.`];
61
- const diagnosis = diagnoseInput(toolInput.prompt, entry.label);
62
- if (diagnosis) lines.push(` found \`${diagnosis.line}\`, ${diagnosis.reason}`);
63
- return lines;
64
- }),
65
- ].join('\n');
66
-
67
- process.stdout.write(JSON.stringify({
68
- hookSpecificOutput: {
69
- hookEventName: 'PreToolUse',
70
- permissionDecision: 'deny',
71
- permissionDecisionReason: reason,
72
- },
73
- }));
@@ -1,77 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * PreToolUse lock for file-writing tools in the Claude Code host.
4
- *
5
- * A live codex-build runner snapshots its repository before and after editing. Letting the
6
- * orchestrator write during that window folds unrelated work into the runner's result, exactly
7
- * as the 2026-08-05 incident did. This hook denies only a recognised file path inside a live
8
- * build run's repository; every malformed or unreadable input passes so diagnostics never break
9
- * unrelated host work.
10
- */
11
- import fs from 'node:fs';
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';
16
- import { allLiveRuns, normalizePath } from './live-runs.mjs';
17
-
18
- const WRITE_TOOL_NAMES = new Set(WRITE_TOOLS);
19
- const pass = () => process.exit(0);
20
-
21
- let input;
22
- try {
23
- input = JSON.parse(fs.readFileSync(0, 'utf8'));
24
- } catch {
25
- pass();
26
- }
27
-
28
- if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
29
- if (!WRITE_TOOL_NAMES.has(input.tool_name)) pass();
30
-
31
- const toolInput = input.tool_input;
32
- if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
33
- const pathField = input.tool_name === 'NotebookEdit' ? 'notebook_path' : 'file_path';
34
- const rawPath = toolInput[pathField];
35
- if (typeof rawPath !== 'string' || !rawPath.trim()) pass();
36
-
37
- let targetPath;
38
- try {
39
- if (path.isAbsolute(rawPath)) targetPath = rawPath;
40
- else if (typeof input.cwd === 'string' && input.cwd.trim()) targetPath = path.resolve(input.cwd, rawPath);
41
- else pass();
42
- } catch {
43
- pass();
44
- }
45
-
46
- const target = normalizePath(targetPath);
47
- if (!target) pass();
48
-
49
- let liveRuns;
50
- try {
51
- liveRuns = allLiveRuns(runsRoot());
52
- } catch {
53
- pass();
54
- }
55
- const owner = liveRuns.find(({ status }) => {
56
- if (status.agent !== 'codex-build') return false;
57
- const repository = normalizePath(status.repo);
58
- return Boolean(repository) && (target === repository || target.startsWith(`${repository}/`));
59
- });
60
- if (!owner) pass();
61
-
62
- const { status, dir } = owner;
63
- process.stdout.write(JSON.stringify({
64
- hookSpecificOutput: {
65
- hookEventName: 'PreToolUse',
66
- permissionDecision: 'deny',
67
- permissionDecisionReason: `Worktree lock denied ${input.tool_name}: ${targetPath} is inside repository `
68
- + `${status.repo}, held by live run folder ${dir} (agent ${status.agent}, slug ${status.slug}). `
69
- // "Working, last progress X ago" rather than "silent for X": this hook only ever denies a
70
- // run whose heartbeat is still fresh — a stale one is not live here and the edit passes. The
71
- // first live probe printed "silent for 10 seconds" next to a stop command on a perfectly
72
- // healthy run, which reads as "it hung, kill it" and would cost the operator 20 minutes of
73
- // work they meant to keep.
74
- + `It is working; last progress ${formatSilence(heartbeatAge(dir))} ago. `
75
- + `To take the repository back before it finishes: codex-bridge stop ${path.basename(dir)}.`,
76
- },
77
- }));
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes