@lyupro/codex-bridge 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.1.4] - 2026-08-05
8
+
9
+ ### Added
10
+
11
+ - A continuation has to prove the orchestrator ordered it. `--continue` is refused unless the task text carries a `continue:` grant naming the run being continued and why, and the refusal costs nothing: no run folder is created and Codex is never started. The grant is single-use by construction rather than by a counter — continuing the last run of a chain appends a later one, so the same line stops matching by itself. Until this release a dispatcher could decide on a second pass alone: one did, spent 75 691 tokens of the Codex subscription and created a module the order never asked for.
12
+ - A reply that stays silent about a live `codex-build` run of the same project is blocked. The guard reads the project's runs folder from disk instead of trusting the paths the reply chose to mention, so a second run can no longer be left working in the background while the verdict of the first is handed back. Runs of `codex-scout` and `codex-review` are not counted: they hold a read-only sandbox, and blocking a reply over one would spend the guard's budget on a run that touches nothing.
13
+ - A `PreToolUse` lock refuses file edits inside a repository held by a live `codex-build` run. Knowing that a run is live is not the same as being unable to disturb it — edits made during that window land in the runner's before/after snapshot and fail an honest run for work it never did. An edit made through `Bash` still walks past the lock; parsing a command line is not something a guard can do reliably.
14
+
15
+ ### Fixed
16
+
17
+ - `doctor` names the file each hook was registered for. With two hooks on one event it matched the installation record by event alone and reported the worktree lock's matcher as pointing at `order-gate.mjs` — the one line where an operator can check what is installed, showing the wrong file.
18
+ - Hook presence is decided by this package's own command rather than by the matcher it currently sits under. The matcher is generated from the list of host tool names and grows whenever a spelling is added, so a host installed under the previous matcher would collect a duplicate hook on update and keep a hook pointing at a deleted file after uninstall.
19
+
7
20
  ## [0.1.3] - 2026-08-05
8
21
 
9
22
  ### Added
package/cli/doctor.mjs CHANGED
@@ -73,7 +73,12 @@ async function hookChecks(host, record) {
73
73
  return Promise.all(HOOK_DEFINITIONS.map(async (definition) => {
74
74
  const key = `hook:${definition.event}`;
75
75
  if (!record) return check(key, 'warn', 'cannot check before installation');
76
- const recorded = record.hooks.find((hook) => hook.event === definition.event);
76
+ // The file, not just the event: PreToolUse carries two hooks of this package, and matching
77
+ // by event alone reported the worktree lock's matcher as pointing at order-gate.mjs. The
78
+ // hook line is the only place an operator can see WHICH file a matcher was registered for,
79
+ // so a wrong name here is worse than no line at all.
80
+ const recorded = record.hooks.find((hook) => hook.event === definition.event
81
+ && path.basename(hook.path) === definition.file);
77
82
  if (!recorded) {
78
83
  return check(key, 'warn', `${definition.event} hook is not present in the installation record`);
79
84
  }
package/cli/manifest.mjs CHANGED
@@ -164,15 +164,17 @@ export function validateInstallRecord(record) {
164
164
  if (!hook || typeof hook !== 'object' || Array.isArray(hook)) {
165
165
  throw new Error('installation record hook must be an object');
166
166
  }
167
- const definition = HOOK_DEFINITIONS.find((entry) => entry.event === hook.event);
168
- if (!definition || typeof hook.path !== 'string' || !hook.path) {
167
+ const definitions = HOOK_DEFINITIONS.filter((entry) => entry.event === hook.event);
168
+ if (!definitions.length || typeof hook.path !== 'string' || !hook.path) {
169
169
  throw new Error('installation record hook must identify a supported event and its path');
170
170
  }
171
171
  if (path.isAbsolute(hook.path) || hook.path.split(/[\\/]/).includes('..')) {
172
172
  throw new Error('installation record hook path must stay relative to the host root');
173
173
  }
174
- if (path.basename(hook.path) !== definition.file || !record.files.includes(hook.path)) {
175
- throw new Error(`installation record hook path must name the installed ${definition.file}`);
174
+ const definition = definitions.find((entry) => path.basename(hook.path) === entry.file);
175
+ if (!definition || !record.files.includes(hook.path)) {
176
+ const files = definitions.map((entry) => entry.file).join(' or ');
177
+ throw new Error(`installation record hook path must name the installed ${files}`);
176
178
  }
177
179
  }
178
180
  return record;
@@ -57,13 +57,24 @@ function groupHooks(group) {
57
57
  return Array.isArray(group?.hooks) ? group.hooks : [];
58
58
  }
59
59
 
60
+ const hasOwnCommand = (group, command) =>
61
+ groupHooks(group).some((hook) => hook?.type === 'command' && hook.command === command);
62
+
63
+ /**
64
+ * Presence is decided by the command, not by the matcher it currently sits under.
65
+ *
66
+ * The matcher is generated from the tool list in hook-definitions.mjs, so it changes whenever a
67
+ * host spelling is added — `Agent` became `Agent|Task` in one release, and the write-tool matcher
68
+ * will grow the same way. A host installed under the old matcher would then be invisible to the
69
+ * new lookup: update would register a duplicate and uninstall would leave a hook pointing at a
70
+ * deleted file. The command is this package's own absolute path, so it identifies our entry
71
+ * wherever the group ended up.
72
+ */
60
73
  export async function inspectHook(settingsPath, specOrPath) {
61
74
  const state = await readSettings(settingsPath);
62
75
  const spec = normalizeSpec(specOrPath);
63
76
  const present = groups(state.settings, spec.event)
64
- .filter((group) => group?.matcher === spec.matcher)
65
- .some((group) => groupHooks(group)
66
- .some((hook) => hook?.type === 'command' && hook.command === spec.command));
77
+ .some((group) => hasOwnCommand(group, spec.command));
67
78
  return { ...state, ...spec, present };
68
79
  }
69
80
 
@@ -110,9 +121,12 @@ export async function removeHook(settingsPath, specOrPath, { createdGroup = fals
110
121
  if (!state.present) return { changed: false };
111
122
  const settings = structuredClone(state.settings);
112
123
  const eventGroups = groups(settings, spec.event);
124
+ // Removal follows the same rule as the lookup above: our command, whatever matcher it is
125
+ // filed under. A foreign hook is left alone because its command is not ours, not because it
126
+ // sits in another group.
113
127
  for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
114
128
  const group = eventGroups[index];
115
- if (group?.matcher !== spec.matcher) continue;
129
+ if (!hasOwnCommand(group, spec.command)) continue;
116
130
  if (!Array.isArray(group?.hooks)) continue;
117
131
  group.hooks = group.hooks.filter((hook) =>
118
132
  !(hook?.type === 'command' && hook.command === spec.command));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyupro/codex-bridge",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Delegate implementation, reconnaissance and code review from Claude Code to the Codex CLI: dispatcher agents, a runner that survives a dropped call, and a verdict that checks the report against the worktree.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -32,12 +32,13 @@ job is to report the run status honestly, including failure.
32
32
  reuse an order id from another order — the runner chains runs by that label, and a made-up label
33
33
  is how a repeat run hides. If the orchestrator did not give a required input, do not guess: start
34
34
  the runner without its flag and return the runner's refusal exactly.
35
- - `continue` — pass it as the `--continue` flag only if the orchestrator gave it explicitly; do not
36
- add or guess it yourself. The contract is the same kind as `--scope`: if this task (the same
37
- `slug` and the same repository) already had a run, the runner will refuse to start without
38
- `--continue` — before
39
- starting Codex, so no quota is spent. The orchestrator decides on a repeat run; your job is to
40
- pass the flag if given and return the runner's refusal exactly if it is absent.
35
+ - `continue` — pass the `--continue` flag only when the orchestrator gave the matching `continue:`
36
+ grant explicitly; do not add or guess it yourself. A continuation is assigned by the
37
+ orchestrator, never chosen by you. After the verdict, return the exact attaching output and stop;
38
+ do not issue or invent another continuation. The contract is the same kind as `--scope`: if this
39
+ task (the same `slug` and the same repository) already had a run, the runner will refuse to start
40
+ without `--continue` — before starting Codex, so no quota is spent. If the orchestrator did not
41
+ grant a repeat, return the runner's refusal exactly.
41
42
  - Optional: `effort: <none|minimal|low|medium|high|xhigh|max>`, `slug:`, `verify: <verification
42
43
  command>` (for example `npm test`, `tsc --noEmit`).
43
44
 
@@ -57,7 +58,8 @@ Add `--effort "<value>"` only when the orchestrator named a depth, and only with
57
58
  decides, which is the intended default — a placeholder copied from this template is refused before
58
59
  Codex starts.
59
60
 
60
- If the orchestrator gave `continue`, add the bare `--continue` flag to the command, with no value.
61
+ If the orchestrator gave the matching `continue:` grant, add the bare `--continue` flag to the
62
+ command, with no value.
61
63
  No value
62
64
  is passed to it: the runner accepts only `1/true/yes/0/false/no`, while a placeholder string left in
63
65
  the
@@ -31,6 +31,10 @@ opinion, not a verdict.
31
31
  another order — the runner chains runs by that label, and a made-up label is how a repeat run
32
32
  hides. If the orchestrator did not give a required input, do not guess — start the runner without
33
33
  its flag and return the runner's refusal verbatim.
34
+ - `continue` is conditional — pass the `--continue` flag only when the orchestrator gave the
35
+ matching `continue:` grant explicitly; do not add or guess it yourself. A continuation is
36
+ assigned by the orchestrator, never chosen by you. After the verdict, return the exact attaching
37
+ output and stop; do not issue or invent another continuation.
34
38
  - Optional: review focus as text ("look for races and error handling"), `slug:`, `effort:
35
39
  <none|minimal|low|medium|high|xhigh|max>`.
36
40
 
@@ -54,6 +58,9 @@ line. To get the verdict, run the **identical command a second time** — same `
54
58
  `--slug`, same flags. That second call does not start a second run and costs no quota: it attaches
55
59
  to the run already in flight, prints `ATTACH=<path>`, blocks until the verdict exists and prints it.
56
60
 
61
+ If the orchestrator gave the matching `continue:` grant, add the bare `--continue` flag to the
62
+ command, with no value. If it was not given, the flag must not be present in the command at all.
63
+
57
64
  Background execution (`run_in_background`, `&`, `nohup`) is prohibited. Interruption is no longer a
58
65
  problem worth handling: if the attaching call is killed by a time ceiling, repeat it — every repeat
59
66
  attaches to the same run. A real run takes 20-25 minutes, which is normal, not a hang. Give the
@@ -32,11 +32,12 @@ files, do not run grep, do not retell the report, and do not reason about the ta
32
32
  pass one `--question "<text>"` flag exactly as given. Never invent, merge, reword, or drop a
33
33
  sub-question. If the orchestrator did not give one, do not guess — start the runner without
34
34
  the flag and return its refusal verbatim.
35
- - `continue` — pass it as the `--continue` flag only if the orchestrator gave it explicitly; do not
36
- add or guess it yourself. The contract is strict, like `--scope` in codex-build: if this task
37
- (the same `slug` and the same repository) already had a run, the runner will refuse to start
38
- without `--continue` — before starting Codex, so no quota is spent. The orchestrator decides on a
39
- repeat run.
35
+ - `continue` — pass the `--continue` flag only when the orchestrator gave the matching `continue:`
36
+ grant explicitly; do not add or guess it yourself. A continuation is assigned by the
37
+ orchestrator, never chosen by you. After the verdict, return the exact attaching output and stop;
38
+ do not issue or invent another continuation. The contract is strict, like `--scope` in
39
+ codex-build: if this task (the same `slug` and the same repository) already had a run, the runner
40
+ will refuse to start without `--continue` — before starting Codex, so no quota is spent.
40
41
 
41
42
  ## The only thing you do
42
43
 
@@ -57,7 +58,8 @@ Add `--effort "<value>"` only when the orchestrator named a depth, and only with
57
58
  decides, which is the intended default — a placeholder copied from this template is refused before
58
59
  Codex starts.
59
60
 
60
- If the orchestrator gave `continue`, add the bare `--continue` flag to the command, with no value.
61
+ If the orchestrator gave the matching `continue:` grant, add the bare `--continue` flag to the
62
+ command, with no value.
61
63
  No value
62
64
  is passed to it: the runner accepts only `1/true/yes/0/false/no`, while a placeholder string left in
63
65
  the
@@ -19,7 +19,18 @@ export const SUBAGENT_TOOLS = Object.freeze(['Agent', 'Task']);
19
19
  /** A PreToolUse matcher is a regular expression over tool names, so alternation covers both. */
20
20
  export const SUBAGENT_TOOL_MATCHER = SUBAGENT_TOOLS.join('|');
21
21
 
22
+ /**
23
+ * File-writing tools differ across Claude Code hosts just like subagent tools do. Register every
24
+ * host spelling here so the worktree lock receives the edit before it can enter a live runner's
25
+ * before/after snapshot; a list kept only in the hook would leave one host silently unlocked.
26
+ */
27
+ export const WRITE_TOOLS = Object.freeze(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
28
+
29
+ /** A PreToolUse matcher is a regular expression over tool names, so alternation covers all four. */
30
+ export const WRITE_TOOL_MATCHER = WRITE_TOOLS.join('|');
31
+
22
32
  export const HOOK_DEFINITIONS = Object.freeze([
23
33
  Object.freeze({ event: 'SubagentStop', matcher: '*', file: 'reply-guard.mjs' }),
24
34
  Object.freeze({ event: 'PreToolUse', matcher: SUBAGENT_TOOL_MATCHER, file: 'order-gate.mjs' }),
35
+ Object.freeze({ event: 'PreToolUse', matcher: WRITE_TOOL_MATCHER, file: 'worktree-lock.mjs' }),
25
36
  ]);
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Shared, fail-open knowledge of live Codex runs.
3
+ *
4
+ * The reply guard and the worktree lock must answer the same question: a status.json is live
5
+ * only while it says `running` and its runner pid accepts signal 0. Keeping that probe and the
6
+ * directory scan here prevents the two protections from disagreeing about a run, which would
7
+ * recreate the incident where the dispatcher reported while another run still edited the tree.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ /** Signal 0 tests existence without stopping the process; EPERM still means that it exists. */
13
+ export const isPidAlive = (pid) => {
14
+ if (typeof pid !== 'number') return false;
15
+ try {
16
+ process.kill(pid, 0);
17
+ return true;
18
+ } catch (err) {
19
+ return err.code === 'EPERM';
20
+ }
21
+ };
22
+
23
+ /**
24
+ * Match the package's repository comparison without resolving symlinks: Windows realpath adds
25
+ * `\\?\\` and UNC spellings, turning equal paths into unequal ones. A null result is uncertainty,
26
+ * and callers must pass rather than deny on it.
27
+ */
28
+ export function normalizePath(value) {
29
+ if (typeof value !== 'string' || !value.trim()) return null;
30
+ try {
31
+ let normalized = path.resolve(value).replaceAll('\\', '/').replace(/\/+$/, '');
32
+ if (!normalized) normalized = '/';
33
+ if (process.platform === 'win32') normalized = normalized.toLowerCase();
34
+ return normalized;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function isNonEmptyString(value) {
41
+ return typeof value === 'string' && Boolean(value.trim());
42
+ }
43
+
44
+ function recognizedStatus(status) {
45
+ return Boolean(status)
46
+ && typeof status === 'object'
47
+ && !Array.isArray(status)
48
+ && status.state === 'running'
49
+ && typeof status.pid === 'number'
50
+ && isNonEmptyString(status.agent)
51
+ && isNonEmptyString(status.slug)
52
+ && isNonEmptyString(status.repo)
53
+ && isPidAlive(status.pid);
54
+ }
55
+
56
+ function readStatus(runDir) {
57
+ try {
58
+ return JSON.parse(fs.readFileSync(path.join(runDir, 'status.json'), 'utf8'));
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /** Scan one project's direct run folders; unreadable or unfamiliar entries are ignored. */
65
+ export function liveRuns(runsDir) {
66
+ if (typeof runsDir !== 'string' || !runsDir.trim()) return [];
67
+ let entries;
68
+ try {
69
+ entries = fs.readdirSync(runsDir, { withFileTypes: true });
70
+ } catch {
71
+ return [];
72
+ }
73
+ const result = [];
74
+ for (const entry of entries) {
75
+ if (!entry.isDirectory()) continue;
76
+ const dir = path.join(runsDir, entry.name);
77
+ const status = readStatus(dir);
78
+ if (recognizedStatus(status)) result.push({ dir, status });
79
+ }
80
+ return result;
81
+ }
82
+
83
+ /** Scan every project under the configured runs root for worktree ownership. */
84
+ export function allLiveRuns(runsRoot) {
85
+ if (typeof runsRoot !== 'string' || !runsRoot.trim()) return [];
86
+ let projects;
87
+ try {
88
+ projects = fs.readdirSync(runsRoot, { withFileTypes: true });
89
+ } catch {
90
+ return [];
91
+ }
92
+ const result = [];
93
+ for (const project of projects) {
94
+ if (!project.isDirectory()) continue;
95
+ result.push(...liveRuns(path.join(runsRoot, project.name)));
96
+ }
97
+ return result;
98
+ }
@@ -29,6 +29,7 @@
29
29
  import fs from 'node:fs';
30
30
  import os from 'node:os';
31
31
  import path from 'node:path';
32
+ import { isPidAlive, liveRuns, normalizePath } from './live-runs.mjs';
32
33
 
33
34
  const HOME = os.homedir();
34
35
  const LOG_DIR = path.join(HOME, '.claude', 'logs');
@@ -168,8 +169,9 @@ const blockState = (reason, stopReason) => {
168
169
  * verdict is the stdout of the attaching call. Matching only `RUN=` would block every honest
169
170
  * answer given under the new contract as "did not delegate at all".
170
171
  */
171
- const runMatch = reply.match(/(?:RUN|ATTACH)=(.+?)(?:\r?\n|$)/);
172
- const runDir = runMatch ? runMatch[1].trim().replace(/\s+started=\S*$/, '').replace(/[`"'*]+$/g, '') : null;
172
+ const cleanRunDir = (value) => value.trim().replace(/\s+started=\S*$/, '').replace(/[`"'*]+$/g, '');
173
+ const runDirs = [...reply.matchAll(/(?:RUN|ATTACH)=(.+?)(?:\r?\n|$)/g)].map((match) => cleanRunDir(match[1]));
174
+ const runDir = runDirs[0] || null;
173
175
 
174
176
  if (!runDir || !fs.existsSync(runDir)) {
175
177
  blockForm(
@@ -180,22 +182,6 @@ if (!runDir || !fs.existsSync(runDir)) {
180
182
  );
181
183
  }
182
184
 
183
- /**
184
- * Best-effort liveness probe: signal 0 sends nothing, it only tests that the pid exists.
185
- * EPERM means the process is there and owned by someone else — alive for our purposes, and
186
- * the same reading write-meta.mjs uses. Two answers to "is this run still going" would be
187
- * one answer too many.
188
- */
189
- const isPidAlive = (pid) => {
190
- if (typeof pid !== 'number') return false;
191
- try {
192
- process.kill(pid, 0);
193
- return true;
194
- } catch (err) {
195
- return err.code === 'EPERM';
196
- }
197
- };
198
-
199
185
  /**
200
186
  * The text the operator reads when the state budget is gone. Everything in it is quoted
201
187
  * from status.json — the operator is being told to stop, and a stop justified by guesswork
@@ -203,12 +189,12 @@ const isPidAlive = (pid) => {
203
189
  */
204
190
  const fact = (value) => (value === undefined || value === null || value === '' ? 'not recorded' : String(value));
205
191
 
206
- const stopText = (headline, observed, runStatus) =>
192
+ const stopText = (headline, observed, runStatus, folder = runDir) =>
207
193
  [
208
194
  headline,
209
195
  `Run: slug ${fact(runStatus?.slug)}, agent ${fact(runStatus?.agent)}, repository ` +
210
196
  `${fact(runStatus?.repo)}, started ${fact(runStatus?.started_at)}.`,
211
- `Run folder: ${runDir}. Read state from ${path.join(runDir, 'status.json')}; the verdict ` +
197
+ `Run folder: ${folder}. Read state from ${path.join(folder, 'status.json')}; the verdict ` +
212
198
  'will appear in the adjacent meta.json.',
213
199
  observed,
214
200
  `Worktree ${fact(runStatus?.repo)} is busy with this run: do not build, test, or commit until ` +
@@ -220,6 +206,43 @@ const stopText = (headline, observed, runStatus) =>
220
206
  'command with the same --order-id attaches to this run rather than starting another.',
221
207
  ].join(' ');
222
208
 
209
+ /**
210
+ * The dispatcher must name every WRITING run the project has live, not just the one it chose to
211
+ * quote. The sibling scan is disk-only, so silence cannot hide a second runner; a scan failure
212
+ * remains fail-open because an uncertain diagnostic must never deny unrelated work.
213
+ *
214
+ * Only codex-build counts. Scout and review run in a read-only sandbox and leave the worktree
215
+ * alone, and launching them alongside other work is deliberate practice here — blocking a reply
216
+ * over a live reader would spend the state budget, and possibly the session, on a run that
217
+ * threatens nothing. What the 2026-08-05 incident cost was a hidden WRITER: the orchestrator
218
+ * edited files that landed in its before/after snapshot.
219
+ */
220
+ const namedRunDirs = new Set(runDirs.map((dir) => normalizePath(dir)).filter(Boolean));
221
+ let silentLiveSibling = null;
222
+ try {
223
+ silentLiveSibling = liveRuns(path.dirname(runDir)).find((run) => {
224
+ if (run.status.agent !== 'codex-build') return false;
225
+ const normalized = normalizePath(run.dir);
226
+ return normalized && !namedRunDirs.has(normalized);
227
+ });
228
+ } catch {
229
+ silentLiveSibling = null;
230
+ }
231
+ if (silentLiveSibling) {
232
+ const { dir, status } = silentLiveSibling;
233
+ blockState(
234
+ `Contract violated: the response names ${runDir} but omits live run folder ${dir}; report every `
235
+ + `live run with its agent ${status.agent}, slug ${status.slug}, and repository ${status.repo}.`,
236
+ stopText(
237
+ `The reply guard stopped the session: the dispatcher responded ${MAX_STATE_BLOCKS} times `
238
+ + 'while a live sibling Codex run was omitted from its reply.',
239
+ `status.json in ${dir} says state=running; process pid ${fact(status.pid)} is alive.`,
240
+ status,
241
+ dir,
242
+ ),
243
+ );
244
+ }
245
+
223
246
  /**
224
247
  * status.json is written by run-codex.mjs before meta.json exists, so it catches the
225
248
  * dispatcher that reports before its own run is done — meta.json alone cannot, because a
@@ -0,0 +1,70 @@
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 { WRITE_TOOLS } from '../hook-definitions.mjs';
14
+ import { runsRoot } from '../runner/runs-root.mjs';
15
+ import { allLiveRuns, normalizePath } from './live-runs.mjs';
16
+
17
+ const WRITE_TOOL_NAMES = new Set(WRITE_TOOLS);
18
+ const pass = () => process.exit(0);
19
+
20
+ let input;
21
+ try {
22
+ input = JSON.parse(fs.readFileSync(0, 'utf8'));
23
+ } catch {
24
+ pass();
25
+ }
26
+
27
+ if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
28
+ if (!WRITE_TOOL_NAMES.has(input.tool_name)) pass();
29
+
30
+ const toolInput = input.tool_input;
31
+ if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
32
+ const pathField = input.tool_name === 'NotebookEdit' ? 'notebook_path' : 'file_path';
33
+ const rawPath = toolInput[pathField];
34
+ if (typeof rawPath !== 'string' || !rawPath.trim()) pass();
35
+
36
+ let targetPath;
37
+ try {
38
+ if (path.isAbsolute(rawPath)) targetPath = rawPath;
39
+ else if (typeof input.cwd === 'string' && input.cwd.trim()) targetPath = path.resolve(input.cwd, rawPath);
40
+ else pass();
41
+ } catch {
42
+ pass();
43
+ }
44
+
45
+ const target = normalizePath(targetPath);
46
+ if (!target) pass();
47
+
48
+ let liveRuns;
49
+ try {
50
+ liveRuns = allLiveRuns(runsRoot());
51
+ } catch {
52
+ pass();
53
+ }
54
+ const owner = liveRuns.find(({ status }) => {
55
+ if (status.agent !== 'codex-build') return false;
56
+ const repository = normalizePath(status.repo);
57
+ return Boolean(repository) && (target === repository || target.startsWith(`${repository}/`));
58
+ });
59
+ if (!owner) pass();
60
+
61
+ const { status, dir } = owner;
62
+ process.stdout.write(JSON.stringify({
63
+ hookSpecificOutput: {
64
+ hookEventName: 'PreToolUse',
65
+ permissionDecision: 'deny',
66
+ permissionDecisionReason: `Worktree lock denied ${input.tool_name}: ${targetPath} is inside repository `
67
+ + `${status.repo}, held by live run folder ${dir} (agent ${status.agent}, slug ${status.slug}). `
68
+ + 'Wait for status.json to change state before editing this repository.',
69
+ },
70
+ }));
@@ -11,6 +11,15 @@ const PLACEHOLDER_VALUES = new Set(['todo', 'tbd', 'label', 'none', 'order id',
11
11
 
12
12
  const freezeEntries = (entries) => Object.freeze(entries.map((entry) => Object.freeze(entry)));
13
13
 
14
+ export const CONTINUATION_INPUT = Object.freeze({
15
+ label: 'continue',
16
+ source: 'the orchestrator',
17
+ explanation:
18
+ 'The run folder this pass is ordered to continue, followed by why the orchestrator is spending another pass. A continuation is assigned by the orchestrator; after a verdict, the dispatcher returns it and stops.',
19
+ example: '2026-08-05_092913_plan14-build — LIMIT at step 3, tests unwritten',
20
+ conditional: 'when --continue is passed',
21
+ });
22
+
14
23
  export const REQUIRED_INPUTS = Object.freeze({
15
24
  'codex-scout': freezeEntries([
16
25
  {
@@ -19,6 +28,7 @@ export const REQUIRED_INPUTS = Object.freeze({
19
28
  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.',
20
29
  example: 'plan-13-scout-20260804',
21
30
  },
31
+ CONTINUATION_INPUT,
22
32
  ]),
23
33
  'codex-build': freezeEntries([
24
34
  {
@@ -33,6 +43,7 @@ export const REQUIRED_INPUTS = Object.freeze({
33
43
  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.',
34
44
  example: 'src/runner/**,tests/runner/**',
35
45
  },
46
+ CONTINUATION_INPUT,
36
47
  ]),
37
48
  'codex-review': freezeEntries([
38
49
  {
@@ -41,6 +52,7 @@ export const REQUIRED_INPUTS = Object.freeze({
41
52
  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.',
42
53
  example: 'plan-13-review-20260804',
43
54
  },
55
+ CONTINUATION_INPUT,
44
56
  ]),
45
57
  });
46
58
 
@@ -51,14 +63,14 @@ function cleanValue(value) {
51
63
  return value.trim().replace(/^([`'\"])(.*)\1$/, '$2').trim();
52
64
  }
53
65
 
54
- function isPlaceholder(value) {
66
+ export function isPlaceholder(value) {
55
67
  const cleaned = cleanValue(value);
56
68
  if (!cleaned) return true;
57
69
  if (cleaned.startsWith('<') && cleaned.endsWith('>')) return true;
58
70
  return PLACEHOLDER_VALUES.has(cleaned.replace(/[.,;:]+$/, '').trim().toLowerCase());
59
71
  }
60
72
 
61
- function extractValue(promptText, label) {
73
+ export function extractValue(promptText, label) {
62
74
  const labelPattern = escapeRegExp(label).replaceAll('\\ ', '\\s+');
63
75
  const flag = `--${label.replaceAll(/\s+/g, '-')}`;
64
76
  const direct = new RegExp(
@@ -77,6 +89,23 @@ function extractValue(promptText, label) {
77
89
  return cleanValue(flagMatch[1] ?? flagMatch[2] ?? flagMatch[3]);
78
90
  }
79
91
 
92
+ /** Keeps the 2026-08-05 continuation incident's run and reason in the shared input parser. */
93
+ export function parseContinuationGrant(promptText) {
94
+ const prompt = typeof promptText === 'string' ? promptText : '';
95
+ const value = extractValue(prompt, CONTINUATION_INPUT.label);
96
+ if (isPlaceholder(value)) return null;
97
+
98
+ for (const separator of [/\s+—\s+/, /\s+-\s+/, /\s*:\s*/]) {
99
+ const match = value.match(new RegExp(`^(.+?)${separator.source}(.+)$`));
100
+ if (!match) continue;
101
+ const run = cleanValue(match[1]);
102
+ const reason = cleanValue(match[2]);
103
+ if (isPlaceholder(run) || isPlaceholder(reason)) return null;
104
+ return { run, reason };
105
+ }
106
+ return null;
107
+ }
108
+
80
109
  /** Returns the immutable input entries for one dispatcher type. */
81
110
  export function requiredInputsFor(agentType) {
82
111
  return REQUIRED_INPUTS[agentType] || [];
@@ -85,20 +114,34 @@ export function requiredInputsFor(agentType) {
85
114
  /** Returns required entries whose value is absent or is still an obvious template placeholder. */
86
115
  export function missingInputs(agentType, promptText) {
87
116
  const prompt = typeof promptText === 'string' ? promptText : '';
88
- return requiredInputsFor(agentType).filter((entry) => isPlaceholder(extractValue(prompt, entry.label)));
117
+ return requiredInputsFor(agentType).filter(
118
+ (entry) => !entry.conditional && isPlaceholder(extractValue(prompt, entry.label)),
119
+ );
89
120
  }
90
121
 
91
122
  /** Renders the compact contract the orchestrator sees while choosing a dispatcher. */
92
123
  export function renderRequiredInputSummary(agentType) {
93
124
  const entries = requiredInputsFor(agentType);
94
125
  if (!entries.length) return '';
95
- const labels = entries.map((entry) => `\`${entry.label}\``);
96
- return `Requires ${entries[0].source}-provided ${labels.join(' and ')}.`;
126
+ const labels = entries.map((entry) => {
127
+ const label = `\`${entry.label}\``;
128
+ return entry.conditional ? `${label} (${entry.conditional})` : label;
129
+ });
130
+ // Commas until the last pair: the list grew to three entries when the continuation grant
131
+ // joined it, and "a and b and c" reads as a stutter in the description the orchestrator
132
+ // sees while choosing a dispatcher.
133
+ const rendered = labels.length > 1
134
+ ? `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`
135
+ : labels[0];
136
+ return `Requires ${entries[0].source}-provided ${rendered}.`;
97
137
  }
98
138
 
99
139
  /** Renders the same contract for pass 2 agent instructions. */
100
140
  export function renderRequiredInputs(agentType) {
101
141
  return requiredInputsFor(agentType)
102
- .map((entry) => `- ${entry.label}: ${entry.explanation} Example: \`${entry.example}\`.`)
142
+ .map((entry) => {
143
+ const condition = entry.conditional ? ` Condition: ${entry.conditional}.` : '';
144
+ return `- ${entry.label}: ${entry.explanation} Example: \`${entry.example}\`.${condition}`;
145
+ })
103
146
  .join('\n');
104
147
  }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Owns the zero-quota decision about whether a requested continuation was ordered.
3
+ *
4
+ * The 2026-08-05 incident showed why the runner must not infer a second pass from a verdict:
5
+ * only the orchestrator read that verdict and can name the run and reason it is willing to fund.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { CONTINUATION_INPUT, parseContinuationGrant } from '../required-inputs.mjs';
10
+ import { readJson } from '../write-meta.mjs';
11
+
12
+ const sameRun = (runsRootPath, left, right) => {
13
+ const a = path.resolve(runsRootPath, left);
14
+ const b = path.resolve(runsRootPath, right);
15
+ return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
16
+ };
17
+
18
+ function namedRunDirectory(runsRootPath, run) {
19
+ if (!run || run === '.' || run === '..' || path.basename(run) !== run) return null;
20
+ const directory = path.join(runsRootPath, run);
21
+ try {
22
+ return fs.statSync(directory).isDirectory() ? directory : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function normalizeGrant(grant) {
29
+ if (typeof grant === 'string') return parseContinuationGrant(grant);
30
+ if (!grant || typeof grant !== 'object') return null;
31
+ const run = String(grant.run ?? '').trim();
32
+ const reason = String(grant.reason ?? '').trim();
33
+ return run && reason ? { run, reason } : null;
34
+ }
35
+
36
+ const grantExample = `Example: \`continue: ${CONTINUATION_INPUT.example}\`.`;
37
+ const grantAction =
38
+ 'Action: ask the orchestrator for an explicit grant naming the run and reason; do not invent or reuse a continuation.';
39
+
40
+ /**
41
+ * Applies the one-continuation limit and all continuation safety gates before a folder is created.
42
+ *
43
+ * The limit is counted over the runs carrying THIS order id, not over the whole chain. The chain
44
+ * also ties runs together by slug and by the fingerprint of the task text, which is what catches a
45
+ * repeat that renamed itself — but counting continuations that way makes the escape hatch
46
+ * unreachable: the operator's rule is "more passes than one need a new order id", and a new order
47
+ * id lands in the same chain through the task hash. A task would then be refused with --continue
48
+ * for having spent its continuation and refused without it for already having runs — permanently
49
+ * unrunnable, which is a worse failure than the retry storm this limit exists to stop.
50
+ */
51
+ export function continuationRefusal(runsRootPath, chain, isContinue, orderId, grant) {
52
+ if (!isContinue) return null;
53
+
54
+ const continuation = normalizeGrant(grant);
55
+ if (!continuation) {
56
+ return (
57
+ '--continue is refused: the orchestrator did not provide a `continue:` grant in the task text. ' +
58
+ `${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
59
+ );
60
+ }
61
+
62
+ const namedDirectory = namedRunDirectory(runsRootPath, continuation.run);
63
+ if (!namedDirectory) {
64
+ return (
65
+ `--continue is refused: the orchestrator named run “${continuation.run}”, but it does not ` +
66
+ `exist as a run folder in this project's runs directory ${runsRootPath}. ` +
67
+ `${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
68
+ );
69
+ }
70
+
71
+ const last = chain[chain.length - 1];
72
+ // Continuing the last run appends a later run, so the old grant stops matching by itself; this
73
+ // incident needs no counter or new state to make an orchestrator grant single-use.
74
+ if (!last || !sameRun(runsRootPath, continuation.run, last)) {
75
+ return (
76
+ `--continue is refused: grant ${continuation.run} is not the LAST run of this task's chain; ` +
77
+ `the current last run is ${last || 'none'}. A continuation is single-use: continuing the ` +
78
+ 'last run appends a later run, so the old grant stops matching by itself — no counter or new ' +
79
+ `state is used. ${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
80
+ );
81
+ }
82
+
83
+ const wanted = String(orderId ?? '').trim();
84
+ const ofThisOrder = chain.filter(
85
+ (run) => String(readJson(path.join(runsRootPath, run, 'status.json'))?.order_id ?? '').trim() === wanted,
86
+ );
87
+ if (ofThisOrder.length === 0) return null;
88
+ if (ofThisOrder.length > 1) {
89
+ const spent = ofThisOrder.map((run) => path.join(runsRootPath, run)).join(', ');
90
+ return (
91
+ `--continue is refused: order “${wanted}” already spent its allowed continuation on ${spent}. ` +
92
+ 'A further pass needs a new order id from the orchestrator. The run folder was not ' +
93
+ 'created; quota was not spent.'
94
+ );
95
+ }
96
+ const previous = path.join(runsRootPath, ofThisOrder[0]);
97
+ const status = readJson(path.join(previous, 'status.json'));
98
+ const meta = readJson(path.join(previous, 'meta.json'));
99
+ if (!meta?.status || status?.state === 'running') {
100
+ return (
101
+ `--continue is refused: previous run ${previous} has no finished verdict and may still ` +
102
+ 'be editing the worktree. Repeat without --continue to attach to it. The run folder was ' +
103
+ 'not created; quota was not spent.'
104
+ );
105
+ }
106
+ return null;
107
+ }
@@ -28,6 +28,8 @@ import { attach } from './attach.mjs';
28
28
  import { setRun } from './run-context.mjs';
29
29
  import { loadRunEnv, RUN_ENV } from './run-env.mjs';
30
30
  import { parseArgs, die } from './args.mjs';
31
+ import { parseContinuationGrant } from '../required-inputs.mjs';
32
+ import { continuationRefusal } from './continuation.mjs';
31
33
  import { SCHEMAS } from './schemas.mjs';
32
34
  import { INSTRUCTIONS } from './prompts.mjs';
33
35
  import { git, headSha, branchName, worktreeSnapshot, reviewScope } from './git-state.mjs';
@@ -114,6 +116,7 @@ export async function launcher() {
114
116
  // like --scope.
115
117
  const taskHash = taskFingerprint(taskText);
116
118
  const chain = chainRuns(projectRunsRoot, repoRoot, opts.slug, taskHash, opts.orderId);
119
+ const continuationGrant = opts.continue ? parseContinuationGrant(taskText) : null;
117
120
 
118
121
  // One order produced six Codex runs on 2026-08-03 because the caller's time ceiling made it
119
122
  // restart the synchronous launcher. A live same-order run is now the repeat target: attach
@@ -132,7 +135,13 @@ export async function launcher() {
132
135
  // markAbandoned() ran before this gate, so a dead runner already has meta.json with FAIL.
133
136
  // A run without a verdict can therefore only still be in flight; ordinary repeats go through
134
137
  // attach(), while --continue must refuse to overlap that live work.
135
- const continuationError = continuationRefusal(projectRunsRoot, chain, opts.continue, opts.orderId);
138
+ const continuationError = continuationRefusal(
139
+ projectRunsRoot,
140
+ chain,
141
+ opts.continue,
142
+ opts.orderId,
143
+ continuationGrant,
144
+ );
136
145
  if (continuationError) die(continuationError);
137
146
 
138
147
  if (chain.length && !opts.continue) {
@@ -183,6 +192,8 @@ export async function launcher() {
183
192
  // Which run of this task started the chain — the base every later pass is measured
184
193
  // against. Absent means this is the first pass.
185
194
  ...(chain.length ? { continues: chain[0] } : {}),
195
+ // `continued_from` is the exact run the orchestrator named; `continues` above remains the chain base.
196
+ ...(continuationGrant ? { continued_from: continuationGrant.run } : {}),
186
197
  });
187
198
 
188
199
  // Printed before anything can go wrong: even a dispatcher that dies mid-run leaves the
@@ -333,42 +344,3 @@ export async function launcher() {
333
344
  );
334
345
  process.exit(0);
335
346
  }
336
-
337
- /**
338
- * Applies the one-continuation limit and the in-flight safety gate before a folder is created.
339
- *
340
- * The limit is counted over the runs carrying THIS order id, not over the whole chain. The chain
341
- * also ties runs together by slug and by the fingerprint of the task text, which is what catches a
342
- * repeat that renamed itself — but counting continuations that way makes the escape hatch
343
- * unreachable: the operator's rule is "more passes than one need a new order id", and a new order
344
- * id lands in the same chain through the task hash. A task would then be refused with --continue
345
- * for having spent its continuation and refused without it for already having runs — permanently
346
- * unrunnable, which is a worse failure than the retry storm this limit exists to stop.
347
- */
348
- export function continuationRefusal(runsRootPath, chain, isContinue, orderId) {
349
- if (!isContinue || chain.length === 0) return null;
350
- const wanted = String(orderId ?? '').trim();
351
- const ofThisOrder = chain.filter(
352
- (run) => String(readJson(path.join(runsRootPath, run, 'status.json'))?.order_id ?? '').trim() === wanted,
353
- );
354
- if (ofThisOrder.length === 0) return null;
355
- if (ofThisOrder.length > 1) {
356
- const spent = ofThisOrder.map((run) => path.join(runsRootPath, run)).join(', ');
357
- return (
358
- `--continue is refused: order “${wanted}” already spent its allowed continuation on ${spent}. ` +
359
- 'A further pass needs a new order id from the orchestrator. The run folder was not ' +
360
- 'created; quota was not spent.'
361
- );
362
- }
363
- const previous = path.join(runsRootPath, ofThisOrder[0]);
364
- const status = readJson(path.join(previous, 'status.json'));
365
- const meta = readJson(path.join(previous, 'meta.json'));
366
- if (!meta?.status || status?.state === 'running') {
367
- return (
368
- `--continue is refused: previous run ${previous} has no finished verdict and may still ` +
369
- 'be editing the worktree. Repeat without --continue to attach to it. The run folder was ' +
370
- 'not created; quota was not spent.'
371
- );
372
- }
373
- return null;
374
- }