@lyupro/codex-bridge 0.1.2 → 0.1.4

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,32 @@ 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.1.4] - 2026-08-05
8
+
9
+ ### Added
10
+
11
+ - A continuation has to prove the orchestrator ordered it. `--continue` is refused unless the task text carries a `continue:` grant naming the run being continued and why, and the refusal costs nothing: no run folder is created and Codex is never started. The grant is single-use by construction rather than by a counter — continuing the last run of a chain appends a later one, so the same line stops matching by itself. Until this release a dispatcher could decide on a second pass alone: one did, spent 75 691 tokens of the Codex subscription and created a module the order never asked for.
12
+ - A reply that stays silent about a live `codex-build` run of the same project is blocked. The guard reads the project's runs folder from disk instead of trusting the paths the reply chose to mention, so a second run can no longer be left working in the background while the verdict of the first is handed back. Runs of `codex-scout` and `codex-review` are not counted: they hold a read-only sandbox, and blocking a reply over one would spend the guard's budget on a run that touches nothing.
13
+ - A `PreToolUse` lock refuses file edits inside a repository held by a live `codex-build` run. Knowing that a run is live is not the same as being unable to disturb it — edits made during that window land in the runner's before/after snapshot and fail an honest run for work it never did. An edit made through `Bash` still walks past the lock; parsing a command line is not something a guard can do reliably.
14
+
15
+ ### Fixed
16
+
17
+ - `doctor` names the file each hook was registered for. With two hooks on one event it matched the installation record by event alone and reported the worktree lock's matcher as pointing at `order-gate.mjs` — the one line where an operator can check what is installed, showing the wrong file.
18
+ - Hook presence is decided by this package's own command rather than by the matcher it currently sits under. The matcher is generated from the list of host tool names and grows whenever a spelling is added, so a host installed under the previous matcher would collect a duplicate hook on update and keep a hook pointing at a deleted file after uninstall.
19
+
20
+ ## [0.1.3] - 2026-08-05
21
+
22
+ ### Added
23
+
24
+ - Dispatcher calls are refused before a subagent exists. A `PreToolUse` gate rejects a `codex-*` call whose task text carries no order label — and, for `codex-build`, no scope — or whose value is still a template placeholder. Until this release the missing input was discovered by the runner, after the subagent had been created and paid for out of the Claude subscription.
25
+ - `codex-build` declares the outcome of its own run in a required `outcome` field. A run that could not do the work reports `fail`, and the verdict goes red with the reason even when the worktree is clean and verification is green — the shape that used to answer `OK`, because an empty delta is a legitimate outcome the runner cannot tell apart from a failure. Whether a run owed that declaration is read from the response schema it was handed, so runs recorded before this release keep the verdict of their own day.
26
+
27
+ ### Changed
28
+
29
+ - Every dispatcher's description and prompt lists the inputs it requires, filled in at install time from the same table the gate enforces, and the runner's own refusals name the input, an example value and what to do about it. A caller no longer learns the contract by being refused.
30
+ - A failed `codex-build` reports what it left in the worktree, the way a quota-exhausted run already did. "The work was not done" is not "the tree is clean", and where no snapshot exists the line says so instead of claiming there were no changes.
31
+ - The installation record stores a list of hooks rather than a single hook, so a host can register more than one. Records written by earlier versions are read as a one-element list.
32
+
7
33
  ## [0.1.2] - 2026-08-04
8
34
 
9
35
  ### Fixed
package/README.md CHANGED
@@ -17,7 +17,8 @@ node bin/codex-bridge.mjs stop <run> # close a hanging run by hand, without h
17
17
  ```
18
18
 
19
19
  `install` copies the package into the host's `agents/codex/`, its slash commands into
20
- `commands/codex/`, and registers the stop hook in `settings.json` — **merged**, so hooks that
20
+ `commands/codex/`, and registers both the stop hook and the pre-dispatch order gate in
21
+ `settings.json` — **merged**, so hooks that
21
22
  are already there survive, and the file is backed up before every write. Run it twice and the
22
23
  second run does nothing. `--dry-run` prints the plan and touches nothing.
23
24
 
package/cli/doctor.mjs CHANGED
@@ -2,7 +2,13 @@
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
- import { fileFingerprint, readInstallRecord, packageInfo } from './manifest.mjs';
5
+ import {
6
+ fileFingerprint,
7
+ HOOK_DEFINITIONS,
8
+ readInstallRecord,
9
+ packageInfo,
10
+ } from './manifest.mjs';
11
+ import { commandFor, inspectHook } from './settings-merge.mjs';
6
12
  import { readRulesRegistry } from './rules-owners.mjs';
7
13
  import { runsRoot } from '../src/runner/runs-root.mjs';
8
14
  import { resolveProjectRunsDir } from '../src/runner/project-dir.mjs';
@@ -53,22 +59,6 @@ function projectRunsCheck() {
53
59
  return check('projectRuns', 'ok', `${path.resolve(resolved.dir)} (${note})`);
54
60
  }
55
61
 
56
- function hookCommands(value) {
57
- if (Array.isArray(value)) return value.flatMap(hookCommands);
58
- if (value && typeof value === 'object') {
59
- const own = value.type === 'command' && typeof value.command === 'string' ? [value.command] : [];
60
- return [...own, ...Object.values(value).filter((child) => child !== value.command).flatMap(hookCommands)];
61
- }
62
- return [];
63
- }
64
-
65
- function commandReferences(command, expected) {
66
- const normalizedExpected = path.resolve(expected).split(path.sep).join('/');
67
- const tokens = [...command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)]
68
- .map((match) => match[1] ?? match[2] ?? match[3]);
69
- return tokens.some((token) => path.resolve(token).split(path.sep).join('/') === normalizedExpected);
70
- }
71
-
72
62
  export function probeCodex() {
73
63
  const command = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex';
74
64
  const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'];
@@ -79,23 +69,35 @@ export function probeCodex() {
79
69
  return { available: true, value: (result.stdout || result.stderr).trim() };
80
70
  }
81
71
 
82
- async function hookCheck(host, record) {
83
- if (!record) return check('hook', 'warn', 'cannot check before installation');
84
- let settings;
85
- try {
86
- settings = JSON.parse(await fs.readFile(host.settingsPath, 'utf8'));
87
- } catch (err) {
88
- const value = err.code === 'ENOENT' ? 'settings.json is absent' : `settings.json is invalid: ${err.message}`;
89
- return check('hook', 'warn', value);
90
- }
91
- const registration = settings?.hooks?.SubagentStop;
92
- if (!registration) return check('hook', 'warn', 'SubagentStop is not registered');
93
- const expected = path.resolve(host.root, record.hook.path);
94
- const commands = hookCommands(registration);
95
- const pointsToGuard = commands.some((command) => commandReferences(command, expected));
96
- return pointsToGuard
97
- ? check('hook', 'ok', `SubagentStop -> ${expected}`)
98
- : check('hook', 'warn', 'SubagentStop does not point to the installed reply-guard.mjs');
72
+ async function hookChecks(host, record) {
73
+ return Promise.all(HOOK_DEFINITIONS.map(async (definition) => {
74
+ const key = `hook:${definition.event}`;
75
+ if (!record) return check(key, 'warn', 'cannot check before installation');
76
+ // The file, not just the event: PreToolUse carries two hooks of this package, and matching
77
+ // by event alone reported the worktree lock's matcher as pointing at order-gate.mjs. The
78
+ // hook line is the only place an operator can see WHICH file a matcher was registered for,
79
+ // so a wrong name here is worse than no line at all.
80
+ const recorded = record.hooks.find((hook) => hook.event === definition.event
81
+ && path.basename(hook.path) === definition.file);
82
+ if (!recorded) {
83
+ return check(key, 'warn', `${definition.event} hook is not present in the installation record`);
84
+ }
85
+ const expected = path.resolve(host.root, recorded.path);
86
+ try {
87
+ const state = await inspectHook(host.settingsPath, {
88
+ event: definition.event,
89
+ matcher: definition.matcher,
90
+ command: commandFor(expected),
91
+ });
92
+ if (state.present) {
93
+ return check(key, 'ok', `${definition.event} matcher ${definition.matcher} -> ${expected}`);
94
+ }
95
+ return check(key, 'warn', `${definition.event} matcher ${definition.matcher} does not point to the installed ${definition.file}`);
96
+ } catch (err) {
97
+ const value = err.code === 'ENOENT' ? 'settings.json is absent' : `settings.json is invalid: ${err.message}`;
98
+ return check(key, 'warn', `${definition.event} hook: ${value}`);
99
+ }
100
+ }));
99
101
  }
100
102
 
101
103
  async function rulesCheck(host, record) {
@@ -155,7 +157,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
155
157
  ));
156
158
  const rules = await rulesCheck(host, record);
157
159
  checks.push(rules);
158
- checks.push(await hookCheck(host, record));
160
+ checks.push(...await hookChecks(host, record));
159
161
 
160
162
  const codex = codexProbe();
161
163
  checks.push(check('codex', codex.available ? 'ok' : 'warn', codex.value || 'available'));
package/cli/install.mjs CHANGED
@@ -4,6 +4,8 @@ import path from 'node:path';
4
4
  import {
5
5
  buildInstallPlan,
6
6
  fileFingerprint,
7
+ HOOK_DEFINITIONS,
8
+ installedHookPath,
7
9
  packageInfo,
8
10
  readInstallRecord,
9
11
  recordMatchesPackage,
@@ -12,7 +14,7 @@ import {
12
14
  writeInstallRecord,
13
15
  } from './manifest.mjs';
14
16
  import { copyPlannedFile, targetMatches } from './copy.mjs';
15
- import { inspectHook, mergeHook } from './settings-merge.mjs';
17
+ import { commandFor, inspectHook, mergeHook } from './settings-merge.mjs';
16
18
  import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
17
19
 
18
20
  async function targetExists(target) {
@@ -25,6 +27,24 @@ async function targetExists(target) {
25
27
  }
26
28
  }
27
29
 
30
+ function hookTargets(host) {
31
+ return HOOK_DEFINITIONS.map((definition) => {
32
+ const target = installedHookPath(host, definition);
33
+ return {
34
+ definition,
35
+ target,
36
+ relative: path.relative(host.root, target).split(path.sep).join('/'),
37
+ spec: { event: definition.event, matcher: definition.matcher, command: commandFor(target) },
38
+ };
39
+ });
40
+ }
41
+
42
+ function recordHasHooks(record, targets) {
43
+ return Boolean(record)
44
+ && targets.every(({ definition, relative }) => record.hooks?.some((hook) =>
45
+ hook.event === definition.event && hook.path === relative));
46
+ }
47
+
28
48
  export async function install({ host, dryRun = false, force = false, packageRoot } = {}) {
29
49
  // Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
30
50
  await readRulesRegistry(host);
@@ -32,10 +52,14 @@ export async function install({ host, dryRun = false, force = false, packageRoot
32
52
  const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
33
53
  const currentPackage = await packageInfo(packageRoot);
34
54
  const record = await readInstallRecord(host);
35
- const inspectedHook = await inspectHook(
36
- host.settingsPath,
37
- path.join(host.agentsDir, 'hooks', 'reply-guard.mjs'),
38
- );
55
+ const targets = hookTargets(host);
56
+ for (const target of targets) {
57
+ if (!plan.some((item) => item.relativeToHost === target.relative)) {
58
+ throw new Error(`install plan does not contain hooks/${target.definition.file}`);
59
+ }
60
+ }
61
+ const inspectedHooks = await Promise.all(targets.map(({ spec }) =>
62
+ inspectHook(host.settingsPath, spec)));
39
63
  const states = await Promise.all(plan.map(async (item) => ({
40
64
  item,
41
65
  exists: await targetExists(item.target),
@@ -68,7 +92,8 @@ export async function install({ host, dryRun = false, force = false, packageRoot
68
92
  const sameRecord = recordMatchesPackage(record, plan, currentPackage,
69
93
  new Map(states.map((state) => [state.item.relativeToHost, state.fingerprint])),
70
94
  { path: rule.target, fingerprint: ruleState.fingerprint });
71
- if (!changedFiles.length && !changedRule && inspectedHook.present && sameRecord) {
95
+ if (!changedFiles.length && !changedRule && inspectedHooks.every((state) => state.present)
96
+ && recordHasHooks(record, targets) && sameRecord) {
72
97
  if (!dryRun) await addRulesOwner(host);
73
98
  return { exitCode: 0, output: 'codex-bridge is already installed; nothing to do.' };
74
99
  }
@@ -79,7 +104,12 @@ export async function install({ host, dryRun = false, force = false, packageRoot
79
104
  if (changedRule) {
80
105
  lines.push(`${ruleState.exists ? 'Would overwrite' : 'Would create'} ${rule.target}`);
81
106
  }
82
- lines.push(inspectedHook.present ? 'Hook is already registered.' : 'Would register SubagentStop hook.');
107
+ inspectedHooks.forEach((state, index) => {
108
+ const { definition } = targets[index];
109
+ lines.push(state.present
110
+ ? `${definition.event} hook is already registered.`
111
+ : `Would register ${definition.event} hook for matcher ${definition.matcher}.`);
112
+ });
83
113
  lines.push('Would write installation record.');
84
114
  return { exitCode: 0, output: lines.join('\n') };
85
115
  }
@@ -95,15 +125,16 @@ export async function install({ host, dryRun = false, force = false, packageRoot
95
125
  for (const seed of seedPlan(host, packageRoot)) {
96
126
  if (!(await targetExists(seed.target))) await copyPlannedFile(seed, host.agentsDir);
97
127
  }
98
- const hookResult = await mergeHook(
99
- host.settingsPath,
100
- path.join(host.agentsDir, 'hooks', 'reply-guard.mjs'),
101
- inspectedHook,
102
- );
103
- const hookRelative = plan.find((item) => path.basename(item.target) === 'reply-guard.mjs')?.relativeToHost;
104
- if (!hookRelative) throw new Error('install plan does not contain hooks/reply-guard.mjs');
128
+ const hookResults = [];
129
+ for (const { spec } of targets) hookResults.push(await mergeHook(host.settingsPath, spec));
105
130
  const fingerprints = Object.fromEntries(await Promise.all(plan.map(async (item) =>
106
131
  [item.relativeToHost, await fileFingerprint(item.target)])));
132
+ const hooks = targets.map(({ definition, relative }, index) => {
133
+ const prior = record?.hooks?.find((hook) => hook.event === definition.event);
134
+ const createdGroup = hookResults[index].createdGroup || prior?.createdGroup === true;
135
+ return createdGroup ? { event: definition.event, path: relative, createdGroup: true }
136
+ : { event: definition.event, path: relative };
137
+ });
107
138
  await writeInstallRecord(host, {
108
139
  ...currentPackage,
109
140
  installedAt: new Date().toISOString(),
@@ -111,7 +142,10 @@ export async function install({ host, dryRun = false, force = false, packageRoot
111
142
  files: plan.map((item) => item.relativeToHost),
112
143
  fingerprints,
113
144
  rules: { path: rule.target, fingerprint: await fileFingerprint(rule.target) },
114
- hook: { event: 'SubagentStop', path: hookRelative, createdGroup: hookResult.createdGroup || record?.hook?.createdGroup || false },
145
+ hooks,
115
146
  });
116
- return { exitCode: 0, output: `Installed ${plan.length + 1} files and registered the SubagentStop hook.` };
147
+ return {
148
+ exitCode: 0,
149
+ output: `Installed ${plan.length + 1} files and registered the ${targets.map(({ definition }) => definition.event).join(' and ')} hooks.`,
150
+ };
117
151
  }
package/cli/manifest.mjs CHANGED
@@ -3,6 +3,15 @@ import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { createHash } from 'node:crypto';
5
5
  import { fileURLToPath } from 'node:url';
6
+ // The hook table lives under src/ because the installed gate reads it too: cli/ is not copied
7
+ // into a host, so a definition kept here would be invisible to the very hook it describes.
8
+ import { HOOK_DEFINITIONS } from '../src/hook-definitions.mjs';
9
+ import {
10
+ renderRequiredInputSummary,
11
+ renderRequiredInputs,
12
+ } from '../src/required-inputs.mjs';
13
+
14
+ export { HOOK_DEFINITIONS };
6
15
 
7
16
  const HERE = path.dirname(fileURLToPath(import.meta.url));
8
17
  export const PACKAGE_ROOT = path.resolve(HERE, '..');
@@ -29,7 +38,11 @@ export const INSTALL_TABLE = Object.freeze([
29
38
  const posix = (value) => value.split(path.sep).join('/');
30
39
 
31
40
  export function replacePlaceholders(content, agentsDir) {
32
- return content.replaceAll('{{CODEX_BRIDGE_DIR}}', posix(path.resolve(agentsDir)));
41
+ const agentType = content.match(/^name:\s*([^\r\n]+)$/m)?.[1].trim();
42
+ return content
43
+ .replaceAll('{{CODEX_BRIDGE_DIR}}', posix(path.resolve(agentsDir)))
44
+ .replaceAll('{{CODEX_REQUIRED_INPUTS_SUMMARY}}', renderRequiredInputSummary(agentType))
45
+ .replaceAll('{{CODEX_REQUIRED_INPUTS}}', renderRequiredInputs(agentType));
33
46
  }
34
47
 
35
48
  export async function packageInfo(packageRoot = PACKAGE_ROOT) {
@@ -41,6 +54,10 @@ export function installRecordPath(host) {
41
54
  return path.join(host.agentsDir, INSTALL_RECORD_NAME);
42
55
  }
43
56
 
57
+ export function installedHookPath(host, definition) {
58
+ return path.join(host.agentsDir, 'hooks', definition.file);
59
+ }
60
+
44
61
  /** Where each seeded file comes from and where it goes; contents are copied verbatim. */
45
62
  export function seedPlan(host, packageRoot = PACKAGE_ROOT) {
46
63
  return SEEDED_SOURCES.map((source) => ({
@@ -136,21 +153,40 @@ export function validateInstallRecord(record) {
136
153
  throw new Error('installation record rules fingerprint must be a 64-character hexadecimal SHA256 string');
137
154
  }
138
155
  }
139
- if (!record.hook || typeof record.hook !== 'object' || Array.isArray(record.hook)) {
140
- throw new Error('installation record hook must be an object');
156
+ if (record.hooks !== undefined && record.hook !== undefined) {
157
+ throw new Error('installation record must use hooks instead of hook');
141
158
  }
142
- if (record.hook.event !== 'SubagentStop' || typeof record.hook.path !== 'string' || !record.hook.path) {
143
- throw new Error('installation record hook must identify SubagentStop and its path');
159
+ const hooks = record.hooks !== undefined ? record.hooks : record.hook !== undefined ? [record.hook] : null;
160
+ if (!Array.isArray(hooks) || !hooks.length) {
161
+ throw new Error('installation record hooks must be a non-empty list');
144
162
  }
145
- if (path.isAbsolute(record.hook.path) || record.hook.path.split(/[\\/]/).includes('..')) {
146
- throw new Error('installation record hook path must stay relative to the host root');
147
- }
148
- if (path.basename(record.hook.path) !== 'reply-guard.mjs' || !record.files.includes(record.hook.path)) {
149
- throw new Error('installation record hook path must name the installed reply-guard.mjs');
163
+ for (const hook of hooks) {
164
+ if (!hook || typeof hook !== 'object' || Array.isArray(hook)) {
165
+ throw new Error('installation record hook must be an object');
166
+ }
167
+ const definitions = HOOK_DEFINITIONS.filter((entry) => entry.event === hook.event);
168
+ if (!definitions.length || typeof hook.path !== 'string' || !hook.path) {
169
+ throw new Error('installation record hook must identify a supported event and its path');
170
+ }
171
+ if (path.isAbsolute(hook.path) || hook.path.split(/[\\/]/).includes('..')) {
172
+ throw new Error('installation record hook path must stay relative to the host root');
173
+ }
174
+ const definition = definitions.find((entry) => path.basename(hook.path) === entry.file);
175
+ if (!definition || !record.files.includes(hook.path)) {
176
+ const files = definitions.map((entry) => entry.file).join(' or ');
177
+ throw new Error(`installation record hook path must name the installed ${files}`);
178
+ }
150
179
  }
151
180
  return record;
152
181
  }
153
182
 
183
+ export function normalizeInstallRecord(record) {
184
+ validateInstallRecord(record);
185
+ if (record.hooks !== undefined) return record;
186
+ const { hook, ...withoutLegacyHook } = record;
187
+ return { ...withoutLegacyHook, hooks: [hook] };
188
+ }
189
+
154
190
  export async function readInstallRecord(host) {
155
191
  let raw;
156
192
  try {
@@ -165,7 +201,7 @@ export async function readInstallRecord(host) {
165
201
  } catch (err) {
166
202
  throw new Error(`invalid installation record JSON: ${err.message}`);
167
203
  }
168
- const record = validateInstallRecord(parsed);
204
+ const record = normalizeInstallRecord(parsed);
169
205
  // Earlier versions installed the config as a package file and recorded it. Dropping it here
170
206
  // is what stops the next update from calling the operator's edited config an orphan and
171
207
  // deleting it, and it needs no migration step: the record is rewritten on the next write.
@@ -180,8 +216,8 @@ export async function readInstallRecord(host) {
180
216
  }
181
217
 
182
218
  export async function writeInstallRecord(host, record) {
183
- validateInstallRecord(record);
184
- await fs.writeFile(installRecordPath(host), `${JSON.stringify(record, null, 2)}\n`);
219
+ const normalized = normalizeInstallRecord(record);
220
+ await fs.writeFile(installRecordPath(host), `${JSON.stringify(normalized, null, 2)}\n`);
185
221
  }
186
222
 
187
223
  export async function buildInstallPlan(host, packageRoot = PACKAGE_ROOT) {
@@ -1,14 +1,31 @@
1
- /** Merges and removes the codex-bridge SubagentStop hook without disturbing host settings. */
1
+ /** Merges and removes named codex-bridge hooks without disturbing host settings. */
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
 
6
- const EVENT = 'SubagentStop';
6
+ const LEGACY_SPEC = { event: 'SubagentStop', matcher: '*' };
7
7
 
8
- function commandFor(guardPath) {
8
+ export function commandFor(guardPath) {
9
9
  return `node "${path.resolve(guardPath)}"`;
10
10
  }
11
11
 
12
+ function normalizeSpec(specOrPath) {
13
+ if (typeof specOrPath === 'string') {
14
+ return { ...LEGACY_SPEC, command: commandFor(specOrPath) };
15
+ }
16
+ if (!specOrPath || typeof specOrPath !== 'object'
17
+ || typeof specOrPath.event !== 'string'
18
+ || typeof specOrPath.matcher !== 'string'
19
+ || typeof specOrPath.command !== 'string') {
20
+ throw new TypeError('hook spec must contain event, matcher, and command strings');
21
+ }
22
+ return {
23
+ event: specOrPath.event,
24
+ matcher: specOrPath.matcher,
25
+ command: specOrPath.command,
26
+ };
27
+ }
28
+
12
29
  async function readSettings(settingsPath) {
13
30
  try {
14
31
  const raw = await fs.readFile(settingsPath, 'utf8');
@@ -27,8 +44,8 @@ async function readSettings(settingsPath) {
27
44
  }
28
45
  }
29
46
 
30
- function groups(settings) {
31
- const value = settings?.hooks?.[EVENT];
47
+ function groups(settings, event) {
48
+ const value = settings?.hooks?.[event];
32
49
  return Array.isArray(value) ? value : [];
33
50
  }
34
51
 
@@ -40,12 +57,25 @@ function groupHooks(group) {
40
57
  return Array.isArray(group?.hooks) ? group.hooks : [];
41
58
  }
42
59
 
43
- export async function inspectHook(settingsPath, guardPath) {
60
+ const hasOwnCommand = (group, command) =>
61
+ groupHooks(group).some((hook) => hook?.type === 'command' && hook.command === command);
62
+
63
+ /**
64
+ * Presence is decided by the command, not by the matcher it currently sits under.
65
+ *
66
+ * The matcher is generated from the tool list in hook-definitions.mjs, so it changes whenever a
67
+ * host spelling is added — `Agent` became `Agent|Task` in one release, and the write-tool matcher
68
+ * will grow the same way. A host installed under the old matcher would then be invisible to the
69
+ * new lookup: update would register a duplicate and uninstall would leave a hook pointing at a
70
+ * deleted file. The command is this package's own absolute path, so it identifies our entry
71
+ * wherever the group ended up.
72
+ */
73
+ export async function inspectHook(settingsPath, specOrPath) {
44
74
  const state = await readSettings(settingsPath);
45
- const command = commandFor(guardPath);
46
- const present = groups(state.settings).some((group) =>
47
- groupHooks(group).some((hook) => hook?.type === 'command' && hook.command === command));
48
- return { ...state, command, present };
75
+ const spec = normalizeSpec(specOrPath);
76
+ const present = groups(state.settings, spec.event)
77
+ .some((group) => hasOwnCommand(group, spec.command));
78
+ return { ...state, ...spec, present };
49
79
  }
50
80
 
51
81
  function backupName(settingsPath) {
@@ -66,35 +96,41 @@ async function atomicWrite(settingsPath, settings, previous) {
66
96
  }
67
97
  }
68
98
 
69
- export async function mergeHook(settingsPath, guardPath, inspected) {
70
- const state = inspected || await inspectHook(settingsPath, guardPath);
99
+ export async function mergeHook(settingsPath, specOrPath, inspected) {
100
+ const spec = normalizeSpec(specOrPath);
101
+ const state = inspected || await inspectHook(settingsPath, spec);
71
102
  if (state.present) return { changed: false, createdGroup: false };
72
103
  const settings = structuredClone(state.settings);
73
104
  settings.hooks ??= {};
74
- if (!Array.isArray(settings.hooks[EVENT])) settings.hooks[EVENT] = [];
75
- let group = settings.hooks[EVENT].find((entry) => entry?.matcher === '*');
105
+ if (!Array.isArray(settings.hooks[spec.event])) settings.hooks[spec.event] = [];
106
+ let group = settings.hooks[spec.event].find((entry) => entry?.matcher === spec.matcher);
76
107
  const createdGroup = !group;
77
108
  if (!group) {
78
- group = { matcher: '*', hooks: [] };
79
- settings.hooks[EVENT].push(group);
109
+ group = { matcher: spec.matcher, hooks: [] };
110
+ settings.hooks[spec.event].push(group);
80
111
  }
81
112
  if (!Array.isArray(group.hooks)) group.hooks = [];
82
- group.hooks.push(ownHook(state.command));
113
+ group.hooks.push(ownHook(spec.command));
83
114
  await atomicWrite(settingsPath, settings, state);
84
115
  return { changed: true, createdGroup };
85
116
  }
86
117
 
87
- export async function removeHook(settingsPath, guardPath, { createdGroup = false } = {}) {
88
- const state = await inspectHook(settingsPath, guardPath);
118
+ export async function removeHook(settingsPath, specOrPath, { createdGroup = false } = {}) {
119
+ const spec = normalizeSpec(specOrPath);
120
+ const state = await inspectHook(settingsPath, spec);
89
121
  if (!state.present) return { changed: false };
90
122
  const settings = structuredClone(state.settings);
91
- const eventGroups = groups(settings);
123
+ const eventGroups = groups(settings, spec.event);
124
+ // Removal follows the same rule as the lookup above: our command, whatever matcher it is
125
+ // filed under. A foreign hook is left alone because its command is not ours, not because it
126
+ // sits in another group.
92
127
  for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
93
128
  const group = eventGroups[index];
129
+ if (!hasOwnCommand(group, spec.command)) continue;
94
130
  if (!Array.isArray(group?.hooks)) continue;
95
131
  group.hooks = group.hooks.filter((hook) =>
96
- !(hook?.type === 'command' && hook.command === state.command));
97
- if (createdGroup && group.matcher === '*' && group.hooks.length === 0) eventGroups.splice(index, 1);
132
+ !(hook?.type === 'command' && hook.command === spec.command));
133
+ if (createdGroup && group.hooks.length === 0) eventGroups.splice(index, 1);
98
134
  }
99
135
  await atomicWrite(settingsPath, settings, state);
100
136
  return { changed: true };
package/cli/uninstall.mjs CHANGED
@@ -1,8 +1,13 @@
1
1
  /** Uninstalls only recorded codex-bridge files while preserving host data and foreign files. */
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { fileFingerprint, installRecordPath, readInstallRecord } from './manifest.mjs';
5
- import { removeHook } from './settings-merge.mjs';
4
+ import {
5
+ fileFingerprint,
6
+ HOOK_DEFINITIONS,
7
+ installRecordPath,
8
+ readInstallRecord,
9
+ } from './manifest.mjs';
10
+ import { commandFor, removeHook } from './settings-merge.mjs';
6
11
  import { readRulesRegistry, removeRulesOwner, remainingRulesOwners } from './rules-owners.mjs';
7
12
 
8
13
  async function removeEmpty(directory) {
@@ -25,6 +30,10 @@ function remainingOwnersText(count) {
25
30
  return `${count} other owner${count === 1 ? '' : 's'} ${count === 1 ? 'remains' : 'remain'}`;
26
31
  }
27
32
 
33
+ function hookDefinition(event) {
34
+ return HOOK_DEFINITIONS.find((definition) => definition.event === event);
35
+ }
36
+
28
37
  export async function uninstall({ host, dryRun = false } = {}) {
29
38
  // Preflight before removing the hook: package removal on a broken registry left the host without its watchdog.
30
39
  let registry = null;
@@ -61,14 +70,24 @@ export async function uninstall({ host, dryRun = false } = {}) {
61
70
  }
62
71
  }
63
72
  }
64
- lines.push('Would remove the SubagentStop hook and installation record.');
73
+ for (const hook of record.hooks) {
74
+ const definition = hookDefinition(hook.event);
75
+ lines.push(`Would remove the ${hook.event} hook for matcher ${definition.matcher}.`);
76
+ }
77
+ lines.push('Would remove the installation record.');
65
78
  lines.push(preservation);
66
79
  return { exitCode: 0, output: lines.join('\n') };
67
80
  }
68
81
 
69
- await removeHook(host.settingsPath, path.join(host.root, record.hook.path), {
70
- createdGroup: record.hook.createdGroup === true,
71
- });
82
+ for (const hook of record.hooks) {
83
+ const definition = hookDefinition(hook.event);
84
+ const target = path.join(host.root, hook.path);
85
+ await removeHook(host.settingsPath, {
86
+ event: hook.event,
87
+ matcher: definition.matcher,
88
+ command: commandFor(target),
89
+ }, { createdGroup: hook.createdGroup === true });
90
+ }
72
91
  let ownership = null;
73
92
  if (!registryError) {
74
93
  try {
package/cli/update.mjs CHANGED
@@ -5,13 +5,15 @@ import { install } from './install.mjs';
5
5
  import {
6
6
  buildInstallPlan,
7
7
  fileFingerprint,
8
+ HOOK_DEFINITIONS,
9
+ installedHookPath,
8
10
  packageInfo,
9
11
  readInstallRecord,
10
12
  recordMatchesPackage,
11
13
  rulesPlan,
12
14
  } from './manifest.mjs';
13
15
  import { targetMatches } from './copy.mjs';
14
- import { inspectHook } from './settings-merge.mjs';
16
+ import { commandFor, inspectHook } from './settings-merge.mjs';
15
17
  import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
16
18
 
17
19
  async function exists(target) {
@@ -37,6 +39,23 @@ function classifyOrphan(state, record) {
37
39
  return 'modified';
38
40
  }
39
41
 
42
+ function hookTargets(host) {
43
+ return HOOK_DEFINITIONS.map((definition) => {
44
+ const target = installedHookPath(host, definition);
45
+ return {
46
+ definition,
47
+ relative: path.relative(host.root, target).split(path.sep).join('/'),
48
+ spec: { event: definition.event, matcher: definition.matcher, command: commandFor(target) },
49
+ };
50
+ });
51
+ }
52
+
53
+ function recordHasHooks(record, targets) {
54
+ return Boolean(record)
55
+ && targets.every(({ definition, relative }) => record.hooks?.some((hook) =>
56
+ hook.event === definition.event && hook.path === relative));
57
+ }
58
+
40
59
  /**
41
60
  * What the run actually did, counted by outcome. `Updated codex-bridge.` alone told the operator
42
61
  * nothing about the files that were rewritten or removed on their behalf.
@@ -62,7 +81,7 @@ function conflictOutput(conflicts, legacy, dryRun) {
62
81
  return [heading, ...lines].join('\n');
63
82
  }
64
83
 
65
- function dryRunOutput(states, hookPresent, legacy) {
84
+ function dryRunOutput(states, hookStates, legacy) {
66
85
  const lines = [];
67
86
  for (const state of states) {
68
87
  if (state.status === 'outdated') lines.push(`Would update ${state.relative}.`);
@@ -72,7 +91,10 @@ function dryRunOutput(states, hookPresent, legacy) {
72
91
  if (state.status === 'orphaned' && state.exists) lines.push(`Would remove ${state.relative}.`);
73
92
  if (state.status === 'modified' && !state.item) lines.push(`Would preserve modified orphan ${state.relative}.`);
74
93
  }
75
- if (!hookPresent) lines.push('Would register SubagentStop hook.');
94
+ hookStates.forEach(({ state, target }) => {
95
+ const { definition } = target;
96
+ if (!state.present) lines.push(`Would register ${definition.event} hook for matcher ${definition.matcher}.`);
97
+ });
76
98
  lines.push('Would write installation record with new fingerprints.');
77
99
  if (legacy && states.some((state) => state.status === 'modified')) {
78
100
  lines.push('Installation record has no fingerprints; differing files are treated as modified.');
@@ -135,19 +157,23 @@ export async function update({ host, dryRun = false, force = false, packageRoot
135
157
  return { exitCode: 1, output: conflictOutput(conflicts, legacy, dryRun) };
136
158
  }
137
159
 
138
- const guardPath = path.join(host.agentsDir, 'hooks', 'reply-guard.mjs');
139
- const inspectedHook = await inspectHook(host.settingsPath, guardPath);
160
+ const targets = hookTargets(host);
161
+ const inspectedHooks = await Promise.all(targets.map(async (target) => ({
162
+ target,
163
+ state: await inspectHook(host.settingsPath, target.spec),
164
+ })));
140
165
  const currentPackage = await packageInfo(packageRoot);
141
166
  const recordCurrent = recordMatchesPackage(record, plan, currentPackage,
142
167
  new Map(plannedStates.map((state) => [state.relative, state.fingerprint])),
143
168
  { path: rule.target, fingerprint: ruleState.fingerprint });
144
169
  const changed = states.some((state) => state.status !== 'up-to-date');
145
- if (!changed && inspectedHook.present && recordCurrent) {
170
+ if (!changed && inspectedHooks.every(({ state }) => state.present)
171
+ && recordHasHooks(record, targets) && recordCurrent) {
146
172
  if (!dryRun) await addRulesOwner(host);
147
173
  return { exitCode: 0, output: 'codex-bridge is up to date' };
148
174
  }
149
175
  if (dryRun) {
150
- return { exitCode: 0, output: dryRunOutput(states, inspectedHook.present, legacy) };
176
+ return { exitCode: 0, output: dryRunOutput(states, inspectedHooks, legacy) };
151
177
  }
152
178
 
153
179
  for (const state of orphanStates) {