@lyupro/codex-bridge 0.6.6 → 0.6.8

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 (55) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +7 -5
  3. package/bin/codex-bridge.mjs +186 -178
  4. package/cli/dispatcher-contract-record.mjs +51 -0
  5. package/cli/dispatcher-contract.mjs +90 -0
  6. package/cli/doctor-hooks.mjs +104 -114
  7. package/cli/doctor-installation.mjs +111 -111
  8. package/cli/doctor-runs.mjs +55 -55
  9. package/cli/doctor.mjs +53 -5
  10. package/cli/handback-witness-check.mjs +31 -0
  11. package/cli/hook-targets.mjs +14 -6
  12. package/cli/hook.mjs +32 -23
  13. package/cli/host-contract.mjs +145 -135
  14. package/cli/install.mjs +23 -17
  15. package/cli/launcher-probe.mjs +38 -0
  16. package/cli/probe-contract.mjs +55 -70
  17. package/cli/probe-rig.mjs +53 -0
  18. package/cli/probe-target.mjs +33 -0
  19. package/cli/run-launcher.mjs +17 -0
  20. package/cli/session-hosts.mjs +51 -0
  21. package/cli/settings-merge.mjs +42 -35
  22. package/cli/uninstall.mjs +16 -3
  23. package/cli/update.mjs +45 -16
  24. package/package.json +4 -4
  25. package/scripts/run-tests.mjs +7 -0
  26. package/src/agents/codex-advisor.md +3 -4
  27. package/src/agents/codex-build.md +5 -5
  28. package/src/agents/codex-review.md +3 -3
  29. package/src/agents/codex-scout.md +3 -4
  30. package/src/home/hooks/dispatcher-gate.mjs +132 -0
  31. package/src/home/hooks/guard-tries.mjs +5 -6
  32. package/src/home/hooks/live-runs.mjs +1 -0
  33. package/src/home/hooks/order-gate.mjs +20 -5
  34. package/src/home/hooks/reply-guard.mjs +95 -10
  35. package/src/home/hooks/reply-verdicts.mjs +5 -0
  36. package/src/home/hooks/worktree-lock.mjs +108 -107
  37. package/src/home/lib/atomic-json.mjs +21 -0
  38. package/src/home/lib/brand-home.mjs +6 -0
  39. package/src/home/lib/dispatcher-command.mjs +79 -0
  40. package/src/home/lib/dispatcher-gate.mjs +95 -0
  41. package/src/home/lib/dispatcher-order.mjs +30 -8
  42. package/src/home/lib/dispatcher-state.mjs +111 -0
  43. package/src/home/lib/dispatcher-stop.mjs +52 -0
  44. package/src/home/lib/handback-witness.mjs +70 -0
  45. package/src/home/lib/hook-definitions.mjs +10 -0
  46. package/src/home/lib/hook-entry.mjs +20 -0
  47. package/src/home/lib/host-observations.mjs +57 -0
  48. package/src/home/lib/host-version.mjs +27 -0
  49. package/src/home/lib/meta/advice-verdict.mjs +37 -21
  50. package/src/home/lib/required-inputs.mjs +1 -1
  51. package/src/home/lib/runner/codex-cmd.mjs +3 -2
  52. package/src/home/lib/runner/git-state.mjs +1 -1
  53. package/src/home/lib/runner/preflight.mjs +6 -3
  54. package/src/home/lib/runner/prompts.mjs +8 -7
  55. package/src/home/lib/runner/schemas.mjs +33 -7
@@ -21,18 +21,24 @@
21
21
  * argument about the SHAPE of the reply can loop forever and eventually steps aside, an
22
22
  * argument about EXTERNAL run state cannot be argued away by the model and ends the turn
23
23
  * with `continue: false` instead of a silent pass. See MAX_FORM_BLOCKS / MAX_STATE_BLOCKS.
24
+ * In the handback era, SubagentHandback delivers the caller-visible answer before SubagentStop.
25
+ * Alarms name the host by its transcript version: on 2026-09-25 the SDK variable proved inherited.
26
+ * Plan_62 makes this hook yield after delivery and audit every transcript tool call against gate receipts.
24
27
  *
25
28
  * Input is JSON on stdin. The fields used here (`agent_type`, `last_assistant_message`)
26
- * come from Claude Code; the last payload is kept in logs/codex-reply-guard.last.json so
27
- * the contract stays inspectable if it ever changes shape.
29
+ * come from Claude Code; the last payload is kept in state/diagnostics/reply-guard.last.json
30
+ * so the contract stays inspectable if it ever changes shape.
28
31
  */
29
32
  import fs from 'node:fs';
30
- import os from 'node:os';
31
33
  import path from 'node:path';
32
34
  import { AGENTS } from '../lib/agents.mjs';
35
+ import { BRAND_STATE_DIR } from '../lib/brand-home.mjs';
33
36
  import { runOrderMismatch, transcriptOrderId } from '../lib/dispatcher-order.mjs';
37
+ import { decideDispatcherStop, transcriptToolUses } from '../lib/dispatcher-stop.mjs';
34
38
  import { recognizeHostRefusal } from '../lib/host-refusal.mjs';
35
39
  import { readJsonFileSync } from '../lib/json-file.mjs';
40
+ import { readDispatcherState, updateDispatcherState } from '../lib/dispatcher-state.mjs';
41
+ import { recordHandbackWitness, witnessHostVersion } from '../lib/handback-witness.mjs';
36
42
  import { runLiveness } from '../lib/meta/run-liveness.mjs';
37
43
  import { resolveProjectRunsDir } from '../lib/runner/project-dir.mjs';
38
44
  import { runsRoot } from '../lib/runner/runs-root.mjs';
@@ -44,6 +50,7 @@ import {
44
50
  abandonedRunStop,
45
51
  deadRunReason,
46
52
  deadRunStop,
53
+ handbackDemandReason,
47
54
  liveRunReason,
48
55
  liveRunStop,
49
56
  missingDiscoveredMetaStop,
@@ -65,8 +72,6 @@ import {
65
72
  // Plan_46's first invariant recognizes only direct expressions such as
66
73
  // blockForm('Contract violated: run codex-bridge run and return its stdout verbatim.');
67
74
  // The broader hook-directory scan protects the extracted verdict text from the same incident.
68
- const HOME = os.homedir();
69
- const LOG_DIR = path.join(HOME, '.claude', 'logs');
70
75
  const GUARDED = new Set(Object.keys(AGENTS));
71
76
 
72
77
  /**
@@ -91,7 +96,13 @@ const GUARDED = new Set(Object.keys(AGENTS));
91
96
  * worktree was busy.
92
97
  */
93
98
  /** Never let a guard failure break real work: on any doubt, stay silent. */
94
- const pass = () => process.exit(0);
99
+ let dispatcherSystemMessage = null;
100
+ const pass = () => {
101
+ if (dispatcherSystemMessage) {
102
+ process.stdout.write(JSON.stringify({ systemMessage: dispatcherSystemMessage }));
103
+ }
104
+ process.exit(0);
105
+ };
95
106
 
96
107
  let input;
97
108
  try {
@@ -101,21 +112,90 @@ try {
101
112
  }
102
113
 
103
114
  try {
104
- fs.mkdirSync(LOG_DIR, { recursive: true });
105
- fs.writeFileSync(path.join(LOG_DIR, 'codex-reply-guard.last.json'), `${JSON.stringify(input, null, 2)}\n`);
115
+ const diagnosticsDir = path.join(BRAND_STATE_DIR, 'diagnostics');
116
+ fs.mkdirSync(diagnosticsDir, { recursive: true });
117
+ fs.writeFileSync(path.join(diagnosticsDir, 'reply-guard.last.json'), `${JSON.stringify(input, null, 2)}\n`);
106
118
  } catch {
107
119
  // Diagnostics are a convenience, never a reason to fail the turn.
108
120
  }
109
121
 
122
+ // A host that stops sending agent_type would let every dispatcher past the gate and this
123
+ // audit without a word (Plan_66 D2 (c)); say so instead of passing silently.
124
+ if (typeof input.agent_id === 'string' && input.agent_id.length > 0
125
+ && !(typeof input.agent_type === 'string' && input.agent_type.length > 0)) {
126
+ try {
127
+ await recordHandbackWitness({
128
+ stateDir: BRAND_STATE_DIR,
129
+ kind: 'alarm',
130
+ hostVersion: await witnessHostVersion(input),
131
+ detail: `host omitted agent_type for agent ${input.agent_id}`,
132
+ });
133
+ } catch {
134
+ // Plan_66 H2: the 2026-09-17..24 handback break showed host contract loss must alarm, but never block work.
135
+ }
136
+ process.stdout.write(JSON.stringify({
137
+ systemMessage: 'codex-bridge: the host did not report agent_type for a subagent — the dispatcher gate cannot recognise dispatchers, so their answers are unchecked; run codex-bridge doctor.',
138
+ }));
139
+ process.exit(0);
140
+ }
110
141
  // Only the Codex dispatchers have this contract. Every other agent is none of our
111
142
  // business — an earlier version inferred the agent from transcript text and blocked an
112
143
  // unrelated one, which cost a re-answer for nothing.
113
144
  if (!GUARDED.has(input.agent_type)) pass();
114
145
 
146
+ const emitStop = (payload) => {
147
+ process.stdout.write(JSON.stringify(payload));
148
+ process.exit(0);
149
+ };
150
+ try {
151
+ if (typeof input.session_id === 'string' && input.session_id.length > 0
152
+ && typeof input.agent_id === 'string' && input.agent_id.length > 0) {
153
+ const ids = {
154
+ stateDir: BRAND_STATE_DIR,
155
+ sessionId: input.session_id,
156
+ agentId: input.agent_id,
157
+ };
158
+ const state = readDispatcherState(ids);
159
+ const toolUses = transcriptToolUses(input.agent_transcript_path);
160
+ const stop = decideDispatcherStop({ state, toolUses });
161
+ if (stop.unseen.length) {
162
+ const names = stop.unseen.map((toolUse) => toolUse.name).join(', ');
163
+ dispatcherSystemMessage = `codex-bridge: dispatcher ${input.agent_type} used ${names} outside the dispatcher gate — do not trust its answer; run codex-bridge doctor.`;
164
+ await recordHandbackWitness({
165
+ stateDir: BRAND_STATE_DIR,
166
+ kind: 'alarm',
167
+ hostVersion: await witnessHostVersion(input),
168
+ detail: `${input.agent_type} ${input.agent_id}: ${names} outside the dispatcher gate`,
169
+ });
170
+ }
171
+ if (stop.stateUpdate) {
172
+ await updateDispatcherState(ids, (current) => ({
173
+ ...(current.corrupt ? {} : current),
174
+ ...stop.stateUpdate,
175
+ }));
176
+ }
177
+ if (stop.route === 'yield') {
178
+ if (dispatcherSystemMessage) emitStop({ systemMessage: dispatcherSystemMessage });
179
+ pass();
180
+ }
181
+ if (stop.route === 'demand') {
182
+ emitStop({
183
+ decision: 'block',
184
+ reason: handbackDemandReason,
185
+ ...(dispatcherSystemMessage ? { systemMessage: dispatcherSystemMessage } : {}),
186
+ });
187
+ }
188
+ }
189
+ } catch {
190
+ // The handback path is fail-open; the established reply checks remain the fallback.
191
+ }
192
+
115
193
  const reply = String(input.last_assistant_message || '').trim();
116
194
  if (!reply) pass();
117
195
  const emit = (payload) => {
118
- process.stdout.write(JSON.stringify(payload));
196
+ process.stdout.write(JSON.stringify(
197
+ dispatcherSystemMessage ? { ...payload, systemMessage: dispatcherSystemMessage } : payload,
198
+ ));
119
199
  process.exit(0);
120
200
  };
121
201
  /** Wrong shape of reply: three tries, then the reply goes through as it always has. */
@@ -166,7 +246,12 @@ if (!runDir) {
166
246
  let candidates;
167
247
  try {
168
248
  searchedRunsDir = resolveProjectRunsDir(runsRoot(), input.cwd, { create: false }).dir;
169
- candidates = recentRuns(searchedRunsDir, { agent: input.agent_type });
249
+ // Plan_62 r2: on 2026-09-24 the newest run of this agent type belonged to an earlier order, and the
250
+ // guard judged the reply against that stranger's folder. With the order id known, only its runs count.
251
+ candidates = recentRuns(searchedRunsDir, {
252
+ agent: input.agent_type,
253
+ ...(orderedOrderId ? { orderId: orderedOrderId } : {}),
254
+ });
170
255
  } catch {
171
256
  pass();
172
257
  }
@@ -57,6 +57,11 @@ export const missingRunReason =
57
57
  'confirmed. Run codex-bridge run and return its stdout verbatim. If the runner refused before ' +
58
58
  'creating a folder, return that refusal exactly as printed.';
59
59
 
60
+ export const handbackDemandReason =
61
+ 'Hand your answer back now through SubagentHandback. If the dispatcher gate refuses the handback, run ' +
62
+ 'exactly the command its refusal quotes and hand back again; the gate replaces the message with the ' +
63
+ "runner's output.";
64
+
60
65
  export const noRecentRunReason =
61
66
  'Contract violated: the response has no RUN= or ATTACH= line, and no recent run for this ' +
62
67
  'dispatcher was found on disk. Run codex-bridge run and return its stdout verbatim; your own ' +
@@ -1,44 +1,44 @@
1
- #!/usr/bin/env node
2
- /**
3
- * PreToolUse lock for file tools and plainly recognisable shell writes in the Claude Code host.
4
- *
5
- * A live codex-build runner snapshots its repository before and after editing. Letting the
6
- * orchestrator write during that window folds unrelated work into the runner's result, exactly
7
- * as the 2026-08-05 incident did. This hook denies only a recognised file path inside a live
8
- * build run's repository; every malformed or unreadable input passes so diagnostics never break
9
- * unrelated host work.
10
- */
11
- import fs from 'node:fs';
12
- import path from 'node:path';
13
- import { spawnSync } from 'node:child_process';
14
- import { formatSilence, heartbeatAge } from '../lib/heartbeat.mjs';
15
- import { SHELL_TOOLS, WRITE_TOOLS } from '../lib/hook-definitions.mjs';
16
- import { runsRoot } from '../lib/runner/runs-root.mjs';
17
- import { allLiveRuns, normalizePath } from './live-runs.mjs';
18
- import { shellWriteIntent } from './shell-write-intent.mjs';
19
-
20
- const WRITE_TOOL_NAMES = new Set(WRITE_TOOLS);
21
- const SHELL_TOOL_NAMES = new Set(SHELL_TOOLS);
22
- const pass = () => process.exit(0);
23
-
24
- let input;
25
- try {
26
- input = JSON.parse(fs.readFileSync(0, 'utf8'));
27
- } catch {
28
- pass();
29
- }
30
-
31
- if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
32
- if (!WRITE_TOOL_NAMES.has(input.tool_name) && !SHELL_TOOL_NAMES.has(input.tool_name)) pass();
33
-
34
- const toolInput = input.tool_input;
35
- if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
36
- let rawPaths;
37
- if (WRITE_TOOL_NAMES.has(input.tool_name)) {
38
- const pathField = input.tool_name === 'NotebookEdit' ? 'notebook_path' : 'file_path';
39
- const rawPath = toolInput[pathField];
40
- if (typeof rawPath !== 'string' || !rawPath.trim()) pass();
41
- rawPaths = [rawPath];
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse lock for file tools and plainly recognisable shell writes in the Claude Code host.
4
+ *
5
+ * A live codex-build runner snapshots its repository before and after editing. Letting the
6
+ * orchestrator write during that window folds unrelated work into the runner's result, exactly
7
+ * as the 2026-08-05 incident did. This hook denies only a recognised file path inside a live
8
+ * build run's repository; every malformed or unreadable input passes so diagnostics never break
9
+ * unrelated host work.
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { spawnSync } from 'node:child_process';
14
+ import { formatSilence, heartbeatAge } from '../lib/heartbeat.mjs';
15
+ import { SHELL_TOOLS, WRITE_TOOLS } from '../lib/hook-definitions.mjs';
16
+ import { runsRoot } from '../lib/runner/runs-root.mjs';
17
+ import { allLiveRuns, normalizePath } from './live-runs.mjs';
18
+ import { shellWriteIntent } from './shell-write-intent.mjs';
19
+
20
+ const WRITE_TOOL_NAMES = new Set(WRITE_TOOLS);
21
+ const SHELL_TOOL_NAMES = new Set(SHELL_TOOLS);
22
+ const pass = () => process.exit(0);
23
+
24
+ let input;
25
+ try {
26
+ input = JSON.parse(fs.readFileSync(0, 'utf8'));
27
+ } catch {
28
+ pass();
29
+ }
30
+
31
+ if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
32
+ if (!WRITE_TOOL_NAMES.has(input.tool_name) && !SHELL_TOOL_NAMES.has(input.tool_name)) pass();
33
+
34
+ const toolInput = input.tool_input;
35
+ if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
36
+ let rawPaths;
37
+ if (WRITE_TOOL_NAMES.has(input.tool_name)) {
38
+ const pathField = input.tool_name === 'NotebookEdit' ? 'notebook_path' : 'file_path';
39
+ const rawPath = toolInput[pathField];
40
+ if (typeof rawPath !== 'string' || !rawPath.trim()) pass();
41
+ rawPaths = [rawPath];
42
42
  } else {
43
43
  let intent;
44
44
  try {
@@ -48,69 +48,70 @@ if (WRITE_TOOL_NAMES.has(input.tool_name)) {
48
48
  // the scanner. Unrecognised input must fail open; the witness checks writes afterwards.
49
49
  pass();
50
50
  }
51
- if (!intent.writes || !intent.paths.length) pass();
52
- rawPaths = intent.paths;
53
- }
54
-
55
- let liveRuns;
56
- try {
57
- liveRuns = allLiveRuns(runsRoot());
58
- } catch {
59
- pass();
60
- }
61
- let match;
62
- for (const rawPath of rawPaths) {
63
- let targetPath;
64
- try {
65
- if (path.isAbsolute(rawPath)) targetPath = rawPath;
66
- else if (typeof input.cwd === 'string' && input.cwd.trim()) targetPath = path.resolve(input.cwd, rawPath);
67
- else continue;
68
- } catch {
69
- continue;
70
- }
71
- const target = normalizePath(targetPath);
72
- if (!target) continue;
73
- const owner = liveRuns.find(({ status }) => {
74
- if (status.agent !== 'codex-build') return false;
75
- const repository = normalizePath(status.repo);
76
- return Boolean(repository) && (target === repository || target.startsWith(`${repository}/`));
77
- });
78
- if (owner) {
79
- match = { owner, targetPath };
80
- break;
81
- }
82
- }
83
- if (!match) pass();
84
-
85
- const { status, dir } = match.owner;
86
- // A run is only ever failed for what `git status --porcelain` reports, and an ignored path never
87
- // appears there — the witness that grades a run does not see it either. Meanwhile the working
88
- // order for a live run is to touch exactly those files: plans and checklists live in ignored
89
- // folders. On 2026-08-23 this guard refused four such edits in five minutes, which is worse than
90
- // a miss: a miss has the witness behind it, a false refusal has nothing and reads as a broken
91
- // tool. Only a proven `ignored` passes — anything else (not a repository, git absent, any other
92
- // error) keeps the refusal, because "no proof it is ignored" is not "proof it is harmless".
93
- const repository = normalizePath(status.repo);
94
- const target = normalizePath(match.targetPath);
95
- const gitMetadata = target === `${repository}/.git` || target.startsWith(`${repository}/.git/`);
96
- if (!gitMetadata) {
97
- const ignored = spawnSync('git', ['-C', status.repo, 'check-ignore', '--quiet', '--', match.targetPath], {
98
- stdio: 'ignore',
99
- });
100
- if (ignored.status === 0) pass();
101
- }
102
- process.stdout.write(JSON.stringify({
103
- hookSpecificOutput: {
104
- hookEventName: 'PreToolUse',
105
- permissionDecision: 'deny',
106
- permissionDecisionReason: `Worktree lock denied ${input.tool_name}: ${match.targetPath} is inside repository `
107
- + `${status.repo}, held by live run folder ${dir} (agent ${status.agent}, slug ${status.slug}). `
108
- // "Working, last progress X ago" rather than "silent for X": this hook only ever denies a
109
- // run whose heartbeat is still fresh — a stale one is not live here and the edit passes. The
110
- // first live probe printed "silent for 10 seconds" next to a stop command on a perfectly
111
- // healthy run, which reads as "it hung, kill it" and would cost the operator 20 minutes of
112
- // work they meant to keep.
113
- + `It is working; last progress ${formatSilence(heartbeatAge(dir))} ago. `
114
- + `To take the repository back before it finishes: codex-bridge stop ${path.basename(dir)}.`,
115
- },
116
- }));
51
+ if (!intent.writes || !intent.paths.length) pass();
52
+ rawPaths = intent.paths;
53
+ }
54
+
55
+ let liveRuns;
56
+ try {
57
+ liveRuns = allLiveRuns(runsRoot());
58
+ } catch {
59
+ pass();
60
+ }
61
+ let match;
62
+ for (const rawPath of rawPaths) {
63
+ let targetPath;
64
+ try {
65
+ if (path.isAbsolute(rawPath)) targetPath = rawPath;
66
+ else if (typeof input.cwd === 'string' && input.cwd.trim()) targetPath = path.resolve(input.cwd, rawPath);
67
+ else continue;
68
+ } catch {
69
+ continue;
70
+ }
71
+ const target = normalizePath(targetPath);
72
+ if (!target) continue;
73
+ const owner = liveRuns.find(({ status }) => {
74
+ if (status.agent !== 'codex-build') return false;
75
+ const repository = normalizePath(status.repo);
76
+ return Boolean(repository) && (target === repository || target.startsWith(`${repository}/`));
77
+ });
78
+ if (owner) {
79
+ match = { owner, targetPath };
80
+ break;
81
+ }
82
+ }
83
+ if (!match) pass();
84
+
85
+ const { status, dir } = match.owner;
86
+ // A run is only ever failed for what `git status --porcelain` reports, and an ignored path never
87
+ // appears there — the witness that grades a run does not see it either. Meanwhile the working
88
+ // order for a live run is to touch exactly those files: plans and checklists live in ignored
89
+ // folders. On 2026-08-23 this guard refused four such edits in five minutes, which is worse than
90
+ // a miss: a miss has the witness behind it, a false refusal has nothing and reads as a broken
91
+ // tool. Only a proven `ignored` passes — anything else (not a repository, git absent, any other
92
+ // error) keeps the refusal, because "no proof it is ignored" is not "proof it is harmless".
93
+ const repository = normalizePath(status.repo);
94
+ const target = normalizePath(match.targetPath);
95
+ const gitMetadata = target === `${repository}/.git` || target.startsWith(`${repository}/.git/`);
96
+ if (!gitMetadata) {
97
+ const ignored = spawnSync('git', ['-C', status.repo, 'check-ignore', '--quiet', '--', match.targetPath], {
98
+ stdio: 'ignore',
99
+ windowsHide: true,
100
+ });
101
+ if (ignored.status === 0) pass();
102
+ }
103
+ process.stdout.write(JSON.stringify({
104
+ hookSpecificOutput: {
105
+ hookEventName: 'PreToolUse',
106
+ permissionDecision: 'deny',
107
+ permissionDecisionReason: `Worktree lock denied ${input.tool_name}: ${match.targetPath} is inside repository `
108
+ + `${status.repo}, held by live run folder ${dir} (agent ${status.agent}, slug ${status.slug}). `
109
+ // "Working, last progress X ago" rather than "silent for X": this hook only ever denies a
110
+ // run whose heartbeat is still fresh — a stale one is not live here and the edit passes. The
111
+ // first live probe printed "silent for 10 seconds" next to a stop command on a perfectly
112
+ // healthy run, which reads as "it hung, kill it" and would cost the operator 20 minutes of
113
+ // work they meant to keep.
114
+ + `It is working; last progress ${formatSilence(heartbeatAge(dir))} ago. `
115
+ + `To take the repository back before it finishes: codex-bridge stop ${path.basename(dir)}.`,
116
+ },
117
+ }));
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Publishes a JSON record whole or not at all: a temporary file in the target's directory renamed over it.
3
+ *
4
+ * Plan_62 B2 wrote this twice, once per state module; two copies of one write discipline drift apart the
5
+ * first time either is fixed. Callers hold their own lock — this does not serialize concurrent writers
6
+ * (`config-edit.mjs` keeps its own compare-and-swap publish for that reason).
7
+ */
8
+ import { randomUUID } from 'node:crypto';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ export function writeJsonAtomic(file, record) {
13
+ fs.mkdirSync(path.dirname(file), { recursive: true });
14
+ const temporary = `${file}.${randomUUID()}.tmp`;
15
+ try {
16
+ fs.writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { flag: 'wx' });
17
+ fs.renameSync(temporary, file);
18
+ } finally {
19
+ fs.rmSync(temporary, { force: true });
20
+ }
21
+ }
@@ -15,6 +15,10 @@
15
15
  import os from 'node:os';
16
16
  import path from 'node:path';
17
17
 
18
+ export function brandStateDir(root) {
19
+ return path.join(root, 'state');
20
+ }
21
+
18
22
  export function resolveBrandHome({ homedir = os.homedir(), env = process.env } = {}) {
19
23
  const overridden = Boolean(env.CODEX_BRIDGE_HOME);
20
24
  const root = path.resolve(
@@ -23,6 +27,7 @@ export function resolveBrandHome({ homedir = os.homedir(), env = process.env } =
23
27
  return {
24
28
  root,
25
29
  source: overridden ? 'CODEX_BRIDGE_HOME' : 'default',
30
+ stateDir: brandStateDir(root),
26
31
  configPath: path.join(root, 'config.json'),
27
32
  conventionsPath: path.join(root, 'conventions.md'),
28
33
  };
@@ -35,5 +40,6 @@ export function resolveBrandHome({ homedir = os.homedir(), env = process.env } =
35
40
  * environment after import, which would not be seen here.
36
41
  */
37
42
  export const BRAND_HOME = resolveBrandHome();
43
+ export const BRAND_STATE_DIR = BRAND_HOME.stateDir;
38
44
  export const BRAND_CONFIG_PATH = BRAND_HOME.configPath;
39
45
  export const BRAND_CONVENTIONS_PATH = BRAND_HOME.conventionsPath;
@@ -0,0 +1,79 @@
1
+ /** Owns the canonical dispatcher command because the 2026-09-23 TradeForge and 2026-09-24 local incidents let haiku ignore its prompt and register two paid runs for one order id. */
2
+ import { CLI_NAMES } from './cli-names.mjs';
3
+ import {
4
+ extractValue,
5
+ isAbsoluteTaskFilePath,
6
+ isInputPlaceholder,
7
+ parseContinuationGrant,
8
+ REQUIRED_INPUTS,
9
+ } from './required-inputs.mjs';
10
+ import { firstShellUnsafeSequence } from './shell-unsafe.mjs';
11
+
12
+ function refusal(reason) {
13
+ return { refusal: `Refused: ${reason}.` };
14
+ }
15
+
16
+ function unsafeValueReason(value, label) {
17
+ const sequence = firstShellUnsafeSequence(value);
18
+ if (sequence !== null) return `input "${label}" contains unsafe shell sequence ${JSON.stringify(sequence)}`;
19
+ if (value.includes('"') || value.includes('\r') || value.includes('\n')) {
20
+ return `input "${label}" contains a double quote or line break`;
21
+ }
22
+ return null;
23
+ }
24
+
25
+ function quote(value) {
26
+ return `"${value}"`;
27
+ }
28
+
29
+ /** Builds the only permitted run command directly from the immutable order transcript. */
30
+ export function canonicalRunCommand(agentType, promptText) {
31
+ if (typeof agentType !== 'string' || !Object.prototype.hasOwnProperty.call(REQUIRED_INPUTS, agentType)) {
32
+ return refusal(`unknown dispatcher agent ${JSON.stringify(agentType)}`);
33
+ }
34
+ if (typeof promptText !== 'string') return refusal('prompt text is missing or is not text');
35
+
36
+ const inputs = new Map();
37
+ for (const entry of REQUIRED_INPUTS[agentType]) {
38
+ if (entry.conditional) continue;
39
+ const value = extractValue(promptText, entry.label);
40
+ if (isInputPlaceholder(value, entry.label)) {
41
+ return refusal(`required input "${entry.label}" is missing or is a placeholder`);
42
+ }
43
+ const unsafe = unsafeValueReason(value, entry.label);
44
+ if (unsafe) return refusal(unsafe);
45
+ if (entry.label === 'task file' && !isAbsoluteTaskFilePath(value)) {
46
+ return refusal('input "task file" must be an absolute path');
47
+ }
48
+ inputs.set(entry.label, value);
49
+ }
50
+
51
+ // The continuation grant never reaches the command line (`--continue` is bare), so its free-text
52
+ // reason is not screened: the live grant "…five risks; advise settles them" would have been refused.
53
+ const optionalLabels = ['repository', 'scope new', 'slug', 'effort'];
54
+ for (const label of optionalLabels) {
55
+ const value = extractValue(promptText, label);
56
+ if (value === null) continue;
57
+ const unsafe = unsafeValueReason(value, label);
58
+ if (unsafe) return refusal(unsafe);
59
+ inputs.set(label, value);
60
+ }
61
+
62
+ const tokens = [CLI_NAMES[0], 'run', '--agent', agentType, '--repo', quote(inputs.get('repository') || '.')];
63
+ if (agentType === 'codex-advisor') tokens.push('--phase', quote(inputs.get('phase')));
64
+ if (agentType === 'codex-build') {
65
+ tokens.push('--scope', quote(inputs.get('scope')));
66
+ if (inputs.get('scope new')) tokens.push('--scope-new', quote(inputs.get('scope new')));
67
+ }
68
+ if (inputs.get('slug')) tokens.push('--slug', quote(inputs.get('slug')));
69
+ tokens.push('--order-id', quote(inputs.get('order id')));
70
+ tokens.push('--task-file', quote(inputs.get('task file')));
71
+ if (inputs.get('effort')) tokens.push('--effort', quote(inputs.get('effort')));
72
+ if (parseContinuationGrant(promptText)) tokens.push('--continue');
73
+ return { command: tokens.join(' ') };
74
+ }
75
+
76
+ /** Compares the proposed command as text, allowing whitespace only around its boundary. */
77
+ export function sameCommand(candidate, canonical) {
78
+ return typeof candidate === 'string' && typeof canonical === 'string' && candidate.trim() === canonical;
79
+ }
@@ -0,0 +1,95 @@
1
+ /** Holds dispatchers to the ordered runner because the 2026-09-23 TradeForge incident let one write and hand back without running it. */
2
+ import { canonicalRunCommand, sameCommand } from './dispatcher-command.mjs';
3
+ import { ownTranscriptPath, transcriptPrompt } from './dispatcher-order.mjs';
4
+ import { HANDBACK_TOOL } from './hook-definitions.mjs';
5
+
6
+ export { HANDBACK_TOOL };
7
+
8
+ export function gateOrder(payload) {
9
+ const transcriptPath = ownTranscriptPath(payload);
10
+ const prompt = transcriptPath ? transcriptPrompt(transcriptPath) : null;
11
+ if (!transcriptPath || !prompt) {
12
+ return {
13
+ refusal: `the order could not be read from the agent transcript ${transcriptPath || '(none)'}`,
14
+ };
15
+ }
16
+ return canonicalRunCommand(payload.agent_type, prompt);
17
+ }
18
+
19
+ export function runnerOutput(payload) {
20
+ if (payload?.hook_event_name === 'PostToolUse') {
21
+ const output = payload.tool_response?.stdout;
22
+ return typeof output === 'string' && output.length > 0 ? { output, exitCode: 0 } : null;
23
+ }
24
+ if (payload?.hook_event_name !== 'PostToolUseFailure' || typeof payload.error !== 'string'
25
+ || payload.error.length === 0) return null;
26
+
27
+ const newline = payload.error.indexOf('\n');
28
+ const firstLine = (newline < 0 ? payload.error : payload.error.slice(0, newline)).replace(/\r$/, '');
29
+ const match = /^Exit code (\d+)$/.exec(firstLine);
30
+ const output = match ? (newline < 0 ? '' : payload.error.slice(newline + 1)) : payload.error;
31
+ return output.length > 0 ? { output, exitCode: match ? Number(match[1]) : null } : null;
32
+ }
33
+
34
+ export function isFinalOutput(output) {
35
+ return !output.split(/\r?\n/).some((line) => line.startsWith('STARTED '));
36
+ }
37
+
38
+ function denial(message, order) {
39
+ const prefix = `codex-bridge dispatcher gate: ${message}`;
40
+ if (typeof order?.command !== 'string' || order.command.length === 0) return prefix;
41
+ return `${prefix}\n${order.command}\nRun it exactly, in the foreground; repeating it attaches to the same run.`;
42
+ }
43
+
44
+ function deny(message, order, stateUpdate) {
45
+ const decision = { kind: 'deny', reason: denial(message, order) };
46
+ if (stateUpdate) decision.stateUpdate = stateUpdate;
47
+ return decision;
48
+ }
49
+
50
+ function allow(message) {
51
+ return { kind: 'allow', message, stateUpdate: { handback: 'delivered' } };
52
+ }
53
+
54
+ export function decidePreToolUse({ payload, order, state }) {
55
+ const toolName = payload?.tool_name;
56
+ // Once the caller holds the answer, the dispatcher is finished. On 2026-09-23 the real run was started
57
+ // after the handback and wrote into a tree the caller believed settled; a substituted FAIL followed by a
58
+ // late run would repeat exactly that, so nothing — not even the canonical command — runs past delivery.
59
+ if (state?.handback === 'delivered') {
60
+ return deny('the handback was already delivered; this dispatcher is finished and may not act further.');
61
+ }
62
+ if (toolName === 'Bash') {
63
+ if (order?.refusal) {
64
+ return deny('the order could not be read; nothing may run, so hand back now.', order);
65
+ }
66
+ if (order?.command && sameCommand(payload.tool_input?.command, order.command)
67
+ && payload.tool_input?.run_in_background !== true) return { kind: 'pass' };
68
+ return deny('only the canonical runner command may run in the foreground.', order);
69
+ }
70
+
71
+ if (toolName === HANDBACK_TOOL) {
72
+ if (order?.refusal) return allow(`FAIL — dispatcher gate: ${order.refusal}`);
73
+ if (typeof state?.runnerOutput === 'string' && state.runnerOutput.length > 0) {
74
+ if (state.runnerFinal) return allow(state.runnerOutput);
75
+ return deny('the run has started without a verdict; repeat the exact command to attach and wait.', order);
76
+ }
77
+
78
+ const attempts = Number.isInteger(state?.handbackAttempts) ? state.handbackAttempts : 0;
79
+ const handbackAttempts = attempts + 1;
80
+ if (handbackAttempts === 1) {
81
+ return deny('the dispatcher handed back without a runner verdict; run the command first.', order,
82
+ { handbackAttempts });
83
+ }
84
+ return {
85
+ kind: 'allow',
86
+ message: 'FAIL — dispatcher did not delegate: it handed back without running `' + order.command + '`',
87
+ stateUpdate: { handbackAttempts, handback: 'delivered' },
88
+ };
89
+ }
90
+
91
+ if (order?.refusal) {
92
+ return deny('the order could not be read; nothing may run, so hand back now.', order);
93
+ }
94
+ return deny('only the canonical runner command may run before handback.', order);
95
+ }