@lyupro/codex-bridge 0.1.1 → 0.1.2

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,14 @@ 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.2] - 2026-08-04
8
+
9
+ ### Fixed
10
+
11
+ - 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.
12
+ - 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.
13
+ - 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.
14
+
7
15
  ## [0.1.1] - 2026-08-04
8
16
 
9
17
  ### Fixed
package/cli/doctor.mjs CHANGED
@@ -103,7 +103,13 @@ async function rulesCheck(host, record) {
103
103
  if (!record.rules) {
104
104
  return check('rules', 'warn', 'rules were not installed by this installation; install or update will add them');
105
105
  }
106
- const registry = await readRulesRegistry(host);
106
+ let registry;
107
+ try {
108
+ registry = await readRulesRegistry(host);
109
+ } catch (err) {
110
+ // Keep every diagnostic visible: package removal on a broken registry left the host without its watchdog.
111
+ return check('rules', 'fail', err.message);
112
+ }
107
113
  const ownerNote = registry?.owners.length > 1 ? `; ${registry.owners.length} owners` : '';
108
114
  const fingerprint = await fileFingerprint(record.rules.path);
109
115
  if (!fingerprint) return check('rules', 'fail', `${record.rules.path}${ownerNote ? ` (${registry.owners.length} owners)` : ''}`);
package/cli/install.mjs CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  } from './manifest.mjs';
14
14
  import { copyPlannedFile, targetMatches } from './copy.mjs';
15
15
  import { inspectHook, mergeHook } from './settings-merge.mjs';
16
- import { addRulesOwner } from './rules-owners.mjs';
16
+ import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
17
17
 
18
18
  async function targetExists(target) {
19
19
  try {
@@ -26,6 +26,8 @@ async function targetExists(target) {
26
26
  }
27
27
 
28
28
  export async function install({ host, dryRun = false, force = false, packageRoot } = {}) {
29
+ // Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
30
+ await readRulesRegistry(host);
29
31
  const plan = await buildInstallPlan(host, packageRoot);
30
32
  const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
31
33
  const currentPackage = await packageInfo(packageRoot);
@@ -82,6 +84,10 @@ export async function install({ host, dryRun = false, force = false, packageRoot
82
84
  return { exitCode: 0, output: lines.join('\n') };
83
85
  }
84
86
 
87
+ // Claim ownership of the shared rules file before writing anything. Claiming it last meant a
88
+ // failure at that step left a fully installed host absent from the registry, and the next
89
+ // uninstall elsewhere would then delete the rules out from under it.
90
+ await addRulesOwner(host);
85
91
  for (const { item } of changedFiles) await copyPlannedFile(item, host.agentsDir);
86
92
  if (changedRule) await copyPlannedFile(rule, host.agentsDir);
87
93
  // Seeded files are written once and then belong to the operator: an existing one is left
@@ -107,6 +113,5 @@ export async function install({ host, dryRun = false, force = false, packageRoot
107
113
  rules: { path: rule.target, fingerprint: await fileFingerprint(rule.target) },
108
114
  hook: { event: 'SubagentStop', path: hookRelative, createdGroup: hookResult.createdGroup || record?.hook?.createdGroup || false },
109
115
  });
110
- await addRulesOwner(host);
111
116
  return { exitCode: 0, output: `Installed ${plan.length + 1} files and registered the SubagentStop hook.` };
112
117
  }
@@ -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
  }
package/cli/uninstall.mjs CHANGED
@@ -26,6 +26,14 @@ function remainingOwnersText(count) {
26
26
  }
27
27
 
28
28
  export async function uninstall({ host, dryRun = false } = {}) {
29
+ // Preflight before removing the hook: package removal on a broken registry left the host without its watchdog.
30
+ let registry = null;
31
+ let registryError = null;
32
+ try {
33
+ registry = await readRulesRegistry(host);
34
+ } catch (err) {
35
+ registryError = err;
36
+ }
29
37
  const record = await readInstallRecord(host);
30
38
  const preservation = `Run artifacts in ${path.join(host.root, 'codex-runs')} and the run `
31
39
  + `configuration in ${path.join(host.agentsDir, 'run-config.json')} are preserved.`;
@@ -34,17 +42,23 @@ export async function uninstall({ host, dryRun = false } = {}) {
34
42
  if (dryRun) {
35
43
  const lines = record.files.map((file) => `Would remove ${file}`);
36
44
  if (record.rules) {
37
- const registry = await readRulesRegistry(host);
38
- const remainingOwners = remainingRulesOwners(registry, host);
39
- const currentFingerprint = await fileFingerprint(record.rules.path);
40
- if (remainingOwners?.length) {
41
- lines.push(`Would leave ${record.rules.path} because ${remainingOwnersText(remainingOwners.length)}.`);
42
- } else if (currentFingerprint === record.rules.fingerprint) {
43
- lines.push(`Would remove ${record.rules.path}; no other owners remain and its fingerprint is unchanged.`);
44
- } else if (currentFingerprint !== null) {
45
- lines.push(`Would leave ${record.rules.path} because its contents changed after installation.`);
45
+ if (registryError) {
46
+ lines.push(`Would leave ${record.rules.path} because the rules ownership registry is invalid; ownership is unknown.`);
46
47
  } else {
47
- lines.push(`Would leave ${record.rules.path} because it is already absent.`);
48
+ const remainingOwners = remainingRulesOwners(registry, host);
49
+ const currentFingerprint = await fileFingerprint(record.rules.path);
50
+ if (remainingOwners?.length) {
51
+ lines.push(`Would leave ${record.rules.path} because ${remainingOwnersText(remainingOwners.length)}.`);
52
+ } else if (currentFingerprint === record.rules.fingerprint) {
53
+ lines.push(`Would remove ${record.rules.path}; no other owners remain and its fingerprint is unchanged.`);
54
+ } else if (currentFingerprint !== null) {
55
+ lines.push(`Would leave ${record.rules.path} because its contents changed after installation.`);
56
+ } else {
57
+ lines.push(`Would leave ${record.rules.path} because it is already absent.`);
58
+ }
59
+ if (!registry) {
60
+ lines.push(`Warning: the rules ownership registry was missing; other installations may use ${record.rules.path}.`);
61
+ }
48
62
  }
49
63
  }
50
64
  lines.push('Would remove the SubagentStop hook and installation record.');
@@ -55,17 +69,31 @@ export async function uninstall({ host, dryRun = false } = {}) {
55
69
  await removeHook(host.settingsPath, path.join(host.root, record.hook.path), {
56
70
  createdGroup: record.hook.createdGroup === true,
57
71
  });
58
- const ownership = await removeRulesOwner(host);
59
- let rulesOutput;
72
+ let ownership = null;
73
+ if (!registryError) {
74
+ try {
75
+ ownership = await removeRulesOwner(host);
76
+ } catch (err) {
77
+ registryError = err;
78
+ }
79
+ }
80
+ const rulesOutput = [];
60
81
  if (record.rules) {
61
- if (ownership?.owners.length) {
62
- rulesOutput = `Left ${record.rules.path} because ${remainingOwnersText(ownership.owners.length)}.`;
82
+ if (registryError) {
83
+ rulesOutput.push(`Left ${record.rules.path} because the rules ownership registry is invalid; ownership is unknown.`);
63
84
  } else {
64
- const currentFingerprint = await fileFingerprint(record.rules.path);
65
- if (currentFingerprint === record.rules.fingerprint) {
66
- await fs.rm(record.rules.path, { force: true });
67
- } else if (currentFingerprint !== null) {
68
- rulesOutput = `Left ${record.rules.path} because its contents changed after installation.`;
85
+ if (ownership?.owners.length) {
86
+ rulesOutput.push(`Left ${record.rules.path} because ${remainingOwnersText(ownership.owners.length)}.`);
87
+ } else {
88
+ const currentFingerprint = await fileFingerprint(record.rules.path);
89
+ if (currentFingerprint === record.rules.fingerprint) {
90
+ await fs.rm(record.rules.path, { force: true });
91
+ } else if (currentFingerprint !== null) {
92
+ rulesOutput.push(`Left ${record.rules.path} because its contents changed after installation.`);
93
+ }
94
+ }
95
+ if (!registry) {
96
+ rulesOutput.push(`Warning: the rules ownership registry was missing; other installations may use ${record.rules.path}.`);
69
97
  }
70
98
  }
71
99
  }
@@ -80,6 +108,6 @@ export async function uninstall({ host, dryRun = false } = {}) {
80
108
  await removeEmpty(host.agentsDir);
81
109
  return {
82
110
  exitCode: 0,
83
- output: [`Uninstalled codex-bridge.`, rulesOutput, preservation].filter(Boolean).join('\n'),
111
+ output: [`Uninstalled codex-bridge.`, ...rulesOutput, preservation].join('\n'),
84
112
  };
85
113
  }
package/cli/update.mjs CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  } from './manifest.mjs';
13
13
  import { targetMatches } from './copy.mjs';
14
14
  import { inspectHook } from './settings-merge.mjs';
15
- import { addRulesOwner } from './rules-owners.mjs';
15
+ import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
16
16
 
17
17
  async function exists(target) {
18
18
  try {
@@ -81,6 +81,7 @@ function dryRunOutput(states, hookPresent, legacy) {
81
81
  }
82
82
 
83
83
  export async function update({ host, dryRun = false, force = false, packageRoot } = {}) {
84
+ await readRulesRegistry(host);
84
85
  const record = await readInstallRecord(host);
85
86
  if (!record) {
86
87
  return { exitCode: 1, output: 'codex-bridge is not installed. Run codex-bridge install first.' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyupro/codex-bridge",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Delegate implementation, reconnaissance and code review from Claude Code to the Codex CLI: dispatcher agents, a runner that survives a dropped call, and a verdict that checks the report against the worktree.",
5
5
  "keywords": [
6
6
  "claude-code",