@lyupro/codex-bridge 0.6.5 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +5 -5
  3. package/bin/codex-bridge.mjs +181 -178
  4. package/cli/dispatcher-contract-record.mjs +42 -0
  5. package/cli/dispatcher-contract.mjs +89 -0
  6. package/cli/doctor-hooks.mjs +104 -114
  7. package/cli/doctor-runs.mjs +55 -55
  8. package/cli/doctor.mjs +39 -3
  9. package/cli/handback-witness-check.mjs +39 -0
  10. package/cli/hook-targets.mjs +14 -6
  11. package/cli/hook.mjs +32 -23
  12. package/cli/host-contract.mjs +136 -135
  13. package/cli/install.mjs +9 -14
  14. package/cli/launcher-probe.mjs +38 -0
  15. package/cli/probe-contract.mjs +31 -62
  16. package/cli/probe-rig.mjs +53 -0
  17. package/cli/run-launcher.mjs +17 -0
  18. package/cli/settings-merge.mjs +42 -35
  19. package/cli/uninstall.mjs +15 -3
  20. package/cli/update.mjs +45 -16
  21. package/package.json +4 -4
  22. package/src/agents/codex-advisor.md +3 -4
  23. package/src/agents/codex-build.md +5 -5
  24. package/src/agents/codex-review.md +3 -3
  25. package/src/agents/codex-scout.md +3 -4
  26. package/src/home/hooks/dispatcher-gate.mjs +119 -0
  27. package/src/home/hooks/guard-tries.mjs +5 -6
  28. package/src/home/hooks/live-runs.mjs +1 -0
  29. package/src/home/hooks/order-gate.mjs +20 -5
  30. package/src/home/hooks/reply-guard.mjs +75 -10
  31. package/src/home/hooks/reply-verdicts.mjs +5 -0
  32. package/src/home/hooks/worktree-lock.mjs +108 -107
  33. package/src/home/lib/atomic-json.mjs +21 -0
  34. package/src/home/lib/brand-home.mjs +6 -0
  35. package/src/home/lib/dispatcher-command.mjs +79 -0
  36. package/src/home/lib/dispatcher-gate.mjs +95 -0
  37. package/src/home/lib/dispatcher-order.mjs +30 -8
  38. package/src/home/lib/dispatcher-state.mjs +111 -0
  39. package/src/home/lib/dispatcher-stop.mjs +52 -0
  40. package/src/home/lib/handback-witness.mjs +65 -0
  41. package/src/home/lib/hook-definitions.mjs +10 -0
  42. package/src/home/lib/hook-entry.mjs +20 -0
  43. package/src/home/lib/meta/advice-status.mjs +3 -7
  44. package/src/home/lib/meta/advice-verdict.mjs +37 -21
  45. package/src/home/lib/required-inputs.mjs +1 -1
  46. package/src/home/lib/runner/advise-carry.mjs +50 -0
  47. package/src/home/lib/runner/attach.mjs +11 -7
  48. package/src/home/lib/runner/codex-cmd.mjs +3 -2
  49. package/src/home/lib/runner/continuation.mjs +3 -1
  50. package/src/home/lib/runner/git-state.mjs +1 -1
  51. package/src/home/lib/runner/launcher.mjs +14 -13
  52. package/src/home/lib/runner/preflight.mjs +6 -3
  53. package/src/home/lib/runner/prompts.mjs +9 -8
  54. package/src/home/lib/runner/schemas.mjs +33 -7
@@ -1,135 +1,136 @@
1
- /**
2
- * Records whether the installed Claude Code host still honours hook refusals.
3
- *
4
- * Every guard here refuses work with `permissionDecision: "deny"`, and the sibling key of that same
5
- * object died quietly: a neighbouring package established that `"allow"` stopped being honoured
6
- * somewhere between host 2.1.119 and 2.1.231, with no error printed on the way. Nothing in this
7
- * package would notice `deny` going the same way — the suite checks that a hook RETURNED a refusal,
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.
10
- */
11
- import { spawnSync } from 'node:child_process';
12
- import { randomUUID } from 'node:crypto';
13
- import fs from 'node:fs/promises';
14
- import path from 'node:path';
15
- import { CLI_NAMES } from '../src/home/lib/cli-names.mjs';
16
- import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
17
- import { readJsonFile } from '../src/home/lib/json-file.mjs';
18
-
19
- export const HOST_CONTRACT_RECORD_NAME = '.host-contract.json';
20
-
21
- /**
22
- * The command that runs the probe, kept here so every message gains it in one edit.
23
- *
24
- * It stayed unnamed in the messages until the command existed: a package that sends an operator to
25
- * a command answering `unknown doctor option` has spent its credibility on the first line they
26
- * read. The advice arrived in the same change as the command, and only in the states a probe can
27
- * actually change — a message that advises a run which would tell you nothing new is noise.
28
- *
29
- * Derived rather than spelled out: Plan_17 §5 found installer and hook lists drifting apart once
30
- * the same name lived in two places. `CLI_NAMES[0]` is the canonical spelling.
31
- */
32
- export const PROBE_COMMAND = `${CLI_NAMES[0]} doctor --probe-contract`;
33
-
34
- export function hostContractPath(host) {
35
- if (!host?.brandRoot) throw new Error('host has no brand installation root');
36
- return path.join(host.brandRoot, HOST_CONTRACT_RECORD_NAME);
37
- }
38
-
39
- export async function readHostContract(host) {
40
- const target = hostContractPath(host);
41
- try {
42
- 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
- };
50
- } catch {
51
- return null;
52
- }
53
- }
54
-
55
- export async function writeHostContract(host, { version, result, now = new Date() }) {
56
- if (typeof version !== 'string' || !version) {
57
- throw new Error('host contract version must be a non-empty string');
58
- }
59
- if (result !== 'honored' && result !== 'ignored') {
60
- throw new Error('host contract result must be honored or ignored');
61
- }
62
-
63
- const target = hostContractPath(host);
64
- const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
65
- const record = { version, result, checkedAt: now.toISOString() };
66
- await fs.mkdir(host.brandRoot, { recursive: true });
67
- try {
68
- await fs.writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
69
- await fs.rename(temporary, target);
70
- } finally {
71
- await fs.rm(temporary, { force: true });
72
- }
73
- }
74
-
75
- export function parseHostVersion(text) {
76
- if (typeof text !== 'string') return null;
77
- return text.trim().match(/^(\d+\.\d+\.\d+)(?:\s|$)/)?.[1] ?? null;
78
- }
79
-
80
- export function detectHostVersion({ run = spawnSync } = {}) {
81
- try {
82
- const result = run('claude', ['--version'], {
83
- encoding: 'utf8',
84
- shell: false,
85
- timeout: 5000,
86
- });
87
- if (!result || result.error || result.status !== 0) return null;
88
- return parseHostVersion(result.stdout);
89
- } catch {
90
- return null;
91
- }
92
- }
93
-
94
- export function contractStatus({ record, version }) {
95
- // Loose equality on purpose: a caller that forgot the field hands over `undefined`, and the
96
- // stale branch below would then report "the current host is undefined" as if it had measured it.
97
- if (version == null) {
98
- return {
99
- state: 'unknown-host',
100
- message: 'The host version could not be read, so the refusal contract cannot be judged.',
101
- };
102
- }
103
- if (!record) {
104
- return {
105
- state: 'unverified',
106
- message: `The refusal contract has never been probed on this machine (host ${version}); run ${PROBE_COMMAND}.`,
107
- };
108
- }
109
- if (record.version !== version) {
110
- return {
111
- state: 'stale',
112
- message: `The refusal contract was probed on host ${record.version}, but the current host is ${version}; run ${PROBE_COMMAND}.`,
113
- };
114
- }
115
- if (record.result === 'ignored') {
116
- const guards = HOOK_DEFINITIONS
117
- .filter((definition) => definition.event === 'PreToolUse')
118
- .map((definition) => definition.name)
119
- .join(', ');
120
- return {
121
- state: 'ignored',
122
- message: `DANGER: Host ${version} does not honour hook refusals, so every guard is inert: ${guards}. Re-run ${PROBE_COMMAND} once the host updates.`,
123
- };
124
- }
125
- if (record.result === 'honored') {
126
- return {
127
- state: 'verified',
128
- message: `Host ${version} honours hook refusals (verified ${record.checkedAt}).`,
129
- };
130
- }
131
- return {
132
- state: 'unverified',
133
- message: `The refusal contract record is invalid (host ${version}); run ${PROBE_COMMAND}.`,
134
- };
135
- }
1
+ /**
2
+ * Records whether the installed Claude Code host still honours hook refusals.
3
+ *
4
+ * Every guard here refuses work with `permissionDecision: "deny"`, and the sibling key of that same
5
+ * object died quietly: a neighbouring package established that `"allow"` stopped being honoured
6
+ * somewhere between host 2.1.119 and 2.1.231, with no error printed on the way. Nothing in this
7
+ * package would notice `deny` going the same way — the suite checks that a hook RETURNED a refusal,
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.
10
+ */
11
+ import { spawnSync } from 'node:child_process';
12
+ import { randomUUID } from 'node:crypto';
13
+ import fs from 'node:fs/promises';
14
+ import path from 'node:path';
15
+ import { CLI_NAMES } from '../src/home/lib/cli-names.mjs';
16
+ import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
17
+ import { readJsonFile } from '../src/home/lib/json-file.mjs';
18
+
19
+ export const HOST_CONTRACT_RECORD_NAME = '.host-contract.json';
20
+
21
+ /**
22
+ * The command that runs the probe, kept here so every message gains it in one edit.
23
+ *
24
+ * It stayed unnamed in the messages until the command existed: a package that sends an operator to
25
+ * a command answering `unknown doctor option` has spent its credibility on the first line they
26
+ * read. The advice arrived in the same change as the command, and only in the states a probe can
27
+ * actually change — a message that advises a run which would tell you nothing new is noise.
28
+ *
29
+ * Derived rather than spelled out: Plan_17 §5 found installer and hook lists drifting apart once
30
+ * the same name lived in two places. `CLI_NAMES[0]` is the canonical spelling.
31
+ */
32
+ export const PROBE_COMMAND = `${CLI_NAMES[0]} doctor --probe-contract`;
33
+
34
+ export function hostContractPath(host) {
35
+ if (!host?.brandRoot) throw new Error('host has no brand installation root');
36
+ return path.join(host.brandRoot, HOST_CONTRACT_RECORD_NAME);
37
+ }
38
+
39
+ export async function readHostContract(host) {
40
+ const target = hostContractPath(host);
41
+ try {
42
+ 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
+ };
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ export async function writeHostContract(host, { version, result, now = new Date() }) {
56
+ if (typeof version !== 'string' || !version) {
57
+ throw new Error('host contract version must be a non-empty string');
58
+ }
59
+ if (result !== 'honored' && result !== 'ignored') {
60
+ throw new Error('host contract result must be honored or ignored');
61
+ }
62
+
63
+ const target = hostContractPath(host);
64
+ const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
65
+ const record = { version, result, checkedAt: now.toISOString() };
66
+ await fs.mkdir(host.brandRoot, { recursive: true });
67
+ try {
68
+ await fs.writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
69
+ await fs.rename(temporary, target);
70
+ } finally {
71
+ await fs.rm(temporary, { force: true });
72
+ }
73
+ }
74
+
75
+ export function parseHostVersion(text) {
76
+ if (typeof text !== 'string') return null;
77
+ return text.trim().match(/^(\d+\.\d+\.\d+)(?:\s|$)/)?.[1] ?? null;
78
+ }
79
+
80
+ export function detectHostVersion({ run = spawnSync } = {}) {
81
+ try {
82
+ const result = run('claude', ['--version'], {
83
+ encoding: 'utf8',
84
+ shell: false,
85
+ timeout: 5000,
86
+ windowsHide: true,
87
+ });
88
+ if (!result || result.error || result.status !== 0) return null;
89
+ return parseHostVersion(result.stdout);
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ export function contractStatus({ record, version }) {
96
+ // Loose equality on purpose: a caller that forgot the field hands over `undefined`, and the
97
+ // stale branch below would then report "the current host is undefined" as if it had measured it.
98
+ if (version == null) {
99
+ return {
100
+ state: 'unknown-host',
101
+ message: 'The host version could not be read, so the refusal contract cannot be judged.',
102
+ };
103
+ }
104
+ if (!record) {
105
+ return {
106
+ state: 'unverified',
107
+ message: `The refusal contract has never been probed on this machine (host ${version}); run ${PROBE_COMMAND}.`,
108
+ };
109
+ }
110
+ if (record.version !== version) {
111
+ return {
112
+ state: 'stale',
113
+ message: `The refusal contract was probed on host ${record.version}, but the current host is ${version}; run ${PROBE_COMMAND}.`,
114
+ };
115
+ }
116
+ if (record.result === 'ignored') {
117
+ const guards = HOOK_DEFINITIONS
118
+ .filter((definition) => definition.event === 'PreToolUse')
119
+ .map((definition) => definition.name)
120
+ .join(', ');
121
+ return {
122
+ state: 'ignored',
123
+ message: `DANGER: Host ${version} does not honour hook refusals, so every guard is inert: ${guards}. Re-run ${PROBE_COMMAND} once the host updates.`,
124
+ };
125
+ }
126
+ if (record.result === 'honored') {
127
+ return {
128
+ state: 'verified',
129
+ message: `Host ${version} honours hook refusals (verified ${record.checkedAt}).`,
130
+ };
131
+ }
132
+ return {
133
+ state: 'unverified',
134
+ message: `The refusal contract record is invalid (host ${version}); run ${PROBE_COMMAND}.`,
135
+ };
136
+ }
package/cli/install.mjs CHANGED
@@ -15,11 +15,10 @@ import {
15
15
  writeInstallRecord,
16
16
  } from './manifest.mjs';
17
17
  import { copyPlannedFile, targetMatches } from './copy.mjs';
18
- import { hookTargets } from './hook-targets.mjs';
18
+ import { hookTargets, recordHasHooks } from './hook-targets.mjs';
19
19
  import { fingerprintFor, INSTALL_METHOD_COPY, INSTALL_METHOD_KEY } from './install-record.mjs';
20
20
  import {
21
21
  commandFor,
22
- hookRegistration,
23
22
  inspectHook,
24
23
  mergeHook,
25
24
  withSettingsRun,
@@ -42,12 +41,6 @@ async function targetExists(target) {
42
41
  }
43
42
  }
44
43
 
45
- function recordHasHooks(record, targets) {
46
- return Boolean(record)
47
- && targets.every(({ definition, relative }) => record.hooks?.some((hook) =>
48
- hook.event === definition.event && hook.root === 'brand' && hook.path === relative));
49
- }
50
-
51
44
  function retentionLine(host) {
52
45
  const notice = retentionNotice(readRunConfig(host.brandConfigPath));
53
46
  return notice.enabled ? `${WARNING}${notice.text}${RESET}` : notice.text;
@@ -128,7 +121,7 @@ async function installInRun({
128
121
  const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
129
122
  const currentPackage = await packageInfo(packageRoot);
130
123
  const record = await readInstallRecord(host);
131
- const targets = hookTargets(host, env, currentPackage.version);
124
+ const targets = hookTargets(host, env);
132
125
  for (const target of targets) {
133
126
  if (!plan.some((item) => item.root === 'brand' && item.relativeToRoot === target.relative)) {
134
127
  throw new Error(`install plan does not contain hooks/${target.definition.file}`);
@@ -168,7 +161,7 @@ async function installInRun({
168
161
  const sameRecord = recordMatchesPackage(record, plan, currentPackage,
169
162
  new Map(states.map((state) => [recordFileKey(state.item), state.fingerprint])),
170
163
  { path: rule.target, fingerprint: ruleState.fingerprint });
171
- if (!changedFiles.length && !changedRule && inspectedHooks.every((state) => state.present)
164
+ if (!changedFiles.length && !changedRule && inspectedHooks.every((state) => state.current)
172
165
  && recordHasHooks(record, targets) && sameRecord) {
173
166
  if (!dryRun) {
174
167
  await addRulesOwner(host);
@@ -200,9 +193,11 @@ async function installInRun({
200
193
  }
201
194
  inspectedHooks.forEach((state, index) => {
202
195
  const { definition, registration } = targets[index];
203
- lines.push(state.present
204
- ? `${definition.event} hook is already registered (${registration.form} command).`
205
- : `Would register ${definition.event} hook for matcher ${definition.matcher} with ${registration.form} command.`);
196
+ lines.push(state.present && !state.current
197
+ ? `Would rewrite ${definition.event} hook ${definition.name} for matcher ${definition.matcher} to ${registration.form} command: ${registration.reason}.`
198
+ : state.present
199
+ ? `${definition.event} hook is already registered (${registration.form} command).`
200
+ : `Would register ${definition.event} hook ${definition.name} for matcher ${definition.matcher} with ${registration.form} command.`);
206
201
  });
207
202
  lines.push('Would write installation record in the brand root.');
208
203
  return { exitCode: 0, output: contractOutput(hostContract, retentionOutput(configuredRetentionLine, lines.join('\n'))) };
@@ -242,7 +237,7 @@ async function installInRun({
242
237
  const prior = record?.hooks?.find((hook) => hook.event === definition.event
243
238
  && hook.root === 'brand' && hook.path === relative);
244
239
  const createdGroup = hookResults[index].createdGroup || prior?.createdGroup === true;
245
- const command = inspectedHooks[index].matchedCommand || registration.command;
240
+ const command = registration.command;
246
241
  return {
247
242
  event: definition.event,
248
243
  root: 'brand',
@@ -0,0 +1,38 @@
1
+ /** Proves the PATH launcher dispatches guards from the home being installed. */
2
+ import path from 'node:path';
3
+ import { HOOK_LAUNCHER_PROTOCOL } from './hook.mjs';
4
+ import { runReachableCommand } from './settings-merge.mjs';
5
+ import { parseJsonText } from '../src/home/lib/json-file.mjs';
6
+
7
+ function normalizeRoot(root) {
8
+ let normalized = root.replaceAll('\\', '/').replace(/\/+$/, '');
9
+ if (process.platform === 'win32') normalized = normalized.toLowerCase();
10
+ return normalized;
11
+ }
12
+
13
+ export function probeHookLauncher({ env, brandRoot }) {
14
+ const result = runReachableCommand('codex-bridge', ['hook', '--home'], env);
15
+ if (!result) return { ok: false, reason: 'codex-bridge is not on PATH' };
16
+ if (result.status !== 0) {
17
+ return { ok: false, reason: 'codex-bridge on PATH is not a launcher (hook --home failed)' };
18
+ }
19
+ let answer;
20
+ try {
21
+ answer = parseJsonText('codex-bridge hook --home', result.stdout);
22
+ } catch {
23
+ return { ok: false, reason: 'malformed launcher answer' };
24
+ }
25
+ if (!answer || typeof answer !== 'object' || Array.isArray(answer)) {
26
+ return { ok: false, reason: 'malformed launcher answer' };
27
+ }
28
+ if (answer.protocol !== HOOK_LAUNCHER_PROTOCOL) {
29
+ return { ok: false, reason: `unsupported launcher protocol ${String(answer.protocol)}` };
30
+ }
31
+ if (answer.dispatch !== 'home' || typeof answer.homeRoot !== 'string' || !answer.homeRoot.trim()) {
32
+ return { ok: false, reason: 'malformed launcher answer' };
33
+ }
34
+ if (normalizeRoot(answer.homeRoot) !== normalizeRoot(path.resolve(brandRoot))) {
35
+ return { ok: false, reason: `launcher runs from ${answer.homeRoot}, not ${brandRoot}` };
36
+ }
37
+ return { ok: true, commandPath: result.path, homeRoot: answer.homeRoot };
38
+ }
@@ -8,12 +8,15 @@ import fs from 'node:fs/promises';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
10
  import { detectHostVersion, writeHostContract } from './host-contract.mjs';
11
+ import { buildRig } from './probe-rig.mjs';
12
+ import { DISPATCHER_CONTRACTS, judgeDispatcherContracts } from './dispatcher-contract.mjs';
13
+ import { writeDispatcherContract } from './dispatcher-contract-record.mjs';
14
+ import { brandStateDir } from '../src/home/lib/brand-home.mjs';
15
+ import { parseJsonText } from '../src/home/lib/json-file.mjs';
16
+ import { randomUUID } from 'node:crypto';
11
17
 
12
18
  export const PROBE_MARKER = 'codex-bridge-contract-probe';
13
19
 
14
- const MARKER_NAME = `${PROBE_MARKER}.marker`;
15
- const JOURNAL_NAME = 'probe-journal.log';
16
-
17
20
  const exists = async (target) => {
18
21
  try {
19
22
  await fs.access(target);
@@ -55,63 +58,6 @@ export function judgeProbe({ markerExists, hookFired, hostResult }) {
55
58
  return { result: 'honored', reason: 'The hook fired and the refused marker command did not run.' };
56
59
  }
57
60
 
58
- const hookSource = `#!/usr/bin/env node
59
- import fs from 'node:fs';
60
- import { fileURLToPath } from 'node:url';
61
-
62
- const marker = ${JSON.stringify(PROBE_MARKER)};
63
- const journalPath = fileURLToPath(new URL('../${JOURNAL_NAME}', import.meta.url));
64
- let input;
65
- try {
66
- input = JSON.parse(fs.readFileSync(0, 'utf8'));
67
- } catch {
68
- process.exit(0);
69
- }
70
-
71
- if (typeof input?.tool_input?.command === 'string' && input.tool_input.command.includes(marker)) {
72
- fs.appendFileSync(journalPath, \`${PROBE_MARKER}\\n\`, 'utf8');
73
- process.stdout.write(JSON.stringify({
74
- hookSpecificOutput: {
75
- hookEventName: 'PreToolUse',
76
- permissionDecision: 'deny',
77
- permissionDecisionReason: 'Contract probe refused its marker command.',
78
- },
79
- }));
80
- }
81
- `;
82
-
83
- // Forward slashes on purpose: the host runs this hook command line through a POSIX shell, and a
84
- // Windows path handed over verbatim arrives carrying backslashes that shell may swallow. The
85
- // doubled form this replacement first carried matched nothing at all on a real path.
86
- const shellQuote = (value) => `"${value.replaceAll('\\', '/').replaceAll('"', '\\"')}"`;
87
-
88
- export async function buildRig(dir) {
89
- const claudeDir = path.join(dir, '.claude');
90
- const hooksDir = path.join(claudeDir, 'hooks');
91
- const hookPath = path.join(hooksDir, 'probe-hook.mjs');
92
- const markerPath = path.join(dir, MARKER_NAME);
93
- const journalPath = path.join(claudeDir, JOURNAL_NAME);
94
- const command = `touch ${MARKER_NAME}`;
95
- const prompt = `Run exactly this Bash command and nothing else: ${command}`;
96
- const settings = {
97
- hooks: {
98
- PreToolUse: [{
99
- matcher: 'Bash',
100
- hooks: [{ type: 'command', command: `${shellQuote(process.execPath)} ${shellQuote(hookPath)}` }],
101
- }],
102
- },
103
- };
104
-
105
- await fs.mkdir(hooksDir, { recursive: true });
106
- await fs.writeFile(hookPath, hookSource, 'utf8');
107
- await fs.writeFile(
108
- path.join(claudeDir, 'settings.json'),
109
- `${JSON.stringify(settings, null, 2)}\n`,
110
- 'utf8',
111
- );
112
- return { dir, markerPath, journalPath, command, prompt };
113
- }
114
-
115
61
  export async function probeContract({
116
62
  host,
117
63
  version = detectHostVersion(),
@@ -126,34 +72,55 @@ export async function probeContract({
126
72
  version: null,
127
73
  message: 'The host version could not be read, so no contract probe was run.',
128
74
  recorded: false,
75
+ dispatcher: Object.fromEntries(DISPATCHER_CONTRACTS.map((name) => [name, { result: 'inconclusive', detail: 'Host version is unavailable.' }])),
129
76
  };
130
77
  }
131
78
 
132
79
  await fs.mkdir(rigRoot, { recursive: true });
133
80
  const dir = await fs.mkdtemp(path.join(rigRoot, `${PROBE_MARKER}-`));
134
81
  try {
135
- const rig = await buildRig(dir);
82
+ const token = randomUUID().slice(0, 8);
83
+ const rig = await buildRig(dir, token);
136
84
  let hostResult;
137
85
  try {
138
86
  hostResult = runHost('claude', [
139
87
  '--setting-sources', 'project',
140
- '--allowedTools', 'Bash',
88
+ '--allowedTools', 'Bash,Agent',
141
89
  '-p', rig.prompt,
142
90
  ], {
143
91
  cwd: rig.dir,
144
92
  encoding: 'utf8',
145
93
  shell: false,
146
94
  timeout: 120000,
95
+ windowsHide: true,
147
96
  });
148
97
  } catch (error) {
149
98
  hostResult = { error };
150
99
  }
151
100
 
101
+ let entries = [];
102
+ let malformed = false;
103
+ try {
104
+ const source = await fs.readFile(rig.dispatcherJournalPath, 'utf8');
105
+ entries = source.split(/\r?\n/).filter(Boolean).map((line) => parseJsonText(rig.dispatcherJournalPath, line));
106
+ } catch (error) {
107
+ if (error.code !== 'ENOENT') malformed = true;
108
+ }
152
109
  const verdict = judgeProbe({
153
110
  markerExists: await exists(rig.markerPath),
154
111
  hookFired: await exists(rig.journalPath),
155
112
  hostResult,
156
113
  });
114
+ let dispatcher = judgeDispatcherContracts({
115
+ entries: malformed ? null : entries,
116
+ hostHealthy: !malformed && !hostResult?.error && !hostResult?.signal && hostResult?.status === 0,
117
+ okCommand: rig.okCommand, failCommand: rig.failCommand, okOutput: rig.okOutput,
118
+ failOutput: rig.failOutput, agentType: 'probe-agent', promptToken: token,
119
+ });
120
+ 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 });
123
+ }
157
124
  if (verdict.result == null) {
158
125
  return {
159
126
  state: 'inconclusive',
@@ -161,6 +128,7 @@ export async function probeContract({
161
128
  version,
162
129
  message: verdict.reason,
163
130
  recorded: false,
131
+ dispatcher: Object.fromEntries(Object.entries(dispatcher).map(([name, item]) => [name, { result: 'inconclusive', detail: 'Host did not complete the probe.' }])),
164
132
  };
165
133
  }
166
134
 
@@ -171,6 +139,7 @@ export async function probeContract({
171
139
  version,
172
140
  message: verdict.reason,
173
141
  recorded: true,
142
+ dispatcher,
174
143
  };
175
144
  } finally {
176
145
  // Windows can retain a just-written hook tree briefly; cleanup failure must not erase a valid
@@ -0,0 +1,53 @@
1
+ /** Builds one host run that measures refusal and dispatcher contracts together to avoid charging for separate probes. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { PROBE_MARKER } from './probe-contract.mjs';
6
+
7
+ const JOURNAL_NAME = 'probe-journal.log';
8
+ const shellQuote = (value) => `"${value.replaceAll('\\', '/').replaceAll('"', '\\"')}"`;
9
+
10
+ const hookSource = (refusalCommand) => `#!/usr/bin/env node
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ const base = path.dirname(fileURLToPath(import.meta.url));
15
+ let payload;
16
+ try { payload = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { payload = {}; }
17
+ const event = process.argv[2];
18
+ const entry = { event, payload };
19
+ if (event === 'PreToolUse' && payload?.agent_id && payload?.transcript_path && payload?.session_id) {
20
+ const transcriptPath = path.join(path.dirname(payload.transcript_path), payload.session_id, 'subagents', 'agent-' + payload.agent_id + '.jsonl');
21
+ try { entry.transcript = { path: transcriptPath, firstLine: fs.readFileSync(transcriptPath, 'utf8').split(/\\r?\\n/, 1)[0] }; }
22
+ catch (error) { entry.transcript = { path: transcriptPath, error: error.code || error.message }; }
23
+ }
24
+ try { fs.appendFileSync(path.join(base, '..', 'dispatcher-journal.jsonl'), JSON.stringify(entry) + '\\n', 'utf8'); } catch {}
25
+ const refusal = ${JSON.stringify(PROBE_MARKER)};
26
+ if (event === 'PreToolUse' && payload?.tool_input?.command === ${JSON.stringify(refusalCommand)}) {
27
+ try { fs.appendFileSync(path.join(base, '..', '${JOURNAL_NAME}'), refusal + '\\n', 'utf8'); } catch {}
28
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'Contract probe refused its marker command.' } }));
29
+ }
30
+ `;
31
+
32
+ export async function buildRig(dir, token) {
33
+ const claudeDir = path.join(dir, '.claude');
34
+ const hooksDir = path.join(claudeDir, 'hooks');
35
+ const hookPath = path.join(hooksDir, 'probe-hook.mjs');
36
+ const markerPath = path.join(dir, `${PROBE_MARKER}-${token}.marker`);
37
+ const journalPath = path.join(claudeDir, JOURNAL_NAME);
38
+ const dispatcherJournalPath = path.join(claudeDir, 'dispatcher-journal.jsonl');
39
+ const okCommand = `node -e "console.log('cb-probe-ok-${token}')"`;
40
+ const failCommand = `node -e "console.log('cb-probe-fail-${token}'); console.error('cb-probe-err-${token}'); process.exit(2)"`;
41
+ const refusalCommand = `touch ${PROBE_MARKER}-${token}.marker`;
42
+ const agentPrompt = `probe ${token}: run your two commands`;
43
+ const prompt = `Use the Agent tool with subagent_type "probe-agent" in the foreground and pass it exactly: ${agentPrompt}. Wait for its answer, then run exactly this Bash command and nothing else: ${refusalCommand}`;
44
+ const hook = (event) => ({ matcher: 'Bash', hooks: [{ type: 'command', command: `${shellQuote(process.execPath)} ${shellQuote(hookPath)} ${event}` }] });
45
+ const settings = { hooks: { PreToolUse: [hook('PreToolUse')], PostToolUse: [hook('PostToolUse')], PostToolUseFailure: [hook('PostToolUseFailure')] } };
46
+ const agent = `---\nname: probe-agent\ndescription: Run the dispatcher contract commands.\ntools: Bash\n---\nRun exactly two Bash commands, each as its own call, in the foreground, never retried: first ${okCommand}, then ${failCommand}. Reply with the literal outputs, nothing else.\n`;
47
+ await fs.mkdir(hooksDir, { recursive: true });
48
+ await fs.mkdir(path.join(claudeDir, 'agents'), { recursive: true });
49
+ await fs.writeFile(hookPath, hookSource(refusalCommand), 'utf8');
50
+ await fs.writeFile(path.join(claudeDir, 'agents', 'probe-agent.md'), agent, 'utf8');
51
+ await fs.writeFile(path.join(claudeDir, 'settings.json'), `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
52
+ return { dir, markerPath, journalPath, dispatcherJournalPath, prompt, token, okCommand, failCommand, refusalCommand, okOutput: `cb-probe-ok-${token}`, failOutput: `cb-probe-fail-${token}` };
53
+ }
@@ -0,0 +1,17 @@
1
+ /** Loads the installed runner because Plan_62 D17 makes the brand home the runtime source of truth. */
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { resolveBrandHome } from '../src/home/lib/brand-home.mjs';
7
+
8
+ export async function runCodex(args = [], io = console) {
9
+ const home = resolveBrandHome({ homedir: os.homedir(), env: process.env });
10
+ const entry = path.join(home.root, 'lib', 'run-codex.mjs');
11
+ if (!fs.existsSync(entry)) {
12
+ io.error(`codex-bridge run: codex-bridge is not installed in ${home.root}. Run codex-bridge install.`);
13
+ return 1;
14
+ }
15
+ const { runCodexCommand } = await import(pathToFileURL(entry).href);
16
+ return runCodexCommand(args);
17
+ }