@lyupro/codex-bridge 0.1.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 +39 -0
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/bin/codex-bridge.mjs +105 -0
- package/cli/copy.mjs +38 -0
- package/cli/doctor.mjs +170 -0
- package/cli/hosts.mjs +50 -0
- package/cli/install.mjs +109 -0
- package/cli/manifest.mjs +210 -0
- package/cli/settings-merge.mjs +101 -0
- package/cli/stop.mjs +125 -0
- package/cli/uninstall.mjs +62 -0
- package/cli/update.mjs +156 -0
- package/package.json +48 -0
- package/scripts/check-file-size.mjs +193 -0
- package/scripts/run-tests.mjs +26 -0
- package/src/agents/codex-build.md +224 -0
- package/src/agents/codex-review.md +166 -0
- package/src/agents/codex-scout.md +188 -0
- package/src/commands/env.md +38 -0
- package/src/commands/usage.md +88 -0
- package/src/hooks/reply-guard.mjs +311 -0
- package/src/meta/chain.mjs +87 -0
- package/src/meta/environment.mjs +36 -0
- package/src/meta/paths.mjs +193 -0
- package/src/meta/reply.mjs +133 -0
- package/src/meta/run-state.mjs +197 -0
- package/src/meta/verdict.mjs +362 -0
- package/src/rules/codex-bridge.rules +173 -0
- package/src/run-codex.mjs +113 -0
- package/src/run-config.json +4 -0
- package/src/run-config.mjs +302 -0
- package/src/runner/args.mjs +111 -0
- package/src/runner/attach.mjs +111 -0
- package/src/runner/codex-cmd.mjs +265 -0
- package/src/runner/git-state.mjs +144 -0
- package/src/runner/launcher.mjs +374 -0
- package/src/runner/project-dir.mjs +92 -0
- package/src/runner/prompts.mjs +97 -0
- package/src/runner/run-context.mjs +52 -0
- package/src/runner/run-env.mjs +38 -0
- package/src/runner/runs-root.mjs +16 -0
- package/src/runner/schemas.mjs +135 -0
- package/src/runner/worker.mjs +62 -0
- package/src/write-meta.mjs +135 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: codex-scout
|
|
3
|
+
description: Разведка и диагностика ВНЕ подписки Claude — исследование кодовой базы, поиск причины бага, ревью, сбор фактов. Работу выполняет Codex CLI (подписка ChatGPT), Claude платит только за постановку задачи и ≤5 строк выжимки. Строго read-only: писать в репозиторий физически не может. Подробный отчёт кладёт в ~/.claude/codex-runs/, в чат — короткая выжимка + путь.
|
|
4
|
+
model: haiku
|
|
5
|
+
tools: Bash
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
You are the scout dispatcher. You do not investigate anything yourself: one command starts Codex,
|
|
9
|
+
and its output is your answer.
|
|
10
|
+
|
|
11
|
+
**Why you exist.** The orchestrator runs on a Claude Max subscription, while Codex runs on a
|
|
12
|
+
ChatGPT subscription. All heavy work (reading files, reasoning, generating the report) must happen
|
|
13
|
+
on the Codex side. Every extra line you send to chat costs Claude tokens. Therefore: do not read
|
|
14
|
+
files, do not run grep, do not retell the report, and do not reason about the task.
|
|
15
|
+
|
|
16
|
+
## What you receive as input
|
|
17
|
+
|
|
18
|
+
- The task statement (what to find out / what to diagnose / what to review).
|
|
19
|
+
- The path to the repository. If none is given, work in the current working directory.
|
|
20
|
+
- Optional: `effort: <none|minimal|low|medium|high|xhigh|max>` — Codex reasoning depth.
|
|
21
|
+
- Optional: `slug: <short-name>` for the run folder.
|
|
22
|
+
- `order id: <label>` — the label the orchestrator issued for this order. Pass it as `--order-id`
|
|
23
|
+
exactly as given. Never invent one, never edit one, never reuse one from another order: the
|
|
24
|
+
runner chains runs by this label, and a made-up label is how a repeat run hides. If the
|
|
25
|
+
orchestrator did not give it, do not guess — start the runner without the flag and return its
|
|
26
|
+
refusal verbatim, the same way you would with a missing `--scope` in codex-build.
|
|
27
|
+
- `question: <text>` — REQUIRED and repeatable. For every sub-question the orchestrator gave,
|
|
28
|
+
pass one `--question "<text>"` flag exactly as given. Never invent, merge, reword, or drop a
|
|
29
|
+
sub-question. If the orchestrator did not give one, do not guess — start the runner without
|
|
30
|
+
the flag and return its refusal verbatim.
|
|
31
|
+
- `continue` — pass it as the `--continue` flag only if the orchestrator gave it explicitly; do not
|
|
32
|
+
add or guess it yourself. The contract is strict, like `--scope` in codex-build: if this task
|
|
33
|
+
(the same `slug` and the same repository) already had a run, the runner will refuse to start
|
|
34
|
+
without `--continue` — before starting Codex, so no quota is spent. The orchestrator decides on a
|
|
35
|
+
repeat run.
|
|
36
|
+
|
|
37
|
+
## The only thing you do
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
node "{{CODEX_BRIDGE_DIR}}/run-codex.mjs" --agent codex-scout \
|
|
41
|
+
--repo "<repository-path or .>" --slug "<slug>" --order-id "<order id from the orchestrator>" \
|
|
42
|
+
--question "<sub-question 1 from the orchestrator, verbatim>" \
|
|
43
|
+
--question "<sub-question 2 from the orchestrator, verbatim>" <<'TASK'
|
|
44
|
+
<operator's task statement verbatim, without your rewording>
|
|
45
|
+
TASK
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Repeat the `--question` line once for every sub-question the orchestrator gave; a single line is
|
|
49
|
+
a valid order. The example shows two flags only to make the repeatable form explicit.
|
|
50
|
+
|
|
51
|
+
Add `--effort "<value>"` only when the orchestrator named a depth, and only with one of
|
|
52
|
+
`none|minimal|low|medium|high|xhigh|max`. Without the flag the configured profile of the mode
|
|
53
|
+
decides, which is the intended default — a placeholder copied from this template is refused before
|
|
54
|
+
Codex starts.
|
|
55
|
+
|
|
56
|
+
If the orchestrator gave `continue`, add the bare `--continue` flag to the command, with no value.
|
|
57
|
+
No value
|
|
58
|
+
is passed to it: the runner accepts only `1/true/yes/0/false/no`, while a placeholder string left in
|
|
59
|
+
the
|
|
60
|
+
command from this template is an argument error, and the run will not start. If it was not given,
|
|
61
|
+
the flag
|
|
62
|
+
must not be present in the command at all.
|
|
63
|
+
|
|
64
|
+
The call does not wait. It starts the run and returns at once with `RUN=<path>` and a `STARTED`
|
|
65
|
+
line. To get the verdict, run the **identical command a second time** — same `--order-id`, same
|
|
66
|
+
`--slug`, same flags. That second call does not start a second run and costs no quota: it attaches
|
|
67
|
+
to the run already in flight, prints `ATTACH=<path>`, blocks until the verdict exists and prints it.
|
|
68
|
+
|
|
69
|
+
Background execution (`run_in_background`, `&`, `nohup`) is prohibited, and so is inventing a report
|
|
70
|
+
from memory. Interruption is no longer a problem worth handling: if the attaching call is killed by
|
|
71
|
+
a time ceiling, repeat it — every repeat attaches to the same run. A real run takes 20-25 minutes,
|
|
72
|
+
which is normal, not a hang. Give the attaching call `timeout: 1800000` (30 minutes).
|
|
73
|
+
|
|
74
|
+
**Never change `--order-id` or `--slug` to get a fresh run.** The order id is issued by the
|
|
75
|
+
orchestrator and is what makes a repeat harmless; changing it turns a repeat into a second paid run.
|
|
76
|
+
On 2026-08-03 one order became six runs exactly this way, and four of them were never accounted for
|
|
77
|
+
at all.
|
|
78
|
+
|
|
79
|
+
The runner does the rest: creates the run folder, `task.md` (the verbatim task + instructions for
|
|
80
|
+
Codex),
|
|
81
|
+
the response schema, the synchronous Codex run in a read-only sandbox, `meta.json`, artifact status,
|
|
82
|
+
and ready-made response lines.
|
|
83
|
+
|
|
84
|
+
**Your response = the exact stdout of the attaching call**: the `ATTACH=<path>` line and the status
|
|
85
|
+
block below it.
|
|
86
|
+
Do not add or remove anything: no preamble, explanations, apologies, or retelling of
|
|
87
|
+
findings. The report is in the file; the orchestrator will read it if needed.
|
|
88
|
+
The `STARTED` output of the first call is not a result and is never the response on its own.
|
|
89
|
+
|
|
90
|
+
The only allowed final response is this exact stdout. Wording such as "the run has started,
|
|
91
|
+
waiting for completion," "I will wait for a notification," or "Monitor started in the background" is
|
|
92
|
+
prohibited in any
|
|
93
|
+
form: the subagent ceases to exist immediately after responding, nobody can wait, and the
|
|
94
|
+
orchestrator gets
|
|
95
|
+
a promise instead of a result. This is the same failure as in codex-build: the dispatcher gives
|
|
96
|
+
such a response while a run is live, it is left without an owner, and this can only be discovered by
|
|
97
|
+
checking
|
|
98
|
+
`status.json` manually.
|
|
99
|
+
|
|
100
|
+
## What you return
|
|
101
|
+
|
|
102
|
+
Only the contents of the run files exactly as printed by the runner. Codex performs the scouting;
|
|
103
|
+
the orchestrator judges whether its answer is good because it has the task context.
|
|
104
|
+
A bad Codex answer is still a result and must be reported as is.
|
|
105
|
+
|
|
106
|
+
## Why the run lives in the script, not here
|
|
107
|
+
|
|
108
|
+
The flags are proven, and they do not belong in the prompt: `--ignore-user-config` halves Codex's
|
|
109
|
+
startup ballast (~9k quota instead of ~19k) and structurally blocks writing — no flags can override
|
|
110
|
+
read-only; `--disable hooks --disable plugins` disable the operator's extensions for this call,
|
|
111
|
+
so the run does not depend on what is installed in `~/.codex` today (the default, switched
|
|
112
|
+
by the operator through `/codex:env`); `--model` is never passed because model IDs
|
|
113
|
+
are volatile.
|
|
114
|
+
The run is asynchronous, and waiting for it is a separate call. That is deliberate: while the start
|
|
115
|
+
and the wait were one call, a time ceiling on that call looked exactly like a dead run, and the
|
|
116
|
+
dispatcher restarted it. Now the start cannot be interrupted in any way that matters, and the wait
|
|
117
|
+
can be repeated for free, so neither has any reason to restart anything.
|
|
118
|
+
|
|
119
|
+
## What Codex returns
|
|
120
|
+
|
|
121
|
+
The runner uses the repeatable `--question` flags as subquestions Q1..Qn, and `result.json`
|
|
122
|
+
requires `answers[]` — an answer and evidence
|
|
123
|
+
(analysis, not just a location) for every subquestion, plus `findings[]` (fact / location
|
|
124
|
+
`path:line` /
|
|
125
|
+
confidence), `unknowns[]`, and `report_markdown`. An uncovered subquestion or an answer without
|
|
126
|
+
evidence is FAIL;
|
|
127
|
+
the runner prints the line `Coverage: N/M subquestions`. The runner expands the last field into
|
|
128
|
+
`report.md`.
|
|
129
|
+
An empty `answer` is FAIL with a path to the log, not a reason to choose different flags.
|
|
130
|
+
|
|
131
|
+
## The script determines status, not you
|
|
132
|
+
|
|
133
|
+
- `OK` — `result.json` is filled, and the return code is zero.
|
|
134
|
+
- `FAIL` — the result is empty, the return code is nonzero, or `raw.log` is empty (the run was
|
|
135
|
+
abandoned
|
|
136
|
+
at startup: there was no Codex process).
|
|
137
|
+
- `LIMIT` — the result is empty and the log signals a limit. The ChatGPT quota is exhausted, and the
|
|
138
|
+
task was not
|
|
139
|
+
completed; this is not a task failure and not a reason to restart.
|
|
140
|
+
|
|
141
|
+
The script return code mirrors the status: `0` / `1` / `3`. A nonzero code is not a reason to retry,
|
|
142
|
+
not a reason to change the command, and not a reason to investigate on your own.
|
|
143
|
+
|
|
144
|
+
The run folder contains `status.json` (`running` / `finished` / `failed` / `abandoned`) and the
|
|
145
|
+
runner pid.
|
|
146
|
+
An abandoned run is not a reason to start over yourself: the orchestrator decides whether to repeat
|
|
147
|
+
it, and
|
|
148
|
+
without the `--continue` it issued, the runner will reject that repeat run itself. Only a run still
|
|
149
|
+
alive is attached to; a run that already has a verdict sends your repeat into that same refusal,
|
|
150
|
+
which is the runner telling you the answer is on disk already.
|
|
151
|
+
|
|
152
|
+
## Codex is unavailable
|
|
153
|
+
|
|
154
|
+
The runner checks `codex --version` before starting. If the binary is missing or authentication
|
|
155
|
+
failed,
|
|
156
|
+
it prints a ready-made FAIL with a verification command for the operator. Your job is to return that
|
|
157
|
+
output.
|
|
158
|
+
Performing the task manually instead of Codex is prohibited for any reason it fails.
|
|
159
|
+
|
|
160
|
+
## What a violation looks like
|
|
161
|
+
|
|
162
|
+
Correct (runner output copied exactly):
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
ATTACH=<artifact root>\myproject\2026-07-30_1412_hooks started=2026-07-30T14:12:03.000Z
|
|
166
|
+
OK — The settings.json hook loads twice: from the plugin and the local config
|
|
167
|
+
Key finding: duplicate loader (src/hooks/loader.ts:42)
|
|
168
|
+
Unresolved: why the second load is needed
|
|
169
|
+
Report: ...\report.md · Log: ...\raw.log
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Incorrect — "Codex started in the background (PID recorded). Waiting for scouting to finish — it
|
|
173
|
+
takes
|
|
174
|
+
up to 10 minutes. I will notify you when it is done." There will be no notification: as a subagent,
|
|
175
|
+
you terminate
|
|
176
|
+
with the response, so nobody can physically wait and send a notification — the orchestrator receives
|
|
177
|
+
a promise instead of a result. Background execution (`run_in_background`, `&`, `nohup`) is already
|
|
178
|
+
prohibited by the separate rule above.
|
|
179
|
+
|
|
180
|
+
Incorrect — "I analyzed the changes manually because Codex did not perform the scouting
|
|
181
|
+
correctly," followed by a 40-line analysis. This costs more than not delegating at all: the agent
|
|
182
|
+
exists
|
|
183
|
+
specifically so reading and reasoning use someone else's quota.
|
|
184
|
+
|
|
185
|
+
Incorrect — scouting returned a "Fact | Location | Confidence" table containing only locations such
|
|
186
|
+
as
|
|
187
|
+
`packages/x/y.ts:60-79` with no analysis, and "Missing: Nothing" for a task with six substantive
|
|
188
|
+
subquestions. A list of locations without analysis is a runner coverage FAIL, not a result.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Show or switch the environment of delegated Codex runs — the operator's hooks and plugins
|
|
3
|
+
allowed-tools: Bash
|
|
4
|
+
argument-hint: "[hooks|plugins on|off] | [reset]"
|
|
5
|
+
---
|
|
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
|
|
9
|
+
symlinks nor pointer files (verified 2026-08-02). Edit it here; it travels as a copy when the
|
|
10
|
+
package is installed. -->
|
|
11
|
+
|
|
12
|
+
Environment switches for `codex-scout` / `codex-build` / `codex-review` runs. By default the hooks
|
|
13
|
+
and plugins from `~/.codex` do not reach a delegated run: they are written for interactive work and
|
|
14
|
+
do harm in an automated one — the failing `Stop` hook of `oh-my-codex` would not let Codex leave the
|
|
15
|
+
session, and instead of doing the task it quarantined `.omx/state/session.json`. The operator's
|
|
16
|
+
interactive Codex is untouched: the flags are set on one specific runner invocation,
|
|
17
|
+
`~/.codex/hooks.json` is not modified.
|
|
18
|
+
|
|
19
|
+
Run exactly this command, substituting the user's arguments (`$ARGUMENTS`), and return its output
|
|
20
|
+
verbatim. Add nothing and recompute nothing:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
node "{{CODEX_BRIDGE_DIR}}/run-config.mjs" $ARGUMENTS
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Call forms:
|
|
27
|
+
|
|
28
|
+
- no arguments — show the current state and the path to the file;
|
|
29
|
+
- `hooks on` / `hooks off` — bring back or remove the operator's hooks;
|
|
30
|
+
- `plugins on` / `plugins off` — the same for plugins;
|
|
31
|
+
- `reset` — back to the default (both off).
|
|
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.
|
|
35
|
+
|
|
36
|
+
If the user turns `hooks` on, say in one line: while the failing `Stop` hook of `oh-my-codex` stays
|
|
37
|
+
in `~/.codex/hooks.json`, runs will again spend quota on unrelated repairs. Checking the report
|
|
38
|
+
against the tree will fail such a run, but the quota is already gone.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Show the ChatGPT quota spent on runs delegated to Codex — by day, project and agent
|
|
3
|
+
allowed-tools: Bash
|
|
4
|
+
---
|
|
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
|
|
8
|
+
symlinks nor pointer files (verified 2026-08-02). Edit it here; it travels as a copy when the
|
|
9
|
+
package is installed. -->
|
|
10
|
+
|
|
11
|
+
Count how much ChatGPT quota the runs delegated to Codex burned. This is spending on the Codex side,
|
|
12
|
+
not on the Claude side.
|
|
13
|
+
|
|
14
|
+
The root of the run folders comes from `CODEX_RUNS_ROOT` when it is set, otherwise from
|
|
15
|
+
`~/.claude/codex-runs` — the same order the runner itself uses, because otherwise the accounting
|
|
16
|
+
would stop seeing runs whenever the root is overridden.
|
|
17
|
+
|
|
18
|
+
The source is the `meta.json` in each run folder: it holds the spending, the model and the sandbox.
|
|
19
|
+
The `raw.log` itself is not kept in git and may be deleted, so parsing it is not allowed — the
|
|
20
|
+
accounting has to survive a transcript cleanup. For older runs without `meta.json` there is a
|
|
21
|
+
fallback log parser.
|
|
22
|
+
|
|
23
|
+
`codex exec review` prints no spending at all, so such runs land in the "unaccounted" line. Counting
|
|
24
|
+
them as zero is not allowed — that would silently understate the total.
|
|
25
|
+
|
|
26
|
+
A project folder is marked by a `.project.json` file holding the repository path. Two different
|
|
27
|
+
repositories with the same directory name get `api` and `api-2`, so the repository path is printed
|
|
28
|
+
under the project line — but only when the folder name and the repository directory name have
|
|
29
|
+
diverged. Otherwise the `api-2` line in a spending report would not say whose spending it is.
|
|
30
|
+
|
|
31
|
+
Run exactly this command and recompute nothing by hand:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
node -e "
|
|
35
|
+
const fs=require('fs'),path=require('path');
|
|
36
|
+
const root=(process.env.CODEX_RUNS_ROOT||'').trim()||path.join(process.env.USERPROFILE||process.env.HOME,'.claude','codex-runs');
|
|
37
|
+
if(!fs.existsSync(root)){console.log('No runs yet.');process.exit(0)}
|
|
38
|
+
const runs=[],unknown=[];
|
|
39
|
+
for(const proj of fs.readdirSync(root)){
|
|
40
|
+
const pdir=path.join(root,proj);
|
|
41
|
+
if(!fs.statSync(pdir).isDirectory())continue;
|
|
42
|
+
let repo=null;
|
|
43
|
+
try{repo=JSON.parse(fs.readFileSync(path.join(pdir,'.project.json'),'utf8')).repo||null}catch{}
|
|
44
|
+
for(const run of fs.readdirSync(pdir)){
|
|
45
|
+
const dir=path.join(pdir,run),meta=path.join(dir,'meta.json'),log=path.join(dir,'raw.log');
|
|
46
|
+
let tokens=null,agent=null,sandbox=null;
|
|
47
|
+
if(fs.existsSync(meta)){
|
|
48
|
+
try{const m=JSON.parse(fs.readFileSync(meta,'utf8'));tokens=m.tokens??null;agent=m.agent||null;sandbox=m.sandbox||null}catch{}
|
|
49
|
+
} else if(fs.existsSync(log)){
|
|
50
|
+
const hit=[...fs.readFileSync(log,'utf8').matchAll(/tokens used[\r\n]+([^\r\n]+)/g)].pop();
|
|
51
|
+
tokens=hit?parseInt(hit[1].replace(/\D/g,''),10)||null:null;
|
|
52
|
+
} else continue;
|
|
53
|
+
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
|
+
if(tokens===null){unknown.push(entry);continue}
|
|
55
|
+
runs.push({...entry,tokens});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if(!runs.length&&!unknown.length){console.log('No runs yet.');process.exit(0)}
|
|
59
|
+
const sum=a=>a.reduce((s,r)=>s+r.tokens,0);
|
|
60
|
+
const group=k=>{const m={};runs.forEach(r=>{(m[r[k]]=m[r[k]]||[]).push(r)});return m};
|
|
61
|
+
const fmt=n=>n.toLocaleString('en-US');
|
|
62
|
+
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));
|
|
63
|
+
if(runs.length)console.log('TOTAL: '+fmt(sum(runs))+' tokens over '+runs.length+' runs · average '+fmt(Math.round(sum(runs)/runs.length)));
|
|
64
|
+
if(unknown.length)console.log('Unaccounted: '+unknown.length+' runs (Codex reported no spending) — the total is understated');
|
|
65
|
+
const risky=[...runs,...unknown].filter(r=>r.sandbox&&r.sandbox!=='read-only'&&r.sandbox!=='workspace-write');
|
|
66
|
+
if(risky.length)console.log('WARNING: '+risky.length+' runs went outside the usual sandbox: '+risky.map(r=>r.run+' ('+r.sandbox+')').join(', '));
|
|
67
|
+
if(runs.length){
|
|
68
|
+
console.log('');console.log('By day:');
|
|
69
|
+
Object.entries(group('day')).sort().forEach(([d,rs])=>console.log(row(d,rs)));
|
|
70
|
+
console.log('');console.log('By project:');
|
|
71
|
+
Object.entries(group('proj')).sort((a,b)=>sum(b[1])-sum(a[1])).forEach(([p,rs])=>{
|
|
72
|
+
console.log(row(p,rs));
|
|
73
|
+
const repo=rs.find(r=>r.repo)?.repo;
|
|
74
|
+
if(repo&&path.basename(repo)!==p)console.log(' '+repo);
|
|
75
|
+
});
|
|
76
|
+
console.log('');console.log('By agent:');
|
|
77
|
+
Object.entries(group('agent')).sort((a,b)=>sum(b[1])-sum(a[1])).forEach(([k,rs])=>console.log(row(k,rs)));
|
|
78
|
+
console.log('');console.log('Most expensive runs:');
|
|
79
|
+
runs.sort((a,b)=>b.tokens-a.tokens).slice(0,5).forEach(r=>console.log(' '+fmt(r.tokens).padStart(11)+' '+r.proj+'/'+r.run));
|
|
80
|
+
}
|
|
81
|
+
"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Print the result as is, without retelling it. Add exactly one line of your own: whether the current
|
|
85
|
+
pace fits inside the subscription window or the spending is growing fast.
|
|
86
|
+
|
|
87
|
+
If the `WARNING` line about the sandbox showed up — work out why the run went outside `read-only` or
|
|
88
|
+
`workspace-write`, and say so separately: it is a sign that the agent deviated from its command.
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SubagentStop guard for the Codex dispatchers (codex-scout / codex-build / codex-review).
|
|
4
|
+
*
|
|
5
|
+
* Their contract is that the reply IS the runner's stdout: a `RUN=` or `ATTACH=` line naming the
|
|
6
|
+
* run folder plus the status block printed by write-meta.mjs. Prose forbids anything else, and prose has been
|
|
7
|
+
* ignored twice — once a dispatcher reviewed the diff itself (68k Claude tokens instead of
|
|
8
|
+
* five lines), once it announced a background run that never existed. Neither is visible
|
|
9
|
+
* from the reply alone, which is why this check lives outside the model.
|
|
10
|
+
*
|
|
11
|
+
* It blocks on substance only:
|
|
12
|
+
* - no usable `RUN=` / `ATTACH=` path in the reply (the dispatcher did not delegate at all);
|
|
13
|
+
* - status.json says the run is still going, or was abandoned, before meta.json exists;
|
|
14
|
+
* - the run folder has no meta.json (the reply rests on nothing);
|
|
15
|
+
* - the reply's status word contradicts meta.json.
|
|
16
|
+
*
|
|
17
|
+
* Cosmetics — code fences, blank lines, reordered whitespace — pass. Blocking those would
|
|
18
|
+
* spend Claude tokens on a re-answer that changes no decision.
|
|
19
|
+
*
|
|
20
|
+
* Those four reasons are of two different kinds, and they get two different budgets: an
|
|
21
|
+
* argument about the SHAPE of the reply can loop forever and eventually steps aside, an
|
|
22
|
+
* argument about EXTERNAL run state cannot be argued away by the model and ends the turn
|
|
23
|
+
* with `continue: false` instead of a silent pass. See MAX_FORM_BLOCKS / MAX_STATE_BLOCKS.
|
|
24
|
+
*
|
|
25
|
+
* Input is JSON on stdin. The fields used here (`agent_type`, `last_assistant_message`)
|
|
26
|
+
* come from Claude Code; the last payload is kept in logs/codex-reply-guard.last.json so
|
|
27
|
+
* the contract stays inspectable if it ever changes shape.
|
|
28
|
+
*/
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import os from 'node:os';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
|
|
33
|
+
const HOME = os.homedir();
|
|
34
|
+
const LOG_DIR = path.join(HOME, '.claude', 'logs');
|
|
35
|
+
const BLOCKED_FILE = path.join(LOG_DIR, 'codex-reply-guard.blocked.json');
|
|
36
|
+
const GUARDED = new Set(['codex-scout', 'codex-build', 'codex-review']);
|
|
37
|
+
const STATUSES = ['OK', 'FAIL', 'LIMIT'];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How many times a single agent may be blocked over the SHAPE of its reply — no RUN= line,
|
|
41
|
+
* no meta.json, a status word that contradicts meta.json. 1 is too few: the incident that
|
|
42
|
+
* prompted this constant was a dispatcher blocked once, retried, and answered wrong again
|
|
43
|
+
* ("run started in background, waiting 5-10 minutes") — the old one-shot guard let that
|
|
44
|
+
* second wrong answer straight through. Unbounded is worse: here the argument is with the
|
|
45
|
+
* model, and a dispatcher stuck re-answering the same way loops forever and burns Claude
|
|
46
|
+
* quota on retries that never converge. Three tries, then let it through so the operator
|
|
47
|
+
* sees a reply with substance in it and judges for himself.
|
|
48
|
+
*/
|
|
49
|
+
const MAX_FORM_BLOCKS = 3;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The same allowance for blocks caused by EXTERNAL state — the run is still going, or its
|
|
53
|
+
* runner died. Counted separately because the two run out for unrelated reasons: three
|
|
54
|
+
* malformed replies must not spend the budget that protects a live worktree, and a live
|
|
55
|
+
* worktree is not something the model can answer its way out of. Exhausting this one does
|
|
56
|
+
* NOT step aside — see the stopReason path below. What went through the shared budget was
|
|
57
|
+
* "Waiting for the Codex run to finish... Monitor started in background. Awaiting notification",
|
|
58
|
+
* promised over a run whose runner was already dead (counter a662d99e0c67d3a8a => 3): the
|
|
59
|
+
* orchestrator got a promise from a process that no longer existed and never learned the
|
|
60
|
+
* worktree was busy.
|
|
61
|
+
*/
|
|
62
|
+
const MAX_STATE_BLOCKS = 3;
|
|
63
|
+
|
|
64
|
+
const FORM = 'form';
|
|
65
|
+
const STATE = 'state';
|
|
66
|
+
|
|
67
|
+
/** Never let a guard failure break real work: on any doubt, stay silent. */
|
|
68
|
+
const pass = () => process.exit(0);
|
|
69
|
+
|
|
70
|
+
let input;
|
|
71
|
+
try {
|
|
72
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
73
|
+
} catch {
|
|
74
|
+
pass();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
79
|
+
fs.writeFileSync(path.join(LOG_DIR, 'codex-reply-guard.last.json'), `${JSON.stringify(input, null, 2)}\n`);
|
|
80
|
+
} catch {
|
|
81
|
+
// Diagnostics are a convenience, never a reason to fail the turn.
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Only the Codex dispatchers have this contract. Every other agent is none of our
|
|
85
|
+
// business — an earlier version inferred the agent from transcript text and blocked an
|
|
86
|
+
// unrelated one, which cost a re-answer for nothing.
|
|
87
|
+
if (!GUARDED.has(input.agent_type)) pass();
|
|
88
|
+
|
|
89
|
+
const reply = String(input.last_assistant_message || '').trim();
|
|
90
|
+
if (!reply) pass();
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Reads one stored entry into {form, state}. Three shapes live in this file at once and all
|
|
94
|
+
* three must keep their meaning: the current pair, the single number written by the previous
|
|
95
|
+
* version, and an ISO string from the version before that (it meant "blocked once"). A
|
|
96
|
+
* number is read as form tries — that budget is the one the old counter mostly guarded.
|
|
97
|
+
* Anything else counts as one form try already spent, so an agent recorded before this fix
|
|
98
|
+
* does not silently get its tries back.
|
|
99
|
+
*/
|
|
100
|
+
const readCounts = (prior) => {
|
|
101
|
+
const whole = (value) => (Number.isInteger(value) && value > 0 ? value : 0);
|
|
102
|
+
if (prior && typeof prior === 'object' && !Array.isArray(prior)) {
|
|
103
|
+
return { form: whole(prior.form), state: whole(prior.state) };
|
|
104
|
+
}
|
|
105
|
+
if (typeof prior === 'number') return { form: whole(prior), state: 0 };
|
|
106
|
+
return { form: prior ? 1 : 0, state: 0 };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Spends one try of the named budget. stop_hook_active is not reliable here (it already
|
|
111
|
+
* arrives true in normal runs), so the loop protection is our own.
|
|
112
|
+
*
|
|
113
|
+
* 'granted' — block normally, a try was recorded;
|
|
114
|
+
* 'exhausted' — this budget is used up, the caller decides what that means;
|
|
115
|
+
* 'untracked' — the guard cannot count at all (no agent id, log not writable). A guard
|
|
116
|
+
* that cannot count must not decide anything, least of all end a session.
|
|
117
|
+
*/
|
|
118
|
+
const takeTry = (agentId, kind) => {
|
|
119
|
+
if (!agentId) return 'untracked';
|
|
120
|
+
let seen;
|
|
121
|
+
try {
|
|
122
|
+
seen = JSON.parse(fs.readFileSync(BLOCKED_FILE, 'utf8'));
|
|
123
|
+
} catch {
|
|
124
|
+
seen = {};
|
|
125
|
+
}
|
|
126
|
+
if (!seen || typeof seen !== 'object' || Array.isArray(seen)) seen = {};
|
|
127
|
+
const counts = readCounts(seen[agentId]);
|
|
128
|
+
if (counts[kind] >= (kind === STATE ? MAX_STATE_BLOCKS : MAX_FORM_BLOCKS)) return 'exhausted';
|
|
129
|
+
counts[kind] += 1;
|
|
130
|
+
seen[agentId] = counts;
|
|
131
|
+
// Keep the file from growing without bound; order of insertion is good enough.
|
|
132
|
+
const ids = Object.keys(seen);
|
|
133
|
+
if (ids.length > 200) ids.slice(0, ids.length - 200).forEach((id) => delete seen[id]);
|
|
134
|
+
try {
|
|
135
|
+
fs.writeFileSync(BLOCKED_FILE, `${JSON.stringify(seen)}\n`);
|
|
136
|
+
} catch {
|
|
137
|
+
return 'untracked';
|
|
138
|
+
}
|
|
139
|
+
return 'granted';
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const emit = (payload) => {
|
|
143
|
+
process.stdout.write(JSON.stringify(payload));
|
|
144
|
+
process.exit(0);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** Wrong shape of reply: three tries, then the reply goes through as it always has. */
|
|
148
|
+
const blockForm = (reason) => {
|
|
149
|
+
if (takeTry(input.agent_id, FORM) !== 'granted') pass();
|
|
150
|
+
emit({ decision: 'block', reason });
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Wrong external state: three tries, then the turn ends with stopReason instead of the
|
|
155
|
+
* silent pass. Silence here is what let a run in progress be reported as finished business
|
|
156
|
+
* — the operator saw a confident reply and nothing about the worktree being occupied.
|
|
157
|
+
*/
|
|
158
|
+
const blockState = (reason, stopReason) => {
|
|
159
|
+
const verdict = takeTry(input.agent_id, STATE);
|
|
160
|
+
if (verdict === 'granted') emit({ decision: 'block', reason });
|
|
161
|
+
if (verdict === 'exhausted') emit({ continue: false, stopReason });
|
|
162
|
+
pass();
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Either line names the run folder. `RUN=` is printed by the call that starts a run, `ATTACH=`
|
|
167
|
+
* by the repeat that joins it — and since the launcher stopped waiting, the reply carrying the
|
|
168
|
+
* verdict is the stdout of the attaching call. Matching only `RUN=` would block every honest
|
|
169
|
+
* answer given under the new contract as "did not delegate at all".
|
|
170
|
+
*/
|
|
171
|
+
const runMatch = reply.match(/(?:RUN|ATTACH)=(.+?)(?:\r?\n|$)/);
|
|
172
|
+
const runDir = runMatch ? runMatch[1].trim().replace(/\s+started=\S*$/, '').replace(/[`"'*]+$/g, '') : null;
|
|
173
|
+
|
|
174
|
+
if (!runDir || !fs.existsSync(runDir)) {
|
|
175
|
+
blockForm(
|
|
176
|
+
'Contract violated: the response has no RUN= or ATTACH= line with an existing run folder, so ' +
|
|
177
|
+
'delegation to Codex is not confirmed. Run run-codex.mjs and return its stdout ' +
|
|
178
|
+
'verbatim — the ATTACH=<path> line and the status block below it. If no run occurred, ' +
|
|
179
|
+
'report the runner status: your own analysis instead of Codex is prohibited in all outcomes.',
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
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
|
+
/**
|
|
200
|
+
* The text the operator reads when the state budget is gone. Everything in it is quoted
|
|
201
|
+
* from status.json — the operator is being told to stop, and a stop justified by guesswork
|
|
202
|
+
* is a stop he learns to ignore. `fact` names the missing field instead of inventing one.
|
|
203
|
+
*/
|
|
204
|
+
const fact = (value) => (value === undefined || value === null || value === '' ? 'not recorded' : String(value));
|
|
205
|
+
|
|
206
|
+
const stopText = (headline, observed, runStatus) =>
|
|
207
|
+
[
|
|
208
|
+
headline,
|
|
209
|
+
`Run: slug ${fact(runStatus?.slug)}, agent ${fact(runStatus?.agent)}, repository ` +
|
|
210
|
+
`${fact(runStatus?.repo)}, started ${fact(runStatus?.started_at)}.`,
|
|
211
|
+
`Run folder: ${runDir}. Read state from ${path.join(runDir, 'status.json')}; the verdict ` +
|
|
212
|
+
'will appear in the adjacent meta.json.',
|
|
213
|
+
observed,
|
|
214
|
+
`Worktree ${fact(runStatus?.repo)} is busy with this run: do not build, test, or commit until ` +
|
|
215
|
+
'status.json changes state to finished or failed. The runner snapshots the tree before ' +
|
|
216
|
+
'and after the run and will claim any changes made during this window.',
|
|
217
|
+
'The run will close itself: the worker outlives every caller and completes meta.json and ' +
|
|
218
|
+
'status.json without the dispatcher. Wait for state to change and reread status.json — the ' +
|
|
219
|
+
'verdict will be there; there is no need to ask the dispatcher again. Repeating the same ' +
|
|
220
|
+
'command with the same --order-id attaches to this run rather than starting another.',
|
|
221
|
+
].join(' ');
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* status.json is written by run-codex.mjs before meta.json exists, so it catches the
|
|
225
|
+
* dispatcher that reports before its own run is done — meta.json alone cannot, because a
|
|
226
|
+
* run in progress simply has none yet, same as a run that was never started. Runs from
|
|
227
|
+
* before status.json existed have none: behave exactly as before (meta.json decides).
|
|
228
|
+
*/
|
|
229
|
+
const statusPath = path.join(runDir, 'status.json');
|
|
230
|
+
if (fs.existsSync(statusPath)) {
|
|
231
|
+
let runStatus = null;
|
|
232
|
+
try {
|
|
233
|
+
runStatus = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
|
|
234
|
+
} catch {
|
|
235
|
+
runStatus = null;
|
|
236
|
+
}
|
|
237
|
+
if (runStatus?.state === 'running') {
|
|
238
|
+
if (isPidAlive(runStatus.pid)) {
|
|
239
|
+
blockState(
|
|
240
|
+
'Contract violated: status.json says state=running and the process is alive — the run is ' +
|
|
241
|
+
'not finished, but you are already responding. The STARTED output of the starting call ' +
|
|
242
|
+
'is not a result. Repeat the identical run-codex.mjs command, same --order-id, with ' +
|
|
243
|
+
'timeout 1800000: it attaches to this same run, costs no quota, and prints the verdict.',
|
|
244
|
+
stopText(
|
|
245
|
+
`The reply guard stopped the session: the dispatcher responded ${MAX_STATE_BLOCKS} ` +
|
|
246
|
+
'times while the Codex run was still in progress.',
|
|
247
|
+
`status.json currently says state=running; process pid ${fact(runStatus.pid)} is alive.`,
|
|
248
|
+
runStatus,
|
|
249
|
+
),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
if (!fs.existsSync(path.join(runDir, 'meta.json'))) {
|
|
253
|
+
blockState(
|
|
254
|
+
'Contract violated: status.json says state=running, but the process with this pid is dead ' +
|
|
255
|
+
'and meta.json is missing — the run is abandoned. Repeat the identical run-codex.mjs ' +
|
|
256
|
+
'command, same --order-id, with timeout of at least 1800000 and return its stdout ' +
|
|
257
|
+
'verbatim.',
|
|
258
|
+
stopText(
|
|
259
|
+
`The reply guard stopped the session: the dispatcher responded ${MAX_STATE_BLOCKS} ` +
|
|
260
|
+
'times for a run it did not complete.',
|
|
261
|
+
`status.json currently says state=running, but process pid ${fact(runStatus.pid)} is ` +
|
|
262
|
+
'dead and meta.json is missing: an interrupted Bash call killed the runner. Codex ' +
|
|
263
|
+
'survives the runner and keeps editing the tree — in run 2026-07-31_114736, changes ' +
|
|
264
|
+
'in 11+ files survived while raw.log and meta.json were never recorded.',
|
|
265
|
+
runStatus,
|
|
266
|
+
),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
} else if (runStatus?.state === 'abandoned') {
|
|
270
|
+
blockState(
|
|
271
|
+
'Contract violated: status.json says state=abandoned — the runner died without a verdict. ' +
|
|
272
|
+
'Repeat the identical run-codex.mjs command, same --order-id, with timeout of at least ' +
|
|
273
|
+
'1800000 and return its stdout verbatim.',
|
|
274
|
+
stopText(
|
|
275
|
+
`The reply guard stopped the session: the dispatcher responded ${MAX_STATE_BLOCKS} ` +
|
|
276
|
+
'times for an abandoned run.',
|
|
277
|
+
`status.json currently says state=abandoned (${fact(runStatus.abandoned_reason)}, ` +
|
|
278
|
+
`${fact(runStatus.abandoned_at)}): the runner died without recording a verdict. Codex ` +
|
|
279
|
+
'survives the runner and keeps editing the tree — in run 2026-07-31_114736, changes in ' +
|
|
280
|
+
'11+ files survived while raw.log and meta.json were never recorded.',
|
|
281
|
+
runStatus,
|
|
282
|
+
),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const metaPath = path.join(runDir, 'meta.json');
|
|
288
|
+
if (!fs.existsSync(metaPath)) {
|
|
289
|
+
blockForm(
|
|
290
|
+
`Contract violated: run folder ${runDir} has no meta.json; nothing supports the response. ` +
|
|
291
|
+
'Run run-codex.mjs again and return its stdout verbatim.',
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let meta;
|
|
296
|
+
try {
|
|
297
|
+
meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
|
298
|
+
} catch {
|
|
299
|
+
pass();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const claimed = STATUSES.find((status) => new RegExp(`(^|\\n)\\s*\`*${status}\\b`).test(reply));
|
|
303
|
+
if (claimed && meta.status && claimed !== meta.status) {
|
|
304
|
+
blockForm(
|
|
305
|
+
`Contract violated: you reported ${claimed}, but the run's meta.json says ${meta.status} ` +
|
|
306
|
+
`(${meta.reason || 'no reason given'}). The runner calculates status from artifacts — return ` +
|
|
307
|
+
'its output verbatim without substituting your own judgment.',
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
pass();
|