@lyupro/codex-bridge 0.6.7 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,45 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.6.8] - 2026-09-25
8
+
9
+ ### Added
10
+
11
+ - Every session records the version of the host that runs its hooks, read from the `version` the
12
+ host writes into its own transcript, in `state/host-observations.json`. `doctor` prints a
13
+ `sessionHost` line and one `otherHost:<version>` line for each other host seen in the last 30
14
+ days. Sessions ran in the VS Code extension 2.1.282 while `claude` on PATH was 2.1.281, and the
15
+ extension's `CLAUDE_CODE_EXECPATH` and `CLAUDE_AGENT_SDK_VERSION` are inherited by every `claude`
16
+ it starts, so neither PATH nor the environment names the host.
17
+ - A subagent stop that carries an `agent_id` but no `agent_type` records a witness alarm and tells
18
+ the session that the dispatcher gate cannot recognise dispatchers. That was the one host change the
19
+ gate would otherwise have passed silently.
20
+
21
+ ### Changed
22
+
23
+ - `.host-contract.json` and `state/dispatcher-contract.json` keep one verdict per host version, so a
24
+ CLI session and an extension session on different versions no longer overwrite each other's
25
+ measurement. Existing single-version files are read as the record of their own version.
26
+ - `doctor --probe-contract` measures the executable of the newest observed host. `claude` on PATH
27
+ and `CLAUDE_CODE_EXECPATH` are used only when their own `--version` matches it;
28
+ `--probe-executable <path>` names one explicitly. A host that completes the probe but writes
29
+ another version into its transcript records nothing.
30
+
31
+ ### Fixed
32
+
33
+ - `doctor` and `install` judge the hosts sessions actually ran on instead of `claude` on PATH. A
34
+ machine whose sessions run in an editor extension had its contracts reported as verified for a
35
+ different program, and a permanent `handbackWitness` warning asked for a dispatcher run that could
36
+ never clear it. With no session observed yet, `doctor` says so rather than falling back to PATH.
37
+ - The handback witness records the host version from the transcript and compares it exactly. It had
38
+ matched an SDK version to a CLI version by patch number, which accepted unrelated versions sharing
39
+ a patch. An older SDK-based witness file reads as "not seen yet" and keeps its alarms.
40
+ - `uninstall` names the host observations among the files it leaves in the package home.
41
+
42
+ After installing 0.6.8, run `codex-bridge update` once so the home receives the observation code,
43
+ then run any shell command in a session. If `doctor` reports the session host as probed on another
44
+ version, run `codex-bridge doctor --probe-contract` once (about two minutes of Claude quota).
45
+
7
46
  ## [0.6.7] - 2026-09-25
8
47
 
9
48
  ### Added
package/README.md CHANGED
@@ -62,7 +62,7 @@ If a global install and a clone coexist, you have two package copies. `codex-bri
62
62
  - **Every run leaves an audit folder.** The verbatim task, scope, before/after git state, events, report, verdict, and reason remain together.
63
63
  - **Zero runtime dependencies and zero build steps.** The package is plain `.mjs` on the Node.js standard library.
64
64
 
65
- The current suite contains **1636 automated tests: 1635 passing and 1 skipped**.
65
+ The current suite contains **1667 automated tests: 1666 passing and 1 skipped**.
66
66
 
67
67
  ## Verify
68
68
 
@@ -91,7 +91,9 @@ Output on a healthy host (trimmed):
91
91
  [ok] liveRuns: 0 runs working right now
92
92
  ```
93
93
 
94
- `doctor` reports the selected host, package source, installed files and guards, Codex availability, retention policy, permissions, live runs, and the current repository's run folder. It also reports `handbackWitness` and the four dispatcher contract lines (`dispatcherContract:agentIdentity`, `dispatcherContract:shellStdout`, `dispatcherContract:shellFailure`, and `dispatcherContract:agentTranscript`): verified contracts are `ok`, unknown, unprobed, or stale contracts are `warn`, and a measured change is `fail` with exit 1. The `agents` line is a read, not a file count: each installed definition is parsed and its `name` checked against the dispatcher it claims to be, because a definition the host cannot read is a dispatcher that does not exist while every other line still says `[ok]`. Content that drifts from the packaged definition is a failure, not a warning: `doctor` exits 1 and names `codex-bridge update --force`, because a host running someone else's agent files may be calling the runner in a form no permission rule matches.
94
+ `doctor` reports the selected host, package source, installed files and guards, Codex availability, retention policy, permissions, live runs, and the current repository's run folder. It judges contracts against the newest host version observed in a session transcript, not `claude` on `PATH`: on 2026-09-25 the VS Code extension ran sessions on 2.1.282 while `PATH` resolved to 2.1.281, and the extension's `CLAUDE_CODE_EXECPATH` and `CLAUDE_AGENT_SDK_VERSION` were inherited by every descendant `claude`. The transcript entry's `version` identifies the host. The dispatcher gate records it once per session in `state/host-observations.json` (sessions kept 7 days, hosts 30); `doctor` and `install` judge the newest observed host. `doctor` prints `sessionHost` and one `otherHost:<version>` line for each other observed version, with `ok`, `warn` and a probe command, or `fail` with exit 1. If no session has been observed, they say so instead of using `PATH`. Refusal and dispatcher contract records keep one verdict per host version, so a measurement of version V applies to every host of version V; older single-version records count only for their recorded version.
95
+
96
+ `doctor` also reports `handbackWitness` and the four dispatcher contract lines (`dispatcherContract:agentIdentity`, `dispatcherContract:shellStdout`, `dispatcherContract:shellFailure`, and `dispatcherContract:agentTranscript`): verified contracts are `ok`, unknown, unprobed, or stale contracts are `warn`, and a measured change is `fail` with exit 1. Handback sightings and alarms carry the transcript version, and sightings match only that exact version; an older SDK-based record counts as no sighting while preserving its alarms. If a subagent stop has an `agent_id` but no `agent_type`, the reply guard records an alarm and tells the session that the dispatcher gate cannot recognise dispatchers. The `agents` line is a read, not a file count: each installed definition is parsed and its `name` checked against the dispatcher it claims to be, because a definition the host cannot read is a dispatcher that does not exist while every other line still says `[ok]`. Content that drifts from the packaged definition is a failure, not a warning: `doctor` exits 1 and names `codex-bridge update --force`, because a host running someone else's agent files may be calling the runner in a form no permission rule matches.
95
97
 
96
98
  If a session answers `Agent type 'codex-build' not found`, a run can still be started without the dispatcher — the agent is a wrapper that types one command:
97
99
 
@@ -128,7 +130,7 @@ Transport files from runs at least **30 days** old are pruned automatically when
128
130
  | `update [--scope user\|project] [--host <path>] [--dry-run] [--force]` | Refresh a recorded installation. |
129
131
  | `permissions [add\|remove] [--scope user\|project] [--host <path>]` | Inspect or manage optional shell rules. |
130
132
  | `uninstall [--scope user\|project] [--host <path>] [--dry-run]` | Remove recorded package files while preserving run artifacts. |
131
- | `doctor [--scope user\|project] [--host <path>] [--probe-contract]` | Diagnose the selected host and Codex connection, including the handback witness and four dispatcher contract lines. `--probe-contract` measures refusal and dispatcher contracts in one live host run and records them when the host completes it. |
133
+ | `doctor [--scope user\|project] [--host <path>] [--probe-contract] [--probe-executable <path>]` | Diagnose the selected host and Codex connection, including the handback witness and four dispatcher contract lines. `--probe-contract` targets the newest observed host, accepting `CLAUDE_CODE_EXECPATH` or `claude` on `PATH` only when each reports that version with `--version`; use `--probe-executable <path>` to name an executable (`--host` selects the Claude Code configuration). The probe prints its target and records nothing if the completed host run reports another transcript version. |
132
134
  | `run <runner options> --task-file <abs path>` | Start or attach to a delegated run through the permission-stable package command. The task file carries the statement, the scout questions and the verification command; its path must be absolute. Piping the statement on stdin is the alternative channel, and the two cannot be combined. |
133
135
  | `model [list\|set\|unset\|speed]` | Show the model, effort, pinned speed tier and provenance of each role, with the config path; `model list` prints the live catalogue from Codex, hidden models included and marked; `model set <role> <model> [effort]` checks the pair and any retained speed tier against that model's catalogue entry, and `model unset <role>` removes the profile. `model speed <role> <tier>` previews a live accelerated tier and its quoted cost description; repeat with trailing `confirm` to pin it. `model speed <role> unset` removes only speed, without confirmation. The profile is machine-wide, shared by every project on this machine. |
134
136
  | `projects [<name>] [--json]` | List projects or runs in the run store. |
@@ -2,6 +2,8 @@
2
2
  /** Dispatches codex-bridge CLI arguments to focused command modules. */
3
3
  import { diagnose, renderDoctor } from '../cli/doctor.mjs';
4
4
  import { probeContract } from '../cli/probe-contract.mjs';
5
+ import { resolveProbeTarget } from '../cli/probe-target.mjs';
6
+ import { brandStateDir } from '../src/home/lib/brand-home.mjs';
5
7
  import { isInvokedDirectly } from '../cli/invoked-directly.mjs';
6
8
  import { hook } from '../cli/hook.mjs';
7
9
  import { resolveHost } from '../cli/hosts.mjs';
@@ -25,7 +27,7 @@ Usage:
25
27
  codex-bridge update [--scope user|project] [--host <path>] [--dry-run] [--force]
26
28
  codex-bridge permissions [add|remove] [--scope user|project] [--host <path>]
27
29
  codex-bridge uninstall [--scope user|project] [--host <path>] [--dry-run]
28
- codex-bridge doctor [--scope user|project] [--host <path>] [--probe-contract]
30
+ codex-bridge doctor [--scope user|project] [--host <path>] [--probe-contract] [--probe-executable <path>]
29
31
  codex-bridge run <runner options> --task-file <path>
30
32
  codex-bridge model [list]
31
33
  codex-bridge projects [<name>] [--json]
@@ -74,10 +76,10 @@ export function commandOptions(command, argv) {
74
76
  options[flagNames.get(arg)] = true;
75
77
  continue;
76
78
  }
77
- if (arg !== '--scope' && arg !== '--host') throw new Error(`unknown ${command} option "${arg}"`);
79
+ if (arg !== '--scope' && arg !== '--host' && !(command === 'doctor' && arg === '--probe-executable')) throw new Error(`unknown ${command} option "${arg}"`);
78
80
  const value = argv[index + 1];
79
81
  if (!value || value.startsWith('-')) throw new Error(`${arg} requires a value`);
80
- options[arg === '--scope' ? 'scope' : 'host'] = value;
82
+ options[arg === '--scope' ? 'scope' : arg === '--host' ? 'host' : 'probeExecutable'] = value;
81
83
  index += 1;
82
84
  }
83
85
  return options;
@@ -96,13 +98,16 @@ export async function main(argv, io = console) {
96
98
  if (command === 'run') return runCodex(rest);
97
99
  if (command === 'doctor') {
98
100
  const options = commandOptions(command, rest);
101
+ if (options.probeExecutable && !options.probeContract) throw new Error('--probe-executable requires --probe-contract');
99
102
  const host = resolveHost(options);
100
103
  // The probe runs BEFORE the diagnosis so the `hostContract` line below carries the verdict just
101
104
  // measured. Printing the diagnosis first and the measurement after would answer one question
102
105
  // twice in one output, with the older answer on top (Plan_52 D25).
103
106
  let probe = null;
104
107
  if (options.probeContract) {
105
- probe = await probeContract({ host });
108
+ const target = resolveProbeTarget({ stateDir: brandStateDir(host.brandRoot), executable: options.probeExecutable });
109
+ if (!target.error) io.log(`probe: target ${target.version} at ${target.executable} (${target.source})`);
110
+ probe = await probeContract({ host, target });
106
111
  io.log(`probe: ${probe.message}`);
107
112
  for (const [name, verdict] of Object.entries(probe.dispatcher ?? {})) {
108
113
  io.log(`probe: ${name} ${verdict.result} — ${verdict.detail}`);
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Keeps the latest conclusive verdict per dispatcher host contract, each bound to the host version it was
3
- * measured on. Its own file rather than a field of .host-contract.json (Plan_62 D14, D19): the refusal
4
- * record is replaced whole by its writer, and an inconclusive probe must never erase an earlier verdict.
2
+ * Keeps the latest conclusive verdict per dispatcher host contract, per host version: on 2026-09-25 the
3
+ * VS Code host 2.1.282 ran beside the CLI host 2.1.281, and probing either must keep the other's verdict.
4
+ * Its own file rather than a field of .host-contract.json (Plan_62 D14, D19), and an inconclusive probe
5
+ * must never erase an earlier verdict.
5
6
  */
6
7
  import fs from 'node:fs';
7
8
  import path from 'node:path';
@@ -19,9 +20,17 @@ export function readDispatcherContract({ stateDir }) {
19
20
  }
20
21
  try {
21
22
  const record = parseJsonText(file, source);
22
- if (!record || typeof record !== 'object' || Array.isArray(record)
23
- || !record.contracts || typeof record.contracts !== 'object' || Array.isArray(record.contracts)) return { corrupt: true };
24
- return record;
23
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return { corrupt: true };
24
+ if (record.hosts && typeof record.hosts === 'object' && !Array.isArray(record.hosts)) return record;
25
+ if (!record.contracts || typeof record.contracts !== 'object' || Array.isArray(record.contracts)) return { corrupt: true };
26
+ const hosts = {};
27
+ for (const [name, item] of Object.entries(record.contracts)) {
28
+ if (!item || typeof item.version !== 'string' || !item.version) continue;
29
+ hosts[item.version] ??= { contracts: {} };
30
+ const { version: _version, ...entry } = item;
31
+ hosts[item.version].contracts[name] = entry;
32
+ }
33
+ return { hosts };
25
34
  } catch { return { corrupt: true }; }
26
35
  }
27
36
 
@@ -29,14 +38,14 @@ export function writeDispatcherContract({ stateDir, version, verdicts, now = new
29
38
  const file = path.join(stateDir, DISPATCHER_CONTRACT_FILE);
30
39
  const previous = readDispatcherContract({ stateDir });
31
40
  if (previous?.corrupt) throw new Error(`Cannot update corrupt dispatcher contract: ${file}`);
32
- const contracts = { ...(previous?.contracts ?? {}) };
41
+ const contracts = { ...(previous?.hosts?.[version]?.contracts ?? {}) };
33
42
  for (const name of DISPATCHER_CONTRACTS) {
34
43
  const result = verdicts?.[name]?.result;
35
44
  if (result === 'honored' || result === 'changed') {
36
- contracts[name] = { result, version, checkedAt: new Date(now).toISOString(), detail: verdicts[name].detail };
45
+ contracts[name] = { result, checkedAt: new Date(now).toISOString(), detail: verdicts[name].detail };
37
46
  }
38
47
  }
39
- const record = { contracts };
48
+ const record = { hosts: { ...(previous?.hosts ?? {}), [version]: { contracts } } };
40
49
  writeJsonAtomic(file, record);
41
50
  return record;
42
51
  }
@@ -1,4 +1,4 @@
1
- /** Judges dispatcher host behaviour so a silent regression cannot leave the Plan_62 D19 gate trusted. */
1
+ /** Judges dispatcher host behaviour per version: the 2026-09-25 incident ran VS Code 2.1.282 beside CLI 2.1.281. */
2
2
  import { parseJsonText } from '../src/home/lib/json-file.mjs';
3
3
  import { PROBE_COMMAND } from './host-contract.mjs';
4
4
 
@@ -68,15 +68,16 @@ export function judgeDispatcherContracts({ entries, hostHealthy, okCommand, fail
68
68
 
69
69
  export function dispatcherContractStatus({ record, version }) {
70
70
  return DISPATCHER_CONTRACTS.map((contract) => {
71
- const item = record?.contracts?.[contract];
71
+ const item = record?.hosts?.[version]?.contracts?.[contract];
72
72
  let state;
73
73
  let message;
74
74
  if (version == null) {
75
75
  state = 'unknown-host'; message = 'Host version is unknown; dispatcher contracts cannot be judged.';
76
76
  } else if (!item) {
77
- state = 'unverified'; message = `Dispatcher contract ${contract} has never been probed on host ${version}; run ${PROBE_COMMAND}.`;
78
- } else if (item.version !== version) {
79
- state = 'stale'; message = `Dispatcher contract ${contract} was recorded for host ${item.version}; run ${PROBE_COMMAND}.`;
77
+ const other = Object.entries(record?.hosts ?? {}).filter(([, host]) => host?.contracts?.[contract])
78
+ .sort((a, b) => Date.parse(b[1].contracts[contract].checkedAt) - Date.parse(a[1].contracts[contract].checkedAt))[0];
79
+ if (other) { state = 'stale'; message = `Dispatcher contract ${contract} was recorded for host ${other[0]}; run ${PROBE_COMMAND}.`; }
80
+ else { state = 'unverified'; message = `Dispatcher contract ${contract} has never been probed on host ${version}; run ${PROBE_COMMAND}.`; }
80
81
  } else if (item.result === 'honored') {
81
82
  state = 'verified'; message = `Dispatcher contract ${contract} is verified on host ${version}.`;
82
83
  } else if (item.result === 'changed') {
@@ -1,111 +1,111 @@
1
- /** Reports whether what this package installed into the host still matches the package. */
2
- import fs from 'node:fs/promises';
3
- import path from 'node:path';
4
- import {
5
- buildInstallPlan,
6
- contentFingerprint,
7
- fileFingerprint,
8
- } from './manifest.mjs';
9
- import { plannedContent } from './copy.mjs';
10
- import { RULE_LIST_NAMES, inspectPermissions } from './permissions.mjs';
11
- import { readRulesRegistry } from './rules-owners.mjs';
12
- import { parseFrontmatter } from './frontmatter.mjs';
13
- import { check } from './doctor-format.mjs';
14
-
15
- export async function exists(target) {
16
- try {
17
- await fs.access(target);
18
- return true;
19
- } catch {
20
- return false;
21
- }
22
- }
23
-
24
- export async function isFile(target) {
25
- try {
26
- return (await fs.stat(target)).isFile();
27
- } catch {
28
- return false;
29
- }
30
- }
31
-
32
- export async function agentsCheck(host, record) {
33
- if (!record) return check('agents', 'warn', 'Agent definitions were not checked; run codex-bridge install');
34
- const plan = await buildInstallPlan(host);
35
- const agents = plan.filter((item) => /[\\/]src[\\/]agents[\\/][^\\/]+\.md$/.test(item.source));
36
- const mismatches = [];
37
- const unreadable = [];
38
- for (const item of agents) {
39
- const expected = contentFingerprint(await plannedContent(item, host.brandRoot));
40
- if (await fileFingerprint(item.target) !== expected) mismatches.push(path.basename(item.target));
41
- const expectedName = path.basename(item.source, '.md');
42
- try {
43
- const frontmatter = parseFrontmatter(await fs.readFile(item.target, 'utf8'));
44
- if (!frontmatter) throw new Error('frontmatter is missing');
45
- if (frontmatter.name !== expectedName) {
46
- throw new Error(`name is ${JSON.stringify(frontmatter.name)}, expected ${JSON.stringify(expectedName)}`);
47
- }
48
- } catch (err) {
49
- unreadable.push(`${path.basename(item.target)} (${err.message})`);
50
- }
51
- }
52
- // The 2026-08-10 registration failure left files present but unreadable by Claude Code. Drift was
53
- // advisory until a 2026-08-16 checklist run planted the pre-Plan_41 invocation — a path instead of
54
- // the package command — in an installed definition: doctor kept exit 0 while every delegation
55
- // would stop on a permission prompt. A definition that is not this package's is not judged healthy.
56
- if (unreadable.length) {
57
- return check('agents', 'fail', `Installed agent definitions cannot be read: ${unreadable.join(', ')}; run codex-bridge update --force`);
58
- }
59
- return mismatches.length
60
- ? check('agents', 'fail', `Installed agent definitions differ from this package: ${mismatches.join(', ')}; run codex-bridge update --force`)
61
- : check('agents', 'ok', `${agents.length} installed agent definition(s) match this package`);
62
- }
63
-
64
- export async function conventionsCheck(host) {
65
- const file = host.brandConventionsPath;
66
- let content;
67
- try {
68
- content = await fs.readFile(file, 'utf8');
69
- } catch (err) {
70
- if (err.code === 'ENOENT') return check('conventions', 'ok', `${file} (not found; optional)`);
71
- return check('conventions', 'fail', `cannot read ${file}: ${err.message}`);
72
- }
73
- return content.trim()
74
- ? check('conventions', 'ok', `${file} (found)`)
75
- : check('conventions', 'warn', `${file} (found but empty)`);
76
- }
77
-
78
- export async function rulesCheck(host, record) {
79
- if (!record) return check('rules', 'warn', 'cannot check before installation');
80
- if (!record.rules) {
81
- return check('rules', 'warn', 'rules were not installed by this installation; install or update will add them');
82
- }
83
- let registry;
84
- try {
85
- registry = await readRulesRegistry(host);
86
- } catch (err) {
87
- // Keep every diagnostic visible: package removal on a broken registry left the host without its watchdog.
88
- return check('rules', 'fail', err.message);
89
- }
90
- const ownerNote = registry?.owners.length > 1 ? `; ${registry.owners.length} owners` : '';
91
- const fingerprint = await fileFingerprint(record.rules.path);
92
- if (!fingerprint) return check('rules', 'fail', `${record.rules.path}${ownerNote ? ` (${registry.owners.length} owners)` : ''}`);
93
- return fingerprint === record.rules.fingerprint
94
- ? check('rules', 'ok', `${record.rules.path} (matches record${ownerNote})`)
95
- : check('rules', 'warn', `${record.rules.path} (modified after installation${ownerNote})`);
96
- }
97
-
98
- export async function permissionsCheck(host) {
99
- try {
100
- const status = await inspectPermissions(host.settingsPath);
101
- // Permission rules are an optional operator action; Plan_22 keeps their absence a warning so
102
- // doctor does not turn a healthy installation red merely because hardening was not requested.
103
- // The ask count is part of the line because a full set shadowed by `ask` reads as working and
104
- // is not: the live run of Plan_22-1 found this line saying `installed (24/24)` over it.
105
- const shadow = status.askCount ? `, ${status.askCount} shadowed by ask` : '';
106
- return check('permissions', status.state === 'installed' ? 'ok' : 'warn',
107
- `${status.state} (${status.present}/${status.total} own strings in ${RULE_LIST_NAMES}${shadow})`);
108
- } catch (err) {
109
- return check('permissions', 'warn', `cannot inspect permission rules: ${err.message}`);
110
- }
111
- }
1
+ /** Reports whether what this package installed into the host still matches the package. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import {
5
+ buildInstallPlan,
6
+ contentFingerprint,
7
+ fileFingerprint,
8
+ } from './manifest.mjs';
9
+ import { plannedContent } from './copy.mjs';
10
+ import { RULE_LIST_NAMES, inspectPermissions } from './permissions.mjs';
11
+ import { readRulesRegistry } from './rules-owners.mjs';
12
+ import { parseFrontmatter } from './frontmatter.mjs';
13
+ import { check } from './doctor-format.mjs';
14
+
15
+ export async function exists(target) {
16
+ try {
17
+ await fs.access(target);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ export async function isFile(target) {
25
+ try {
26
+ return (await fs.stat(target)).isFile();
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ export async function agentsCheck(host, record) {
33
+ if (!record) return check('agents', 'warn', 'Agent definitions were not checked; run codex-bridge install');
34
+ const plan = await buildInstallPlan(host);
35
+ const agents = plan.filter((item) => /[\\/]src[\\/]agents[\\/][^\\/]+\.md$/.test(item.source));
36
+ const mismatches = [];
37
+ const unreadable = [];
38
+ for (const item of agents) {
39
+ const expected = contentFingerprint(await plannedContent(item, host.brandRoot));
40
+ if (await fileFingerprint(item.target) !== expected) mismatches.push(path.basename(item.target));
41
+ const expectedName = path.basename(item.source, '.md');
42
+ try {
43
+ const frontmatter = parseFrontmatter(await fs.readFile(item.target, 'utf8'));
44
+ if (!frontmatter) throw new Error('frontmatter is missing');
45
+ if (frontmatter.name !== expectedName) {
46
+ throw new Error(`name is ${JSON.stringify(frontmatter.name)}, expected ${JSON.stringify(expectedName)}`);
47
+ }
48
+ } catch (err) {
49
+ unreadable.push(`${path.basename(item.target)} (${err.message})`);
50
+ }
51
+ }
52
+ // The 2026-08-10 registration failure left files present but unreadable by Claude Code. Drift was
53
+ // advisory until a 2026-08-16 checklist run planted the pre-Plan_41 invocation — a path instead of
54
+ // the package command — in an installed definition: doctor kept exit 0 while every delegation
55
+ // would stop on a permission prompt. A definition that is not this package's is not judged healthy.
56
+ if (unreadable.length) {
57
+ return check('agents', 'fail', `Installed agent definitions cannot be read: ${unreadable.join(', ')}; run codex-bridge update --force`);
58
+ }
59
+ return mismatches.length
60
+ ? check('agents', 'fail', `Installed agent definitions differ from this package: ${mismatches.join(', ')}; run codex-bridge update --force`)
61
+ : check('agents', 'ok', `${agents.length} installed agent definition(s) match this package`);
62
+ }
63
+
64
+ export async function conventionsCheck(host) {
65
+ const file = host.brandConventionsPath;
66
+ let content;
67
+ try {
68
+ content = await fs.readFile(file, 'utf8');
69
+ } catch (err) {
70
+ if (err.code === 'ENOENT') return check('conventions', 'ok', `${file} (not found; optional)`);
71
+ return check('conventions', 'fail', `cannot read ${file}: ${err.message}`);
72
+ }
73
+ return content.trim()
74
+ ? check('conventions', 'ok', `${file} (found)`)
75
+ : check('conventions', 'warn', `${file} (found but empty)`);
76
+ }
77
+
78
+ export async function rulesCheck(host, record) {
79
+ if (!record) return check('rules', 'warn', 'cannot check before installation');
80
+ if (!record.rules) {
81
+ return check('rules', 'warn', 'rules were not installed by this installation; install or update will add them');
82
+ }
83
+ let registry;
84
+ try {
85
+ registry = await readRulesRegistry(host);
86
+ } catch (err) {
87
+ // Keep every diagnostic visible: package removal on a broken registry left the host without its watchdog.
88
+ return check('rules', 'fail', err.message);
89
+ }
90
+ const ownerNote = registry?.owners.length > 1 ? `; ${registry.owners.length} owners` : '';
91
+ const fingerprint = await fileFingerprint(record.rules.path);
92
+ if (!fingerprint) return check('rules', 'fail', `${record.rules.path}${ownerNote ? ` (${registry.owners.length} owners)` : ''}`);
93
+ return fingerprint === record.rules.fingerprint
94
+ ? check('rules', 'ok', `${record.rules.path} (matches record${ownerNote})`)
95
+ : check('rules', 'warn', `${record.rules.path} (modified after installation${ownerNote})`);
96
+ }
97
+
98
+ export async function permissionsCheck(host) {
99
+ try {
100
+ const status = await inspectPermissions(host.settingsPath);
101
+ // Permission rules are an optional operator action; Plan_22 keeps their absence a warning so
102
+ // doctor does not turn a healthy installation red merely because hardening was not requested.
103
+ // The ask count is part of the line because a full set shadowed by `ask` reads as working and
104
+ // is not: the live run of Plan_22-1 found this line saying `installed (24/24)` over it.
105
+ const shadow = status.askCount ? `, ${status.askCount} shadowed by ask` : '';
106
+ return check('permissions', status.state === 'installed' ? 'ok' : 'warn',
107
+ `${status.state} (${status.present}/${status.total} own strings in ${RULE_LIST_NAMES}${shadow})`);
108
+ } catch (err) {
109
+ return check('permissions', 'warn', `cannot inspect permission rules: ${err.message}`);
110
+ }
111
+ }
package/cli/doctor.mjs CHANGED
@@ -16,13 +16,15 @@ import {
16
16
  rulesCheck,
17
17
  } from './doctor-installation.mjs';
18
18
  import { hookChecks } from './doctor-hooks.mjs';
19
- import { contractStatus, detectHostVersion, readHostContract } from './host-contract.mjs';
19
+ import { contractStatus, readHostContract } from './host-contract.mjs';
20
20
  import { handbackWitnessStatus } from './handback-witness-check.mjs';
21
21
  import { dispatcherContractStatus } from './dispatcher-contract.mjs';
22
22
  import { DISPATCHER_CONTRACT_FILE, readDispatcherContract } from './dispatcher-contract-record.mjs';
23
23
  import { PROBE_COMMAND } from './host-contract.mjs';
24
24
  import { readHandbackWitness } from '../src/home/lib/handback-witness.mjs';
25
25
  import { brandStateDir } from '../src/home/lib/brand-home.mjs';
26
+ import { readHostObservations } from '../src/home/lib/host-observations.mjs';
27
+ import { otherHostCheck, sessionHostCheck, sessionHostVersions } from './session-hosts.mjs';
26
28
  import { liveRunsCheck, projectRunsCheck, retentionCheck } from './doctor-runs.mjs';
27
29
 
28
30
  export { renderDoctor };
@@ -81,6 +83,7 @@ export async function diagnose({
81
83
  handbackWitnessRecord,
82
84
  dispatcherContractRecord,
83
85
  hostVersion,
86
+ observations,
84
87
  } = {}) {
85
88
  const checks = [sourceCheck()];
86
89
  const hostExists = await exists(host.root);
@@ -124,27 +127,35 @@ export async function diagnose({
124
127
  const bridge = bridgeProbe();
125
128
  checks.push(bridgeCommandCheck(bridge));
126
129
  checks.push(...await hookChecks(host, record, launcherProbe));
127
- const detectedHostVersion = hostVersion === undefined ? detectHostVersion() : hostVersion;
130
+ const stateDir = brandStateDir(host.brandRoot);
131
+ const hostObservations = observations === undefined ? readHostObservations({ stateDir }) : observations;
132
+ const observedVersions = sessionHostVersions(hostObservations);
133
+ const detectedHostVersion = hostVersion === undefined ? observedVersions[0] ?? null : hostVersion;
134
+ checks.push(sessionHostCheck(observedVersions, hostObservations));
135
+ const refusalRecord = contractRecord === undefined ? await readHostContract(host) : contractRecord;
128
136
  const hostContract = contractStatus({
129
- record: contractRecord === undefined ? await readHostContract(host) : contractRecord,
137
+ record: refusalRecord,
130
138
  version: detectedHostVersion,
131
139
  });
132
140
  const hostContractStatus = hostContract.state === 'verified'
133
141
  ? 'ok'
134
142
  : hostContract.state === 'ignored' ? 'fail' : 'warn';
135
143
  checks.push(check('hostContract', hostContractStatus, hostContract.message));
136
- const stateDir = brandStateDir(host.brandRoot);
137
144
  const witness = handbackWitnessStatus({
138
145
  record: handbackWitnessRecord === undefined ? readHandbackWitness({ stateDir }) : handbackWitnessRecord,
139
146
  hostVersion: detectedHostVersion,
140
147
  stateDir,
141
148
  });
142
149
  checks.push(check('handbackWitness', ['seen', 'unobserved'].includes(witness.state) ? 'ok' : 'warn', witness.message));
150
+ const dispatcherRecord = dispatcherContractRecord === undefined ? readDispatcherContract({ stateDir }) : dispatcherContractRecord;
143
151
  checks.push(...dispatcherContractChecks({
144
- record: dispatcherContractRecord === undefined ? readDispatcherContract({ stateDir }) : dispatcherContractRecord,
152
+ record: dispatcherRecord,
145
153
  version: detectedHostVersion,
146
154
  stateDir,
147
155
  }));
156
+ for (const version of observedVersions.filter((item) => item !== detectedHostVersion)) {
157
+ checks.push(otherHostCheck({ version, contractRecord: refusalRecord, dispatcherRecord }));
158
+ }
148
159
  const retention = retentionCheck(host);
149
160
  checks.push(retention);
150
161
  const conventions = await conventionsCheck(host);
@@ -163,7 +174,8 @@ export async function diagnose({
163
174
  exitCode: !record || recordBroken || missingFiles.length || agents.status === 'fail' || rules.status === 'fail'
164
175
  || hostContractStatus === 'fail' || retention.status === 'fail' || conventions.status === 'fail'
165
176
  || projectRuns.status === 'fail'
166
- || checks.some((item) => /^(hook|dispatcherContract):/.test(item.key) && item.status === 'fail') ? 1 : 0,
177
+ || checks.some((item) => /^(hook|dispatcherContract):/.test(item.key) && item.status === 'fail')
178
+ || checks.some((item) => item.key.startsWith('otherHost:') && item.status === 'fail') ? 1 : 0,
167
179
  checks,
168
180
  record,
169
181
  missingFiles,
@@ -1,13 +1,7 @@
1
- /** Surfaces handback drift because the 2026-09-17..24 contract break went unnoticed for a week. */
1
+ /** Surfaces handback drift; 2026-09-25 established exact host transcript versions as identity. */
2
2
  import path from 'node:path';
3
3
  import { WITNESS_FILE } from '../src/home/lib/handback-witness.mjs';
4
4
 
5
- function versionParts(value) {
6
- if (typeof value !== 'string') return null;
7
- const match = value.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:\s|$)/);
8
- return match ? { version: match[0].trim(), patch: match[3] } : null;
9
- }
10
-
11
5
  export function handbackWitnessStatus({ record, hostVersion, stateDir }) {
12
6
  if (record?.corrupt) {
13
7
  return { state: 'unreadable', message: `The handback witness is unreadable; delete ${path.join(stateDir, WITNESS_FILE)} to reset it.` };
@@ -16,24 +10,22 @@ export function handbackWitnessStatus({ record, hostVersion, stateDir }) {
16
10
  const alarm = record.alarms.at(-1);
17
11
  return {
18
12
  state: 'alarm',
19
- message: `${record.alarms.length} dispatcher alarm(s); newest: ${alarm.detail} at ${alarm.at}; do not trust a dispatcher answer from that time; see Plan_62 D15.`,
13
+ message: `${record.alarms.length} dispatcher alarm(s); newest on ${alarm.hostVersion ?? 'unknown host'}: ${alarm.detail} at ${alarm.at}; do not trust a dispatcher answer from that time; see Plan_62 D15.`,
20
14
  };
21
15
  }
22
- if (!record?.lastSeen) {
16
+ if (!record?.lastSeen || Object.keys(record.lastSeen).length === 0) {
23
17
  return { state: 'unobserved', message: 'Not observed yet — a handback is seen only when a dispatcher runs in an interactive session.' };
24
18
  }
25
19
 
26
- const seen = versionParts(record.lastSeen.sdkVersion);
27
- const host = versionParts(hostVersion);
28
- // Plan_62 D9 pairs observed SDK 0.3.281 inside Claude Code 2.1.281 hooks; matching patches are current.
29
- if (seen && host && seen.patch !== host.patch) {
20
+ if (record.lastSeen[hostVersion]) {
21
+ return { state: 'seen', message: `Handback contract last seen on host ${hostVersion} at ${record.lastSeen[hostVersion]}.` };
22
+ }
23
+ const [newest, iso] = Object.entries(record.lastSeen).sort((left, right) => right[1].localeCompare(left[1]))[0];
24
+ if (newest) {
30
25
  return {
31
26
  state: 'stale',
32
- message: `Last seen on SDK ${record.lastSeen.sdkVersion} at ${record.lastSeen.at}; current host ${hostVersion}; run any dispatcher once in an interactive session to observe it again.`,
27
+ message: `Handback contract last seen on host ${newest} at ${iso}, not yet on host ${hostVersion}; it is recorded the next time a dispatcher runs in an interactive session on that host.`,
33
28
  };
34
29
  }
35
- return {
36
- state: 'seen',
37
- message: `Handback contract last seen on SDK ${record.lastSeen.sdkVersion} (host ${hostVersion}) at ${record.lastSeen.at}.`,
38
- };
30
+ return { state: 'unobserved', message: 'Not observed yet — a handback is seen only when a dispatcher runs in an interactive session.' };
39
31
  }
@@ -6,7 +6,9 @@
6
6
  * somewhere between host 2.1.119 and 2.1.231, with no error printed on the way. Nothing in this
7
7
  * package would notice `deny` going the same way — the suite checks that a hook RETURNED a refusal,
8
8
  * never that the host APPLIED it. The record is bound to a host version because that is what
9
- * changes underneath an installation: the host updates itself, the package does not.
9
+ * changes underneath an installation: the host updates itself, the package does not. The
10
+ * 2026-09-25 incident ran VS Code host 2.1.282 beside CLI host 2.1.281, so each version keeps
11
+ * its own verdict and probing either must preserve the other.
10
12
  */
11
13
  import { spawnSync } from 'node:child_process';
12
14
  import { randomUUID } from 'node:crypto';
@@ -40,13 +42,12 @@ export async function readHostContract(host) {
40
42
  const target = hostContractPath(host);
41
43
  try {
42
44
  const record = await readJsonFile(target);
43
- if (!record || typeof record !== 'object' || Array.isArray(record)
44
- || typeof record.version !== 'string' || !record.version) return null;
45
- return {
46
- version: record.version,
47
- checkedAt: record.checkedAt,
48
- result: record.result,
49
- };
45
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return null;
46
+ if (record.hosts && typeof record.hosts === 'object' && !Array.isArray(record.hosts)) return record;
47
+ if (typeof record.version === 'string' && record.version) {
48
+ return { hosts: { [record.version]: { result: record.result, checkedAt: record.checkedAt } } };
49
+ }
50
+ return null;
50
51
  } catch {
51
52
  return null;
52
53
  }
@@ -62,7 +63,12 @@ export async function writeHostContract(host, { version, result, now = new Date(
62
63
 
63
64
  const target = hostContractPath(host);
64
65
  const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
65
- const record = { version, result, checkedAt: now.toISOString() };
66
+ const previous = await readHostContract(host);
67
+ const cutoff = now.getTime() - 180 * 24 * 60 * 60 * 1000;
68
+ const hosts = Object.fromEntries(Object.entries(previous?.hosts ?? {}).filter(([, entry]) =>
69
+ Number.isFinite(Date.parse(entry?.checkedAt)) && Date.parse(entry.checkedAt) >= cutoff));
70
+ hosts[version] = { result, checkedAt: now.toISOString() };
71
+ const record = { hosts };
66
72
  await fs.mkdir(host.brandRoot, { recursive: true });
67
73
  try {
68
74
  await fs.writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
@@ -77,9 +83,9 @@ export function parseHostVersion(text) {
77
83
  return text.trim().match(/^(\d+\.\d+\.\d+)(?:\s|$)/)?.[1] ?? null;
78
84
  }
79
85
 
80
- export function detectHostVersion({ run = spawnSync } = {}) {
86
+ export function detectHostVersion({ run = spawnSync, executable = 'claude' } = {}) {
81
87
  try {
82
- const result = run('claude', ['--version'], {
88
+ const result = run(executable, ['--version'], {
83
89
  encoding: 'utf8',
84
90
  shell: false,
85
91
  timeout: 5000,
@@ -107,13 +113,16 @@ export function contractStatus({ record, version }) {
107
113
  message: `The refusal contract has never been probed on this machine (host ${version}); run ${PROBE_COMMAND}.`,
108
114
  };
109
115
  }
110
- if (record.version !== version) {
116
+ const entry = record.hosts?.[version];
117
+ if (!entry && Object.keys(record.hosts ?? {}).length) {
118
+ const [otherVersion] = Object.entries(record.hosts).sort((a, b) => Date.parse(b[1]?.checkedAt) - Date.parse(a[1]?.checkedAt))[0];
111
119
  return {
112
120
  state: 'stale',
113
- message: `The refusal contract was probed on host ${record.version}, but the current host is ${version}; run ${PROBE_COMMAND}.`,
121
+ message: `The refusal contract was probed on host ${otherVersion}, but the current host is ${version}; run ${PROBE_COMMAND}.`,
114
122
  };
115
123
  }
116
- if (record.result === 'ignored') {
124
+ if (!entry) return { state: 'unverified', message: `The refusal contract has never been probed on this machine (host ${version}); run ${PROBE_COMMAND}.` };
125
+ if (entry.result === 'ignored') {
117
126
  const guards = HOOK_DEFINITIONS
118
127
  .filter((definition) => definition.event === 'PreToolUse')
119
128
  .map((definition) => definition.name)
@@ -123,10 +132,10 @@ export function contractStatus({ record, version }) {
123
132
  message: `DANGER: Host ${version} does not honour hook refusals, so every guard is inert: ${guards}. Re-run ${PROBE_COMMAND} once the host updates.`,
124
133
  };
125
134
  }
126
- if (record.result === 'honored') {
135
+ if (entry.result === 'honored') {
127
136
  return {
128
137
  state: 'verified',
129
- message: `Host ${version} honours hook refusals (verified ${record.checkedAt}).`,
138
+ message: `Host ${version} honours hook refusals (verified ${entry.checkedAt}).`,
130
139
  };
131
140
  }
132
141
  return {
package/cli/install.mjs CHANGED
@@ -25,7 +25,10 @@ import {
25
25
  } from './settings-merge.mjs';
26
26
  import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
27
27
  import { addPermissionRules, inspectPermissions } from './permissions.mjs';
28
- import { contractStatus, detectHostVersion, readHostContract } from './host-contract.mjs';
28
+ import { contractStatus, readHostContract } from './host-contract.mjs';
29
+ import { readHostObservations } from '../src/home/lib/host-observations.mjs';
30
+ import { brandStateDir } from '../src/home/lib/brand-home.mjs';
31
+ import { sessionHostVersions } from './session-hosts.mjs';
29
32
  import { readRunConfig, retentionNotice } from '../src/home/lib/run-config.mjs';
30
33
 
31
34
  const WARNING = '\u001b[33m';
@@ -109,14 +112,22 @@ async function installInRun({
109
112
  env = process.env,
110
113
  contractRecord,
111
114
  hostVersion,
115
+ observations,
112
116
  } = {}) {
113
117
  // Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
114
118
  await readRulesRegistry(host);
115
119
  const configuredRetentionLine = retentionLine(host);
116
- const hostContract = contractStatus({
120
+ const hostObservations = observations === undefined
121
+ ? readHostObservations({ stateDir: brandStateDir(host.brandRoot) }) : observations;
122
+ const observedVersion = sessionHostVersions(hostObservations)[0] ?? null;
123
+ const currentHostVersion = hostVersion === undefined ? observedVersion : hostVersion;
124
+ let hostContract = contractStatus({
117
125
  record: contractRecord === undefined ? await readHostContract(host) : contractRecord,
118
- version: hostVersion === undefined ? detectHostVersion() : hostVersion,
126
+ version: currentHostVersion,
119
127
  });
128
+ const noObservedHostMessage = currentHostVersion === null
129
+ ? 'Host contracts are judged once a session has run a shell command; then run codex-bridge doctor.' : null;
130
+ if (noObservedHostMessage) hostContract = { ...hostContract, message: noObservedHostMessage };
120
131
  const plan = await buildInstallPlan(host, packageRoot);
121
132
  const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
122
133
  const currentPackage = await packageInfo(packageRoot);
@@ -7,13 +7,14 @@ import { spawnSync } from 'node:child_process';
7
7
  import fs from 'node:fs/promises';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
- import { detectHostVersion, writeHostContract } from './host-contract.mjs';
10
+ import { writeHostContract } from './host-contract.mjs';
11
11
  import { buildRig } from './probe-rig.mjs';
12
12
  import { DISPATCHER_CONTRACTS, judgeDispatcherContracts } from './dispatcher-contract.mjs';
13
13
  import { writeDispatcherContract } from './dispatcher-contract-record.mjs';
14
14
  import { brandStateDir } from '../src/home/lib/brand-home.mjs';
15
15
  import { parseJsonText } from '../src/home/lib/json-file.mjs';
16
16
  import { randomUUID } from 'node:crypto';
17
+ import { transcriptHostVersion } from '../src/home/lib/host-version.mjs';
17
18
 
18
19
  export const PROBE_MARKER = 'codex-bridge-contract-probe';
19
20
 
@@ -60,17 +61,18 @@ export function judgeProbe({ markerExists, hookFired, hostResult }) {
60
61
 
61
62
  export async function probeContract({
62
63
  host,
63
- version = detectHostVersion(),
64
+ target,
64
65
  runHost = spawnSync,
65
66
  rigRoot = os.tmpdir(),
66
67
  now = new Date(),
67
68
  }) {
68
- if (version == null) {
69
+ if (target?.error || !target?.version || !target?.executable) {
70
+ const message = target?.error ?? 'The probe target is unavailable.';
69
71
  return {
70
72
  state: 'inconclusive',
71
73
  result: null,
72
74
  version: null,
73
- message: 'The host version could not be read, so no contract probe was run.',
75
+ message,
74
76
  recorded: false,
75
77
  dispatcher: Object.fromEntries(DISPATCHER_CONTRACTS.map((name) => [name, { result: 'inconclusive', detail: 'Host version is unavailable.' }])),
76
78
  };
@@ -83,7 +85,7 @@ export async function probeContract({
83
85
  const rig = await buildRig(dir, token);
84
86
  let hostResult;
85
87
  try {
86
- hostResult = runHost('claude', [
88
+ hostResult = runHost(target.executable, [
87
89
  '--setting-sources', 'project',
88
90
  '--allowedTools', 'Bash,Agent',
89
91
  '-p', rig.prompt,
@@ -106,6 +108,20 @@ export async function probeContract({
106
108
  } catch (error) {
107
109
  if (error.code !== 'ENOENT') malformed = true;
108
110
  }
111
+ // Only a host that completed the run can be asked who it was; a spawn failure or timeout keeps its own
112
+ // reason below instead of reading as "ran on host unknown".
113
+ const hostCompleted = !malformed && !hostResult?.error && !hostResult?.signal && hostResult?.status === 0;
114
+ let seen = null;
115
+ const transcriptPath = entries.find((entry) => typeof entry?.payload?.transcript_path === 'string')?.payload.transcript_path;
116
+ if (transcriptPath) seen = await transcriptHostVersion(transcriptPath);
117
+ if (hostCompleted && seen !== target.version) {
118
+ return {
119
+ state: 'inconclusive', result: null, version: target.version,
120
+ message: `The probe ran on host ${seen ?? 'unknown'}, not on ${target.version}; nothing was recorded.`,
121
+ recorded: false,
122
+ dispatcher: Object.fromEntries(DISPATCHER_CONTRACTS.map((name) => [name, { result: 'inconclusive', detail: 'Probe host version did not match target.' }])),
123
+ };
124
+ }
109
125
  const verdict = judgeProbe({
110
126
  markerExists: await exists(rig.markerPath),
111
127
  hookFired: await exists(rig.journalPath),
@@ -118,25 +134,25 @@ export async function probeContract({
118
134
  failOutput: rig.failOutput, agentType: 'probe-agent', promptToken: token,
119
135
  });
120
136
  if (verdict.result == null) dispatcher = Object.fromEntries(Object.keys(dispatcher).map((name) => [name, { result: 'inconclusive', detail: 'Refusal probe did not complete.' }]));
121
- if (verdict.result != null && version != null && !malformed && !hostResult?.error && !hostResult?.signal && hostResult?.status === 0) {
122
- await writeDispatcherContract({ stateDir: brandStateDir(host.brandRoot), version, verdicts: dispatcher, now });
137
+ if (verdict.result != null && !malformed && !hostResult?.error && !hostResult?.signal && hostResult?.status === 0) {
138
+ await writeDispatcherContract({ stateDir: brandStateDir(host.brandRoot), version: target.version, verdicts: dispatcher, now });
123
139
  }
124
140
  if (verdict.result == null) {
125
141
  return {
126
142
  state: 'inconclusive',
127
143
  result: null,
128
- version,
144
+ version: target.version,
129
145
  message: verdict.reason,
130
146
  recorded: false,
131
147
  dispatcher: Object.fromEntries(Object.entries(dispatcher).map(([name, item]) => [name, { result: 'inconclusive', detail: 'Host did not complete the probe.' }])),
132
148
  };
133
149
  }
134
150
 
135
- await writeHostContract(host, { version, result: verdict.result, now });
151
+ await writeHostContract(host, { version: target.version, result: verdict.result, now });
136
152
  return {
137
153
  state: 'probed',
138
154
  result: verdict.result,
139
- version,
155
+ version: target.version,
140
156
  message: verdict.reason,
141
157
  recorded: true,
142
158
  dispatcher,
@@ -0,0 +1,33 @@
1
+ /** Resolves the session host after the 2026-09-25 PATH host identity incident. */
2
+ import { spawnSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import { detectHostVersion } from './host-contract.mjs';
5
+ import { readHostObservations } from '../src/home/lib/host-observations.mjs';
6
+
7
+ export function resolveProbeTarget({ stateDir, executable, env = process.env, run = spawnSync, exists = fs.existsSync }) {
8
+ if (executable) {
9
+ const version = detectHostVersion({ run, executable });
10
+ return version === null
11
+ ? { error: `Could not read the version of ${executable}.` }
12
+ : { executable, version, source: 'flag' };
13
+ }
14
+
15
+ const observations = readHostObservations({ stateDir });
16
+ const newest = Object.entries(observations.hosts)
17
+ .filter(([, entry]) => Number.isFinite(Date.parse(entry?.lastSeen)))
18
+ .sort((a, b) => Date.parse(b[1].lastSeen) - Date.parse(a[1].lastSeen))[0];
19
+ if (!newest) {
20
+ return { error: 'No Claude Code session has been observed by this installation yet; run any shell command in a session first, or pass --probe-executable <path>.' };
21
+ }
22
+ const [version] = newest;
23
+ const candidates = [];
24
+ if (env.CLAUDE_CODE_EXECPATH && exists(env.CLAUDE_CODE_EXECPATH)) candidates.push(env.CLAUDE_CODE_EXECPATH);
25
+ candidates.push('claude');
26
+ const checked = [];
27
+ for (const candidate of candidates) {
28
+ const candidateVersion = detectHostVersion({ run, executable: candidate });
29
+ checked.push(`${candidate} (${candidateVersion ?? 'unknown'})`);
30
+ if (candidateVersion === version) return { executable: candidate, version, source: 'observed' };
31
+ }
32
+ return { error: `No executable of the observed host ${version} was found (checked: ${checked.join(', ')}); pass --probe-executable <path>.` };
33
+ }
@@ -0,0 +1,51 @@
1
+ /** Judges every observed session host after the 2026-09-25 PATH host identity incident. */
2
+ import { contractStatus, PROBE_COMMAND } from './host-contract.mjs';
3
+ import { DISPATCHER_CONTRACTS, dispatcherContractStatus } from './dispatcher-contract.mjs';
4
+
5
+ export function sessionHostVersions(observations) {
6
+ return Object.entries(observations?.hosts ?? {})
7
+ .map(([version, entry]) => ({ version, timestamp: Date.parse(entry?.lastSeen) }))
8
+ .filter(({ timestamp }) => Number.isFinite(timestamp))
9
+ .sort((a, b) => b.timestamp - a.timestamp)
10
+ .map(({ version }) => version);
11
+ }
12
+
13
+ export function sessionHostCheck(versions, observations) {
14
+ if (!versions.length) {
15
+ return {
16
+ key: 'sessionHost',
17
+ status: 'warn',
18
+ value: 'No Claude Code session has been observed by this installation yet; host contracts are judged once a session runs a shell command.',
19
+ };
20
+ }
21
+ const newest = versions[0];
22
+ const lastSeen = new Date(observations.hosts[newest].lastSeen).toISOString();
23
+ const others = versions.slice(1);
24
+ return {
25
+ key: 'sessionHost',
26
+ status: 'ok',
27
+ value: `Sessions run on host ${newest} (last seen ${lastSeen})${others.length ? `; also seen: ${others.join(', ')}` : ''}`,
28
+ };
29
+ }
30
+
31
+ export function otherHostCheck({ version, contractRecord, dispatcherRecord }) {
32
+ const refusal = contractStatus({ record: contractRecord, version });
33
+ const dispatchers = dispatcherContractStatus({ record: dispatcherRecord, version });
34
+ const key = `otherHost:${version}`;
35
+ if (refusal.state === 'ignored' || dispatchers.some(({ state }) => state === 'changed')) {
36
+ const changed = dispatchers.filter(({ state }) => state === 'changed').map(({ contract }) => contract);
37
+ return { key, status: 'fail', value: [refusal.state === 'ignored' ? 'refusal contract ignored' : '', ...changed].filter(Boolean).join('; ') };
38
+ }
39
+ if (refusal.state === 'verified' && dispatchers.every(({ state }) => state === 'verified')) {
40
+ return { key, status: 'ok', value: `Host ${version} refusal and dispatcher contracts are verified.` };
41
+ }
42
+ const missing = [];
43
+ if (refusal.state !== 'verified') missing.push('refusal');
44
+ missing.push(...dispatchers.filter(({ state }) => state !== 'verified').map(({ contract }) => contract));
45
+ const probe = `${PROBE_COMMAND} --probe-executable <path to a ${version} executable>`;
46
+ return {
47
+ key,
48
+ status: 'warn',
49
+ value: `Host ${version} contracts not yet measured: ${missing.join(', ')}; run ${probe}.`,
50
+ };
51
+ }
package/cli/uninstall.mjs CHANGED
@@ -42,8 +42,9 @@ function preservationText(host) {
42
42
  return `Run artifacts in ${path.join(host.root, 'codex-runs')} are preserved, and so is what the package `
43
43
  + `wrote into ${host.brandRoot} while working: the run configuration ${host.brandConfigPath}, the conventions `
44
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.';
45
+ + `${brandStateDir(host.brandRoot)} (dispatcher state, guard counters, the handback witness, the host `
46
+ + 'observations, dispatcher contract verdicts, and hook diagnostics that hold the full hook input). Delete them by '
47
+ + 'hand for a complete removal.';
47
48
  }
48
49
 
49
50
  function displayFile(file) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lyupro/codex-bridge",
3
- "version": "0.6.7",
4
- "description": "Claude Code-to-Codex delegation with 7 guards, 1636 tests, zero runtime dependencies, and zero build steps.",
3
+ "version": "0.6.8",
4
+ "description": "Claude Code-to-Codex delegation with 7 guards, 1667 tests, zero runtime dependencies, and zero build steps.",
5
5
  "keywords": [
6
6
  "claude-code",
7
7
  "codex",
@@ -21,6 +21,13 @@ import { spawnSync } from 'node:child_process';
21
21
  import { ISOLATED_ROOTS } from './isolated-roots.mjs';
22
22
  import { readmeText, suiteCountMismatch } from './suite-count.mjs';
23
23
 
24
+ // One pattern only: on 2026-09-25 three files passed as three arguments ran just the first, and a
25
+ // verification reported green for two files it never touched. Refuse before any root is created.
26
+ if (process.argv.length > 3) {
27
+ console.error('run-tests: takes ONE pattern; pass a glob such as "tests/cli/{doctor,install}.test.mjs"');
28
+ process.exit(2);
29
+ }
30
+
24
31
  const created = ISOLATED_ROOTS.map((name) => [
25
32
  name,
26
33
  fs.mkdtempSync(path.join(os.tmpdir(), `codex-bridge-test-${name.toLowerCase()}-`)),
@@ -17,7 +17,8 @@ import {
17
17
  readDispatcherState,
18
18
  updateDispatcherState,
19
19
  } from '../lib/dispatcher-state.mjs';
20
- import { hostSdkVersion, recordHandbackWitness } from '../lib/handback-witness.mjs';
20
+ import { recordHandbackWitness, witnessHostVersion } from '../lib/handback-witness.mjs';
21
+ import { observeSessionHost } from '../lib/host-observations.mjs';
21
22
 
22
23
  function emit(decision, toolInput) {
23
24
  const hookSpecificOutput = {
@@ -43,6 +44,18 @@ async function main() {
43
44
  return;
44
45
  }
45
46
 
47
+ if (payload?.hook_event_name === 'PreToolUse'
48
+ && typeof payload.session_id === 'string' && payload.session_id.length > 0
49
+ && typeof payload.transcript_path === 'string' && payload.transcript_path.length > 0) {
50
+ try {
51
+ await observeSessionHost({
52
+ stateDir: BRAND_STATE_DIR,
53
+ sessionId: payload.session_id,
54
+ transcriptPath: payload.transcript_path,
55
+ });
56
+ } catch {}
57
+ }
58
+
46
59
  if (!payload || !Object.prototype.hasOwnProperty.call(AGENTS, payload.agent_type)
47
60
  || typeof payload.session_id !== 'string' || payload.session_id.length === 0
48
61
  || typeof payload.agent_id !== 'string' || payload.agent_id.length === 0) return;
@@ -87,7 +100,7 @@ async function main() {
87
100
  await recordHandbackWitness({
88
101
  stateDir: BRAND_STATE_DIR,
89
102
  kind: 'seen',
90
- sdkVersion: hostSdkVersion(),
103
+ hostVersion: await witnessHostVersion(payload),
91
104
  });
92
105
  } catch {}
93
106
  try {
@@ -22,6 +22,7 @@
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
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.
25
26
  * Plan_62 makes this hook yield after delivery and audit every transcript tool call against gate receipts.
26
27
  *
27
28
  * Input is JSON on stdin. The fields used here (`agent_type`, `last_assistant_message`)
@@ -37,7 +38,7 @@ import { decideDispatcherStop, transcriptToolUses } from '../lib/dispatcher-stop
37
38
  import { recognizeHostRefusal } from '../lib/host-refusal.mjs';
38
39
  import { readJsonFileSync } from '../lib/json-file.mjs';
39
40
  import { readDispatcherState, updateDispatcherState } from '../lib/dispatcher-state.mjs';
40
- import { hostSdkVersion, recordHandbackWitness } from '../lib/handback-witness.mjs';
41
+ import { recordHandbackWitness, witnessHostVersion } from '../lib/handback-witness.mjs';
41
42
  import { runLiveness } from '../lib/meta/run-liveness.mjs';
42
43
  import { resolveProjectRunsDir } from '../lib/runner/project-dir.mjs';
43
44
  import { runsRoot } from '../lib/runner/runs-root.mjs';
@@ -118,6 +119,25 @@ try {
118
119
  // Diagnostics are a convenience, never a reason to fail the turn.
119
120
  }
120
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
+ }
121
141
  // Only the Codex dispatchers have this contract. Every other agent is none of our
122
142
  // business — an earlier version inferred the agent from transcript text and blocked an
123
143
  // unrelated one, which cost a re-answer for nothing.
@@ -144,7 +164,7 @@ try {
144
164
  await recordHandbackWitness({
145
165
  stateDir: BRAND_STATE_DIR,
146
166
  kind: 'alarm',
147
- sdkVersion: hostSdkVersion(),
167
+ hostVersion: await witnessHostVersion(input),
148
168
  detail: `${input.agent_type} ${input.agent_id}: ${names} outside the dispatcher gate`,
149
169
  });
150
170
  }
@@ -1,27 +1,32 @@
1
- /** Records the 2026-09-17..24 silent SubagentHandback contract break before it can go unnoticed again. */
1
+ /** Records the 2026-09-17..24 handback break; 2026-09-25 proved SDK env is inherited, so identify hosts from transcripts. */
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { writeJsonAtomic } from './atomic-json.mjs';
5
5
  import { parseJsonText } from './json-file.mjs';
6
6
  import { withFileLock } from './file-lock.mjs';
7
+ import { transcriptHostVersion } from './host-version.mjs';
7
8
 
8
9
  export const WITNESS_FILE = 'handback-witness.json';
9
10
 
10
- export function hostSdkVersion(env = process.env) {
11
- const version = env.CLAUDE_AGENT_SDK_VERSION;
12
- if (typeof version !== 'string') return null;
13
- return version.trim() || null;
11
+ export async function witnessHostVersion(payload) {
12
+ return transcriptHostVersion(payload?.transcript_path);
14
13
  }
15
14
 
16
- function isWitness(record) {
17
- const witnessEntry = (entry, alarm = false) => entry && typeof entry === 'object'
18
- && !Array.isArray(entry)
19
- && (entry.sdkVersion === null || typeof entry.sdkVersion === 'string')
20
- && typeof entry.at === 'string'
21
- && (!alarm || typeof entry.detail === 'string');
22
- return record && typeof record === 'object' && !Array.isArray(record)
23
- && (record.lastSeen === null || witnessEntry(record.lastSeen))
24
- && Array.isArray(record.alarms) && record.alarms.every((entry) => witnessEntry(entry, true));
15
+ function normalizeWitness(record) {
16
+ const alarmEntry = (entry) => entry && typeof entry === 'object' && !Array.isArray(entry)
17
+ && (entry.hostVersion === null || typeof entry.hostVersion === 'string')
18
+ && typeof entry.at === 'string' && typeof entry.detail === 'string';
19
+ if (!record || typeof record !== 'object' || Array.isArray(record) || !Array.isArray(record.alarms)) return null;
20
+ if (record.alarms.every(alarmEntry) && record.lastSeen && typeof record.lastSeen === 'object'
21
+ && !Array.isArray(record.lastSeen) && !Object.hasOwn(record.lastSeen, 'sdkVersion')
22
+ && !Object.hasOwn(record.lastSeen, 'at') && Object.values(record.lastSeen).every((at) => typeof at === 'string')) return record;
23
+ const legacy = record.lastSeen === null || (record.lastSeen && typeof record.lastSeen === 'object'
24
+ && !Array.isArray(record.lastSeen) && typeof record.lastSeen.sdkVersion === 'string' && typeof record.lastSeen.at === 'string');
25
+ const legacyAlarm = (entry) => entry && typeof entry === 'object' && !Array.isArray(entry)
26
+ && (entry.sdkVersion === null || typeof entry.sdkVersion === 'string') && typeof entry.at === 'string'
27
+ && typeof entry.detail === 'string';
28
+ if (legacy && record.alarms.every(legacyAlarm)) return { lastSeen: {}, alarms: record.alarms.map(({ at, detail }) => ({ hostVersion: null, at, detail })) };
29
+ return null;
25
30
  }
26
31
 
27
32
  export function readHandbackWitness({ stateDir }) {
@@ -30,12 +35,12 @@ export function readHandbackWitness({ stateDir }) {
30
35
  try {
31
36
  source = fs.readFileSync(file, 'utf8');
32
37
  } catch (error) {
33
- if (error.code === 'ENOENT') return { lastSeen: null, alarms: [] };
38
+ if (error.code === 'ENOENT') return { lastSeen: {}, alarms: [] };
34
39
  return { corrupt: true };
35
40
  }
36
41
  try {
37
42
  const record = parseJsonText(file, source);
38
- return isWitness(record) ? record : { corrupt: true };
43
+ return normalizeWitness(record) || { corrupt: true };
39
44
  } catch {
40
45
  return { corrupt: true };
41
46
  }
@@ -46,10 +51,10 @@ function timestamp(now) {
46
51
  }
47
52
 
48
53
 
49
- export async function recordHandbackWitness({ stateDir, kind, sdkVersion, detail = '', now = new Date() }) {
54
+ export async function recordHandbackWitness({ stateDir, kind, hostVersion, detail = '', now = new Date() }) {
50
55
  if (kind !== 'seen' && kind !== 'alarm') throw new TypeError("kind must be 'seen' or 'alarm'");
51
- if (sdkVersion !== null && typeof sdkVersion !== 'string') {
52
- throw new TypeError('sdkVersion must be a string or null');
56
+ if (hostVersion !== null && typeof hostVersion !== 'string') {
57
+ throw new TypeError('hostVersion must be a string or null');
53
58
  }
54
59
  const file = path.join(stateDir, WITNESS_FILE);
55
60
  return withFileLock(`${file}.lock`, async () => {
@@ -57,8 +62,8 @@ export async function recordHandbackWitness({ stateDir, kind, sdkVersion, detail
57
62
  if (current.corrupt) throw new Error(`Cannot update corrupt handback witness: ${file}`);
58
63
  const at = timestamp(now);
59
64
  const record = kind === 'seen'
60
- ? { ...current, lastSeen: { sdkVersion, at } }
61
- : { ...current, alarms: [...current.alarms, { sdkVersion, at, detail }].slice(-20) };
65
+ ? { ...current, lastSeen: hostVersion === null ? current.lastSeen : { ...current.lastSeen, [hostVersion]: at } }
66
+ : { ...current, alarms: [...current.alarms, { hostVersion, at, detail }].slice(-20) };
62
67
  writeJsonAtomic(file, record);
63
68
  return record;
64
69
  }, { description: 'handback witness' });
@@ -0,0 +1,57 @@
1
+ /** Records session host versions after the 2026-09-25 PATH host identity incident. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { writeJsonAtomic } from './atomic-json.mjs';
5
+ import { withFileLock } from './file-lock.mjs';
6
+ import { parseJsonText } from './json-file.mjs';
7
+ import { transcriptHostVersion } from './host-version.mjs';
8
+
9
+ export const HOST_OBSERVATIONS_FILE = 'host-observations.json';
10
+
11
+ export function readHostObservations({ stateDir }) {
12
+ const file = path.join(stateDir, HOST_OBSERVATIONS_FILE);
13
+ let raw;
14
+ try {
15
+ raw = fs.readFileSync(file, 'utf8');
16
+ } catch (error) {
17
+ if (error.code === 'ENOENT') return { hosts: {}, sessions: {} };
18
+ return { hosts: {}, sessions: {}, corrupt: true };
19
+ }
20
+ try {
21
+ const record = parseJsonText(file, raw);
22
+ if (!record || typeof record !== 'object' || Array.isArray(record)
23
+ || !record.hosts || typeof record.hosts !== 'object' || Array.isArray(record.hosts)
24
+ || !record.sessions || typeof record.sessions !== 'object' || Array.isArray(record.sessions)) {
25
+ return { hosts: {}, sessions: {}, corrupt: true };
26
+ }
27
+ return { hosts: record.hosts, sessions: record.sessions };
28
+ } catch {
29
+ return { hosts: {}, sessions: {}, corrupt: true };
30
+ }
31
+ }
32
+
33
+ export async function observeSessionHost({
34
+ stateDir, sessionId, transcriptPath, now = new Date(), readVersion = transcriptHostVersion,
35
+ }) {
36
+ const file = path.join(stateDir, HOST_OBSERVATIONS_FILE);
37
+ if (readHostObservations({ stateDir }).sessions[sessionId]) return { recorded: false };
38
+ const version = await readVersion(transcriptPath);
39
+ if (version === null) return { recorded: false };
40
+ return withFileLock(`${file}.lock`, async () => {
41
+ const current = readHostObservations({ stateDir });
42
+ if (current.corrupt) throw new Error(`Cannot update corrupt host observations: ${file}`);
43
+ if (current.sessions[sessionId]) return { recorded: false };
44
+ const at = new Date(now).toISOString();
45
+ const cutoffSessions = Date.parse(at) - 7 * 24 * 60 * 60 * 1000;
46
+ const cutoffHosts = Date.parse(at) - 30 * 24 * 60 * 60 * 1000;
47
+ const sessions = Object.fromEntries(Object.entries(current.sessions)
48
+ .filter(([, entry]) => Date.parse(entry.at) >= cutoffSessions));
49
+ const hosts = Object.fromEntries(Object.entries(current.hosts)
50
+ .filter(([, entry]) => Date.parse(entry.lastSeen) >= cutoffHosts));
51
+ sessions[sessionId] = { version, at };
52
+ const existing = hosts[version];
53
+ hosts[version] = { firstSeen: existing?.firstSeen || at, lastSeen: at };
54
+ writeJsonAtomic(file, { hosts, sessions });
55
+ return { recorded: true };
56
+ }, { description: 'host observations' });
57
+ }
@@ -0,0 +1,27 @@
1
+ /** Reads the host version from the transcript tail after the 2026-09-25 PATH host identity incident. */
2
+ import fs from 'node:fs/promises';
3
+ import { parseJsonText } from './json-file.mjs';
4
+
5
+ export async function transcriptHostVersion(transcriptPath, { tailBytes = 65536 } = {}) {
6
+ try {
7
+ const handle = await fs.open(transcriptPath, 'r');
8
+ try {
9
+ const { size } = await handle.stat();
10
+ const length = Math.min(size, tailBytes);
11
+ const buffer = Buffer.alloc(length);
12
+ await handle.read(buffer, 0, length, size - length);
13
+ const lines = buffer.toString('utf8').split(/\r?\n/);
14
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
15
+ try {
16
+ const entry = parseJsonText(transcriptPath, lines[index]);
17
+ if (typeof entry?.version === 'string' && /^\d+\.\d+\.\d+/.test(entry.version)) {
18
+ return entry.version;
19
+ }
20
+ } catch {}
21
+ }
22
+ } finally {
23
+ await handle.close();
24
+ }
25
+ } catch {}
26
+ return null;
27
+ }