@lyupro/codex-bridge 0.6.8 → 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 (93) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +6 -4
  3. package/bin/codex-bridge.mjs +35 -177
  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 +3 -2
  8. package/cli/doctor-host-side.mjs +110 -0
  9. package/cli/doctor.mjs +17 -1
  10. package/cli/home-inspection.mjs +122 -0
  11. package/cli/home-plan-execute.mjs +140 -0
  12. package/cli/home-removal-plan.mjs +61 -0
  13. package/cli/hook-recognizer.mjs +78 -0
  14. package/cli/host-contract.mjs +11 -5
  15. package/cli/host-inspection.mjs +156 -0
  16. package/cli/host-removal.mjs +91 -0
  17. package/cli/image-evidence.mjs +44 -0
  18. package/cli/install-owners.mjs +244 -0
  19. package/cli/install-record.mjs +71 -9
  20. package/cli/install.mjs +88 -75
  21. package/cli/inventory-removal.mjs +84 -0
  22. package/cli/inventory-transition.mjs +66 -0
  23. package/cli/lifecycle-lock-clear.mjs +104 -0
  24. package/cli/lifecycle-lock-inspect.mjs +91 -0
  25. package/cli/lifecycle-lock.mjs +335 -0
  26. package/cli/lifecycle-transaction.mjs +57 -0
  27. package/cli/link-segments.mjs +45 -0
  28. package/cli/model-set.mjs +4 -3
  29. package/cli/model-speed.mjs +9 -2
  30. package/cli/model.mjs +5 -3
  31. package/cli/owner-record.mjs +40 -0
  32. package/cli/owner-sync-apply.mjs +108 -0
  33. package/cli/owner-sync.mjs +115 -0
  34. package/cli/projects.mjs +5 -2
  35. package/cli/prune-args.mjs +9 -4
  36. package/cli/prune.mjs +23 -72
  37. package/cli/purge-live-runs.mjs +98 -0
  38. package/cli/purge-preflight.mjs +103 -0
  39. package/cli/read.mjs +2 -1
  40. package/cli/record-removal.mjs +43 -0
  41. package/cli/removal-outcomes.mjs +115 -0
  42. package/cli/removal-plan.mjs +187 -0
  43. package/cli/remove-layout.mjs +40 -1
  44. package/cli/rules-owners.mjs +4 -2
  45. package/cli/terminal-question.mjs +41 -0
  46. package/cli/uninstall-purge.mjs +139 -0
  47. package/cli/uninstall-rules.mjs +72 -0
  48. package/cli/uninstall.mjs +208 -127
  49. package/cli/unlock-lifecycle.mjs +102 -0
  50. package/cli/unlock.mjs +7 -4
  51. package/cli/update.mjs +102 -27
  52. package/package.json +4 -3
  53. package/scripts/home-write-audit.mjs +286 -0
  54. package/scripts/home-write-inventory.mjs +49 -0
  55. package/scripts/home-write-lexer.mjs +182 -0
  56. package/scripts/isolated-roots.mjs +6 -1
  57. package/scripts/open-work/check.mjs +180 -0
  58. package/scripts/open-work/live-thresholds.mjs +155 -0
  59. package/scripts/open-work/register.mjs +56 -0
  60. package/scripts/root-guard/root-entries.mjs +15 -0
  61. package/scripts/run-tests.mjs +26 -1
  62. package/src/agents/codex-advisor.md +5 -2
  63. package/src/home/conventions.md +9 -0
  64. package/src/home/hooks/guard-tries.mjs +6 -4
  65. package/src/home/hooks/order-gate.mjs +2 -8
  66. package/src/home/hooks/reply-guard.mjs +2 -7
  67. package/src/home/hooks/worktree-witness.mjs +6 -6
  68. package/src/home/lib/agents.mjs +1 -1
  69. package/src/home/lib/atomic-json.mjs +24 -6
  70. package/src/home/lib/config-edit.mjs +25 -10
  71. package/src/home/lib/dispatcher-state.mjs +25 -10
  72. package/src/home/lib/file-lock.mjs +41 -10
  73. package/src/home/lib/handback-witness.mjs +7 -4
  74. package/src/home/lib/home-registry.mjs +211 -0
  75. package/src/home/lib/home-write.mjs +189 -0
  76. package/src/home/lib/hook-diagnostics.mjs +20 -0
  77. package/src/home/lib/host-observations.mjs +7 -4
  78. package/src/home/lib/meta/advice-status.mjs +2 -2
  79. package/src/home/lib/meta/advice-verdict.mjs +30 -1
  80. package/src/home/lib/meta/events.mjs +2 -1
  81. package/src/home/lib/meta/paths.mjs +24 -24
  82. package/src/home/lib/meta/run-snapshots.mjs +29 -0
  83. package/src/home/lib/meta/snapshot-format.mjs +96 -0
  84. package/src/home/lib/meta/verdict.mjs +14 -16
  85. package/src/home/lib/run-config.mjs +10 -1
  86. package/src/home/lib/runner/git-paths.mjs +74 -0
  87. package/src/home/lib/runner/git-state.mjs +38 -34
  88. package/src/home/lib/runner/launcher.mjs +2 -2
  89. package/src/home/lib/runner/preflight.mjs +7 -0
  90. package/src/home/lib/runner/prompts.mjs +35 -2
  91. package/src/home/lib/runner/schemas.mjs +4 -1
  92. package/src/home/lib/runner/scope-check.mjs +29 -20
  93. package/src/home/lib/runner/worker.mjs +3 -3
package/cli/uninstall.mjs CHANGED
@@ -1,33 +1,27 @@
1
- /** Uninstalls only recorded files from both roots while preserving host data and foreign files. */
2
- import fs from 'node:fs/promises';
3
- import path from 'node:path';
1
+ /**
2
+ * Uninstalls one host: its side from an inspection of the package's marks, the shared image only from
3
+ * the record and only once the host is detached, preserving host data and foreign files (Plan_65 D10).
4
+ */
4
5
  import {
5
- definitionForRecordedHook,
6
- fileFingerprint,
7
- installRecordPath,
8
6
  legacyInstallRecordPath,
9
7
  readInstallRecord,
10
- recordTarget,
11
8
  } from './manifest.mjs';
9
+ import { imageRemoval } from './install-owners.mjs';
10
+ import { asFormat2, readInstallRecordFile } from './install-record.mjs';
11
+ import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
12
12
  import { removePermissionRules } from './permissions.mjs';
13
- import { hostContractPath } from './host-contract.mjs';
14
- import { brandStateDir } from '../src/home/lib/brand-home.mjs';
15
- import {
16
- commandFor,
17
- removeHook,
18
- withSettingsRun,
19
- } from './settings-merge.mjs';
20
- import { readRulesRegistry, removeRulesOwner, remainingRulesOwners } from './rules-owners.mjs';
21
- import {
22
- claudeBoundary,
23
- removeEmpty,
24
- removeEmptyLayout,
25
- removeEmptyParents,
26
- } from './remove-layout.mjs';
27
-
28
- function remainingOwnersText(count) {
29
- return `${count} other owner${count === 1 ? '' : 's'} ${count === 1 ? 'remains' : 'remain'}`;
30
- }
13
+ import { inspectHost, hasPackageMarks } from './host-inspection.mjs';
14
+ import { removeHostSide } from './host-removal.mjs';
15
+ import { askRemoval, removalHint } from './inventory-removal.mjs';
16
+ import { withSettingsRun } from './settings-merge.mjs';
17
+ import { withLifecycle } from './lifecycle-transaction.mjs';
18
+ import { readRulesRegistry } from './rules-owners.mjs';
19
+ import { rulesDryRunLines, removeRulesForHost, remainingOwnersText } from './uninstall-rules.mjs';
20
+ import { removeEmpty, removeEmptyLayout } from './remove-layout.mjs';
21
+ import { recordHomeWriter, removeOutside } from './record-removal.mjs';
22
+ import { buildHomeRemovalPlan } from './home-removal-plan.mjs';
23
+ import { executeHomePlan } from './home-plan-execute.mjs';
24
+ import { outcomeLines, outcomeExitCode, planLines, planExitCode } from './removal-outcomes.mjs';
31
25
 
32
26
  function permissionOutput(host, removed, dryRun) {
33
27
  const verb = dryRun ? 'Would remove' : 'Removed';
@@ -35,36 +29,116 @@ function permissionOutput(host, removed, dryRun) {
35
29
  return `${verb} ${removed} permission rule ${plural} from ${host.settingsPath}.`;
36
30
  }
37
31
 
38
- // Plan_62 D22: uninstall removes only recorded files, and the message used to name only runs and
39
- // config.json — state/, the host measurement and conventions.md stayed behind unmentioned. Until
40
- // Plan_65 derives this from a registry, the sentence names every kind the package writes while working.
41
- function preservationText(host) {
42
- return `Run artifacts in ${path.join(host.root, 'codex-runs')} are preserved, and so is what the package `
43
- + `wrote into ${host.brandRoot} while working: the run configuration ${host.brandConfigPath}, the conventions `
44
- + `${host.brandConventionsPath}, the host measurement ${hostContractPath(host)} and runtime state in `
45
- + `${brandStateDir(host.brandRoot)} (dispatcher state, guard counters, the handback witness, the host `
46
- + 'observations, dispatcher contract verdicts, and hook diagnostics that hold the full hook input). Delete them by '
47
- + 'hand for a complete removal.';
32
+ // Permission strings live in the same settings.json as the hooks; an unreadable file is left untouched
33
+ // and named rather than rewritten, like the hooks themselves (Plan_65 D10 item 4).
34
+ export async function removePermissions(host, inspection, dryRun) {
35
+ if (inspection.settingsError !== null) {
36
+ return `Left permission rules in ${host.settingsPath} because \
37
+ settings could not be read: ${inspection.settingsError}`;
38
+ }
39
+ const { removed } = await removePermissionRules(host.settingsPath, { dryRun });
40
+ return permissionOutput(host, removed, dryRun);
41
+ }
42
+
43
+ export function stillAttachedLine(host) {
44
+ return `Did not finish uninstalling codex-bridge: ${host.root} still has its hooks; fix \
45
+ ${host.settingsPath} and run uninstall again.`;
46
+ }
47
+
48
+ // Plan_65 D6: uninstalling one host removed the shared image from under every other host of the
49
+ // home. Only a record at the old per-host path (no home record at all) keeps the old behavior.
50
+ function imageDecision(rawRecord, host) {
51
+ if (rawRecord === null) return { removeImage: true };
52
+ return imageRemoval(asFormat2(rawRecord, host), host);
48
53
  }
49
54
 
50
- function displayFile(file) {
51
- return `${file.root}/${file.path}`;
55
+ // Plan_65 D12: only a detached last owner permits the plan to remove the shared image.
56
+ function imagePolicyFor(decision, hostSide) {
57
+ if (!decision.removeImage) return { remove: false, reason: decision.reason };
58
+ return hostSide.detached
59
+ ? { remove: true, reason: 'last owner' }
60
+ : { remove: false, reason: 'this host is still attached' };
52
61
  }
53
62
 
54
- function hookRemovalSpec(host, hook) {
55
- const definition = definitionForRecordedHook(hook);
56
- const target = recordTarget(host, hook);
57
- const full = commandFor(target);
58
- const short = `codex-bridge hook ${definition.name}`;
63
+ function imageDispositionLine(host, decision, dryRun) {
64
+ if (decision.removeImage) return null;
65
+ const verb = dryRun ? 'Would leave' : 'Left';
66
+ if (decision.reason === 'no-record') {
67
+ return `${verb} the shared image in ${host.brandRoot} because \
68
+ this host has no installation record; its members are unknown.`;
69
+ }
70
+ if (decision.reason === 'other-owners') {
71
+ return `${verb} the shared image in ${host.brandRoot} because ${remainingOwnersText(decision.remaining)}.`;
72
+ }
73
+ return `${verb} the shared image in ${host.brandRoot} because \
74
+ the installation inventory is incomplete: other installations may use this home.`;
75
+ }
76
+
77
+ // Plan_65 D9 item 3 as amended by D10 item 4: after the last known owner answered "no", a repeat uninstall
78
+ // of ANY host of the home promised a question and printed "not installed" instead. The branch decides from
79
+ // the raw record and the inspection only — `legacy` is nobody's host view (D10 item 5) — removes the named
80
+ // host's side either way, and removes the image only on "yes" for a detached host.
81
+ async function uninstallOrphan(options, inspection, registry) {
82
+ const { host, dryRun = false } = options;
83
+ const promptOptions = { ...options, candidates: options.candidates ?? registry?.owners ?? [] };
84
+ if (dryRun) {
85
+ const permissionLine = await removePermissions(host, inspection, true);
86
+ const hostSide = await removeHostSide(host, inspection, { owner: null, dryRun: true });
87
+ const imagePolicy = { remove: hostSide.detached,
88
+ reason: hostSide.detached ? 'operator confirmed' : 'this host is still attached' };
89
+ const plan = await buildHomeRemovalPlan({ command: 'uninstall', host, packageRoot: options.packageRoot,
90
+ imagePolicy });
91
+ return {
92
+ exitCode: 1,
93
+ output: [permissionLine, ...hostSide.lines,
94
+ `A real run would ask whether to remove the shared image of ${host.brandRoot}: \
95
+ no host is recorded as using it.`,
96
+ 'If the answer is yes:', ...planLines(plan, { host, detached: hostSide.detached })].join('\n'),
97
+ };
98
+ }
99
+
100
+ const answer = await askRemoval(host, 'orphan', promptOptions);
101
+ if (answer === 'cancel') return { exitCode: 130, output: 'Cancelled; nothing was changed.' };
102
+
103
+ const permissionLine = await removePermissions(host, inspection, false);
104
+ const hostSide = await removeHostSide(host, inspection, { owner: null });
105
+ const imagePolicy = answer === 'remove'
106
+ ? { remove: hostSide.detached,
107
+ reason: hostSide.detached ? 'operator confirmed' : 'this host is still attached' }
108
+ : { remove: false, reason: 'no host is recorded as using it' };
109
+ const plan = await buildHomeRemovalPlan({ command: 'uninstall', host, packageRoot: options.packageRoot,
110
+ imagePolicy });
111
+ await removeEmpty(host.commandsDir);
112
+ await removeEmpty(host.agentsDir);
113
+ await removeEmptyLayout(host.legacyAgentsDir);
114
+ await removeEmptyLayout(host.legacyCommandsDir);
115
+ const { outcomes } = await executeHomePlan(host, plan,
116
+ { detached: hostSide.detached, imageMembers: plan.imageMembers });
117
+
118
+ let imageLine = null;
119
+ let hint = null;
120
+ if (answer === 'remove' && !hostSide.detached) {
121
+ imageLine = `Left the shared image in ${host.brandRoot} because this host's hooks could not be removed.`;
122
+ } else if (answer !== 'remove') {
123
+ imageLine = `Left the shared image in ${host.brandRoot} because no host is recorded as using it \
124
+ and the inventory is incomplete.`;
125
+ hint = removalHint(host);
126
+ }
127
+
128
+ const hasMarks = hasPackageMarks(inspection);
129
+ const heading = !hostSide.detached
130
+ ? stillAttachedLine(host)
131
+ : hasMarks ? 'Uninstalled codex-bridge.'
132
+ : `No codex-bridge files or hooks were found in ${host.root}.`;
59
133
  return {
60
- event: hook.event,
61
- matcher: definition.matcher,
62
- command: hook.command || full,
63
- alternateCommands: [full, short],
134
+ exitCode: answer === 'remove' && hostSide.detached ? outcomeExitCode(outcomes) : 1,
135
+ output: [heading, permissionLine, ...hostSide.lines, imageLine, hint, ...outcomeLines(outcomes, { host })]
136
+ .filter(Boolean).join('\n'),
64
137
  };
65
138
  }
66
139
 
67
- async function uninstallInRun({ host, dryRun = false } = {}) {
140
+ async function uninstallInRun(options = {}) {
141
+ const { host, dryRun = false } = options;
68
142
  // Preflight before removing the hook: package removal on a broken registry left the host without its watchdog.
69
143
  let registry = null;
70
144
  let registryError = null;
@@ -73,101 +147,108 @@ async function uninstallInRun({ host, dryRun = false } = {}) {
73
147
  } catch (err) {
74
148
  registryError = err;
75
149
  }
76
- const permissionResult = await removePermissionRules(host.settingsPath, { dryRun });
77
- const permissionLine = permissionOutput(host, permissionResult.removed, dryRun);
150
+ // Plan_65 D9: the question precedes the first change, so the record is read before the permission rules go;
151
+ // a cancel then leaves the host exactly as it was.
152
+ const rawRecord = await readInstallRecordFile(host);
153
+ const ownerKey = normalizeRepoPath(host.root);
154
+ const format2 = rawRecord === null ? null : asFormat2(rawRecord, host);
155
+ const orphaned = format2 !== null && Object.keys(format2.owners).length === 0;
156
+ const owner = format2?.owners[ownerKey] ?? null;
157
+ // Plan_65 D10: inspect marks and content before permission edits or any other host mutation.
158
+ const inspection = await inspectHost(host, { owner });
159
+ if (orphaned) return uninstallOrphan(options, inspection, registry);
160
+
78
161
  const record = await readInstallRecord(host);
79
- const preservation = preservationText(host);
80
- if (!record) {
81
- return { exitCode: 1, output: `${permissionLine}\ncodex-bridge is not installed.\n${preservation}` };
162
+ let decision = rawRecord === null
163
+ ? (record ? { removeImage: true } : { removeImage: false, reason: 'no-record' })
164
+ : imageDecision(rawRecord, host);
165
+ if (!record && decision.removeImage) decision = { removeImage: false, reason: 'no-record' };
166
+ const lastKnownOwner = decision.reason === 'incomplete-inventory'
167
+ && Object.hasOwn(format2.owners, ownerKey);
168
+ let lastOwnerHint = null;
169
+ if (lastKnownOwner && !dryRun) {
170
+ const answer = await askRemoval(host, 'last-owner', options);
171
+ if (answer === 'cancel') return { exitCode: 130, output: 'Cancelled; nothing was changed.' };
172
+ if (answer === 'remove') decision = { removeImage: true };
173
+ else lastOwnerHint = removalHint(host);
82
174
  }
175
+ const permissionLine = await removePermissions(host, inspection, dryRun);
176
+ const hostMarks = hasPackageMarks(inspection);
177
+ if (!record && !hostMarks) {
178
+ return {
179
+ exitCode: 1,
180
+ output: [permissionLine, 'codex-bridge is not installed.', ...outcomeLines([], { host })].join('\n'),
181
+ };
182
+ }
183
+ const writer = record ? recordHomeWriter(host, record.files) : null;
83
184
 
84
185
  if (dryRun) {
85
- const lines = [permissionLine, ...record.files.map((file) => `Would remove ${displayFile(file)}`)];
86
- if (record.rules) {
87
- if (registryError) {
88
- lines.push(`Would leave ${record.rules.path} because the rules ownership registry is invalid; ownership is unknown.`);
89
- } else {
90
- const remainingOwners = remainingRulesOwners(registry, host);
91
- const currentFingerprint = await fileFingerprint(record.rules.path);
92
- if (remainingOwners?.length) {
93
- lines.push(`Would leave ${record.rules.path} because ${remainingOwnersText(remainingOwners.length)}.`);
94
- } else if (currentFingerprint === record.rules.fingerprint) {
95
- lines.push(`Would remove ${record.rules.path}; no other owners remain and its fingerprint is unchanged.`);
96
- } else if (currentFingerprint !== null) {
97
- lines.push(`Would leave ${record.rules.path} because its contents changed after installation.`);
98
- } else {
99
- lines.push(`Would leave ${record.rules.path} because it is already absent.`);
100
- }
101
- if (!registry) {
102
- lines.push(`Warning: the rules ownership registry was missing; other installations may use ${record.rules.path}.`);
103
- }
104
- }
105
- }
106
- for (const hook of record.hooks) {
107
- const definition = definitionForRecordedHook(hook);
108
- lines.push(`Would remove the ${hook.event} hook ${definition.name} for matcher ${definition.matcher}.`);
109
- }
110
- lines.push('Would remove the installation record from the brand root.');
111
- lines.push(preservation);
112
- return { exitCode: 0, output: lines.join('\n') };
186
+ const hostSide = await removeHostSide(host, inspection, { owner, dryRun: true });
187
+ const imagePolicy = imagePolicyFor(decision, hostSide);
188
+ const plan = await buildHomeRemovalPlan({ command: 'uninstall', host, packageRoot: options.packageRoot,
189
+ imagePolicy });
190
+ const lines = [permissionLine, ...hostSide.lines];
191
+ const blockedImageLine = decision.removeImage && !hostSide.detached
192
+ ? `Would leave the shared image in ${host.brandRoot} because this host's hooks could not be removed.`
193
+ : null;
194
+ const imageLine = blockedImageLine || (!decision.removeImage
195
+ ? imageDispositionLine(host, decision, true) : null);
196
+ if (imageLine) lines.push(imageLine);
197
+ if (lastKnownOwner) lines.push(`A real run would ask whether ${host.root} is the last host \
198
+ using ${host.brandRoot}.`);
199
+ lines.push(...await rulesDryRunLines({
200
+ host, record, registry, registryError, detached: hostSide.detached,
201
+ }));
202
+ lines.push(...planLines(plan, { host, detached: hostSide.detached }));
203
+ return {
204
+ exitCode: decision.reason === 'incomplete-inventory' || !hostSide.detached ? 1 : planExitCode(plan),
205
+ output: lines.join('\n'),
206
+ };
113
207
  }
114
208
 
115
- for (const hook of record.hooks) {
116
- await removeHook(host.settingsPath, hookRemovalSpec(host, hook), {
117
- createdGroup: hook.createdGroup === true,
118
- });
119
- }
120
- let ownership = null;
121
- if (!registryError) {
122
- try {
123
- ownership = await removeRulesOwner(host);
124
- } catch (err) {
125
- registryError = err;
126
- }
127
- }
128
- const rulesOutput = [];
129
- if (record.rules) {
130
- if (registryError) {
131
- rulesOutput.push(`Left ${record.rules.path} because the rules ownership registry is invalid; ownership is unknown.`);
132
- } else {
133
- if (ownership?.owners.length) {
134
- rulesOutput.push(`Left ${record.rules.path} because ${remainingOwnersText(ownership.owners.length)}.`);
135
- } else {
136
- const currentFingerprint = await fileFingerprint(record.rules.path);
137
- if (currentFingerprint === record.rules.fingerprint) {
138
- await fs.rm(record.rules.path, { force: true });
139
- } else if (currentFingerprint !== null) {
140
- rulesOutput.push(`Left ${record.rules.path} because its contents changed after installation.`);
141
- }
142
- }
143
- if (!registry) {
144
- rulesOutput.push(`Warning: the rules ownership registry was missing; other installations may use ${record.rules.path}.`);
145
- }
146
- }
147
- }
148
- for (const file of record.files) {
149
- const target = recordTarget(host, file);
150
- await fs.rm(target, { force: true });
151
- const boundary = file.root === 'brand'
152
- ? host.brandRoot
153
- : claudeBoundary(host, target);
154
- await removeEmptyParents(target, boundary);
155
- }
209
+ const hostSide = await removeHostSide(host, inspection, { owner });
210
+ const imagePolicy = imagePolicyFor(decision, hostSide);
211
+ const plan = await buildHomeRemovalPlan({ command: 'uninstall', host, packageRoot: options.packageRoot,
212
+ imagePolicy });
213
+ // Plan_65 D10 item 4: a host still attached keeps everything it shares — its rules ownership, its
214
+ // place in the record, the old per-host record — so a repeat run after the fix finds it whole.
215
+ const detachedRecord = record && hostSide.detached ? record : null;
216
+ const rulesResult = await removeRulesForHost({
217
+ host, record, registry, registryError, detached: hostSide.detached, writer,
218
+ });
219
+ const rulesOutput = rulesResult.lines;
220
+ registryError = rulesResult.registryError;
221
+ const { outcomes } = await executeHomePlan(host, plan,
222
+ { detached: hostSide.detached, imageMembers: plan.imageMembers });
156
223
  await removeEmpty(host.commandsDir);
157
- await fs.rm(installRecordPath(host), { force: true });
158
- await fs.rm(legacyInstallRecordPath(host), { force: true });
224
+ if (detachedRecord) await removeOutside(writer, legacyInstallRecordPath(host));
159
225
  await removeEmpty(host.agentsDir);
160
226
  await removeEmptyLayout(host.legacyAgentsDir);
161
227
  await removeEmptyLayout(host.legacyCommandsDir);
162
- await removeEmpty(host.brandRoot);
228
+ const blockedImageLine = decision.removeImage && !hostSide.detached
229
+ ? `Left the shared image in ${host.brandRoot} because this host's hooks could not be removed.`
230
+ : null;
231
+ const imageLine = blockedImageLine || (!decision.removeImage
232
+ ? imageDispositionLine(host, decision, false) : null);
233
+ const heading = hostSide.detached
234
+ ? 'Uninstalled codex-bridge.'
235
+ : stillAttachedLine(host);
163
236
  return {
164
- exitCode: 0,
165
- output: ['Uninstalled codex-bridge.', permissionLine, ...rulesOutput, preservation].join('\n'),
237
+ exitCode: decision.reason === 'incomplete-inventory' || !hostSide.detached ? 1 : outcomeExitCode(outcomes),
238
+ output: [heading, permissionLine, ...hostSide.lines, ...rulesOutput, imageLine, lastOwnerHint,
239
+ ...outcomeLines(outcomes, { host })].filter(Boolean).join('\n'),
166
240
  };
167
241
  }
168
242
 
169
243
  export async function uninstall(options = {}) {
170
244
  const host = options?.host;
171
245
  if (!host?.settingsPath) return uninstallInRun(options);
172
- return withSettingsRun(host.settingsPath, () => uninstallInRun(options));
246
+ // A dry run only reads; like install's, it must not wait on or create anything.
247
+ if (options.dryRun === true) return withSettingsRun(host.settingsPath, () => uninstallInRun(options));
248
+ return withLifecycle(
249
+ host,
250
+ 'uninstall',
251
+ () => withSettingsRun(host.settingsPath, () => uninstallInRun(options)),
252
+ { ticket: options.lifecycleTicket, waitMs: options.lifecycleWaitMs },
253
+ );
173
254
  }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Parses and renders `unlock --lifecycle [--clear]` (Plan_65 D11): the busy refusal of install, update
3
+ * and uninstall sends the operator here, so the command has to name the holder and clear a crash's lock.
4
+ */
5
+ import { resolveBrandHome } from '../src/home/lib/brand-home.mjs';
6
+ import { withCommandHelp } from './command-help.mjs';
7
+ import { inspectLifecycleLock } from './lifecycle-lock-inspect.mjs';
8
+ import { clearLifecycleLock } from './lifecycle-lock-clear.mjs';
9
+
10
+ const heldLine = (holder) => (
11
+ `held by ${holder.command} for ${holder.hostRoot}, pid ${holder.pid}, since ${holder.acquiredAt}.`
12
+ );
13
+
14
+ function renderClear(observation) {
15
+ const { homeRoot, strategy, holder, lockPath, gatePath, gateLeft } = observation;
16
+ const lines = [`Lifecycle lock of ${homeRoot} (${strategy}), clearing:`];
17
+ let exitCode = 0;
18
+ switch (observation.outcome) {
19
+ case 'cleared':
20
+ lines.push(`Removed the lock left by ${holder.command} for ${holder.hostRoot}, pid ${holder.pid} (dead).`);
21
+ lines.push('The next install, update or uninstall may take it.');
22
+ break;
23
+ case 'free':
24
+ lines.push('Nothing to clear; the lock is free.');
25
+ break;
26
+ case 'kernel-managed':
27
+ lines.push('Kernel-managed lock; nothing to clear.');
28
+ lines.push('Run codex-bridge unlock --lifecycle to see its holder.');
29
+ break;
30
+ case 'refused':
31
+ lines.push(`Refused: ${observation.reason}. Nothing was removed.`);
32
+ if (holder) lines.push(heldLine(holder));
33
+ lines.push(`If you are sure no install, update or uninstall is running, delete ${lockPath} yourself.`);
34
+ exitCode = 1;
35
+ break;
36
+ case 'gate-busy':
37
+ lines.push(`Another clear is in progress or crashed: ${gatePath}.`);
38
+ lines.push('If none is running, delete that file yourself.');
39
+ exitCode = 1;
40
+ break;
41
+ case 'no-home':
42
+ lines.push(`codex-bridge unlock --lifecycle: no package home at ${homeRoot}; nothing to inspect.`);
43
+ exitCode = 1;
44
+ break;
45
+ default:
46
+ throw new Error(`Unknown lifecycle lock clear outcome: ${observation.outcome}`);
47
+ }
48
+ if (gateLeft) {
49
+ lines.push(`Could not remove the clear gate: ${gateLeft}.`);
50
+ lines.push('If no clear is running, delete that file yourself.');
51
+ exitCode = 1;
52
+ }
53
+ return { exitCode, output: lines.join('\n') };
54
+ }
55
+
56
+ export async function unlockLifecycle(argv, options = {}) {
57
+ const clearing = argv.length === 2 && argv[0] === '--lifecycle' && argv[1] === '--clear';
58
+ if (!clearing && (argv.length !== 1 || argv[0] !== '--lifecycle')) {
59
+ // Name the first argument past the longest accepted prefix, not always the second one.
60
+ const accepted = argv[0] === '--lifecycle' ? (argv[1] === '--clear' ? 2 : 1) : 0;
61
+ const argument = argv[accepted];
62
+ return {
63
+ exitCode: 2,
64
+ output: withCommandHelp('unlock', `codex-bridge unlock --lifecycle: unexpected argument "${argument}".`),
65
+ };
66
+ }
67
+ const homeRoot = options.homeRoot ?? resolveBrandHome().root;
68
+ if (clearing) return renderClear(await clearLifecycleLock(homeRoot, options.clear ?? {}));
69
+ const observation = await inspectLifecycleLock(homeRoot, options.inspect ?? {});
70
+ const { strategy, observedAt, holder, liveness } = observation;
71
+ const lines = [`Lifecycle lock of ${homeRoot} (${strategy}), observed ${observedAt}:`];
72
+ let exitCode = 0;
73
+ switch (observation.state) {
74
+ case 'free':
75
+ lines.push('free — no install, update or uninstall holds it.');
76
+ break;
77
+ case 'held':
78
+ if (holder === null) {
79
+ lines.push('held; the holder did not identify itself.');
80
+ break;
81
+ }
82
+ lines.push(heldLine(holder));
83
+ if (strategy === 'file') {
84
+ lines.push(`Holder process: ${liveness}.`);
85
+ if (liveness === 'dead') {
86
+ lines.push('The file was left by a crash. Clear it with codex-bridge unlock --lifecycle --clear');
87
+ }
88
+ }
89
+ break;
90
+ case 'unverified':
91
+ lines.push(`could not be verified: ${observation.reason}. Nothing was changed.`);
92
+ exitCode = 1;
93
+ break;
94
+ case 'no-home':
95
+ lines.push(`codex-bridge unlock --lifecycle: no package home at ${homeRoot}; nothing to inspect.`);
96
+ exitCode = 1;
97
+ break;
98
+ default:
99
+ throw new Error(`Unknown lifecycle lock state: ${observation.state}`);
100
+ }
101
+ return { exitCode, output: lines.join('\n') };
102
+ }
package/cli/unlock.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  /** Closes dead-pid running records without deleting any run artifacts. */
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
+ import { withCommandHelp } from './command-help.mjs';
5
6
  import { formatSilence, heartbeatAge, HEARTBEAT_STALE_MS } from '../src/home/lib/heartbeat.mjs';
6
7
  import { runLiveness } from '../src/home/lib/meta/run-liveness.mjs';
7
8
  import { markAbandoned } from '../src/home/lib/meta/run-state.mjs';
@@ -14,6 +15,7 @@ import { resolveProjectRunsDir } from '../src/home/lib/runner/project-dir.mjs';
14
15
  import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
15
16
  import { readJson } from '../src/home/lib/write-meta.mjs';
16
17
  import { renderTable } from './table.mjs';
18
+ import { unlockLifecycle } from './unlock-lifecycle.mjs';
17
19
 
18
20
  const CLOSED_COLUMNS = [
19
21
  { key: 'project', label: 'project' },
@@ -38,20 +40,20 @@ function parseArgs(argv) {
38
40
  let all = false;
39
41
  for (const arg of argv) {
40
42
  if (arg === '--all') {
41
- if (all) return { error: 'codex-bridge unlock accepts --all only once.' };
43
+ if (all) return { error: withCommandHelp('unlock', 'codex-bridge unlock accepts --all only once.') };
42
44
  all = true;
43
45
  continue;
44
46
  }
45
47
  if (typeof arg !== 'string' || arg.startsWith('-')) {
46
- return { error: `codex-bridge unlock: unknown option "${arg}".` };
48
+ return { error: withCommandHelp('unlock', `codex-bridge unlock: unknown option "${arg}".`) };
47
49
  }
48
50
  positional.push(arg);
49
51
  }
50
52
  if (all && positional.length) {
51
- return { error: 'codex-bridge unlock accepts either --all or one project name.' };
53
+ return { error: withCommandHelp('unlock', 'codex-bridge unlock accepts either --all or one project name.') };
52
54
  }
53
55
  if (positional.length > 1) {
54
- return { error: 'codex-bridge unlock accepts at most one project name.' };
56
+ return { error: withCommandHelp('unlock', 'codex-bridge unlock accepts at most one project name.') };
55
57
  }
56
58
  const project = positional[0] || null;
57
59
  if (project && (path.isAbsolute(project) || path.dirname(project) !== '.')) {
@@ -228,6 +230,7 @@ function renderReport(plan, records, closed, failures, options) {
228
230
  }
229
231
 
230
232
  export function unlock(argv = [], options = {}) {
233
+ if (argv[0] === '--lifecycle') return unlockLifecycle(argv, options);
231
234
  const plan = unlockPlan(argv, options);
232
235
  if (plan.error) return result(2, plan.error);
233
236
  const records = plan.projects.flatMap((project) => inspectRuns(project, options));