@lyupro/codex-bridge 0.1.0 → 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 +16 -0
- package/cli/doctor.mjs +14 -5
- package/cli/install.mjs +8 -0
- package/cli/rules-owners.mjs +172 -0
- package/cli/uninstall.mjs +59 -8
- package/cli/update.mjs +3 -0
- package/package.json +1 -1
- package/src/runner/attach.mjs +17 -8
- package/src/runner/project-dir.mjs +37 -9
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@ 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
|
+
|
|
15
|
+
## [0.1.1] - 2026-08-04
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Two runners starting at the same moment in equally named repositories no longer fail on the exclusive writes that assign a runs directory: the runner that loses either race now reads the marker the winner wrote and takes the next candidate, which is the collision the marker was introduced to survive.
|
|
20
|
+
- A repeated order is answered from its newest run instead of the pass before it: while a continuation was in flight, repeating the command returned the previous run's verdict as if it described the current one.
|
|
21
|
+
- Uninstalling one installation no longer takes the Codex rules file away from every other installation on the machine. The file lives in `CODEX_HOME`, which is shared by every host, while ownership was recorded per host and two installations of the same version share a fingerprint — so removing a project-scope or throwaway host silently left the remaining ones running Codex without the execpolicy that forbids destructive repository work. Ownership is now tracked next to the file itself, and the last owner to leave is the one that removes it.
|
|
22
|
+
|
|
7
23
|
## [0.1.0] - 2026-08-04
|
|
8
24
|
|
|
9
25
|
### Added
|
package/cli/doctor.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
5
|
import { fileFingerprint, readInstallRecord, packageInfo } from './manifest.mjs';
|
|
6
|
+
import { readRulesRegistry } from './rules-owners.mjs';
|
|
6
7
|
import { runsRoot } from '../src/runner/runs-root.mjs';
|
|
7
8
|
import { resolveProjectRunsDir } from '../src/runner/project-dir.mjs';
|
|
8
9
|
|
|
@@ -97,16 +98,24 @@ async function hookCheck(host, record) {
|
|
|
97
98
|
: check('hook', 'warn', 'SubagentStop does not point to the installed reply-guard.mjs');
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
async function rulesCheck(record) {
|
|
101
|
+
async function rulesCheck(host, record) {
|
|
101
102
|
if (!record) return check('rules', 'warn', 'cannot check before installation');
|
|
102
103
|
if (!record.rules) {
|
|
103
104
|
return check('rules', 'warn', 'rules were not installed by this installation; install or update will add them');
|
|
104
105
|
}
|
|
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
|
+
}
|
|
113
|
+
const ownerNote = registry?.owners.length > 1 ? `; ${registry.owners.length} owners` : '';
|
|
105
114
|
const fingerprint = await fileFingerprint(record.rules.path);
|
|
106
|
-
if (!fingerprint) return check('rules', 'fail', record.rules.path);
|
|
115
|
+
if (!fingerprint) return check('rules', 'fail', `${record.rules.path}${ownerNote ? ` (${registry.owners.length} owners)` : ''}`);
|
|
107
116
|
return fingerprint === record.rules.fingerprint
|
|
108
|
-
? check('rules', 'ok', `${record.rules.path} (matches record)`)
|
|
109
|
-
: check('rules', 'warn', `${record.rules.path} (modified after installation)`);
|
|
117
|
+
? check('rules', 'ok', `${record.rules.path} (matches record${ownerNote})`)
|
|
118
|
+
: check('rules', 'warn', `${record.rules.path} (modified after installation${ownerNote})`);
|
|
110
119
|
}
|
|
111
120
|
|
|
112
121
|
export async function diagnose({ host, codexProbe = probeCodex, currentPackage } = {}) {
|
|
@@ -144,7 +153,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
144
153
|
!record ? 'warn' : missingFiles.length ? 'fail' : 'ok',
|
|
145
154
|
!record ? 'not checked' : missingFiles.length ? `missing: ${missingFiles.join(', ')}` : `${record.files.length} installed file(s) present`,
|
|
146
155
|
));
|
|
147
|
-
const rules = await rulesCheck(record);
|
|
156
|
+
const rules = await rulesCheck(host, record);
|
|
148
157
|
checks.push(rules);
|
|
149
158
|
checks.push(await hookCheck(host, record));
|
|
150
159
|
|
package/cli/install.mjs
CHANGED
|
@@ -13,6 +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, readRulesRegistry } from './rules-owners.mjs';
|
|
16
17
|
|
|
17
18
|
async function targetExists(target) {
|
|
18
19
|
try {
|
|
@@ -25,6 +26,8 @@ async function targetExists(target) {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
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);
|
|
28
31
|
const plan = await buildInstallPlan(host, packageRoot);
|
|
29
32
|
const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
|
|
30
33
|
const currentPackage = await packageInfo(packageRoot);
|
|
@@ -66,6 +69,7 @@ export async function install({ host, dryRun = false, force = false, packageRoot
|
|
|
66
69
|
new Map(states.map((state) => [state.item.relativeToHost, state.fingerprint])),
|
|
67
70
|
{ path: rule.target, fingerprint: ruleState.fingerprint });
|
|
68
71
|
if (!changedFiles.length && !changedRule && inspectedHook.present && sameRecord) {
|
|
72
|
+
if (!dryRun) await addRulesOwner(host);
|
|
69
73
|
return { exitCode: 0, output: 'codex-bridge is already installed; nothing to do.' };
|
|
70
74
|
}
|
|
71
75
|
|
|
@@ -80,6 +84,10 @@ export async function install({ host, dryRun = false, force = false, packageRoot
|
|
|
80
84
|
return { exitCode: 0, output: lines.join('\n') };
|
|
81
85
|
}
|
|
82
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);
|
|
83
91
|
for (const { item } of changedFiles) await copyPlannedFile(item, host.agentsDir);
|
|
84
92
|
if (changedRule) await copyPlannedFile(rule, host.agentsDir);
|
|
85
93
|
// Seeded files are written once and then belong to the operator: an existing one is left
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/** Tracks which Claude Code hosts own the shared Codex bridge rules file. */
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { normalizeRepoPath } from '../src/runner/project-dir.mjs';
|
|
6
|
+
|
|
7
|
+
export const RULES_REGISTRY_NAME = '.codex-bridge-rules.json';
|
|
8
|
+
export const RULES_REGISTRY_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
const REGISTRY_LOCK_RETRIES = 200;
|
|
11
|
+
const REGISTRY_LOCK_DELAY_MS = 5;
|
|
12
|
+
const REGISTRY_LOCK_STALE_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
export function rulesRegistryPath(host) {
|
|
15
|
+
return path.join(host.codexRulesDir, RULES_REGISTRY_NAME);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function normalizedRulesOwner(host) {
|
|
19
|
+
return normalizeRepoPath(host.root);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeOwners(owners) {
|
|
23
|
+
return [...new Set(owners.map((owner) => normalizeRepoPath(owner)))];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function validateRegistry(parsed) {
|
|
27
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
28
|
+
throw new Error('rules ownership registry must be an object');
|
|
29
|
+
}
|
|
30
|
+
if (parsed.version !== RULES_REGISTRY_VERSION) {
|
|
31
|
+
throw new Error(`rules ownership registry version must be ${RULES_REGISTRY_VERSION}`);
|
|
32
|
+
}
|
|
33
|
+
if (!Array.isArray(parsed.owners) || parsed.owners.some((owner) => typeof owner !== 'string' || !owner)) {
|
|
34
|
+
throw new Error('rules ownership registry owners must be a list of non-empty strings');
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
version: RULES_REGISTRY_VERSION,
|
|
38
|
+
owners: normalizeOwners(parsed.owners),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function loadRulesRegistry(host) {
|
|
43
|
+
let raw;
|
|
44
|
+
try {
|
|
45
|
+
raw = await fs.readFile(rulesRegistryPath(host), 'utf8');
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err.code === 'ENOENT') return null;
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
let parsed;
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(raw);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
throw new Error(`invalid rules ownership registry JSON: ${err.message}`);
|
|
55
|
+
}
|
|
56
|
+
return { raw: parsed, registry: validateRegistry(parsed) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function hasSameOwners(raw, registry) {
|
|
60
|
+
return raw?.version === registry.version
|
|
61
|
+
&& Array.isArray(raw.owners)
|
|
62
|
+
&& raw.owners.length === registry.owners.length
|
|
63
|
+
&& raw.owners.every((owner, index) => owner === registry.owners[index]);
|
|
64
|
+
}
|
|
65
|
+
|
|
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) {
|
|
105
|
+
await fs.mkdir(host.codexRulesDir, { recursive: true });
|
|
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
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function readRulesRegistry(host) {
|
|
130
|
+
return (await loadRulesRegistry(host))?.registry ?? null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function addRulesOwner(host) {
|
|
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
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function remainingRulesOwners(registry, host) {
|
|
150
|
+
if (!registry) return null;
|
|
151
|
+
const owner = normalizedRulesOwner(host);
|
|
152
|
+
return registry.owners.filter((candidate) => candidate !== owner);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function removeRulesOwner(host) {
|
|
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
|
+
});
|
|
172
|
+
}
|
package/cli/uninstall.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileFingerprint, installRecordPath, readInstallRecord } from './manifest.mjs';
|
|
5
5
|
import { removeHook } from './settings-merge.mjs';
|
|
6
|
+
import { readRulesRegistry, removeRulesOwner, remainingRulesOwners } from './rules-owners.mjs';
|
|
6
7
|
|
|
7
8
|
async function removeEmpty(directory) {
|
|
8
9
|
try {
|
|
@@ -20,7 +21,19 @@ async function removeEmptyParents(target, boundary) {
|
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
function remainingOwnersText(count) {
|
|
25
|
+
return `${count} other owner${count === 1 ? '' : 's'} ${count === 1 ? 'remains' : 'remain'}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
23
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
|
+
}
|
|
24
37
|
const record = await readInstallRecord(host);
|
|
25
38
|
const preservation = `Run artifacts in ${path.join(host.root, 'codex-runs')} and the run `
|
|
26
39
|
+ `configuration in ${path.join(host.agentsDir, 'run-config.json')} are preserved.`;
|
|
@@ -28,7 +41,26 @@ export async function uninstall({ host, dryRun = false } = {}) {
|
|
|
28
41
|
|
|
29
42
|
if (dryRun) {
|
|
30
43
|
const lines = record.files.map((file) => `Would remove ${file}`);
|
|
31
|
-
if (record.rules)
|
|
44
|
+
if (record.rules) {
|
|
45
|
+
if (registryError) {
|
|
46
|
+
lines.push(`Would leave ${record.rules.path} because the rules ownership registry is invalid; ownership is unknown.`);
|
|
47
|
+
} else {
|
|
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
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
32
64
|
lines.push('Would remove the SubagentStop hook and installation record.');
|
|
33
65
|
lines.push(preservation);
|
|
34
66
|
return { exitCode: 0, output: lines.join('\n') };
|
|
@@ -37,13 +69,32 @@ export async function uninstall({ host, dryRun = false } = {}) {
|
|
|
37
69
|
await removeHook(host.settingsPath, path.join(host.root, record.hook.path), {
|
|
38
70
|
createdGroup: record.hook.createdGroup === true,
|
|
39
71
|
});
|
|
40
|
-
let
|
|
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 = [];
|
|
41
81
|
if (record.rules) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
82
|
+
if (registryError) {
|
|
83
|
+
rulesOutput.push(`Left ${record.rules.path} because the rules ownership registry is invalid; ownership is unknown.`);
|
|
84
|
+
} else {
|
|
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}.`);
|
|
97
|
+
}
|
|
47
98
|
}
|
|
48
99
|
}
|
|
49
100
|
for (const relative of record.files) {
|
|
@@ -57,6 +108,6 @@ export async function uninstall({ host, dryRun = false } = {}) {
|
|
|
57
108
|
await removeEmpty(host.agentsDir);
|
|
58
109
|
return {
|
|
59
110
|
exitCode: 0,
|
|
60
|
-
output: [`Uninstalled codex-bridge.`, rulesOutput, preservation].
|
|
111
|
+
output: [`Uninstalled codex-bridge.`, ...rulesOutput, preservation].join('\n'),
|
|
61
112
|
};
|
|
62
113
|
}
|
package/cli/update.mjs
CHANGED
|
@@ -12,6 +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, readRulesRegistry } from './rules-owners.mjs';
|
|
15
16
|
|
|
16
17
|
async function exists(target) {
|
|
17
18
|
try {
|
|
@@ -80,6 +81,7 @@ function dryRunOutput(states, hookPresent, legacy) {
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
export async function update({ host, dryRun = false, force = false, packageRoot } = {}) {
|
|
84
|
+
await readRulesRegistry(host);
|
|
83
85
|
const record = await readInstallRecord(host);
|
|
84
86
|
if (!record) {
|
|
85
87
|
return { exitCode: 1, output: 'codex-bridge is not installed. Run codex-bridge install first.' };
|
|
@@ -141,6 +143,7 @@ export async function update({ host, dryRun = false, force = false, packageRoot
|
|
|
141
143
|
{ path: rule.target, fingerprint: ruleState.fingerprint });
|
|
142
144
|
const changed = states.some((state) => state.status !== 'up-to-date');
|
|
143
145
|
if (!changed && inspectedHook.present && recordCurrent) {
|
|
146
|
+
if (!dryRun) await addRulesOwner(host);
|
|
144
147
|
return { exitCode: 0, output: 'codex-bridge is up to date' };
|
|
145
148
|
}
|
|
146
149
|
if (dryRun) {
|
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",
|
package/src/runner/attach.mjs
CHANGED
|
@@ -71,15 +71,24 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
|
|
|
71
71
|
.map((run) => ({ run, status: readJsonFile(path.join(runsRoot, run, 'status.json')) }))
|
|
72
72
|
.filter(({ status }) => status && String(status.order_id ?? '') === String(orderId ?? ''));
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
// The run an order is currently about is its newest one, and the chain arrives oldest first.
|
|
75
|
+
// Reading it from the end is the whole point: `--continue` adds a second run under the same
|
|
76
|
+
// order, and while it was in flight a repeat used to be answered by the first run's reply.txt —
|
|
77
|
+
// a stale verdict presented as this pass's answer. Found by the Plan_11-2 checklist, 2026-08-04.
|
|
78
|
+
let candidate = null;
|
|
79
|
+
for (let i = sameOrder.length - 1; i >= 0; i -= 1) {
|
|
80
|
+
const entry = sameOrder[i];
|
|
81
|
+
const dir = path.join(runsRoot, entry.run);
|
|
82
|
+
if (fs.existsSync(path.join(dir, 'reply.txt'))) {
|
|
83
|
+
console.log(`ATTACH=${dir} started=${entry.status.started_at}`);
|
|
84
|
+
console.log(fs.readFileSync(path.join(dir, 'reply.txt'), 'utf8').replace(/\s+$/, ''));
|
|
85
|
+
return exitCodeFor(readJsonFile(path.join(dir, 'meta.json'))?.status);
|
|
86
|
+
}
|
|
87
|
+
if (alive(entry.status.pid)) {
|
|
88
|
+
candidate = entry;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
80
91
|
}
|
|
81
|
-
|
|
82
|
-
const candidate = sameOrder.find(({ status }) => alive(status.pid));
|
|
83
92
|
if (!candidate) return null;
|
|
84
93
|
|
|
85
94
|
const runDir = path.join(runsRoot, candidate.run);
|
|
@@ -28,9 +28,35 @@ function readMarker(dir) {
|
|
|
28
28
|
return marker;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Takes ownership of a candidate directory, or reports that someone else got it.
|
|
33
|
+
*
|
|
34
|
+
* Two runners starting at the same moment in equally named repositories pick the
|
|
35
|
+
* same free candidate, and exactly one of them wins each of the two exclusive
|
|
36
|
+
* writes below. Until 2026-08-04 the loser died on `EEXIST` instead of reading
|
|
37
|
+
* the marker the winner had just written — the very collision stage 9 of Plan_9
|
|
38
|
+
* existed to solve.
|
|
39
|
+
*/
|
|
40
|
+
function claimMarker(dir, repo, wantedRepo) {
|
|
32
41
|
const marker = { repo, created: new Date().toISOString() };
|
|
33
|
-
|
|
42
|
+
try {
|
|
43
|
+
fs.writeFileSync(path.join(dir, PROJECT_MARKER), `${JSON.stringify(marker, null, 2)}\n`, { flag: 'wx' });
|
|
44
|
+
return true;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err.code !== 'EEXIST') throw err;
|
|
47
|
+
const existing = readMarker(dir);
|
|
48
|
+
return Boolean(existing) && normalizeRepoPath(existing.repo) === wantedRepo;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createDir(dir) {
|
|
53
|
+
try {
|
|
54
|
+
fs.mkdirSync(dir);
|
|
55
|
+
return true;
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (err.code !== 'EEXIST') throw err;
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
34
60
|
}
|
|
35
61
|
|
|
36
62
|
function legacyOwner(dir) {
|
|
@@ -60,11 +86,12 @@ export function resolveProjectRunsDir(runsRootPath, repoRoot, { create = true }
|
|
|
60
86
|
const name = candidateName(base, index);
|
|
61
87
|
const dir = path.join(runsRootPath, name);
|
|
62
88
|
if (!fs.existsSync(dir)) {
|
|
63
|
-
if (create) {
|
|
64
|
-
|
|
65
|
-
|
|
89
|
+
if (!create) return { dir, name, reason: 'created' };
|
|
90
|
+
if (createDir(dir)) {
|
|
91
|
+
if (claimMarker(dir, repoRoot, wantedRepo)) return { dir, name, reason: 'created' };
|
|
92
|
+
continue;
|
|
66
93
|
}
|
|
67
|
-
|
|
94
|
+
// The name was free a moment ago and is not any more: judge it like any other.
|
|
68
95
|
}
|
|
69
96
|
if (!fs.statSync(dir).isDirectory()) {
|
|
70
97
|
throw new Error(`Project runs candidate is not a directory: ${dir}`);
|
|
@@ -78,11 +105,12 @@ export function resolveProjectRunsDir(runsRootPath, repoRoot, { create = true }
|
|
|
78
105
|
|
|
79
106
|
const owner = legacyOwner(dir);
|
|
80
107
|
if (!owner || normalizeRepoPath(owner) === wantedRepo) {
|
|
81
|
-
if (create)
|
|
82
|
-
return { dir, name, reason: 'adopted' };
|
|
108
|
+
if (!create) return { dir, name, reason: 'adopted' };
|
|
109
|
+
if (claimMarker(dir, repoRoot, wantedRepo)) return { dir, name, reason: 'adopted' };
|
|
110
|
+
continue;
|
|
83
111
|
}
|
|
84
112
|
// Recording the discovered owner prevents later runs from reinterpreting mixed history.
|
|
85
|
-
if (create)
|
|
113
|
+
if (create) claimMarker(dir, owner, normalizeRepoPath(owner));
|
|
86
114
|
}
|
|
87
115
|
|
|
88
116
|
throw new Error(
|