@lyupro/codex-bridge 0.1.1 → 0.1.3

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,27 @@ 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.3] - 2026-08-05
8
+
9
+ ### Added
10
+
11
+ - 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.
12
+ - `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.
13
+
14
+ ### Changed
15
+
16
+ - 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.
17
+ - 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.
18
+ - 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.
19
+
20
+ ## [0.1.2] - 2026-08-04
21
+
22
+ ### Fixed
23
+
24
+ - A damaged ownership registry no longer breaks the command that reads it. Every command parses the registry before its first write, `doctor` reports the damage as a failed check while still printing the other eight, and `uninstall` finishes removing the package but keeps the shared rules file, because ownership is unknown and disarming another installation is worse than leaving a file behind. Until this release an `uninstall` on a damaged registry stopped after the hook was gone, leaving the host with every package file and no reply guard.
25
+ - Owner updates are written through a temporary file under a lock, so two installers running at once merge instead of overwriting each other. The lock expires: an interrupted run used to leave it behind, and every later install and uninstall then died on it until someone deleted the file by hand.
26
+ - Installation claims its share of the rules file before writing anything. Claiming it last meant a failure at that step left a fully installed host missing from the registry, and the next uninstall elsewhere would delete the rules out from under it.
27
+
7
28
  ## [0.1.1] - 2026-08-04
8
29
 
9
30
  ### 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,30 @@ 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
+ const recorded = record.hooks.find((hook) => hook.event === definition.event);
77
+ if (!recorded) {
78
+ return check(key, 'warn', `${definition.event} hook is not present in the installation record`);
79
+ }
80
+ const expected = path.resolve(host.root, recorded.path);
81
+ try {
82
+ const state = await inspectHook(host.settingsPath, {
83
+ event: definition.event,
84
+ matcher: definition.matcher,
85
+ command: commandFor(expected),
86
+ });
87
+ if (state.present) {
88
+ return check(key, 'ok', `${definition.event} matcher ${definition.matcher} -> ${expected}`);
89
+ }
90
+ return check(key, 'warn', `${definition.event} matcher ${definition.matcher} does not point to the installed ${definition.file}`);
91
+ } catch (err) {
92
+ const value = err.code === 'ENOENT' ? 'settings.json is absent' : `settings.json is invalid: ${err.message}`;
93
+ return check(key, 'warn', `${definition.event} hook: ${value}`);
94
+ }
95
+ }));
99
96
  }
100
97
 
101
98
  async function rulesCheck(host, record) {
@@ -103,7 +100,13 @@ async function rulesCheck(host, record) {
103
100
  if (!record.rules) {
104
101
  return check('rules', 'warn', 'rules were not installed by this installation; install or update will add them');
105
102
  }
106
- const registry = await readRulesRegistry(host);
103
+ let registry;
104
+ try {
105
+ registry = await readRulesRegistry(host);
106
+ } catch (err) {
107
+ // Keep every diagnostic visible: package removal on a broken registry left the host without its watchdog.
108
+ return check('rules', 'fail', err.message);
109
+ }
107
110
  const ownerNote = registry?.owners.length > 1 ? `; ${registry.owners.length} owners` : '';
108
111
  const fingerprint = await fileFingerprint(record.rules.path);
109
112
  if (!fingerprint) return check('rules', 'fail', `${record.rules.path}${ownerNote ? ` (${registry.owners.length} owners)` : ''}`);
@@ -149,7 +152,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
149
152
  ));
150
153
  const rules = await rulesCheck(host, record);
151
154
  checks.push(rules);
152
- checks.push(await hookCheck(host, record));
155
+ checks.push(...await hookChecks(host, record));
153
156
 
154
157
  const codex = codexProbe();
155
158
  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,8 +14,8 @@ 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';
16
- import { addRulesOwner } from './rules-owners.mjs';
17
+ import { commandFor, inspectHook, mergeHook } from './settings-merge.mjs';
18
+ import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
17
19
 
18
20
  async function targetExists(target) {
19
21
  try {
@@ -25,15 +27,39 @@ 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 } = {}) {
49
+ // Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
50
+ await readRulesRegistry(host);
29
51
  const plan = await buildInstallPlan(host, packageRoot);
30
52
  const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
31
53
  const currentPackage = await packageInfo(packageRoot);
32
54
  const record = await readInstallRecord(host);
33
- const inspectedHook = await inspectHook(
34
- host.settingsPath,
35
- path.join(host.agentsDir, 'hooks', 'reply-guard.mjs'),
36
- );
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)));
37
63
  const states = await Promise.all(plan.map(async (item) => ({
38
64
  item,
39
65
  exists: await targetExists(item.target),
@@ -66,7 +92,8 @@ export async function install({ host, dryRun = false, force = false, packageRoot
66
92
  const sameRecord = recordMatchesPackage(record, plan, currentPackage,
67
93
  new Map(states.map((state) => [state.item.relativeToHost, state.fingerprint])),
68
94
  { path: rule.target, fingerprint: ruleState.fingerprint });
69
- if (!changedFiles.length && !changedRule && inspectedHook.present && sameRecord) {
95
+ if (!changedFiles.length && !changedRule && inspectedHooks.every((state) => state.present)
96
+ && recordHasHooks(record, targets) && sameRecord) {
70
97
  if (!dryRun) await addRulesOwner(host);
71
98
  return { exitCode: 0, output: 'codex-bridge is already installed; nothing to do.' };
72
99
  }
@@ -77,11 +104,20 @@ export async function install({ host, dryRun = false, force = false, packageRoot
77
104
  if (changedRule) {
78
105
  lines.push(`${ruleState.exists ? 'Would overwrite' : 'Would create'} ${rule.target}`);
79
106
  }
80
- 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
+ });
81
113
  lines.push('Would write installation record.');
82
114
  return { exitCode: 0, output: lines.join('\n') };
83
115
  }
84
116
 
117
+ // Claim ownership of the shared rules file before writing anything. Claiming it last meant a
118
+ // failure at that step left a fully installed host absent from the registry, and the next
119
+ // uninstall elsewhere would then delete the rules out from under it.
120
+ await addRulesOwner(host);
85
121
  for (const { item } of changedFiles) await copyPlannedFile(item, host.agentsDir);
86
122
  if (changedRule) await copyPlannedFile(rule, host.agentsDir);
87
123
  // Seeded files are written once and then belong to the operator: an existing one is left
@@ -89,15 +125,16 @@ export async function install({ host, dryRun = false, force = false, packageRoot
89
125
  for (const seed of seedPlan(host, packageRoot)) {
90
126
  if (!(await targetExists(seed.target))) await copyPlannedFile(seed, host.agentsDir);
91
127
  }
92
- const hookResult = await mergeHook(
93
- host.settingsPath,
94
- path.join(host.agentsDir, 'hooks', 'reply-guard.mjs'),
95
- inspectedHook,
96
- );
97
- const hookRelative = plan.find((item) => path.basename(item.target) === 'reply-guard.mjs')?.relativeToHost;
98
- 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));
99
130
  const fingerprints = Object.fromEntries(await Promise.all(plan.map(async (item) =>
100
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
+ });
101
138
  await writeInstallRecord(host, {
102
139
  ...currentPackage,
103
140
  installedAt: new Date().toISOString(),
@@ -105,8 +142,10 @@ export async function install({ host, dryRun = false, force = false, packageRoot
105
142
  files: plan.map((item) => item.relativeToHost),
106
143
  fingerprints,
107
144
  rules: { path: rule.target, fingerprint: await fileFingerprint(rule.target) },
108
- hook: { event: 'SubagentStop', path: hookRelative, createdGroup: hookResult.createdGroup || record?.hook?.createdGroup || false },
145
+ hooks,
109
146
  });
110
- await addRulesOwner(host);
111
- 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
+ };
112
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,38 @@ 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 definition = HOOK_DEFINITIONS.find((entry) => entry.event === hook.event);
168
+ if (!definition || 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
+ if (path.basename(hook.path) !== definition.file || !record.files.includes(hook.path)) {
175
+ throw new Error(`installation record hook path must name the installed ${definition.file}`);
176
+ }
150
177
  }
151
178
  return record;
152
179
  }
153
180
 
181
+ export function normalizeInstallRecord(record) {
182
+ validateInstallRecord(record);
183
+ if (record.hooks !== undefined) return record;
184
+ const { hook, ...withoutLegacyHook } = record;
185
+ return { ...withoutLegacyHook, hooks: [hook] };
186
+ }
187
+
154
188
  export async function readInstallRecord(host) {
155
189
  let raw;
156
190
  try {
@@ -165,7 +199,7 @@ export async function readInstallRecord(host) {
165
199
  } catch (err) {
166
200
  throw new Error(`invalid installation record JSON: ${err.message}`);
167
201
  }
168
- const record = validateInstallRecord(parsed);
202
+ const record = normalizeInstallRecord(parsed);
169
203
  // Earlier versions installed the config as a package file and recorded it. Dropping it here
170
204
  // is what stops the next update from calling the operator's edited config an orphan and
171
205
  // deleting it, and it needs no migration step: the record is rewritten on the next write.
@@ -180,8 +214,8 @@ export async function readInstallRecord(host) {
180
214
  }
181
215
 
182
216
  export async function writeInstallRecord(host, record) {
183
- validateInstallRecord(record);
184
- await fs.writeFile(installRecordPath(host), `${JSON.stringify(record, null, 2)}\n`);
217
+ const normalized = normalizeInstallRecord(record);
218
+ await fs.writeFile(installRecordPath(host), `${JSON.stringify(normalized, null, 2)}\n`);
185
219
  }
186
220
 
187
221
  export async function buildInstallPlan(host, packageRoot = PACKAGE_ROOT) {
@@ -1,11 +1,16 @@
1
1
  /** Tracks which Claude Code hosts own the shared Codex bridge rules file. */
2
2
  import fs from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
3
4
  import path from 'node:path';
4
5
  import { normalizeRepoPath } from '../src/runner/project-dir.mjs';
5
6
 
6
7
  export const RULES_REGISTRY_NAME = '.codex-bridge-rules.json';
7
8
  export const RULES_REGISTRY_VERSION = 1;
8
9
 
10
+ const REGISTRY_LOCK_RETRIES = 200;
11
+ const REGISTRY_LOCK_DELAY_MS = 5;
12
+ const REGISTRY_LOCK_STALE_MS = 30_000;
13
+
9
14
  export function rulesRegistryPath(host) {
10
15
  return path.join(host.codexRulesDir, RULES_REGISTRY_NAME);
11
16
  }
@@ -59,8 +64,66 @@ function hasSameOwners(raw, registry) {
59
64
  }
60
65
 
61
66
  async function writeRulesRegistry(host, registry) {
67
+ const target = rulesRegistryPath(host);
68
+ const temporary = `${target}.${randomUUID()}.tmp`;
69
+ try {
70
+ await fs.writeFile(temporary, `${JSON.stringify(registry, null, 2)}\n`, { flag: 'wx' });
71
+ await fs.rename(temporary, target);
72
+ } catch (err) {
73
+ await fs.rm(temporary, { force: true }).catch(() => {});
74
+ throw err;
75
+ }
76
+ }
77
+
78
+ function registryLockPath(host) {
79
+ return `${rulesRegistryPath(host)}.lock`;
80
+ }
81
+
82
+ function waitForRegistryLock() {
83
+ return new Promise((resolve) => setTimeout(resolve, REGISTRY_LOCK_DELAY_MS));
84
+ }
85
+
86
+ /**
87
+ * A lock nobody released is worse than no lock: interrupting an install left the file behind and
88
+ * every later install and uninstall died on it until someone deleted it by hand (reproduced
89
+ * 2026-08-04). An owner update takes milliseconds, so a lock older than the staleness window
90
+ * belongs to a process that is gone.
91
+ */
92
+ async function dropStaleLock(lockPath) {
93
+ try {
94
+ const { mtimeMs } = await fs.stat(lockPath);
95
+ if (Date.now() - mtimeMs < REGISTRY_LOCK_STALE_MS) return false;
96
+ await fs.rm(lockPath, { force: true });
97
+ return true;
98
+ } catch {
99
+ // Vanished or unreadable — let the next open() decide rather than guess here.
100
+ return false;
101
+ }
102
+ }
103
+
104
+ async function acquireRegistryLock(host) {
62
105
  await fs.mkdir(host.codexRulesDir, { recursive: true });
63
- await fs.writeFile(rulesRegistryPath(host), `${JSON.stringify(registry, null, 2)}\n`);
106
+ const lockPath = registryLockPath(host);
107
+ for (let attempt = 0; attempt < REGISTRY_LOCK_RETRIES; attempt += 1) {
108
+ try {
109
+ const handle = await fs.open(lockPath, 'wx');
110
+ return { handle, lockPath };
111
+ } catch (err) {
112
+ if (err.code !== 'EEXIST') throw err;
113
+ if (!(await dropStaleLock(lockPath))) await waitForRegistryLock();
114
+ }
115
+ }
116
+ throw new Error(`timed out waiting for rules ownership registry lock: ${lockPath}`);
117
+ }
118
+
119
+ async function withRegistryLock(host, action) {
120
+ const lock = await acquireRegistryLock(host);
121
+ try {
122
+ return await action();
123
+ } finally {
124
+ await lock.handle.close().catch(() => {});
125
+ await fs.rm(lock.lockPath, { force: true }).catch(() => {});
126
+ }
64
127
  }
65
128
 
66
129
  export async function readRulesRegistry(host) {
@@ -68,15 +131,19 @@ export async function readRulesRegistry(host) {
68
131
  }
69
132
 
70
133
  export async function addRulesOwner(host) {
71
- const loaded = await loadRulesRegistry(host);
72
- const owner = normalizedRulesOwner(host);
73
- const current = loaded?.registry || { version: RULES_REGISTRY_VERSION, owners: [] };
74
- const registry = {
75
- version: RULES_REGISTRY_VERSION,
76
- owners: normalizeOwners([...current.owners, owner]),
77
- };
78
- if (!loaded || !hasSameOwners(loaded.raw, registry)) await writeRulesRegistry(host, registry);
79
- return registry;
134
+ await loadRulesRegistry(host);
135
+ return withRegistryLock(host, async () => {
136
+ // Read after locking so a concurrent installer is merged instead of overwritten.
137
+ const loaded = await loadRulesRegistry(host);
138
+ const owner = normalizedRulesOwner(host);
139
+ const current = loaded?.registry || { version: RULES_REGISTRY_VERSION, owners: [] };
140
+ const registry = {
141
+ version: RULES_REGISTRY_VERSION,
142
+ owners: normalizeOwners([...current.owners, owner]),
143
+ };
144
+ if (!loaded || !hasSameOwners(loaded.raw, registry)) await writeRulesRegistry(host, registry);
145
+ return registry;
146
+ });
80
147
  }
81
148
 
82
149
  export function remainingRulesOwners(registry, host) {
@@ -86,16 +153,20 @@ export function remainingRulesOwners(registry, host) {
86
153
  }
87
154
 
88
155
  export async function removeRulesOwner(host) {
89
- const loaded = await loadRulesRegistry(host);
90
- if (!loaded) return null;
91
- const registry = {
92
- version: RULES_REGISTRY_VERSION,
93
- owners: remainingRulesOwners(loaded.registry, host),
94
- };
95
- if (registry.owners.length) {
96
- if (!hasSameOwners(loaded.raw, registry)) await writeRulesRegistry(host, registry);
97
- } else {
98
- await fs.rm(rulesRegistryPath(host), { force: true });
99
- }
100
- return registry;
156
+ const initial = await loadRulesRegistry(host);
157
+ if (!initial) return null;
158
+ return withRegistryLock(host, async () => {
159
+ const loaded = await loadRulesRegistry(host);
160
+ if (!loaded) return null;
161
+ const registry = {
162
+ version: RULES_REGISTRY_VERSION,
163
+ owners: remainingRulesOwners(loaded.registry, host),
164
+ };
165
+ if (registry.owners.length) {
166
+ if (!hasSameOwners(loaded.raw, registry)) await writeRulesRegistry(host, registry);
167
+ } else {
168
+ await fs.rm(rulesRegistryPath(host), { force: true });
169
+ }
170
+ return registry;
171
+ });
101
172
  }
@@ -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,14 @@ function groupHooks(group) {
40
57
  return Array.isArray(group?.hooks) ? group.hooks : [];
41
58
  }
42
59
 
43
- export async function inspectHook(settingsPath, guardPath) {
60
+ export async function inspectHook(settingsPath, specOrPath) {
44
61
  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 };
62
+ const spec = normalizeSpec(specOrPath);
63
+ const present = groups(state.settings, spec.event)
64
+ .filter((group) => group?.matcher === spec.matcher)
65
+ .some((group) => groupHooks(group)
66
+ .some((hook) => hook?.type === 'command' && hook.command === spec.command));
67
+ return { ...state, ...spec, present };
49
68
  }
50
69
 
51
70
  function backupName(settingsPath) {
@@ -66,35 +85,38 @@ async function atomicWrite(settingsPath, settings, previous) {
66
85
  }
67
86
  }
68
87
 
69
- export async function mergeHook(settingsPath, guardPath, inspected) {
70
- const state = inspected || await inspectHook(settingsPath, guardPath);
88
+ export async function mergeHook(settingsPath, specOrPath, inspected) {
89
+ const spec = normalizeSpec(specOrPath);
90
+ const state = inspected || await inspectHook(settingsPath, spec);
71
91
  if (state.present) return { changed: false, createdGroup: false };
72
92
  const settings = structuredClone(state.settings);
73
93
  settings.hooks ??= {};
74
- if (!Array.isArray(settings.hooks[EVENT])) settings.hooks[EVENT] = [];
75
- let group = settings.hooks[EVENT].find((entry) => entry?.matcher === '*');
94
+ if (!Array.isArray(settings.hooks[spec.event])) settings.hooks[spec.event] = [];
95
+ let group = settings.hooks[spec.event].find((entry) => entry?.matcher === spec.matcher);
76
96
  const createdGroup = !group;
77
97
  if (!group) {
78
- group = { matcher: '*', hooks: [] };
79
- settings.hooks[EVENT].push(group);
98
+ group = { matcher: spec.matcher, hooks: [] };
99
+ settings.hooks[spec.event].push(group);
80
100
  }
81
101
  if (!Array.isArray(group.hooks)) group.hooks = [];
82
- group.hooks.push(ownHook(state.command));
102
+ group.hooks.push(ownHook(spec.command));
83
103
  await atomicWrite(settingsPath, settings, state);
84
104
  return { changed: true, createdGroup };
85
105
  }
86
106
 
87
- export async function removeHook(settingsPath, guardPath, { createdGroup = false } = {}) {
88
- const state = await inspectHook(settingsPath, guardPath);
107
+ export async function removeHook(settingsPath, specOrPath, { createdGroup = false } = {}) {
108
+ const spec = normalizeSpec(specOrPath);
109
+ const state = await inspectHook(settingsPath, spec);
89
110
  if (!state.present) return { changed: false };
90
111
  const settings = structuredClone(state.settings);
91
- const eventGroups = groups(settings);
112
+ const eventGroups = groups(settings, spec.event);
92
113
  for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
93
114
  const group = eventGroups[index];
115
+ if (group?.matcher !== spec.matcher) continue;
94
116
  if (!Array.isArray(group?.hooks)) continue;
95
117
  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);
118
+ !(hook?.type === 'command' && hook.command === spec.command));
119
+ if (createdGroup && group.hooks.length === 0) eventGroups.splice(index, 1);
98
120
  }
99
121
  await atomicWrite(settingsPath, settings, state);
100
122
  return { changed: true };