@lyupro/codex-bridge 0.6.5 → 0.6.7

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 (54) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +5 -5
  3. package/bin/codex-bridge.mjs +181 -178
  4. package/cli/dispatcher-contract-record.mjs +42 -0
  5. package/cli/dispatcher-contract.mjs +89 -0
  6. package/cli/doctor-hooks.mjs +104 -114
  7. package/cli/doctor-runs.mjs +55 -55
  8. package/cli/doctor.mjs +39 -3
  9. package/cli/handback-witness-check.mjs +39 -0
  10. package/cli/hook-targets.mjs +14 -6
  11. package/cli/hook.mjs +32 -23
  12. package/cli/host-contract.mjs +136 -135
  13. package/cli/install.mjs +9 -14
  14. package/cli/launcher-probe.mjs +38 -0
  15. package/cli/probe-contract.mjs +31 -62
  16. package/cli/probe-rig.mjs +53 -0
  17. package/cli/run-launcher.mjs +17 -0
  18. package/cli/settings-merge.mjs +42 -35
  19. package/cli/uninstall.mjs +15 -3
  20. package/cli/update.mjs +45 -16
  21. package/package.json +4 -4
  22. package/src/agents/codex-advisor.md +3 -4
  23. package/src/agents/codex-build.md +5 -5
  24. package/src/agents/codex-review.md +3 -3
  25. package/src/agents/codex-scout.md +3 -4
  26. package/src/home/hooks/dispatcher-gate.mjs +119 -0
  27. package/src/home/hooks/guard-tries.mjs +5 -6
  28. package/src/home/hooks/live-runs.mjs +1 -0
  29. package/src/home/hooks/order-gate.mjs +20 -5
  30. package/src/home/hooks/reply-guard.mjs +75 -10
  31. package/src/home/hooks/reply-verdicts.mjs +5 -0
  32. package/src/home/hooks/worktree-lock.mjs +108 -107
  33. package/src/home/lib/atomic-json.mjs +21 -0
  34. package/src/home/lib/brand-home.mjs +6 -0
  35. package/src/home/lib/dispatcher-command.mjs +79 -0
  36. package/src/home/lib/dispatcher-gate.mjs +95 -0
  37. package/src/home/lib/dispatcher-order.mjs +30 -8
  38. package/src/home/lib/dispatcher-state.mjs +111 -0
  39. package/src/home/lib/dispatcher-stop.mjs +52 -0
  40. package/src/home/lib/handback-witness.mjs +65 -0
  41. package/src/home/lib/hook-definitions.mjs +10 -0
  42. package/src/home/lib/hook-entry.mjs +20 -0
  43. package/src/home/lib/meta/advice-status.mjs +3 -7
  44. package/src/home/lib/meta/advice-verdict.mjs +37 -21
  45. package/src/home/lib/required-inputs.mjs +1 -1
  46. package/src/home/lib/runner/advise-carry.mjs +50 -0
  47. package/src/home/lib/runner/attach.mjs +11 -7
  48. package/src/home/lib/runner/codex-cmd.mjs +3 -2
  49. package/src/home/lib/runner/continuation.mjs +3 -1
  50. package/src/home/lib/runner/git-state.mjs +1 -1
  51. package/src/home/lib/runner/launcher.mjs +14 -13
  52. package/src/home/lib/runner/preflight.mjs +6 -3
  53. package/src/home/lib/runner/prompts.mjs +9 -8
  54. package/src/home/lib/runner/schemas.mjs +33 -7
@@ -57,44 +57,45 @@ export function commandReachable(name = 'codex-bridge', env = process.env) {
57
57
  * could decide anything, and the host refused Bash, PowerShell, file edits and agent launches.
58
58
  */
59
59
  export function reachableCommandVersion(name = 'codex-bridge', env = process.env) {
60
+ const result = runReachableCommand(name, ['--version'], env);
61
+ if (!result || result.status !== 0) return null;
62
+ return String(result.stdout || '').trim().split(/\r?\n/).pop()?.trim() || null;
63
+ }
64
+
65
+ export function runReachableCommand(name, args, env = process.env) {
60
66
  const shimPath = resolveCommand(name, env);
61
67
  if (!shimPath) return null;
62
68
  // Node 24 warned with DEP0190 during update (2026-09-07): invoke cmd explicitly for npm shims.
63
69
  // Live probes covered plain, bin with space, Program Files (x86), weird & name, and caret^dir.
64
- // /d /s /c with path and --version as separate arguments only passed the plain directory:
70
+ // /d /s /c with path and fixed arguments as separate arguments only passed the plain directory:
65
71
  // Node adds quotes, then cmd's /s strips the first and last quote of the whole command line.
66
72
  // /d /c without /s failed on & and ^. This single, double-wrapped verbatim command passed all
67
73
  // five. Keep native path separators: forward-slash Windows paths do not work through cmd.
68
74
  // The interpreter is a property of the machine, not of the PATH handed in: resolving cmd.exe
69
- // through the caller's PATH would return null on any env whose PATH omits System32, and a null
70
- // version is exactly the silent failure this function exists to prevent.
75
+ // through the caller's PATH would return null on any env whose PATH omits System32, hiding the
76
+ // exact command outcome this probe exists to report.
71
77
  const shell = envValue(env, 'comspec') || process.env.ComSpec || 'cmd.exe';
78
+ const fixedArgs = args.map((argument) => ` ${argument}`).join('');
72
79
  const result = process.platform === 'win32'
73
- ? spawnSync(shell, ['/d', '/s', '/c', `""${shimPath}" --version"`], {
74
- encoding: 'utf8', env, shell: false, windowsVerbatimArguments: true,
80
+ ? spawnSync(shell, ['/d', '/s', '/c', `""${shimPath}"${fixedArgs}"`], {
81
+ encoding: 'utf8', env, shell: false, windowsVerbatimArguments: true, windowsHide: true,
82
+ timeout: 10000, maxBuffer: 64 * 1024,
75
83
  })
76
- : spawnSync(shimPath, ['--version'], { encoding: 'utf8', env, shell: false });
77
- if (result.error || result.status !== 0) return null;
78
- return String(result.stdout || '').trim().split(/\r?\n/).pop()?.trim() || null;
84
+ : spawnSync(shimPath, args, {
85
+ encoding: 'utf8', env, shell: false, windowsHide: true, timeout: 10000, maxBuffer: 64 * 1024,
86
+ });
87
+ return { path: shimPath, status: result.status, stdout: String(result.stdout || '') };
79
88
  }
80
89
 
81
- export function hookRegistration(name, target, env = process.env, packageVersion = null) {
82
- // Requiring the versions to match is stricter than asking whether `hook` exists, deliberately:
83
- // a command of another version would run code this install has never seen, which is the hazard
84
- // README describes when a global install and a clone coexist. Anything short of a match falls
85
- // back to the copy this install placed itself.
86
- const reachable = reachableCommandVersion('codex-bridge', env);
87
- if (reachable && packageVersion && reachable === packageVersion) {
90
+ export function hookRegistration(name, target, probe) {
91
+ if (probe.ok) {
88
92
  return {
89
93
  command: shortCommandFor(name),
90
94
  form: 'short',
91
- reason: `codex-bridge ${reachable} on PATH matches this package; the global command will execute`,
95
+ reason: `codex-bridge on PATH launches guards from this home (${probe.homeRoot})`,
92
96
  };
93
97
  }
94
- const reason = reachable
95
- ? `codex-bridge on PATH reports ${reachable}, not ${packageVersion}; the installed copy will execute`
96
- : 'codex-bridge is not reachable from PATH; the installed copy will execute';
97
- return { command: commandFor(target), form: 'path', reason };
98
+ return { command: commandFor(target), form: 'path', reason: `${probe.reason}; the installed copy will execute` };
98
99
  }
99
100
 
100
101
  export function commandForm(command) {
@@ -163,7 +164,9 @@ const hasOwnCommand = (group, commands) => {
163
164
  * deleted file. Conversely, the 2026-08-17 host proved that finding alone is insufficient: its
164
165
  * worktree lock stayed under the pre-shell matcher, so Bash never reached the guard. The command
165
166
  * is this package's own absolute path, so it identifies our entry wherever the group ended up,
166
- * and merge can move it without creating a duplicate.
167
+ * and merge can move it without creating a duplicate. Alternate commands identify our entry, but
168
+ * do not prove it is in the launcher form selected for this operation. Update must rewrite stale
169
+ * forms, or unsafe registrations already in settings.json would survive every future update.
167
170
  */
168
171
  export async function inspectHook(settingsPath, specOrPath) {
169
172
  const state = await readSettings(settingsPath);
@@ -172,11 +175,15 @@ export async function inspectHook(settingsPath, specOrPath) {
172
175
  let matchedCommand;
173
176
  let matchedMatcher;
174
177
  let requiresMove = false;
178
+ let requiresRewrite = false;
179
+ let duplicates = 0;
175
180
  for (const group of groups(state.settings, spec.event)) {
176
181
  for (const hook of groupHooks(group)) {
177
182
  if (hook?.type !== 'command' || !commands.includes(hook.command)) continue;
178
183
  matchedCommand ??= hook.command;
179
184
  matchedMatcher ??= group?.matcher;
185
+ duplicates += 1;
186
+ if (hook.command !== spec.command) requiresRewrite = true;
180
187
  if (group?.matcher !== spec.matcher) requiresMove = true;
181
188
  }
182
189
  }
@@ -187,6 +194,9 @@ export async function inspectHook(settingsPath, specOrPath) {
187
194
  matchedCommand,
188
195
  matchedMatcher,
189
196
  requiresMove,
197
+ requiresRewrite,
198
+ duplicates: duplicates > 1,
199
+ current: Boolean(matchedCommand) && !requiresMove && !requiresRewrite && duplicates === 1,
190
200
  };
191
201
  }
192
202
 
@@ -258,15 +268,17 @@ export async function mergeHook(settingsPath, specOrPath, inspected) {
258
268
  return withMutationRun(settingsPath, async () => {
259
269
  const spec = normalizeSpec(specOrPath);
260
270
  const state = inspected || await inspectHook(settingsPath, spec);
261
- if (state.present && !state.requiresMove) return { changed: false, createdGroup: false };
271
+ if (state.current) return { changed: false, createdGroup: false };
262
272
  const settings = structuredClone(state.settings);
263
273
  settings.hooks ??= {};
264
274
  if (!Array.isArray(settings.hooks[spec.event])) settings.hooks[spec.event] = [];
265
275
  if (state.present) {
266
276
  const eventGroups = settings.hooks[spec.event];
267
277
  const commands = [spec.command, ...spec.alternateCommands];
278
+ // The entry in the declared group wins, so a field the operator added there (a timeout)
279
+ // survives the rewrite; groups are walked from the end, which would otherwise pick a stale copy.
268
280
  let hookToMove;
269
- let keptDeclaredHook = false;
281
+ let declaredHook;
270
282
  for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
271
283
  const group = eventGroups[index];
272
284
  if (!Array.isArray(group?.hooks)) continue;
@@ -274,26 +286,21 @@ export async function mergeHook(settingsPath, specOrPath, inspected) {
274
286
  group.hooks = group.hooks.filter((hook) => {
275
287
  if (hook?.type !== 'command' || !commands.includes(hook.command)) return true;
276
288
  hookToMove ??= hook;
277
- if (group.matcher === spec.matcher && !keptDeclaredHook) {
278
- keptDeclaredHook = true;
279
- return true;
280
- }
289
+ if (group.matcher === spec.matcher) declaredHook ??= hook;
281
290
  return false;
282
291
  });
283
292
  if (hadOwnHook && group.hooks.length === 0) eventGroups.splice(index, 1);
284
293
  }
285
294
  let group = eventGroups.find((entry) => entry?.matcher === spec.matcher);
286
295
  const createdGroup = !group;
287
- if (!keptDeclaredHook) {
288
- if (!group) {
289
- group = { matcher: spec.matcher, hooks: [] };
290
- eventGroups.push(group);
291
- }
292
- if (!Array.isArray(group.hooks)) group.hooks = [];
293
- group.hooks.push(hookToMove);
296
+ if (!group) {
297
+ group = { matcher: spec.matcher, hooks: [] };
298
+ eventGroups.push(group);
294
299
  }
300
+ if (!Array.isArray(group.hooks)) group.hooks = [];
301
+ group.hooks.push({ ...(declaredHook || hookToMove), command: spec.command });
295
302
  await atomicWrite(settingsPath, settings, state);
296
- return { changed: true, createdGroup, moved: true };
303
+ return { changed: true, createdGroup, ...(state.requiresMove ? { moved: true } : {}), ...(state.requiresRewrite ? { rewritten: true } : {}) };
297
304
  }
298
305
  let group = settings.hooks[spec.event].find((entry) => entry?.matcher === spec.matcher);
299
306
  const createdGroup = !group;
package/cli/uninstall.mjs CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  recordTarget,
11
11
  } from './manifest.mjs';
12
12
  import { removePermissionRules } from './permissions.mjs';
13
+ import { hostContractPath } from './host-contract.mjs';
14
+ import { brandStateDir } from '../src/home/lib/brand-home.mjs';
13
15
  import {
14
16
  commandFor,
15
17
  removeHook,
@@ -33,6 +35,17 @@ function permissionOutput(host, removed, dryRun) {
33
35
  return `${verb} ${removed} permission rule ${plural} from ${host.settingsPath}.`;
34
36
  }
35
37
 
38
+ // Plan_62 D22: uninstall removes only recorded files, and the message used to name only runs and
39
+ // config.json — state/, the host measurement and conventions.md stayed behind unmentioned. Until
40
+ // Plan_65 derives this from a registry, the sentence names every kind the package writes while working.
41
+ function preservationText(host) {
42
+ return `Run artifacts in ${path.join(host.root, 'codex-runs')} are preserved, and so is what the package `
43
+ + `wrote into ${host.brandRoot} while working: the run configuration ${host.brandConfigPath}, the conventions `
44
+ + `${host.brandConventionsPath}, the host measurement ${hostContractPath(host)} and runtime state in `
45
+ + `${brandStateDir(host.brandRoot)} (dispatcher state, guard counters, the handback witness, dispatcher `
46
+ + 'contract verdicts, and hook diagnostics that hold the full hook input). Delete them by hand for a complete removal.';
47
+ }
48
+
36
49
  function displayFile(file) {
37
50
  return `${file.root}/${file.path}`;
38
51
  }
@@ -62,8 +75,7 @@ async function uninstallInRun({ host, dryRun = false } = {}) {
62
75
  const permissionResult = await removePermissionRules(host.settingsPath, { dryRun });
63
76
  const permissionLine = permissionOutput(host, permissionResult.removed, dryRun);
64
77
  const record = await readInstallRecord(host);
65
- const preservation = `Run artifacts in ${path.join(host.root, 'codex-runs')} and the run `
66
- + `configuration in ${host.brandConfigPath} are preserved.`;
78
+ const preservation = preservationText(host);
67
79
  if (!record) {
68
80
  return { exitCode: 1, output: `${permissionLine}\ncodex-bridge is not installed.\n${preservation}` };
69
81
  }
@@ -92,7 +104,7 @@ async function uninstallInRun({ host, dryRun = false } = {}) {
92
104
  }
93
105
  for (const hook of record.hooks) {
94
106
  const definition = definitionForRecordedHook(hook);
95
- lines.push(`Would remove the ${hook.event} hook for matcher ${definition.matcher}.`);
107
+ lines.push(`Would remove the ${hook.event} hook ${definition.name} for matcher ${definition.matcher}.`);
96
108
  }
97
109
  lines.push('Would remove the installation record from the brand root.');
98
110
  lines.push(preservation);
package/cli/update.mjs CHANGED
@@ -17,11 +17,10 @@ import {
17
17
  rulesPlan,
18
18
  } from './manifest.mjs';
19
19
  import { targetMatches } from './copy.mjs';
20
- import { hookTargets } from './hook-targets.mjs';
20
+ import { hookTargets, recordHasHooks } from './hook-targets.mjs';
21
21
  import { definitionForRecordedHook, fileEntry, fingerprintFor } from './install-record.mjs';
22
22
  import {
23
23
  commandFor,
24
- hookRegistration,
25
24
  inspectHook,
26
25
  removeHook,
27
26
  withSettingsRun,
@@ -29,6 +28,12 @@ import {
29
28
  import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
30
29
  import { claudeBoundary, removeEmptyLayout, removeEmptyParents } from './remove-layout.mjs';
31
30
 
31
+ const LEGACY_GUARD_LOG_FILES = [
32
+ 'codex-reply-guard.blocked.json',
33
+ 'codex-order-gate.last.json',
34
+ 'codex-reply-guard.last.json',
35
+ ];
36
+
32
37
  async function exists(target) {
33
38
  try {
34
39
  return (await fs.stat(target)).isFile();
@@ -45,6 +50,18 @@ async function retireLegacyLayout(host) {
45
50
  await removeEmptyLayout(host.legacyCommandsDir);
46
51
  }
47
52
 
53
+ async function retireLegacyGuardLogs(host, { dryRun = false } = {}) {
54
+ const logDir = path.join(path.dirname(host.settingsPath), 'logs');
55
+ const lines = [];
56
+ for (const name of LEGACY_GUARD_LOG_FILES) {
57
+ const target = path.join(logDir, name);
58
+ if (!await exists(target)) continue;
59
+ if (!dryRun) await fs.rm(target, { force: true });
60
+ lines.push(`${dryRun ? 'Would remove' : 'Removed'} ${path.join('logs', name)}.`);
61
+ }
62
+ return lines.join('\n');
63
+ }
64
+
48
65
  function displayFile(file) {
49
66
  const entry = fileEntry(file);
50
67
  return `${entry.root}/${entry.path}`;
@@ -64,12 +81,6 @@ function classifyOrphan(state) {
64
81
  return 'modified';
65
82
  }
66
83
 
67
- function recordHasHooks(record, targets) {
68
- return Boolean(record)
69
- && targets.every(({ definition, relative }) => record.hooks?.some((hook) =>
70
- hook.event === definition.event && hook.root === 'brand' && hook.path === relative));
71
- }
72
-
73
84
  function appliedOutput(states) {
74
85
  const count = (status) => states.filter((state) => state.status === status).length;
75
86
  const parts = [
@@ -104,12 +115,14 @@ function dryRunOutput(states, hookStates, legacy, oldHooks) {
104
115
  // "the previous undefined hook registration" five times during the Plan_25 migration — the one
105
116
  // dry run where the operator most needed to see which registrations were about to be taken away.
106
117
  oldHooks.forEach(({ hook, definition }) => lines.push(
107
- `Would remove the previous ${hook.event} hook registration for matcher ${definition.matcher}.`,
118
+ `Would remove the previous ${hook.event} hook registration ${definition.name} for matcher ${definition.matcher}.`,
108
119
  ));
109
120
  hookStates.forEach(({ state, target }) => {
110
121
  const { definition, registration } = target;
111
- if (!state.present) {
112
- lines.push(`Would register ${definition.event} hook for matcher ${definition.matcher} with ${registration.form} command.`);
122
+ if (state.present && !state.current) {
123
+ lines.push(`Would rewrite ${definition.event} hook ${definition.name} for matcher ${definition.matcher} to ${registration.form} command: ${registration.reason}.`);
124
+ } else if (!state.present) {
125
+ lines.push(`Would register ${definition.event} hook ${definition.name} for matcher ${definition.matcher} with ${registration.form} command.`);
113
126
  }
114
127
  });
115
128
  lines.push('Would write installation record with new fingerprints in both roots.');
@@ -210,7 +223,7 @@ async function updateInRun({ host, dryRun = false, force = false, packageRoot, e
210
223
  + ' The home was updated from the copy that answered, not from that checkout'
211
224
  + ` (npm i -g ${currentPackage.name}@${elsewhere.version} changes which one that is).`
212
225
  : '';
213
- const targets = hookTargets(host, env, currentPackage.version);
226
+ const targets = hookTargets(host, env);
214
227
  const inspectedHooks = await Promise.all(targets.map(async (target) => ({
215
228
  target,
216
229
  state: await inspectHook(host.settingsPath, target.spec),
@@ -220,8 +233,9 @@ async function updateInRun({ host, dryRun = false, force = false, packageRoot, e
220
233
  new Map(plannedStates.map((state) => [recordFileKey(state.item), state.fingerprint])),
221
234
  { path: rule.target, fingerprint: ruleState.fingerprint });
222
235
  const changed = states.some((state) => state.status !== 'up-to-date') || oldHooks.length > 0;
223
- if (!changed && inspectedHooks.every(({ state }) => state.present)
236
+ if (!changed && inspectedHooks.every(({ state }) => state.current)
224
237
  && recordHasHooks(record, targets) && recordCurrent) {
238
+ const legacyLogOutput = await retireLegacyGuardLogs(host, { dryRun });
225
239
  if (!dryRun) {
226
240
  await addRulesOwner(host);
227
241
  // An update that installed the new layout but stopped before retiring the old one used to
@@ -230,10 +244,20 @@ async function updateInRun({ host, dryRun = false, force = false, packageRoot, e
230
244
  // it on this path costs nothing when there is nothing left to retire.
231
245
  await retireLegacyLayout(host);
232
246
  }
233
- return { exitCode: 0, output: `codex-bridge is up to date with ${source}${mismatch}` };
247
+ return {
248
+ exitCode: 0,
249
+ output: [`codex-bridge is up to date with ${source}${mismatch}`, legacyLogOutput]
250
+ .filter(Boolean).join('\n'),
251
+ };
234
252
  }
235
253
  if (dryRun) {
236
- return { exitCode: 0, output: `Would update codex-bridge with ${source}.${mismatch}\n${dryRunOutput(states, inspectedHooks, legacy, oldHooks)}` };
254
+ const legacyLogOutput = await retireLegacyGuardLogs(host, { dryRun: true });
255
+ return {
256
+ exitCode: 0,
257
+ output: [`Would update codex-bridge with ${source}.${mismatch}`,
258
+ dryRunOutput(states, inspectedHooks, legacy, oldHooks), legacyLogOutput]
259
+ .filter(Boolean).join('\n'),
260
+ };
237
261
  }
238
262
 
239
263
  for (const state of orphanStates) {
@@ -249,7 +273,12 @@ async function updateInRun({ host, dryRun = false, force = false, packageRoot, e
249
273
  const installed = await install({ host, force: true, packageRoot, env });
250
274
  if (installed.exitCode !== 0) return installed;
251
275
  await retireLegacyLayout(host);
252
- return { exitCode: 0, output: `${appliedOutput(states)}\nSource: ${source}${mismatch}` };
276
+ const legacyLogOutput = await retireLegacyGuardLogs(host);
277
+ return {
278
+ exitCode: 0,
279
+ output: [`${appliedOutput(states)}\nSource: ${source}${mismatch}`, legacyLogOutput]
280
+ .filter(Boolean).join('\n'),
281
+ };
253
282
  }
254
283
 
255
284
  export async function update(options = {}) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lyupro/codex-bridge",
3
- "version": "0.6.5",
4
- "description": "Claude Code-to-Codex delegation with 6 guards, 1491 tests, zero runtime dependencies, and zero build steps.",
3
+ "version": "0.6.7",
4
+ "description": "Claude Code-to-Codex delegation with 7 guards, 1636 tests, zero runtime dependencies, and zero build steps.",
5
5
  "keywords": [
6
6
  "claude-code",
7
7
  "codex",
@@ -23,8 +23,8 @@
23
23
  "homepage": "https://github.com/lyupro/codex-bridge#readme",
24
24
  "type": "module",
25
25
  "bin": {
26
- "codex-bridge": "./bin/codex-bridge.mjs",
27
- "codexb": "./bin/codex-bridge.mjs"
26
+ "codex-bridge": "bin/codex-bridge.mjs",
27
+ "codexb": "bin/codex-bridge.mjs"
28
28
  },
29
29
  "engines": {
30
30
  "node": ">=24.0.0"
@@ -103,10 +103,9 @@ differs — that refusal is the whole answer: return `FAIL` with the runner's te
103
103
  a different id of your own choosing; the order id is the orchestrator's.
104
104
 
105
105
  Background execution (`run_in_background`, `&`, `nohup`) is prohibited, and so is inventing a
106
- report from memory. If an attaching call is killed by a time ceiling, repeat the identical command
107
- once with `--no-wait`. This only checks state; it is never a final answer. If stdout contains the
108
- ready reply, return it normally. If the call exits 4, return to the ordinary waiting call with the
109
- same order id and without `--no-wait`. A real run takes 20-25 minutes, which is normal, not a hang.
106
+ report from memory. If the attaching call is killed by a time ceiling, run the identical command again: it attaches to the same
107
+ run and keeps waiting. Never add a flag — the host allows exactly the one command your
108
+ order yields and refuses every other. A real run takes 20-25 minutes, which is normal, not a hang.
110
109
  Give the ordinary attaching call `timeout: 1800000` (30 minutes).
111
110
 
112
111
  **Never change `--order-id` or `--slug` to get a fresh run.** The order id is issued by the
@@ -52,7 +52,8 @@ job is to report the run status honestly, including failure.
52
52
  operator's real check command contained `&&`, and a compound operator in an argument makes the
53
53
  host stop applying its permission rule, which is how a delegation dies on a refusal.
54
54
  - Every build task file must contain exactly one `advice:` line from the orchestrator: an absolute
55
- path to an existing run folder whose `meta.json` identifies `codex-advisor`, or exactly
55
+ path to an existing run folder whose `meta.json` identifies a `codex-advisor` advise pass with
56
+ status `OK`, or exactly
56
57
  `mechanical`, `revert`, `docs-only`, or `test-only`. Anything else is refused before the run
57
58
  starts. Pass the task file as supplied; the orchestrator owns this line.
58
59
 
@@ -111,10 +112,9 @@ differs — that refusal is the whole answer: return `FAIL` with the runner's te
111
112
  remedy. Never retry under a different id of your own choosing; the order id is the orchestrator's.
112
113
 
113
114
  Background execution (`run_in_background`, `&`, `nohup`) is prohibited, and so is inventing a report
114
- from memory. If the attaching call is killed by a time ceiling, repeat the identical command once with
115
- `--no-wait`. This only checks state; it is never a final answer. If stdout contains the ready reply,
116
- return it normally. If the call exits 4, return to the ordinary waiting call with the same `--order-id`
117
- and without `--no-wait`. A real run takes 20-25 minutes, which is normal, not a hang. Give the ordinary
115
+ from memory. If the attaching call is killed by a time ceiling, run the identical command again: it attaches to the same
116
+ run and keeps waiting. Never add a flag — the host allows exactly the one command your
117
+ order yields and refuses every other. A real run takes 20-25 minutes, which is normal, not a hang. Give the ordinary
118
118
  attaching call `timeout: 1800000` (30 minutes).
119
119
 
120
120
  **Never change `--order-id` or `--slug` to get a fresh run.** The order id is issued by the
@@ -96,9 +96,9 @@ the runner parses it and issues the refusal. If no such grant line is present, t
96
96
  present in the command at all.
97
97
 
98
98
  Background execution (`run_in_background`, `&`, `nohup`) is prohibited. If the attaching call is killed
99
- by a time ceiling, repeat the identical command once with `--no-wait`. This only checks state; it is
100
- never a final answer. If stdout contains the ready reply, return it normally. If the call exits 4,
101
- return to the ordinary waiting call with the same `--order-id` and without `--no-wait`. A real run takes
99
+ by a time ceiling, run the identical command again: it attaches to the same
100
+ run and keeps waiting. Never add a flag — the host allows exactly the one command your
101
+ order yields and refuses every other. A real run takes
102
102
  20-25 minutes, which is normal, not a hang. Give the ordinary attaching call `timeout: 1800000` (30
103
103
  minutes).
104
104
 
@@ -107,10 +107,9 @@ differs — that refusal is the whole answer: return `FAIL` with the runner's te
107
107
  remedy. Never retry under a different id of your own choosing; the order id is the orchestrator's.
108
108
 
109
109
  Background execution (`run_in_background`, `&`, `nohup`) is prohibited, and so is inventing a report
110
- from memory. If the attaching call is killed by a time ceiling, repeat the identical command once with
111
- `--no-wait`. This only checks state; it is never a final answer. If stdout contains the ready reply,
112
- return it normally. If the call exits 4, return to the ordinary waiting call with the same `--order-id`
113
- and without `--no-wait`. A real run takes 20-25 minutes, which is normal, not a hang. Give the ordinary
110
+ from memory. If the attaching call is killed by a time ceiling, run the identical command again: it attaches to the same
111
+ run and keeps waiting. Never add a flag — the host allows exactly the one command your
112
+ order yields and refuses every other. A real run takes 20-25 minutes, which is normal, not a hang. Give the ordinary
114
113
  attaching call `timeout: 1800000` (30 minutes).
115
114
 
116
115
  **Never change `--order-id` or `--slug` to get a fresh run.** The order id is issued by the
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ /** Enforces each dispatcher order at the host tool boundary and returns only the runner's verdict. */
3
+ import fs from 'node:fs';
4
+ import { AGENTS } from '../lib/agents.mjs';
5
+ import { BRAND_STATE_DIR } from '../lib/brand-home.mjs';
6
+ import { sameCommand } from '../lib/dispatcher-command.mjs';
7
+ import { parseJsonText } from '../lib/json-file.mjs';
8
+ import {
9
+ decidePreToolUse,
10
+ gateOrder,
11
+ HANDBACK_TOOL,
12
+ isFinalOutput,
13
+ runnerOutput,
14
+ } from '../lib/dispatcher-gate.mjs';
15
+ import {
16
+ pruneDispatcherStates,
17
+ readDispatcherState,
18
+ updateDispatcherState,
19
+ } from '../lib/dispatcher-state.mjs';
20
+ import { hostSdkVersion, recordHandbackWitness } from '../lib/handback-witness.mjs';
21
+
22
+ function emit(decision, toolInput) {
23
+ const hookSpecificOutput = {
24
+ hookEventName: 'PreToolUse',
25
+ permissionDecision: decision.kind,
26
+ };
27
+ if (decision.kind === 'deny') hookSpecificOutput.permissionDecisionReason = decision.reason;
28
+ if (decision.kind === 'allow') {
29
+ hookSpecificOutput.updatedInput = { ...toolInput, message: decision.message };
30
+ }
31
+ process.stdout.write(`${JSON.stringify({ hookSpecificOutput })}\n`);
32
+ }
33
+
34
+ function errorMessage(error) {
35
+ return error instanceof Error ? error.message : String(error);
36
+ }
37
+
38
+ async function main() {
39
+ let payload;
40
+ try {
41
+ payload = parseJsonText('stdin', fs.readFileSync(0, 'utf8'));
42
+ } catch {
43
+ return;
44
+ }
45
+
46
+ if (!payload || !Object.prototype.hasOwnProperty.call(AGENTS, payload.agent_type)
47
+ || typeof payload.session_id !== 'string' || payload.session_id.length === 0
48
+ || typeof payload.agent_id !== 'string' || payload.agent_id.length === 0) return;
49
+
50
+ const ids = {
51
+ stateDir: BRAND_STATE_DIR,
52
+ sessionId: payload.session_id,
53
+ agentId: payload.agent_id,
54
+ };
55
+
56
+ if (payload.hook_event_name === 'PreToolUse') {
57
+ let decision;
58
+ let order;
59
+ try {
60
+ order = gateOrder(payload);
61
+ const stored = readDispatcherState(ids);
62
+ const state = stored?.corrupt ? {} : (stored || {});
63
+ decision = decidePreToolUse({ payload, order, state });
64
+ const toolUseId = typeof payload.tool_use_id === 'string' && payload.tool_use_id.length > 0
65
+ ? payload.tool_use_id
66
+ : null;
67
+ await updateDispatcherState(ids, (current) => {
68
+ const next = {
69
+ ...(current.corrupt ? {} : current),
70
+ ...(decision.stateUpdate || {}),
71
+ };
72
+ if (toolUseId) {
73
+ const seenToolUseIds = Array.isArray(next.seenToolUseIds) ? next.seenToolUseIds : [];
74
+ next.seenToolUseIds = [...seenToolUseIds, toolUseId].slice(-200);
75
+ }
76
+ return next;
77
+ });
78
+ } catch (error) {
79
+ decision = {
80
+ kind: 'deny',
81
+ reason: `codex-bridge dispatcher gate: internal error: ${errorMessage(error)}`,
82
+ };
83
+ }
84
+
85
+ if (payload.tool_name === HANDBACK_TOOL) {
86
+ try {
87
+ await recordHandbackWitness({
88
+ stateDir: BRAND_STATE_DIR,
89
+ kind: 'seen',
90
+ sdkVersion: hostSdkVersion(),
91
+ });
92
+ } catch {}
93
+ try {
94
+ pruneDispatcherStates({ stateDir: BRAND_STATE_DIR });
95
+ } catch {}
96
+ }
97
+
98
+ if (decision.kind !== 'pass') emit(decision, payload.tool_input || {});
99
+ return;
100
+ }
101
+
102
+ if ((payload.hook_event_name === 'PostToolUse' || payload.hook_event_name === 'PostToolUseFailure')
103
+ && payload.tool_name === 'Bash') {
104
+ try {
105
+ const order = gateOrder(payload);
106
+ if (!order.command || !sameCommand(payload.tool_input?.command, order.command)) return;
107
+ const result = runnerOutput(payload);
108
+ if (!result) return;
109
+ await updateDispatcherState(ids, (current) => ({
110
+ ...(current.corrupt ? {} : current),
111
+ runnerOutput: result.output,
112
+ runnerFinal: isFinalOutput(result.output),
113
+ runnerExitCode: result.exitCode,
114
+ }));
115
+ } catch {}
116
+ }
117
+ }
118
+
119
+ main().catch(() => {});
@@ -1,15 +1,14 @@
1
1
  import fs from 'node:fs';
2
- import os from 'node:os';
3
2
  import path from 'node:path';
3
+ import { BRAND_STATE_DIR } from '../lib/brand-home.mjs';
4
4
  import { readJsonFileSync } from '../lib/json-file.mjs';
5
5
 
6
- const LOG_DIR = path.join(os.homedir(), '.claude', 'logs');
7
6
  /**
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.
7
+ * Two guards share this budget (reply-guard and stop-guard). It lived in Claude Code's own
8
+ * `~/.claude/logs/` until Plan_62 D14 moved package state into the brand home; the move abandons
9
+ * budgets spent before it, which is harmless because they are keyed by agent ids that no longer run.
11
10
  */
12
- export const BLOCKED_FILE = path.join(LOG_DIR, 'codex-reply-guard.blocked.json');
11
+ export const BLOCKED_FILE = path.join(BRAND_STATE_DIR, 'reply-guard-tries.json');
13
12
  export const FORM = 'form';
14
13
  export const STATE = 'state';
15
14
  export const MAX_FORM_BLOCKS = 3;
@@ -129,6 +129,7 @@ export function recentRuns(runsDir, options = {}) {
129
129
  }
130
130
  if (!status || typeof status !== 'object' || Array.isArray(status)) return null;
131
131
  if (options.agent && status.agent !== options.agent) continue;
132
+ if (options.orderId && String(status.order_id ?? '').trim() !== options.orderId) continue;
132
133
  const timestamp = Date.parse(status.finished_at || status.started_at || '');
133
134
  if (!Number.isFinite(timestamp) || timestamp > now || now - timestamp > maxAgeMs) continue;
134
135
  result.push({ dir, status, timestamp });
@@ -12,9 +12,9 @@
12
12
  * silently because a diagnostic guard must never break an unrelated tool call.
13
13
  */
14
14
  import fs from 'node:fs';
15
- import os from 'node:os';
16
15
  import path from 'node:path';
17
16
  import { AGENTS } from '../lib/agents.mjs';
17
+ import { BRAND_STATE_DIR } from '../lib/brand-home.mjs';
18
18
  import { readJsonFileSync } from '../lib/json-file.mjs';
19
19
  import {
20
20
  diagnoseInput,
@@ -30,8 +30,7 @@ import { resolveProjectRunsDir } from '../lib/runner/project-dir.mjs';
30
30
  import { runsRoot } from '../lib/runner/runs-root.mjs';
31
31
  import { parseTaskDocument } from '../lib/runner/task-file.mjs';
32
32
 
33
- const HOME = os.homedir();
34
- const LOG_DIR = path.join(HOME, '.claude', 'logs');
33
+ const DIAGNOSTICS_DIR = path.join(BRAND_STATE_DIR, 'diagnostics');
35
34
  const GUARDED = new Set(Object.keys(AGENTS));
36
35
  /**
37
36
  * Both spellings of the subagent-launching tool, from the same list the installer builds its
@@ -60,8 +59,8 @@ try {
60
59
  }
61
60
 
62
61
  try {
63
- fs.mkdirSync(LOG_DIR, { recursive: true });
64
- fs.writeFileSync(path.join(LOG_DIR, 'codex-order-gate.last.json'), `${JSON.stringify(input, null, 2)}\n`);
62
+ fs.mkdirSync(DIAGNOSTICS_DIR, { recursive: true });
63
+ fs.writeFileSync(path.join(DIAGNOSTICS_DIR, 'order-gate.last.json'), `${JSON.stringify(input, null, 2)}\n`);
65
64
  } catch {
66
65
  // Diagnostics are a convenience, never a reason to fail the turn.
67
66
  }
@@ -73,6 +72,22 @@ const toolInput = input.tool_input;
73
72
  if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
74
73
  if (!GUARDED.has(toolInput.subagent_type) || typeof toolInput.prompt !== 'string') pass();
75
74
 
75
+ /**
76
+ * Plan_62 D7: with agent teams enabled, a registered dispatcher launched with `name` or `team_name`
77
+ * starts as a teammate — its `agent_type` becomes the teammate's name and it answers through
78
+ * SendMessage (live probe 2026-09-24, `docs/plans/Plan_62-probe/journal-run1.jsonl`), so the dispatcher
79
+ * gate never recognises it and every prohibition falls back to prompt text. Refused before it starts.
80
+ */
81
+ const teammateField = ['name', 'team_name']
82
+ .find((key) => typeof toolInput[key] === 'string' && toolInput[key].trim());
83
+ if (teammateField) {
84
+ deny(
85
+ `Order gate denied the Agent call because it passes \`${teammateField}\`: a dispatcher launched as a ` +
86
+ 'teammate escapes the dispatcher gate, whose agent_type becomes the teammate name. Call ' +
87
+ `${toolInput.subagent_type} with subagent_type only, without name or team_name.`,
88
+ );
89
+ }
90
+
76
91
  const missing = missingInputs(toolInput.subagent_type, toolInput.prompt);
77
92
  const unsafe = shellUnsafeInputs(toolInput.subagent_type, toolInput.prompt);
78
93
  if (missing.length || unsafe.length) {