@lyupro/codex-bridge 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +104 -1
  2. package/README.md +62 -20
  3. package/bin/codex-bridge.mjs +4 -0
  4. package/cli/doctor.mjs +118 -19
  5. package/cli/frontmatter.mjs +137 -0
  6. package/cli/hook.mjs +2 -2
  7. package/cli/install-record.mjs +2 -2
  8. package/cli/install.mjs +14 -2
  9. package/cli/manifest.mjs +23 -22
  10. package/cli/permissions.mjs +2 -2
  11. package/cli/projects.mjs +3 -3
  12. package/cli/prune-plan.mjs +2 -2
  13. package/cli/prune.mjs +1 -1
  14. package/cli/read.mjs +2 -2
  15. package/cli/rules-owners.mjs +26 -4
  16. package/cli/run-lookup.mjs +3 -3
  17. package/cli/runs-inventory.mjs +3 -3
  18. package/cli/settings-merge.mjs +1 -1
  19. package/cli/stop.mjs +5 -5
  20. package/cli/unlock.mjs +7 -7
  21. package/package.json +2 -2
  22. package/scripts/check-file-size.mjs +1 -1
  23. package/src/agents/codex-build.md +265 -231
  24. package/src/agents/codex-review.md +219 -184
  25. package/src/agents/codex-scout.md +232 -200
  26. package/src/{hooks → home/hooks}/guard-tries.mjs +1 -1
  27. package/src/{hooks → home/hooks}/live-runs.mjs +43 -3
  28. package/src/home/hooks/order-gate.mjs +135 -0
  29. package/src/{hooks → home/hooks}/prune-guard.mjs +2 -2
  30. package/src/{hooks → home/hooks}/reply-guard.mjs +117 -97
  31. package/src/home/hooks/reply-parser.mjs +27 -0
  32. package/src/home/hooks/reply-verdicts.mjs +174 -0
  33. package/src/{hooks → home/hooks}/stop-guard.mjs +3 -3
  34. package/src/{hooks → home/hooks}/worktree-lock.mjs +3 -3
  35. package/src/home/lib/dispatcher-order.mjs +36 -0
  36. package/src/{hook-definitions.mjs → home/lib/hook-definitions.mjs} +1 -1
  37. package/src/home/lib/host-refusal.mjs +20 -0
  38. package/src/{json-file.mjs → home/lib/json-file.mjs} +4 -4
  39. package/src/{meta → home/lib/meta}/paths.mjs +20 -1
  40. package/src/{meta → home/lib/meta}/reply.mjs +1 -1
  41. package/src/{meta → home/lib/meta}/verdict.mjs +1 -1
  42. package/src/{no-self-execution.mjs → home/lib/no-self-execution.mjs} +7 -2
  43. package/src/{required-inputs.mjs → home/lib/required-inputs.mjs} +56 -5
  44. package/src/{retention.mjs → home/lib/retention.mjs} +1 -1
  45. package/src/{run-codex.mjs → home/lib/run-codex.mjs} +39 -11
  46. package/src/{run-config.mjs → home/lib/run-config.mjs} +2 -2
  47. package/src/{runner → home/lib/runner}/args.mjs +86 -10
  48. package/src/{runner → home/lib/runner}/attach.mjs +58 -9
  49. package/src/{runner → home/lib/runner}/launcher.mjs +33 -21
  50. package/src/home/lib/runner/order-owner.mjs +28 -0
  51. package/src/{runner → home/lib/runner}/run-env.mjs +1 -1
  52. package/src/home/lib/runner/task-file.mjs +92 -0
  53. package/src/home/lib/runner/task-input.mjs +26 -0
  54. package/src/{runner → home/lib/runner}/worker.mjs +1 -1
  55. package/src/home/lib/shell-unsafe.mjs +26 -0
  56. package/src/{write-meta.mjs → home/lib/write-meta.mjs} +12 -1
  57. package/src/hooks/order-gate.mjs +0 -73
  58. /package/src/{run-config.json → home/config.json} +0 -0
  59. /package/src/{conventions.md → home/conventions.md} +0 -0
  60. /package/src/{cli-names.mjs → home/lib/cli-names.mjs} +0 -0
  61. /package/src/{heartbeat.mjs → home/lib/heartbeat.mjs} +0 -0
  62. /package/src/{meta → home/lib/meta}/chain.mjs +0 -0
  63. /package/src/{meta → home/lib/meta}/deadline.mjs +0 -0
  64. /package/src/{meta → home/lib/meta}/environment.mjs +0 -0
  65. /package/src/{meta → home/lib/meta}/events.mjs +0 -0
  66. /package/src/{meta → home/lib/meta}/outcome.mjs +0 -0
  67. /package/src/{meta → home/lib/meta}/pre-start.mjs +0 -0
  68. /package/src/{meta → home/lib/meta}/reason.mjs +0 -0
  69. /package/src/{meta → home/lib/meta}/run-state.mjs +0 -0
  70. /package/src/{meta → home/lib/meta}/startup.mjs +0 -0
  71. /package/src/{meta → home/lib/meta}/transport.mjs +0 -0
  72. /package/src/{process-identity.mjs → home/lib/process-identity.mjs} +0 -0
  73. /package/src/{runner → home/lib/runner}/codex-cmd.mjs +0 -0
  74. /package/src/{runner → home/lib/runner}/continuation.mjs +0 -0
  75. /package/src/{runner → home/lib/runner}/conventions.mjs +0 -0
  76. /package/src/{runner → home/lib/runner}/git-state.mjs +0 -0
  77. /package/src/{runner → home/lib/runner}/project-dir.mjs +0 -0
  78. /package/src/{runner → home/lib/runner}/prompts.mjs +0 -0
  79. /package/src/{runner → home/lib/runner}/run-context.mjs +0 -0
  80. /package/src/{runner → home/lib/runner}/runs-root.mjs +0 -0
  81. /package/src/{runner → home/lib/runner}/schemas.mjs +0 -0
  82. /package/src/{runner → home/lib/runner}/scope-check.mjs +0 -0
  83. /package/src/{stop-contract.mjs → home/lib/stop-contract.mjs} +0 -0
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse gate for the Codex dispatchers (codex-scout / codex-build / codex-review).
4
+ *
5
+ * The runner rejects a dispatcher immediately when order id (and scope for codex-build) is
6
+ * absent, but the old requirement lived only in the prompt read by the dispatcher. The caller
7
+ * therefore had no enforced place to provide it and codex-build died before doing work. This
8
+ * gate checks the producer's task text while it can still be corrected.
9
+ *
10
+ * Input is Claude Code's hook JSON on stdin. The last payload is retained for diagnostics so a
11
+ * future host schema change can be inspected instead of guessed at. Any uncertain shape passes
12
+ * silently because a diagnostic guard must never break an unrelated tool call.
13
+ */
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { readJsonFileSync } from '../lib/json-file.mjs';
18
+ import {
19
+ diagnoseInput,
20
+ extractValue,
21
+ missingInputs,
22
+ parseContinuationGrant,
23
+ shellUnsafeInputs,
24
+ } from '../lib/required-inputs.mjs';
25
+ import { SUBAGENT_TOOLS } from '../lib/hook-definitions.mjs';
26
+ import { taskFingerprint } from '../lib/meta/chain.mjs';
27
+ import { conflictingOrderOwner } from '../lib/runner/order-owner.mjs';
28
+ import { resolveProjectRunsDir } from '../lib/runner/project-dir.mjs';
29
+ import { runsRoot } from '../lib/runner/runs-root.mjs';
30
+ import { parseTaskDocument } from '../lib/runner/task-file.mjs';
31
+
32
+ const HOME = os.homedir();
33
+ const LOG_DIR = path.join(HOME, '.claude', 'logs');
34
+ const GUARDED = new Set(['codex-scout', 'codex-build', 'codex-review']);
35
+ /**
36
+ * Both spellings of the subagent-launching tool, from the same list the installer builds its
37
+ * matcher from. Recognising only the name this host happens to use would make the gate silent
38
+ * on every other host, and a gate that is silent for an unknown reason is worse than none.
39
+ */
40
+ const SUBAGENT_TOOL_NAMES = new Set(SUBAGENT_TOOLS);
41
+
42
+ const pass = () => process.exit(0);
43
+ const deny = (reason) => {
44
+ process.stdout.write(JSON.stringify({
45
+ hookSpecificOutput: {
46
+ hookEventName: 'PreToolUse',
47
+ permissionDecision: 'deny',
48
+ permissionDecisionReason: reason,
49
+ },
50
+ }));
51
+ process.exit(0);
52
+ };
53
+
54
+ let input;
55
+ try {
56
+ input = JSON.parse(fs.readFileSync(0, 'utf8'));
57
+ } catch {
58
+ pass();
59
+ }
60
+
61
+ try {
62
+ fs.mkdirSync(LOG_DIR, { recursive: true });
63
+ fs.writeFileSync(path.join(LOG_DIR, 'codex-order-gate.last.json'), `${JSON.stringify(input, null, 2)}\n`);
64
+ } catch {
65
+ // Diagnostics are a convenience, never a reason to fail the turn.
66
+ }
67
+
68
+ if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
69
+ if (!SUBAGENT_TOOL_NAMES.has(input.tool_name)) pass();
70
+
71
+ const toolInput = input.tool_input;
72
+ if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
73
+ if (!GUARDED.has(toolInput.subagent_type) || typeof toolInput.prompt !== 'string') pass();
74
+
75
+ const missing = missingInputs(toolInput.subagent_type, toolInput.prompt);
76
+ const unsafe = shellUnsafeInputs(toolInput.subagent_type, toolInput.prompt);
77
+ if (missing.length || unsafe.length) {
78
+ const reason = missing.length
79
+ ? [
80
+ 'Order gate denied the Agent call because required dispatcher input(s) are missing or still placeholders.',
81
+ 'Write each value in tool_input.prompt using `label: value` before launching the dispatcher:',
82
+ ...missing.flatMap((entry) => {
83
+ const lines = [`- ${entry.label}: ${entry.explanation} Example: \`${entry.example}\`.`];
84
+ const diagnosis = diagnoseInput(toolInput.prompt, entry.label);
85
+ if (diagnosis) lines.push(` found \`${diagnosis.line}\`, ${diagnosis.reason}`);
86
+ return lines;
87
+ }),
88
+ ].join('\n')
89
+ : [
90
+ 'Order gate denied the Agent call because labelled dispatcher input(s) contain forbidden shell sequences.',
91
+ 'Correct each field before launching the dispatcher:',
92
+ ...unsafe.map(({ entry, sequence }) =>
93
+ `- ${entry.label}: found ${JSON.stringify(sequence)}; put free text in the task file and pass only a short command-line value.`),
94
+ ].join('\n');
95
+ deny(reason);
96
+ }
97
+
98
+ const readStatus = (file) => {
99
+ try {
100
+ return readJsonFileSync(file);
101
+ } catch {
102
+ return null;
103
+ }
104
+ };
105
+
106
+ const orderId = extractValue(toolInput.prompt, 'order id');
107
+ const taskFile = extractValue(toolInput.prompt, 'task file');
108
+ if (!orderId || !taskFile || parseContinuationGrant(toolInput.prompt)) pass();
109
+
110
+ try {
111
+ const taskDocument = parseTaskDocument(fs.readFileSync(taskFile, 'utf8'));
112
+ const taskHash = taskFingerprint(taskDocument.task);
113
+ const projectRunsDir = resolveProjectRunsDir(runsRoot(), input.cwd, { create: false }).dir;
114
+ // Per folder, not per directory: a run folder created a moment before its status.json, or any
115
+ // leftover beside the runs, would otherwise throw out of the whole check and silently disarm the
116
+ // gate for every other order. A guard that one stray folder switches off is not a guard.
117
+ const runs = fs.readdirSync(projectRunsDir, { withFileTypes: true })
118
+ .filter((entry) => entry.isDirectory())
119
+ .map((entry) => ({ run: entry.name, status: readStatus(path.join(projectRunsDir, entry.name, 'status.json')) }))
120
+ .filter(({ status }) => status);
121
+ const owner = conflictingOrderOwner(runs, orderId, taskHash);
122
+ if (owner) {
123
+ const ownerDir = path.join(projectRunsDir, owner.run);
124
+ deny(
125
+ `Order gate denied the Agent call because order id ${JSON.stringify(orderId)} already belongs ` +
126
+ `to run folder ${ownerDir} (slug ${owner.status.slug}, started_at ${owner.status.started_at}) ` +
127
+ 'with a different task. Use a new order id, or provide an explicit continuation grant if ' +
128
+ 'this is another pass of that order.',
129
+ );
130
+ }
131
+ } catch {
132
+ // The 2026-08-15 collision guard is diagnostic: uncertain disk state must not block real work.
133
+ }
134
+
135
+ pass();
@@ -11,8 +11,8 @@
11
11
  * Anything unrecognised passes. A guard that misreads its input must not break unrelated host work.
12
12
  */
13
13
  import fs from 'node:fs';
14
- import { CLI_NAMES } from '../cli-names.mjs';
15
- import { SHELL_TOOLS } from '../hook-definitions.mjs';
14
+ import { CLI_NAMES } from '../lib/cli-names.mjs';
15
+ import { SHELL_TOOLS } from '../lib/hook-definitions.mjs';
16
16
 
17
17
  const SHELL_TOOL_NAMES = new Set(SHELL_TOOLS);
18
18
  const pass = () => process.exit(0);
@@ -29,14 +29,43 @@
29
29
  import fs from 'node:fs';
30
30
  import os from 'node:os';
31
31
  import path from 'node:path';
32
- import { readJsonFileSync } from '../json-file.mjs';
33
- import { isPidAlive, liveRuns, normalizePath } from './live-runs.mjs';
32
+ import { runOrderMismatch, transcriptOrderId } from '../lib/dispatcher-order.mjs';
33
+ import { recognizeHostRefusal } from '../lib/host-refusal.mjs';
34
+ import { readJsonFileSync } from '../lib/json-file.mjs';
35
+ import { resolveProjectRunsDir } from '../lib/runner/project-dir.mjs';
36
+ import { runsRoot } from '../lib/runner/runs-root.mjs';
37
+ import { isPidAlive, liveRuns, normalizePath, recentRuns } from './live-runs.mjs';
34
38
  import { FORM, MAX_STATE_BLOCKS, STATE, takeTry } from './guard-tries.mjs';
39
+ import { parseReply } from './reply-parser.mjs';
40
+ import {
41
+ abandonedRunReason,
42
+ abandonedRunStop,
43
+ deadRunReason,
44
+ deadRunStop,
45
+ liveRunReason,
46
+ liveRunStop,
47
+ missingDiscoveredMetaStop,
48
+ missingHostOrderIdReason,
49
+ missingMetaReason,
50
+ missingRunReason,
51
+ noRecentRunReason,
52
+ noRecentRunStop,
53
+ nonexistentRunReason,
54
+ omittedDiscoveredRunReason,
55
+ omittedDiscoveredRunStop,
56
+ omittedSiblingReason,
57
+ omittedSiblingStop,
58
+ orderMismatchStop,
59
+ statusMismatchReason,
60
+ statusMismatchStop,
61
+ } from './reply-verdicts.mjs';
35
62
 
63
+ // Plan_46's first invariant recognizes only direct expressions such as
64
+ // blockForm('Contract violated: run codex-bridge run and return its stdout verbatim.');
65
+ // The broader hook-directory scan protects the extracted verdict text from the same incident.
36
66
  const HOME = os.homedir();
37
67
  const LOG_DIR = path.join(HOME, '.claude', 'logs');
38
68
  const GUARDED = new Set(['codex-scout', 'codex-build', 'codex-review']);
39
- const STATUSES = ['OK', 'FAIL', 'LIMIT'];
40
69
 
41
70
  /**
42
71
  * How many times a single agent may be blocked over the SHAPE of its reply — no RUN= line,
@@ -83,18 +112,23 @@ if (!GUARDED.has(input.agent_type)) pass();
83
112
 
84
113
  const reply = String(input.last_assistant_message || '').trim();
85
114
  if (!reply) pass();
86
-
87
115
  const emit = (payload) => {
88
116
  process.stdout.write(JSON.stringify(payload));
89
117
  process.exit(0);
90
118
  };
91
-
92
119
  /** Wrong shape of reply: three tries, then the reply goes through as it always has. */
93
120
  const blockForm = (reason) => {
94
121
  if (takeTry(input.agent_id, FORM) !== 'granted') pass();
95
122
  emit({ decision: 'block', reason });
96
123
  };
97
124
 
125
+ // The 2026-08-16 probe spent all state tries on an honest refusal; decide it before RUN/disk checks.
126
+ const hostRefusal = recognizeHostRefusal(reply);
127
+ if (hostRefusal.recognized) pass();
128
+ if (hostRefusal.declaresFailure && hostRefusal.namesInstallRemedy && !hostRefusal.namesOrderId) {
129
+ blockForm(missingHostOrderIdReason);
130
+ }
131
+
98
132
  /**
99
133
  * Wrong external state: three tries, then the turn ends with stopReason instead of the
100
134
  * silent pass. Silence here is what let a run in progress be reported as finished business
@@ -107,48 +141,45 @@ const blockState = (reason, stopReason) => {
107
141
  pass();
108
142
  };
109
143
 
110
- /**
111
- * Either line names the run folder. `RUN=` is printed by the call that starts a run, `ATTACH=`
112
- * by the repeat that joins it — and since the launcher stopped waiting, the reply carrying the
113
- * verdict is the stdout of the attaching call. Matching only `RUN=` would block every honest
114
- * answer given under the new contract as "did not delegate at all".
115
- */
116
- const cleanRunDir = (value) => value.trim().replace(/\s+started=\S*$/, '').replace(/[`"'*]+$/g, '');
117
- const runDirs = [...reply.matchAll(/(?:RUN|ATTACH)=(.+?)(?:\r?\n|$)/g)].map((match) => cleanRunDir(match[1]));
118
- const runDir = runDirs[0] || null;
144
+ const { runDirs, claimed } = parseReply(reply);
145
+ let runDir = runDirs[0] || null;
146
+ let discoveredRun = false;
147
+ let discoveredStatus = null;
148
+ let searchedRunsDir = null;
119
149
 
120
- if (!runDir || !fs.existsSync(runDir)) {
121
- blockForm(
122
- 'Contract violated: the response has no RUN= or ATTACH= line with an existing run folder, so ' +
123
- 'delegation to Codex is not confirmed. Run run-codex.mjs and return its stdout ' +
124
- 'verbatim — the ATTACH=<path> line and the status block below it. If no run occurred, ' +
125
- 'report the runner status: your own analysis instead of Codex is prohibited in all outcomes.',
126
- );
150
+ if (runDir && !fs.existsSync(runDir)) {
151
+ blockForm(nonexistentRunReason);
127
152
  }
128
153
 
129
- /**
130
- * The text the operator reads when the state budget is gone. Everything in it is quoted
131
- * from status.json — the operator is being told to stop, and a stop justified by guesswork
132
- * is a stop he learns to ignore. `fact` names the missing field instead of inventing one.
133
- */
134
- const fact = (value) => (value === undefined || value === null || value === '' ? 'not recorded' : String(value));
154
+ const orderedOrderId = transcriptOrderId(input.agent_transcript_path);
155
+
156
+ if (!runDir && !claimed) {
157
+ // A reply that pronounces nothing cannot contradict the disk. It stays on the old, softer path:
158
+ // three tries about the SHAPE of the answer and then through, which is what a quoted refusal
159
+ // needs. Only a reply that hands down a verdict earns the disk search below.
160
+ blockForm(missingRunReason);
161
+ }
162
+
163
+ if (!runDir) {
164
+ let candidates;
165
+ try {
166
+ searchedRunsDir = resolveProjectRunsDir(runsRoot(), input.cwd, { create: false }).dir;
167
+ candidates = recentRuns(searchedRunsDir, { agent: input.agent_type });
168
+ } catch {
169
+ pass();
170
+ }
171
+ if (candidates === null) pass();
135
172
 
136
- const stopText = (headline, observed, runStatus, folder = runDir) =>
137
- [
138
- headline,
139
- `Run: slug ${fact(runStatus?.slug)}, agent ${fact(runStatus?.agent)}, repository ` +
140
- `${fact(runStatus?.repo)}, started ${fact(runStatus?.started_at)}.`,
141
- `Run folder: ${folder}. Read state from ${path.join(folder, 'status.json')}; the verdict ` +
142
- 'will appear in the adjacent meta.json.',
143
- observed,
144
- `Worktree ${fact(runStatus?.repo)} is busy with this run: do not build, test, or commit until ` +
145
- 'status.json changes state to finished or failed. The runner snapshots the tree before ' +
146
- 'and after the run and will claim any changes made during this window.',
147
- 'The run will close itself: the worker outlives every caller and completes meta.json and ' +
148
- 'status.json without the dispatcher. Wait for state to change and reread status.json — the ' +
149
- 'verdict will be there; there is no need to ask the dispatcher again. Repeating the same ' +
150
- 'command with the same --order-id attaches to this run rather than starting another.',
151
- ].join(' ');
173
+ if (!candidates.length) {
174
+ blockState(
175
+ noRecentRunReason,
176
+ noRecentRunStop(MAX_STATE_BLOCKS, input.agent_type, searchedRunsDir),
177
+ );
178
+ }
179
+
180
+ ({ dir: runDir, status: discoveredStatus } = candidates[0]);
181
+ discoveredRun = true;
182
+ }
152
183
 
153
184
  /**
154
185
  * The dispatcher must name every WRITING run the project has live, not just the one it chose to
@@ -175,15 +206,8 @@ try {
175
206
  if (silentLiveSibling) {
176
207
  const { dir, status } = silentLiveSibling;
177
208
  blockState(
178
- `Contract violated: the response names ${runDir} but omits live run folder ${dir}; report every `
179
- + `live run with its agent ${status.agent}, slug ${status.slug}, and repository ${status.repo}.`,
180
- stopText(
181
- `The reply guard stopped the session: the dispatcher responded ${MAX_STATE_BLOCKS} times `
182
- + 'while a live sibling Codex run was omitted from its reply.',
183
- `status.json in ${dir} says state=running; process pid ${fact(status.pid)} is alive.`,
184
- status,
185
- dir,
186
- ),
209
+ omittedSiblingReason(runDir, dir, status),
210
+ omittedSiblingStop(MAX_STATE_BLOCKS, dir, status),
187
211
  );
188
212
  }
189
213
 
@@ -194,69 +218,54 @@ if (silentLiveSibling) {
194
218
  * before status.json existed have none: behave exactly as before (meta.json decides).
195
219
  */
196
220
  const statusPath = path.join(runDir, 'status.json');
221
+ let runStatus = null;
197
222
  if (fs.existsSync(statusPath)) {
198
- let runStatus = null;
199
223
  try {
200
224
  runStatus = readJsonFileSync(statusPath);
201
225
  } catch {
202
226
  runStatus = null;
203
227
  }
228
+ const mismatch = runOrderMismatch(orderedOrderId, runStatus, runDir);
229
+
230
+ if (mismatch) {
231
+ blockState(mismatch.reason, orderMismatchStop(MAX_STATE_BLOCKS, mismatch, runStatus, runDir));
232
+ }
233
+
204
234
  if (runStatus?.state === 'running') {
205
235
  if (isPidAlive(runStatus.pid)) {
206
236
  blockState(
207
- 'Contract violated: status.json says state=running and the process is alive — the run is ' +
208
- 'not finished, but you are already responding. The STARTED output of the starting call ' +
209
- 'is not a result. Repeat the identical run-codex.mjs command, same --order-id, with ' +
210
- 'timeout 1800000: it attaches to this same run, costs no quota, and prints the verdict.',
211
- stopText(
212
- `The reply guard stopped the session: the dispatcher responded ${MAX_STATE_BLOCKS} ` +
213
- 'times while the Codex run was still in progress.',
214
- `status.json currently says state=running; process pid ${fact(runStatus.pid)} is alive.`,
215
- runStatus,
216
- ),
237
+ liveRunReason,
238
+ liveRunStop(MAX_STATE_BLOCKS, runStatus, runDir),
217
239
  );
218
240
  }
241
+
219
242
  if (!fs.existsSync(path.join(runDir, 'meta.json'))) {
220
243
  blockState(
221
- 'Contract violated: status.json says state=running, but the process with this pid is dead ' +
222
- 'and meta.json is missing — the run is abandoned. Repeat the identical run-codex.mjs ' +
223
- 'command, same --order-id, with timeout of at least 1800000 and return its stdout ' +
224
- 'verbatim.',
225
- stopText(
226
- `The reply guard stopped the session: the dispatcher responded ${MAX_STATE_BLOCKS} ` +
227
- 'times for a run it did not complete.',
228
- `status.json currently says state=running, but process pid ${fact(runStatus.pid)} is ` +
229
- 'dead and meta.json is missing: an interrupted Bash call killed the runner. Codex ' +
230
- 'survives the runner and keeps editing the tree — in run 2026-07-31_114736, changes ' +
231
- 'in 11+ files survived while no run artifact and no meta.json were ever recorded.',
232
- runStatus,
233
- ),
244
+ deadRunReason,
245
+ deadRunStop(MAX_STATE_BLOCKS, runStatus, runDir),
234
246
  );
235
247
  }
236
248
  } else if (runStatus?.state === 'abandoned') {
237
249
  blockState(
238
- 'Contract violated: status.json says state=abandoned — the runner died without a verdict. ' +
239
- 'Repeat the identical run-codex.mjs command, same --order-id, with timeout of at least ' +
240
- '1800000 and return its stdout verbatim.',
241
- stopText(
242
- `The reply guard stopped the session: the dispatcher responded ${MAX_STATE_BLOCKS} ` +
243
- 'times for an abandoned run.',
244
- `status.json currently says state=abandoned (${fact(runStatus.abandoned_reason)}, ` +
245
- `${fact(runStatus.abandoned_at)}): the runner died without recording a verdict. Codex ` +
246
- 'survives the runner and keeps editing the tree — in run 2026-07-31_114736, changes in ' +
247
- '11+ files survived while no run artifact and no meta.json were ever recorded.',
248
- runStatus,
249
- ),
250
+ abandonedRunReason,
251
+ abandonedRunStop(MAX_STATE_BLOCKS, runStatus, runDir),
250
252
  );
251
253
  }
252
254
  }
253
255
 
254
256
  const metaPath = path.join(runDir, 'meta.json');
257
+
255
258
  if (!fs.existsSync(metaPath)) {
256
- blockForm(
257
- `Contract violated: run folder ${runDir} has no meta.json; nothing supports the response. ` +
258
- 'Run run-codex.mjs again and return its stdout verbatim.',
259
- );
259
+ const reason = missingMetaReason(runDir);
260
+
261
+ if (discoveredRun) {
262
+ blockState(
263
+ reason,
264
+ missingDiscoveredMetaStop(MAX_STATE_BLOCKS, discoveredStatus, runDir),
265
+ );
266
+ }
267
+
268
+ blockForm(reason);
260
269
  }
261
270
 
262
271
  let meta;
@@ -266,12 +275,23 @@ try {
266
275
  pass();
267
276
  }
268
277
 
269
- const claimed = STATUSES.find((status) => new RegExp(`(^|\\n)\\s*\`*${status}\\b`).test(reply));
270
278
  if (claimed && meta.status && claimed !== meta.status) {
271
- blockForm(
272
- `Contract violated: you reported ${claimed}, but the run's meta.json says ${meta.status} ` +
273
- `(${meta.reason || 'no reason given'}). The runner calculates status from artifacts — return ` +
274
- 'its output verbatim without substituting your own judgment.',
279
+ const reason = statusMismatchReason(claimed, meta, discoveredStatus, runDir);
280
+
281
+ if (discoveredRun) {
282
+ blockState(
283
+ reason,
284
+ statusMismatchStop(MAX_STATE_BLOCKS, claimed, meta, discoveredStatus, runDir),
285
+ );
286
+ }
287
+
288
+ blockForm(reason);
289
+ }
290
+
291
+ if (discoveredRun) {
292
+ blockState(
293
+ omittedDiscoveredRunReason(meta, discoveredStatus, runDir),
294
+ omittedDiscoveredRunStop(MAX_STATE_BLOCKS, meta, discoveredStatus, runDir),
275
295
  );
276
296
  }
277
297
 
@@ -0,0 +1,27 @@
1
+ /** Parses dispatcher replies into guard facts. */
2
+ const STATUSES = ['OK', 'FAIL', 'LIMIT'];
3
+
4
+ /**
5
+ * Either line names the run folder. `RUN=` is printed by the call that starts a run, `ATTACH=`
6
+ * by the repeat that joins it — and since the launcher stopped waiting, the reply carrying the
7
+ * verdict is the stdout of the attaching call. Matching only `RUN=` would block every honest
8
+ * answer given under the new contract as "did not delegate at all".
9
+ */
10
+ export const cleanRunDir = (value) => value.trim().replace(/\s+(?:order-id|started)=.*$/, '').replace(/[`"'*]+$/g, '');
11
+
12
+ export function parseReply(reply) {
13
+ const runDirs = [...reply.matchAll(/(?:RUN|ATTACH)=(.+?)(?:\r?\n|$)/g)].map((match) => cleanRunDir(match[1]));
14
+
15
+ /**
16
+ * Does this reply pronounce a verdict at all? Computed here rather than after meta.json is read,
17
+ * because it decides whether an unnamed folder is worth searching the disk for.
18
+ *
19
+ * Not every honest reply carries one. The runner refuses before creating a folder — a repeat
20
+ * without `--continue`, an impossible `--scope`, a missing `--question` — and its refusal is the
21
+ * whole truthful answer, with no run to name. Escalating those would punish the dispatcher for
22
+ * quoting the runner correctly, and on 2026-08-14 two such refusals arrived in a row.
23
+ */
24
+ const claimed = STATUSES.find((status) => new RegExp(`(^|\\n)\\s*\`*${status}\\b`).test(reply));
25
+
26
+ return { runDirs, claimed };
27
+ }
@@ -0,0 +1,174 @@
1
+ /** Provides the reply guard's operator-facing verdict text. */
2
+ import path from 'node:path';
3
+
4
+ export const fact = (value) => (value === undefined || value === null || value === '' ? 'not recorded' : String(value));
5
+
6
+ /**
7
+ * The text the operator reads when the state budget is gone. Everything in it is quoted
8
+ * from status.json — the operator is being told to stop, and a stop justified by guesswork
9
+ * is a stop he learns to ignore. `fact` names the missing field instead of inventing one.
10
+ */
11
+ export const stopText = (headline, observed, runStatus, folder) => {
12
+ if (!runStatus) {
13
+ return [headline, observed, `Project runs directory checked: ${folder || 'not recorded'}.`].join(' ');
14
+ }
15
+
16
+ if (runStatus.state === 'finished' || runStatus.state === 'failed') {
17
+ return [
18
+ headline,
19
+ `Run: slug ${fact(runStatus.slug)}, agent ${fact(runStatus.agent)}, repository ` +
20
+ `${fact(runStatus.repo)}, started ${fact(runStatus.started_at)}.`,
21
+ `Run folder: ${folder}. Read state from ${path.join(folder, 'status.json')} and verdict ` +
22
+ `from ${path.join(folder, 'meta.json')}.`,
23
+ observed,
24
+ 'The recorded run is complete; return the runner artifacts verbatim instead of replacing ' +
25
+ 'their verdict or omitting their folder.',
26
+ ].join(' ');
27
+ }
28
+
29
+ return [
30
+ headline,
31
+ `Run: slug ${fact(runStatus?.slug)}, agent ${fact(runStatus?.agent)}, repository ` +
32
+ `${fact(runStatus?.repo)}, started ${fact(runStatus?.started_at)}.`,
33
+ `Run folder: ${folder}. Read state from ${path.join(folder, 'status.json')}; the verdict ` +
34
+ 'will appear in the adjacent meta.json.',
35
+ observed,
36
+ `Worktree ${fact(runStatus?.repo)} is busy with this run: do not build, test, or commit until ` +
37
+ 'status.json changes state to finished or failed. The runner snapshots the tree before ' +
38
+ 'and after the run and will claim any changes made during this window.',
39
+ 'The run will close itself: the worker outlives every caller and completes meta.json and ' +
40
+ 'status.json without the dispatcher. Wait for state to change and reread status.json — the ' +
41
+ 'verdict will be there; there is no need to ask the dispatcher again. Repeating the same ' +
42
+ 'command with the same --order-id attaches to this run rather than starting another.',
43
+ ].join(' ');
44
+ };
45
+
46
+ export const missingHostOrderIdReason =
47
+ 'Contract violated: the host-refusal reply is missing the order id the orchestrator issued.';
48
+
49
+ export const nonexistentRunReason =
50
+ 'Contract violated: the response has no RUN= or ATTACH= line with an existing run folder, so ' +
51
+ 'delegation to Codex is not confirmed. Run codex-bridge run and return its stdout ' +
52
+ 'verbatim — the ATTACH=<path> line and the status block below it. If no run occurred, ' +
53
+ 'report the runner status: your own analysis instead of Codex is prohibited in all outcomes.';
54
+
55
+ export const missingRunReason =
56
+ 'Contract violated: the response has no RUN= or ATTACH= line, so delegation to Codex is not ' +
57
+ 'confirmed. Run codex-bridge run and return its stdout verbatim. If the runner refused before ' +
58
+ 'creating a folder, return that refusal exactly as printed.';
59
+
60
+ export const noRecentRunReason =
61
+ 'Contract violated: the response has no RUN= or ATTACH= line, and no recent run for this ' +
62
+ 'dispatcher was found on disk. Run codex-bridge run and return its stdout verbatim; your own ' +
63
+ 'analysis instead of Codex is prohibited in all outcomes.';
64
+
65
+ export const noRecentRunStop = (maxBlocks, agentType, runsDir) => stopText(
66
+ `The reply guard stopped the session: the dispatcher responded ${maxBlocks} times ` +
67
+ 'without naming a run and no recent matching run was found on disk.',
68
+ `No run for agent ${agentType} was found in the last 24 hours.`,
69
+ null,
70
+ runsDir,
71
+ );
72
+
73
+ export const omittedSiblingReason = (runDir, dir, status) =>
74
+ `Contract violated: the response names ${runDir} but omits live run folder ${dir}; report every ` +
75
+ `live run with its agent ${status.agent}, slug ${status.slug}, and repository ${status.repo}.`;
76
+
77
+ export const omittedSiblingStop = (maxBlocks, dir, status) => stopText(
78
+ `The reply guard stopped the session: the dispatcher responded ${maxBlocks} times ` +
79
+ 'while a live sibling Codex run was omitted from its reply.',
80
+ `status.json in ${dir} says state=running; process pid ${fact(status.pid)} is alive.`,
81
+ status,
82
+ dir,
83
+ );
84
+
85
+ export const orderMismatchStop = (maxBlocks, mismatch, runStatus, runDir) => stopText(
86
+ `The reply guard stopped the session: the dispatcher returned another order's run ${maxBlocks} times.`,
87
+ mismatch.observed,
88
+ runStatus,
89
+ runDir,
90
+ );
91
+
92
+ export const liveRunReason =
93
+ 'Contract violated: status.json says state=running and the process is alive — the run is ' +
94
+ 'not finished, but you are already responding. The STARTED output of the starting call ' +
95
+ 'is not a result. Repeat the identical codex-bridge run command, same --order-id, with ' +
96
+ 'timeout 1800000: it attaches to this same run, costs no quota, and prints the verdict.';
97
+
98
+ export const liveRunStop = (maxBlocks, runStatus, runDir) => stopText(
99
+ `The reply guard stopped the session: the dispatcher responded ${maxBlocks} ` +
100
+ 'times while the Codex run was still in progress.',
101
+ `status.json currently says state=running; process pid ${fact(runStatus.pid)} is alive.`,
102
+ runStatus,
103
+ runDir,
104
+ );
105
+
106
+ export const deadRunReason =
107
+ 'Contract violated: status.json says state=running, but the process with this pid is dead ' +
108
+ 'and meta.json is missing — the run is abandoned. Repeat the identical codex-bridge run ' +
109
+ 'command, same --order-id, with timeout of at least 1800000 and return its stdout verbatim.';
110
+
111
+ export const deadRunStop = (maxBlocks, runStatus, runDir) => stopText(
112
+ `The reply guard stopped the session: the dispatcher responded ${maxBlocks} times for a run it did not complete.`,
113
+ `status.json currently says state=running, but process pid ${fact(runStatus.pid)} is ` +
114
+ 'dead and meta.json is missing: an interrupted Bash call killed the runner. Codex ' +
115
+ 'survives the runner and keeps editing the tree — in run 2026-07-31_114736, changes ' +
116
+ 'in 11+ files survived while no run artifact and no meta.json were ever recorded.',
117
+ runStatus,
118
+ runDir,
119
+ );
120
+
121
+ export const abandonedRunReason =
122
+ 'Contract violated: status.json says state=abandoned — the runner died without a verdict. ' +
123
+ 'Repeat the identical codex-bridge run command, same --order-id, with timeout of at least ' +
124
+ '1800000 and return its stdout verbatim.';
125
+
126
+ export const abandonedRunStop = (maxBlocks, runStatus, runDir) => stopText(
127
+ `The reply guard stopped the session: the dispatcher responded ${maxBlocks} times for an abandoned run.`,
128
+ `status.json currently says state=abandoned (${fact(runStatus.abandoned_reason)}, ` +
129
+ `${fact(runStatus.abandoned_at)}): the runner died without recording a verdict. Codex ` +
130
+ 'survives the runner and keeps editing the tree — in run 2026-07-31_114736, changes in ' +
131
+ '11+ files survived while no run artifact and no meta.json were ever recorded.',
132
+ runStatus,
133
+ runDir,
134
+ );
135
+
136
+ export const missingMetaReason = (runDir) =>
137
+ `Contract violated: run folder ${runDir} has no meta.json; nothing supports the ` +
138
+ 'response. Run codex-bridge run again and return its stdout verbatim.';
139
+
140
+ export const missingDiscoveredMetaStop = (maxBlocks, discoveredStatus, runDir) => stopText(
141
+ `The reply guard stopped the session: the dispatcher responded ${maxBlocks} times ` +
142
+ 'without naming the recent run found on disk.',
143
+ `status.json says state=${fact(discoveredStatus?.state)}, status=${fact(discoveredStatus?.status)}, ` +
144
+ 'but the adjacent meta.json is missing.',
145
+ discoveredStatus,
146
+ runDir,
147
+ );
148
+
149
+ export const statusMismatchReason = (claimed, meta, discoveredStatus, runDir) =>
150
+ `Contract violated: you reported ${claimed}, but run folder ${runDir} has ` +
151
+ `state=${fact(discoveredStatus?.state)} in status.json and status=${meta.status} in meta.json ` +
152
+ `(${meta.reason || 'no reason given'}). The runner calculates status from artifacts — return ` +
153
+ 'its output verbatim without substituting your own judgment.';
154
+
155
+ export const statusMismatchStop = (maxBlocks, claimed, meta, discoveredStatus, runDir) => stopText(
156
+ `The reply guard stopped the session: the dispatcher contradicted the recent run on disk ${maxBlocks} times.`,
157
+ `status.json says state=${fact(discoveredStatus.state)}, status=${fact(discoveredStatus.status)}; ` +
158
+ `meta.json says status=${meta.status}, but the reply says ${claimed}.`,
159
+ discoveredStatus,
160
+ runDir,
161
+ );
162
+
163
+ export const omittedDiscoveredRunReason = (meta, discoveredStatus, runDir) =>
164
+ `Contract violated: the response omitted RUN=${runDir}; status.json says ` +
165
+ `state=${fact(discoveredStatus.state)}, status=${fact(discoveredStatus.status)}, and meta.json ` +
166
+ `says status=${fact(meta.status)}. Return the runner stdout verbatim.`;
167
+
168
+ export const omittedDiscoveredRunStop = (maxBlocks, meta, discoveredStatus, runDir) => stopText(
169
+ `The reply guard stopped the session: the dispatcher omitted the recent run found on disk ${maxBlocks} times.`,
170
+ `status.json says state=${fact(discoveredStatus.state)}, status=${fact(discoveredStatus.status)}; ` +
171
+ `meta.json says status=${fact(meta.status)}.`,
172
+ discoveredStatus,
173
+ runDir,
174
+ );
@@ -1,8 +1,8 @@
1
1
  import fs from 'node:fs';
2
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';
3
+ import { STOP_TOOLS } from '../lib/hook-definitions.mjs';
4
+ import { runsRoot } from '../lib/runner/runs-root.mjs';
5
+ import { renderStopCommand, STOP_REASON } from '../lib/stop-contract.mjs';
6
6
  import { allLiveRuns, normalizePath } from './live-runs.mjs';
7
7
  import { takeTry } from './guard-tries.mjs';
8
8