@lyupro/codex-bridge 0.4.0 → 0.5.1
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 +63 -0
- package/README.md +112 -96
- package/bin/codex-bridge.mjs +11 -7
- package/cli/copy.mjs +7 -6
- package/cli/doctor.mjs +56 -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 +67 -39
- package/cli/invoked-directly.mjs +37 -0
- package/cli/manifest.mjs +88 -173
- package/cli/projects.mjs +20 -5
- package/cli/remove-layout.mjs +59 -0
- package/cli/rules-owners.mjs +24 -2
- package/cli/settings-merge.mjs +91 -7
- package/cli/uninstall.mjs +47 -32
- package/cli/update.mjs +109 -48
- package/package.json +2 -2
- package/scripts/isolated-roots.mjs +13 -0
- package/scripts/run-tests.mjs +14 -4
- package/src/agents/codex-build.md +15 -13
- package/src/agents/codex-review.md +13 -9
- package/src/agents/codex-scout.md +14 -11
- package/src/commands/env.md +4 -4
- package/src/commands/usage.md +8 -6
- package/src/hook-definitions.mjs +17 -4
- package/src/hooks/guard-tries.mjs +59 -0
- package/src/hooks/live-runs.mjs +20 -10
- package/src/hooks/reply-guard.mjs +1 -58
- package/src/hooks/stop-guard.mjs +71 -0
- package/src/runner/args.mjs +10 -1
- package/src/runner/attach.mjs +9 -2
- package/src/runner/continuation.mjs +34 -3
- package/src/runner/launcher.mjs +15 -13
- package/src/stop-contract.mjs +15 -0
- package/src/write-meta.mjs +4 -0
|
@@ -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
|
---
|
|
@@ -26,7 +26,8 @@ files, do not run grep, do not retell the report, and do not reason about the ta
|
|
|
26
26
|
- Scope patterns are globs relative to the repository root. A pattern that matches nothing there is
|
|
27
27
|
refused before the run starts.
|
|
28
28
|
- Optional: `effort: <none|minimal|low|medium|high|xhigh|max>` — Codex reasoning depth.
|
|
29
|
-
- 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.
|
|
30
31
|
- Every input listed under **Required dispatcher inputs** above, passed on exactly as given:
|
|
31
32
|
`order id` as `--order-id`. Never invent a value, never edit one, never reuse an order id from
|
|
32
33
|
another order — the runner chains runs by that label, and a made-up label is how a repeat run
|
|
@@ -36,12 +37,12 @@ files, do not run grep, do not retell the report, and do not reason about the ta
|
|
|
36
37
|
pass one `--question "<text>"` flag exactly as given. Never invent, merge, reword, or drop a
|
|
37
38
|
sub-question. If the orchestrator did not give one, do not guess — start the runner without
|
|
38
39
|
the flag and return its refusal verbatim.
|
|
39
|
-
- `continue` —
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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.
|
|
45
46
|
|
|
46
47
|
## The only thing you do
|
|
47
48
|
|
|
@@ -62,8 +63,10 @@ Add `--effort "<value>"` only when the orchestrator named a depth, and only with
|
|
|
62
63
|
decides, which is the intended default — a placeholder copied from this template is refused before
|
|
63
64
|
Codex starts.
|
|
64
65
|
|
|
65
|
-
If the
|
|
66
|
-
|
|
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.
|
|
67
70
|
No value
|
|
68
71
|
is passed to it: the runner accepts only `1/true/yes/0/false/no`, while a placeholder string left in
|
|
69
72
|
the
|
|
@@ -119,7 +122,7 @@ The flags are proven, and they do not belong in the prompt: `--ignore-user-confi
|
|
|
119
122
|
startup ballast (~9k quota instead of ~19k) and structurally blocks writing — no flags can override
|
|
120
123
|
read-only; `--disable hooks --disable plugins` disable the operator's extensions for this call,
|
|
121
124
|
so the run does not depend on what is installed in `~/.codex` today (the default, switched
|
|
122
|
-
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
|
|
123
126
|
are volatile.
|
|
124
127
|
The run is asynchronous, and waiting for it is a separate call. That is deliberate: while the start
|
|
125
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/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
|
@@ -13,17 +13,25 @@
|
|
|
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
17
|
import { readJsonFileSync } from '../json-file.mjs';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
IDENTITY_ALIVE,
|
|
20
|
+
IDENTITY_DEAD,
|
|
21
|
+
IDENTITY_FOREIGN,
|
|
22
|
+
processIdentity,
|
|
23
|
+
} from '../process-identity.mjs';
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
26
|
* The hook requires pid judgment plus a fresh heartbeat. Identity uncertainty stays live here
|
|
22
27
|
* (fail-open), while the identity module deliberately treats a missing heartbeat as unverified;
|
|
23
28
|
* isHeartbeatFresh() keeps its pre-Plan_20 missing-file compatibility for this separate question.
|
|
24
29
|
*/
|
|
25
|
-
|
|
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 = {}) => {
|
|
26
33
|
const identity = processIdentity({ runDir, status: { ...status, pid } });
|
|
34
|
+
if (options.requireConfirmedIdentity === true) return identity === IDENTITY_ALIVE;
|
|
27
35
|
return identity !== IDENTITY_DEAD && identity !== IDENTITY_FOREIGN;
|
|
28
36
|
};
|
|
29
37
|
|
|
@@ -48,7 +56,9 @@ function isNonEmptyString(value) {
|
|
|
48
56
|
return typeof value === 'string' && Boolean(value.trim());
|
|
49
57
|
}
|
|
50
58
|
|
|
51
|
-
function recognizedStatus(runDir, status) {
|
|
59
|
+
function recognizedStatus(runDir, status, options) {
|
|
60
|
+
const heartbeatFresh = isHeartbeatFresh(runDir)
|
|
61
|
+
&& (options.requireConfirmedIdentity !== true || heartbeatAge(runDir) !== null);
|
|
52
62
|
return Boolean(status)
|
|
53
63
|
&& typeof status === 'object'
|
|
54
64
|
&& !Array.isArray(status)
|
|
@@ -57,8 +67,8 @@ function recognizedStatus(runDir, status) {
|
|
|
57
67
|
&& isNonEmptyString(status.agent)
|
|
58
68
|
&& isNonEmptyString(status.slug)
|
|
59
69
|
&& isNonEmptyString(status.repo)
|
|
60
|
-
&&
|
|
61
|
-
&& isPidAlive(status.pid, runDir, status);
|
|
70
|
+
&& heartbeatFresh
|
|
71
|
+
&& isPidAlive(status.pid, runDir, status, options);
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
function readStatus(runDir) {
|
|
@@ -70,7 +80,7 @@ function readStatus(runDir) {
|
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
/** Scan one project's direct run folders; unreadable or unfamiliar entries are ignored. */
|
|
73
|
-
export function liveRuns(runsDir) {
|
|
83
|
+
export function liveRuns(runsDir, options = {}) {
|
|
74
84
|
if (typeof runsDir !== 'string' || !runsDir.trim()) return [];
|
|
75
85
|
let entries;
|
|
76
86
|
try {
|
|
@@ -83,13 +93,13 @@ export function liveRuns(runsDir) {
|
|
|
83
93
|
if (!entry.isDirectory()) continue;
|
|
84
94
|
const dir = path.join(runsDir, entry.name);
|
|
85
95
|
const status = readStatus(dir);
|
|
86
|
-
if (recognizedStatus(dir, status)) result.push({ dir, status });
|
|
96
|
+
if (recognizedStatus(dir, status, options)) result.push({ dir, status });
|
|
87
97
|
}
|
|
88
98
|
return result;
|
|
89
99
|
}
|
|
90
100
|
|
|
91
101
|
/** Scan every project under the configured runs root for worktree ownership. */
|
|
92
|
-
export function allLiveRuns(runsRoot) {
|
|
102
|
+
export function allLiveRuns(runsRoot, options = {}) {
|
|
93
103
|
if (typeof runsRoot !== 'string' || !runsRoot.trim()) return [];
|
|
94
104
|
let projects;
|
|
95
105
|
try {
|
|
@@ -100,7 +110,7 @@ export function allLiveRuns(runsRoot) {
|
|
|
100
110
|
const result = [];
|
|
101
111
|
for (const project of projects) {
|
|
102
112
|
if (!project.isDirectory()) continue;
|
|
103
|
-
result.push(...liveRuns(path.join(runsRoot, project.name)));
|
|
113
|
+
result.push(...liveRuns(path.join(runsRoot, project.name), options));
|
|
104
114
|
}
|
|
105
115
|
return result;
|
|
106
116
|
}
|
|
@@ -31,10 +31,10 @@ import os from 'node:os';
|
|
|
31
31
|
import path from 'node:path';
|
|
32
32
|
import { readJsonFileSync } from '../json-file.mjs';
|
|
33
33
|
import { isPidAlive, liveRuns, normalizePath } from './live-runs.mjs';
|
|
34
|
+
import { FORM, MAX_STATE_BLOCKS, STATE, takeTry } from './guard-tries.mjs';
|
|
34
35
|
|
|
35
36
|
const HOME = os.homedir();
|
|
36
37
|
const LOG_DIR = path.join(HOME, '.claude', 'logs');
|
|
37
|
-
const BLOCKED_FILE = path.join(LOG_DIR, 'codex-reply-guard.blocked.json');
|
|
38
38
|
const GUARDED = new Set(['codex-scout', 'codex-build', 'codex-review']);
|
|
39
39
|
const STATUSES = ['OK', 'FAIL', 'LIMIT'];
|
|
40
40
|
|
|
@@ -48,8 +48,6 @@ const STATUSES = ['OK', 'FAIL', 'LIMIT'];
|
|
|
48
48
|
* quota on retries that never converge. Three tries, then let it through so the operator
|
|
49
49
|
* sees a reply with substance in it and judges for himself.
|
|
50
50
|
*/
|
|
51
|
-
const MAX_FORM_BLOCKS = 3;
|
|
52
|
-
|
|
53
51
|
/**
|
|
54
52
|
* The same allowance for blocks caused by EXTERNAL state — the run is still going, or its
|
|
55
53
|
* runner died. Counted separately because the two run out for unrelated reasons: three
|
|
@@ -61,11 +59,6 @@ const MAX_FORM_BLOCKS = 3;
|
|
|
61
59
|
* orchestrator got a promise from a process that no longer existed and never learned the
|
|
62
60
|
* worktree was busy.
|
|
63
61
|
*/
|
|
64
|
-
const MAX_STATE_BLOCKS = 3;
|
|
65
|
-
|
|
66
|
-
const FORM = 'form';
|
|
67
|
-
const STATE = 'state';
|
|
68
|
-
|
|
69
62
|
/** Never let a guard failure break real work: on any doubt, stay silent. */
|
|
70
63
|
const pass = () => process.exit(0);
|
|
71
64
|
|
|
@@ -91,56 +84,6 @@ if (!GUARDED.has(input.agent_type)) pass();
|
|
|
91
84
|
const reply = String(input.last_assistant_message || '').trim();
|
|
92
85
|
if (!reply) pass();
|
|
93
86
|
|
|
94
|
-
/**
|
|
95
|
-
* Reads one stored entry into {form, state}. Three shapes live in this file at once and all
|
|
96
|
-
* three must keep their meaning: the current pair, the single number written by the previous
|
|
97
|
-
* version, and an ISO string from the version before that (it meant "blocked once"). A
|
|
98
|
-
* number is read as form tries — that budget is the one the old counter mostly guarded.
|
|
99
|
-
* Anything else counts as one form try already spent, so an agent recorded before this fix
|
|
100
|
-
* does not silently get its tries back.
|
|
101
|
-
*/
|
|
102
|
-
const readCounts = (prior) => {
|
|
103
|
-
const whole = (value) => (Number.isInteger(value) && value > 0 ? value : 0);
|
|
104
|
-
if (prior && typeof prior === 'object' && !Array.isArray(prior)) {
|
|
105
|
-
return { form: whole(prior.form), state: whole(prior.state) };
|
|
106
|
-
}
|
|
107
|
-
if (typeof prior === 'number') return { form: whole(prior), state: 0 };
|
|
108
|
-
return { form: prior ? 1 : 0, state: 0 };
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Spends one try of the named budget. stop_hook_active is not reliable here (it already
|
|
113
|
-
* arrives true in normal runs), so the loop protection is our own.
|
|
114
|
-
*
|
|
115
|
-
* 'granted' — block normally, a try was recorded;
|
|
116
|
-
* 'exhausted' — this budget is used up, the caller decides what that means;
|
|
117
|
-
* 'untracked' — the guard cannot count at all (no agent id, log not writable). A guard
|
|
118
|
-
* that cannot count must not decide anything, least of all end a session.
|
|
119
|
-
*/
|
|
120
|
-
const takeTry = (agentId, kind) => {
|
|
121
|
-
if (!agentId) return 'untracked';
|
|
122
|
-
let seen;
|
|
123
|
-
try {
|
|
124
|
-
seen = readJsonFileSync(BLOCKED_FILE);
|
|
125
|
-
} catch {
|
|
126
|
-
seen = {};
|
|
127
|
-
}
|
|
128
|
-
if (!seen || typeof seen !== 'object' || Array.isArray(seen)) seen = {};
|
|
129
|
-
const counts = readCounts(seen[agentId]);
|
|
130
|
-
if (counts[kind] >= (kind === STATE ? MAX_STATE_BLOCKS : MAX_FORM_BLOCKS)) return 'exhausted';
|
|
131
|
-
counts[kind] += 1;
|
|
132
|
-
seen[agentId] = counts;
|
|
133
|
-
// Keep the file from growing without bound; order of insertion is good enough.
|
|
134
|
-
const ids = Object.keys(seen);
|
|
135
|
-
if (ids.length > 200) ids.slice(0, ids.length - 200).forEach((id) => delete seen[id]);
|
|
136
|
-
try {
|
|
137
|
-
fs.writeFileSync(BLOCKED_FILE, `${JSON.stringify(seen)}\n`);
|
|
138
|
-
} catch {
|
|
139
|
-
return 'untracked';
|
|
140
|
-
}
|
|
141
|
-
return 'granted';
|
|
142
|
-
};
|
|
143
|
-
|
|
144
87
|
const emit = (payload) => {
|
|
145
88
|
process.stdout.write(JSON.stringify(payload));
|
|
146
89
|
process.exit(0);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { STOP_TOOLS } from '../hook-definitions.mjs';
|
|
4
|
+
import { runsRoot } from '../runner/runs-root.mjs';
|
|
5
|
+
import { renderStopCommand, STOP_REASON } from '../stop-contract.mjs';
|
|
6
|
+
import { allLiveRuns, normalizePath } from './live-runs.mjs';
|
|
7
|
+
import { takeTry } from './guard-tries.mjs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Plan_31 catches TaskStop killing the dispatcher wrapper while the runner keeps writing. It
|
|
11
|
+
* must stay silent on malformed input or uncertain liveness, and the second denial budget keeps
|
|
12
|
+
* an unrelated host task from becoming impossible to stop.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const pass = () => process.exit(0);
|
|
16
|
+
const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
|
|
18
|
+
function readInput() {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function runForRepository(cwd) {
|
|
27
|
+
const repository = normalizePath(cwd);
|
|
28
|
+
if (!repository) return null;
|
|
29
|
+
|
|
30
|
+
let live;
|
|
31
|
+
try {
|
|
32
|
+
live = allLiveRuns(runsRoot(), { requireConfirmedIdentity: true });
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return live
|
|
38
|
+
.filter(({ status }) => {
|
|
39
|
+
const runRepository = normalizePath(status.repo);
|
|
40
|
+
return runRepository === repository || repository.startsWith(`${runRepository}/`);
|
|
41
|
+
})
|
|
42
|
+
.sort(({ status: left }, { status: right }) => right.repo.length - left.repo.length)[0] ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function deny(run) {
|
|
46
|
+
const runName = path.basename(run.dir);
|
|
47
|
+
// Key by run folder: the host task_id can belong to an unrelated background build, so it cannot
|
|
48
|
+
// safely identify the dispatcher this guard just warned about.
|
|
49
|
+
if (takeTry(normalizePath(run.dir), 'stop', 1) !== 'granted') pass();
|
|
50
|
+
|
|
51
|
+
process.stdout.write(JSON.stringify({
|
|
52
|
+
hookSpecificOutput: {
|
|
53
|
+
hookEventName: 'PreToolUse',
|
|
54
|
+
permissionDecision: 'deny',
|
|
55
|
+
permissionDecisionReason:
|
|
56
|
+
`TaskStop denied: live run ${runName} is working. `
|
|
57
|
+
+ `Use ${renderStopCommand(runName)}. ${STOP_REASON}`,
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const input = readInput();
|
|
63
|
+
if (!isObject(input) || !STOP_TOOLS.includes(input.tool_name)) pass();
|
|
64
|
+
if (!isObject(input.tool_input) || typeof input.tool_input.task_id !== 'string' || !input.tool_input.task_id) {
|
|
65
|
+
pass();
|
|
66
|
+
}
|
|
67
|
+
if (typeof input.cwd !== 'string' || !input.cwd) pass();
|
|
68
|
+
|
|
69
|
+
const run = runForRepository(input.cwd);
|
|
70
|
+
if (!run) pass();
|
|
71
|
+
deny(run);
|
package/src/runner/args.mjs
CHANGED
|
@@ -97,7 +97,16 @@ export function parseArgs(argv) {
|
|
|
97
97
|
die(`--effort must be one of: ${allowedEfforts}; got ${JSON.stringify(opts.effort)}`);
|
|
98
98
|
}
|
|
99
99
|
opts.repo = path.resolve(opts.repo || process.cwd());
|
|
100
|
-
|
|
100
|
+
const slugSource = opts.slug ? '--slug' : '--order-id';
|
|
101
|
+
opts.slug = (opts.slug || opts.orderId).replace(/[^A-Za-z0-9._-]+/g, '-');
|
|
102
|
+
// Plan_29 incident: the generic `build` slug made an honest order inherit a days-old chain;
|
|
103
|
+
// reject a value with no alphanumeric anchor instead of creating an empty or dot-only folder.
|
|
104
|
+
if (!/[A-Za-z0-9]/.test(opts.slug)) {
|
|
105
|
+
die(
|
|
106
|
+
`${slugSource} produces an unusable run folder name after sanitization: ` +
|
|
107
|
+
`${JSON.stringify(opts.slug)} must contain a letter or digit.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
101
110
|
opts.mode = opts.mode || 'uncommitted';
|
|
102
111
|
// A writing run without a declared scope is how `!Plans/*.md` got edited by a run that was
|
|
103
112
|
// told in prose not to touch them: prose does not bind, a file list does. Required rather
|
package/src/runner/attach.mjs
CHANGED
|
@@ -10,6 +10,13 @@ import { exitCodeFor, writeFailure, chainRuns } from '../write-meta.mjs';
|
|
|
10
10
|
const POLL_MS = 500;
|
|
11
11
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
12
|
|
|
13
|
+
// The 2026-08-10_220535_plan25-2-install-table-two-roots incident showed that ATTACH alone
|
|
14
|
+
// can be mistaken for this pass; state the previous run and the fact that no work was started.
|
|
15
|
+
function announceAttach(runDir, startedAt) {
|
|
16
|
+
console.log(`ATTACH=${runDir} started=${startedAt}`);
|
|
17
|
+
console.log(`This is the answer of the previous run started at ${startedAt}; no new work was started.`);
|
|
18
|
+
}
|
|
19
|
+
|
|
13
20
|
export const readJsonFile = (file) => {
|
|
14
21
|
try {
|
|
15
22
|
return readJsonFileSync(file);
|
|
@@ -86,7 +93,7 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
|
|
|
86
93
|
const entry = sameOrder[i];
|
|
87
94
|
const dir = path.join(runsRoot, entry.run);
|
|
88
95
|
if (fs.existsSync(path.join(dir, 'reply.txt'))) {
|
|
89
|
-
|
|
96
|
+
announceAttach(dir, entry.status.started_at);
|
|
90
97
|
console.log(fs.readFileSync(path.join(dir, 'reply.txt'), 'utf8').replace(/\s+$/, ''));
|
|
91
98
|
return exitCodeFor(readJsonFile(path.join(dir, 'meta.json'))?.status);
|
|
92
99
|
}
|
|
@@ -98,7 +105,7 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
|
|
|
98
105
|
if (!candidate) return null;
|
|
99
106
|
|
|
100
107
|
const runDir = path.join(runsRoot, candidate.run);
|
|
101
|
-
|
|
108
|
+
announceAttach(runDir, candidate.status.started_at);
|
|
102
109
|
const replyText = await waitForReply(runDir, candidate.status.pid, candidate.status);
|
|
103
110
|
if (replyText !== null) {
|
|
104
111
|
console.log(replyText);
|
|
@@ -37,6 +37,28 @@ const grantExample = `Example: \`continue: ${CONTINUATION_INPUT.example}\`.`;
|
|
|
37
37
|
const grantAction =
|
|
38
38
|
'Action: ask the orchestrator for an explicit grant naming the run and reason; do not invent or reuse a continuation.';
|
|
39
39
|
|
|
40
|
+
// The 2026-08-10_220535_plan25-2-install-table-two-roots incident showed that a refusal must
|
|
41
|
+
// carry the repair line; otherwise a typo forces an unnecessary directory listing and retry.
|
|
42
|
+
function lastRunOutcome(runsRootPath, chain) {
|
|
43
|
+
const run = chain[chain.length - 1];
|
|
44
|
+
if (!run) return null;
|
|
45
|
+
const status = readJson(path.join(runsRootPath, run, 'status.json')) || {};
|
|
46
|
+
const meta = readJson(path.join(runsRootPath, run, 'meta.json')) || {};
|
|
47
|
+
const outcomeStatus = String(meta.status ?? status.status ?? status.state ?? 'unknown').trim() || 'unknown';
|
|
48
|
+
const outcomeReason = String(meta.reason ?? status.reason ?? 'reason not recorded').trim() || 'reason not recorded';
|
|
49
|
+
return { run, status: outcomeStatus, reason: outcomeReason };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function lastRunHints(runsRootPath, chain, grantReason) {
|
|
53
|
+
const last = lastRunOutcome(runsRootPath, chain);
|
|
54
|
+
if (!last) {
|
|
55
|
+
return 'Last run: none. Outcome: none. Ready grant line: none (there is no run to continue).';
|
|
56
|
+
}
|
|
57
|
+
const reason = String(grantReason ?? last.reason).trim() || last.reason;
|
|
58
|
+
return `Last run: ${last.run}. Outcome: ${last.status} — ${last.reason}. ` +
|
|
59
|
+
`Ready grant line: continue: ${last.run} — ${reason}.`;
|
|
60
|
+
}
|
|
61
|
+
|
|
40
62
|
/**
|
|
41
63
|
* Applies the one-continuation limit and all continuation safety gates before a folder is created.
|
|
42
64
|
*
|
|
@@ -49,9 +71,16 @@ const grantAction =
|
|
|
49
71
|
* unrunnable, which is a worse failure than the retry storm this limit exists to stop.
|
|
50
72
|
*/
|
|
51
73
|
export function continuationRefusal(runsRootPath, chain, isContinue, orderId, grant) {
|
|
52
|
-
if (!isContinue) return null;
|
|
53
|
-
|
|
54
74
|
const continuation = normalizeGrant(grant);
|
|
75
|
+
if (!isContinue) {
|
|
76
|
+
if (!continuation) return null;
|
|
77
|
+
return (
|
|
78
|
+
'--continue is required when the task text contains a `continue:` grant. ' +
|
|
79
|
+
`${lastRunHints(runsRootPath, chain, continuation.reason)} ` +
|
|
80
|
+
'The submitted grant was not rewritten. The run folder was not created; quota was not spent.'
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
55
84
|
if (!continuation) {
|
|
56
85
|
return (
|
|
57
86
|
'--continue is refused: the orchestrator did not provide a `continue:` grant in the task text. ' +
|
|
@@ -64,7 +93,9 @@ export function continuationRefusal(runsRootPath, chain, isContinue, orderId, gr
|
|
|
64
93
|
return (
|
|
65
94
|
`--continue is refused: the orchestrator named run “${continuation.run}”, but it does not ` +
|
|
66
95
|
`exist as a run folder in this project's runs directory ${runsRootPath}. ` +
|
|
67
|
-
`${
|
|
96
|
+
`${lastRunHints(runsRootPath, chain, continuation.reason)} ` +
|
|
97
|
+
`${grantExample} ${grantAction} The submitted grant was not rewritten. ` +
|
|
98
|
+
'The run folder was not created; quota was not spent.'
|
|
68
99
|
);
|
|
69
100
|
}
|
|
70
101
|
|
package/src/runner/launcher.mjs
CHANGED
|
@@ -135,7 +135,21 @@ export async function launcher() {
|
|
|
135
135
|
const taskHash = taskFingerprint(taskText);
|
|
136
136
|
const chain = chainRuns(projectRunsRoot, repoRoot, opts.slug, taskHash, opts.orderId);
|
|
137
137
|
const startedChain = startedRuns(projectRunsRoot, chain);
|
|
138
|
-
const continuationGrant =
|
|
138
|
+
const continuationGrant = parseContinuationGrant(taskText);
|
|
139
|
+
|
|
140
|
+
// The 2026-08-10_220535_plan25-2-install-table-two-roots incident exposed this ordering:
|
|
141
|
+
// a grant without its flag must refuse before attach can print an older run's verdict.
|
|
142
|
+
// markAbandoned() ran before this gate, so a dead runner already has meta.json with FAIL.
|
|
143
|
+
// A run without a verdict can therefore only still be in flight; ordinary repeats go through
|
|
144
|
+
// attach(), while --continue must refuse to overlap that live work.
|
|
145
|
+
const continuationError = continuationRefusal(
|
|
146
|
+
projectRunsRoot,
|
|
147
|
+
startedChain,
|
|
148
|
+
opts.continue,
|
|
149
|
+
opts.orderId,
|
|
150
|
+
continuationGrant,
|
|
151
|
+
);
|
|
152
|
+
if (continuationError) die(continuationError);
|
|
139
153
|
|
|
140
154
|
// One order produced six Codex runs on 2026-08-03 because the caller's time ceiling made it
|
|
141
155
|
// restart the synchronous launcher. A live same-order run is now the repeat target: attach
|
|
@@ -151,18 +165,6 @@ export async function launcher() {
|
|
|
151
165
|
});
|
|
152
166
|
if (attachedExitCode !== null) process.exit(attachedExitCode);
|
|
153
167
|
|
|
154
|
-
// markAbandoned() ran before this gate, so a dead runner already has meta.json with FAIL.
|
|
155
|
-
// A run without a verdict can therefore only still be in flight; ordinary repeats go through
|
|
156
|
-
// attach(), while --continue must refuse to overlap that live work.
|
|
157
|
-
const continuationError = continuationRefusal(
|
|
158
|
-
projectRunsRoot,
|
|
159
|
-
startedChain,
|
|
160
|
-
opts.continue,
|
|
161
|
-
opts.orderId,
|
|
162
|
-
continuationGrant,
|
|
163
|
-
);
|
|
164
|
-
if (continuationError) die(continuationError);
|
|
165
|
-
|
|
166
168
|
if (startedChain.length && !opts.continue) {
|
|
167
169
|
const last = startedChain[startedChain.length - 1];
|
|
168
170
|
const lastSlug = String(readJson(path.join(projectRunsRoot, last, 'status.json'))?.slug || '');
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const STOP_COMMAND = 'codex-bridge stop';
|
|
2
|
+
export const STOP_COMMAND_TEMPLATE = `${STOP_COMMAND} <run>`;
|
|
3
|
+
export const STOP_REASON =
|
|
4
|
+
'TaskStop removes the wrapper while the run keeps writing to the worktree.';
|
|
5
|
+
|
|
6
|
+
export function renderStopCommand(run) {
|
|
7
|
+
if (typeof run !== 'string' || !run.trim()) {
|
|
8
|
+
throw new TypeError('A run folder name is required to render the stop command.');
|
|
9
|
+
}
|
|
10
|
+
return `${STOP_COMMAND} ${run.trim()}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function renderStopSummary() {
|
|
14
|
+
return `Stop live runs with \`${STOP_COMMAND_TEMPLATE}\` before \`TaskStop\`: ${STOP_REASON}`;
|
|
15
|
+
}
|
package/src/write-meta.mjs
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import fs from 'node:fs';
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { readJsonFileSync } from './json-file.mjs';
|
|
25
26
|
import { changedPaths, readJson, readText, size } from './meta/paths.mjs';
|
|
26
27
|
import { splitRunChanges } from './meta/environment.mjs';
|
|
27
28
|
import { readEvents } from './meta/events.mjs';
|
|
@@ -29,6 +30,8 @@ import { writeStatus } from './meta/run-state.mjs';
|
|
|
29
30
|
import { resolveStatus } from './meta/verdict.mjs';
|
|
30
31
|
import { AGENTS, failReply, limitReply } from './meta/reply.mjs';
|
|
31
32
|
|
|
33
|
+
const RUNNER_VERSION = readJsonFileSync(new URL('../package.json', import.meta.url)).version;
|
|
34
|
+
|
|
32
35
|
export { expandDeclared, globToRegExp, readJson } from './meta/paths.mjs';
|
|
33
36
|
export { chainBaseline, chainRuns, taskFingerprint } from './meta/chain.mjs';
|
|
34
37
|
export { abortedPreStart, startedRuns } from './meta/pre-start.mjs';
|
|
@@ -84,6 +87,7 @@ export function collect(runDir, agent, exitCode) {
|
|
|
84
87
|
|
|
85
88
|
const meta = {
|
|
86
89
|
agent,
|
|
90
|
+
runner_version: RUNNER_VERSION,
|
|
87
91
|
project: path.basename(path.dirname(runDir)),
|
|
88
92
|
run: path.basename(runDir),
|
|
89
93
|
finished_at: new Date().toISOString(),
|