@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
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Asks the operator's permission for removals the installation record cannot prove safe: uninstall's
3
+ * shared image under an incomplete inventory (Plan_65 D9) and purge's two consents (D6, D12 item 5).
4
+ *
5
+ * An old installation record never named every host of the home, so leaving the last KNOWN owner
6
+ * behind, or finding no owner at all, is not proof that nobody else runs from the image. Only the
7
+ * operator can say so; "yes" is that permission, anything else keeps the image with a hint how to
8
+ * come back to the question.
9
+ */
10
+ import { askYesNo, isInteractive } from './terminal-question.mjs';
11
+ import { readRulesRegistry } from './rules-owners.mjs';
12
+ import { registryHintLines } from './inventory-transition.mjs';
13
+
14
+ const KEPT_DATA = 'config.json, conventions.md and run data stay.';
15
+
16
+ export function lastOwnerQuestion(host, candidates) {
17
+ return [
18
+ `Home: ${host.brandRoot}`,
19
+ `Known owners: only this host (${host.root}), which is leaving.`,
20
+ 'The installation inventory is incomplete: an old installation record did not name every host that used this home.',
21
+ ...registryHintLines(host, candidates),
22
+ `Is ${host.root} the last host using this home? Yes removes the shared image and the installation record; ${KEPT_DATA}`,
23
+ ].join('\n');
24
+ }
25
+
26
+ export function orphanQuestion(host, candidates) {
27
+ return [
28
+ `Home: ${host.brandRoot}`,
29
+ 'No host is recorded as using this home, and the inventory is incomplete.',
30
+ ...registryHintLines(host, candidates),
31
+ `Remove the shared image and the installation record of this home? The package's files and hooks in ${host.root} are removed either way; ${KEPT_DATA}`,
32
+ ].join('\n');
33
+ }
34
+
35
+ const QUESTIONS = { 'last-owner': lastOwnerQuestion, orphan: orphanQuestion };
36
+
37
+ export async function askRemoval(host, kind, options) {
38
+ const question = QUESTIONS[kind];
39
+ if (!question) throw new TypeError(`Unknown inventory removal kind: ${kind}`);
40
+ if (!isInteractive(options)) return 'keep';
41
+ const candidates = options.candidates ?? (await readRulesRegistry(host))?.owners ?? [];
42
+ const answer = await askYesNo(question(host, candidates), options);
43
+ if (answer === 'yes') return 'remove';
44
+ if (answer === 'no') return 'keep';
45
+ return 'cancel';
46
+ }
47
+
48
+ // Plan_65 D6/D12 item 5: purge needs two separate consents, in this order. The first replaces the
49
+ // inventory the record cannot prove; the second is the only permission to delete the operator's own data.
50
+ export function purgeInventoryQuestion(host, candidates) {
51
+ return [
52
+ `Home: ${host.brandRoot}`,
53
+ `Purge removes this home for good, including what you edited in it. Leaving host: ${host.root}.`,
54
+ ...registryHintLines(host, candidates),
55
+ 'Is the inventory complete — does no other host use this home?',
56
+ ].join('\n');
57
+ }
58
+
59
+ export function purgeDataQuestion(host, dataFiles) {
60
+ const listed = dataFiles.length ? dataFiles.map((file) => ` ${file}`) : [' none found'];
61
+ return [
62
+ `Your data in ${host.brandRoot}:`,
63
+ ...listed,
64
+ 'Delete it? This cannot be undone; run artifacts outside the home stay.',
65
+ ].join('\n');
66
+ }
67
+
68
+ /**
69
+ * Asks one purge consent. Unlike askRemoval, "no terminal" is answered here as 'no': purge without
70
+ * a person at the keyboard has no consent to rely on, and the caller refuses rather than keeping.
71
+ */
72
+ export async function askPurgeConsent(host, kind, options, detail = []) {
73
+ if (kind !== 'inventory' && kind !== 'data') throw new TypeError(`Unknown purge consent: ${kind}`);
74
+ if (!isInteractive(options)) return 'no';
75
+ const question = kind === 'inventory'
76
+ ? purgeInventoryQuestion(host, options.candidates ?? (await readRulesRegistry(host))?.owners ?? [])
77
+ : purgeDataQuestion(host, detail);
78
+ return askYesNo(question, options);
79
+ }
80
+
81
+ export function removalHint(host) {
82
+ return `Run codex-bridge uninstall --host "${host.root}" again in a terminal to confirm that no other host uses `
83
+ + `${host.brandRoot} and remove it.`;
84
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * D9: The inventory becomes incomplete in one transition known before any write; ask then and
3
+ * store nothing about a refusal.
4
+ */
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { askYesNo, isInteractive } from './terminal-question.mjs';
8
+ import { isFormat2 } from './install-owners.mjs';
9
+ import { readInstallRecordFile } from './install-record.mjs';
10
+ import { normalizedRulesOwner, readRulesRegistry } from './rules-owners.mjs';
11
+
12
+ async function exists(target) {
13
+ try {
14
+ await fs.access(target);
15
+ return true;
16
+ } catch (err) {
17
+ if (err.code === 'ENOENT') return false;
18
+ throw err;
19
+ }
20
+ }
21
+
22
+ export async function detectTransition(host) {
23
+ const [record, homeHadImage] = await Promise.all([
24
+ readInstallRecordFile(host),
25
+ exists(path.join(host.brandRoot, 'lib')),
26
+ ]);
27
+ return {
28
+ transition: (record !== null && !isFormat2(record)) || (record === null && homeHadImage),
29
+ homeHadImage,
30
+ };
31
+ }
32
+
33
+ // The uninstall questions (cli/inventory-removal.mjs) show the same hint: one rendering, one wording.
34
+ export function registryHintLines(host, candidates) {
35
+ const owner = normalizedRulesOwner(host);
36
+ const otherCandidates = candidates.filter((root) => root !== owner);
37
+ return [
38
+ 'Codex rules registry host roots are a hint only; they may be stale or belong to another home:',
39
+ ...(otherCandidates.length ? otherCandidates.map((root) => ` ${root}`) : [' none found']),
40
+ ];
41
+ }
42
+
43
+ export function transitionQuestion(host, candidates) {
44
+ return [
45
+ `Home: ${host.brandRoot}`,
46
+ `Known owners: none yet besides this host (${host.root}).`,
47
+ 'The old installation record did not name every host that used this home.',
48
+ ...registryHintLines(host, candidates),
49
+ `Is ${host.root} the only host using this home?`,
50
+ ].join('\n');
51
+ }
52
+
53
+ export async function askTransition(host, options) {
54
+ if (!isInteractive(options)) return 'incomplete';
55
+ const candidates = options.candidates ?? (await readRulesRegistry(host))?.owners ?? [];
56
+ const answer = await askYesNo(transitionQuestion(host, candidates), options);
57
+ if (answer === 'yes') return 'complete';
58
+ if (answer === 'no') return 'incomplete';
59
+ return 'cancel';
60
+ }
61
+ /** What the operator is told after a transition: which answer was recorded and what it keeps. */
62
+ export function transitionOutcome(host, inventory) {
63
+ return inventory === 'complete'
64
+ ? `Recorded this host as the only one using ${host.brandRoot}.`
65
+ : 'The inventory stays incomplete: the shared image is kept until an uninstall confirms no other host uses it.';
66
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Removes a crashed holder's file lock, and only that (Plan_65 D11). D8 forbids ever removing a live
3
+ * holder's lock; advice A14 showed two clearers that both read dead holder X can remove the lock a new
4
+ * holder Y created in between, so clearers queue on a gate file taken before the authoritative read.
5
+ * Rename-then-verify is forbidden: moving a replacement live lock already frees its name.
6
+ */
7
+ import fsp from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { createHomeWriter } from '../src/home/lib/home-write.mjs';
10
+ import { holderLiveness, lifecycleLockStrategy, parseHolder } from './lifecycle-lock.mjs';
11
+
12
+ const ARTIFACT_ID = 'install-record';
13
+ const errorReason = (error) => error?.code || error?.message || String(error);
14
+
15
+ async function clearHeldGate(lockPath, identity, writer) {
16
+ let before;
17
+ try {
18
+ before = await fsp.lstat(lockPath);
19
+ } catch (error) {
20
+ if (error?.code === 'ENOENT') return { outcome: 'free' };
21
+ throw error;
22
+ }
23
+ if (!before.isFile()) return { outcome: 'refused', reason: 'not a regular file' };
24
+ const content = await fsp.readFile(lockPath);
25
+ const holder = parseHolder(content.toString('utf8').split(/\r?\n/, 1)[0]);
26
+ if (!holder) return { outcome: 'refused', reason: 'holder record is unreadable or incomplete' };
27
+ const liveness = holderLiveness(holder, identity);
28
+ if (liveness !== 'dead') return { outcome: 'refused', reason: `the holder process is ${liveness}`, holder };
29
+
30
+ let current;
31
+ let after;
32
+ try {
33
+ current = await fsp.readFile(lockPath);
34
+ after = await fsp.lstat(lockPath);
35
+ } catch (error) {
36
+ if (error?.code === 'ENOENT') {
37
+ return { outcome: 'refused', reason: 'the lock changed while clearing', holder };
38
+ }
39
+ return { outcome: 'refused', reason: errorReason(error), holder };
40
+ }
41
+ if (!after.isFile() || !content.equals(current)
42
+ || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
43
+ return { outcome: 'refused', reason: 'the lock changed while clearing', holder };
44
+ }
45
+ try {
46
+ await writer.unlink(ARTIFACT_ID, lockPath);
47
+ } catch (error) {
48
+ return { outcome: 'refused', reason: errorReason(error), holder };
49
+ }
50
+ try {
51
+ await fsp.lstat(lockPath);
52
+ return { outcome: 'refused', reason: 'the lock is still present after clearing', holder };
53
+ } catch (error) {
54
+ if (error?.code === 'ENOENT') return { outcome: 'cleared', holder };
55
+ return { outcome: 'refused', reason: errorReason(error), holder };
56
+ }
57
+ }
58
+
59
+ export async function clearLifecycleLock(homeRoot, {
60
+ platform = process.platform,
61
+ identity = {},
62
+ writer,
63
+ } = {}) {
64
+ const strategy = lifecycleLockStrategy(platform);
65
+ const lockPath = strategy === 'file' ? path.join(homeRoot, '.installed.json.lock') : undefined;
66
+ const base = { homeRoot, strategy, ...(lockPath === undefined ? {} : { lockPath }) };
67
+ const gatePath = lockPath === undefined ? undefined : `${lockPath}.clear`;
68
+ let handle;
69
+ let result;
70
+ try {
71
+ let home;
72
+ try {
73
+ home = await fsp.stat(homeRoot);
74
+ } catch (error) {
75
+ if (error?.code === 'ENOENT') return { ...base, outcome: 'no-home' };
76
+ throw error;
77
+ }
78
+ if (!home.isDirectory()) return { ...base, outcome: 'refused', reason: 'home is not a directory' };
79
+ if (strategy !== 'file') return { ...base, outcome: 'kernel-managed' };
80
+ writer ??= createHomeWriter({ root: homeRoot });
81
+ try {
82
+ handle = await writer.open(ARTIFACT_ID, gatePath, 'wx');
83
+ } catch (error) {
84
+ if (error?.code === 'EEXIST') return { ...base, outcome: 'gate-busy', gatePath };
85
+ throw error;
86
+ }
87
+ await handle.writeFile(`${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`);
88
+ result = { ...base, ...await clearHeldGate(lockPath, identity, writer) };
89
+ } catch (error) {
90
+ result = { ...base, outcome: 'refused', reason: errorReason(error) };
91
+ } finally {
92
+ if (handle) {
93
+ // The outcome already happened: a gate that cannot be closed must not turn a removed lock into
94
+ // "nothing was removed". If it also cannot be unlinked, gateLeft below names it.
95
+ await handle.close().catch(() => {});
96
+ try {
97
+ await writer.unlink(ARTIFACT_ID, gatePath);
98
+ } catch (error) {
99
+ if (error?.code !== 'ENOENT') result = { ...result, gateLeft: gatePath };
100
+ }
101
+ }
102
+ }
103
+ return result;
104
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Inspects a lifecycle lock without changing its home or lock file for unlock diagnosis (Plan_65 D11).
3
+ * A14: a silent holder cannot prove freedom; only a refused connection followed by an exclusive
4
+ * trial hold can, and a dead file-lock holder still owns the present file until explicit clearing.
5
+ */
6
+ import fsp from 'node:fs/promises';
7
+ import net from 'node:net';
8
+ import {
9
+ holderLiveness, lifecycleLockAddress, lifecycleLockStrategy, parseHolder, tryHoldSocket,
10
+ } from './lifecycle-lock.mjs';
11
+
12
+ const refusedCodes = new Set(['ECONNREFUSED', 'ENOENT']);
13
+ const errorReason = (error) => error?.code || error?.message || String(error);
14
+
15
+ function readSocketAnswer(address, timeoutMs, createConnection) {
16
+ return new Promise((resolve) => {
17
+ let socket;
18
+ let timer;
19
+ let data = '';
20
+ let settled = false;
21
+ const finish = (outcome) => {
22
+ if (settled) return;
23
+ settled = true;
24
+ clearTimeout(timer);
25
+ socket?.destroy();
26
+ resolve(outcome);
27
+ };
28
+ const failed = (error) => finish({
29
+ kind: refusedCodes.has(error?.code) ? 'refused' : 'error',
30
+ reason: errorReason(error),
31
+ });
32
+ const answered = (line) => {
33
+ const holder = parseHolder(line);
34
+ finish(holder ? { kind: 'answer', holder } : { kind: 'malformed', reason: 'malformed holder answer' });
35
+ };
36
+ try {
37
+ socket = createConnection(address);
38
+ timer = setTimeout(() => finish({ kind: 'timeout', reason: 'holder answer timed out' }), timeoutMs);
39
+ socket.on('data', (chunk) => {
40
+ data += chunk.toString('utf8');
41
+ const newline = data.indexOf('\n');
42
+ if (newline !== -1) answered(data.slice(0, newline));
43
+ });
44
+ socket.once('end', () => answered(data));
45
+ socket.once('error', failed);
46
+ socket.once('close', () => finish({ kind: 'error', reason: 'connection closed without a holder answer' }));
47
+ } catch (error) {
48
+ failed(error);
49
+ }
50
+ });
51
+ }
52
+
53
+ export async function inspectLifecycleLock(homeRoot, {
54
+ platform = process.platform,
55
+ createServer = net.createServer,
56
+ answerTimeoutMs = 1_000,
57
+ identity = {},
58
+ createConnection = net.createConnection,
59
+ } = {}) {
60
+ const observation = {
61
+ strategy: lifecycleLockStrategy(platform),
62
+ homeRoot,
63
+ observedAt: new Date().toISOString(),
64
+ };
65
+ const result = (value) => ({ ...observation, ...value });
66
+ try {
67
+ const target = lifecycleLockAddress(homeRoot, { platform });
68
+ if (target === null) return result({ state: 'no-home' });
69
+ if (target.strategy === 'file') {
70
+ let content;
71
+ try {
72
+ content = await fsp.readFile(target.address, 'utf8');
73
+ } catch (error) {
74
+ if (error?.code === 'ENOENT') return result({ state: 'free' });
75
+ return result({ state: 'unverified', reason: errorReason(error) });
76
+ }
77
+ const holder = parseHolder(content.split(/\r?\n/, 1)[0]);
78
+ if (!holder) return result({ state: 'unverified', reason: 'malformed holder file' });
79
+ return result({ state: 'held', holder, liveness: holderLiveness(holder, identity) });
80
+ }
81
+ const answer = await readSocketAnswer(target.address, answerTimeoutMs, createConnection);
82
+ if (answer.kind === 'answer') return result({ state: 'held', holder: answer.holder });
83
+ if (answer.kind !== 'refused') return result({ state: 'unverified', reason: answer.reason });
84
+ const release = await tryHoldSocket(target.address, createServer);
85
+ if (release === 'in-use') return result({ state: 'held', holder: null });
86
+ await release();
87
+ return result({ state: 'free' });
88
+ } catch (error) {
89
+ return result({ state: 'unverified', reason: errorReason(error) });
90
+ }
91
+ }
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Serializes install, update and uninstall of one shared package home, which several Claude Code
3
+ * hosts may use at once (Plan_65 D5).
4
+ *
5
+ * The shared file lock deletes a lock older than its window, which would steal from an install
6
+ * paused by sleep or an antivirus scan. Deleting a lock whose holder looks dead is no safer: two
7
+ * waiters both see holder X gone, A deletes X's lock and takes it, B deletes A's on its stale
8
+ * observation, and both write (advice A5). So nothing here ever deletes another holder's lock, and
9
+ * where the kernel can release a lock on the holder's death, the lock is a kernel object: a named
10
+ * pipe on Windows, an abstract socket on Linux — both reproduced on real machines (hard kill, name
11
+ * free at once, nothing on disk), which matters on a machine that lost power six times in two weeks.
12
+ * macOS has no abstract namespace, so it gets an exclusively created file that only its own holder
13
+ * removes, and a crash leaves it for `codex-bridge unlock --lifecycle` (D8).
14
+ *
15
+ * The name comes from the home directory's `dev`+`ino`, not its path: a junction, another letter
16
+ * case or forward slashes reach the same home and must reach the same lock. A second listen inside
17
+ * the holder must fail, or the platform did not give exclusivity and the call refuses rather than
18
+ * switch protocols. What the holder answers a waiter is diagnosis only — it never grants anything.
19
+ */
20
+ import { createHash, randomUUID } from 'node:crypto';
21
+ import fs from 'node:fs';
22
+ import fsp from 'node:fs/promises';
23
+ import net from 'node:net';
24
+ import path from 'node:path';
25
+ import { createHomeWriter } from '../src/home/lib/home-write.mjs';
26
+ import { parseJsonText } from '../src/home/lib/json-file.mjs';
27
+ import { processIdentity } from '../src/home/lib/process-identity.mjs';
28
+
29
+ const ARTIFACT_ID = 'install-record';
30
+ const LOCK_NAME = '.installed.json.lock';
31
+ const TAKEN_CODES = new Set(['EEXIST', 'EPERM', 'EBUSY']);
32
+ const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
33
+
34
+ function validateOptions(homeRoot, { command, hostRoot, waitMs, retryMs, answerTimeoutMs }) {
35
+ if (typeof homeRoot !== 'string' || homeRoot.length === 0) throw new TypeError('homeRoot must be a non-empty path');
36
+ if (typeof command !== 'string' || command.length === 0) throw new TypeError('command must be a non-empty string');
37
+ if (typeof hostRoot !== 'string' || hostRoot.length === 0) throw new TypeError('hostRoot must be a non-empty path');
38
+ for (const [name, value, allowZero] of [
39
+ ['waitMs', waitMs, true], ['retryMs', retryMs, false], ['answerTimeoutMs', answerTimeoutMs, true],
40
+ ]) {
41
+ if (!Number.isFinite(value) || value < (allowZero ? 0 : 1)) {
42
+ throw new TypeError(`${name} must be a ${allowZero ? 'non-negative' : 'positive'} number`);
43
+ }
44
+ }
45
+ }
46
+
47
+ function statIdentity(homeRoot) {
48
+ try {
49
+ const stat = fs.statSync(homeRoot, { bigint: true });
50
+ if (!stat.isDirectory()) throw new Error(`lifecycle lock home is not a directory: ${homeRoot}`);
51
+ return createHash('sha256').update(`${stat.dev}:${stat.ino}`).digest('hex');
52
+ } catch (error) {
53
+ if (error && error.code !== 'ENOENT') throw error;
54
+ return null;
55
+ }
56
+ }
57
+
58
+ export function lifecycleLockStrategy(platform = process.platform) {
59
+ if (platform === 'win32') return 'named-pipe';
60
+ return platform === 'linux' ? 'abstract-socket' : 'file';
61
+ }
62
+
63
+ function lockAddress(homeRoot, digest, platform) {
64
+ const strategy = lifecycleLockStrategy(platform);
65
+ if (strategy === 'named-pipe') return { strategy, address: `\\\\.\\pipe\\codex-bridge-lifecycle-${digest}` };
66
+ if (strategy === 'abstract-socket') return { strategy, address: `\0codex-bridge-lifecycle-${digest}` };
67
+ return { strategy, address: path.join(homeRoot, LOCK_NAME) };
68
+ }
69
+
70
+ export function lifecycleLockAddress(homeRoot, { platform = process.platform } = {}) {
71
+ const digest = statIdentity(homeRoot);
72
+ return digest === null ? null : lockAddress(homeRoot, digest, platform);
73
+ }
74
+
75
+ async function prepareHome(homeRoot) {
76
+ const writer = createHomeWriter({ root: homeRoot });
77
+ let digest = statIdentity(homeRoot);
78
+ if (digest === null) {
79
+ try {
80
+ await writer.mkdir(ARTIFACT_ID, homeRoot, { recursive: true });
81
+ } catch (error) {
82
+ throw new Error(`could not create lifecycle lock home through the home adapter: ${error.message}`, { cause: error });
83
+ }
84
+ digest = statIdentity(homeRoot);
85
+ if (digest === null) throw new Error(`home adapter did not create lifecycle lock home: ${homeRoot}`);
86
+ }
87
+ return { writer, digest };
88
+ }
89
+
90
+ function listen(server, address) {
91
+ return new Promise((resolve, reject) => {
92
+ const listening = () => { server.removeListener('error', failed); resolve(); };
93
+ const failed = (error) => { server.removeListener('listening', listening); reject(error); };
94
+ server.once('listening', listening);
95
+ server.once('error', failed);
96
+ try {
97
+ server.listen(address);
98
+ } catch (error) {
99
+ server.removeListener('listening', listening);
100
+ server.removeListener('error', failed);
101
+ reject(error);
102
+ }
103
+ });
104
+ }
105
+
106
+ function closeServer(server) {
107
+ if (!server) return Promise.resolve();
108
+ return new Promise((resolve, reject) => {
109
+ try {
110
+ server.close((error) => {
111
+ if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') reject(error);
112
+ else resolve();
113
+ });
114
+ } catch (error) {
115
+ if (error.code === 'ERR_SERVER_NOT_RUNNING') resolve();
116
+ else reject(error);
117
+ }
118
+ });
119
+ }
120
+
121
+ async function closeQuietly(server) {
122
+ try { await closeServer(server); } catch { /* Preserve the original failure. */ }
123
+ }
124
+
125
+ async function selfCheck(server, address, createServer) {
126
+ let probe;
127
+ let detail;
128
+ try {
129
+ probe = createServer();
130
+ await listen(probe, address);
131
+ detail = 'a second listener succeeded';
132
+ } catch (error) {
133
+ if (!error || error.code !== 'EADDRINUSE') detail = (error && (error.code || error.message)) || String(error);
134
+ }
135
+ if (detail === undefined) {
136
+ await closeQuietly(probe);
137
+ return;
138
+ }
139
+ await Promise.all([closeQuietly(probe), closeQuietly(server)]);
140
+ throw new Error(`lifecycle lock self-check failed: ${detail}`);
141
+ }
142
+
143
+ export async function tryHoldSocket(address, createServer = net.createServer) {
144
+ const server = createServer((socket) => socket.destroy());
145
+ try {
146
+ await listen(server, address);
147
+ await selfCheck(server, address, createServer);
148
+ let releasePromise;
149
+ return () => (releasePromise ??= closeServer(server));
150
+ } catch (error) {
151
+ await closeQuietly(server);
152
+ if (error?.code === 'EADDRINUSE') return 'in-use';
153
+ throw error;
154
+ }
155
+ }
156
+
157
+ function validHolder(value) {
158
+ return value && Number.isInteger(value.pid) && value.pid > 0
159
+ && typeof value.command === 'string' && value.command.length > 0
160
+ && typeof value.hostRoot === 'string' && value.hostRoot.length > 0
161
+ && typeof value.acquiredAt === 'string' && value.acquiredAt.length > 0
162
+ && typeof value.token === 'string' && value.token.length > 0;
163
+ }
164
+
165
+ export function parseHolder(line) {
166
+ try {
167
+ const holder = parseJsonText('<lifecycle lock holder>', line);
168
+ return validHolder(holder) ? holder : null;
169
+ } catch { return null; }
170
+ }
171
+
172
+ export function holderLiveness(holder, options = {}) {
173
+ // A14: acquisition is a run-style timestamp, not the holder process's exact start time.
174
+ return processIdentity({
175
+ pid: holder.pid,
176
+ started_at: holder.acquiredAt,
177
+ ignoreHeartbeat: true,
178
+ kill: options.kill,
179
+ probe: options.probe,
180
+ now: options.now,
181
+ });
182
+ }
183
+
184
+ function busyMessage(homeRoot, holder, hint = '') {
185
+ const details = validHolder(holder)
186
+ ? `held by ${holder.command} for ${holder.hostRoot}, pid ${holder.pid}, since ${holder.acquiredAt}`
187
+ : 'the holder did not answer (unverified)';
188
+ return new Error(`lifecycle lock busy for ${homeRoot}: ${details}; nothing was changed${hint}`);
189
+ }
190
+
191
+ function readSocketHolder(address, timeoutMs) {
192
+ return new Promise((resolve) => {
193
+ let data = '';
194
+ let settled = false;
195
+ const socket = net.createConnection(address);
196
+ const timer = setTimeout(() => finish(null), timeoutMs);
197
+ const finish = (holder) => {
198
+ if (settled) return;
199
+ settled = true;
200
+ clearTimeout(timer);
201
+ socket.destroy();
202
+ resolve(holder);
203
+ };
204
+ socket.on('data', (chunk) => {
205
+ data += chunk.toString('utf8');
206
+ const newline = data.indexOf('\n');
207
+ if (newline !== -1) finish(parseHolder(data.slice(0, newline)));
208
+ });
209
+ socket.once('end', () => finish(parseHolder(data)));
210
+ socket.once('error', () => finish(null));
211
+ });
212
+ }
213
+
214
+ function lockResult(token, strategy, releaseAction) {
215
+ let releasePromise;
216
+ return {
217
+ token,
218
+ strategy,
219
+ release() {
220
+ if (!releasePromise) releasePromise = releaseAction();
221
+ return releasePromise;
222
+ },
223
+ };
224
+ }
225
+
226
+ async function acquireSocketLock(homeRoot, holder, {
227
+ address, strategy, waitMs, retryMs, answerTimeoutMs, createServer,
228
+ }) {
229
+ const accepted = new Set();
230
+ const onConnection = (socket) => {
231
+ accepted.add(socket);
232
+ socket.once('close', () => accepted.delete(socket));
233
+ socket.end(`${JSON.stringify(holder)}\n`);
234
+ };
235
+ const deadline = Date.now() + waitMs;
236
+
237
+ for (;;) {
238
+ const server = createServer(onConnection);
239
+ try {
240
+ await listen(server, address);
241
+ holder.acquiredAt = new Date().toISOString();
242
+ await selfCheck(server, address, createServer);
243
+ server.unref();
244
+ return lockResult(holder.token, strategy, async () => {
245
+ const closing = closeServer(server);
246
+ for (const socket of accepted) socket.destroy();
247
+ await closing;
248
+ });
249
+ } catch (error) {
250
+ await closeQuietly(server);
251
+ if (!error || error.code !== 'EADDRINUSE') throw error;
252
+ const remaining = deadline - Date.now();
253
+ if (remaining <= 0) break;
254
+ await wait(Math.min(retryMs, remaining));
255
+ }
256
+ }
257
+
258
+ throw busyMessage(homeRoot, await readSocketHolder(address, answerTimeoutMs));
259
+ }
260
+
261
+ async function acquireFileLock(homeRoot, writer, holder, { waitMs, retryMs }) {
262
+ const lockPath = path.join(homeRoot, LOCK_NAME);
263
+ const deadline = Date.now() + waitMs;
264
+ let handle;
265
+ for (;;) {
266
+ try {
267
+ handle = await writer.open(ARTIFACT_ID, lockPath, 'wx');
268
+ break;
269
+ } catch (error) {
270
+ if (!error || !TAKEN_CODES.has(error.code)) throw error;
271
+ const remaining = deadline - Date.now();
272
+ if (remaining <= 0) {
273
+ let current = null;
274
+ try { current = parseHolder((await fsp.readFile(lockPath, 'utf8')).split(/\r?\n/, 1)[0]); } catch { /* Diagnosis only. */ }
275
+ throw busyMessage(
276
+ homeRoot,
277
+ current,
278
+ '. If no install, update or uninstall is running, the lock was left by a crash: codex-bridge unlock --lifecycle',
279
+ );
280
+ }
281
+ await wait(Math.min(retryMs, remaining));
282
+ }
283
+ }
284
+
285
+ holder.acquiredAt = new Date().toISOString();
286
+ try {
287
+ await handle.writeFile(`${JSON.stringify(holder)}\n`);
288
+ } catch (error) {
289
+ await handle.close().catch(() => {});
290
+ await writer.unlink(ARTIFACT_ID, lockPath).catch(() => {});
291
+ throw error;
292
+ }
293
+ return lockResult(holder.token, 'file', async () => {
294
+ let closeError;
295
+ try { await handle.close(); } catch (error) { closeError = error; }
296
+ try {
297
+ await writer.unlink(ARTIFACT_ID, lockPath);
298
+ } catch (error) {
299
+ if (!error || error.code !== 'ENOENT') throw error;
300
+ }
301
+ if (closeError) throw closeError;
302
+ });
303
+ }
304
+
305
+ export async function acquireLifecycleLock(homeRoot, {
306
+ command,
307
+ hostRoot,
308
+ waitMs = 15_000,
309
+ retryMs = 100,
310
+ platform = process.platform,
311
+ answerTimeoutMs = 1_000,
312
+ createServer = net.createServer,
313
+ } = {}) {
314
+ validateOptions(homeRoot, { command, hostRoot, waitMs, retryMs, answerTimeoutMs });
315
+ const { writer, digest } = await prepareHome(homeRoot);
316
+ const holder = {
317
+ pid: process.pid,
318
+ command,
319
+ hostRoot,
320
+ token: randomUUID(),
321
+ };
322
+
323
+ if (platform === 'win32' || platform === 'linux') {
324
+ const { address, strategy } = lockAddress(homeRoot, digest, platform);
325
+ return acquireSocketLock(homeRoot, holder, {
326
+ address,
327
+ strategy,
328
+ waitMs,
329
+ retryMs,
330
+ answerTimeoutMs,
331
+ createServer,
332
+ });
333
+ }
334
+ return acquireFileLock(homeRoot, writer, holder, { waitMs, retryMs });
335
+ }