@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.
- package/CHANGELOG.md +92 -0
- package/README.md +88 -84
- package/bin/codex-bridge.mjs +38 -11
- package/cli/copy.mjs +7 -6
- package/cli/doctor.mjs +73 -12
- package/cli/hook-targets.mjs +39 -0
- package/cli/hook.mjs +34 -0
- package/cli/hosts.mjs +28 -4
- package/cli/install-record.mjs +287 -0
- package/cli/install.mjs +73 -39
- package/cli/invoked-directly.mjs +37 -0
- package/cli/manifest.mjs +90 -164
- package/cli/permissions.mjs +179 -0
- package/cli/projects.mjs +20 -5
- package/cli/prune-args.mjs +1 -1
- package/cli/remove-layout.mjs +59 -0
- package/cli/rules-owners.mjs +6 -8
- package/cli/runs-inventory.mjs +2 -1
- package/cli/settings-merge.mjs +182 -50
- package/cli/stop.mjs +33 -20
- package/cli/uninstall.mjs +64 -36
- package/cli/unlock.mjs +239 -0
- package/cli/update.mjs +109 -48
- package/package.json +2 -2
- package/scripts/check-file-size.mjs +8 -10
- package/scripts/isolated-roots.mjs +13 -0
- package/scripts/run-tests.mjs +14 -4
- package/src/agents/codex-build.md +17 -13
- package/src/agents/codex-review.md +15 -9
- package/src/agents/codex-scout.md +16 -11
- package/src/commands/env.md +4 -4
- package/src/commands/usage.md +8 -6
- package/src/heartbeat.mjs +1 -1
- package/src/hook-definitions.mjs +17 -4
- package/src/hooks/guard-tries.mjs +59 -0
- package/src/hooks/live-runs.mjs +31 -20
- package/src/hooks/order-gate.mjs +7 -2
- package/src/hooks/reply-guard.mjs +4 -60
- package/src/hooks/stop-guard.mjs +71 -0
- package/src/json-file.mjs +30 -0
- package/src/meta/chain.mjs +8 -6
- package/src/meta/paths.mjs +5 -9
- package/src/meta/pre-start.mjs +30 -0
- package/src/meta/run-state.mjs +31 -16
- package/src/process-identity.mjs +140 -0
- package/src/required-inputs.mjs +42 -0
- package/src/run-config.mjs +4 -8
- package/src/runner/args.mjs +26 -3
- package/src/runner/attach.mjs +32 -19
- package/src/runner/codex-cmd.mjs +1 -1
- package/src/runner/continuation.mjs +34 -3
- package/src/runner/launcher.mjs +54 -28
- package/src/runner/project-dir.mjs +4 -3
- package/src/runner/scope-check.mjs +159 -0
- package/src/runner/worker.mjs +13 -5
- package/src/stop-contract.mjs +15 -0
- package/src/write-meta.mjs +12 -1
- 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` —
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
63
|
-
|
|
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
|
|
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`
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|
63
|
-
|
|
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` —
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
64
|
-
|
|
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
|
package/src/commands/env.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/src/commands/usage.md
CHANGED
|
@@ -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
|
|
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
|
|
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 `
|
|
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) {
|
package/src/hook-definitions.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/hooks/live-runs.mjs
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
&&
|
|
60
|
-
&&
|
|
70
|
+
&& heartbeatFresh
|
|
71
|
+
&& isPidAlive(status.pid, runDir, status, options);
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
function readStatus(runDir) {
|
|
64
75
|
try {
|
|
65
|
-
return
|
|
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
|
}
|
package/src/hooks/order-gate.mjs
CHANGED
|
@@ -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.
|
|
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 =
|
|
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 =
|
|
264
|
+
meta = readJsonFileSync(metaPath);
|
|
321
265
|
} catch {
|
|
322
266
|
pass();
|
|
323
267
|
}
|