@lyupro/codex-bridge 0.6.10 → 0.6.12

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 (60) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +16 -4
  3. package/cli/active-hosts.mjs +44 -0
  4. package/cli/command-registry.mjs +18 -2
  5. package/cli/dispatcher-model-check.mjs +61 -0
  6. package/cli/doctor-host-side.mjs +117 -110
  7. package/cli/doctor-installation.mjs +111 -111
  8. package/cli/doctor.mjs +19 -4
  9. package/cli/handback-witness-check.mjs +30 -20
  10. package/cli/hosts.mjs +2 -10
  11. package/cli/install-owners.mjs +1 -4
  12. package/cli/install-record.mjs +7 -1
  13. package/cli/inventory-confirm.mjs +75 -0
  14. package/cli/inventory-transition.mjs +4 -2
  15. package/cli/manifest.mjs +6 -6
  16. package/package.json +2 -2
  17. package/src/agents/codex-advisor.md +25 -119
  18. package/src/agents/codex-build.md +15 -133
  19. package/src/agents/codex-review.md +10 -109
  20. package/src/agents/codex-scout.md +11 -130
  21. package/src/home/hooks/dispatcher-gate.mjs +14 -9
  22. package/src/home/hooks/live-runs.mjs +0 -41
  23. package/src/home/hooks/order-gate.mjs +59 -40
  24. package/src/home/hooks/reply-guard.mjs +52 -65
  25. package/src/home/hooks/reply-identity.mjs +64 -0
  26. package/src/home/hooks/reply-verdicts.mjs +31 -27
  27. package/src/home/hooks/reply-witness.mjs +48 -0
  28. package/src/home/lib/claude-layout.mjs +14 -0
  29. package/src/home/lib/dispatcher-call.mjs +171 -0
  30. package/src/home/lib/dispatcher-command.mjs +18 -56
  31. package/src/home/lib/dispatcher-gate.mjs +4 -0
  32. package/src/home/lib/dispatcher-model.mjs +112 -0
  33. package/src/home/lib/dispatcher-order.mjs +21 -22
  34. package/src/home/lib/dispatcher-pin.mjs +60 -0
  35. package/src/home/lib/dispatcher-protocol.mjs +91 -0
  36. package/src/home/lib/dispatcher-stop.mjs +11 -2
  37. package/src/home/lib/handback-witness.mjs +137 -48
  38. package/src/home/lib/home-registry.mjs +7 -0
  39. package/src/home/lib/install-owner-roots.mjs +55 -0
  40. package/src/home/lib/meta/paths.mjs +1 -1
  41. package/src/home/lib/meta/reply.mjs +2 -1
  42. package/src/home/lib/model-family.mjs +38 -0
  43. package/src/home/lib/observation-ledger.mjs +145 -0
  44. package/src/home/lib/order-schema.mjs +204 -0
  45. package/src/home/lib/order-values.mjs +46 -0
  46. package/src/home/lib/runner/args.mjs +140 -231
  47. package/src/home/lib/runner/attach.mjs +3 -3
  48. package/src/home/lib/runner/continuation.mjs +174 -192
  49. package/src/home/lib/runner/launcher.mjs +9 -9
  50. package/src/home/lib/runner/order-options.mjs +30 -0
  51. package/src/home/lib/runner/order-owner.mjs +2 -1
  52. package/src/home/lib/runner/pass-gate.mjs +11 -9
  53. package/src/home/lib/runner/preflight.mjs +4 -3
  54. package/src/home/lib/runner/scope-check.mjs +11 -10
  55. package/src/home/lib/runner/task-input.mjs +23 -10
  56. package/src/home/lib/subagent-model-env.mjs +44 -0
  57. package/src/home/lib/task-header.mjs +158 -127
  58. package/src/home/lib/untyped-stop.mjs +30 -0
  59. package/src/home/lib/required-inputs.mjs +0 -301
  60. /package/{cli → src/home/lib}/frontmatter.mjs +0 -0
@@ -4,6 +4,7 @@ import fs from 'node:fs';
4
4
  import { AGENTS } from '../lib/agents.mjs';
5
5
  import { BRAND_STATE_DIR } from '../lib/brand-home.mjs';
6
6
  import { sameCommand } from '../lib/dispatcher-command.mjs';
7
+ import { bindReceipt, runnerReceipt } from '../lib/dispatcher-order.mjs';
7
8
  import { parseJsonText } from '../lib/json-file.mjs';
8
9
  import {
9
10
  decidePreToolUse,
@@ -17,7 +18,7 @@ import {
17
18
  readDispatcherState,
18
19
  updateDispatcherState,
19
20
  } from '../lib/dispatcher-state.mjs';
20
- import { recordHandbackWitness, witnessHostVersion } from '../lib/handback-witness.mjs';
21
+ import { recordInterceptedAttempt, witnessHostVersion } from '../lib/handback-witness.mjs';
21
22
  import { observeSessionHost } from '../lib/host-observations.mjs';
22
23
 
23
24
  function emit(decision, toolInput) {
@@ -81,6 +82,7 @@ async function main() {
81
82
  const next = {
82
83
  ...(current.corrupt ? {} : current),
83
84
  ...(decision.stateUpdate || {}),
85
+ agentType: payload.agent_type,
84
86
  };
85
87
  if (toolUseId) {
86
88
  const seenToolUseIds = Array.isArray(next.seenToolUseIds) ? next.seenToolUseIds : [];
@@ -97,9 +99,8 @@ async function main() {
97
99
 
98
100
  if (payload.tool_name === HANDBACK_TOOL) {
99
101
  try {
100
- await recordHandbackWitness({
102
+ await recordInterceptedAttempt({
101
103
  stateDir: BRAND_STATE_DIR,
102
- kind: 'seen',
103
104
  hostVersion: await witnessHostVersion(payload),
104
105
  });
105
106
  } catch {}
@@ -119,12 +120,16 @@ async function main() {
119
120
  if (!order.command || !sameCommand(payload.tool_input?.command, order.command)) return;
120
121
  const result = runnerOutput(payload);
121
122
  if (!result) return;
122
- await updateDispatcherState(ids, (current) => ({
123
- ...(current.corrupt ? {} : current),
124
- runnerOutput: result.output,
125
- runnerFinal: isFinalOutput(result.output),
126
- runnerExitCode: result.exitCode,
127
- }));
123
+ await updateDispatcherState(ids, (current) => {
124
+ const base = current.corrupt ? {} : current;
125
+ return {
126
+ ...base,
127
+ ...bindReceipt(base, runnerReceipt(result.output)),
128
+ runnerOutput: result.output,
129
+ runnerFinal: isFinalOutput(result.output),
130
+ runnerExitCode: result.exitCode,
131
+ };
132
+ });
128
133
  } catch {}
129
134
  }
130
135
  }
@@ -18,8 +18,6 @@ import { readJsonFileSync } from '../lib/json-file.mjs';
18
18
  import { runLiveness } from '../lib/meta/run-liveness.mjs';
19
19
  import { IDENTITY_ALIVE } from '../lib/process-identity.mjs';
20
20
 
21
- export const RECENT_RUN_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
22
-
23
21
  /**
24
22
  * Match the package's repository comparison without resolving symlinks: Windows realpath adds
25
23
  * `\\?\\` and UNC spellings, turning equal paths into unequal ones. A null result is uncertainty,
@@ -98,45 +96,6 @@ export function liveRuns(runsDir, options = {}) {
98
96
  return result;
99
97
  }
100
98
 
101
- /**
102
- * Find runs recent enough to explain the current dispatcher reply, including completed runs.
103
- * The 24-hour window covers long and overnight dispatches without letting old project history
104
- * explain a new reply. This closes the August 13 incident where a fabricated FAIL hid a
105
- * finished/OK run simply by omitting its folder. Null means the disk could not be trusted.
106
- */
107
- export function recentRuns(runsDir, options = {}) {
108
- if (typeof runsDir !== 'string' || !runsDir.trim()) return null;
109
- const now = options.now ?? Date.now();
110
- const maxAgeMs = options.maxAgeMs ?? RECENT_RUN_MAX_AGE_MS;
111
- let entries;
112
- try {
113
- entries = fs.readdirSync(runsDir, { withFileTypes: true });
114
- } catch (err) {
115
- return err.code === 'ENOENT' ? [] : null;
116
- }
117
-
118
- const result = [];
119
- for (const entry of entries) {
120
- if (!entry.isDirectory()) continue;
121
- const dir = path.join(runsDir, entry.name);
122
- const statusPath = path.join(dir, 'status.json');
123
- if (!fs.existsSync(statusPath)) continue;
124
- let status;
125
- try {
126
- status = readJsonFileSync(statusPath);
127
- } catch {
128
- return null;
129
- }
130
- if (!status || typeof status !== 'object' || Array.isArray(status)) return null;
131
- if (options.agent && status.agent !== options.agent) continue;
132
- if (options.orderId && String(status.order_id ?? '').trim() !== options.orderId) continue;
133
- const timestamp = Date.parse(status.finished_at || status.started_at || '');
134
- if (!Number.isFinite(timestamp) || timestamp > now || now - timestamp > maxAgeMs) continue;
135
- result.push({ dir, status, timestamp });
136
- }
137
- return result.sort((a, b) => b.timestamp - a.timestamp);
138
- }
139
-
140
99
  /** Scan every project under the configured runs root for worktree ownership. */
141
100
  export function allLiveRuns(runsRoot, options = {}) {
142
101
  if (typeof runsRoot !== 'string' || !runsRoot.trim()) return [];
@@ -2,10 +2,10 @@
2
2
  /**
3
3
  * PreToolUse gate for the Codex dispatchers (codex-scout / codex-build / codex-review).
4
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.
5
+ * Plan_63 D9: the call carries only the task file; order id and grant come from its header.
6
+ * OW-054: validate the header and order before a grant can skip the order-owner diagnostic,
7
+ * so a `continue: none` line cannot disarm the check. The producer can still correct the order
8
+ * before the runner creates a run folder or spends quota.
9
9
  *
10
10
  * Input is Claude Code's hook JSON on stdin. The last payload is retained for diagnostics so a
11
11
  * future host schema change can be inspected instead of guessed at. Any uncertain shape passes
@@ -14,15 +14,14 @@
14
14
  import fs from 'node:fs';
15
15
  import path from 'node:path';
16
16
  import { AGENTS } from '../lib/agents.mjs';
17
+ import { BRAND_HOME, BRAND_STATE_DIR } from '../lib/brand-home.mjs';
18
+ import { modelLatch, readDispatcherModel } from '../lib/dispatcher-model.mjs';
19
+ import { readDispatcherPin } from '../lib/dispatcher-pin.mjs';
17
20
  import { recordHookDiagnostic } from '../lib/hook-diagnostics.mjs';
21
+ import { transcriptHostVersion } from '../lib/host-version.mjs';
18
22
  import { readJsonFileSync } from '../lib/json-file.mjs';
19
- import {
20
- diagnoseInput,
21
- extractValue,
22
- missingInputs,
23
- parseGrant,
24
- shellUnsafeInputs,
25
- } from '../lib/required-inputs.mjs';
23
+ import { parseDispatcherCall, renderCallRefusal } from '../lib/dispatcher-call.mjs';
24
+ import { orderFromHeader, renderOrderProblems } from '../lib/order-schema.mjs';
26
25
  import { SUBAGENT_TOOLS } from '../lib/hook-definitions.mjs';
27
26
  import { taskFingerprint } from '../lib/meta/chain.mjs';
28
27
  import { conflictingOrderOwner, orderOwnerConflictText } from '../lib/runner/order-owner.mjs';
@@ -30,6 +29,7 @@ import { resolveProjectRunsDir } from '../lib/runner/project-dir.mjs';
30
29
  import { runsRoot } from '../lib/runner/runs-root.mjs';
31
30
  import { parseTaskDocument } from '../lib/runner/task-file.mjs';
32
31
  import { parseTaskHeader, taskHeaderRefusal } from '../lib/task-header.mjs';
32
+ import { envModelProblem } from '../lib/subagent-model-env.mjs';
33
33
 
34
34
  const GUARDED = new Set(Object.keys(AGENTS));
35
35
  /**
@@ -39,9 +39,14 @@ const GUARDED = new Set(Object.keys(AGENTS));
39
39
  */
40
40
  const SUBAGENT_TOOL_NAMES = new Set(SUBAGENT_TOOLS);
41
41
 
42
- const pass = () => process.exit(0);
42
+ let systemMessage;
43
+ const pass = () => {
44
+ if (systemMessage) process.stdout.write(JSON.stringify({ systemMessage }));
45
+ process.exit(0);
46
+ };
43
47
  const deny = (reason) => {
44
48
  process.stdout.write(JSON.stringify({
49
+ ...(systemMessage ? { systemMessage } : {}),
45
50
  hookSpecificOutput: {
46
51
  hookEventName: 'PreToolUse',
47
52
  permissionDecision: 'deny',
@@ -65,7 +70,37 @@ if (!SUBAGENT_TOOL_NAMES.has(input.tool_name)) pass();
65
70
 
66
71
  const toolInput = input.tool_input;
67
72
  if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
68
- if (!GUARDED.has(toolInput.subagent_type) || typeof toolInput.prompt !== 'string') pass();
73
+ if (!GUARDED.has(toolInput.subagent_type)) pass();
74
+
75
+ // Plan_67 D6: one installed model contract; refuse even matching aliases before prompt exits.
76
+ const agentType = toolInput.subagent_type;
77
+ if (Object.hasOwn(toolInput, 'model')) {
78
+ deny(
79
+ "Order gate denied the Agent call because it passes `model`: a dispatcher's model is set in one place, "
80
+ + `its installed agent file. Call ${agentType} without model. `
81
+ + 'The run folder was not created; quota was not spent.',
82
+ );
83
+ }
84
+ try {
85
+ const hostVersion = await transcriptHostVersion(input.transcript_path).catch(() => null);
86
+ const pin = readDispatcherPin({ brandRoot: BRAND_HOME.root, agentType });
87
+ const problem = envModelProblem({ env: process.env, hostVersion, pinFamily: pin.family });
88
+ if (problem) deny(problem);
89
+ } catch {
90
+ // D6 (b): uncertain host/env evidence is diagnostic only; the stop observer still checks it.
91
+ }
92
+ try {
93
+ const latch = modelLatch({ ledger: readDispatcherModel({ stateDir: BRAND_STATE_DIR }), agentType });
94
+ if (latch.active) {
95
+ const { data, at } = latch.violation;
96
+ systemMessage = `codex-bridge: the last ${agentType} ran on ${data.parsed.join(', ')} `
97
+ + `while the installed contract pins ${data.pinFamily} (${data.hostVersion ?? 'unknown'}, ${at}); `
98
+ + 'the cause is outside this call — another hook or the host; run codex-bridge doctor.';
99
+ }
100
+ } catch {
101
+ // D4 (a): a corrupt ledger must not turn a diagnostic warning into a refusal.
102
+ }
103
+ if (typeof toolInput.prompt !== 'string') pass();
69
104
 
70
105
  /**
71
106
  * Plan_62 D7: with agent teams enabled, a registered dispatcher launched with `name` or `team_name`
@@ -83,28 +118,9 @@ if (teammateField) {
83
118
  );
84
119
  }
85
120
 
86
- const missing = missingInputs(toolInput.subagent_type, toolInput.prompt);
87
- const unsafe = shellUnsafeInputs(toolInput.subagent_type, toolInput.prompt);
88
- if (missing.length || unsafe.length) {
89
- const reason = missing.length
90
- ? [
91
- 'Order gate denied the Agent call because required dispatcher input(s) are missing or still placeholders.',
92
- 'Write each value in tool_input.prompt using `label: value` before launching the dispatcher:',
93
- ...missing.flatMap((entry) => {
94
- const lines = [`- ${entry.label}: ${entry.explanation} Example: \`${entry.example}\`.`];
95
- const diagnosis = diagnoseInput(toolInput.prompt, entry.label);
96
- if (diagnosis) lines.push(` found \`${diagnosis.line}\`, ${diagnosis.reason}`);
97
- return lines;
98
- }),
99
- ].join('\n')
100
- : [
101
- 'Order gate denied the Agent call because labelled dispatcher input(s) contain forbidden shell sequences.',
102
- 'Correct each field before launching the dispatcher:',
103
- ...unsafe.map(({ entry, sequence }) =>
104
- `- ${entry.label}: found ${JSON.stringify(sequence)}; put free text in the task file and pass only a short command-line value.`),
105
- ].join('\n');
106
- deny(reason);
107
- }
121
+ // Plan_76 D1: exact registry labels prevent the scope-new and repository-prose incident of 2026-10-04.
122
+ const call = parseDispatcherCall(toolInput.subagent_type, toolInput.prompt);
123
+ if (call.problems.length) deny(renderCallRefusal(toolInput.subagent_type, call.problems));
108
124
 
109
125
  const readStatus = (file) => {
110
126
  try {
@@ -114,11 +130,7 @@ const readStatus = (file) => {
114
130
  }
115
131
  };
116
132
 
117
- const orderId = extractValue(toolInput.prompt, 'order id');
118
- const taskFile = extractValue(toolInput.prompt, 'task file');
119
- // Plan_75 D1, TradeForge capacity incident: retries keep their order; the runner owns conflicting-grant refusal.
120
- const grant = parseGrant(toolInput.prompt);
121
- if (!orderId || !taskFile || grant?.kind || grant?.error) pass();
133
+ const taskFile = call.inputs.get('task file');
122
134
 
123
135
  let rawTask;
124
136
  try {
@@ -130,6 +142,13 @@ try {
130
142
  const parsed = parseTaskHeader(rawTask);
131
143
  const headerRefusal = taskHeaderRefusal(parsed);
132
144
  if (headerRefusal) deny(headerRefusal);
145
+ const { order, problems } = orderFromHeader(toolInput.subagent_type, parsed);
146
+ if (problems.length) {
147
+ deny(`${renderOrderProblems(toolInput.subagent_type, problems)}\nThe run folder was not created; quota was not spent.`);
148
+ }
149
+ const orderId = order.get('order id');
150
+ // Plan_63 D9 / OW-054: only a validated header grant reaches the runner's grant rules.
151
+ if (parsed.grant) pass();
133
152
  let taskHash;
134
153
  try {
135
154
  taskHash = taskFingerprint(parseTaskDocument(parsed.body).task);
@@ -24,6 +24,7 @@
24
24
  * In the handback era, SubagentHandback delivers the caller-visible answer before SubagentStop.
25
25
  * Alarms name the host by its transcript version: on 2026-09-25 the SDK variable proved inherited.
26
26
  * Plan_62 makes this hook yield after delivery and audit every transcript tool call against gate receipts.
27
+ * Plan_63 D10 identifies this run only by the host-keyed gate's immutable runner receipt.
27
28
  *
28
29
  * Input is JSON on stdin. The fields used here (`agent_type`, `last_assistant_message`)
29
30
  * come from Claude Code; the last payload is kept in state/diagnostics/reply-guard.last.json
@@ -32,20 +33,21 @@
32
33
  import fs from 'node:fs';
33
34
  import path from 'node:path';
34
35
  import { AGENTS } from '../lib/agents.mjs';
35
- import { BRAND_STATE_DIR } from '../lib/brand-home.mjs';
36
+ import { BRAND_HOME, BRAND_STATE_DIR } from '../lib/brand-home.mjs';
36
37
  import { recordHookDiagnostic } from '../lib/hook-diagnostics.mjs';
37
- import { runOrderMismatch, transcriptOrderId } from '../lib/dispatcher-order.mjs';
38
38
  import { decideDispatcherStop, transcriptToolUses } from '../lib/dispatcher-stop.mjs';
39
+ import { observeDispatcherModelStop } from '../lib/dispatcher-model.mjs';
39
40
  import { recognizeHostRefusal } from '../lib/host-refusal.mjs';
40
41
  import { readJsonFileSync } from '../lib/json-file.mjs';
41
42
  import { readDispatcherState, updateDispatcherState } from '../lib/dispatcher-state.mjs';
42
- import { recordHandbackWitness, witnessHostVersion } from '../lib/handback-witness.mjs';
43
+ import { witnessHostVersion } from '../lib/handback-witness.mjs';
44
+ import { untypedStopEvidence } from '../lib/untyped-stop.mjs';
43
45
  import { runLiveness } from '../lib/meta/run-liveness.mjs';
44
- import { resolveProjectRunsDir } from '../lib/runner/project-dir.mjs';
45
- import { runsRoot } from '../lib/runner/runs-root.mjs';
46
- import { liveRuns, normalizePath, recentRuns } from './live-runs.mjs';
46
+ import { liveRuns, normalizePath } from './live-runs.mjs';
47
47
  import { FORM, MAX_STATE_BLOCKS, STATE, takeTry } from './guard-tries.mjs';
48
48
  import { parseReply } from './reply-parser.mjs';
49
+ import { recordStopWitness, typedStopObservations, untypedStopObservation } from './reply-witness.mjs';
50
+ import { decideReplyIdentity, missingIdsAlarm, readReceiptEvidence } from './reply-identity.mjs';
49
51
  import {
50
52
  abandonedRunReason,
51
53
  abandonedRunStop,
@@ -58,14 +60,11 @@ import {
58
60
  missingHostOrderIdReason,
59
61
  missingMetaReason,
60
62
  missingRunReason,
61
- noRecentRunReason,
62
- noRecentRunStop,
63
63
  nonexistentRunReason,
64
64
  omittedDiscoveredRunReason,
65
65
  omittedDiscoveredRunStop,
66
66
  omittedSiblingReason,
67
67
  omittedSiblingStop,
68
- orderMismatchStop,
69
68
  statusMismatchReason,
70
69
  statusMismatchStop,
71
70
  } from './reply-verdicts.mjs';
@@ -114,20 +113,24 @@ try {
114
113
 
115
114
  recordHookDiagnostic('reply-guard', input);
116
115
 
117
- // A host that stops sending agent_type would let every dispatcher past the gate and this
118
- // audit without a word (Plan_66 D2 (c)); say so instead of passing silently.
116
+ // Plan_67 D9: only evidence bound to this stop can identify an untyped dispatcher.
119
117
  if (typeof input.agent_id === 'string' && input.agent_id.length > 0
120
118
  && !(typeof input.agent_type === 'string' && input.agent_type.length > 0)) {
119
+ let evidence;
121
120
  try {
122
- await recordHandbackWitness({
123
- stateDir: BRAND_STATE_DIR,
124
- kind: 'alarm',
125
- hostVersion: await witnessHostVersion(input),
126
- detail: `host omitted agent_type for agent ${input.agent_id}`,
127
- });
121
+ const state = typeof input.session_id === 'string' && input.session_id.length > 0
122
+ ? readDispatcherState({ stateDir: BRAND_STATE_DIR, sessionId: input.session_id, agentId: input.agent_id }) : null;
123
+ const transcript = transcriptToolUses(input.agent_transcript_path);
124
+ evidence = untypedStopEvidence({ state, toolUses: transcript?.toolUses ?? null });
128
125
  } catch {
129
- // Plan_66 H2: the 2026-09-17..24 handback break showed host contract loss must alarm, but never block work.
126
+ // An unreadable state is no evidence either way; a guard failure must never break the stop.
127
+ evidence = { dispatcher: false, reason: 'state-unreadable' };
130
128
  }
129
+ const { observation, alarm } = untypedStopObservation({
130
+ evidence, hostVersion: await witnessHostVersion(input), agentId: input.agent_id,
131
+ });
132
+ await recordStopWitness({ stateDir: BRAND_STATE_DIR, observations: [observation] });
133
+ if (!alarm) pass();
131
134
  process.stdout.write(JSON.stringify({
132
135
  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.',
133
136
  }));
@@ -137,32 +140,40 @@ if (typeof input.agent_id === 'string' && input.agent_id.length > 0
137
140
  // business — an earlier version inferred the agent from transcript text and blocked an
138
141
  // unrelated one, which cost a re-answer for nothing.
139
142
  if (!GUARDED.has(input.agent_type)) pass();
143
+ try {
144
+ dispatcherSystemMessage = await observeDispatcherModelStop({
145
+ brandRoot: BRAND_HOME.root, stateDir: BRAND_STATE_DIR, input, hostVersion: await witnessHostVersion(input),
146
+ });
147
+ } catch { /* Plan_67 D4: model diagnostics must never block a dispatcher stop. */ }
140
148
 
141
149
  const emitStop = (payload) => {
142
150
  process.stdout.write(JSON.stringify(payload));
143
151
  process.exit(0);
144
152
  };
153
+ const hasIds = typeof input.session_id === 'string' && input.session_id.length > 0
154
+ && typeof input.agent_id === 'string' && input.agent_id.length > 0;
155
+ let receipt = null;
156
+ let receiptConflict = null;
145
157
  try {
146
- if (typeof input.session_id === 'string' && input.session_id.length > 0
147
- && typeof input.agent_id === 'string' && input.agent_id.length > 0) {
158
+ if (hasIds) {
148
159
  const ids = {
149
160
  stateDir: BRAND_STATE_DIR,
150
161
  sessionId: input.session_id,
151
162
  agentId: input.agent_id,
152
163
  };
153
164
  const state = readDispatcherState(ids);
154
- const toolUses = transcriptToolUses(input.agent_transcript_path);
165
+ ({ receipt, receiptConflict } = readReceiptEvidence(state));
166
+ const transcript = transcriptToolUses(input.agent_transcript_path);
167
+ const toolUses = transcript?.toolUses ?? null;
155
168
  const stop = decideDispatcherStop({ state, toolUses });
156
169
  if (stop.unseen.length) {
157
170
  const names = stop.unseen.map((toolUse) => toolUse.name).join(', ');
158
- dispatcherSystemMessage = `codex-bridge: dispatcher ${input.agent_type} used ${names} outside the dispatcher gate — do not trust its answer; run codex-bridge doctor.`;
159
- await recordHandbackWitness({
160
- stateDir: BRAND_STATE_DIR,
161
- kind: 'alarm',
162
- hostVersion: await witnessHostVersion(input),
163
- detail: `${input.agent_type} ${input.agent_id}: ${names} outside the dispatcher gate`,
164
- });
171
+ dispatcherSystemMessage = [dispatcherSystemMessage, `codex-bridge: dispatcher ${input.agent_type} used ${names} outside the dispatcher gate — do not trust its answer; run codex-bridge doctor.`].filter(Boolean).join(' ');
165
172
  }
173
+ await recordStopWitness({ stateDir: BRAND_STATE_DIR, observations: typedStopObservations({
174
+ agentType: input.agent_type, agentId: input.agent_id, hostVersion: await witnessHostVersion(input),
175
+ hasIds, state, toolUses, unseen: stop.unseen, transcriptComplete: transcript?.complete === true,
176
+ }) });
166
177
  if (stop.stateUpdate) {
167
178
  await updateDispatcherState(ids, (current) => ({
168
179
  ...(current.corrupt ? {} : current),
@@ -185,6 +196,14 @@ try {
185
196
  // The handback path is fail-open; the established reply checks remain the fallback.
186
197
  }
187
198
 
199
+ if (!hasIds) {
200
+ await recordStopWitness({ stateDir: BRAND_STATE_DIR, observations: typedStopObservations({
201
+ agentType: input.agent_type, hostVersion: await witnessHostVersion(input),
202
+ hasIds, state: null, toolUses: null, unseen: [], transcriptComplete: false,
203
+ }) });
204
+ dispatcherSystemMessage ||= missingIdsAlarm(input.agent_type);
205
+ }
206
+
188
207
  const reply = String(input.last_assistant_message || '').trim();
189
208
  if (!reply) pass();
190
209
  const emit = (payload) => {
@@ -220,48 +239,22 @@ const blockState = (reason, stopReason) => {
220
239
 
221
240
  const { runDirs, claimed } = parseReply(reply);
222
241
  let runDir = runDirs[0] || null;
223
- let discoveredRun = false;
224
- let discoveredStatus = null;
225
- let searchedRunsDir = null;
226
242
 
227
243
  if (runDir && !fs.existsSync(runDir)) {
228
244
  blockForm(nonexistentRunReason);
229
245
  }
230
246
 
231
- const orderedOrderId = transcriptOrderId(input.agent_transcript_path);
232
-
233
247
  if (!runDir && !claimed) {
234
248
  // A reply that pronounces nothing cannot contradict the disk. It stays on the old, softer path:
235
249
  // three tries about the SHAPE of the answer and then through, which is what a quoted refusal
236
- // needs. Only a reply that hands down a verdict earns the disk search below.
250
+ // needs. Only a reply that hands down a verdict earns the receipt checks below.
237
251
  blockForm(missingRunReason);
238
252
  }
239
253
 
240
- if (!runDir) {
241
- let candidates;
242
- try {
243
- searchedRunsDir = resolveProjectRunsDir(runsRoot(), input.cwd, { create: false }).dir;
244
- // Plan_62 r2: on 2026-09-24 the newest run of this agent type belonged to an earlier order, and the
245
- // guard judged the reply against that stranger's folder. With the order id known, only its runs count.
246
- candidates = recentRuns(searchedRunsDir, {
247
- agent: input.agent_type,
248
- ...(orderedOrderId ? { orderId: orderedOrderId } : {}),
249
- });
250
- } catch {
251
- pass();
252
- }
253
- if (candidates === null) pass();
254
-
255
- if (!candidates.length) {
256
- blockState(
257
- noRecentRunReason,
258
- noRecentRunStop(MAX_STATE_BLOCKS, input.agent_type, searchedRunsDir),
259
- );
260
- }
261
-
262
- ({ dir: runDir, status: discoveredStatus } = candidates[0]);
263
- discoveredRun = true;
264
- }
254
+ const identity = decideReplyIdentity({ runDir, hasIds, receipt, receiptConflict, agentType: input.agent_type });
255
+ if (identity.block) blockState(identity.block.reason, identity.block.stop);
256
+ const { discoveredRun, discoveredStatus } = identity;
257
+ runDir = identity.runDir;
265
258
 
266
259
  /**
267
260
  * The dispatcher must name every WRITING run the project has live, not just the one it chose to
@@ -307,12 +300,6 @@ if (fs.existsSync(statusPath)) {
307
300
  } catch {
308
301
  runStatus = null;
309
302
  }
310
- const mismatch = runOrderMismatch(orderedOrderId, runStatus, runDir);
311
-
312
- if (mismatch) {
313
- blockState(mismatch.reason, orderMismatchStop(MAX_STATE_BLOCKS, mismatch, runStatus, runDir));
314
- }
315
-
316
303
  if (runStatus?.state === 'running') {
317
304
  // Plan_57 D5/D27: a bare pid mistook a reused foreign process for this run's worker.
318
305
  // The shared judge uses run identity and keeps unverified processes live (fail open).
@@ -0,0 +1,64 @@
1
+ /** Decides which run a dispatcher reply answers for, from the gate's receipt (Plan_63 D10; 2026-09-24 stranger-run incident). */
2
+ import path from 'node:path';
3
+ import { readJsonFileSync } from '../lib/json-file.mjs';
4
+ import { normalizePath } from './live-runs.mjs';
5
+ import { MAX_STATE_BLOCKS } from './guard-tries.mjs';
6
+ import {
7
+ noReceiptReason,
8
+ noReceiptStop,
9
+ receiptConflictReason,
10
+ receiptConflictStop,
11
+ receiptMismatchReason,
12
+ receiptMismatchStop,
13
+ } from './reply-verdicts.mjs';
14
+
15
+ export function readReceiptEvidence(state) {
16
+ return {
17
+ receipt: typeof state?.runReceipt === 'string' && state.runReceipt.length > 0 ? state.runReceipt : null,
18
+ receiptConflict: typeof state?.runReceiptConflict === 'string' && state.runReceiptConflict.length > 0
19
+ ? state.runReceiptConflict : null,
20
+ };
21
+ }
22
+
23
+ export function missingIdsAlarm(agentType) {
24
+ return `codex-bridge: the host did not report session_id and agent_id for dispatcher ${agentType} — whose run this answer quotes cannot be checked; run codex-bridge doctor.`;
25
+ }
26
+
27
+ // Plan_63 D10: a recent stranger is never evidence for this reply; only the gate receipt is.
28
+ // Form checks stay in the caller before this decision, preserving their softer try budget.
29
+ export function decideReplyIdentity({ runDir, hasIds, receipt, receiptConflict, agentType }) {
30
+ if (hasIds && receiptConflict) {
31
+ return { block: {
32
+ reason: receiptConflictReason(receipt, receiptConflict),
33
+ stop: receiptConflictStop(MAX_STATE_BLOCKS, agentType, receipt, receiptConflict),
34
+ } };
35
+ }
36
+
37
+ if (!runDir) {
38
+ if (!(hasIds && receipt)) {
39
+ return { block: { reason: noReceiptReason, stop: noReceiptStop(MAX_STATE_BLOCKS, agentType) } };
40
+ }
41
+ let discoveredStatus = null;
42
+ try {
43
+ discoveredStatus = readJsonFileSync(path.join(receipt, 'status.json'));
44
+ } catch {
45
+ discoveredStatus = null;
46
+ }
47
+ return { runDir: receipt, discoveredRun: true, discoveredStatus };
48
+ }
49
+
50
+ if (hasIds) {
51
+ if (!receipt) {
52
+ return { block: { reason: noReceiptReason, stop: noReceiptStop(MAX_STATE_BLOCKS, agentType) } };
53
+ }
54
+ if (normalizePath(runDir) !== normalizePath(receipt)) {
55
+ return { block: {
56
+ reason: receiptMismatchReason(runDir, receipt),
57
+ stop: receiptMismatchStop(MAX_STATE_BLOCKS, agentType, runDir, receipt),
58
+ } };
59
+ }
60
+ }
61
+ // Review 2026-10-05_201513_plan63-r-review-20261005 and Plan_66 H2: missing ids alarm
62
+ // in the caller; named folders retain disk checks because host contract loss must never block work.
63
+ return { runDir, discoveredRun: false, discoveredStatus: null };
64
+ }