@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,57 @@
1
+ /**
2
+ * Runs one install, update or uninstall of a shared package home as a single transaction under
3
+ * the home's lifecycle lock (Plan_65 D8).
4
+ *
5
+ * Two installs into one home used to interleave their image copies and their record writes, and
6
+ * uninstall removed permission rules from settings.json before it had read anything — so a
7
+ * refused uninstall would already have changed the host. The lock is therefore taken before the
8
+ * first read, outside the settings backup scope (backup bookkeeping is not exclusion). `update`
9
+ * nests `install`, and a second acquire inside one process is refused by design (the lock's own
10
+ * self-check), so the outer transaction hands the nested call a ticket instead: live only while
11
+ * the transaction runs, and bound to its home.
12
+ *
13
+ * Only install may bring a home into existence: the lock needs the directory, and an update or an
14
+ * uninstall that created an empty home folder just to lock it would leave debris behind a removal.
15
+ * A home that does not exist has nothing shared to protect, so those run without the lock.
16
+ */
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { acquireLifecycleLock } from './lifecycle-lock.mjs';
20
+
21
+ const liveTickets = new WeakMap();
22
+
23
+ /**
24
+ * Throws unless `ticket` belongs to a transaction still running on this host's home. The purge authorization
25
+ * (Plan_65 D14) binds itself to the ticket through this check, so the two can never disagree about "live".
26
+ */
27
+ export function assertLiveTicket(ticket, host) {
28
+ if (!ticket || typeof ticket !== 'object' || !liveTickets.has(ticket)) {
29
+ throw new Error('lifecycle ticket is no longer active');
30
+ }
31
+ if (typeof host?.brandRoot !== 'string' || path.resolve(host.brandRoot) !== liveTickets.get(ticket)) {
32
+ throw new Error('lifecycle ticket belongs to a different home');
33
+ }
34
+ }
35
+
36
+ export async function withLifecycle(host, command, action, { ticket, waitMs, createHome = false } = {}) {
37
+ if (typeof action !== 'function') throw new TypeError('lifecycle action must be a function');
38
+
39
+ if (ticket !== undefined) {
40
+ assertLiveTicket(ticket, host);
41
+ return action(ticket);
42
+ }
43
+
44
+ if (!createHome && !fs.existsSync(host.brandRoot)) return action(undefined);
45
+
46
+ const lockOptions = { command, hostRoot: host.root };
47
+ if (waitMs !== undefined) lockOptions.waitMs = waitMs;
48
+ const lock = await acquireLifecycleLock(host.brandRoot, lockOptions);
49
+ const transactionTicket = Object.freeze({});
50
+ liveTickets.set(transactionTicket, path.resolve(host.brandRoot));
51
+ try {
52
+ return await action(transactionTicket);
53
+ } finally {
54
+ liveTickets.delete(transactionTicket);
55
+ await lock.release();
56
+ }
57
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Says whether a delete target is reached only through real directories below a trusted root.
3
+ *
4
+ * Lexical containment alone is satisfiable: independent review replaced `<root>/<project>` with a
5
+ * junction to an outside directory, and a target that still spelled out as inside the root deleted
6
+ * what was outside it. Paths are deliberately never resolved (`realpath` returns `\\?\` and UNC
7
+ * forms on Windows and creates a new class of mismatch), so every segment is checked with lstat,
8
+ * top-down, right before deletion. Plan_65 D4 made this one module for prune and the coming purge
9
+ * after prune's own copy was found wrong twice: it walked from the leaf up, touching paths under a
10
+ * link before reaching the link, and it read every lstat error as a clear path. Only ENOENT means
11
+ * gone here; any other error is a finding, and the caller keeps the entry. The root itself is taken
12
+ * as written, because relocating the runs root or the home through a link is legitimate.
13
+ * Containment compares through normalizePath, which folds Windows case: a root spelled `C:\Users`
14
+ * against a target resolved as `c:\users` must not read as "outside".
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { normalizePath } from '../src/home/hooks/live-runs.mjs';
19
+
20
+ export function inspectSegments(target, root, { lstat = (file) => fs.lstatSync(file) } = {}) {
21
+ const normalizedRoot = normalizePath(root);
22
+ const normalizedTarget = normalizePath(target);
23
+ if (!normalizedRoot || !normalizedTarget) return { kind: 'outside' };
24
+
25
+ const rootPrefix = normalizedRoot.endsWith('/') ? normalizedRoot : `${normalizedRoot}/`;
26
+ if (normalizedTarget === normalizedRoot || !normalizedTarget.startsWith(rootPrefix)) {
27
+ return { kind: 'outside' };
28
+ }
29
+
30
+ const rootPath = path.resolve(root);
31
+ const relativeTarget = path.relative(rootPath, path.resolve(target));
32
+ let current = rootPath;
33
+ for (const segment of relativeTarget.split(path.sep).filter(Boolean)) {
34
+ current = path.join(current, segment);
35
+ let entry;
36
+ try {
37
+ entry = lstat(current);
38
+ } catch (error) {
39
+ if (error.code === 'ENOENT') return { kind: 'missing', at: current };
40
+ return { kind: 'error', at: current, code: error.code };
41
+ }
42
+ if (entry.isSymbolicLink()) return { kind: 'link', at: current };
43
+ }
44
+ return { kind: 'clear' };
45
+ }
package/cli/model-set.mjs CHANGED
@@ -5,6 +5,7 @@ import { ROLES, validateRunConfig } from '../src/home/lib/config-validate.mjs';
5
5
  import { readJsonFileSync } from '../src/home/lib/json-file.mjs';
6
6
  import { editRunConfig } from '../src/home/lib/config-edit.mjs';
7
7
  import { fetchCatalogue, parseCatalogue } from './model-catalogue.mjs';
8
+ import { withCommandHelp } from './command-help.mjs';
8
9
 
9
10
  const failure = (exitCode, message) => ({ exitCode, output: `codex-bridge model: ${message}` });
10
11
  const refusal = (message) => Object.assign(new Error(message), { exitCode: 2 });
@@ -17,14 +18,14 @@ function parseChanges(args) {
17
18
  let value = argument;
18
19
  if (argument.startsWith('-')) {
19
20
  if (argument !== '--model' && argument !== '--effort') {
20
- throw new Error(`unexpected argument "${argument}".`);
21
+ throw new Error(withCommandHelp('model set', `unexpected argument "${argument}".`));
21
22
  }
22
23
  field = argument.slice(2);
23
24
  value = args[++index];
24
25
  if (value === undefined || value.startsWith('-')) throw new Error(`missing value for ${argument}.`);
25
26
  } else {
26
27
  field = ['model', 'effort'].find((key) => changes[key] === undefined);
27
- if (!field) throw new Error(`unexpected argument "${argument}".`);
28
+ if (!field) throw new Error(withCommandHelp('model set', `unexpected argument "${argument}".`));
28
29
  }
29
30
  if (changes[field] !== undefined) throw new Error(`${field} was supplied more than once.`);
30
31
  if (!value || /\s/.test(value)) {
@@ -65,7 +66,7 @@ export async function editModelProfile(action, argv, options) {
65
66
  }
66
67
  let changes;
67
68
  try {
68
- if (action === 'unset' && args.length) throw new Error(`unexpected argument "${args[0]}".`);
69
+ if (action === 'unset' && args.length) throw new Error(withCommandHelp('model unset', `unexpected argument "${args[0]}".`));
69
70
  if (action === 'set') changes = parseChanges(args);
70
71
  } catch (error) {
71
72
  return failure(2, error.message);
@@ -5,12 +5,19 @@ import { ROLES, validateRunConfig } from '../src/home/lib/config-validate.mjs';
5
5
  import { editRunConfig } from '../src/home/lib/config-edit.mjs';
6
6
  import { fetchCatalogue, parseCatalogue } from './model-catalogue.mjs';
7
7
  import { describe, readProfiles } from './model-set.mjs';
8
+ import { withCommandHelp } from './command-help.mjs';
8
9
 
9
10
  const failure = (exitCode, message) => ({ exitCode, output: `codex-bridge model: ${message}` });
10
11
  const refusal = (message) => Object.assign(new Error(message), { exitCode: 2 });
12
+ const unexpected = (argument) => {
13
+ const result = failure(2, `unexpected argument "${argument}".`);
14
+ return { ...result, output: withCommandHelp('model speed', result.output) };
15
+ };
11
16
 
12
17
  export async function editModelSpeed(argv, options) {
13
18
  const [role, speed, confirmation, ...extra] = argv;
19
+ const dashArgument = argv.find((arg) => typeof arg === 'string' && arg.startsWith('-'));
20
+ if (dashArgument !== undefined) return unexpected(dashArgument);
14
21
  if (!ROLES.includes(role)) {
15
22
  return failure(2, `${role ? `unknown role "${role}"` : 'role is required'}. Allowed roles: ${ROLES.join(', ')}.`);
16
23
  }
@@ -18,9 +25,9 @@ export async function editModelSpeed(argv, options) {
18
25
  return failure(2, 'speed requires a tier identifier: a non-empty single word with no whitespace, or unset.');
19
26
  }
20
27
  if (confirmation !== undefined && (speed === 'unset' || confirmation !== 'confirm')) {
21
- return failure(2, `unexpected argument "${confirmation}".`);
28
+ return unexpected(confirmation);
22
29
  }
23
- if (extra.length) return failure(2, `unexpected argument "${extra[0]}".`);
30
+ if (extra.length) return unexpected(extra[0]);
24
31
 
25
32
  try {
26
33
  const configPath = path.resolve(options.configPath ?? CONFIG_PATH);
package/cli/model.mjs CHANGED
@@ -8,6 +8,7 @@ import { editModelProfile } from './model-set.mjs';
8
8
  import { editModelSpeed } from './model-speed.mjs';
9
9
  import { readCodexUserTier } from './codex-user-config.mjs';
10
10
  import { renderTable } from './table.mjs';
11
+ import { withCommandHelp } from './command-help.mjs';
11
12
 
12
13
  const PROFILE_COLUMNS = ['role', 'model', 'effort', 'speed', 'source']
13
14
  .map((key) => ({ key, header: key, fixed: true }));
@@ -43,10 +44,10 @@ export async function model(argv = [], options = {}) {
43
44
  }
44
45
  if (action === 'speed') return editModelSpeed(optionArgs, options);
45
46
  if (action && action !== 'list') {
46
- return { exitCode: 2, output: `codex-bridge model: unknown action "${action}". Use model, model list, model set, model unset or model speed.` };
47
+ return { exitCode: 2, output: withCommandHelp('model', `codex-bridge model: unknown action "${action}". Use model, model list, model set, model unset or model speed.`) };
47
48
  }
48
49
  if (optionArgs.length) {
49
- return { exitCode: 2, output: `codex-bridge model: unexpected argument "${optionArgs[0]}".` };
50
+ return { exitCode: 2, output: withCommandHelp('model', `codex-bridge model: unexpected argument "${optionArgs[0]}".`) };
50
51
  }
51
52
 
52
53
  try {
@@ -75,7 +76,8 @@ export async function model(argv = [], options = {}) {
75
76
  return {
76
77
  exitCode: 0,
77
78
  output: `${renderTable(PROFILE_COLUMNS, rows, options.terminalWidth)}\n\nConfig file: ${configPath}\n`
78
- + 'Machine-wide: shared by every project on this machine, not per-project.',
79
+ + 'Machine-wide: shared by every project on this machine, not per-project.\n'
80
+ + 'Set: codex-bridge model set <role> <model> [effort] · catalogue: codex-bridge model list',
79
81
  };
80
82
  } catch (error) {
81
83
  const subject = action === 'list' ? 'live catalogue unavailable; refusing to list models' : 'cannot read profile';
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Builds one owner's installation-record row from what is on disk after its files and hooks were written.
3
+ * Install and the owner sync (Plan_65 D7) both advance a row; two copies of this construction would let
4
+ * a synced owner carry fingerprints or `createdGroup` flags that its own install would never write.
5
+ */
6
+ import { fileFingerprint } from './manifest.mjs';
7
+ import { INSTALL_METHOD_COPY, INSTALL_METHOD_KEY } from './install-record.mjs';
8
+
9
+ export async function ownerRecord({ plan, rule, targets, hookResults, prior, currentPackage }) {
10
+ const fingerprints = {};
11
+ for (const item of plan) {
12
+ fingerprints[item.root] ??= {};
13
+ fingerprints[item.root][item.relativeToRoot] = await fileFingerprint(item.target);
14
+ }
15
+ const hooks = targets.map(({ definition, relative, registration }, index) => {
16
+ const recorded = prior?.hooks?.find((hook) => hook.event === definition.event
17
+ && hook.root === 'brand' && hook.path === relative);
18
+ // A group we created stays ours after a later run merely joins it; losing the flag would make
19
+ // uninstall leave behind an empty group that only this package ever put there.
20
+ const createdGroup = hookResults[index].createdGroup || recorded?.createdGroup === true;
21
+ const command = registration.command;
22
+ return {
23
+ event: definition.event,
24
+ root: 'brand',
25
+ path: relative,
26
+ command,
27
+ form: command.startsWith('codex-bridge hook ') ? 'short' : 'path',
28
+ ...(createdGroup ? { createdGroup: true } : {}),
29
+ };
30
+ });
31
+ return {
32
+ ...currentPackage,
33
+ installedAt: new Date().toISOString(),
34
+ [INSTALL_METHOD_KEY]: INSTALL_METHOD_COPY,
35
+ files: plan.map((item) => ({ root: item.root, path: item.relativeToRoot })),
36
+ fingerprints,
37
+ rules: { path: rule.target, fingerprint: await fileFingerprint(rule.target) },
38
+ hooks,
39
+ };
40
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Applies other owners' host updates under the initiator's lifecycle lock (Plan_65 D7, advice A6).
3
+ * Replacing the shared image must bring eligible owners' files and hooks along, without extending
4
+ * the initiator's force to their edits, permissions, seeds, or Codex rules registry. An owner row
5
+ * advances only after its whole host update verifies; failures must not stop the remaining owners.
6
+ * In-sync rows are stamped with the image fingerprint too (D11): clones of one version ship different
7
+ * images (A6), so only the stamp tells doctor which image a host was last verified against.
8
+ */
9
+ import fs from 'node:fs/promises';
10
+ import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
11
+ import { buildInstallPlan, packageInfo, rulesPlan } from './manifest.mjs';
12
+ import { copyPlannedFile, planHomeWriter } from './copy.mjs';
13
+ import { readInstallRecordFile, writeInstallRecord } from './install-record.mjs';
14
+ import { imageFingerprint, ownerView } from './install-owners.mjs';
15
+ import { ownerRecord } from './owner-record.mjs';
16
+ import { ownerHost, ownerSyncLines, planOwner, planOwnerSync } from './owner-sync.mjs';
17
+ import { mergeHook } from './settings-merge.mjs';
18
+
19
+ async function applyOwner(owner, initiator, { packageRoot, env }) {
20
+ const host = ownerHost(owner, initiator);
21
+ // D7: a root lost after planning must not be recreated by the copy or settings writers.
22
+ if (!(await fs.stat(host.root)).isDirectory()) throw new Error('owner root is not a directory');
23
+ const record = await readInstallRecordFile(host);
24
+ const prior = ownerView(record, host);
25
+ if (!prior) throw new Error('owner is not recorded');
26
+ const plan = await buildInstallPlan(host, packageRoot);
27
+ const { writer } = planHomeWriter(host.brandRoot, plan);
28
+ let files = 0;
29
+ for (const { item, state } of owner.files) {
30
+ if (state !== 'replace' && state !== 'create') continue;
31
+ await copyPlannedFile(item, host.brandRoot, { writer });
32
+ files += 1;
33
+ }
34
+ const hookResults = [];
35
+ let hooks = 0;
36
+ for (const { item, state } of owner.hooks) {
37
+ if (state === 'register') {
38
+ hookResults.push(await mergeHook(host.settingsPath, item.spec));
39
+ hooks += 1;
40
+ } else {
41
+ hookResults.push({ createdGroup: false });
42
+ }
43
+ }
44
+ const verified = await planOwner(record, record.owners[normalizeRepoPath(host.root)], initiator,
45
+ { packageRoot, env });
46
+ // A bare status word made the operator line read "Left <root> behind: conflict", naming no cause.
47
+ if (verified.status !== 'in-sync') throw new Error(`verification after the update found ${verified.status}`);
48
+ // D7: resolving an explicit root must not relabel a recorded user/project owner as host.
49
+ await writeInstallRecord({ ...host, scope: owner.scope }, await ownerRecord({
50
+ plan,
51
+ rule: rulesPlan(host, packageRoot),
52
+ targets: owner.hooks.map(({ item }) => item),
53
+ hookResults,
54
+ prior,
55
+ currentPackage: await packageInfo(packageRoot),
56
+ }), {});
57
+ return { root: owner.root, status: 'updated', files, hooks };
58
+ }
59
+
60
+ export async function applyOwnerSync(plan, initiator, { packageRoot, env } = {}) {
61
+ const record = await readInstallRecordFile(initiator);
62
+ const fingerprint = imageFingerprint(record.image);
63
+ const owners = [];
64
+ for (const owner of plan.owners) {
65
+ if (owner.status === 'in-sync'
66
+ // An image without fingerprints yields null; a row can then never carry a stamp to match.
67
+ && (record.owners[normalizeRepoPath(owner.root)]?.imageFingerprint ?? null) === fingerprint) {
68
+ owners.push({ root: owner.root, status: 'in-sync' });
69
+ } else if (owner.status !== 'eligible' && owner.status !== 'in-sync') {
70
+ owners.push(owner);
71
+ } else {
72
+ try {
73
+ const applied = await applyOwner(owner, initiator, { packageRoot, env });
74
+ owners.push(owner.status === 'in-sync' && applied.files === 0 && applied.hooks === 0
75
+ ? { root: owner.root, status: 'in-sync' } : applied);
76
+ } catch (error) {
77
+ owners.push({ root: owner.root, status: 'failed', reason: error.message });
78
+ }
79
+ }
80
+ }
81
+ return { owners, complete: owners.every(({ status }) => status === 'in-sync' || status === 'updated') };
82
+ }
83
+
84
+ /**
85
+ * One entrance for every command that replaced the image: install and update both call it, so neither
86
+ * can sync the other owners differently. A dry run only plans; it never counts as incomplete.
87
+ */
88
+ export async function syncOtherOwners(initiator, { packageRoot, env, dryRun = false } = {}) {
89
+ const record = await readInstallRecordFile(initiator);
90
+ if (record?.format !== 2) return { complete: true, lines: [] };
91
+ const plan = await planOwnerSync(record, initiator, { packageRoot, env });
92
+ if (dryRun) return { complete: true, lines: ownerSyncLines(plan) };
93
+ // Plan_65 D7: the initiator's force never authorizes another owner's edits.
94
+ const result = await applyOwnerSync(plan, initiator, { packageRoot, env });
95
+ return { complete: result.complete, lines: ownerApplyLines(result) };
96
+ }
97
+
98
+ export function ownerApplyLines(result) {
99
+ return result.owners.flatMap((owner) => {
100
+ if (owner.status === 'updated') {
101
+ return [`Updated ${owner.files} file(s) and ${owner.hooks} hook(s) of ${owner.root}.`];
102
+ }
103
+ if (owner.status === 'failed') {
104
+ return [`Left ${owner.root} behind: ${owner.reason}; run codex-bridge update --host "${owner.root}".`];
105
+ }
106
+ return ownerSyncLines({ owners: [owner] });
107
+ });
108
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Plans other owners' host updates without writes or locks (Plan_65 D7, advice A6).
3
+ * Replacing the shared image leaves their agents, commands, and guards behind. Only an owner's
4
+ * recorded, unedited files may catch up; the initiator's force never authorizes another owner's
5
+ * conflicts, permissions, seeds, or unrelated settings. B13e2 applies and verifies this plan.
6
+ */
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
10
+ import { resolveHost } from './hosts.mjs';
11
+ import { buildInstallPlan, fileFingerprint } from './manifest.mjs';
12
+ import { targetMatches } from './copy.mjs';
13
+ import { fingerprintFor, recordFileKey } from './install-record.mjs';
14
+ import { ownerView, validateFormat2 } from './install-owners.mjs';
15
+ import { hookTargets } from './hook-targets.mjs';
16
+ import { inspectHook } from './settings-merge.mjs';
17
+
18
+ async function fileState(item, host, view) {
19
+ let target;
20
+ try {
21
+ target = await fs.lstat(item.target);
22
+ } catch (error) {
23
+ if (error.code !== 'ENOENT') throw error;
24
+ }
25
+ // D7: lstat must precede content reads, including for dangling links and Windows junctions.
26
+ if (target?.isSymbolicLink()) return { item, state: 'conflict', reason: 'link' };
27
+ if (!target) {
28
+ const recorded = view.files.some((file) => recordFileKey(file) === recordFileKey(item));
29
+ return recorded
30
+ ? { item, state: 'conflict', reason: 'missing' }
31
+ : { item, state: 'create' };
32
+ }
33
+ if (target.isFile()) {
34
+ if (await targetMatches(item, host.brandRoot)) return { item, state: 'current' };
35
+ const recorded = fingerprintFor(view, item);
36
+ if (recorded !== undefined && await fileFingerprint(item.target) === recorded) {
37
+ return { item, state: 'replace' };
38
+ }
39
+ }
40
+ return { item, state: 'conflict', reason: 'changed' };
41
+ }
42
+
43
+ /** The owner's host seen from the initiator: same home and Codex home, the owner's own root and scope. */
44
+ export function ownerHost(owner, initiator) {
45
+ return resolveHost({
46
+ host: owner.root,
47
+ scope: owner.scope,
48
+ brandRoot: initiator.brandRoot,
49
+ // A resolved host has no codexHome field; it keeps the Codex home only as `<home>/rules`.
50
+ codexHome: path.dirname(initiator.codexRulesDir),
51
+ });
52
+ }
53
+
54
+ export async function planOwner(record2, owner, initiator, { packageRoot, env }) {
55
+ const entry = { root: owner.root, scope: owner.scope, version: owner.version, files: [], hooks: [] };
56
+ const host = ownerHost(owner, initiator);
57
+ try {
58
+ if (!(await fs.stat(host.root)).isDirectory()) {
59
+ return { ...entry, status: 'unreachable', reason: 'ENOTDIR' };
60
+ }
61
+ await fs.readdir(host.root);
62
+ } catch (error) {
63
+ return { ...entry, status: 'unreachable', reason: error.code === 'ENOENT' ? 'absent' : error.code };
64
+ }
65
+
66
+ const view = ownerView(record2, host);
67
+ const items = (await buildInstallPlan(host, packageRoot)).filter((item) => item.root === 'claude');
68
+ try {
69
+ for (const item of items) entry.files.push(await fileState(item, host, view));
70
+ } catch (error) {
71
+ return { ...entry, status: 'unreachable', reason: error.code ?? error.message };
72
+ }
73
+ const targets = hookTargets(host, env);
74
+ try {
75
+ for (const item of targets) {
76
+ const inspected = await inspectHook(host.settingsPath, item.spec);
77
+ entry.hooks.push({ item, state: inspected.current ? 'current' : 'register' });
78
+ }
79
+ } catch (error) {
80
+ return { ...entry, status: 'unreachable', reason: `settings unreadable: ${error.message}` };
81
+ }
82
+ const conflict = entry.files.some(({ state }) => state === 'conflict');
83
+ const pending = entry.files.some(({ state }) => state === 'replace' || state === 'create')
84
+ || entry.hooks.some(({ state }) => state === 'register');
85
+ return { ...entry, status: conflict ? 'conflict' : pending ? 'eligible' : 'in-sync' };
86
+ }
87
+
88
+ export async function planOwnerSync(record2, initiator, { packageRoot, env } = {}) {
89
+ validateFormat2(record2);
90
+ const key = normalizeRepoPath(initiator.root);
91
+ const others = Object.values(record2.owners)
92
+ .filter((owner) => normalizeRepoPath(owner.root) !== key)
93
+ .sort((a, b) => a.root.localeCompare(b.root));
94
+ const owners = [];
95
+ for (const owner of others) owners.push(await planOwner(record2, owner, initiator, { packageRoot, env }));
96
+ return { owners };
97
+ }
98
+
99
+ export function ownerSyncLines(plan) {
100
+ const lines = [];
101
+ for (const owner of plan.owners) {
102
+ if (owner.status === 'eligible') {
103
+ const files = owner.files.filter(({ state }) => state === 'replace' || state === 'create').length;
104
+ const hooks = owner.hooks.filter(({ state }) => state === 'register').length;
105
+ lines.push(`Would update ${files} file(s) and ${hooks} hook(s) of ${owner.root}.`);
106
+ } else if (owner.status === 'conflict') {
107
+ const conflicts = owner.files.filter(({ state }) => state === 'conflict')
108
+ .map(({ item, reason }) => `${item.relativeToRoot} (${reason})`).join(', ');
109
+ lines.push(`Left ${owner.root} behind: ${conflicts}; run codex-bridge update --host "${owner.root}" after resolving it.`);
110
+ } else if (owner.status === 'unreachable') {
111
+ lines.push(`Left ${owner.root} behind: ${owner.reason}; run codex-bridge update --host "${owner.root}" when it is available.`);
112
+ }
113
+ }
114
+ return lines;
115
+ }
@@ -7,13 +7,14 @@ import { spawnSync } from 'node:child_process';
7
7
  import fs from 'node:fs/promises';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
- import { detectHostVersion, writeHostContract } from './host-contract.mjs';
10
+ import { writeHostContract } from './host-contract.mjs';
11
11
  import { buildRig } from './probe-rig.mjs';
12
12
  import { DISPATCHER_CONTRACTS, judgeDispatcherContracts } from './dispatcher-contract.mjs';
13
13
  import { writeDispatcherContract } from './dispatcher-contract-record.mjs';
14
14
  import { brandStateDir } from '../src/home/lib/brand-home.mjs';
15
15
  import { parseJsonText } from '../src/home/lib/json-file.mjs';
16
16
  import { randomUUID } from 'node:crypto';
17
+ import { transcriptHostVersion } from '../src/home/lib/host-version.mjs';
17
18
 
18
19
  export const PROBE_MARKER = 'codex-bridge-contract-probe';
19
20
 
@@ -60,17 +61,18 @@ export function judgeProbe({ markerExists, hookFired, hostResult }) {
60
61
 
61
62
  export async function probeContract({
62
63
  host,
63
- version = detectHostVersion(),
64
+ target,
64
65
  runHost = spawnSync,
65
66
  rigRoot = os.tmpdir(),
66
67
  now = new Date(),
67
68
  }) {
68
- if (version == null) {
69
+ if (target?.error || !target?.version || !target?.executable) {
70
+ const message = target?.error ?? 'The probe target is unavailable.';
69
71
  return {
70
72
  state: 'inconclusive',
71
73
  result: null,
72
74
  version: null,
73
- message: 'The host version could not be read, so no contract probe was run.',
75
+ message,
74
76
  recorded: false,
75
77
  dispatcher: Object.fromEntries(DISPATCHER_CONTRACTS.map((name) => [name, { result: 'inconclusive', detail: 'Host version is unavailable.' }])),
76
78
  };
@@ -83,7 +85,7 @@ export async function probeContract({
83
85
  const rig = await buildRig(dir, token);
84
86
  let hostResult;
85
87
  try {
86
- hostResult = runHost('claude', [
88
+ hostResult = runHost(target.executable, [
87
89
  '--setting-sources', 'project',
88
90
  '--allowedTools', 'Bash,Agent',
89
91
  '-p', rig.prompt,
@@ -106,6 +108,20 @@ export async function probeContract({
106
108
  } catch (error) {
107
109
  if (error.code !== 'ENOENT') malformed = true;
108
110
  }
111
+ // Only a host that completed the run can be asked who it was; a spawn failure or timeout keeps its own
112
+ // reason below instead of reading as "ran on host unknown".
113
+ const hostCompleted = !malformed && !hostResult?.error && !hostResult?.signal && hostResult?.status === 0;
114
+ let seen = null;
115
+ const transcriptPath = entries.find((entry) => typeof entry?.payload?.transcript_path === 'string')?.payload.transcript_path;
116
+ if (transcriptPath) seen = await transcriptHostVersion(transcriptPath);
117
+ if (hostCompleted && seen !== target.version) {
118
+ return {
119
+ state: 'inconclusive', result: null, version: target.version,
120
+ message: `The probe ran on host ${seen ?? 'unknown'}, not on ${target.version}; nothing was recorded.`,
121
+ recorded: false,
122
+ dispatcher: Object.fromEntries(DISPATCHER_CONTRACTS.map((name) => [name, { result: 'inconclusive', detail: 'Probe host version did not match target.' }])),
123
+ };
124
+ }
109
125
  const verdict = judgeProbe({
110
126
  markerExists: await exists(rig.markerPath),
111
127
  hookFired: await exists(rig.journalPath),
@@ -118,25 +134,25 @@ export async function probeContract({
118
134
  failOutput: rig.failOutput, agentType: 'probe-agent', promptToken: token,
119
135
  });
120
136
  if (verdict.result == null) dispatcher = Object.fromEntries(Object.keys(dispatcher).map((name) => [name, { result: 'inconclusive', detail: 'Refusal probe did not complete.' }]));
121
- if (verdict.result != null && version != null && !malformed && !hostResult?.error && !hostResult?.signal && hostResult?.status === 0) {
122
- await writeDispatcherContract({ stateDir: brandStateDir(host.brandRoot), version, verdicts: dispatcher, now });
137
+ if (verdict.result != null && !malformed && !hostResult?.error && !hostResult?.signal && hostResult?.status === 0) {
138
+ await writeDispatcherContract({ stateDir: brandStateDir(host.brandRoot), version: target.version, verdicts: dispatcher, now });
123
139
  }
124
140
  if (verdict.result == null) {
125
141
  return {
126
142
  state: 'inconclusive',
127
143
  result: null,
128
- version,
144
+ version: target.version,
129
145
  message: verdict.reason,
130
146
  recorded: false,
131
147
  dispatcher: Object.fromEntries(Object.entries(dispatcher).map(([name, item]) => [name, { result: 'inconclusive', detail: 'Host did not complete the probe.' }])),
132
148
  };
133
149
  }
134
150
 
135
- await writeHostContract(host, { version, result: verdict.result, now });
151
+ await writeHostContract(host, { version: target.version, result: verdict.result, now });
136
152
  return {
137
153
  state: 'probed',
138
154
  result: verdict.result,
139
- version,
155
+ version: target.version,
140
156
  message: verdict.reason,
141
157
  recorded: true,
142
158
  dispatcher,
@@ -0,0 +1,33 @@
1
+ /** Resolves the session host after the 2026-09-25 PATH host identity incident. */
2
+ import { spawnSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import { detectHostVersion } from './host-contract.mjs';
5
+ import { readHostObservations } from '../src/home/lib/host-observations.mjs';
6
+
7
+ export function resolveProbeTarget({ stateDir, executable, env = process.env, run = spawnSync, exists = fs.existsSync }) {
8
+ if (executable) {
9
+ const version = detectHostVersion({ run, executable });
10
+ return version === null
11
+ ? { error: `Could not read the version of ${executable}.` }
12
+ : { executable, version, source: 'flag' };
13
+ }
14
+
15
+ const observations = readHostObservations({ stateDir });
16
+ const newest = Object.entries(observations.hosts)
17
+ .filter(([, entry]) => Number.isFinite(Date.parse(entry?.lastSeen)))
18
+ .sort((a, b) => Date.parse(b[1].lastSeen) - Date.parse(a[1].lastSeen))[0];
19
+ if (!newest) {
20
+ return { error: 'No Claude Code session has been observed by this installation yet; run any shell command in a session first, or pass --probe-executable <path>.' };
21
+ }
22
+ const [version] = newest;
23
+ const candidates = [];
24
+ if (env.CLAUDE_CODE_EXECPATH && exists(env.CLAUDE_CODE_EXECPATH)) candidates.push(env.CLAUDE_CODE_EXECPATH);
25
+ candidates.push('claude');
26
+ const checked = [];
27
+ for (const candidate of candidates) {
28
+ const candidateVersion = detectHostVersion({ run, executable: candidate });
29
+ checked.push(`${candidate} (${candidateVersion ?? 'unknown'})`);
30
+ if (candidateVersion === version) return { executable: candidate, version, source: 'observed' };
31
+ }
32
+ return { error: `No executable of the observed host ${version} was found (checked: ${checked.join(', ')}); pass --probe-executable <path>.` };
33
+ }
package/cli/projects.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  /** Implements the projects command. */
2
+ import { withCommandHelp } from './command-help.mjs';
2
3
  import { listProjectRuns, listProjects } from './runs-inventory.mjs';
3
4
  import { renderTable } from './table.mjs';
4
5
  import { allLiveRuns } from '../src/home/hooks/live-runs.mjs';
@@ -69,11 +70,13 @@ function parseArgs(args) {
69
70
  json = true;
70
71
  continue;
71
72
  }
72
- if (arg.startsWith('-')) return { error: `codex-bridge projects: unknown option "${arg}"` };
73
+ if (arg.startsWith('-')) {
74
+ return { error: withCommandHelp('projects', `codex-bridge projects: unknown option "${arg}"`) };
75
+ }
73
76
  positional.push(arg);
74
77
  }
75
78
  if (positional.length > 1) {
76
- return { error: 'codex-bridge projects accepts at most one project name.' };
79
+ return { error: withCommandHelp('projects', 'codex-bridge projects accepts at most one project name.') };
77
80
  }
78
81
  return { json, projectName: positional[0] || null };
79
82
  }