@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.
@@ -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` — pass the `--continue` flag only when the orchestrator gave the matching `continue:`
40
- grant explicitly; do not add or guess it yourself. A continuation is assigned by the
41
- orchestrator, never chosen by you. After the verdict, return the exact attaching output and stop;
42
- do not issue or invent another continuation. The contract is strict, like `--scope` in
43
- codex-build: if this task (the same `slug` and the same repository) already had a run, the runner
44
- will refuse to start without `--continue` — before starting Codex, so no quota is spent.
40
+ - `continue` — when the task text contains a line beginning with the `continue:` label, pass the
41
+ bare `--continue` flag even when its run name or reason looks malformed. Do not inspect, repair,
42
+ or swallow this grant; pass it through and let the runner issue the refusal. A continuation is
43
+ assigned by the orchestrator, never chosen by you. After the verdict, return the exact attaching
44
+ output and stop; do not issue or invent another continuation. If no such grant line is present,
45
+ the flag must not be present.
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 orchestrator gave the matching `continue:` grant, add the bare `--continue` flag to the
66
- command, with no value.
66
+ If the task text contains a line beginning with the `continue:` label, add the bare `--continue`
67
+ flag even when its run name or reason looks malformed. Do not inspect, repair, or swallow the line;
68
+ the runner parses it and issues the refusal. If no such grant line is present, the flag must not be
69
+ present in the command.
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
@@ -4,8 +4,8 @@ allowed-tools: Bash
4
4
  argument-hint: "[hooks|plugins on|off] | [reset]"
5
5
  ---
6
6
 
7
- <!-- Part of the agents/codex/ package. The file lives here out of necessity: the slash command name
8
- is set by its location under commands/<namespace>/, and Claude Code understands neither
7
+ <!-- Part of the agents/codex-bridge/ package. The file lives here out of necessity: the slash
8
+ command name is set by its location under commands/<namespace>/, and Claude Code understands neither
9
9
  symlinks nor pointer files (verified 2026-08-02). Edit it here; it travels as a copy when the
10
10
  package is installed. -->
11
11
 
@@ -30,8 +30,8 @@ Call forms:
30
30
  - `plugins on` / `plugins off` — the same for plugins;
31
31
  - `reset` — back to the default (both off).
32
32
 
33
- The state lives in `agents/codex/run-config.json` and lands in the `meta.json` of every run, so the
34
- artifacts always show which environment a run went through.
33
+ The state lives in `~/.lyupro/.codex-bridge/config.json` and lands in the `meta.json` of every run,
34
+ so the artifacts always show which environment a run went through.
35
35
 
36
36
  If the user turns `hooks` on, say in one line: while the failing `Stop` hook of `oh-my-codex` stays
37
37
  in `~/.codex/hooks.json`, runs will again spend quota on unrelated repairs. Checking the report
@@ -3,8 +3,8 @@ description: Show the ChatGPT quota spent on runs delegated to Codex — by day,
3
3
  allowed-tools: Bash
4
4
  ---
5
5
 
6
- <!-- Part of the agents/codex/ package. The file lives here out of necessity: the slash command name
7
- is set by its location under commands/<namespace>/, and Claude Code understands neither
6
+ <!-- Part of the agents/codex-bridge/ package. The file lives here out of necessity: the slash
7
+ command name is set by its location under commands/<namespace>/, and Claude Code understands neither
8
8
  symlinks nor pointer files (verified 2026-08-02). Edit it here; it travels as a copy when the
9
9
  package is installed. -->
10
10
 
@@ -44,14 +44,14 @@ for(const proj of fs.readdirSync(root)){
44
44
  try{repo=JSON.parse(fs.readFileSync(path.join(pdir,'.project.json'),'utf8')).repo||null}catch{}
45
45
  for(const run of fs.readdirSync(pdir)){
46
46
  const dir=path.join(pdir,run),meta=path.join(dir,'meta.json'),log=path.join(dir,'raw.log');
47
- let tokens=null,agent=null,sandbox=null;
47
+ let tokens=null,agent=null,sandbox=null,runnerVersion=false;
48
48
  if(fs.existsSync(meta)){
49
- try{const m=JSON.parse(fs.readFileSync(meta,'utf8'));tokens=m.tokens??null;agent=m.agent||null;sandbox=m.sandbox||null}catch{}
49
+ try{const m=JSON.parse(fs.readFileSync(meta,'utf8'));tokens=m.tokens??null;agent=m.agent||null;sandbox=m.sandbox||null;runnerVersion=Object.hasOwn(m,'runner_version')}catch{}
50
50
  } else if(fs.existsSync(log)){
51
51
  const hit=[...fs.readFileSync(log,'utf8').matchAll(/tokens used[\r\n]+([^\r\n]+)/g)].pop();
52
52
  tokens=hit?parseInt(hit[1].replace(/\D/g,''),10)||null:null;
53
53
  } else continue;
54
- const entry={proj,repo,run,day:run.slice(0,10),agent:agent||(/review/.test(run)?'codex-review':/build/.test(run)?'codex-build':'codex-scout'),sandbox};
54
+ const entry={proj,repo,run,day:run.slice(0,10),agent:agent||(/review/.test(run)?'codex-review':/build/.test(run)?'codex-build':'codex-scout'),sandbox,runnerVersion};
55
55
  if(tokens===null){unknown.push(entry);continue}
56
56
  runs.push({...entry,tokens});
57
57
  }
@@ -63,7 +63,9 @@ const fmt=n=>n.toLocaleString('en-US');
63
63
  const row=(l,rs)=>' '+String(l).padEnd(22)+String(rs.length).padStart(3)+' runs '+fmt(sum(rs)).padStart(11)+' average '+fmt(Math.round(sum(rs)/rs.length));
64
64
  if(runs.length)console.log('TOTAL: '+fmt(sum(runs))+' tokens over '+runs.length+' runs · average '+fmt(Math.round(sum(runs)/runs.length)));
65
65
  if(unknown.length)console.log('Unaccounted: '+unknown.length+' runs (Codex reported no spending) — the total is understated');
66
- const risky=[...runs,...unknown].filter(r=>r.sandbox&&r.sandbox!=='read-only'&&r.sandbox!=='workspace-write');
66
+ const legacy=[...runs,...unknown].filter(r=>!r.runnerVersion);
67
+ if(legacy.length)console.log('Before the runner: '+legacy.length+' runs carry no runner version and are not judged by the sandbox contract');
68
+ const risky=[...runs,...unknown].filter(r=>r.runnerVersion&&r.sandbox&&r.sandbox!=='read-only'&&r.sandbox!=='workspace-write');
67
69
  if(risky.length)console.log('WARNING: '+risky.length+' runs went outside the usual sandbox: '+risky.map(r=>r.run+' ('+r.sandbox+')').join(', '));
68
70
  if(runs.length){
69
71
  console.log('');console.log('By day:');
@@ -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
+ }
@@ -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 { IDENTITY_DEAD, IDENTITY_FOREIGN, processIdentity } from '../process-identity.mjs';
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
- export const isPidAlive = (pid, runDir, status = {}) => {
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
- && isHeartbeatFresh(runDir)
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);
@@ -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
- opts.slug = (opts.slug || opts.agent.replace(/^codex-/, '')).replace(/[^A-Za-z0-9._-]+/g, '-');
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
@@ -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
- console.log(`ATTACH=${dir} started=${entry.status.started_at}`);
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
- console.log(`ATTACH=${runDir} started=${candidate.status.started_at}`);
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
- `${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
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
 
@@ -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 = opts.continue ? parseContinuationGrant(taskText) : null;
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
+ }
@@ -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(),