@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 +8 -0
- package/cli/doctor.mjs +7 -1
- package/cli/install.mjs +7 -2
- package/cli/rules-owners.mjs +93 -22
- package/cli/uninstall.mjs +48 -20
- package/cli/update.mjs +2 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
}
|
package/cli/rules-owners.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
owners:
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
90
|
-
if (!
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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 (
|
|
62
|
-
rulesOutput
|
|
82
|
+
if (registryError) {
|
|
83
|
+
rulesOutput.push(`Left ${record.rules.path} because the rules ownership registry is invalid; ownership is unknown.`);
|
|
63
84
|
} else {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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].
|
|
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.
|
|
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",
|