@lyupro/codex-bridge 0.1.2 → 0.1.4
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 +26 -0
- package/README.md +2 -1
- package/cli/doctor.mjs +37 -35
- package/cli/install.mjs +50 -16
- package/cli/manifest.mjs +49 -13
- package/cli/settings-merge.mjs +58 -22
- package/cli/uninstall.mjs +25 -6
- package/cli/update.mjs +33 -7
- package/package.json +1 -1
- package/src/agents/codex-build.md +19 -17
- package/src/agents/codex-review.md +17 -5
- package/src/agents/codex-scout.md +18 -12
- package/src/hook-definitions.mjs +36 -0
- package/src/hooks/live-runs.mjs +98 -0
- package/src/hooks/order-gate.mjs +68 -0
- package/src/hooks/reply-guard.mjs +43 -20
- package/src/hooks/worktree-lock.mjs +70 -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 +147 -0
- package/src/runner/args.mjs +13 -5
- package/src/runner/continuation.mjs +107 -0
- package/src/runner/launcher.mjs +12 -40
- package/src/runner/prompts.mjs +9 -0
- package/src/runner/schemas.mjs +23 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,32 @@ 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.4] - 2026-08-05
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- A continuation has to prove the orchestrator ordered it. `--continue` is refused unless the task text carries a `continue:` grant naming the run being continued and why, and the refusal costs nothing: no run folder is created and Codex is never started. The grant is single-use by construction rather than by a counter — continuing the last run of a chain appends a later one, so the same line stops matching by itself. Until this release a dispatcher could decide on a second pass alone: one did, spent 75 691 tokens of the Codex subscription and created a module the order never asked for.
|
|
12
|
+
- A reply that stays silent about a live `codex-build` run of the same project is blocked. The guard reads the project's runs folder from disk instead of trusting the paths the reply chose to mention, so a second run can no longer be left working in the background while the verdict of the first is handed back. Runs of `codex-scout` and `codex-review` are not counted: they hold a read-only sandbox, and blocking a reply over one would spend the guard's budget on a run that touches nothing.
|
|
13
|
+
- A `PreToolUse` lock refuses file edits inside a repository held by a live `codex-build` run. Knowing that a run is live is not the same as being unable to disturb it — edits made during that window land in the runner's before/after snapshot and fail an honest run for work it never did. An edit made through `Bash` still walks past the lock; parsing a command line is not something a guard can do reliably.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- `doctor` names the file each hook was registered for. With two hooks on one event it matched the installation record by event alone and reported the worktree lock's matcher as pointing at `order-gate.mjs` — the one line where an operator can check what is installed, showing the wrong file.
|
|
18
|
+
- Hook presence is decided by this package's own command rather than by the matcher it currently sits under. The matcher is generated from the list of host tool names and grows whenever a spelling is added, so a host installed under the previous matcher would collect a duplicate hook on update and keep a hook pointing at a deleted file after uninstall.
|
|
19
|
+
|
|
20
|
+
## [0.1.3] - 2026-08-05
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- 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.
|
|
25
|
+
- `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.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- 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.
|
|
30
|
+
- 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.
|
|
31
|
+
- 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.
|
|
32
|
+
|
|
7
33
|
## [0.1.2] - 2026-08-04
|
|
8
34
|
|
|
9
35
|
### 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,35 @@ 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
|
+
// The file, not just the event: PreToolUse carries two hooks of this package, and matching
|
|
77
|
+
// by event alone reported the worktree lock's matcher as pointing at order-gate.mjs. The
|
|
78
|
+
// hook line is the only place an operator can see WHICH file a matcher was registered for,
|
|
79
|
+
// so a wrong name here is worse than no line at all.
|
|
80
|
+
const recorded = record.hooks.find((hook) => hook.event === definition.event
|
|
81
|
+
&& path.basename(hook.path) === definition.file);
|
|
82
|
+
if (!recorded) {
|
|
83
|
+
return check(key, 'warn', `${definition.event} hook is not present in the installation record`);
|
|
84
|
+
}
|
|
85
|
+
const expected = path.resolve(host.root, recorded.path);
|
|
86
|
+
try {
|
|
87
|
+
const state = await inspectHook(host.settingsPath, {
|
|
88
|
+
event: definition.event,
|
|
89
|
+
matcher: definition.matcher,
|
|
90
|
+
command: commandFor(expected),
|
|
91
|
+
});
|
|
92
|
+
if (state.present) {
|
|
93
|
+
return check(key, 'ok', `${definition.event} matcher ${definition.matcher} -> ${expected}`);
|
|
94
|
+
}
|
|
95
|
+
return check(key, 'warn', `${definition.event} matcher ${definition.matcher} does not point to the installed ${definition.file}`);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
const value = err.code === 'ENOENT' ? 'settings.json is absent' : `settings.json is invalid: ${err.message}`;
|
|
98
|
+
return check(key, 'warn', `${definition.event} hook: ${value}`);
|
|
99
|
+
}
|
|
100
|
+
}));
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
async function rulesCheck(host, record) {
|
|
@@ -155,7 +157,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
155
157
|
));
|
|
156
158
|
const rules = await rulesCheck(host, record);
|
|
157
159
|
checks.push(rules);
|
|
158
|
-
checks.push(await
|
|
160
|
+
checks.push(...await hookChecks(host, record));
|
|
159
161
|
|
|
160
162
|
const codex = codexProbe();
|
|
161
163
|
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,7 +14,7 @@ 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';
|
|
17
|
+
import { commandFor, inspectHook, mergeHook } from './settings-merge.mjs';
|
|
16
18
|
import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
|
|
17
19
|
|
|
18
20
|
async function targetExists(target) {
|
|
@@ -25,6 +27,24 @@ 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 } = {}) {
|
|
29
49
|
// Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
|
|
30
50
|
await readRulesRegistry(host);
|
|
@@ -32,10 +52,14 @@ export async function install({ host, dryRun = false, force = false, packageRoot
|
|
|
32
52
|
const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
|
|
33
53
|
const currentPackage = await packageInfo(packageRoot);
|
|
34
54
|
const record = await readInstallRecord(host);
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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)));
|
|
39
63
|
const states = await Promise.all(plan.map(async (item) => ({
|
|
40
64
|
item,
|
|
41
65
|
exists: await targetExists(item.target),
|
|
@@ -68,7 +92,8 @@ export async function install({ host, dryRun = false, force = false, packageRoot
|
|
|
68
92
|
const sameRecord = recordMatchesPackage(record, plan, currentPackage,
|
|
69
93
|
new Map(states.map((state) => [state.item.relativeToHost, state.fingerprint])),
|
|
70
94
|
{ path: rule.target, fingerprint: ruleState.fingerprint });
|
|
71
|
-
if (!changedFiles.length && !changedRule &&
|
|
95
|
+
if (!changedFiles.length && !changedRule && inspectedHooks.every((state) => state.present)
|
|
96
|
+
&& recordHasHooks(record, targets) && sameRecord) {
|
|
72
97
|
if (!dryRun) await addRulesOwner(host);
|
|
73
98
|
return { exitCode: 0, output: 'codex-bridge is already installed; nothing to do.' };
|
|
74
99
|
}
|
|
@@ -79,7 +104,12 @@ export async function install({ host, dryRun = false, force = false, packageRoot
|
|
|
79
104
|
if (changedRule) {
|
|
80
105
|
lines.push(`${ruleState.exists ? 'Would overwrite' : 'Would create'} ${rule.target}`);
|
|
81
106
|
}
|
|
82
|
-
|
|
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
|
+
});
|
|
83
113
|
lines.push('Would write installation record.');
|
|
84
114
|
return { exitCode: 0, output: lines.join('\n') };
|
|
85
115
|
}
|
|
@@ -95,15 +125,16 @@ export async function install({ host, dryRun = false, force = false, packageRoot
|
|
|
95
125
|
for (const seed of seedPlan(host, packageRoot)) {
|
|
96
126
|
if (!(await targetExists(seed.target))) await copyPlannedFile(seed, host.agentsDir);
|
|
97
127
|
}
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
path.join(host.agentsDir, 'hooks', 'reply-guard.mjs'),
|
|
101
|
-
inspectedHook,
|
|
102
|
-
);
|
|
103
|
-
const hookRelative = plan.find((item) => path.basename(item.target) === 'reply-guard.mjs')?.relativeToHost;
|
|
104
|
-
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));
|
|
105
130
|
const fingerprints = Object.fromEntries(await Promise.all(plan.map(async (item) =>
|
|
106
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
|
+
});
|
|
107
138
|
await writeInstallRecord(host, {
|
|
108
139
|
...currentPackage,
|
|
109
140
|
installedAt: new Date().toISOString(),
|
|
@@ -111,7 +142,10 @@ export async function install({ host, dryRun = false, force = false, packageRoot
|
|
|
111
142
|
files: plan.map((item) => item.relativeToHost),
|
|
112
143
|
fingerprints,
|
|
113
144
|
rules: { path: rule.target, fingerprint: await fileFingerprint(rule.target) },
|
|
114
|
-
|
|
145
|
+
hooks,
|
|
115
146
|
});
|
|
116
|
-
return {
|
|
147
|
+
return {
|
|
148
|
+
exitCode: 0,
|
|
149
|
+
output: `Installed ${plan.length + 1} files and registered the ${targets.map(({ definition }) => definition.event).join(' and ')} hooks.`,
|
|
150
|
+
};
|
|
117
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,40 @@ 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 definitions = HOOK_DEFINITIONS.filter((entry) => entry.event === hook.event);
|
|
168
|
+
if (!definitions.length || 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
|
+
const definition = definitions.find((entry) => path.basename(hook.path) === entry.file);
|
|
175
|
+
if (!definition || !record.files.includes(hook.path)) {
|
|
176
|
+
const files = definitions.map((entry) => entry.file).join(' or ');
|
|
177
|
+
throw new Error(`installation record hook path must name the installed ${files}`);
|
|
178
|
+
}
|
|
150
179
|
}
|
|
151
180
|
return record;
|
|
152
181
|
}
|
|
153
182
|
|
|
183
|
+
export function normalizeInstallRecord(record) {
|
|
184
|
+
validateInstallRecord(record);
|
|
185
|
+
if (record.hooks !== undefined) return record;
|
|
186
|
+
const { hook, ...withoutLegacyHook } = record;
|
|
187
|
+
return { ...withoutLegacyHook, hooks: [hook] };
|
|
188
|
+
}
|
|
189
|
+
|
|
154
190
|
export async function readInstallRecord(host) {
|
|
155
191
|
let raw;
|
|
156
192
|
try {
|
|
@@ -165,7 +201,7 @@ export async function readInstallRecord(host) {
|
|
|
165
201
|
} catch (err) {
|
|
166
202
|
throw new Error(`invalid installation record JSON: ${err.message}`);
|
|
167
203
|
}
|
|
168
|
-
const record =
|
|
204
|
+
const record = normalizeInstallRecord(parsed);
|
|
169
205
|
// Earlier versions installed the config as a package file and recorded it. Dropping it here
|
|
170
206
|
// is what stops the next update from calling the operator's edited config an orphan and
|
|
171
207
|
// deleting it, and it needs no migration step: the record is rewritten on the next write.
|
|
@@ -180,8 +216,8 @@ export async function readInstallRecord(host) {
|
|
|
180
216
|
}
|
|
181
217
|
|
|
182
218
|
export async function writeInstallRecord(host, record) {
|
|
183
|
-
|
|
184
|
-
await fs.writeFile(installRecordPath(host), `${JSON.stringify(
|
|
219
|
+
const normalized = normalizeInstallRecord(record);
|
|
220
|
+
await fs.writeFile(installRecordPath(host), `${JSON.stringify(normalized, null, 2)}\n`);
|
|
185
221
|
}
|
|
186
222
|
|
|
187
223
|
export async function buildInstallPlan(host, packageRoot = PACKAGE_ROOT) {
|
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,25 @@ function groupHooks(group) {
|
|
|
40
57
|
return Array.isArray(group?.hooks) ? group.hooks : [];
|
|
41
58
|
}
|
|
42
59
|
|
|
43
|
-
|
|
60
|
+
const hasOwnCommand = (group, command) =>
|
|
61
|
+
groupHooks(group).some((hook) => hook?.type === 'command' && hook.command === command);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Presence is decided by the command, not by the matcher it currently sits under.
|
|
65
|
+
*
|
|
66
|
+
* The matcher is generated from the tool list in hook-definitions.mjs, so it changes whenever a
|
|
67
|
+
* host spelling is added — `Agent` became `Agent|Task` in one release, and the write-tool matcher
|
|
68
|
+
* will grow the same way. A host installed under the old matcher would then be invisible to the
|
|
69
|
+
* new lookup: update would register a duplicate and uninstall would leave a hook pointing at a
|
|
70
|
+
* deleted file. The command is this package's own absolute path, so it identifies our entry
|
|
71
|
+
* wherever the group ended up.
|
|
72
|
+
*/
|
|
73
|
+
export async function inspectHook(settingsPath, specOrPath) {
|
|
44
74
|
const state = await readSettings(settingsPath);
|
|
45
|
-
const
|
|
46
|
-
const present = groups(state.settings
|
|
47
|
-
|
|
48
|
-
return { ...state,
|
|
75
|
+
const spec = normalizeSpec(specOrPath);
|
|
76
|
+
const present = groups(state.settings, spec.event)
|
|
77
|
+
.some((group) => hasOwnCommand(group, spec.command));
|
|
78
|
+
return { ...state, ...spec, present };
|
|
49
79
|
}
|
|
50
80
|
|
|
51
81
|
function backupName(settingsPath) {
|
|
@@ -66,35 +96,41 @@ async function atomicWrite(settingsPath, settings, previous) {
|
|
|
66
96
|
}
|
|
67
97
|
}
|
|
68
98
|
|
|
69
|
-
export async function mergeHook(settingsPath,
|
|
70
|
-
const
|
|
99
|
+
export async function mergeHook(settingsPath, specOrPath, inspected) {
|
|
100
|
+
const spec = normalizeSpec(specOrPath);
|
|
101
|
+
const state = inspected || await inspectHook(settingsPath, spec);
|
|
71
102
|
if (state.present) return { changed: false, createdGroup: false };
|
|
72
103
|
const settings = structuredClone(state.settings);
|
|
73
104
|
settings.hooks ??= {};
|
|
74
|
-
if (!Array.isArray(settings.hooks[
|
|
75
|
-
let group = settings.hooks[
|
|
105
|
+
if (!Array.isArray(settings.hooks[spec.event])) settings.hooks[spec.event] = [];
|
|
106
|
+
let group = settings.hooks[spec.event].find((entry) => entry?.matcher === spec.matcher);
|
|
76
107
|
const createdGroup = !group;
|
|
77
108
|
if (!group) {
|
|
78
|
-
group = { matcher:
|
|
79
|
-
settings.hooks[
|
|
109
|
+
group = { matcher: spec.matcher, hooks: [] };
|
|
110
|
+
settings.hooks[spec.event].push(group);
|
|
80
111
|
}
|
|
81
112
|
if (!Array.isArray(group.hooks)) group.hooks = [];
|
|
82
|
-
group.hooks.push(ownHook(
|
|
113
|
+
group.hooks.push(ownHook(spec.command));
|
|
83
114
|
await atomicWrite(settingsPath, settings, state);
|
|
84
115
|
return { changed: true, createdGroup };
|
|
85
116
|
}
|
|
86
117
|
|
|
87
|
-
export async function removeHook(settingsPath,
|
|
88
|
-
const
|
|
118
|
+
export async function removeHook(settingsPath, specOrPath, { createdGroup = false } = {}) {
|
|
119
|
+
const spec = normalizeSpec(specOrPath);
|
|
120
|
+
const state = await inspectHook(settingsPath, spec);
|
|
89
121
|
if (!state.present) return { changed: false };
|
|
90
122
|
const settings = structuredClone(state.settings);
|
|
91
|
-
const eventGroups = groups(settings);
|
|
123
|
+
const eventGroups = groups(settings, spec.event);
|
|
124
|
+
// Removal follows the same rule as the lookup above: our command, whatever matcher it is
|
|
125
|
+
// filed under. A foreign hook is left alone because its command is not ours, not because it
|
|
126
|
+
// sits in another group.
|
|
92
127
|
for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
|
|
93
128
|
const group = eventGroups[index];
|
|
129
|
+
if (!hasOwnCommand(group, spec.command)) continue;
|
|
94
130
|
if (!Array.isArray(group?.hooks)) continue;
|
|
95
131
|
group.hooks = group.hooks.filter((hook) =>
|
|
96
|
-
!(hook?.type === 'command' && hook.command ===
|
|
97
|
-
if (createdGroup && group.
|
|
132
|
+
!(hook?.type === 'command' && hook.command === spec.command));
|
|
133
|
+
if (createdGroup && group.hooks.length === 0) eventGroups.splice(index, 1);
|
|
98
134
|
}
|
|
99
135
|
await atomicWrite(settingsPath, settings, state);
|
|
100
136
|
return { changed: true };
|
package/cli/uninstall.mjs
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
/** Uninstalls only recorded codex-bridge files while preserving host data and foreign files. */
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
fileFingerprint,
|
|
6
|
+
HOOK_DEFINITIONS,
|
|
7
|
+
installRecordPath,
|
|
8
|
+
readInstallRecord,
|
|
9
|
+
} from './manifest.mjs';
|
|
10
|
+
import { commandFor, removeHook } from './settings-merge.mjs';
|
|
6
11
|
import { readRulesRegistry, removeRulesOwner, remainingRulesOwners } from './rules-owners.mjs';
|
|
7
12
|
|
|
8
13
|
async function removeEmpty(directory) {
|
|
@@ -25,6 +30,10 @@ function remainingOwnersText(count) {
|
|
|
25
30
|
return `${count} other owner${count === 1 ? '' : 's'} ${count === 1 ? 'remains' : 'remain'}`;
|
|
26
31
|
}
|
|
27
32
|
|
|
33
|
+
function hookDefinition(event) {
|
|
34
|
+
return HOOK_DEFINITIONS.find((definition) => definition.event === event);
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
export async function uninstall({ host, dryRun = false } = {}) {
|
|
29
38
|
// Preflight before removing the hook: package removal on a broken registry left the host without its watchdog.
|
|
30
39
|
let registry = null;
|
|
@@ -61,14 +70,24 @@ export async function uninstall({ host, dryRun = false } = {}) {
|
|
|
61
70
|
}
|
|
62
71
|
}
|
|
63
72
|
}
|
|
64
|
-
|
|
73
|
+
for (const hook of record.hooks) {
|
|
74
|
+
const definition = hookDefinition(hook.event);
|
|
75
|
+
lines.push(`Would remove the ${hook.event} hook for matcher ${definition.matcher}.`);
|
|
76
|
+
}
|
|
77
|
+
lines.push('Would remove the installation record.');
|
|
65
78
|
lines.push(preservation);
|
|
66
79
|
return { exitCode: 0, output: lines.join('\n') };
|
|
67
80
|
}
|
|
68
81
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
for (const hook of record.hooks) {
|
|
83
|
+
const definition = hookDefinition(hook.event);
|
|
84
|
+
const target = path.join(host.root, hook.path);
|
|
85
|
+
await removeHook(host.settingsPath, {
|
|
86
|
+
event: hook.event,
|
|
87
|
+
matcher: definition.matcher,
|
|
88
|
+
command: commandFor(target),
|
|
89
|
+
}, { createdGroup: hook.createdGroup === true });
|
|
90
|
+
}
|
|
72
91
|
let ownership = null;
|
|
73
92
|
if (!registryError) {
|
|
74
93
|
try {
|
package/cli/update.mjs
CHANGED
|
@@ -5,13 +5,15 @@ import { install } from './install.mjs';
|
|
|
5
5
|
import {
|
|
6
6
|
buildInstallPlan,
|
|
7
7
|
fileFingerprint,
|
|
8
|
+
HOOK_DEFINITIONS,
|
|
9
|
+
installedHookPath,
|
|
8
10
|
packageInfo,
|
|
9
11
|
readInstallRecord,
|
|
10
12
|
recordMatchesPackage,
|
|
11
13
|
rulesPlan,
|
|
12
14
|
} from './manifest.mjs';
|
|
13
15
|
import { targetMatches } from './copy.mjs';
|
|
14
|
-
import { inspectHook } from './settings-merge.mjs';
|
|
16
|
+
import { commandFor, inspectHook } from './settings-merge.mjs';
|
|
15
17
|
import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
|
|
16
18
|
|
|
17
19
|
async function exists(target) {
|
|
@@ -37,6 +39,23 @@ function classifyOrphan(state, record) {
|
|
|
37
39
|
return 'modified';
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
function hookTargets(host) {
|
|
43
|
+
return HOOK_DEFINITIONS.map((definition) => {
|
|
44
|
+
const target = installedHookPath(host, definition);
|
|
45
|
+
return {
|
|
46
|
+
definition,
|
|
47
|
+
relative: path.relative(host.root, target).split(path.sep).join('/'),
|
|
48
|
+
spec: { event: definition.event, matcher: definition.matcher, command: commandFor(target) },
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function recordHasHooks(record, targets) {
|
|
54
|
+
return Boolean(record)
|
|
55
|
+
&& targets.every(({ definition, relative }) => record.hooks?.some((hook) =>
|
|
56
|
+
hook.event === definition.event && hook.path === relative));
|
|
57
|
+
}
|
|
58
|
+
|
|
40
59
|
/**
|
|
41
60
|
* What the run actually did, counted by outcome. `Updated codex-bridge.` alone told the operator
|
|
42
61
|
* nothing about the files that were rewritten or removed on their behalf.
|
|
@@ -62,7 +81,7 @@ function conflictOutput(conflicts, legacy, dryRun) {
|
|
|
62
81
|
return [heading, ...lines].join('\n');
|
|
63
82
|
}
|
|
64
83
|
|
|
65
|
-
function dryRunOutput(states,
|
|
84
|
+
function dryRunOutput(states, hookStates, legacy) {
|
|
66
85
|
const lines = [];
|
|
67
86
|
for (const state of states) {
|
|
68
87
|
if (state.status === 'outdated') lines.push(`Would update ${state.relative}.`);
|
|
@@ -72,7 +91,10 @@ function dryRunOutput(states, hookPresent, legacy) {
|
|
|
72
91
|
if (state.status === 'orphaned' && state.exists) lines.push(`Would remove ${state.relative}.`);
|
|
73
92
|
if (state.status === 'modified' && !state.item) lines.push(`Would preserve modified orphan ${state.relative}.`);
|
|
74
93
|
}
|
|
75
|
-
|
|
94
|
+
hookStates.forEach(({ state, target }) => {
|
|
95
|
+
const { definition } = target;
|
|
96
|
+
if (!state.present) lines.push(`Would register ${definition.event} hook for matcher ${definition.matcher}.`);
|
|
97
|
+
});
|
|
76
98
|
lines.push('Would write installation record with new fingerprints.');
|
|
77
99
|
if (legacy && states.some((state) => state.status === 'modified')) {
|
|
78
100
|
lines.push('Installation record has no fingerprints; differing files are treated as modified.');
|
|
@@ -135,19 +157,23 @@ export async function update({ host, dryRun = false, force = false, packageRoot
|
|
|
135
157
|
return { exitCode: 1, output: conflictOutput(conflicts, legacy, dryRun) };
|
|
136
158
|
}
|
|
137
159
|
|
|
138
|
-
const
|
|
139
|
-
const
|
|
160
|
+
const targets = hookTargets(host);
|
|
161
|
+
const inspectedHooks = await Promise.all(targets.map(async (target) => ({
|
|
162
|
+
target,
|
|
163
|
+
state: await inspectHook(host.settingsPath, target.spec),
|
|
164
|
+
})));
|
|
140
165
|
const currentPackage = await packageInfo(packageRoot);
|
|
141
166
|
const recordCurrent = recordMatchesPackage(record, plan, currentPackage,
|
|
142
167
|
new Map(plannedStates.map((state) => [state.relative, state.fingerprint])),
|
|
143
168
|
{ path: rule.target, fingerprint: ruleState.fingerprint });
|
|
144
169
|
const changed = states.some((state) => state.status !== 'up-to-date');
|
|
145
|
-
if (!changed &&
|
|
170
|
+
if (!changed && inspectedHooks.every(({ state }) => state.present)
|
|
171
|
+
&& recordHasHooks(record, targets) && recordCurrent) {
|
|
146
172
|
if (!dryRun) await addRulesOwner(host);
|
|
147
173
|
return { exitCode: 0, output: 'codex-bridge is up to date' };
|
|
148
174
|
}
|
|
149
175
|
if (dryRun) {
|
|
150
|
-
return { exitCode: 0, output: dryRunOutput(states,
|
|
176
|
+
return { exitCode: 0, output: dryRunOutput(states, inspectedHooks, legacy) };
|
|
151
177
|
}
|
|
152
178
|
|
|
153
179
|
for (const state of orphanStates) {
|