@lyupro/codex-bridge 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +88 -84
  3. package/bin/codex-bridge.mjs +38 -11
  4. package/cli/copy.mjs +7 -6
  5. package/cli/doctor.mjs +73 -12
  6. package/cli/hook-targets.mjs +39 -0
  7. package/cli/hook.mjs +34 -0
  8. package/cli/hosts.mjs +28 -4
  9. package/cli/install-record.mjs +287 -0
  10. package/cli/install.mjs +73 -39
  11. package/cli/invoked-directly.mjs +37 -0
  12. package/cli/manifest.mjs +90 -164
  13. package/cli/permissions.mjs +179 -0
  14. package/cli/projects.mjs +20 -5
  15. package/cli/prune-args.mjs +1 -1
  16. package/cli/remove-layout.mjs +59 -0
  17. package/cli/rules-owners.mjs +6 -8
  18. package/cli/runs-inventory.mjs +2 -1
  19. package/cli/settings-merge.mjs +182 -50
  20. package/cli/stop.mjs +33 -20
  21. package/cli/uninstall.mjs +64 -36
  22. package/cli/unlock.mjs +239 -0
  23. package/cli/update.mjs +109 -48
  24. package/package.json +2 -2
  25. package/scripts/check-file-size.mjs +8 -10
  26. package/scripts/isolated-roots.mjs +13 -0
  27. package/scripts/run-tests.mjs +14 -4
  28. package/src/agents/codex-build.md +17 -13
  29. package/src/agents/codex-review.md +15 -9
  30. package/src/agents/codex-scout.md +16 -11
  31. package/src/commands/env.md +4 -4
  32. package/src/commands/usage.md +8 -6
  33. package/src/heartbeat.mjs +1 -1
  34. package/src/hook-definitions.mjs +17 -4
  35. package/src/hooks/guard-tries.mjs +59 -0
  36. package/src/hooks/live-runs.mjs +31 -20
  37. package/src/hooks/order-gate.mjs +7 -2
  38. package/src/hooks/reply-guard.mjs +4 -60
  39. package/src/hooks/stop-guard.mjs +71 -0
  40. package/src/json-file.mjs +30 -0
  41. package/src/meta/chain.mjs +8 -6
  42. package/src/meta/paths.mjs +5 -9
  43. package/src/meta/pre-start.mjs +30 -0
  44. package/src/meta/run-state.mjs +31 -16
  45. package/src/process-identity.mjs +140 -0
  46. package/src/required-inputs.mjs +42 -0
  47. package/src/run-config.mjs +4 -8
  48. package/src/runner/args.mjs +26 -3
  49. package/src/runner/attach.mjs +32 -19
  50. package/src/runner/codex-cmd.mjs +1 -1
  51. package/src/runner/continuation.mjs +34 -3
  52. package/src/runner/launcher.mjs +54 -28
  53. package/src/runner/project-dir.mjs +4 -3
  54. package/src/runner/scope-check.mjs +159 -0
  55. package/src/runner/worker.mjs +13 -5
  56. package/src/stop-contract.mjs +15 -0
  57. package/src/write-meta.mjs +12 -1
  58. package/cli/sweep.mjs +0 -144
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: codex-build
3
- description: Имплементация ВНЕ подписки Claude — правки кода, новые модули, рефакторинг по образцу, починка тестов. Работу выполняет Codex CLI (подписка ChatGPT) с правом записи в репозиторий строго в пределах обязательного `scope`, Claude платит только за постановку задачи и ≤5 строк выжимки. Отчёт и полный лог кладёт в ~/.claude/codex-runs/, в чат — короткая выжимка + путь. Ревью и приёмку делает отдельный агент Claude, не этот. {{CODEX_REQUIRED_INPUTS_SUMMARY}}
3
+ description: Имплементация ВНЕ подписки Claude — правки кода, новые модули, рефакторинг по образцу, починка тестов. Работу выполняет Codex CLI (подписка ChatGPT) с правом записи в репозиторий строго в пределах обязательного `scope`, Claude платит только за постановку задачи и ≤5 строк выжимки. Отчёт и полный лог кладёт в ~/.claude/codex-runs/, в чат — короткая выжимка + путь. Ревью и приёмку делает отдельный агент Claude, не этот. {{CODEX_REQUIRED_INPUTS_SUMMARY}} {{CODEX_STOP_SUMMARY}}
4
4
  model: haiku
5
5
  tools: Bash
6
6
  ---
@@ -28,20 +28,22 @@ job is to report the run status honestly, including failure.
28
28
 
29
29
  - The task statement: what to change and the completion criteria.
30
30
  - The path to the repository. If none is given, use the current working directory.
31
+ - Scope patterns are globs relative to the repository root. A pattern that matches nothing there is
32
+ refused before the run starts; a file this task is meant to create is declared with `--scope-new`.
31
33
  - Every input listed under **Required dispatcher inputs** above, passed on exactly as given:
32
34
  `order id` as `--order-id`, `scope` as `--scope`. Never invent a value, never edit one, never
33
35
  reuse an order id from another order — the runner chains runs by that label, and a made-up label
34
36
  is how a repeat run hides. If the orchestrator did not give a required input, do not guess: start
35
37
  the runner without its flag and return the runner's refusal exactly.
36
- - `continue` — pass the `--continue` flag only when the orchestrator gave the matching `continue:`
37
- grant explicitly; do not add or guess it yourself. A continuation is assigned by the
38
- orchestrator, never chosen by you. After the verdict, return the exact attaching output and stop;
39
- do not issue or invent another continuation. The contract is the same kind as `--scope`: if this
40
- task (the same `slug` and the same repository) already had a run, the runner will refuse to start
41
- without `--continue` — before starting Codex, so no quota is spent. If the orchestrator did not
42
- grant a repeat, return the runner's refusal exactly.
43
- - Optional: `effort: <none|minimal|low|medium|high|xhigh|max>`, `slug:`, `verify: <verification
44
- command>` (for example `npm test`, `tsc --noEmit`).
38
+ - `continue` — when the task text contains a line beginning with the `continue:` label, pass the
39
+ bare `--continue` flag even when its run name or reason looks malformed. Do not inspect, repair,
40
+ or swallow this grant; pass it through and let the runner issue the refusal. A continuation is
41
+ assigned by the orchestrator, never chosen by you. After the verdict, return the exact attaching
42
+ output and stop; do not issue or invent another continuation. If no such grant line is present,
43
+ the flag must not be present.
44
+ - Optional: `effort: <none|minimal|low|medium|high|xhigh|max>`, `slug:` (by default, the slug is
45
+ taken from the order id), `verify: <verification command>` (for example `npm test`,
46
+ `tsc --noEmit`).
45
47
 
46
48
  ## The only thing you do
47
49
 
@@ -59,8 +61,10 @@ Add `--effort "<value>"` only when the orchestrator named a depth, and only with
59
61
  decides, which is the intended default — a placeholder copied from this template is refused before
60
62
  Codex starts.
61
63
 
62
- If the orchestrator gave the matching `continue:` grant, add the bare `--continue` flag to the
63
- command, with no value.
64
+ If the task text contains a line beginning with the `continue:` label, add the bare `--continue`
65
+ flag even when its run name or reason looks malformed. Do not inspect, repair, or swallow the line;
66
+ the runner parses it and issues the refusal. If no such grant line is present, the flag must not be
67
+ present in the command.
64
68
  No value
65
69
  is passed to it: the runner accepts only `1/true/yes/0/false/no`, while a placeholder string left in
66
70
  the
@@ -130,7 +134,7 @@ are designed for interactive work and cause harm in a delegated run — a failin
130
134
  and the task
131
135
  was not done; without them, 23k and the task was done. The flags affect one call; `~/.codex` is
132
136
  unchanged.
133
- The operator switches the mode through `/codex:env` (file `agents/codex/run-config.json`), and it
137
+ The operator switches the mode through `/codex-bridge:env` (file `~/.lyupro/.codex-bridge/config.json`), and it
134
138
  is recorded in the run's `meta.json` — do not guess it or add flags yourself.
135
139
  `--dangerously-bypass-approvals-and-sandbox` is never used. `--model` is not
136
140
  passed: model IDs are volatile. The script fixes this once, so silent
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: codex-review
3
- description: Независимое второе мнение по коду от другой модели — ревью незакоммиченных правок, ветки или коммита силами Codex CLI (подписка ChatGPT). Не заменяет приёмку на стороне Claude, а дополняет её взглядом со стороны. Выдаёт строгий JSON с severity и confidence по каждой находке, в чат — ≤5 строк со счётчиком по важности. {{CODEX_REQUIRED_INPUTS_SUMMARY}}
3
+ description: Независимое второе мнение по коду от другой модели — ревью незакоммиченных правок, ветки или коммита силами Codex CLI (подписка ChatGPT). Не заменяет приёмку на стороне Claude, а дополняет её взглядом со стороны. Выдаёт строгий JSON с severity и confidence по каждой находке, в чат — ≤5 строк со счётчиком по важности. {{CODEX_REQUIRED_INPUTS_SUMMARY}} {{CODEX_STOP_SUMMARY}}
4
4
  model: haiku
5
5
  tools: Bash
6
6
  ---
@@ -27,17 +27,21 @@ opinion, not a verdict.
27
27
  - branch against base — `--mode base:<branch>`;
28
28
  - specific commit — `--mode commit:<sha>`.
29
29
  - The path to the repository. If none is given, use the current working directory.
30
+ - Scope patterns are globs relative to the repository root. A pattern that matches nothing there is
31
+ refused before the run starts.
30
32
  - Every input listed under **Required dispatcher inputs** above, passed on exactly as given:
31
33
  `order id` as `--order-id`. Never invent a value, never edit one, never reuse an order id from
32
34
  another order — the runner chains runs by that label, and a made-up label is how a repeat run
33
35
  hides. If the orchestrator did not give a required input, do not guess — start the runner without
34
36
  its flag and return the runner's refusal verbatim.
35
- - `continue` is conditional — pass the `--continue` flag only when the orchestrator gave the
36
- matching `continue:` grant explicitly; do not add or guess it yourself. A continuation is
37
+ - `continue` — when the task text contains a line beginning with the `continue:` label, pass the
38
+ bare `--continue` flag even when its run name or reason looks malformed. Do not inspect, repair,
39
+ or swallow this grant; pass it through and let the runner issue the refusal. A continuation is
37
40
  assigned by the orchestrator, never chosen by you. After the verdict, return the exact attaching
38
- output and stop; do not issue or invent another continuation.
39
- - Optional: review focus as text ("look for races and error handling"), `slug:`, `effort:
40
- <none|minimal|low|medium|high|xhigh|max>`.
41
+ output and stop; do not issue or invent another continuation. If no such grant line is present,
42
+ the flag must not be present.
43
+ - Optional: review focus as text ("look for races and error handling"), `slug:` (by default, the
44
+ slug is taken from the order id), `effort: <none|minimal|low|medium|high|xhigh|max>`.
41
45
 
42
46
  ## The only thing you do
43
47
 
@@ -59,8 +63,10 @@ line. To get the verdict, run the **identical command a second time** — same `
59
63
  `--slug`, same flags. That second call does not start a second run and costs no quota: it attaches
60
64
  to the run already in flight, prints `ATTACH=<path>`, blocks until the verdict exists and prints it.
61
65
 
62
- If the orchestrator gave the matching `continue:` grant, add the bare `--continue` flag to the
63
- command, with no value. If it was not given, the flag must not be present in the command at all.
66
+ If the task text contains a line beginning with the `continue:` label, add the bare `--continue`
67
+ flag even when its run name or reason looks malformed. Do not inspect, repair, or swallow the line;
68
+ the runner parses it and issues the refusal. If no such grant line is present, the flag must not be
69
+ present in the command at all.
64
70
 
65
71
  Background execution (`run_in_background`, `&`, `nohup`) is prohibited. Interruption is no longer a
66
72
  problem worth handling: if the attaching call is killed by a time ceiling, repeat it — every repeat
@@ -112,7 +118,7 @@ properties that broke the contract.
112
118
  Regular `codex exec` follows the schema (verified during scouting), so the review uses it: with
113
119
  `--ignore-user-config` (structurally read-only, half the startup ballast), `--sandbox
114
120
  read-only`, `--disable hooks --disable plugins` (operator extensions do not affect the run;
115
- the default, switched through `/codex:env`), and without `--model` — model IDs are volatile. The
121
+ the default, switched through `/codex-bridge:env`), and without `--model` — model IDs are volatile. The
116
122
  runner determines the review scope
117
123
  itself through git: the exact file list and diff command go into the prompt and are saved in the run
118
124
  folder's
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: codex-scout
3
- description: Разведка и диагностика ВНЕ подписки Claude — исследование кодовой базы, поиск причины бага, ревью, сбор фактов. Работу выполняет Codex CLI (подписка ChatGPT), Claude платит только за постановку задачи и ≤5 строк выжимки. Строго read-only: писать в репозиторий физически не может. Подробный отчёт кладёт в ~/.claude/codex-runs/, в чат — короткая выжимка + путь. {{CODEX_REQUIRED_INPUTS_SUMMARY}}
3
+ description: Разведка и диагностика ВНЕ подписки Claude — исследование кодовой базы, поиск причины бага, ревью, сбор фактов. Работу выполняет Codex CLI (подписка ChatGPT), Claude платит только за постановку задачи и ≤5 строк выжимки. Строго read-only: писать в репозиторий физически не может. Подробный отчёт кладёт в ~/.claude/codex-runs/, в чат — короткая выжимка + путь. {{CODEX_REQUIRED_INPUTS_SUMMARY}} {{CODEX_STOP_SUMMARY}}
4
4
  model: haiku
5
5
  tools: Bash
6
6
  ---
@@ -23,8 +23,11 @@ files, do not run grep, do not retell the report, and do not reason about the ta
23
23
 
24
24
  - The task statement (what to find out / what to diagnose / what to review).
25
25
  - The path to the repository. If none is given, work in the current working directory.
26
+ - Scope patterns are globs relative to the repository root. A pattern that matches nothing there is
27
+ refused before the run starts.
26
28
  - Optional: `effort: <none|minimal|low|medium|high|xhigh|max>` — Codex reasoning depth.
27
- - Optional: `slug: <short-name>` for the run folder.
29
+ - Optional: `slug: <short-name>` for the run folder; by default, the slug is taken from the order
30
+ id.
28
31
  - Every input listed under **Required dispatcher inputs** above, passed on exactly as given:
29
32
  `order id` as `--order-id`. Never invent a value, never edit one, never reuse an order id from
30
33
  another order — the runner chains runs by that label, and a made-up label is how a repeat run
@@ -34,12 +37,12 @@ files, do not run grep, do not retell the report, and do not reason about the ta
34
37
  pass one `--question "<text>"` flag exactly as given. Never invent, merge, reword, or drop a
35
38
  sub-question. If the orchestrator did not give one, do not guess — start the runner without
36
39
  the flag and return its refusal verbatim.
37
- - `continue` — pass the `--continue` flag only when the orchestrator gave the matching `continue:`
38
- grant explicitly; do not add or guess it yourself. A continuation is assigned by the
39
- orchestrator, never chosen by you. After the verdict, return the exact attaching output and stop;
40
- do not issue or invent another continuation. The contract is strict, like `--scope` in
41
- codex-build: if this task (the same `slug` and the same repository) already had a run, the runner
42
- will refuse to start without `--continue` — before starting Codex, so no quota is spent.
40
+ - `continue` — when the task text contains a line beginning with the `continue:` label, pass the
41
+ bare `--continue` flag even when its run name or reason looks malformed. Do not inspect, repair,
42
+ or swallow this grant; pass it through and let the runner issue the refusal. A continuation is
43
+ assigned by the orchestrator, never chosen by you. After the verdict, return the exact attaching
44
+ output and stop; do not issue or invent another continuation. If no such grant line is present,
45
+ the flag must not be present.
43
46
 
44
47
  ## The only thing you do
45
48
 
@@ -60,8 +63,10 @@ Add `--effort "<value>"` only when the orchestrator named a depth, and only with
60
63
  decides, which is the intended default — a placeholder copied from this template is refused before
61
64
  Codex starts.
62
65
 
63
- If the orchestrator gave the matching `continue:` grant, add the bare `--continue` flag to the
64
- command, with no value.
66
+ If the task text contains a line beginning with the `continue:` label, add the bare `--continue`
67
+ flag even when its run name or reason looks malformed. Do not inspect, repair, or swallow the line;
68
+ the runner parses it and issues the refusal. If no such grant line is present, the flag must not be
69
+ present in the command.
65
70
  No value
66
71
  is passed to it: the runner accepts only `1/true/yes/0/false/no`, while a placeholder string left in
67
72
  the
@@ -117,7 +122,7 @@ The flags are proven, and they do not belong in the prompt: `--ignore-user-confi
117
122
  startup ballast (~9k quota instead of ~19k) and structurally blocks writing — no flags can override
118
123
  read-only; `--disable hooks --disable plugins` disable the operator's extensions for this call,
119
124
  so the run does not depend on what is installed in `~/.codex` today (the default, switched
120
- by the operator through `/codex:env`); `--model` is never passed because model IDs
125
+ by the operator through `/codex-bridge:env`); `--model` is never passed because model IDs
121
126
  are volatile.
122
127
  The run is asynchronous, and waiting for it is a separate call. That is deliberate: while the start
123
128
  and the wait were one call, a time ceiling on that call looked exactly like a dead run, and the
@@ -4,8 +4,8 @@ allowed-tools: Bash
4
4
  argument-hint: "[hooks|plugins on|off] | [reset]"
5
5
  ---
6
6
 
7
- <!-- Part of the agents/codex/ package. The file lives here out of necessity: the slash command name
8
- is set by its location under commands/<namespace>/, and Claude Code understands neither
7
+ <!-- Part of the agents/codex-bridge/ package. The file lives here out of necessity: the slash
8
+ command name is set by its location under commands/<namespace>/, and Claude Code understands neither
9
9
  symlinks nor pointer files (verified 2026-08-02). Edit it here; it travels as a copy when the
10
10
  package is installed. -->
11
11
 
@@ -30,8 +30,8 @@ Call forms:
30
30
  - `plugins on` / `plugins off` — the same for plugins;
31
31
  - `reset` — back to the default (both off).
32
32
 
33
- The state lives in `agents/codex/run-config.json` and lands in the `meta.json` of every run, so the
34
- artifacts always show which environment a run went through.
33
+ The state lives in `~/.lyupro/.codex-bridge/config.json` and lands in the `meta.json` of every run,
34
+ so the artifacts always show which environment a run went through.
35
35
 
36
36
  If the user turns `hooks` on, say in one line: while the failing `Stop` hook of `oh-my-codex` stays
37
37
  in `~/.codex/hooks.json`, runs will again spend quota on unrelated repairs. Checking the report
@@ -3,8 +3,8 @@ description: Show the ChatGPT quota spent on runs delegated to Codex — by day,
3
3
  allowed-tools: Bash
4
4
  ---
5
5
 
6
- <!-- Part of the agents/codex/ package. The file lives here out of necessity: the slash command name
7
- is set by its location under commands/<namespace>/, and Claude Code understands neither
6
+ <!-- Part of the agents/codex-bridge/ package. The file lives here out of necessity: the slash
7
+ command name is set by its location under commands/<namespace>/, and Claude Code understands neither
8
8
  symlinks nor pointer files (verified 2026-08-02). Edit it here; it travels as a copy when the
9
9
  package is installed. -->
10
10
 
@@ -44,14 +44,14 @@ for(const proj of fs.readdirSync(root)){
44
44
  try{repo=JSON.parse(fs.readFileSync(path.join(pdir,'.project.json'),'utf8')).repo||null}catch{}
45
45
  for(const run of fs.readdirSync(pdir)){
46
46
  const dir=path.join(pdir,run),meta=path.join(dir,'meta.json'),log=path.join(dir,'raw.log');
47
- let tokens=null,agent=null,sandbox=null;
47
+ let tokens=null,agent=null,sandbox=null,runnerVersion=false;
48
48
  if(fs.existsSync(meta)){
49
- try{const m=JSON.parse(fs.readFileSync(meta,'utf8'));tokens=m.tokens??null;agent=m.agent||null;sandbox=m.sandbox||null}catch{}
49
+ try{const m=JSON.parse(fs.readFileSync(meta,'utf8'));tokens=m.tokens??null;agent=m.agent||null;sandbox=m.sandbox||null;runnerVersion=Object.hasOwn(m,'runner_version')}catch{}
50
50
  } else if(fs.existsSync(log)){
51
51
  const hit=[...fs.readFileSync(log,'utf8').matchAll(/tokens used[\r\n]+([^\r\n]+)/g)].pop();
52
52
  tokens=hit?parseInt(hit[1].replace(/\D/g,''),10)||null:null;
53
53
  } else continue;
54
- const entry={proj,repo,run,day:run.slice(0,10),agent:agent||(/review/.test(run)?'codex-review':/build/.test(run)?'codex-build':'codex-scout'),sandbox};
54
+ const entry={proj,repo,run,day:run.slice(0,10),agent:agent||(/review/.test(run)?'codex-review':/build/.test(run)?'codex-build':'codex-scout'),sandbox,runnerVersion};
55
55
  if(tokens===null){unknown.push(entry);continue}
56
56
  runs.push({...entry,tokens});
57
57
  }
@@ -63,7 +63,9 @@ const fmt=n=>n.toLocaleString('en-US');
63
63
  const row=(l,rs)=>' '+String(l).padEnd(22)+String(rs.length).padStart(3)+' runs '+fmt(sum(rs)).padStart(11)+' average '+fmt(Math.round(sum(rs)/rs.length));
64
64
  if(runs.length)console.log('TOTAL: '+fmt(sum(runs))+' tokens over '+runs.length+' runs · average '+fmt(Math.round(sum(runs)/runs.length)));
65
65
  if(unknown.length)console.log('Unaccounted: '+unknown.length+' runs (Codex reported no spending) — the total is understated');
66
- const risky=[...runs,...unknown].filter(r=>r.sandbox&&r.sandbox!=='read-only'&&r.sandbox!=='workspace-write');
66
+ const legacy=[...runs,...unknown].filter(r=>!r.runnerVersion);
67
+ if(legacy.length)console.log('Before the runner: '+legacy.length+' runs carry no runner version and are not judged by the sandbox contract');
68
+ const risky=[...runs,...unknown].filter(r=>r.runnerVersion&&r.sandbox&&r.sandbox!=='read-only'&&r.sandbox!=='workspace-write');
67
69
  if(risky.length)console.log('WARNING: '+risky.length+' runs went outside the usual sandbox: '+risky.map(r=>r.run+' ('+r.sandbox+')').join(', '));
68
70
  if(runs.length){
69
71
  console.log('');console.log('By day:');
package/src/heartbeat.mjs CHANGED
@@ -51,7 +51,7 @@ export function heartbeatAge(runDir, now = Date.now()) {
51
51
 
52
52
  /**
53
53
  * How long a run has been silent, in words. It lives beside the age it formats because both the
54
- * worktree lock's refusal and `sweep`'s report have to name the same silence to the same operator;
54
+ * worktree lock's refusal and `unlock`'s report have to name the same silence to the same operator;
55
55
  * two copies of this drifted apart the moment they were written.
56
56
  */
57
57
  export function formatSilence(age) {
@@ -39,9 +39,22 @@ export const SHELL_TOOLS = Object.freeze(['Bash', 'PowerShell']);
39
39
  /** A PreToolUse matcher is a regular expression over tool names, so alternation covers both. */
40
40
  export const SHELL_TOOL_MATCHER = SHELL_TOOLS.join('|');
41
41
 
42
+ // Plan_31 routes host stop requests through the package guard; keep the matcher derived from
43
+ // the tool list so registration and uninstall cannot drift from the host event spelling.
44
+ export const STOP_TOOLS = Object.freeze(['TaskStop']);
45
+
46
+ /** A PreToolUse matcher is a regular expression over tool names, so the list remains authoritative. */
47
+ export const STOP_TOOL_MATCHER = STOP_TOOLS.join('|');
48
+
49
+ /**
50
+ * Stable CLI names live beside each event and file because the Plan_19 drift incident left
51
+ * registration, diagnostics, and the guard itself carrying different hook identities. The
52
+ * dispatcher must consume this one list instead of guessing a name from a path at its call site.
53
+ */
42
54
  export const HOOK_DEFINITIONS = Object.freeze([
43
- Object.freeze({ event: 'SubagentStop', matcher: '*', file: 'reply-guard.mjs' }),
44
- Object.freeze({ event: 'PreToolUse', matcher: SUBAGENT_TOOL_MATCHER, file: 'order-gate.mjs' }),
45
- Object.freeze({ event: 'PreToolUse', matcher: WRITE_TOOL_MATCHER, file: 'worktree-lock.mjs' }),
46
- Object.freeze({ event: 'PreToolUse', matcher: SHELL_TOOL_MATCHER, file: 'prune-guard.mjs' }),
55
+ Object.freeze({ name: 'reply-guard', event: 'SubagentStop', matcher: '*', file: 'reply-guard.mjs' }),
56
+ Object.freeze({ name: 'order-gate', event: 'PreToolUse', matcher: SUBAGENT_TOOL_MATCHER, file: 'order-gate.mjs' }),
57
+ Object.freeze({ name: 'worktree-lock', event: 'PreToolUse', matcher: WRITE_TOOL_MATCHER, file: 'worktree-lock.mjs' }),
58
+ Object.freeze({ name: 'prune-guard', event: 'PreToolUse', matcher: SHELL_TOOL_MATCHER, file: 'prune-guard.mjs' }),
59
+ Object.freeze({ name: 'stop-guard', event: 'PreToolUse', matcher: STOP_TOOL_MATCHER, file: 'stop-guard.mjs' }),
47
60
  ]);
@@ -0,0 +1,59 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { readJsonFileSync } from '../json-file.mjs';
5
+
6
+ const LOG_DIR = path.join(os.homedir(), '.claude', 'logs');
7
+ /**
8
+ * The file keeps its reply-guard name although two guards now share it: renaming would abandon the
9
+ * budgets already spent on hosts in the field, and a guard that forgets its refusals starts
10
+ * refusing the same call again.
11
+ */
12
+ export const BLOCKED_FILE = path.join(LOG_DIR, 'codex-reply-guard.blocked.json');
13
+ export const FORM = 'form';
14
+ export const STATE = 'state';
15
+ export const MAX_FORM_BLOCKS = 3;
16
+ export const MAX_STATE_BLOCKS = 3;
17
+
18
+ const wholeCount = (value) => (Number.isInteger(value) && value > 0 ? value : 0);
19
+
20
+ function priorCount(prior, kind) {
21
+ if (prior && typeof prior === 'object' && !Array.isArray(prior)) {
22
+ return wholeCount(prior[kind]);
23
+ }
24
+ if (kind === FORM && typeof prior === 'number') return wholeCount(prior);
25
+ if (kind === FORM) return prior ? 1 : 0;
26
+ return 0;
27
+ }
28
+
29
+ // Plan_31 reuses this persisted try budget so the TaskStop guard cannot grow a second,
30
+ // divergent counter beside reply-guard's existing per-agent escape hatch.
31
+ export function takeTry(agentId, kind, maxBlocks = kind === STATE ? MAX_STATE_BLOCKS : MAX_FORM_BLOCKS) {
32
+ if (!agentId) return 'untracked';
33
+
34
+ let seen;
35
+ try {
36
+ seen = readJsonFileSync(BLOCKED_FILE);
37
+ } catch {
38
+ seen = {};
39
+ }
40
+ if (!seen || typeof seen !== 'object' || Array.isArray(seen)) seen = {};
41
+
42
+ const current = priorCount(seen[agentId], kind);
43
+ if (current >= maxBlocks) return 'exhausted';
44
+
45
+ const prior = seen[agentId];
46
+ const next = prior && typeof prior === 'object' && !Array.isArray(prior) ? { ...prior } : {};
47
+ next[kind] = current + 1;
48
+ seen[agentId] = next;
49
+
50
+ const ids = Object.keys(seen);
51
+ if (ids.length > 200) ids.slice(0, ids.length - 200).forEach((id) => delete seen[id]);
52
+ try {
53
+ fs.mkdirSync(path.dirname(BLOCKED_FILE), { recursive: true });
54
+ fs.writeFileSync(BLOCKED_FILE, `${JSON.stringify(seen)}\n`);
55
+ } catch {
56
+ return 'untracked';
57
+ }
58
+ return 'granted';
59
+ }
@@ -2,8 +2,8 @@
2
2
  * Shared, fail-open knowledge of live Codex runs.
3
3
  *
4
4
  * The reply guard and the worktree lock must answer the same question: a status.json is live
5
- * only while it says `running`, its runner pid accepts signal 0, and its progress heartbeat is
6
- * fresh. Keeping those probes and the directory scan here prevents the two protections from
5
+ * only while it says `running`, its runner pid is judged alive for this run, and its progress
6
+ * heartbeat is fresh. Keeping those probes and the directory scan here prevents the two protections from
7
7
  * disagreeing about a run, which would recreate the 2026-08-06 incident where a dead Codex
8
8
  * child left its worker and lock looking live through a grandchild-held stdio pipe.
9
9
  *
@@ -13,17 +13,26 @@
13
13
  */
14
14
  import fs from 'node:fs';
15
15
  import path from 'node:path';
16
- import { isHeartbeatFresh } from '../heartbeat.mjs';
16
+ import { heartbeatAge, isHeartbeatFresh } from '../heartbeat.mjs';
17
+ import { readJsonFileSync } from '../json-file.mjs';
18
+ import {
19
+ IDENTITY_ALIVE,
20
+ IDENTITY_DEAD,
21
+ IDENTITY_FOREIGN,
22
+ processIdentity,
23
+ } from '../process-identity.mjs';
17
24
 
18
- /** Signal 0 tests existence without stopping the process; EPERM still means that it exists. */
19
- export const isPidAlive = (pid) => {
20
- if (typeof pid !== 'number') return false;
21
- try {
22
- process.kill(pid, 0);
23
- return true;
24
- } catch (err) {
25
- return err.code === 'EPERM';
26
- }
25
+ /**
26
+ * The hook requires pid judgment plus a fresh heartbeat. Identity uncertainty stays live here
27
+ * (fail-open), while the identity module deliberately treats a missing heartbeat as unverified;
28
+ * isHeartbeatFresh() keeps its pre-Plan_20 missing-file compatibility for this separate question.
29
+ */
30
+ // Plan_31 needs confirmed process identity before warning about paid work that TaskStop leaves
31
+ // behind; existing lock/reply guards retain the fail-open default for uncertain identities.
32
+ export const isPidAlive = (pid, runDir, status = {}, options = {}) => {
33
+ const identity = processIdentity({ runDir, status: { ...status, pid } });
34
+ if (options.requireConfirmedIdentity === true) return identity === IDENTITY_ALIVE;
35
+ return identity !== IDENTITY_DEAD && identity !== IDENTITY_FOREIGN;
27
36
  };
28
37
 
29
38
  /**
@@ -47,7 +56,9 @@ function isNonEmptyString(value) {
47
56
  return typeof value === 'string' && Boolean(value.trim());
48
57
  }
49
58
 
50
- function recognizedStatus(runDir, status) {
59
+ function recognizedStatus(runDir, status, options) {
60
+ const heartbeatFresh = isHeartbeatFresh(runDir)
61
+ && (options.requireConfirmedIdentity !== true || heartbeatAge(runDir) !== null);
51
62
  return Boolean(status)
52
63
  && typeof status === 'object'
53
64
  && !Array.isArray(status)
@@ -56,20 +67,20 @@ function recognizedStatus(runDir, status) {
56
67
  && isNonEmptyString(status.agent)
57
68
  && isNonEmptyString(status.slug)
58
69
  && isNonEmptyString(status.repo)
59
- && isPidAlive(status.pid)
60
- && isHeartbeatFresh(runDir);
70
+ && heartbeatFresh
71
+ && isPidAlive(status.pid, runDir, status, options);
61
72
  }
62
73
 
63
74
  function readStatus(runDir) {
64
75
  try {
65
- return JSON.parse(fs.readFileSync(path.join(runDir, 'status.json'), 'utf8'));
76
+ return readJsonFileSync(path.join(runDir, 'status.json'));
66
77
  } catch {
67
78
  return null;
68
79
  }
69
80
  }
70
81
 
71
82
  /** Scan one project's direct run folders; unreadable or unfamiliar entries are ignored. */
72
- export function liveRuns(runsDir) {
83
+ export function liveRuns(runsDir, options = {}) {
73
84
  if (typeof runsDir !== 'string' || !runsDir.trim()) return [];
74
85
  let entries;
75
86
  try {
@@ -82,13 +93,13 @@ export function liveRuns(runsDir) {
82
93
  if (!entry.isDirectory()) continue;
83
94
  const dir = path.join(runsDir, entry.name);
84
95
  const status = readStatus(dir);
85
- if (recognizedStatus(dir, status)) result.push({ dir, status });
96
+ if (recognizedStatus(dir, status, options)) result.push({ dir, status });
86
97
  }
87
98
  return result;
88
99
  }
89
100
 
90
101
  /** Scan every project under the configured runs root for worktree ownership. */
91
- export function allLiveRuns(runsRoot) {
102
+ export function allLiveRuns(runsRoot, options = {}) {
92
103
  if (typeof runsRoot !== 'string' || !runsRoot.trim()) return [];
93
104
  let projects;
94
105
  try {
@@ -99,7 +110,7 @@ export function allLiveRuns(runsRoot) {
99
110
  const result = [];
100
111
  for (const project of projects) {
101
112
  if (!project.isDirectory()) continue;
102
- result.push(...liveRuns(path.join(runsRoot, project.name)));
113
+ result.push(...liveRuns(path.join(runsRoot, project.name), options));
103
114
  }
104
115
  return result;
105
116
  }
@@ -14,7 +14,7 @@
14
14
  import fs from 'node:fs';
15
15
  import os from 'node:os';
16
16
  import path from 'node:path';
17
- import { missingInputs } from '../required-inputs.mjs';
17
+ import { diagnoseInput, missingInputs } from '../required-inputs.mjs';
18
18
  import { SUBAGENT_TOOLS } from '../hook-definitions.mjs';
19
19
 
20
20
  const HOME = os.homedir();
@@ -56,7 +56,12 @@ if (!missing.length) pass();
56
56
  const reason = [
57
57
  'Order gate denied the Agent call because required dispatcher input(s) are missing or still placeholders.',
58
58
  'Write each value in tool_input.prompt using `label: value` before launching the dispatcher:',
59
- ...missing.map((entry) => `- ${entry.label}: ${entry.explanation} Example: \`${entry.example}\`.`),
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
+ }),
60
65
  ].join('\n');
61
66
 
62
67
  process.stdout.write(JSON.stringify({
@@ -29,11 +29,12 @@
29
29
  import fs from 'node:fs';
30
30
  import os from 'node:os';
31
31
  import path from 'node:path';
32
+ import { readJsonFileSync } from '../json-file.mjs';
32
33
  import { isPidAlive, liveRuns, normalizePath } from './live-runs.mjs';
34
+ import { FORM, MAX_STATE_BLOCKS, STATE, takeTry } from './guard-tries.mjs';
33
35
 
34
36
  const HOME = os.homedir();
35
37
  const LOG_DIR = path.join(HOME, '.claude', 'logs');
36
- const BLOCKED_FILE = path.join(LOG_DIR, 'codex-reply-guard.blocked.json');
37
38
  const GUARDED = new Set(['codex-scout', 'codex-build', 'codex-review']);
38
39
  const STATUSES = ['OK', 'FAIL', 'LIMIT'];
39
40
 
@@ -47,8 +48,6 @@ const STATUSES = ['OK', 'FAIL', 'LIMIT'];
47
48
  * quota on retries that never converge. Three tries, then let it through so the operator
48
49
  * sees a reply with substance in it and judges for himself.
49
50
  */
50
- const MAX_FORM_BLOCKS = 3;
51
-
52
51
  /**
53
52
  * The same allowance for blocks caused by EXTERNAL state — the run is still going, or its
54
53
  * runner died. Counted separately because the two run out for unrelated reasons: three
@@ -60,11 +59,6 @@ const MAX_FORM_BLOCKS = 3;
60
59
  * orchestrator got a promise from a process that no longer existed and never learned the
61
60
  * worktree was busy.
62
61
  */
63
- const MAX_STATE_BLOCKS = 3;
64
-
65
- const FORM = 'form';
66
- const STATE = 'state';
67
-
68
62
  /** Never let a guard failure break real work: on any doubt, stay silent. */
69
63
  const pass = () => process.exit(0);
70
64
 
@@ -90,56 +84,6 @@ if (!GUARDED.has(input.agent_type)) pass();
90
84
  const reply = String(input.last_assistant_message || '').trim();
91
85
  if (!reply) pass();
92
86
 
93
- /**
94
- * Reads one stored entry into {form, state}. Three shapes live in this file at once and all
95
- * three must keep their meaning: the current pair, the single number written by the previous
96
- * version, and an ISO string from the version before that (it meant "blocked once"). A
97
- * number is read as form tries — that budget is the one the old counter mostly guarded.
98
- * Anything else counts as one form try already spent, so an agent recorded before this fix
99
- * does not silently get its tries back.
100
- */
101
- const readCounts = (prior) => {
102
- const whole = (value) => (Number.isInteger(value) && value > 0 ? value : 0);
103
- if (prior && typeof prior === 'object' && !Array.isArray(prior)) {
104
- return { form: whole(prior.form), state: whole(prior.state) };
105
- }
106
- if (typeof prior === 'number') return { form: whole(prior), state: 0 };
107
- return { form: prior ? 1 : 0, state: 0 };
108
- };
109
-
110
- /**
111
- * Spends one try of the named budget. stop_hook_active is not reliable here (it already
112
- * arrives true in normal runs), so the loop protection is our own.
113
- *
114
- * 'granted' — block normally, a try was recorded;
115
- * 'exhausted' — this budget is used up, the caller decides what that means;
116
- * 'untracked' — the guard cannot count at all (no agent id, log not writable). A guard
117
- * that cannot count must not decide anything, least of all end a session.
118
- */
119
- const takeTry = (agentId, kind) => {
120
- if (!agentId) return 'untracked';
121
- let seen;
122
- try {
123
- seen = JSON.parse(fs.readFileSync(BLOCKED_FILE, 'utf8'));
124
- } catch {
125
- seen = {};
126
- }
127
- if (!seen || typeof seen !== 'object' || Array.isArray(seen)) seen = {};
128
- const counts = readCounts(seen[agentId]);
129
- if (counts[kind] >= (kind === STATE ? MAX_STATE_BLOCKS : MAX_FORM_BLOCKS)) return 'exhausted';
130
- counts[kind] += 1;
131
- seen[agentId] = counts;
132
- // Keep the file from growing without bound; order of insertion is good enough.
133
- const ids = Object.keys(seen);
134
- if (ids.length > 200) ids.slice(0, ids.length - 200).forEach((id) => delete seen[id]);
135
- try {
136
- fs.writeFileSync(BLOCKED_FILE, `${JSON.stringify(seen)}\n`);
137
- } catch {
138
- return 'untracked';
139
- }
140
- return 'granted';
141
- };
142
-
143
87
  const emit = (payload) => {
144
88
  process.stdout.write(JSON.stringify(payload));
145
89
  process.exit(0);
@@ -253,7 +197,7 @@ const statusPath = path.join(runDir, 'status.json');
253
197
  if (fs.existsSync(statusPath)) {
254
198
  let runStatus = null;
255
199
  try {
256
- runStatus = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
200
+ runStatus = readJsonFileSync(statusPath);
257
201
  } catch {
258
202
  runStatus = null;
259
203
  }
@@ -317,7 +261,7 @@ if (!fs.existsSync(metaPath)) {
317
261
 
318
262
  let meta;
319
263
  try {
320
- meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
264
+ meta = readJsonFileSync(metaPath);
321
265
  } catch {
322
266
  pass();
323
267
  }