@lyupro/codex-bridge 0.1.1 → 0.1.3
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 +21 -0
- package/README.md +2 -1
- package/cli/doctor.mjs +39 -36
- package/cli/install.mjs +57 -18
- package/cli/manifest.mjs +47 -13
- package/cli/rules-owners.mjs +93 -22
- package/cli/settings-merge.mjs +44 -22
- package/cli/uninstall.mjs +73 -26
- package/cli/update.mjs +35 -8
- package/package.json +1 -1
- package/src/agents/codex-build.md +10 -10
- package/src/agents/codex-review.md +10 -5
- package/src/agents/codex-scout.md +10 -6
- package/src/hook-definitions.mjs +25 -0
- package/src/hooks/order-gate.mjs +68 -0
- package/src/meta/outcome.mjs +65 -0
- package/src/meta/reply.mjs +23 -0
- package/src/meta/verdict.mjs +8 -0
- package/src/required-inputs.mjs +104 -0
- package/src/runner/args.mjs +13 -5
- package/src/runner/prompts.mjs +9 -0
- package/src/runner/schemas.mjs +23 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ 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.3] - 2026-08-05
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Dispatcher calls are refused before a subagent exists. A `PreToolUse` gate rejects a `codex-*` call whose task text carries no order label — and, for `codex-build`, no scope — or whose value is still a template placeholder. Until this release the missing input was discovered by the runner, after the subagent had been created and paid for out of the Claude subscription.
|
|
12
|
+
- `codex-build` declares the outcome of its own run in a required `outcome` field. A run that could not do the work reports `fail`, and the verdict goes red with the reason even when the worktree is clean and verification is green — the shape that used to answer `OK`, because an empty delta is a legitimate outcome the runner cannot tell apart from a failure. Whether a run owed that declaration is read from the response schema it was handed, so runs recorded before this release keep the verdict of their own day.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Every dispatcher's description and prompt lists the inputs it requires, filled in at install time from the same table the gate enforces, and the runner's own refusals name the input, an example value and what to do about it. A caller no longer learns the contract by being refused.
|
|
17
|
+
- A failed `codex-build` reports what it left in the worktree, the way a quota-exhausted run already did. "The work was not done" is not "the tree is clean", and where no snapshot exists the line says so instead of claiming there were no changes.
|
|
18
|
+
- The installation record stores a list of hooks rather than a single hook, so a host can register more than one. Records written by earlier versions are read as a one-element list.
|
|
19
|
+
|
|
20
|
+
## [0.1.2] - 2026-08-04
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- 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.
|
|
25
|
+
- 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.
|
|
26
|
+
- 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.
|
|
27
|
+
|
|
7
28
|
## [0.1.1] - 2026-08-04
|
|
8
29
|
|
|
9
30
|
### Fixed
|
package/README.md
CHANGED
|
@@ -17,7 +17,8 @@ node bin/codex-bridge.mjs stop <run> # close a hanging run by hand, without h
|
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
`install` copies the package into the host's `agents/codex/`, its slash commands into
|
|
20
|
-
`commands/codex/`, and registers the stop hook
|
|
20
|
+
`commands/codex/`, and registers both the stop hook and the pre-dispatch order gate in
|
|
21
|
+
`settings.json` — **merged**, so hooks that
|
|
21
22
|
are already there survive, and the file is backed up before every write. Run it twice and the
|
|
22
23
|
second run does nothing. `--dry-run` prints the plan and touches nothing.
|
|
23
24
|
|
package/cli/doctor.mjs
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
fileFingerprint,
|
|
7
|
+
HOOK_DEFINITIONS,
|
|
8
|
+
readInstallRecord,
|
|
9
|
+
packageInfo,
|
|
10
|
+
} from './manifest.mjs';
|
|
11
|
+
import { commandFor, inspectHook } from './settings-merge.mjs';
|
|
6
12
|
import { readRulesRegistry } from './rules-owners.mjs';
|
|
7
13
|
import { runsRoot } from '../src/runner/runs-root.mjs';
|
|
8
14
|
import { resolveProjectRunsDir } from '../src/runner/project-dir.mjs';
|
|
@@ -53,22 +59,6 @@ function projectRunsCheck() {
|
|
|
53
59
|
return check('projectRuns', 'ok', `${path.resolve(resolved.dir)} (${note})`);
|
|
54
60
|
}
|
|
55
61
|
|
|
56
|
-
function hookCommands(value) {
|
|
57
|
-
if (Array.isArray(value)) return value.flatMap(hookCommands);
|
|
58
|
-
if (value && typeof value === 'object') {
|
|
59
|
-
const own = value.type === 'command' && typeof value.command === 'string' ? [value.command] : [];
|
|
60
|
-
return [...own, ...Object.values(value).filter((child) => child !== value.command).flatMap(hookCommands)];
|
|
61
|
-
}
|
|
62
|
-
return [];
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function commandReferences(command, expected) {
|
|
66
|
-
const normalizedExpected = path.resolve(expected).split(path.sep).join('/');
|
|
67
|
-
const tokens = [...command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)]
|
|
68
|
-
.map((match) => match[1] ?? match[2] ?? match[3]);
|
|
69
|
-
return tokens.some((token) => path.resolve(token).split(path.sep).join('/') === normalizedExpected);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
62
|
export function probeCodex() {
|
|
73
63
|
const command = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex';
|
|
74
64
|
const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'];
|
|
@@ -79,23 +69,30 @@ export function probeCodex() {
|
|
|
79
69
|
return { available: true, value: (result.stdout || result.stderr).trim() };
|
|
80
70
|
}
|
|
81
71
|
|
|
82
|
-
async function
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
72
|
+
async function hookChecks(host, record) {
|
|
73
|
+
return Promise.all(HOOK_DEFINITIONS.map(async (definition) => {
|
|
74
|
+
const key = `hook:${definition.event}`;
|
|
75
|
+
if (!record) return check(key, 'warn', 'cannot check before installation');
|
|
76
|
+
const recorded = record.hooks.find((hook) => hook.event === definition.event);
|
|
77
|
+
if (!recorded) {
|
|
78
|
+
return check(key, 'warn', `${definition.event} hook is not present in the installation record`);
|
|
79
|
+
}
|
|
80
|
+
const expected = path.resolve(host.root, recorded.path);
|
|
81
|
+
try {
|
|
82
|
+
const state = await inspectHook(host.settingsPath, {
|
|
83
|
+
event: definition.event,
|
|
84
|
+
matcher: definition.matcher,
|
|
85
|
+
command: commandFor(expected),
|
|
86
|
+
});
|
|
87
|
+
if (state.present) {
|
|
88
|
+
return check(key, 'ok', `${definition.event} matcher ${definition.matcher} -> ${expected}`);
|
|
89
|
+
}
|
|
90
|
+
return check(key, 'warn', `${definition.event} matcher ${definition.matcher} does not point to the installed ${definition.file}`);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
const value = err.code === 'ENOENT' ? 'settings.json is absent' : `settings.json is invalid: ${err.message}`;
|
|
93
|
+
return check(key, 'warn', `${definition.event} hook: ${value}`);
|
|
94
|
+
}
|
|
95
|
+
}));
|
|
99
96
|
}
|
|
100
97
|
|
|
101
98
|
async function rulesCheck(host, record) {
|
|
@@ -103,7 +100,13 @@ async function rulesCheck(host, record) {
|
|
|
103
100
|
if (!record.rules) {
|
|
104
101
|
return check('rules', 'warn', 'rules were not installed by this installation; install or update will add them');
|
|
105
102
|
}
|
|
106
|
-
|
|
103
|
+
let registry;
|
|
104
|
+
try {
|
|
105
|
+
registry = await readRulesRegistry(host);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// Keep every diagnostic visible: package removal on a broken registry left the host without its watchdog.
|
|
108
|
+
return check('rules', 'fail', err.message);
|
|
109
|
+
}
|
|
107
110
|
const ownerNote = registry?.owners.length > 1 ? `; ${registry.owners.length} owners` : '';
|
|
108
111
|
const fingerprint = await fileFingerprint(record.rules.path);
|
|
109
112
|
if (!fingerprint) return check('rules', 'fail', `${record.rules.path}${ownerNote ? ` (${registry.owners.length} owners)` : ''}`);
|
|
@@ -149,7 +152,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
149
152
|
));
|
|
150
153
|
const rules = await rulesCheck(host, record);
|
|
151
154
|
checks.push(rules);
|
|
152
|
-
checks.push(await
|
|
155
|
+
checks.push(...await hookChecks(host, record));
|
|
153
156
|
|
|
154
157
|
const codex = codexProbe();
|
|
155
158
|
checks.push(check('codex', codex.available ? 'ok' : 'warn', codex.value || 'available'));
|
package/cli/install.mjs
CHANGED
|
@@ -4,6 +4,8 @@ import path from 'node:path';
|
|
|
4
4
|
import {
|
|
5
5
|
buildInstallPlan,
|
|
6
6
|
fileFingerprint,
|
|
7
|
+
HOOK_DEFINITIONS,
|
|
8
|
+
installedHookPath,
|
|
7
9
|
packageInfo,
|
|
8
10
|
readInstallRecord,
|
|
9
11
|
recordMatchesPackage,
|
|
@@ -12,8 +14,8 @@ import {
|
|
|
12
14
|
writeInstallRecord,
|
|
13
15
|
} from './manifest.mjs';
|
|
14
16
|
import { copyPlannedFile, targetMatches } from './copy.mjs';
|
|
15
|
-
import { inspectHook, mergeHook } from './settings-merge.mjs';
|
|
16
|
-
import { addRulesOwner } from './rules-owners.mjs';
|
|
17
|
+
import { commandFor, inspectHook, mergeHook } from './settings-merge.mjs';
|
|
18
|
+
import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
|
|
17
19
|
|
|
18
20
|
async function targetExists(target) {
|
|
19
21
|
try {
|
|
@@ -25,15 +27,39 @@ async function targetExists(target) {
|
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
function hookTargets(host) {
|
|
31
|
+
return HOOK_DEFINITIONS.map((definition) => {
|
|
32
|
+
const target = installedHookPath(host, definition);
|
|
33
|
+
return {
|
|
34
|
+
definition,
|
|
35
|
+
target,
|
|
36
|
+
relative: path.relative(host.root, target).split(path.sep).join('/'),
|
|
37
|
+
spec: { event: definition.event, matcher: definition.matcher, command: commandFor(target) },
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function recordHasHooks(record, targets) {
|
|
43
|
+
return Boolean(record)
|
|
44
|
+
&& targets.every(({ definition, relative }) => record.hooks?.some((hook) =>
|
|
45
|
+
hook.event === definition.event && hook.path === relative));
|
|
46
|
+
}
|
|
47
|
+
|
|
28
48
|
export async function install({ host, dryRun = false, force = false, packageRoot } = {}) {
|
|
49
|
+
// Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
|
|
50
|
+
await readRulesRegistry(host);
|
|
29
51
|
const plan = await buildInstallPlan(host, packageRoot);
|
|
30
52
|
const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
|
|
31
53
|
const currentPackage = await packageInfo(packageRoot);
|
|
32
54
|
const record = await readInstallRecord(host);
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
55
|
+
const targets = hookTargets(host);
|
|
56
|
+
for (const target of targets) {
|
|
57
|
+
if (!plan.some((item) => item.relativeToHost === target.relative)) {
|
|
58
|
+
throw new Error(`install plan does not contain hooks/${target.definition.file}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const inspectedHooks = await Promise.all(targets.map(({ spec }) =>
|
|
62
|
+
inspectHook(host.settingsPath, spec)));
|
|
37
63
|
const states = await Promise.all(plan.map(async (item) => ({
|
|
38
64
|
item,
|
|
39
65
|
exists: await targetExists(item.target),
|
|
@@ -66,7 +92,8 @@ export async function install({ host, dryRun = false, force = false, packageRoot
|
|
|
66
92
|
const sameRecord = recordMatchesPackage(record, plan, currentPackage,
|
|
67
93
|
new Map(states.map((state) => [state.item.relativeToHost, state.fingerprint])),
|
|
68
94
|
{ path: rule.target, fingerprint: ruleState.fingerprint });
|
|
69
|
-
if (!changedFiles.length && !changedRule &&
|
|
95
|
+
if (!changedFiles.length && !changedRule && inspectedHooks.every((state) => state.present)
|
|
96
|
+
&& recordHasHooks(record, targets) && sameRecord) {
|
|
70
97
|
if (!dryRun) await addRulesOwner(host);
|
|
71
98
|
return { exitCode: 0, output: 'codex-bridge is already installed; nothing to do.' };
|
|
72
99
|
}
|
|
@@ -77,11 +104,20 @@ export async function install({ host, dryRun = false, force = false, packageRoot
|
|
|
77
104
|
if (changedRule) {
|
|
78
105
|
lines.push(`${ruleState.exists ? 'Would overwrite' : 'Would create'} ${rule.target}`);
|
|
79
106
|
}
|
|
80
|
-
|
|
107
|
+
inspectedHooks.forEach((state, index) => {
|
|
108
|
+
const { definition } = targets[index];
|
|
109
|
+
lines.push(state.present
|
|
110
|
+
? `${definition.event} hook is already registered.`
|
|
111
|
+
: `Would register ${definition.event} hook for matcher ${definition.matcher}.`);
|
|
112
|
+
});
|
|
81
113
|
lines.push('Would write installation record.');
|
|
82
114
|
return { exitCode: 0, output: lines.join('\n') };
|
|
83
115
|
}
|
|
84
116
|
|
|
117
|
+
// Claim ownership of the shared rules file before writing anything. Claiming it last meant a
|
|
118
|
+
// failure at that step left a fully installed host absent from the registry, and the next
|
|
119
|
+
// uninstall elsewhere would then delete the rules out from under it.
|
|
120
|
+
await addRulesOwner(host);
|
|
85
121
|
for (const { item } of changedFiles) await copyPlannedFile(item, host.agentsDir);
|
|
86
122
|
if (changedRule) await copyPlannedFile(rule, host.agentsDir);
|
|
87
123
|
// Seeded files are written once and then belong to the operator: an existing one is left
|
|
@@ -89,15 +125,16 @@ export async function install({ host, dryRun = false, force = false, packageRoot
|
|
|
89
125
|
for (const seed of seedPlan(host, packageRoot)) {
|
|
90
126
|
if (!(await targetExists(seed.target))) await copyPlannedFile(seed, host.agentsDir);
|
|
91
127
|
}
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
path.join(host.agentsDir, 'hooks', 'reply-guard.mjs'),
|
|
95
|
-
inspectedHook,
|
|
96
|
-
);
|
|
97
|
-
const hookRelative = plan.find((item) => path.basename(item.target) === 'reply-guard.mjs')?.relativeToHost;
|
|
98
|
-
if (!hookRelative) throw new Error('install plan does not contain hooks/reply-guard.mjs');
|
|
128
|
+
const hookResults = [];
|
|
129
|
+
for (const { spec } of targets) hookResults.push(await mergeHook(host.settingsPath, spec));
|
|
99
130
|
const fingerprints = Object.fromEntries(await Promise.all(plan.map(async (item) =>
|
|
100
131
|
[item.relativeToHost, await fileFingerprint(item.target)])));
|
|
132
|
+
const hooks = targets.map(({ definition, relative }, index) => {
|
|
133
|
+
const prior = record?.hooks?.find((hook) => hook.event === definition.event);
|
|
134
|
+
const createdGroup = hookResults[index].createdGroup || prior?.createdGroup === true;
|
|
135
|
+
return createdGroup ? { event: definition.event, path: relative, createdGroup: true }
|
|
136
|
+
: { event: definition.event, path: relative };
|
|
137
|
+
});
|
|
101
138
|
await writeInstallRecord(host, {
|
|
102
139
|
...currentPackage,
|
|
103
140
|
installedAt: new Date().toISOString(),
|
|
@@ -105,8 +142,10 @@ export async function install({ host, dryRun = false, force = false, packageRoot
|
|
|
105
142
|
files: plan.map((item) => item.relativeToHost),
|
|
106
143
|
fingerprints,
|
|
107
144
|
rules: { path: rule.target, fingerprint: await fileFingerprint(rule.target) },
|
|
108
|
-
|
|
145
|
+
hooks,
|
|
109
146
|
});
|
|
110
|
-
|
|
111
|
-
|
|
147
|
+
return {
|
|
148
|
+
exitCode: 0,
|
|
149
|
+
output: `Installed ${plan.length + 1} files and registered the ${targets.map(({ definition }) => definition.event).join(' and ')} hooks.`,
|
|
150
|
+
};
|
|
112
151
|
}
|
package/cli/manifest.mjs
CHANGED
|
@@ -3,6 +3,15 @@ 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
|
+
import { HOOK_DEFINITIONS } from '../src/hook-definitions.mjs';
|
|
9
|
+
import {
|
|
10
|
+
renderRequiredInputSummary,
|
|
11
|
+
renderRequiredInputs,
|
|
12
|
+
} from '../src/required-inputs.mjs';
|
|
13
|
+
|
|
14
|
+
export { HOOK_DEFINITIONS };
|
|
6
15
|
|
|
7
16
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
8
17
|
export const PACKAGE_ROOT = path.resolve(HERE, '..');
|
|
@@ -29,7 +38,11 @@ export const INSTALL_TABLE = Object.freeze([
|
|
|
29
38
|
const posix = (value) => value.split(path.sep).join('/');
|
|
30
39
|
|
|
31
40
|
export function replacePlaceholders(content, agentsDir) {
|
|
32
|
-
|
|
41
|
+
const agentType = content.match(/^name:\s*([^\r\n]+)$/m)?.[1].trim();
|
|
42
|
+
return content
|
|
43
|
+
.replaceAll('{{CODEX_BRIDGE_DIR}}', posix(path.resolve(agentsDir)))
|
|
44
|
+
.replaceAll('{{CODEX_REQUIRED_INPUTS_SUMMARY}}', renderRequiredInputSummary(agentType))
|
|
45
|
+
.replaceAll('{{CODEX_REQUIRED_INPUTS}}', renderRequiredInputs(agentType));
|
|
33
46
|
}
|
|
34
47
|
|
|
35
48
|
export async function packageInfo(packageRoot = PACKAGE_ROOT) {
|
|
@@ -41,6 +54,10 @@ export function installRecordPath(host) {
|
|
|
41
54
|
return path.join(host.agentsDir, INSTALL_RECORD_NAME);
|
|
42
55
|
}
|
|
43
56
|
|
|
57
|
+
export function installedHookPath(host, definition) {
|
|
58
|
+
return path.join(host.agentsDir, 'hooks', definition.file);
|
|
59
|
+
}
|
|
60
|
+
|
|
44
61
|
/** Where each seeded file comes from and where it goes; contents are copied verbatim. */
|
|
45
62
|
export function seedPlan(host, packageRoot = PACKAGE_ROOT) {
|
|
46
63
|
return SEEDED_SOURCES.map((source) => ({
|
|
@@ -136,21 +153,38 @@ export function validateInstallRecord(record) {
|
|
|
136
153
|
throw new Error('installation record rules fingerprint must be a 64-character hexadecimal SHA256 string');
|
|
137
154
|
}
|
|
138
155
|
}
|
|
139
|
-
if (
|
|
140
|
-
throw new Error('installation record
|
|
156
|
+
if (record.hooks !== undefined && record.hook !== undefined) {
|
|
157
|
+
throw new Error('installation record must use hooks instead of hook');
|
|
141
158
|
}
|
|
142
|
-
|
|
143
|
-
|
|
159
|
+
const hooks = record.hooks !== undefined ? record.hooks : record.hook !== undefined ? [record.hook] : null;
|
|
160
|
+
if (!Array.isArray(hooks) || !hooks.length) {
|
|
161
|
+
throw new Error('installation record hooks must be a non-empty list');
|
|
144
162
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
163
|
+
for (const hook of hooks) {
|
|
164
|
+
if (!hook || typeof hook !== 'object' || Array.isArray(hook)) {
|
|
165
|
+
throw new Error('installation record hook must be an object');
|
|
166
|
+
}
|
|
167
|
+
const definition = HOOK_DEFINITIONS.find((entry) => entry.event === hook.event);
|
|
168
|
+
if (!definition || typeof hook.path !== 'string' || !hook.path) {
|
|
169
|
+
throw new Error('installation record hook must identify a supported event and its path');
|
|
170
|
+
}
|
|
171
|
+
if (path.isAbsolute(hook.path) || hook.path.split(/[\\/]/).includes('..')) {
|
|
172
|
+
throw new Error('installation record hook path must stay relative to the host root');
|
|
173
|
+
}
|
|
174
|
+
if (path.basename(hook.path) !== definition.file || !record.files.includes(hook.path)) {
|
|
175
|
+
throw new Error(`installation record hook path must name the installed ${definition.file}`);
|
|
176
|
+
}
|
|
150
177
|
}
|
|
151
178
|
return record;
|
|
152
179
|
}
|
|
153
180
|
|
|
181
|
+
export function normalizeInstallRecord(record) {
|
|
182
|
+
validateInstallRecord(record);
|
|
183
|
+
if (record.hooks !== undefined) return record;
|
|
184
|
+
const { hook, ...withoutLegacyHook } = record;
|
|
185
|
+
return { ...withoutLegacyHook, hooks: [hook] };
|
|
186
|
+
}
|
|
187
|
+
|
|
154
188
|
export async function readInstallRecord(host) {
|
|
155
189
|
let raw;
|
|
156
190
|
try {
|
|
@@ -165,7 +199,7 @@ export async function readInstallRecord(host) {
|
|
|
165
199
|
} catch (err) {
|
|
166
200
|
throw new Error(`invalid installation record JSON: ${err.message}`);
|
|
167
201
|
}
|
|
168
|
-
const record =
|
|
202
|
+
const record = normalizeInstallRecord(parsed);
|
|
169
203
|
// Earlier versions installed the config as a package file and recorded it. Dropping it here
|
|
170
204
|
// is what stops the next update from calling the operator's edited config an orphan and
|
|
171
205
|
// deleting it, and it needs no migration step: the record is rewritten on the next write.
|
|
@@ -180,8 +214,8 @@ export async function readInstallRecord(host) {
|
|
|
180
214
|
}
|
|
181
215
|
|
|
182
216
|
export async function writeInstallRecord(host, record) {
|
|
183
|
-
|
|
184
|
-
await fs.writeFile(installRecordPath(host), `${JSON.stringify(
|
|
217
|
+
const normalized = normalizeInstallRecord(record);
|
|
218
|
+
await fs.writeFile(installRecordPath(host), `${JSON.stringify(normalized, null, 2)}\n`);
|
|
185
219
|
}
|
|
186
220
|
|
|
187
221
|
export async function buildInstallPlan(host, packageRoot = PACKAGE_ROOT) {
|
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/settings-merge.mjs
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
|
-
/** Merges and removes
|
|
1
|
+
/** Merges and removes named codex-bridge hooks without disturbing host settings. */
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const LEGACY_SPEC = { event: 'SubagentStop', matcher: '*' };
|
|
7
7
|
|
|
8
|
-
function commandFor(guardPath) {
|
|
8
|
+
export function commandFor(guardPath) {
|
|
9
9
|
return `node "${path.resolve(guardPath)}"`;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
function normalizeSpec(specOrPath) {
|
|
13
|
+
if (typeof specOrPath === 'string') {
|
|
14
|
+
return { ...LEGACY_SPEC, command: commandFor(specOrPath) };
|
|
15
|
+
}
|
|
16
|
+
if (!specOrPath || typeof specOrPath !== 'object'
|
|
17
|
+
|| typeof specOrPath.event !== 'string'
|
|
18
|
+
|| typeof specOrPath.matcher !== 'string'
|
|
19
|
+
|| typeof specOrPath.command !== 'string') {
|
|
20
|
+
throw new TypeError('hook spec must contain event, matcher, and command strings');
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
event: specOrPath.event,
|
|
24
|
+
matcher: specOrPath.matcher,
|
|
25
|
+
command: specOrPath.command,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
12
29
|
async function readSettings(settingsPath) {
|
|
13
30
|
try {
|
|
14
31
|
const raw = await fs.readFile(settingsPath, 'utf8');
|
|
@@ -27,8 +44,8 @@ async function readSettings(settingsPath) {
|
|
|
27
44
|
}
|
|
28
45
|
}
|
|
29
46
|
|
|
30
|
-
function groups(settings) {
|
|
31
|
-
const value = settings?.hooks?.[
|
|
47
|
+
function groups(settings, event) {
|
|
48
|
+
const value = settings?.hooks?.[event];
|
|
32
49
|
return Array.isArray(value) ? value : [];
|
|
33
50
|
}
|
|
34
51
|
|
|
@@ -40,12 +57,14 @@ function groupHooks(group) {
|
|
|
40
57
|
return Array.isArray(group?.hooks) ? group.hooks : [];
|
|
41
58
|
}
|
|
42
59
|
|
|
43
|
-
export async function inspectHook(settingsPath,
|
|
60
|
+
export async function inspectHook(settingsPath, specOrPath) {
|
|
44
61
|
const state = await readSettings(settingsPath);
|
|
45
|
-
const
|
|
46
|
-
const present = groups(state.settings
|
|
47
|
-
|
|
48
|
-
|
|
62
|
+
const spec = normalizeSpec(specOrPath);
|
|
63
|
+
const present = groups(state.settings, spec.event)
|
|
64
|
+
.filter((group) => group?.matcher === spec.matcher)
|
|
65
|
+
.some((group) => groupHooks(group)
|
|
66
|
+
.some((hook) => hook?.type === 'command' && hook.command === spec.command));
|
|
67
|
+
return { ...state, ...spec, present };
|
|
49
68
|
}
|
|
50
69
|
|
|
51
70
|
function backupName(settingsPath) {
|
|
@@ -66,35 +85,38 @@ async function atomicWrite(settingsPath, settings, previous) {
|
|
|
66
85
|
}
|
|
67
86
|
}
|
|
68
87
|
|
|
69
|
-
export async function mergeHook(settingsPath,
|
|
70
|
-
const
|
|
88
|
+
export async function mergeHook(settingsPath, specOrPath, inspected) {
|
|
89
|
+
const spec = normalizeSpec(specOrPath);
|
|
90
|
+
const state = inspected || await inspectHook(settingsPath, spec);
|
|
71
91
|
if (state.present) return { changed: false, createdGroup: false };
|
|
72
92
|
const settings = structuredClone(state.settings);
|
|
73
93
|
settings.hooks ??= {};
|
|
74
|
-
if (!Array.isArray(settings.hooks[
|
|
75
|
-
let group = settings.hooks[
|
|
94
|
+
if (!Array.isArray(settings.hooks[spec.event])) settings.hooks[spec.event] = [];
|
|
95
|
+
let group = settings.hooks[spec.event].find((entry) => entry?.matcher === spec.matcher);
|
|
76
96
|
const createdGroup = !group;
|
|
77
97
|
if (!group) {
|
|
78
|
-
group = { matcher:
|
|
79
|
-
settings.hooks[
|
|
98
|
+
group = { matcher: spec.matcher, hooks: [] };
|
|
99
|
+
settings.hooks[spec.event].push(group);
|
|
80
100
|
}
|
|
81
101
|
if (!Array.isArray(group.hooks)) group.hooks = [];
|
|
82
|
-
group.hooks.push(ownHook(
|
|
102
|
+
group.hooks.push(ownHook(spec.command));
|
|
83
103
|
await atomicWrite(settingsPath, settings, state);
|
|
84
104
|
return { changed: true, createdGroup };
|
|
85
105
|
}
|
|
86
106
|
|
|
87
|
-
export async function removeHook(settingsPath,
|
|
88
|
-
const
|
|
107
|
+
export async function removeHook(settingsPath, specOrPath, { createdGroup = false } = {}) {
|
|
108
|
+
const spec = normalizeSpec(specOrPath);
|
|
109
|
+
const state = await inspectHook(settingsPath, spec);
|
|
89
110
|
if (!state.present) return { changed: false };
|
|
90
111
|
const settings = structuredClone(state.settings);
|
|
91
|
-
const eventGroups = groups(settings);
|
|
112
|
+
const eventGroups = groups(settings, spec.event);
|
|
92
113
|
for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
|
|
93
114
|
const group = eventGroups[index];
|
|
115
|
+
if (group?.matcher !== spec.matcher) continue;
|
|
94
116
|
if (!Array.isArray(group?.hooks)) continue;
|
|
95
117
|
group.hooks = group.hooks.filter((hook) =>
|
|
96
|
-
!(hook?.type === 'command' && hook.command ===
|
|
97
|
-
if (createdGroup && group.
|
|
118
|
+
!(hook?.type === 'command' && hook.command === spec.command));
|
|
119
|
+
if (createdGroup && group.hooks.length === 0) eventGroups.splice(index, 1);
|
|
98
120
|
}
|
|
99
121
|
await atomicWrite(settingsPath, settings, state);
|
|
100
122
|
return { changed: true };
|