@lyupro/codex-bridge 0.1.0 → 0.1.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/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.1] - 2026-08-04
8
+
9
+ ### Fixed
10
+
11
+ - 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.
12
+ - 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.
13
+ - 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.
14
+
7
15
  ## [0.1.0] - 2026-08-04
8
16
 
9
17
  ### 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,18 @@ 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
+ const registry = await readRulesRegistry(host);
107
+ const ownerNote = registry?.owners.length > 1 ? `; ${registry.owners.length} owners` : '';
105
108
  const fingerprint = await fileFingerprint(record.rules.path);
106
- if (!fingerprint) return check('rules', 'fail', record.rules.path);
109
+ if (!fingerprint) return check('rules', 'fail', `${record.rules.path}${ownerNote ? ` (${registry.owners.length} owners)` : ''}`);
107
110
  return fingerprint === record.rules.fingerprint
108
- ? check('rules', 'ok', `${record.rules.path} (matches record)`)
109
- : check('rules', 'warn', `${record.rules.path} (modified after installation)`);
111
+ ? check('rules', 'ok', `${record.rules.path} (matches record${ownerNote})`)
112
+ : check('rules', 'warn', `${record.rules.path} (modified after installation${ownerNote})`);
110
113
  }
111
114
 
112
115
  export async function diagnose({ host, codexProbe = probeCodex, currentPackage } = {}) {
@@ -144,7 +147,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
144
147
  !record ? 'warn' : missingFiles.length ? 'fail' : 'ok',
145
148
  !record ? 'not checked' : missingFiles.length ? `missing: ${missingFiles.join(', ')}` : `${record.files.length} installed file(s) present`,
146
149
  ));
147
- const rules = await rulesCheck(record);
150
+ const rules = await rulesCheck(host, record);
148
151
  checks.push(rules);
149
152
  checks.push(await hookCheck(host, record));
150
153
 
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 } from './rules-owners.mjs';
16
17
 
17
18
  async function targetExists(target) {
18
19
  try {
@@ -66,6 +67,7 @@ export async function install({ host, dryRun = false, force = false, packageRoot
66
67
  new Map(states.map((state) => [state.item.relativeToHost, state.fingerprint])),
67
68
  { path: rule.target, fingerprint: ruleState.fingerprint });
68
69
  if (!changedFiles.length && !changedRule && inspectedHook.present && sameRecord) {
70
+ if (!dryRun) await addRulesOwner(host);
69
71
  return { exitCode: 0, output: 'codex-bridge is already installed; nothing to do.' };
70
72
  }
71
73
 
@@ -105,5 +107,6 @@ export async function install({ host, dryRun = false, force = false, packageRoot
105
107
  rules: { path: rule.target, fingerprint: await fileFingerprint(rule.target) },
106
108
  hook: { event: 'SubagentStop', path: hookRelative, createdGroup: hookResult.createdGroup || record?.hook?.createdGroup || false },
107
109
  });
110
+ await addRulesOwner(host);
108
111
  return { exitCode: 0, output: `Installed ${plan.length + 1} files and registered the SubagentStop hook.` };
109
112
  }
@@ -0,0 +1,101 @@
1
+ /** Tracks which Claude Code hosts own the shared Codex bridge rules file. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { normalizeRepoPath } from '../src/runner/project-dir.mjs';
5
+
6
+ export const RULES_REGISTRY_NAME = '.codex-bridge-rules.json';
7
+ export const RULES_REGISTRY_VERSION = 1;
8
+
9
+ export function rulesRegistryPath(host) {
10
+ return path.join(host.codexRulesDir, RULES_REGISTRY_NAME);
11
+ }
12
+
13
+ export function normalizedRulesOwner(host) {
14
+ return normalizeRepoPath(host.root);
15
+ }
16
+
17
+ function normalizeOwners(owners) {
18
+ return [...new Set(owners.map((owner) => normalizeRepoPath(owner)))];
19
+ }
20
+
21
+ function validateRegistry(parsed) {
22
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
23
+ throw new Error('rules ownership registry must be an object');
24
+ }
25
+ if (parsed.version !== RULES_REGISTRY_VERSION) {
26
+ throw new Error(`rules ownership registry version must be ${RULES_REGISTRY_VERSION}`);
27
+ }
28
+ if (!Array.isArray(parsed.owners) || parsed.owners.some((owner) => typeof owner !== 'string' || !owner)) {
29
+ throw new Error('rules ownership registry owners must be a list of non-empty strings');
30
+ }
31
+ return {
32
+ version: RULES_REGISTRY_VERSION,
33
+ owners: normalizeOwners(parsed.owners),
34
+ };
35
+ }
36
+
37
+ async function loadRulesRegistry(host) {
38
+ let raw;
39
+ try {
40
+ raw = await fs.readFile(rulesRegistryPath(host), 'utf8');
41
+ } catch (err) {
42
+ if (err.code === 'ENOENT') return null;
43
+ throw err;
44
+ }
45
+ let parsed;
46
+ try {
47
+ parsed = JSON.parse(raw);
48
+ } catch (err) {
49
+ throw new Error(`invalid rules ownership registry JSON: ${err.message}`);
50
+ }
51
+ return { raw: parsed, registry: validateRegistry(parsed) };
52
+ }
53
+
54
+ function hasSameOwners(raw, registry) {
55
+ return raw?.version === registry.version
56
+ && Array.isArray(raw.owners)
57
+ && raw.owners.length === registry.owners.length
58
+ && raw.owners.every((owner, index) => owner === registry.owners[index]);
59
+ }
60
+
61
+ async function writeRulesRegistry(host, registry) {
62
+ await fs.mkdir(host.codexRulesDir, { recursive: true });
63
+ await fs.writeFile(rulesRegistryPath(host), `${JSON.stringify(registry, null, 2)}\n`);
64
+ }
65
+
66
+ export async function readRulesRegistry(host) {
67
+ return (await loadRulesRegistry(host))?.registry ?? null;
68
+ }
69
+
70
+ 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;
80
+ }
81
+
82
+ export function remainingRulesOwners(registry, host) {
83
+ if (!registry) return null;
84
+ const owner = normalizedRulesOwner(host);
85
+ return registry.owners.filter((candidate) => candidate !== owner);
86
+ }
87
+
88
+ 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;
101
+ }
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,6 +21,10 @@ 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 } = {}) {
24
29
  const record = await readInstallRecord(host);
25
30
  const preservation = `Run artifacts in ${path.join(host.root, 'codex-runs')} and the run `
@@ -28,7 +33,20 @@ export async function uninstall({ host, dryRun = false } = {}) {
28
33
 
29
34
  if (dryRun) {
30
35
  const lines = record.files.map((file) => `Would remove ${file}`);
31
- if (record.rules) lines.push(`Would remove ${record.rules.path} if its fingerprint is unchanged.`);
36
+ 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.`);
46
+ } else {
47
+ lines.push(`Would leave ${record.rules.path} because it is already absent.`);
48
+ }
49
+ }
32
50
  lines.push('Would remove the SubagentStop hook and installation record.');
33
51
  lines.push(preservation);
34
52
  return { exitCode: 0, output: lines.join('\n') };
@@ -37,13 +55,18 @@ export async function uninstall({ host, dryRun = false } = {}) {
37
55
  await removeHook(host.settingsPath, path.join(host.root, record.hook.path), {
38
56
  createdGroup: record.hook.createdGroup === true,
39
57
  });
58
+ const ownership = await removeRulesOwner(host);
40
59
  let rulesOutput;
41
60
  if (record.rules) {
42
- const currentFingerprint = await fileFingerprint(record.rules.path);
43
- if (currentFingerprint === record.rules.fingerprint) {
44
- await fs.rm(record.rules.path, { force: true });
45
- } else if (currentFingerprint !== null) {
46
- rulesOutput = `Left ${record.rules.path} because its contents changed after installation.`;
61
+ if (ownership?.owners.length) {
62
+ rulesOutput = `Left ${record.rules.path} because ${remainingOwnersText(ownership.owners.length)}.`;
63
+ } 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.`;
69
+ }
47
70
  }
48
71
  }
49
72
  for (const relative of record.files) {
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 } from './rules-owners.mjs';
15
16
 
16
17
  async function exists(target) {
17
18
  try {
@@ -141,6 +142,7 @@ export async function update({ host, dryRun = false, force = false, packageRoot
141
142
  { path: rule.target, fingerprint: ruleState.fingerprint });
142
143
  const changed = states.some((state) => state.status !== 'up-to-date');
143
144
  if (!changed && inspectedHook.present && recordCurrent) {
145
+ if (!dryRun) await addRulesOwner(host);
144
146
  return { exitCode: 0, output: 'codex-bridge is up to date' };
145
147
  }
146
148
  if (dryRun) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyupro/codex-bridge",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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",
@@ -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
- const answered = sameOrder.find(({ run }) => fs.existsSync(path.join(runsRoot, run, 'reply.txt')));
75
- if (answered) {
76
- const runDir = path.join(runsRoot, answered.run);
77
- console.log(`ATTACH=${runDir} started=${answered.status.started_at}`);
78
- console.log(fs.readFileSync(path.join(runDir, 'reply.txt'), 'utf8').replace(/\s+$/, ''));
79
- return exitCodeFor(readJsonFile(path.join(runDir, 'meta.json'))?.status);
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
- function writeMarker(dir, repo) {
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
- fs.writeFileSync(path.join(dir, PROJECT_MARKER), `${JSON.stringify(marker, null, 2)}\n`, { flag: 'wx' });
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
- fs.mkdirSync(dir);
65
- writeMarker(dir, repoRoot);
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
- return { dir, name, reason: 'created' };
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) writeMarker(dir, repoRoot);
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) writeMarker(dir, owner);
113
+ if (create) claimMarker(dir, owner, normalizeRepoPath(owner));
86
114
  }
87
115
 
88
116
  throw new Error(