@lyupro/codex-bridge 0.3.1 → 0.5.0

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +88 -84
  3. package/bin/codex-bridge.mjs +38 -11
  4. package/cli/copy.mjs +7 -6
  5. package/cli/doctor.mjs +73 -12
  6. package/cli/hook-targets.mjs +39 -0
  7. package/cli/hook.mjs +34 -0
  8. package/cli/hosts.mjs +28 -4
  9. package/cli/install-record.mjs +287 -0
  10. package/cli/install.mjs +73 -39
  11. package/cli/invoked-directly.mjs +37 -0
  12. package/cli/manifest.mjs +90 -164
  13. package/cli/permissions.mjs +179 -0
  14. package/cli/projects.mjs +20 -5
  15. package/cli/prune-args.mjs +1 -1
  16. package/cli/remove-layout.mjs +59 -0
  17. package/cli/rules-owners.mjs +6 -8
  18. package/cli/runs-inventory.mjs +2 -1
  19. package/cli/settings-merge.mjs +182 -50
  20. package/cli/stop.mjs +33 -20
  21. package/cli/uninstall.mjs +64 -36
  22. package/cli/unlock.mjs +239 -0
  23. package/cli/update.mjs +109 -48
  24. package/package.json +2 -2
  25. package/scripts/check-file-size.mjs +8 -10
  26. package/scripts/isolated-roots.mjs +13 -0
  27. package/scripts/run-tests.mjs +14 -4
  28. package/src/agents/codex-build.md +17 -13
  29. package/src/agents/codex-review.md +15 -9
  30. package/src/agents/codex-scout.md +16 -11
  31. package/src/commands/env.md +4 -4
  32. package/src/commands/usage.md +8 -6
  33. package/src/heartbeat.mjs +1 -1
  34. package/src/hook-definitions.mjs +17 -4
  35. package/src/hooks/guard-tries.mjs +59 -0
  36. package/src/hooks/live-runs.mjs +31 -20
  37. package/src/hooks/order-gate.mjs +7 -2
  38. package/src/hooks/reply-guard.mjs +4 -60
  39. package/src/hooks/stop-guard.mjs +71 -0
  40. package/src/json-file.mjs +30 -0
  41. package/src/meta/chain.mjs +8 -6
  42. package/src/meta/paths.mjs +5 -9
  43. package/src/meta/pre-start.mjs +30 -0
  44. package/src/meta/run-state.mjs +31 -16
  45. package/src/process-identity.mjs +140 -0
  46. package/src/required-inputs.mjs +42 -0
  47. package/src/run-config.mjs +4 -8
  48. package/src/runner/args.mjs +26 -3
  49. package/src/runner/attach.mjs +32 -19
  50. package/src/runner/codex-cmd.mjs +1 -1
  51. package/src/runner/continuation.mjs +34 -3
  52. package/src/runner/launcher.mjs +54 -28
  53. package/src/runner/project-dir.mjs +4 -3
  54. package/src/runner/scope-check.mjs +159 -0
  55. package/src/runner/worker.mjs +13 -5
  56. package/src/stop-contract.mjs +15 -0
  57. package/src/write-meta.mjs +12 -1
  58. package/cli/sweep.mjs +0 -144
@@ -2,13 +2,14 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { liveRuns } from '../src/hooks/live-runs.mjs';
5
+ import { readJsonFileSync } from '../src/json-file.mjs';
5
6
  import { runsRoot } from '../src/runner/runs-root.mjs';
6
7
 
7
8
  const record = (value) => value && typeof value === 'object' && !Array.isArray(value);
8
9
 
9
10
  function readRecord(file) {
10
11
  try {
11
- const value = JSON.parse(fs.readFileSync(file, 'utf8'));
12
+ const value = readJsonFileSync(file);
12
13
  return record(value) ? value : null;
13
14
  } catch {
14
15
  return null;
@@ -1,14 +1,87 @@
1
1
  /** Merges and removes named codex-bridge hooks without disturbing host settings. */
2
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { existsSync, statSync } from 'node:fs';
2
5
  import fs from 'node:fs/promises';
3
6
  import path from 'node:path';
4
7
  import { randomUUID } from 'node:crypto';
8
+ import { readJsonFileWithRaw } from '../src/json-file.mjs';
5
9
 
6
10
  const LEGACY_SPEC = { event: 'SubagentStop', matcher: '*' };
11
+ const settingsRuns = new AsyncLocalStorage();
7
12
 
8
13
  export function commandFor(guardPath) {
9
14
  return `node "${path.resolve(guardPath)}"`;
10
15
  }
11
16
 
17
+ export function shortCommandFor(name) {
18
+ return `codex-bridge hook ${name}`;
19
+ }
20
+
21
+ function pathValue(env) {
22
+ const key = Object.keys(env).find((entry) => entry.toLowerCase() === 'path');
23
+ return key ? env[key] : '';
24
+ }
25
+
26
+ export function commandReachable(name = 'codex-bridge', env = process.env) {
27
+ const extensions = process.platform === 'win32'
28
+ ? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';')
29
+ : [''];
30
+ return pathValue(env).split(path.delimiter).filter(Boolean).some((directory) =>
31
+ extensions.some((extension) => {
32
+ const candidate = path.join(directory, `${name}${extension}`);
33
+ try {
34
+ return existsSync(candidate) && statSync(candidate).isFile();
35
+ } catch {
36
+ return false;
37
+ }
38
+ }));
39
+ }
40
+
41
+ /**
42
+ * The version the command on PATH reports, or null when there is none or it will not say.
43
+ *
44
+ * Existence used to be the only question, and on 2026-08-11 that cost the operator every guard on
45
+ * the machine: an install from the clone wrote `codex-bridge hook <name>` while the command on
46
+ * PATH was the previous release, which has no `hook` subcommand. Every guard then failed before it
47
+ * could decide anything, and the host refused Bash, PowerShell, file edits and agent launches.
48
+ */
49
+ export function reachableCommandVersion(name = 'codex-bridge', env = process.env) {
50
+ if (!commandReachable(name, env)) return null;
51
+ const result = spawnSync(name, ['--version'], {
52
+ encoding: 'utf8',
53
+ env,
54
+ // The launcher npm writes on Windows is a .cmd shim, which spawn cannot execute without a
55
+ // shell. The name is a constant of this module, never operator input.
56
+ shell: process.platform === 'win32',
57
+ });
58
+ if (result.error || result.status !== 0) return null;
59
+ return String(result.stdout || '').trim().split(/\r?\n/).pop()?.trim() || null;
60
+ }
61
+
62
+ export function hookRegistration(name, target, env = process.env, packageVersion = null) {
63
+ // Requiring the versions to match is stricter than asking whether `hook` exists, deliberately:
64
+ // a command of another version would run code this install has never seen, which is the hazard
65
+ // README describes when a global install and a clone coexist. Anything short of a match falls
66
+ // back to the copy this install placed itself.
67
+ const reachable = reachableCommandVersion('codex-bridge', env);
68
+ if (reachable && packageVersion && reachable === packageVersion) {
69
+ return {
70
+ command: shortCommandFor(name),
71
+ form: 'short',
72
+ reason: `codex-bridge ${reachable} on PATH matches this package; the global command will execute`,
73
+ };
74
+ }
75
+ const reason = reachable
76
+ ? `codex-bridge on PATH reports ${reachable}, not ${packageVersion}; the installed copy will execute`
77
+ : 'codex-bridge is not reachable from PATH; the installed copy will execute';
78
+ return { command: commandFor(target), form: 'path', reason };
79
+ }
80
+
81
+ export function commandForm(command) {
82
+ return command.startsWith('codex-bridge hook ') ? 'short' : 'path';
83
+ }
84
+
12
85
  function normalizeSpec(specOrPath) {
13
86
  if (typeof specOrPath === 'string') {
14
87
  return { ...LEGACY_SPEC, command: commandFor(specOrPath) };
@@ -23,21 +96,19 @@ function normalizeSpec(specOrPath) {
23
96
  event: specOrPath.event,
24
97
  matcher: specOrPath.matcher,
25
98
  command: specOrPath.command,
99
+ alternateCommands: Array.isArray(specOrPath.alternateCommands)
100
+ ? specOrPath.alternateCommands.filter((command) => typeof command === 'string')
101
+ : [],
26
102
  };
27
103
  }
28
104
 
29
- async function readSettings(settingsPath) {
105
+ export async function readSettings(settingsPath) {
30
106
  try {
31
- const raw = await fs.readFile(settingsPath, 'utf8');
32
- try {
33
- const parsed = JSON.parse(raw);
34
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
35
- throw new Error('top level must be an object');
36
- }
37
- return { exists: true, raw, settings: parsed };
38
- } catch (err) {
39
- throw new Error(`cannot parse ${settingsPath}: ${err.message}`);
107
+ const { raw, value: settings } = await readJsonFileWithRaw(settingsPath);
108
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
109
+ throw new Error(`cannot parse ${settingsPath}: top level must be an object`);
40
110
  }
111
+ return { exists: true, raw, settings };
41
112
  } catch (err) {
42
113
  if (err.code === 'ENOENT') return { exists: false, raw: null, settings: {} };
43
114
  throw err;
@@ -57,8 +128,10 @@ function groupHooks(group) {
57
128
  return Array.isArray(group?.hooks) ? group.hooks : [];
58
129
  }
59
130
 
60
- const hasOwnCommand = (group, command) =>
61
- groupHooks(group).some((hook) => hook?.type === 'command' && hook.command === command);
131
+ const hasOwnCommand = (group, commands) => {
132
+ const expected = Array.isArray(commands) ? commands : [commands];
133
+ return groupHooks(group).some((hook) => hook?.type === 'command' && expected.includes(hook.command));
134
+ };
62
135
 
63
136
  /**
64
137
  * Presence is decided by the command, not by the matcher it currently sits under.
@@ -73,9 +146,17 @@ const hasOwnCommand = (group, command) =>
73
146
  export async function inspectHook(settingsPath, specOrPath) {
74
147
  const state = await readSettings(settingsPath);
75
148
  const spec = normalizeSpec(specOrPath);
76
- const present = groups(state.settings, spec.event)
77
- .some((group) => hasOwnCommand(group, spec.command));
78
- return { ...state, ...spec, present };
149
+ const commands = [spec.command, ...spec.alternateCommands];
150
+ let matchedCommand;
151
+ for (const group of groups(state.settings, spec.event)) {
152
+ const hook = groupHooks(group).find((entry) =>
153
+ entry?.type === 'command' && commands.includes(entry.command));
154
+ if (hook) {
155
+ matchedCommand = hook.command;
156
+ break;
157
+ }
158
+ }
159
+ return { ...state, ...spec, present: Boolean(matchedCommand), matchedCommand };
79
160
  }
80
161
 
81
162
  function backupName(settingsPath) {
@@ -83,9 +164,38 @@ function backupName(settingsPath) {
83
164
  return `${settingsPath}.codex-bridge-backup-${stamp}-${randomUUID()}`;
84
165
  }
85
166
 
167
+ function runKey(settingsPath) {
168
+ return path.resolve(settingsPath);
169
+ }
170
+
171
+ /**
172
+ * A command gets one in-memory backup scope. AsyncLocalStorage keeps concurrent commands separate
173
+ * in one process, while nested install calls reuse their caller's scope (Plan_24).
174
+ */
175
+ export async function withSettingsRun(settingsPath, action) {
176
+ if (typeof action !== 'function') throw new TypeError('settings run action must be a function');
177
+ const key = runKey(settingsPath);
178
+ const active = settingsRuns.getStore();
179
+ if (active?.key === key) return action();
180
+ return settingsRuns.run({ key, backupTaken: false }, action);
181
+ }
182
+
183
+ function withMutationRun(settingsPath, action) {
184
+ const active = settingsRuns.getStore();
185
+ return active?.key === runKey(settingsPath)
186
+ ? action()
187
+ : withSettingsRun(settingsPath, action);
188
+ }
189
+
86
190
  async function atomicWrite(settingsPath, settings, previous) {
87
191
  await fs.mkdir(path.dirname(settingsPath), { recursive: true });
88
- if (previous.exists) await fs.writeFile(backupName(settingsPath), previous.raw, { flag: 'wx' });
192
+ const active = settingsRuns.getStore();
193
+ const ownsScope = active?.key === runKey(settingsPath);
194
+ const shouldBackup = !ownsScope || !active.backupTaken;
195
+ if (ownsScope) active.backupTaken = true;
196
+ if (shouldBackup && previous.exists) {
197
+ await fs.writeFile(backupName(settingsPath), previous.raw, { flag: 'wx' });
198
+ }
89
199
  const temporary = `${settingsPath}.${randomUUID()}.tmp`;
90
200
  try {
91
201
  await fs.writeFile(temporary, `${JSON.stringify(settings, null, 2)}\n`, { flag: 'wx' });
@@ -96,42 +206,64 @@ async function atomicWrite(settingsPath, settings, previous) {
96
206
  }
97
207
  }
98
208
 
209
+ /**
210
+ * Runs a settings mutation through the hook writer's backup and atomic-write boundary.
211
+ * Plan_22 requires permissions to preserve the same recovery guarantee after the settings merge
212
+ * incident: a second writer must not grow its own backup or serialization path.
213
+ */
214
+ export async function updateSettings(settingsPath, edit) {
215
+ if (typeof edit !== 'function') throw new TypeError('settings edit must be a function');
216
+ return withMutationRun(settingsPath, async () => {
217
+ const previous = await readSettings(settingsPath);
218
+ const settings = structuredClone(previous.settings);
219
+ const result = await edit(settings);
220
+ if (!result?.changed) return result || { changed: false };
221
+ await atomicWrite(settingsPath, settings, previous);
222
+ return result;
223
+ });
224
+ }
225
+
99
226
  export async function mergeHook(settingsPath, specOrPath, inspected) {
100
- const spec = normalizeSpec(specOrPath);
101
- const state = inspected || await inspectHook(settingsPath, spec);
102
- if (state.present) return { changed: false, createdGroup: false };
103
- const settings = structuredClone(state.settings);
104
- 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);
107
- const createdGroup = !group;
108
- if (!group) {
109
- group = { matcher: spec.matcher, hooks: [] };
110
- settings.hooks[spec.event].push(group);
111
- }
112
- if (!Array.isArray(group.hooks)) group.hooks = [];
113
- group.hooks.push(ownHook(spec.command));
114
- await atomicWrite(settingsPath, settings, state);
115
- return { changed: true, createdGroup };
227
+ return withMutationRun(settingsPath, async () => {
228
+ const spec = normalizeSpec(specOrPath);
229
+ const state = inspected || await inspectHook(settingsPath, spec);
230
+ if (state.present) return { changed: false, createdGroup: false };
231
+ const settings = structuredClone(state.settings);
232
+ settings.hooks ??= {};
233
+ if (!Array.isArray(settings.hooks[spec.event])) settings.hooks[spec.event] = [];
234
+ let group = settings.hooks[spec.event].find((entry) => entry?.matcher === spec.matcher);
235
+ const createdGroup = !group;
236
+ if (!group) {
237
+ group = { matcher: spec.matcher, hooks: [] };
238
+ settings.hooks[spec.event].push(group);
239
+ }
240
+ if (!Array.isArray(group.hooks)) group.hooks = [];
241
+ group.hooks.push(ownHook(spec.command));
242
+ await atomicWrite(settingsPath, settings, state);
243
+ return { changed: true, createdGroup };
244
+ });
116
245
  }
117
246
 
118
247
  export async function removeHook(settingsPath, specOrPath, { createdGroup = false } = {}) {
119
- const spec = normalizeSpec(specOrPath);
120
- const state = await inspectHook(settingsPath, spec);
121
- if (!state.present) return { changed: false };
122
- const settings = structuredClone(state.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.
127
- for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
128
- const group = eventGroups[index];
129
- if (!hasOwnCommand(group, spec.command)) continue;
130
- if (!Array.isArray(group?.hooks)) continue;
131
- group.hooks = group.hooks.filter((hook) =>
132
- !(hook?.type === 'command' && hook.command === spec.command));
133
- if (createdGroup && group.hooks.length === 0) eventGroups.splice(index, 1);
134
- }
135
- await atomicWrite(settingsPath, settings, state);
136
- return { changed: true };
248
+ return withMutationRun(settingsPath, async () => {
249
+ const spec = normalizeSpec(specOrPath);
250
+ const state = await inspectHook(settingsPath, spec);
251
+ if (!state.present) return { changed: false };
252
+ const settings = structuredClone(state.settings);
253
+ const eventGroups = groups(settings, spec.event);
254
+ // Removal follows the same rule as the lookup above: our command, whatever matcher it is
255
+ // filed under. A foreign hook is left alone because its command is not ours, not because it
256
+ // sits in another group.
257
+ for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
258
+ const group = eventGroups[index];
259
+ const commands = [spec.command, ...spec.alternateCommands];
260
+ if (!hasOwnCommand(group, commands)) continue;
261
+ if (!Array.isArray(group?.hooks)) continue;
262
+ group.hooks = group.hooks.filter((hook) =>
263
+ !(hook?.type === 'command' && commands.includes(hook.command)));
264
+ if (createdGroup && group.hooks.length === 0) eventGroups.splice(index, 1);
265
+ }
266
+ await atomicWrite(settingsPath, settings, state);
267
+ return { changed: true };
268
+ });
137
269
  }
package/cli/stop.mjs CHANGED
@@ -1,7 +1,14 @@
1
- /** Stops a live run and records the same abandoned FAIL artifacts as the normal sweep. */
1
+ /** Stops a live run and records the same abandoned FAIL artifacts as the normal unlock. */
2
2
  import path from 'node:path';
3
3
  import { stopCodex } from '../src/runner/codex-cmd.mjs';
4
4
  import { git, worktreeSnapshot } from '../src/runner/git-state.mjs';
5
+ import {
6
+ IDENTITY_ALIVE,
7
+ IDENTITY_FOREIGN,
8
+ IDENTITY_UNVERIFIED,
9
+ processAlive,
10
+ processIdentity,
11
+ } from '../src/process-identity.mjs';
5
12
  import { markAbandoned, readJson } from '../src/write-meta.mjs';
6
13
  import { runsRoot } from '../src/runner/runs-root.mjs';
7
14
  import { resolveRunFolder } from './run-lookup.mjs';
@@ -32,27 +39,22 @@ function processHandle(pid) {
32
39
  };
33
40
  }
34
41
 
35
- function alive(pid) {
36
- if (!Number.isInteger(pid) || pid <= 0) return false;
37
- try {
38
- process.kill(pid, 0);
39
- return true;
40
- } catch (err) {
41
- return err.code === 'EPERM';
42
- }
43
- }
44
-
45
42
  async function waitForExit(pid) {
46
43
  const deadline = Date.now() + EXIT_WAIT_MS;
47
- while (alive(pid) && Date.now() < deadline) await sleep(EXIT_POLL_MS);
48
- return !alive(pid);
44
+ while (processAlive(pid) && Date.now() < deadline) await sleep(EXIT_POLL_MS);
45
+ return !processAlive(pid);
49
46
  }
50
47
 
51
48
  function result(exitCode, output) {
52
49
  return { exitCode, output };
53
50
  }
54
51
 
55
- export async function stop({ run, cwd = process.cwd(), runsRootPath = runsRoot() } = {}) {
52
+ export async function stop({
53
+ run,
54
+ cwd = process.cwd(),
55
+ runsRootPath = runsRoot(),
56
+ commandRunner,
57
+ } = {}) {
56
58
  const lookup = resolveRunFolder({ command: 'stop', run, cwd, runsRootPath });
57
59
  if (lookup.error) return result(1, lookup.error);
58
60
  const { runDir } = lookup;
@@ -70,13 +72,24 @@ export async function stop({ run, cwd = process.cwd(), runsRootPath = runsRoot()
70
72
  return result(1, `Run ${runDir} has no valid recorded pid; it was not changed.`);
71
73
  }
72
74
 
73
- try {
74
- stopCodex(processHandle(status.pid));
75
- if (!(await waitForExit(status.pid))) {
76
- return result(1, `Run ${runDir} did not stop within ${EXIT_WAIT_MS}ms; it was not closed.`);
75
+ const identity = processIdentity({ runDir, status, commandRunner });
76
+ if (identity === IDENTITY_UNVERIFIED || identity === IDENTITY_FOREIGN) {
77
+ return result(
78
+ 1,
79
+ `Process identity could not be confirmed for run ${runDir} (${identity}); no signal was sent. ` +
80
+ '"codex-bridge unlock" closes the record without killing a process.',
81
+ );
82
+ }
83
+
84
+ if (identity === IDENTITY_ALIVE) {
85
+ try {
86
+ stopCodex(processHandle(status.pid));
87
+ if (!(await waitForExit(status.pid))) {
88
+ return result(1, `Run ${runDir} did not stop within ${EXIT_WAIT_MS}ms; it was not closed.`);
89
+ }
90
+ } catch (err) {
91
+ return result(1, `Could not stop run ${runDir}: ${err.message}; it was not closed.`);
77
92
  }
78
- } catch (err) {
79
- return result(1, `Could not stop run ${runDir}: ${err.message}; it was not closed.`);
80
93
  }
81
94
 
82
95
  // The snapshot is what turns the verdict from a bare label into a list of what the stopped run
package/cli/uninstall.mjs CHANGED
@@ -1,40 +1,56 @@
1
- /** Uninstalls only recorded codex-bridge files while preserving host data and foreign files. */
1
+ /** Uninstalls only recorded files from both roots while preserving host data and foreign files. */
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import {
5
+ definitionForRecordedHook,
5
6
  fileFingerprint,
6
- HOOK_DEFINITIONS,
7
7
  installRecordPath,
8
+ legacyInstallRecordPath,
8
9
  readInstallRecord,
10
+ recordTarget,
9
11
  } from './manifest.mjs';
10
- import { commandFor, removeHook } from './settings-merge.mjs';
12
+ import { removePermissionRules } from './permissions.mjs';
13
+ import {
14
+ commandFor,
15
+ removeHook,
16
+ withSettingsRun,
17
+ } from './settings-merge.mjs';
11
18
  import { readRulesRegistry, removeRulesOwner, remainingRulesOwners } from './rules-owners.mjs';
19
+ import {
20
+ claudeBoundary,
21
+ removeEmpty,
22
+ removeEmptyLayout,
23
+ removeEmptyParents,
24
+ } from './remove-layout.mjs';
12
25
 
13
- async function removeEmpty(directory) {
14
- try {
15
- if ((await fs.readdir(directory)).length === 0) await fs.rmdir(directory);
16
- } catch (err) {
17
- if (err.code !== 'ENOENT') throw err;
18
- }
26
+ function remainingOwnersText(count) {
27
+ return `${count} other owner${count === 1 ? '' : 's'} ${count === 1 ? 'remains' : 'remain'}`;
19
28
  }
20
29
 
21
- async function removeEmptyParents(target, boundary) {
22
- let current = path.dirname(target);
23
- while (current !== boundary && current.startsWith(`${boundary}${path.sep}`)) {
24
- await removeEmpty(current);
25
- current = path.dirname(current);
26
- }
30
+ function permissionOutput(host, removed, dryRun) {
31
+ const verb = dryRun ? 'Would remove' : 'Removed';
32
+ const plural = removed === 1 ? 'string' : 'strings';
33
+ return `${verb} ${removed} permission rule ${plural} from ${host.settingsPath}.`;
27
34
  }
28
35
 
29
- function remainingOwnersText(count) {
30
- return `${count} other owner${count === 1 ? '' : 's'} ${count === 1 ? 'remains' : 'remain'}`;
36
+ function displayFile(file) {
37
+ return `${file.root}/${file.path}`;
31
38
  }
32
39
 
33
- function hookDefinition(event) {
34
- return HOOK_DEFINITIONS.find((definition) => definition.event === event);
40
+ function hookRemovalSpec(host, hook) {
41
+ const definition = definitionForRecordedHook(hook);
42
+ const target = recordTarget(host, hook);
43
+ const full = commandFor(target);
44
+ const short = `codex-bridge hook ${definition.name}`;
45
+ return {
46
+ event: hook.event,
47
+ matcher: definition.matcher,
48
+ command: hook.command || full,
49
+ alternateCommands: [full, short],
50
+ };
35
51
  }
36
52
 
37
- export async function uninstall({ host, dryRun = false } = {}) {
53
+ async function uninstallInRun({ host, dryRun = false } = {}) {
38
54
  // Preflight before removing the hook: package removal on a broken registry left the host without its watchdog.
39
55
  let registry = null;
40
56
  let registryError = null;
@@ -43,13 +59,17 @@ export async function uninstall({ host, dryRun = false } = {}) {
43
59
  } catch (err) {
44
60
  registryError = err;
45
61
  }
62
+ const permissionResult = await removePermissionRules(host.settingsPath, { dryRun });
63
+ const permissionLine = permissionOutput(host, permissionResult.removed, dryRun);
46
64
  const record = await readInstallRecord(host);
47
65
  const preservation = `Run artifacts in ${path.join(host.root, 'codex-runs')} and the run `
48
- + `configuration in ${path.join(host.agentsDir, 'run-config.json')} are preserved.`;
49
- if (!record) return { exitCode: 1, output: `codex-bridge is not installed.\n${preservation}` };
66
+ + `configuration in ${host.brandConfigPath} are preserved.`;
67
+ if (!record) {
68
+ return { exitCode: 1, output: `${permissionLine}\ncodex-bridge is not installed.\n${preservation}` };
69
+ }
50
70
 
51
71
  if (dryRun) {
52
- const lines = record.files.map((file) => `Would remove ${file}`);
72
+ const lines = [permissionLine, ...record.files.map((file) => `Would remove ${displayFile(file)}`)];
53
73
  if (record.rules) {
54
74
  if (registryError) {
55
75
  lines.push(`Would leave ${record.rules.path} because the rules ownership registry is invalid; ownership is unknown.`);
@@ -71,22 +91,18 @@ export async function uninstall({ host, dryRun = false } = {}) {
71
91
  }
72
92
  }
73
93
  for (const hook of record.hooks) {
74
- const definition = hookDefinition(hook.event);
94
+ const definition = definitionForRecordedHook(hook);
75
95
  lines.push(`Would remove the ${hook.event} hook for matcher ${definition.matcher}.`);
76
96
  }
77
- lines.push('Would remove the installation record.');
97
+ lines.push('Would remove the installation record from the brand root.');
78
98
  lines.push(preservation);
79
99
  return { exitCode: 0, output: lines.join('\n') };
80
100
  }
81
101
 
82
102
  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 });
103
+ await removeHook(host.settingsPath, hookRemovalSpec(host, hook), {
104
+ createdGroup: hook.createdGroup === true,
105
+ });
90
106
  }
91
107
  let ownership = null;
92
108
  if (!registryError) {
@@ -116,17 +132,29 @@ export async function uninstall({ host, dryRun = false } = {}) {
116
132
  }
117
133
  }
118
134
  }
119
- for (const relative of record.files) {
120
- const target = path.join(host.root, relative);
135
+ for (const file of record.files) {
136
+ const target = recordTarget(host, file);
121
137
  await fs.rm(target, { force: true });
122
- const boundary = target.startsWith(`${host.commandsDir}${path.sep}`) ? host.commandsDir : host.agentsDir;
138
+ const boundary = file.root === 'brand'
139
+ ? host.brandRoot
140
+ : claudeBoundary(host, target);
123
141
  await removeEmptyParents(target, boundary);
124
142
  }
125
143
  await removeEmpty(host.commandsDir);
126
144
  await fs.rm(installRecordPath(host), { force: true });
145
+ await fs.rm(legacyInstallRecordPath(host), { force: true });
127
146
  await removeEmpty(host.agentsDir);
147
+ await removeEmptyLayout(host.legacyAgentsDir);
148
+ await removeEmptyLayout(host.legacyCommandsDir);
149
+ await removeEmpty(host.brandRoot);
128
150
  return {
129
151
  exitCode: 0,
130
- output: [`Uninstalled codex-bridge.`, ...rulesOutput, preservation].join('\n'),
152
+ output: ['Uninstalled codex-bridge.', permissionLine, ...rulesOutput, preservation].join('\n'),
131
153
  };
132
154
  }
155
+
156
+ export async function uninstall(options = {}) {
157
+ const host = options?.host;
158
+ if (!host?.settingsPath) return uninstallInRun(options);
159
+ return withSettingsRun(host.settingsPath, () => uninstallInRun(options));
160
+ }