@lyupro/codex-bridge 0.4.0 → 0.5.1

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/cli/manifest.mjs CHANGED
@@ -1,38 +1,52 @@
1
- /** Defines install mappings and validates the persisted installation record. */
1
+ /** Defines the package install mappings and renders files before they are copied. */
2
2
  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
6
  import { HOOK_DEFINITIONS } from '../src/hook-definitions.mjs';
9
7
  import {
10
8
  renderRequiredInputSummary,
11
9
  renderRequiredInputs,
12
10
  } from '../src/required-inputs.mjs';
13
11
  import { renderNoSelfExecution } from '../src/no-self-execution.mjs';
12
+ import { renderStopSummary } from '../src/stop-contract.mjs';
14
13
  import { readJsonFile } from '../src/json-file.mjs';
15
-
16
- export { HOOK_DEFINITIONS };
17
-
18
- /**
19
- * Finds the definition a recorded hook belongs to. The event alone does not identify one: three of
20
- * the four hooks are PreToolUse, so `find(d => d.event === hook.event)` returns the order gate for
21
- * all three. `doctor` was fixed for exactly this and kept its own inline matching; `uninstall` was
22
- * not, and its dry-run announced matcher `Agent|Task` for the worktree lock and the prune guard —
23
- * the operator's only view of what a removal is about to touch. One function, so a third caller
24
- * cannot repeat it.
25
- */
26
- export function definitionForRecordedHook(hook) {
27
- return HOOK_DEFINITIONS.find((definition) => definition.event === hook?.event
28
- && path.basename(String(hook?.path ?? '')) === definition.file);
29
- }
14
+ import {
15
+ INSTALL_RECORD_NAME,
16
+ RULES_NAME,
17
+ definitionForRecordedHook,
18
+ fileEntry,
19
+ installRecordPath,
20
+ legacyInstallRecordPath,
21
+ normalizeInstallRecord,
22
+ readInstallRecord,
23
+ recordFileKey,
24
+ recordMatchesPackage,
25
+ recordTarget,
26
+ validateInstallRecord,
27
+ writeInstallRecord,
28
+ } from './install-record.mjs';
29
+
30
+ export {
31
+ HOOK_DEFINITIONS,
32
+ INSTALL_RECORD_NAME,
33
+ RULES_NAME,
34
+ definitionForRecordedHook,
35
+ installRecordPath,
36
+ legacyInstallRecordPath,
37
+ normalizeInstallRecord,
38
+ readInstallRecord,
39
+ recordFileKey,
40
+ recordMatchesPackage,
41
+ recordTarget,
42
+ validateInstallRecord,
43
+ writeInstallRecord,
44
+ };
30
45
 
31
46
  const HERE = path.dirname(fileURLToPath(import.meta.url));
32
47
  export const PACKAGE_ROOT = path.resolve(HERE, '..');
33
- export const INSTALL_RECORD_NAME = '.codex-bridge-install.json';
34
- export const RULES_NAME = 'codex-bridge.rules';
35
48
  const RULES_SOURCE = `src/rules/${RULES_NAME}`;
49
+
36
50
  /**
37
51
  * Files the operator owns once they exist. Seeded on first install so the defaults are
38
52
  * visible and editable, then never written, never compared, never removed: run-config.json
@@ -41,25 +55,29 @@ const RULES_SOURCE = `src/rules/${RULES_NAME}`;
41
55
  * update — the same way overwriting a .env would.
42
56
  */
43
57
  export const SEEDED_SOURCES = Object.freeze(['src/run-config.json', 'src/conventions.md']);
44
- const seededTargets = (host) =>
45
- new Set(SEEDED_SOURCES.map((source) => posix(
46
- path.relative(host.root, path.join(host.agentsDir, path.relative('src', source))),
47
- )));
58
+
48
59
  export const INSTALL_TABLE = Object.freeze([
49
- { source: 'src/agents/*.md', target: 'agentsDir', processing: 'placeholders' },
50
- { source: 'src/commands/*.md', target: 'commandsDir', processing: 'placeholders' },
51
- { source: 'src/**', target: 'agentsDir', processing: 'copy' },
60
+ { source: 'src/agents/*.md', root: 'claude', target: 'agentsDir', processing: 'placeholders' },
61
+ { source: 'src/commands/*.md', root: 'claude', target: 'commandsDir', processing: 'placeholders' },
62
+ { source: 'src/hooks/**', root: 'brand', target: 'brandHooksDir', processing: 'copy' },
63
+ { source: 'src/**', root: 'brand', target: 'brandRunnerDir', processing: 'copy' },
64
+ // The runner reads its own version out of the package.json beside it, so meta.json can say
65
+ // which code wrote a run (Plan_34). In the clone that file sits one level above src/; without
66
+ // this line the installed layout has nothing one level above lib/, and every run on a real host
67
+ // would die on import while the whole suite stayed green against the clone.
68
+ { source: 'package.json', root: 'brand', target: 'brandRoot', processing: 'copy' },
52
69
  ]);
53
70
 
54
71
  const posix = (value) => value.split(path.sep).join('/');
55
72
 
56
- export function replacePlaceholders(content, agentsDir) {
73
+ export function replacePlaceholders(content, installationRoot) {
57
74
  const agentType = content.match(/^name:\s*([^\r\n]+)$/m)?.[1].trim();
58
75
  return content
59
- .replaceAll('{{CODEX_BRIDGE_DIR}}', posix(path.resolve(agentsDir)))
76
+ .replaceAll('{{CODEX_BRIDGE_DIR}}', posix(path.resolve(installationRoot)))
60
77
  .replaceAll('{{CODEX_REQUIRED_INPUTS_SUMMARY}}', renderRequiredInputSummary(agentType))
61
78
  .replaceAll('{{CODEX_REQUIRED_INPUTS}}', renderRequiredInputs(agentType))
62
- .replaceAll('{{CODEX_NO_SELF_EXECUTION}}', renderNoSelfExecution());
79
+ .replaceAll('{{CODEX_NO_SELF_EXECUTION}}', renderNoSelfExecution())
80
+ .replaceAll('{{CODEX_STOP_SUMMARY}}', renderStopSummary());
63
81
  }
64
82
 
65
83
  export async function packageInfo(packageRoot = PACKAGE_ROOT) {
@@ -67,21 +85,24 @@ export async function packageInfo(packageRoot = PACKAGE_ROOT) {
67
85
  return { name: parsed.name, version: parsed.version };
68
86
  }
69
87
 
70
- export function installRecordPath(host) {
71
- return path.join(host.agentsDir, INSTALL_RECORD_NAME);
72
- }
73
-
74
88
  export function installedHookPath(host, definition) {
75
- return path.join(host.agentsDir, 'hooks', definition.file);
89
+ return path.join(host.brandHooksDir, definition.file);
76
90
  }
77
91
 
78
92
  /** Where each seeded file comes from and where it goes; contents are copied verbatim. */
79
93
  export function seedPlan(host, packageRoot = PACKAGE_ROOT) {
80
- return SEEDED_SOURCES.map((source) => ({
81
- source: path.join(packageRoot, source),
82
- target: path.join(host.agentsDir, path.relative('src', source)),
83
- processing: 'copy',
84
- }));
94
+ return SEEDED_SOURCES.map((source) => {
95
+ const target = source.endsWith('run-config.json')
96
+ ? host.brandConfigPath
97
+ : host.brandConventionsPath;
98
+ return {
99
+ source: path.join(packageRoot, source),
100
+ target,
101
+ root: 'brand',
102
+ relativeToRoot: posix(path.relative(host.brandRoot, target)),
103
+ processing: 'copy',
104
+ };
105
+ });
85
106
  }
86
107
 
87
108
  export function rulesPlan(host, packageRoot = PACKAGE_ROOT) {
@@ -101,137 +122,24 @@ export async function fileFingerprint(absolutePath) {
101
122
  }
102
123
  }
103
124
 
104
- /**
105
- * Whether the recorded installation is exactly what this package would install right now. Install
106
- * and update both decide "nothing to do" by this question, so it is answered in one place: two
107
- * copies of the condition would drift the first time a field is added to the record.
108
- */
109
- export function recordMatchesPackage(record, plan, currentPackage, fingerprints, ruleState) {
110
- return Boolean(record)
111
- && record.name === currentPackage.name
112
- && record.version === currentPackage.version
113
- && record.files.length === plan.length
114
- && record.files.every((file, index) => file === plan[index].relativeToHost)
115
- && Boolean(record.fingerprints)
116
- && plan.every((item) => record.fingerprints[item.relativeToHost] === fingerprints.get(item.relativeToHost))
117
- && (!ruleState || (record.rules?.path === ruleState.path
118
- && record.rules.fingerprint === ruleState.fingerprint));
119
- }
120
-
121
- export function validateInstallRecord(record) {
122
- if (!record || typeof record !== 'object' || Array.isArray(record)) {
123
- throw new Error('installation record must be an object');
124
- }
125
- const strings = ['name', 'version', 'installedAt', 'mode'];
126
- for (const key of strings) {
127
- if (typeof record[key] !== 'string' || !record[key]) throw new Error(`installation record has invalid ${key}`);
128
- }
129
- if (record.mode !== 'copy') throw new Error('installation record mode must be copy');
130
- if (Number.isNaN(Date.parse(record.installedAt))) throw new Error('installation record installedAt is invalid');
131
- if (!Array.isArray(record.files) || !record.files.length || record.files.some((file) => typeof file !== 'string' || !file)) {
132
- throw new Error('installation record files must be a non-empty list of strings');
133
- }
134
- if (record.files.some((file) => path.isAbsolute(file) || file.split(/[\\/]/).some((part) => part === '..' || part === '.'))) {
135
- throw new Error('installation record files must stay relative to the host root');
136
- }
137
- if (new Set(record.files).size !== record.files.length) {
138
- throw new Error('installation record files must not contain duplicates');
139
- }
140
- // Run artifacts are the user's data, not ours. Refusing them here means neither uninstall nor
141
- // update needs its own guard against deleting a run folder someone listed as an installed file.
142
- if (record.files.some((file) => file === 'codex-runs' || file.split(/[\\/]/)[0] === 'codex-runs')) {
143
- throw new Error('installation record files must not name run artifacts under codex-runs');
144
- }
145
- if (record.fingerprints !== undefined) {
146
- if (!record.fingerprints || typeof record.fingerprints !== 'object' || Array.isArray(record.fingerprints)) {
147
- throw new Error('installation record fingerprints must be an object');
148
- }
149
- const fingerprintKeys = Object.keys(record.fingerprints);
150
- if (fingerprintKeys.length !== record.files.length || fingerprintKeys.some((file) => !record.files.includes(file))) {
151
- throw new Error('installation record fingerprints keys must exactly match files');
152
- }
153
- if (Object.values(record.fingerprints).some((fingerprint) =>
154
- typeof fingerprint !== 'string' || !/^[a-f0-9]{64}$/i.test(fingerprint))) {
155
- throw new Error('installation record fingerprints must be 64-character hexadecimal SHA256 strings');
156
- }
157
- }
158
- if (record.rules !== undefined) {
159
- if (!record.rules || typeof record.rules !== 'object' || Array.isArray(record.rules)) {
160
- throw new Error('installation record rules must be an object');
161
- }
162
- if (typeof record.rules.path !== 'string' || !record.rules.path) {
163
- throw new Error('installation record rules path must be a non-empty string');
164
- }
165
- if (path.basename(record.rules.path) !== RULES_NAME) {
166
- throw new Error(`installation record rules path must name ${RULES_NAME}`);
167
- }
168
- if (typeof record.rules.fingerprint !== 'string'
169
- || !/^[a-f0-9]{64}$/i.test(record.rules.fingerprint)) {
170
- throw new Error('installation record rules fingerprint must be a 64-character hexadecimal SHA256 string');
171
- }
172
- }
173
- if (record.hooks !== undefined && record.hook !== undefined) {
174
- throw new Error('installation record must use hooks instead of hook');
175
- }
176
- const hooks = record.hooks !== undefined ? record.hooks : record.hook !== undefined ? [record.hook] : null;
177
- if (!Array.isArray(hooks) || !hooks.length) {
178
- throw new Error('installation record hooks must be a non-empty list');
179
- }
180
- for (const hook of hooks) {
181
- if (!hook || typeof hook !== 'object' || Array.isArray(hook)) {
182
- throw new Error('installation record hook must be an object');
183
- }
184
- const definitions = HOOK_DEFINITIONS.filter((entry) => entry.event === hook.event);
185
- if (!definitions.length || typeof hook.path !== 'string' || !hook.path) {
186
- throw new Error('installation record hook must identify a supported event and its path');
187
- }
188
- if (path.isAbsolute(hook.path) || hook.path.split(/[\\/]/).includes('..')) {
189
- throw new Error('installation record hook path must stay relative to the host root');
190
- }
191
- const definition = definitions.find((entry) => path.basename(hook.path) === entry.file);
192
- if (!definition || !record.files.includes(hook.path)) {
193
- const files = definitions.map((entry) => entry.file).join(' or ');
194
- throw new Error(`installation record hook path must name the installed ${files}`);
195
- }
196
- }
197
- return record;
125
+ function targetBase(host, mapping) {
126
+ const base = host[mapping.target];
127
+ if (!base) throw new Error(`host has no ${mapping.target} install target`);
128
+ return base;
198
129
  }
199
130
 
200
- export function normalizeInstallRecord(record) {
201
- validateInstallRecord(record);
202
- if (record.hooks !== undefined) return record;
203
- const { hook, ...withoutLegacyHook } = record;
204
- return { ...withoutLegacyHook, hooks: [hook] };
131
+ function rootFor(host, mapping) {
132
+ return mapping.root === 'brand' ? host.brandRoot : host.root;
205
133
  }
206
134
 
207
- export async function readInstallRecord(host) {
208
- let parsed;
209
- try {
210
- parsed = await readJsonFile(installRecordPath(host));
211
- } catch (err) {
212
- if (err.code === 'ENOENT') return null;
213
- if (err.cause) {
214
- throw new Error(`invalid installation record JSON: ${err.cause.message}`, { cause: err.cause });
215
- }
216
- throw err;
135
+ function targetRelative(packageRoot, source, mapping) {
136
+ if (mapping.processing === 'placeholders' || mapping.source === 'package.json') {
137
+ return path.basename(source);
217
138
  }
218
- const record = normalizeInstallRecord(parsed);
219
- // Earlier versions installed the config as a package file and recorded it. Dropping it here
220
- // is what stops the next update from calling the operator's edited config an orphan and
221
- // deleting it, and it needs no migration step: the record is rewritten on the next write.
222
- const seeded = seededTargets(host);
223
- record.files = record.files.filter((file) => !seeded.has(file));
224
- if (record.fingerprints) {
225
- record.fingerprints = Object.fromEntries(
226
- Object.entries(record.fingerprints).filter(([file]) => !seeded.has(file)),
227
- );
228
- }
229
- return record;
230
- }
231
-
232
- export async function writeInstallRecord(host, record) {
233
- const normalized = normalizeInstallRecord(record);
234
- await fs.writeFile(installRecordPath(host), `${JSON.stringify(normalized, null, 2)}\n`);
139
+ const sourceRoot = mapping.source === 'src/hooks/**'
140
+ ? path.join(packageRoot, 'src', 'hooks')
141
+ : path.join(packageRoot, 'src');
142
+ return path.relative(sourceRoot, source);
235
143
  }
236
144
 
237
145
  export async function buildInstallPlan(host, packageRoot = PACKAGE_ROOT) {
@@ -239,22 +147,29 @@ export async function buildInstallPlan(host, packageRoot = PACKAGE_ROOT) {
239
147
  const claimedSources = [];
240
148
  for (const mapping of INSTALL_TABLE) {
241
149
  for await (const relative of fs.glob(mapping.source, { cwd: packageRoot, exclude: claimedSources })) {
242
- if (posix(relative) === RULES_SOURCE) continue;
243
- if (SEEDED_SOURCES.includes(posix(relative))) continue;
150
+ if (posix(relative) === RULES_SOURCE || SEEDED_SOURCES.includes(posix(relative))) continue;
244
151
  const source = path.join(packageRoot, relative);
245
152
  if (!(await fs.stat(source)).isFile()) continue;
246
- const targetRelative = mapping.processing === 'copy'
247
- ? path.relative(path.join(packageRoot, 'src'), source)
248
- : path.basename(source);
249
- const target = path.join(host[mapping.target], targetRelative);
153
+ const relativeToTarget = posix(targetRelative(packageRoot, source, mapping));
154
+ const target = path.join(targetBase(host, mapping), relativeToTarget);
155
+ const relativeToRoot = posix(path.relative(rootFor(host, mapping), target));
250
156
  files.push({
251
157
  source,
252
158
  target,
159
+ root: mapping.root,
160
+ relativeToRoot,
253
161
  relativeToHost: posix(path.relative(host.root, target)),
254
162
  processing: mapping.processing,
163
+ installationRoot: mapping.processing === 'placeholders'
164
+ ? host.brandRunnerDir
165
+ : rootFor(host, mapping),
255
166
  });
256
167
  }
257
168
  claimedSources.push(mapping.source);
258
169
  }
259
- return files.sort((a, b) => a.relativeToHost.localeCompare(b.relativeToHost));
170
+ return files.sort((a, b) => recordFileKey(a).localeCompare(recordFileKey(b)));
171
+ }
172
+
173
+ export function targetForPlanItem(host, item) {
174
+ return recordTarget(host, fileEntry({ root: item.root, path: item.relativeToRoot }));
260
175
  }
package/cli/projects.mjs CHANGED
@@ -1,6 +1,9 @@
1
1
  /** Implements the projects command. */
2
2
  import { listProjectRuns, listProjects } from './runs-inventory.mjs';
3
3
  import { renderTable } from './table.mjs';
4
+ import { allLiveRuns } from '../src/hooks/live-runs.mjs';
5
+ import { runsRoot } from '../src/runner/runs-root.mjs';
6
+ import { STOP_COMMAND_TEMPLATE } from '../src/stop-contract.mjs';
4
7
 
5
8
  // The table renders whatever it is given; how a byte count or a timestamp reads is this command's
6
9
  // business. Raw bytes and ISO milliseconds are technically exact and practically unreadable — an
@@ -75,9 +78,21 @@ function parseArgs(args) {
75
78
  return { json, projectName: positional[0] || null };
76
79
  }
77
80
 
78
- function output(rows, columns, options) {
81
+ /**
82
+ * Silence when nothing is running: this line exists to interrupt the operator who believes every
83
+ * run is stopped, and a "0 runs" line printed under every listing is the fastest way to teach them
84
+ * not to read it.
85
+ */
86
+ function workingRunsLine(root) {
87
+ const count = allLiveRuns(root, { requireConfirmedIdentity: true }).length;
88
+ if (!count) return '';
89
+ const noun = count === 1 ? 'run' : 'runs';
90
+ return `\n${count} ${noun} working right now; stop with ${STOP_COMMAND_TEMPLATE}`;
91
+ }
92
+
93
+ function output(rows, columns, options, root) {
79
94
  if (options.json) return JSON.stringify(rows, null, 2);
80
- return renderTable(columns, rows, options.terminalWidth);
95
+ return `${renderTable(columns, rows, options.terminalWidth)}${workingRunsLine(root)}`;
81
96
  }
82
97
 
83
98
  /** Lists projects or runs for one project. */
@@ -86,7 +101,7 @@ export function projects(argv = [], options = {}) {
86
101
  if (parsed.error) return { exitCode: 2, output: parsed.error };
87
102
 
88
103
  const { projectName } = parsed;
89
- const root = options.runsRootPath;
104
+ const root = options.runsRootPath || runsRoot();
90
105
  const renderOptions = { json: parsed.json, terminalWidth: options.terminalWidth };
91
106
 
92
107
  if (projectName) {
@@ -97,9 +112,9 @@ export function projects(argv = [], options = {}) {
97
112
  output: `codex-bridge projects: unknown project "${projectName}"`,
98
113
  };
99
114
  }
100
- return { exitCode: 0, output: output(rows, RUN_COLUMNS, renderOptions) };
115
+ return { exitCode: 0, output: output(rows, RUN_COLUMNS, renderOptions, root) };
101
116
  }
102
117
 
103
118
  const rows = listProjects(root);
104
- return { exitCode: 0, output: output(rows, PROJECT_COLUMNS, renderOptions) };
119
+ return { exitCode: 0, output: output(rows, PROJECT_COLUMNS, renderOptions, root) };
105
120
  }
@@ -0,0 +1,59 @@
1
+ /** Removes emptied directories of an installed layout without walking out of the package's own. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * These four helpers existed as two copies — one in uninstall.mjs, one in update.mjs — and the
7
+ * copies had already drifted before this file was written: update's removeEmptyParents inlined the
8
+ * readdir/rmdir pair instead of calling removeEmpty, and only uninstall's removeEmptyLayout
9
+ * guarded a missing directory. Nothing failed, which is exactly the problem: the same divergence
10
+ * between the installer's file list and the hook's list went unnoticed until Plan_19 had to
11
+ * reconcile them. Removal logic decides what disappears from the operator's ~/.claude, so it gets
12
+ * one definition.
13
+ */
14
+
15
+ export async function removeEmpty(directory) {
16
+ try {
17
+ if ((await fs.readdir(directory)).length === 0) await fs.rmdir(directory);
18
+ } catch (err) {
19
+ if (err.code !== 'ENOENT') throw err;
20
+ }
21
+ }
22
+
23
+ export async function removeEmptyParents(target, boundary) {
24
+ let current = path.dirname(target);
25
+ while (current !== boundary && current.startsWith(`${boundary}${path.sep}`)) {
26
+ await removeEmpty(current);
27
+ current = path.dirname(current);
28
+ }
29
+ }
30
+
31
+ /**
32
+ * The directory an emptied-parent walk must stop at. Plan_25 gave the host four package-owned
33
+ * directories at once — the current agents and commands subdirectories and the two the previous
34
+ * layout used — and a walk that started inside one of them but stopped at host.root would delete
35
+ * the operator's own emptied directories on the way up.
36
+ */
37
+ export function claudeBoundary(host, target) {
38
+ const directories = [
39
+ host.agentsDir,
40
+ host.commandsDir,
41
+ host.legacyAgentsDir,
42
+ host.legacyCommandsDir,
43
+ ].filter(Boolean);
44
+ return directories.find((directory) => target === directory
45
+ || target.startsWith(`${directory}${path.sep}`)) || host.root;
46
+ }
47
+
48
+ /**
49
+ * Takes down one emptied package-owned layout directory — and stops there.
50
+ *
51
+ * It deliberately does not walk into the parent: the parents here are `~/.claude/agents` and
52
+ * `~/.claude/commands`, which belong to Claude Code and are shared with every other agent the
53
+ * operator has. An operator whose only agents were ours would have had those directories deleted
54
+ * out from under Claude Code by an uninstall that was asked to remove our files. Directories
55
+ * *inside* our own are a different case and are still walked, bounded by claudeBoundary().
56
+ */
57
+ export async function removeEmptyLayout(directory) {
58
+ await removeEmpty(directory);
59
+ }
@@ -99,19 +99,41 @@ async function dropStaleLock(lockPath) {
99
99
  }
100
100
  }
101
101
 
102
+ /**
103
+ * Windows answers a taken lock with three different codes, and only one of them is EEXIST. A file
104
+ * whose last handle closed while a delete was pending stays visible but refuses to be opened:
105
+ * `open(..., 'wx')` comes back EPERM, and a file another process still holds comes back EBUSY.
106
+ * Treating those as fatal is what made `tests/cli/rules-owners.test.mjs` fail twice on 2026-08-11
107
+ * with EPERM on the lock file — the suite went red over a lock that was simply busy for one more
108
+ * millisecond. All three mean the same thing to a caller waiting for a lock: not yours yet.
109
+ */
110
+ const LOCK_TAKEN_CODES = new Set(['EEXIST', 'EPERM', 'EBUSY']);
111
+
112
+ /** Exported so the retry contract is asserted directly; the race itself reproduces only by luck. */
113
+ export function isLockTaken(err) {
114
+ return LOCK_TAKEN_CODES.has(err?.code);
115
+ }
116
+
102
117
  async function acquireRegistryLock(host) {
103
118
  await fs.mkdir(host.codexRulesDir, { recursive: true });
104
119
  const lockPath = registryLockPath(host);
120
+ let lastTaken;
105
121
  for (let attempt = 0; attempt < REGISTRY_LOCK_RETRIES; attempt += 1) {
106
122
  try {
107
123
  const handle = await fs.open(lockPath, 'wx');
108
124
  return { handle, lockPath };
109
125
  } catch (err) {
110
- if (err.code !== 'EEXIST') throw err;
126
+ if (!isLockTaken(err)) throw err;
127
+ lastTaken = err;
111
128
  if (!(await dropStaleLock(lockPath))) await waitForRegistryLock();
112
129
  }
113
130
  }
114
- throw new Error(`timed out waiting for rules ownership registry lock: ${lockPath}`);
131
+ // The code is part of the message: a timeout on EPERM points at a delete that never completed,
132
+ // a timeout on EEXIST at an owner that never released.
133
+ throw new Error(
134
+ `timed out waiting for rules ownership registry lock: ${lockPath} (last attempt: ${lastTaken.code})`,
135
+ { cause: lastTaken },
136
+ );
115
137
  }
116
138
 
117
139
  async function withRegistryLock(host, action) {
@@ -1,5 +1,7 @@
1
1
  /** Merges and removes named codex-bridge hooks without disturbing host settings. */
2
2
  import { AsyncLocalStorage } from 'node:async_hooks';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { existsSync, statSync } from 'node:fs';
3
5
  import fs from 'node:fs/promises';
4
6
  import path from 'node:path';
5
7
  import { randomUUID } from 'node:crypto';
@@ -12,6 +14,74 @@ export function commandFor(guardPath) {
12
14
  return `node "${path.resolve(guardPath)}"`;
13
15
  }
14
16
 
17
+ export function shortCommandFor(name) {
18
+ return `codex-bridge hook ${name}`;
19
+ }
20
+
21
+ function pathValue(env) {
22
+ const key = Object.keys(env).find((entry) => entry.toLowerCase() === 'path');
23
+ return key ? env[key] : '';
24
+ }
25
+
26
+ export function commandReachable(name = 'codex-bridge', env = process.env) {
27
+ const extensions = process.platform === 'win32'
28
+ ? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';')
29
+ : [''];
30
+ return pathValue(env).split(path.delimiter).filter(Boolean).some((directory) =>
31
+ extensions.some((extension) => {
32
+ const candidate = path.join(directory, `${name}${extension}`);
33
+ try {
34
+ return existsSync(candidate) && statSync(candidate).isFile();
35
+ } catch {
36
+ return false;
37
+ }
38
+ }));
39
+ }
40
+
41
+ /**
42
+ * The version the command on PATH reports, or null when there is none or it will not say.
43
+ *
44
+ * Existence used to be the only question, and on 2026-08-11 that cost the operator every guard on
45
+ * the machine: an install from the clone wrote `codex-bridge hook <name>` while the command on
46
+ * PATH was the previous release, which has no `hook` subcommand. Every guard then failed before it
47
+ * could decide anything, and the host refused Bash, PowerShell, file edits and agent launches.
48
+ */
49
+ export function reachableCommandVersion(name = 'codex-bridge', env = process.env) {
50
+ if (!commandReachable(name, env)) return null;
51
+ const result = spawnSync(name, ['--version'], {
52
+ encoding: 'utf8',
53
+ env,
54
+ // The launcher npm writes on Windows is a .cmd shim, which spawn cannot execute without a
55
+ // shell. The name is a constant of this module, never operator input.
56
+ shell: process.platform === 'win32',
57
+ });
58
+ if (result.error || result.status !== 0) return null;
59
+ return String(result.stdout || '').trim().split(/\r?\n/).pop()?.trim() || null;
60
+ }
61
+
62
+ export function hookRegistration(name, target, env = process.env, packageVersion = null) {
63
+ // Requiring the versions to match is stricter than asking whether `hook` exists, deliberately:
64
+ // a command of another version would run code this install has never seen, which is the hazard
65
+ // README describes when a global install and a clone coexist. Anything short of a match falls
66
+ // back to the copy this install placed itself.
67
+ const reachable = reachableCommandVersion('codex-bridge', env);
68
+ if (reachable && packageVersion && reachable === packageVersion) {
69
+ return {
70
+ command: shortCommandFor(name),
71
+ form: 'short',
72
+ reason: `codex-bridge ${reachable} on PATH matches this package; the global command will execute`,
73
+ };
74
+ }
75
+ const reason = reachable
76
+ ? `codex-bridge on PATH reports ${reachable}, not ${packageVersion}; the installed copy will execute`
77
+ : 'codex-bridge is not reachable from PATH; the installed copy will execute';
78
+ return { command: commandFor(target), form: 'path', reason };
79
+ }
80
+
81
+ export function commandForm(command) {
82
+ return command.startsWith('codex-bridge hook ') ? 'short' : 'path';
83
+ }
84
+
15
85
  function normalizeSpec(specOrPath) {
16
86
  if (typeof specOrPath === 'string') {
17
87
  return { ...LEGACY_SPEC, command: commandFor(specOrPath) };
@@ -26,6 +96,9 @@ function normalizeSpec(specOrPath) {
26
96
  event: specOrPath.event,
27
97
  matcher: specOrPath.matcher,
28
98
  command: specOrPath.command,
99
+ alternateCommands: Array.isArray(specOrPath.alternateCommands)
100
+ ? specOrPath.alternateCommands.filter((command) => typeof command === 'string')
101
+ : [],
29
102
  };
30
103
  }
31
104
 
@@ -55,8 +128,10 @@ function groupHooks(group) {
55
128
  return Array.isArray(group?.hooks) ? group.hooks : [];
56
129
  }
57
130
 
58
- const hasOwnCommand = (group, command) =>
59
- groupHooks(group).some((hook) => hook?.type === 'command' && hook.command === command);
131
+ const hasOwnCommand = (group, commands) => {
132
+ const expected = Array.isArray(commands) ? commands : [commands];
133
+ return groupHooks(group).some((hook) => hook?.type === 'command' && expected.includes(hook.command));
134
+ };
60
135
 
61
136
  /**
62
137
  * Presence is decided by the command, not by the matcher it currently sits under.
@@ -71,9 +146,17 @@ const hasOwnCommand = (group, command) =>
71
146
  export async function inspectHook(settingsPath, specOrPath) {
72
147
  const state = await readSettings(settingsPath);
73
148
  const spec = normalizeSpec(specOrPath);
74
- const present = groups(state.settings, spec.event)
75
- .some((group) => hasOwnCommand(group, spec.command));
76
- return { ...state, ...spec, present };
149
+ const commands = [spec.command, ...spec.alternateCommands];
150
+ let matchedCommand;
151
+ for (const group of groups(state.settings, spec.event)) {
152
+ const hook = groupHooks(group).find((entry) =>
153
+ entry?.type === 'command' && commands.includes(entry.command));
154
+ if (hook) {
155
+ matchedCommand = hook.command;
156
+ break;
157
+ }
158
+ }
159
+ return { ...state, ...spec, present: Boolean(matchedCommand), matchedCommand };
77
160
  }
78
161
 
79
162
  function backupName(settingsPath) {
@@ -173,10 +256,11 @@ export async function removeHook(settingsPath, specOrPath, { createdGroup = fals
173
256
  // sits in another group.
174
257
  for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
175
258
  const group = eventGroups[index];
176
- if (!hasOwnCommand(group, spec.command)) continue;
259
+ const commands = [spec.command, ...spec.alternateCommands];
260
+ if (!hasOwnCommand(group, commands)) continue;
177
261
  if (!Array.isArray(group?.hooks)) continue;
178
262
  group.hooks = group.hooks.filter((hook) =>
179
- !(hook?.type === 'command' && hook.command === spec.command));
263
+ !(hook?.type === 'command' && commands.includes(hook.command)));
180
264
  if (createdGroup && group.hooks.length === 0) eventGroups.splice(index, 1);
181
265
  }
182
266
  await atomicWrite(settingsPath, settings, state);