@lyupro/codex-bridge 0.6.7 → 0.6.9

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 (101) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +10 -6
  3. package/bin/codex-bridge.mjs +35 -172
  4. package/cli/command-help.mjs +49 -0
  5. package/cli/command-registry.mjs +284 -0
  6. package/cli/copy.mjs +42 -5
  7. package/cli/dispatcher-contract-record.mjs +21 -11
  8. package/cli/dispatcher-contract.mjs +6 -5
  9. package/cli/doctor-host-side.mjs +110 -0
  10. package/cli/doctor-installation.mjs +111 -111
  11. package/cli/doctor.mjs +35 -7
  12. package/cli/handback-witness-check.mjs +10 -18
  13. package/cli/home-inspection.mjs +122 -0
  14. package/cli/home-plan-execute.mjs +140 -0
  15. package/cli/home-removal-plan.mjs +61 -0
  16. package/cli/hook-recognizer.mjs +78 -0
  17. package/cli/host-contract.mjs +36 -21
  18. package/cli/host-inspection.mjs +156 -0
  19. package/cli/host-removal.mjs +91 -0
  20. package/cli/image-evidence.mjs +44 -0
  21. package/cli/install-owners.mjs +244 -0
  22. package/cli/install-record.mjs +71 -9
  23. package/cli/install.mjs +101 -77
  24. package/cli/inventory-removal.mjs +84 -0
  25. package/cli/inventory-transition.mjs +66 -0
  26. package/cli/lifecycle-lock-clear.mjs +104 -0
  27. package/cli/lifecycle-lock-inspect.mjs +91 -0
  28. package/cli/lifecycle-lock.mjs +335 -0
  29. package/cli/lifecycle-transaction.mjs +57 -0
  30. package/cli/link-segments.mjs +45 -0
  31. package/cli/model-set.mjs +4 -3
  32. package/cli/model-speed.mjs +9 -2
  33. package/cli/model.mjs +5 -3
  34. package/cli/owner-record.mjs +40 -0
  35. package/cli/owner-sync-apply.mjs +108 -0
  36. package/cli/owner-sync.mjs +115 -0
  37. package/cli/probe-contract.mjs +26 -10
  38. package/cli/probe-target.mjs +33 -0
  39. package/cli/projects.mjs +5 -2
  40. package/cli/prune-args.mjs +9 -4
  41. package/cli/prune.mjs +23 -72
  42. package/cli/purge-live-runs.mjs +98 -0
  43. package/cli/purge-preflight.mjs +103 -0
  44. package/cli/read.mjs +2 -1
  45. package/cli/record-removal.mjs +43 -0
  46. package/cli/removal-outcomes.mjs +115 -0
  47. package/cli/removal-plan.mjs +187 -0
  48. package/cli/remove-layout.mjs +40 -1
  49. package/cli/rules-owners.mjs +4 -2
  50. package/cli/session-hosts.mjs +51 -0
  51. package/cli/terminal-question.mjs +41 -0
  52. package/cli/uninstall-purge.mjs +139 -0
  53. package/cli/uninstall-rules.mjs +72 -0
  54. package/cli/uninstall.mjs +208 -126
  55. package/cli/unlock-lifecycle.mjs +102 -0
  56. package/cli/unlock.mjs +7 -4
  57. package/cli/update.mjs +102 -27
  58. package/package.json +4 -3
  59. package/scripts/home-write-audit.mjs +286 -0
  60. package/scripts/home-write-inventory.mjs +49 -0
  61. package/scripts/home-write-lexer.mjs +182 -0
  62. package/scripts/isolated-roots.mjs +6 -1
  63. package/scripts/open-work/check.mjs +180 -0
  64. package/scripts/open-work/live-thresholds.mjs +155 -0
  65. package/scripts/open-work/register.mjs +56 -0
  66. package/scripts/root-guard/root-entries.mjs +15 -0
  67. package/scripts/run-tests.mjs +33 -1
  68. package/src/agents/codex-advisor.md +5 -2
  69. package/src/home/conventions.md +9 -0
  70. package/src/home/hooks/dispatcher-gate.mjs +15 -2
  71. package/src/home/hooks/guard-tries.mjs +6 -4
  72. package/src/home/hooks/order-gate.mjs +2 -8
  73. package/src/home/hooks/reply-guard.mjs +24 -9
  74. package/src/home/hooks/worktree-witness.mjs +6 -6
  75. package/src/home/lib/agents.mjs +1 -1
  76. package/src/home/lib/atomic-json.mjs +24 -6
  77. package/src/home/lib/config-edit.mjs +25 -10
  78. package/src/home/lib/dispatcher-state.mjs +25 -10
  79. package/src/home/lib/file-lock.mjs +41 -10
  80. package/src/home/lib/handback-witness.mjs +33 -25
  81. package/src/home/lib/home-registry.mjs +211 -0
  82. package/src/home/lib/home-write.mjs +189 -0
  83. package/src/home/lib/hook-diagnostics.mjs +20 -0
  84. package/src/home/lib/host-observations.mjs +60 -0
  85. package/src/home/lib/host-version.mjs +27 -0
  86. package/src/home/lib/meta/advice-status.mjs +2 -2
  87. package/src/home/lib/meta/advice-verdict.mjs +30 -1
  88. package/src/home/lib/meta/events.mjs +2 -1
  89. package/src/home/lib/meta/paths.mjs +24 -24
  90. package/src/home/lib/meta/run-snapshots.mjs +29 -0
  91. package/src/home/lib/meta/snapshot-format.mjs +96 -0
  92. package/src/home/lib/meta/verdict.mjs +14 -16
  93. package/src/home/lib/run-config.mjs +10 -1
  94. package/src/home/lib/runner/git-paths.mjs +74 -0
  95. package/src/home/lib/runner/git-state.mjs +38 -34
  96. package/src/home/lib/runner/launcher.mjs +2 -2
  97. package/src/home/lib/runner/preflight.mjs +7 -0
  98. package/src/home/lib/runner/prompts.mjs +35 -2
  99. package/src/home/lib/runner/schemas.mjs +4 -1
  100. package/src/home/lib/runner/scope-check.mjs +29 -20
  101. package/src/home/lib/runner/worker.mjs +3 -3
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Gathers what the removal plan of the home needs and builds it, without changing anything.
3
+ *
4
+ * Plan_65 D12 item 1 combines the registry's fixed paths, recorded image members, and the
5
+ * current package's members: a missing record must remain cleanable, and a member dropped
6
+ * from a newer package must still be found. D12 item 4 keeps corrupt records distinct from
7
+ * missing records so an unreadable installation record blocks removal.
8
+ */
9
+ import { asFormat2, imageMembers, readInstallRecordFile } from './install-record.mjs';
10
+ import { validateFormat2 } from './install-owners.mjs';
11
+ import { imagePackageIndex, judgeImageFile } from './image-evidence.mjs';
12
+ import { inspectHome } from './home-inspection.mjs';
13
+ import { planHomeRemoval } from './removal-plan.mjs';
14
+ import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
15
+
16
+ export async function buildHomeRemovalPlan({ command, host, packageRoot, imagePolicy }) {
17
+ let recordState;
18
+ let format2;
19
+ try {
20
+ const parsed = await readInstallRecordFile(host);
21
+ if (parsed === null) {
22
+ recordState = 'missing';
23
+ } else {
24
+ const candidate = asFormat2(parsed, host);
25
+ validateFormat2(candidate);
26
+ format2 = candidate;
27
+ recordState = 'valid';
28
+ }
29
+ } catch {
30
+ recordState = 'corrupt';
31
+ }
32
+
33
+ const packageIndex = await imagePackageIndex(host, packageRoot);
34
+ const recordedMembers = recordState === 'valid'
35
+ ? imageMembers(format2, host).filter((entry) => entry.root === 'brand').map((entry) => entry.path)
36
+ : [];
37
+ const members = [...new Set([...recordedMembers, ...packageIndex.keys()])].sort();
38
+ const inspection = inspectHome(host.brandRoot, { imageMembers: members });
39
+ const fingerprints = recordState === 'valid'
40
+ ? { brand: format2.image.fingerprints?.brand }
41
+ : undefined;
42
+ const imageEvidence = new Map();
43
+ for (const file of inspection.files) {
44
+ if (file.id !== 'install-image' || file.role !== 'primary') continue;
45
+ imageEvidence.set(file.relative, await judgeImageFile(
46
+ host, { root: 'brand', path: file.relative }, fingerprints, packageIndex,
47
+ ));
48
+ }
49
+
50
+ return {
51
+ command,
52
+ ...planHomeRemoval({
53
+ command, inspection, imageMembers: members, imageEvidence, imagePolicy,
54
+ recordState, format2, ownerKey: normalizeRepoPath(host.root),
55
+ }),
56
+ recordState,
57
+ homeRoot: inspection.root,
58
+ format2,
59
+ imageMembers: members,
60
+ };
61
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Recognizes the package's own hook entries in a host's settings by their exact spelling, without
3
+ * an installation record (Plan_65 D10 item 1).
4
+ *
5
+ * A format-1 record never named its host, so a host without an owner entry could only be cleaned
6
+ * through somebody else's record. What the package wrote into a host carries its name; this module
7
+ * reads that mark. Only exact commands count: a substring, another interpreter or an unknown file
8
+ * is a foreign hook, and `commandForm` (settings-merge) only classifies spelling, it proves nothing.
9
+ */
10
+ import path from 'node:path';
11
+ import { HOOK_DEFINITIONS } from './manifest.mjs';
12
+ import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
13
+
14
+ // Every directory a released version installed hook files into. Checked against git history:
15
+ // agents/codex-bridge never held hooks — they moved into the brand home (7a43fa4, 2026-08-10) a
16
+ // day before that directory existed (4eafc85).
17
+ const HOOK_DIRECTORIES = Object.freeze([
18
+ Object.freeze({ root: 'brandRoot', segments: ['hooks'] }),
19
+ // Before 7a43fa4 the path form pointed into host.agentsDir/hooks, and agentsDir was agents/codex.
20
+ Object.freeze({ root: 'root', segments: ['agents', 'codex', 'hooks'] }),
21
+ ]);
22
+
23
+ const DEFINITION_NAMES = new Set(HOOK_DEFINITIONS.map(({ name }) => name));
24
+ const SHORT_COMMAND = /^codex-bridge hook (\S+)$/;
25
+ const PATH_COMMAND = /^node "([^"\r\n]+)"$/;
26
+
27
+ // Windows compares file names without case, like normalizeRepoPath does for the rest of the path.
28
+ const sameFile = (a, b) => (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
29
+
30
+ function inKnownDirectory(hookPath, host) {
31
+ const directory = normalizeRepoPath(path.dirname(hookPath));
32
+ return HOOK_DIRECTORIES.some(({ root, segments }) => typeof host?.[root] === 'string'
33
+ && directory === normalizeRepoPath(path.join(host[root], ...segments)));
34
+ }
35
+
36
+ // One file can serve several hooks (dispatcher-gate.mjs is three), so a path names its hook only
37
+ // together with the event; without one, or with no match, the entry is still ours but unnamed.
38
+ function nameForFile(file, event) {
39
+ const candidates = HOOK_DEFINITIONS.filter((definition) => sameFile(definition.file, file));
40
+ const matching = event === undefined ? candidates : candidates.filter((definition) => definition.event === event);
41
+ return matching.length === 1 ? matching[0].name : null;
42
+ }
43
+
44
+ export function isOwnHookCommand(command, host, event) {
45
+ if (typeof command !== 'string') return { own: false };
46
+ const short = SHORT_COMMAND.exec(command);
47
+ if (short && DEFINITION_NAMES.has(short[1])) return { own: true, name: short[1], form: 'short' };
48
+ const spelled = PATH_COMMAND.exec(command);
49
+ if (!spelled || !path.isAbsolute(spelled[1])) return { own: false };
50
+ const file = path.basename(spelled[1]);
51
+ if (!HOOK_DEFINITIONS.some((definition) => sameFile(definition.file, file)) || !inKnownDirectory(spelled[1], host)) {
52
+ return { own: false };
53
+ }
54
+ return { own: true, name: nameForFile(file, event), form: 'path' };
55
+ }
56
+
57
+ const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
58
+
59
+ /** Lists own hook entries across every event; unreadable settings throw, because unknown is not "none". */
60
+ export function findOwnHooks(settings, host) {
61
+ if (!isObject(settings)) throw new TypeError('settings must be an object');
62
+ if (!isObject(settings.hooks)) return [];
63
+ const found = [];
64
+ for (const [event, groups] of Object.entries(settings.hooks)) {
65
+ if (!Array.isArray(groups)) continue;
66
+ groups.forEach((group, groupIndex) => {
67
+ if (!isObject(group) || !Array.isArray(group.hooks)) return;
68
+ group.hooks.forEach((hook, hookIndex) => {
69
+ if (hook?.type !== 'command') return;
70
+ const recognized = isOwnHookCommand(hook.command, host, event);
71
+ if (!recognized.own) return;
72
+ const { name, form } = recognized;
73
+ found.push({ event, groupIndex, hookIndex, matcher: group.matcher, command: hook.command, name, form });
74
+ });
75
+ });
76
+ }
77
+ return found;
78
+ }
@@ -6,14 +6,16 @@
6
6
  * somewhere between host 2.1.119 and 2.1.231, with no error printed on the way. Nothing in this
7
7
  * package would notice `deny` going the same way — the suite checks that a hook RETURNED a refusal,
8
8
  * never that the host APPLIED it. The record is bound to a host version because that is what
9
- * changes underneath an installation: the host updates itself, the package does not.
9
+ * changes underneath an installation: the host updates itself, the package does not. The
10
+ * 2026-09-25 incident ran VS Code host 2.1.282 beside CLI host 2.1.281, so each version keeps
11
+ * its own verdict and probing either must preserve the other.
10
12
  */
11
13
  import { spawnSync } from 'node:child_process';
12
14
  import { randomUUID } from 'node:crypto';
13
- import fs from 'node:fs/promises';
14
15
  import path from 'node:path';
15
16
  import { CLI_NAMES } from '../src/home/lib/cli-names.mjs';
16
17
  import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
18
+ import { createHomeWriter } from '../src/home/lib/home-write.mjs';
17
19
  import { readJsonFile } from '../src/home/lib/json-file.mjs';
18
20
 
19
21
  export const HOST_CONTRACT_RECORD_NAME = '.host-contract.json';
@@ -40,13 +42,12 @@ export async function readHostContract(host) {
40
42
  const target = hostContractPath(host);
41
43
  try {
42
44
  const record = await readJsonFile(target);
43
- if (!record || typeof record !== 'object' || Array.isArray(record)
44
- || typeof record.version !== 'string' || !record.version) return null;
45
- return {
46
- version: record.version,
47
- checkedAt: record.checkedAt,
48
- result: record.result,
49
- };
45
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return null;
46
+ if (record.hosts && typeof record.hosts === 'object' && !Array.isArray(record.hosts)) return record;
47
+ if (typeof record.version === 'string' && record.version) {
48
+ return { hosts: { [record.version]: { result: record.result, checkedAt: record.checkedAt } } };
49
+ }
50
+ return null;
50
51
  } catch {
51
52
  return null;
52
53
  }
@@ -62,13 +63,24 @@ export async function writeHostContract(host, { version, result, now = new Date(
62
63
 
63
64
  const target = hostContractPath(host);
64
65
  const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
65
- const record = { version, result, checkedAt: now.toISOString() };
66
- await fs.mkdir(host.brandRoot, { recursive: true });
66
+ const previous = await readHostContract(host);
67
+ const cutoff = now.getTime() - 180 * 24 * 60 * 60 * 1000;
68
+ const hosts = Object.fromEntries(Object.entries(previous?.hosts ?? {}).filter(([, entry]) =>
69
+ Number.isFinite(Date.parse(entry?.checkedAt)) && Date.parse(entry.checkedAt) >= cutoff));
70
+ hosts[version] = { result, checkedAt: now.toISOString() };
71
+ const record = { hosts };
72
+ const writer = createHomeWriter({ root: host.brandRoot });
73
+ writer.assertArtifact('host-contract', target);
74
+ await writer.mkdir('host-contract', host.brandRoot, { recursive: true });
67
75
  try {
68
- await fs.writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
69
- await fs.rename(temporary, target);
76
+ await writer.writeFile('host-contract', temporary, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
77
+ await writer.rename('host-contract', temporary, target);
70
78
  } finally {
71
- await fs.rm(temporary, { force: true });
79
+ try {
80
+ await writer.unlink('host-contract', temporary);
81
+ } catch (error) {
82
+ if (error.code !== 'ENOENT') throw error;
83
+ }
72
84
  }
73
85
  }
74
86
 
@@ -77,9 +89,9 @@ export function parseHostVersion(text) {
77
89
  return text.trim().match(/^(\d+\.\d+\.\d+)(?:\s|$)/)?.[1] ?? null;
78
90
  }
79
91
 
80
- export function detectHostVersion({ run = spawnSync } = {}) {
92
+ export function detectHostVersion({ run = spawnSync, executable = 'claude' } = {}) {
81
93
  try {
82
- const result = run('claude', ['--version'], {
94
+ const result = run(executable, ['--version'], {
83
95
  encoding: 'utf8',
84
96
  shell: false,
85
97
  timeout: 5000,
@@ -107,13 +119,16 @@ export function contractStatus({ record, version }) {
107
119
  message: `The refusal contract has never been probed on this machine (host ${version}); run ${PROBE_COMMAND}.`,
108
120
  };
109
121
  }
110
- if (record.version !== version) {
122
+ const entry = record.hosts?.[version];
123
+ if (!entry && Object.keys(record.hosts ?? {}).length) {
124
+ const [otherVersion] = Object.entries(record.hosts).sort((a, b) => Date.parse(b[1]?.checkedAt) - Date.parse(a[1]?.checkedAt))[0];
111
125
  return {
112
126
  state: 'stale',
113
- message: `The refusal contract was probed on host ${record.version}, but the current host is ${version}; run ${PROBE_COMMAND}.`,
127
+ message: `The refusal contract was probed on host ${otherVersion}, but the current host is ${version}; run ${PROBE_COMMAND}.`,
114
128
  };
115
129
  }
116
- if (record.result === 'ignored') {
130
+ if (!entry) return { state: 'unverified', message: `The refusal contract has never been probed on this machine (host ${version}); run ${PROBE_COMMAND}.` };
131
+ if (entry.result === 'ignored') {
117
132
  const guards = HOOK_DEFINITIONS
118
133
  .filter((definition) => definition.event === 'PreToolUse')
119
134
  .map((definition) => definition.name)
@@ -123,10 +138,10 @@ export function contractStatus({ record, version }) {
123
138
  message: `DANGER: Host ${version} does not honour hook refusals, so every guard is inert: ${guards}. Re-run ${PROBE_COMMAND} once the host updates.`,
124
139
  };
125
140
  }
126
- if (record.result === 'honored') {
141
+ if (entry.result === 'honored') {
127
142
  return {
128
143
  state: 'verified',
129
- message: `Host ${version} honours hook refusals (verified ${record.checkedAt}).`,
144
+ message: `Host ${version} honours hook refusals (verified ${entry.checkedAt}).`,
130
145
  };
131
146
  }
132
147
  return {
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Inspects what the package left in one host and which of it may be deleted, without changing
3
+ * anything (Plan_65 D10 items 1 and 3).
4
+ *
5
+ * What lies in a host is read from the package's marks there, not from the home record: a format-1
6
+ * record never named its host. Deletion needs content evidence — the host's own recorded
7
+ * fingerprint or the current package's bytes — because uninstall used to delete recorded files
8
+ * without comparing anything, and an operator-edited agent file was lost with them.
9
+ */
10
+ import fs from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import {
13
+ buildInstallPlan,
14
+ contentFingerprint,
15
+ legacyInstallRecordPath,
16
+ } from './manifest.mjs';
17
+ import { plannedContent } from './copy.mjs';
18
+ import { findOwnHooks } from './hook-recognizer.mjs';
19
+ import { readSettings } from './settings-merge.mjs';
20
+ import { inspectPermissions } from './permissions.mjs';
21
+
22
+ const SEED_NAMES = new Set(['config.json', 'conventions.md']);
23
+ const posix = (value) => value.split(path.sep).join('/');
24
+
25
+ function result(context, target, disposition, reason) {
26
+ const relativeToHost = posix(path.relative(context.host.root, target));
27
+ return { target, relativeToHost, disposition, reason };
28
+ }
29
+
30
+ const unreadable = (context, target, error) => result(context, target, 'keep', `unreadable: ${error.code ?? 'UNKNOWN'}`);
31
+
32
+ function isWithin(directory, target) {
33
+ const relative = path.relative(directory, target);
34
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
35
+ }
36
+
37
+ async function judgeFile(context, target, item) {
38
+ const { host, owner } = context;
39
+ let bytes;
40
+ try {
41
+ bytes = await fs.readFile(target);
42
+ } catch (error) {
43
+ return unreadable(context, target, error);
44
+ }
45
+ const relative = posix(path.relative(host.root, target));
46
+ const recorded = owner?.fingerprints?.claude?.[relative];
47
+ if (typeof recorded === 'string' && contentFingerprint(bytes) === recorded) {
48
+ return result(context, target, 'remove', "matches this host's record");
49
+ }
50
+ if (item && bytes.equals(await plannedContent(item, host.brandRoot))) {
51
+ return result(context, target, 'remove', 'matches the package');
52
+ }
53
+ return result(context, target, 'keep', item ? 'changed' : 'unknown');
54
+ }
55
+
56
+ // Links are never followed (Plan_65 D4): a junction inside a package folder points somewhere else.
57
+ async function lstatOrReport(context, target) {
58
+ try {
59
+ return await fs.lstat(target);
60
+ } catch (error) {
61
+ if (error.code !== 'ENOENT') context.files.push(unreadable(context, target, error));
62
+ return null;
63
+ }
64
+ }
65
+
66
+ async function walk(context, target) {
67
+ const stat = await lstatOrReport(context, target);
68
+ if (!stat) return;
69
+ if (stat.isSymbolicLink()) {
70
+ context.files.push(result(context, target, 'keep', 'link'));
71
+ return;
72
+ }
73
+ if (stat.isDirectory()) {
74
+ let entries;
75
+ try {
76
+ entries = await fs.readdir(target);
77
+ } catch (error) {
78
+ context.files.push(unreadable(context, target, error));
79
+ return;
80
+ }
81
+ for (const entry of entries) await walk(context, path.join(target, entry));
82
+ return;
83
+ }
84
+ const { host, planByTarget } = context;
85
+ if (SEED_NAMES.has(path.basename(target)) && [host.agentsDir, host.commandsDir].includes(path.dirname(target))) {
86
+ context.files.push(result(context, target, 'keep', 'seed'));
87
+ return;
88
+ }
89
+ context.files.push(await judgeFile(context, target, planByTarget.get(path.resolve(target))));
90
+ }
91
+
92
+ // The pre-0.6 folder name is not unique to the package: only the names the package installs are
93
+ // looked at, never the folder as a whole. Each is judged against the package file of the same name.
94
+ async function inspectLegacyDirectory(context, directory, items) {
95
+ const stat = await lstatOrReport(context, directory);
96
+ if (!stat) return;
97
+ if (stat.isSymbolicLink()) {
98
+ context.files.push(result(context, directory, 'keep', 'link'));
99
+ return;
100
+ }
101
+ if (!stat.isDirectory()) return;
102
+ for (const item of items) {
103
+ const target = path.join(directory, path.basename(item.target));
104
+ const entry = await lstatOrReport(context, target);
105
+ if (!entry) continue;
106
+ if (entry.isSymbolicLink()) context.files.push(result(context, target, 'keep', 'link'));
107
+ else if (entry.isFile()) context.files.push(await judgeFile(context, target, item));
108
+ }
109
+ }
110
+
111
+ async function inspectLegacyRecord(context) {
112
+ const target = legacyInstallRecordPath(context.host);
113
+ const stat = await lstatOrReport(context, target);
114
+ if (stat?.isFile()) context.files.push(result(context, target, 'remove', 'old installation record'));
115
+ else if (stat?.isSymbolicLink()) context.files.push(result(context, target, 'keep', 'link'));
116
+ }
117
+
118
+ async function inspectSettings(host) {
119
+ try {
120
+ const state = await readSettings(host.settingsPath);
121
+ return { hooks: state.exists ? findOwnHooks(state.settings, host) : [], settingsError: null };
122
+ } catch (error) {
123
+ return { hooks: [], settingsError: error.message };
124
+ }
125
+ }
126
+
127
+ // Own permission strings are reported, not counted as an installation: `codex-bridge permissions
128
+ // add` sets them alone. An unparseable settings file is already named by settingsError.
129
+ async function inspectOwnPermissions(host) {
130
+ try {
131
+ return { present: (await inspectPermissions(host.settingsPath)).present };
132
+ } catch {
133
+ return { present: null };
134
+ }
135
+ }
136
+
137
+ export async function inspectHost(host, { owner = null, packageRoot } = {}) {
138
+ const plan = await buildInstallPlan(host, packageRoot);
139
+ const context = {
140
+ host,
141
+ owner,
142
+ planByTarget: new Map(plan.map((item) => [path.resolve(item.target), item])),
143
+ files: [],
144
+ };
145
+ await walk(context, host.agentsDir);
146
+ await walk(context, host.commandsDir);
147
+ await inspectLegacyDirectory(context, host.legacyAgentsDir, plan.filter((item) => isWithin(host.agentsDir, item.target)));
148
+ await inspectLegacyDirectory(context, host.legacyCommandsDir, plan.filter((item) => isWithin(host.commandsDir, item.target)));
149
+ await inspectLegacyRecord(context);
150
+ const files = context.files.sort((a, b) => a.relativeToHost.localeCompare(b.relativeToHost));
151
+ return { files, ...(await inspectSettings(host)), permissions: await inspectOwnPermissions(host) };
152
+ }
153
+
154
+ export function hasPackageMarks(inspection) {
155
+ return inspection.files.length > 0 || inspection.hooks.length > 0;
156
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Applies a host inspection by removing eligible host files and this package's hooks (Plan_65 D10
3
+ * items 3 and 4). The shared image is left for uninstall, which may remove it only after the host
4
+ * has no hook pointing to it.
5
+ */
6
+ import { recordHomeWriter, removeOutside } from './record-removal.mjs';
7
+ import { claudeBoundary, removeEmptyParents } from './remove-layout.mjs';
8
+ import { recordTarget } from './install-record.mjs';
9
+ import { commandFor, removeHook, shortCommandFor } from './settings-merge.mjs';
10
+ import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
11
+
12
+ // Plan_65 D10 item 5: only the host's own owner entry proves we created a group; legacy never does.
13
+ function recordedCreation(owner, host, found) {
14
+ if (!owner || !found.name || normalizeRepoPath(owner.root) !== normalizeRepoPath(host.root)) return false;
15
+ return (owner.hooks ?? []).some((hook) => {
16
+ if (hook?.event !== found.event || hook.createdGroup !== true) return false;
17
+ let command = hook.command;
18
+ if (typeof command !== 'string') {
19
+ try {
20
+ command = commandFor(recordTarget(host, hook));
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+ return command === found.command || command === shortCommandFor(found.name);
26
+ });
27
+ }
28
+
29
+ // Decided per group before anything is removed: with two own hooks in one group and evidence on
30
+ // only one, a per-hook flag made the outcome depend on removal order. removeHook drops a group
31
+ // only once it is empty, so an emptied group we created goes and a shared one stays.
32
+ function createdGroups(owner, host, hooks) {
33
+ const created = new Set();
34
+ for (const hook of hooks) {
35
+ if (recordedCreation(owner, host, hook)) created.add(`${hook.event}#${hook.groupIndex}`);
36
+ }
37
+ return created;
38
+ }
39
+
40
+ // Hooks go first and files only once the host is detached. Removing files ahead of a failed hook
41
+ // took the old per-host installation record with them (acceptance of H3b, 2026-09-28): the host kept
42
+ // hooks into an image that no longer had any inventory, so no later uninstall could remove it. Either
43
+ // the host is detached, or its files are exactly as they were and a repeat run finishes the job.
44
+ async function removeOwnHooks(host, inspection, owner, dryRun, lines) {
45
+ if (inspection.settingsError != null) {
46
+ lines.push(`Left the hooks in ${host.settingsPath}: ${inspection.settingsError}`);
47
+ return false;
48
+ }
49
+ let detached = true;
50
+ const created = createdGroups(owner, host, inspection.hooks);
51
+ for (const hook of inspection.hooks) {
52
+ const identity = hook.name ?? hook.command;
53
+ if (dryRun) {
54
+ lines.push(`Would remove the ${hook.event} hook ${identity}`);
55
+ continue;
56
+ }
57
+ try {
58
+ await removeHook(host.settingsPath, {
59
+ event: hook.event,
60
+ matcher: hook.matcher,
61
+ command: hook.command,
62
+ alternateCommands: [],
63
+ }, { createdGroup: created.has(`${hook.event}#${hook.groupIndex}`) });
64
+ lines.push(`Removed the ${hook.event} hook ${identity}`);
65
+ } catch (error) {
66
+ detached = false;
67
+ lines.push(`Failed to remove the ${hook.event} hook ${identity}: ${error?.message ?? String(error)}`);
68
+ }
69
+ }
70
+ return detached;
71
+ }
72
+
73
+ export async function removeHostSide(host, inspection, { owner = null, dryRun = false } = {}) {
74
+ const lines = [];
75
+ const detached = await removeOwnHooks(host, inspection, owner, dryRun, lines);
76
+ const writer = dryRun ? null : recordHomeWriter(host, []);
77
+ for (const file of inspection.files) {
78
+ if (file.disposition !== 'remove') {
79
+ lines.push(`Left ${file.relativeToHost} (${file.reason})`);
80
+ } else if (!detached) {
81
+ lines.push(`${dryRun ? 'Would leave' : 'Left'} ${file.relativeToHost} (this host's hooks could not be removed)`);
82
+ } else {
83
+ if (!dryRun) {
84
+ await removeOutside(writer, file.target);
85
+ await removeEmptyParents(file.target, claudeBoundary(host, file.target));
86
+ }
87
+ lines.push(`${dryRun ? 'Would remove' : 'Removed'} ${file.relativeToHost}`);
88
+ }
89
+ }
90
+ return { lines, detached };
91
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Judges whether an image file may be removed by content evidence (D10 item 3).
3
+ * Plan_65 D12 requires uninstall and the home removal plan to share this read-only judgment,
4
+ * so their protection of operator edits cannot drift into two different implementations.
5
+ */
6
+ import fs from 'node:fs/promises';
7
+ import { buildInstallPlan, contentFingerprint } from './manifest.mjs';
8
+ import { plannedContent } from './copy.mjs';
9
+ import { fingerprintFor, recordTarget } from './install-record.mjs';
10
+
11
+ export async function imagePackageIndex(host, packageRoot) {
12
+ const plan = await buildInstallPlan(host, packageRoot);
13
+ return new Map(plan
14
+ .filter((item) => item.root === 'brand')
15
+ .map((item) => [item.relativeToRoot, item]));
16
+ }
17
+
18
+ export async function judgeImageFile(host, file, fingerprints, packageIndex) {
19
+ const target = recordTarget(host, file);
20
+ let stat;
21
+ try {
22
+ stat = await fs.lstat(target);
23
+ } catch (error) {
24
+ if (error.code === 'ENOENT') return { verdict: 'missing', reason: 'missing' };
25
+ return { verdict: 'keep', reason: `unreadable: ${error.code ?? 'UNKNOWN'}` };
26
+ }
27
+ if (stat.isSymbolicLink()) return { verdict: 'keep', reason: 'link' };
28
+
29
+ let bytes;
30
+ try {
31
+ bytes = await fs.readFile(target);
32
+ } catch (error) {
33
+ if (error.code === 'ENOENT') return { verdict: 'missing', reason: 'missing' };
34
+ return { verdict: 'keep', reason: `unreadable: ${error.code ?? 'UNKNOWN'}` };
35
+ }
36
+
37
+ const recorded = fingerprintFor({ fingerprints }, file);
38
+ const matchesRecord = typeof recorded === 'string' && contentFingerprint(bytes) === recorded;
39
+ if (matchesRecord) return { verdict: 'remove', reason: 'recorded' };
40
+ const item = packageIndex.get(file.path);
41
+ const matchesPackage = item ? bytes.equals(await plannedContent(item, host.brandRoot)) : false;
42
+ if (matchesPackage) return { verdict: 'remove', reason: 'package' };
43
+ return { verdict: 'keep', reason: 'changed' };
44
+ }