@lyupro/codex-bridge 0.5.5 → 0.6.1

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.
package/cli/model.mjs ADDED
@@ -0,0 +1,84 @@
1
+ /** Shows and edits machine-wide delegated profiles using the live catalogue (Plan_56). */
2
+ import path from 'node:path';
3
+ import { AGENTS } from '../src/home/lib/agents.mjs';
4
+ import { CONFIG_PATH, readRunConfig } from '../src/home/lib/run-config.mjs';
5
+ import { runProfile } from '../src/home/lib/runner/codex-args.mjs';
6
+ import { fetchCatalogue, parseCatalogue } from './model-catalogue.mjs';
7
+ import { editModelProfile } from './model-set.mjs';
8
+ import { editModelSpeed } from './model-speed.mjs';
9
+ import { readCodexUserTier } from './codex-user-config.mjs';
10
+ import { renderTable } from './table.mjs';
11
+
12
+ const PROFILE_COLUMNS = ['role', 'model', 'effort', 'speed', 'source']
13
+ .map((key) => ({ key, header: key, fixed: true }));
14
+ const CATALOGUE_COLUMNS = ['slug', 'reasoning levels', 'default level', 'fast tier', 'visibility']
15
+ .map((key) => ({ key, header: key, fixed: true }));
16
+
17
+ // An unpinned model is not a default this package supplies — Codex picks one, and saying
18
+ // "built-in default" would credit the package with a choice it never made. The three cases stay
19
+ // three, because provenance is half of what this command exists to answer.
20
+ const MODEL_SOURCE = { config: 'config file' };
21
+ const EFFORT_SOURCE = { config: 'config file', request: 'this run' };
22
+
23
+ function profileSource(profile, role, userTier) {
24
+ const model = MODEL_SOURCE[profile.model_source] ?? 'not set (Codex chooses)';
25
+ const effort = EFFORT_SOURCE[profile.effort_source] ?? 'package default';
26
+ const source = model === effort ? model : `model: ${model}; effort: ${effort}`;
27
+ const speed = profile.speed_source === 'config' ? 'config file'
28
+ : role === 'build' && userTier ? `operator Codex config (${userTier})` : 'not pinned';
29
+ const ignored = role === 'build' ? '' : ' (operator Codex config not read)';
30
+ return `${source}; speed: ${speed}${ignored}`;
31
+ }
32
+
33
+ /** Returns output for the dispatcher; explicit edit actions use the shared config writer. */
34
+ export async function model(argv = [], options = {}) {
35
+ let action;
36
+ let optionArgs = argv;
37
+ if (argv[0] && !argv[0].startsWith('-')) {
38
+ action = argv[0];
39
+ optionArgs = argv.slice(1);
40
+ }
41
+ if (action === 'set' || action === 'unset') {
42
+ return editModelProfile(action, optionArgs, options);
43
+ }
44
+ if (action === 'speed') return editModelSpeed(optionArgs, options);
45
+ 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
+ }
48
+ if (optionArgs.length) {
49
+ return { exitCode: 2, output: `codex-bridge model: unexpected argument "${optionArgs[0]}".` };
50
+ }
51
+
52
+ try {
53
+ if (action === 'list') {
54
+ const rows = parseCatalogue(await (options.fetchCatalogue ?? fetchCatalogue)());
55
+ return {
56
+ exitCode: 0,
57
+ output: renderTable(CATALOGUE_COLUMNS, rows, options.terminalWidth)
58
+ + (rows.length ? '' : '\nNo models returned by the live catalogue.'),
59
+ };
60
+ }
61
+
62
+ const configPath = path.resolve(options.configPath ?? CONFIG_PATH);
63
+ const config = readRunConfig(configPath);
64
+ const userTier = readCodexUserTier(options);
65
+ const rows = Object.entries(AGENTS).map(([agent, { role }]) => {
66
+ const profile = runProfile({ agent, models: config.models });
67
+ return {
68
+ role,
69
+ model: profile.model || 'Codex default (not pinned)',
70
+ effort: profile.effort,
71
+ speed: profile.speed || 'not pinned',
72
+ source: profileSource(profile, role, userTier),
73
+ };
74
+ });
75
+ return {
76
+ exitCode: 0,
77
+ 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
+ };
80
+ } catch (error) {
81
+ const subject = action === 'list' ? 'live catalogue unavailable; refusing to list models' : 'cannot read profile';
82
+ return { exitCode: 1, output: `codex-bridge model: ${subject}: ${error?.message ?? String(error)}` };
83
+ }
84
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Which copy of the package is answering, and whether the operator is standing in another one.
3
+ *
4
+ * Plan_19 gives the CLI a second name, and a global install puts a second copy of the package on
5
+ * the machine beside any clone. Every command copies host files from whichever copy launched it,
6
+ * so the answer depends on which copy that was — and until 2026-09-07 only `doctor` said so. That
7
+ * day the operator ran `codex-bridge update --force` from a 0.6.0 clone while PATH still held the
8
+ * previous release: the command compared the home against its OWN files, found them current, and
9
+ * answered "codex-bridge is up to date" without naming the package it meant. The fix is not a
10
+ * second detector — it is this one, shared.
11
+ */
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { readJsonFileSync } from '../src/home/lib/json-file.mjs';
15
+
16
+ const PACKAGE_NAME = '@lyupro/codex-bridge';
17
+
18
+ /** The kind of the copy at a known root. A path separator on Windows is a backslash too. */
19
+ export function sourceAt(root) {
20
+ const resolved = path.resolve(root);
21
+ return {
22
+ root: resolved,
23
+ kind: resolved.split(/[\\/]/).includes('node_modules') ? 'installed copy' : 'clone',
24
+ };
25
+ }
26
+
27
+ export function packageSource() {
28
+ return sourceAt(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'));
29
+ }
30
+
31
+ function readPackage(file) {
32
+ try {
33
+ const value = readJsonFileSync(file);
34
+ return value?.name === PACKAGE_NAME && typeof value.version === 'string' ? value : null;
35
+ } catch {
36
+ // A directory that merely holds an unreadable package.json is not a checkout of this package.
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * The checkout the operator is standing in, when that is a different copy than the one running.
43
+ * Walking up from the working directory is how the case reproduces: the operator was inside the
44
+ * clone, the command came from PATH, and nothing in the answer connected the two.
45
+ */
46
+ export function checkoutAt(cwd = process.cwd(), source = packageSource()) {
47
+ let directory = path.resolve(cwd);
48
+ for (;;) {
49
+ const found = readPackage(path.join(directory, 'package.json'));
50
+ if (found) {
51
+ return path.resolve(directory) === path.resolve(source.root)
52
+ ? null
53
+ : { root: directory, version: found.version, name: found.name };
54
+ }
55
+ const parent = path.dirname(directory);
56
+ if (parent === directory) return null;
57
+ directory = parent;
58
+ }
59
+ }
@@ -22,20 +22,31 @@ function buildRules(commandTransform) {
22
22
  * source list answer two different questions and let a future alias bypass one of them.
23
23
  *
24
24
  * The deny half subtracts `prune` from the allow half rather than guarding it: `Bash(codex-bridge:*)`
25
- * would otherwise hand out the deletion this package refuses to give an agent. Its refusal is never
26
- * seen in a live session — a PreToolUse hook answers before permissions are consulted, and
27
- * prune-guard is installed always while these rules are optional. The 2026-08-09 run held that
28
- * observation open as a checklist item until it turned out to be the answer, not the obstacle.
25
+ * would otherwise hand out the deletion this package refuses to give an agent. Plan_56's
26
+ * 2026-09-07 reconnaissance established deny, ask, allow ordering before PreToolUse hooks:
27
+ * the narrow speed ask therefore wins over the broad allow, and no hook can waive it.
29
28
  */
30
29
  export const PERMISSION_RULES = Object.freeze({
31
30
  allow: buildRules((command) => command),
32
31
  deny: buildRules((command) => `${command} prune`),
32
+ ask: buildRules((command) => `${command} model speed`),
33
33
  });
34
34
 
35
- const allRules = new Set([...PERMISSION_RULES.allow, ...PERMISSION_RULES.deny]);
35
+ const allRules = new Set(Object.values(PERMISSION_RULES).flat());
36
36
  const ruleLists = Object.freeze(['allow', 'deny', 'ask']);
37
- const activeLists = Object.freeze(['allow', 'deny']);
38
- const totalRules = PERMISSION_RULES.allow.length + PERMISSION_RULES.deny.length;
37
+ const activeLists = ruleLists;
38
+ const totalRules = allRules.size;
39
+
40
+ /**
41
+ * Which lists a count covers, spelled once.
42
+ *
43
+ * Two sentences report that count — this module's own line and doctor's — and both used to name
44
+ * the lists in prose. Adding `ask` in Plan_56 updated one and left the other saying `allow/deny`
45
+ * over a number that already included the third list: a true number under a false label, which is
46
+ * the same defect Plan_22-1 caught in this very line. Derived from the list of lists so a fourth
47
+ * one cannot introduce a third spelling.
48
+ */
49
+ export const RULE_LIST_NAMES = ruleLists.join('/');
39
50
 
40
51
  function listValue(settings, name) {
41
52
  const list = settings?.permissions?.[name];
@@ -47,19 +58,20 @@ function matchingRules(list, rules) {
47
58
  }
48
59
 
49
60
  /**
50
- * A count of strings in allow and deny is not the state of the set, because `ask` outranks `allow`:
61
+ * A count of installed strings is not the state of the set, because `ask` outranks `allow`:
51
62
  * a copy sitting there keeps asking the question the rule was added to end. The live run of
52
63
  * Plan_22-1 caught doctor reporting `installed (24/24)` over exactly that arrangement — the number
53
64
  * was true and the conclusion drawn from it was false. Every consumer reads the state, so the
54
- * shadow has to live in the state rather than in one command's wording.
65
+ * shadow has to live in the state rather than in one command's wording. Plan_56's own speed ask
66
+ * rules are intentional; only allow strings copied into ask count as that shadow.
55
67
  */
56
68
  function permissionStatus(settings) {
57
69
  const counts = Object.fromEntries(activeLists.map((name) => [
58
70
  name,
59
71
  matchingRules(listValue(settings, name), PERMISSION_RULES[name]),
60
72
  ]));
61
- const askCount = matchingRules(listValue(settings, 'ask'), [...allRules]);
62
- const present = counts.allow + counts.deny;
73
+ const askCount = matchingRules(listValue(settings, 'ask'), PERMISSION_RULES.allow);
74
+ const present = counts.allow + counts.deny + counts.ask;
63
75
  const complete = present === totalRules;
64
76
  const state = askCount
65
77
  ? (complete ? 'shadowed by ask' : 'partially installed, shadowed by ask')
@@ -138,31 +150,35 @@ export async function removePermissionRules(settingsPath, { dryRun = false } = {
138
150
  });
139
151
  }
140
152
 
141
- function statusOutput(status) {
142
- const moved = status.askCount
153
+ function statusOutput(status, action) {
154
+ const moved = status.askCount && action !== 'add'
143
155
  ? `; ${status.askCount} own string(s) in ask, which outranks allow`
144
156
  : '';
145
- return `Permissions: ${status.state} (${status.present}/${status.total} own strings in allow/deny${moved}).`;
157
+ return `Permissions: ${status.state} (${status.present}/${status.total} own strings in ${RULE_LIST_NAMES}${moved}).`;
146
158
  }
147
159
 
148
160
  async function permissionsInRun({ host, action } = {}) {
149
161
  if (!host?.settingsPath) throw new TypeError('permissions requires a resolved host');
150
162
  if (!action) {
151
163
  const status = await inspectPermissions(host.settingsPath);
152
- return { exitCode: 0, output: statusOutput(status), ...status };
164
+ return { exitCode: 0, output: statusOutput(status, action), ...status };
153
165
  }
154
166
  if (action === 'add') {
155
167
  const result = await addPermissionRules(host.settingsPath);
156
168
  // A string the operator moved into `ask` outranks the copy `add` just put into `allow`, so the
157
169
  // command would report a complete set while the questions it was run to end keep appearing.
158
- // `ask` stays untouched — it is a hand-made decision — but silence about it is a false success.
170
+ // That allow string stays in `ask` — it is a hand-made decision — alongside our speed rules.
159
171
  const status = await inspectPermissions(host.settingsPath);
160
172
  const conflict = status.askCount
161
173
  ? ` ${status.askCount} own string(s) also sit in ask, which outranks allow; they were left there.`
162
174
  : '';
175
+ // The 2026-09-07, 0.6.0 install incident mistook a zero delta for failure; report the set too.
176
+ const added = result.added > 0
177
+ ? `Added ${result.added} permission rule string${result.added === 1 ? '' : 's'}. `
178
+ : '';
163
179
  return {
164
180
  exitCode: 0,
165
- output: `Added ${result.added} permission rule string${result.added === 1 ? '' : 's'}.${conflict}`,
181
+ output: `${added}${statusOutput(status, action)}${conflict}`,
166
182
  ...result,
167
183
  askCount: status.askCount,
168
184
  };
@@ -51,7 +51,7 @@ function gentleAction(root, project, run) {
51
51
  if (!files.length) return null;
52
52
  return {
53
53
  kind: 'run',
54
- mode: 'gentle',
54
+ strategy: 'gentle',
55
55
  project,
56
56
  run: run.run,
57
57
  path: runDir,
@@ -65,7 +65,7 @@ function runPurgeAction(root, project, run) {
65
65
  const runDir = path.join(root, project, run.run);
66
66
  return {
67
67
  kind: 'run',
68
- mode: 'purge',
68
+ strategy: 'purge',
69
69
  project,
70
70
  run: run.run,
71
71
  path: runDir,
@@ -79,7 +79,7 @@ function projectPurgeAction(root, project, runs) {
79
79
  const projectDir = path.join(root, project);
80
80
  return {
81
81
  kind: 'project',
82
- mode: 'purge',
82
+ strategy: 'purge',
83
83
  project,
84
84
  run: null,
85
85
  path: projectDir,
@@ -194,7 +194,7 @@ export function prunePlan(args = {}, options = {}) {
194
194
  return {
195
195
  root,
196
196
  scope,
197
- mode: args.purge ? 'purge' : 'gentle',
197
+ strategy: args.purge ? 'purge' : 'gentle',
198
198
  project: args.projectName || null,
199
199
  run: args.runName || null,
200
200
  olderThan: age,
package/cli/prune.mjs CHANGED
@@ -34,7 +34,7 @@ function commandText(args) {
34
34
  function actionPayload(action) {
35
35
  return {
36
36
  kind: action.kind,
37
- mode: action.mode,
37
+ strategy: action.strategy,
38
38
  project: action.project,
39
39
  run: action.run,
40
40
  targets: action.targets,
@@ -45,7 +45,7 @@ function actionPayload(action) {
45
45
  function basePayload(plan) {
46
46
  return {
47
47
  scope: plan.scope,
48
- mode: plan.mode,
48
+ strategy: plan.strategy,
49
49
  project: plan.project,
50
50
  run: plan.run,
51
51
  olderThan: ageText(plan.olderThan),
@@ -57,7 +57,7 @@ function basePayload(plan) {
57
57
 
58
58
  function planLines(plan, args) {
59
59
  const lines = [
60
- `Prune plan: ${plan.mode} ${plan.scope}.`,
60
+ `Prune plan: ${plan.strategy} ${plan.scope}.`,
61
61
  plan.olderThan ? `Age filter: older than ${ageText(plan.olderThan)} (folder name date).` : 'Age filter: none.',
62
62
  ];
63
63
  for (const action of plan.actions) {
@@ -160,8 +160,8 @@ function linkedSegment(target, root) {
160
160
  return null;
161
161
  }
162
162
 
163
- function currentTargetBytes(target, mode) {
164
- if (mode === 'purge') {
163
+ function currentTargetBytes(target, strategy) {
164
+ if (strategy === 'purge') {
165
165
  // The inventory's recursive measurement is the same accounting used by projects and keeps
166
166
  // the report honest when a purge contains nested files.
167
167
  return recursiveSize(target);
@@ -176,7 +176,7 @@ function currentTargetBytes(target, mode) {
176
176
  async function confirmAll(plan, args, options) {
177
177
  const prompt = options.prompt || defaultPrompt;
178
178
  for (const action of plan.actions) {
179
- const question = action.mode === 'purge'
179
+ const question = action.strategy === 'purge'
180
180
  ? `Delete folder ${action.path}?`
181
181
  : `Delete archived transport from ${action.project}/${action.run}?\n ${action.targets.join('\n ')}`;
182
182
  let answer;
@@ -211,9 +211,9 @@ async function execute(plan, options) {
211
211
  failed.push({ target, error: `refusing to delete through a link: ${linked}` });
212
212
  continue;
213
213
  }
214
- const bytes = currentTargetBytes(target, action.mode);
214
+ const bytes = currentTargetBytes(target, action.strategy);
215
215
  try {
216
- fs.rmSync(target, { recursive: action.mode === 'purge', force: false });
216
+ fs.rmSync(target, { recursive: action.strategy === 'purge', force: false });
217
217
  removed.push(target);
218
218
  sizes.push(bytes);
219
219
  } catch (err) {
@@ -4,14 +4,12 @@ import { randomUUID } from 'node:crypto';
4
4
  import path from 'node:path';
5
5
  import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
6
6
  import { readJsonFile } from '../src/home/lib/json-file.mjs';
7
+ import { withFileLock } from '../src/home/lib/file-lock.mjs';
8
+ export { isLockTaken } from '../src/home/lib/file-lock.mjs';
7
9
 
8
10
  export const RULES_REGISTRY_NAME = '.codex-bridge-rules.json';
9
11
  export const RULES_REGISTRY_VERSION = 1;
10
12
 
11
- const REGISTRY_LOCK_RETRIES = 200;
12
- const REGISTRY_LOCK_DELAY_MS = 5;
13
- const REGISTRY_LOCK_STALE_MS = 30_000;
14
-
15
13
  export function rulesRegistryPath(host) {
16
14
  return path.join(host.codexRulesDir, RULES_REGISTRY_NAME);
17
15
  }
@@ -77,73 +75,8 @@ function registryLockPath(host) {
77
75
  return `${rulesRegistryPath(host)}.lock`;
78
76
  }
79
77
 
80
- function waitForRegistryLock() {
81
- return new Promise((resolve) => setTimeout(resolve, REGISTRY_LOCK_DELAY_MS));
82
- }
83
-
84
- /**
85
- * A lock nobody released is worse than no lock: interrupting an install left the file behind and
86
- * every later install and uninstall died on it until someone deleted it by hand (reproduced
87
- * 2026-08-04). An owner update takes milliseconds, so a lock older than the staleness window
88
- * belongs to a process that is gone.
89
- */
90
- async function dropStaleLock(lockPath) {
91
- try {
92
- const { mtimeMs } = await fs.stat(lockPath);
93
- if (Date.now() - mtimeMs < REGISTRY_LOCK_STALE_MS) return false;
94
- await fs.rm(lockPath, { force: true });
95
- return true;
96
- } catch {
97
- // Vanished or unreadable — let the next open() decide rather than guess here.
98
- return false;
99
- }
100
- }
101
-
102
- /**
103
- * Windows answers a taken lock with three different codes, and only one of them is EEXIST. A file
104
- * whose last handle closed while a delete was pending stays visible but refuses to be opened:
105
- * `open(..., 'wx')` comes back EPERM, and a file another process still holds comes back EBUSY.
106
- * Treating those as fatal is what made `tests/cli/rules-owners.test.mjs` fail twice on 2026-08-11
107
- * with EPERM on the lock file — the suite went red over a lock that was simply busy for one more
108
- * millisecond. All three mean the same thing to a caller waiting for a lock: not yours yet.
109
- */
110
- const LOCK_TAKEN_CODES = new Set(['EEXIST', 'EPERM', 'EBUSY']);
111
-
112
- /** Exported so the retry contract is asserted directly; the race itself reproduces only by luck. */
113
- export function isLockTaken(err) {
114
- return LOCK_TAKEN_CODES.has(err?.code);
115
- }
116
-
117
- async function acquireRegistryLock(host) {
118
- await fs.mkdir(host.codexRulesDir, { recursive: true });
119
- const lockPath = registryLockPath(host);
120
- let lastTaken;
121
- for (let attempt = 0; attempt < REGISTRY_LOCK_RETRIES; attempt += 1) {
122
- try {
123
- const handle = await fs.open(lockPath, 'wx');
124
- return { handle, lockPath };
125
- } catch (err) {
126
- if (!isLockTaken(err)) throw err;
127
- lastTaken = err;
128
- if (!(await dropStaleLock(lockPath))) await waitForRegistryLock();
129
- }
130
- }
131
- // The code is part of the message: a timeout on EPERM points at a delete that never completed,
132
- // a timeout on EEXIST at an owner that never released.
133
- throw new Error(
134
- `timed out waiting for rules ownership registry lock: ${lockPath} (last attempt: ${lastTaken.code})`,
135
- { cause: lastTaken },
136
- );
137
- }
138
-
139
78
  async function withRegistryLock(host, action) {
140
- const lock = await acquireRegistryLock(host);
141
- try {
142
- return await action();
143
- } finally {
144
- await lock.handle.close().catch(() => {});
145
- await fs.rm(lock.lockPath, { force: true }).catch(() => {});
146
- }
79
+ return withFileLock(registryLockPath(host), action, { description: 'rules ownership registry' });
147
80
  }
148
81
 
149
82
  export async function readRulesRegistry(host) {
package/cli/table.mjs CHANGED
@@ -23,10 +23,10 @@ export function truncateStart(value, width) {
23
23
  return `…${Array.from(text).slice(-(width - 1)).join('')}`;
24
24
  }
25
25
 
26
- function fit(value, width, mode) {
26
+ function fit(value, width, truncate) {
27
27
  if (displayWidth(value) <= width) return value;
28
- if (mode === 'start') return truncateStart(value, width);
29
- if (mode === 'end') return `${Array.from(value).slice(0, Math.max(0, width - 1)).join('')}…`;
28
+ if (truncate === 'start') return truncateStart(value, width);
29
+ if (truncate === 'end') return `${Array.from(value).slice(0, Math.max(0, width - 1)).join('')}…`;
30
30
  return value;
31
31
  }
32
32
 
@@ -39,7 +39,7 @@ function normalizeColumn(column) {
39
39
  header: String(column.header ?? column.label ?? column.key),
40
40
  kind,
41
41
  align: column.align || (kind === 'number' ? 'right' : 'left'),
42
- mode: column.truncate === undefined ? (fixed ? null : 'start') : column.truncate,
42
+ truncate: column.truncate === undefined ? (fixed ? null : 'start') : column.truncate,
43
43
  };
44
44
  }
45
45
 
@@ -65,7 +65,7 @@ function columnWidths(columns, rows, width) {
65
65
 
66
66
  const flexible = columns
67
67
  .map((column, index) => ({ column, index }))
68
- .filter(({ column }) => column.mode === 'start' || column.mode === 'end');
68
+ .filter(({ column }) => column.truncate === 'start' || column.truncate === 'end');
69
69
  while (spare < 0) {
70
70
  let changed = false;
71
71
  for (const { column, index } of flexible) {
@@ -84,7 +84,7 @@ function columnWidths(columns, rows, width) {
84
84
  function renderRow(row, columns, widths, header = false) {
85
85
  return columns.map((column, index) => {
86
86
  const value = header ? column.header : rowValue(row, column);
87
- const rendered = fit(value, widths[index], header ? null : column.mode);
87
+ const rendered = fit(value, widths[index], header ? null : column.truncate);
88
88
  const padding = Math.max(0, widths[index] - displayWidth(rendered));
89
89
  if (index === columns.length - 1) {
90
90
  return column.align === 'right' ? `${' '.repeat(padding)}${rendered}` : rendered;
package/cli/update.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Updates a recorded installation without overwriting unapproved host changes. */
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { packageSource, sourceAt, checkoutAt } from './package-source.mjs';
4
5
  import { install } from './install.mjs';
5
6
  import {
6
7
  buildInstallPlan,
@@ -197,6 +198,18 @@ async function updateInRun({ host, dryRun = false, force = false, packageRoot, e
197
198
  }
198
199
 
199
200
  const currentPackage = await packageInfo(packageRoot);
201
+ // The 2026-09-07, 0.6.0 install incident hid which package's files update actually compared.
202
+ const resolved = packageRoot ? sourceAt(packageRoot) : packageSource();
203
+ const source = `${currentPackage.name}@${currentPackage.version} from ${resolved.root}`
204
+ + ` (${resolved.kind === 'installed copy' ? 'installed package' : resolved.kind})`;
205
+ // Standing in one checkout while another copy answers is how that install went wrong: the reply
206
+ // was true about the package that ran and silent about the one the operator had on screen.
207
+ const elsewhere = checkoutAt(env.CODEX_BRIDGE_CWD ?? process.cwd(), resolved);
208
+ const mismatch = elsewhere && elsewhere.version !== currentPackage.version
209
+ ? `\nRun from ${currentPackage.version}; the checkout at ${elsewhere.root} is ${elsewhere.version}.`
210
+ + ' The home was updated from the copy that answered, not from that checkout'
211
+ + ` (npm i -g ${currentPackage.name}@${elsewhere.version} changes which one that is).`
212
+ : '';
200
213
  const targets = hookTargets(host, env, currentPackage.version);
201
214
  const inspectedHooks = await Promise.all(targets.map(async (target) => ({
202
215
  target,
@@ -217,10 +230,10 @@ async function updateInRun({ host, dryRun = false, force = false, packageRoot, e
217
230
  // it on this path costs nothing when there is nothing left to retire.
218
231
  await retireLegacyLayout(host);
219
232
  }
220
- return { exitCode: 0, output: 'codex-bridge is up to date' };
233
+ return { exitCode: 0, output: `codex-bridge is up to date with ${source}${mismatch}` };
221
234
  }
222
235
  if (dryRun) {
223
- return { exitCode: 0, output: dryRunOutput(states, inspectedHooks, legacy, oldHooks) };
236
+ return { exitCode: 0, output: `Would update codex-bridge with ${source}.${mismatch}\n${dryRunOutput(states, inspectedHooks, legacy, oldHooks)}` };
224
237
  }
225
238
 
226
239
  for (const state of orphanStates) {
@@ -236,7 +249,7 @@ async function updateInRun({ host, dryRun = false, force = false, packageRoot, e
236
249
  const installed = await install({ host, force: true, packageRoot, env });
237
250
  if (installed.exitCode !== 0) return installed;
238
251
  await retireLegacyLayout(host);
239
- return { exitCode: 0, output: appliedOutput(states) };
252
+ return { exitCode: 0, output: `${appliedOutput(states)}\nSource: ${source}${mismatch}` };
240
253
  }
241
254
 
242
255
  export async function update(options = {}) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lyupro/codex-bridge",
3
- "version": "0.5.5",
4
- "description": "Claude Code-to-Codex delegation with 6 guards, 817 tests, zero runtime dependencies, and zero build steps.",
3
+ "version": "0.6.1",
4
+ "description": "Claude Code-to-Codex delegation with 6 guards, 963 tests, zero runtime dependencies, and zero build steps.",
5
5
  "keywords": [
6
6
  "claude-code",
7
7
  "codex",
@@ -23,9 +23,9 @@ opinion, not a verdict.
23
23
  ## What you receive as input
24
24
 
25
25
  - What to review. One of these modes:
26
- - uncommitted changes (default) — `--mode uncommitted`;
27
- - branch against base — `--mode base:<branch>`;
28
- - specific commit — `--mode commit:<sha>`.
26
+ - uncommitted changes (default) — `--changeset uncommitted`;
27
+ - branch against base — `--changeset base:<branch>`;
28
+ - specific commit — `--changeset commit:<sha>`.
29
29
  - The path to the repository. If none is given, use the current working directory.
30
30
  - The path to a task file containing the review focus verbatim. The orchestrator supplies this
31
31
  path; pass it as `--task-file` and never create, read or rewrite it. Writing that file yourself
@@ -72,7 +72,7 @@ three in sequence and ended by telling its operator to grant a rule on `run-code
72
72
  ## The only thing you do
73
73
 
74
74
  ```bash
75
- codex-bridge run --agent codex-review --repo "<repository-path or .>" --mode "<one mode from the list above>" --slug "<slug>" --order-id "<order id from the orchestrator>" --task-file "<task-file path from the orchestrator>"
75
+ codex-bridge run --agent codex-review --repo "<repository-path or .>" --changeset "<one changeset from the list above>" --slug "<slug>" --order-id "<order id from the orchestrator>" --task-file "<task-file path from the orchestrator>"
76
76
  ```
77
77
 
78
78
  Add `--effort "<value>"` only when the orchestrator named a depth, and only with one of
@@ -0,0 +1,55 @@
1
+ ---
2
+ description: Show, list or change the model and reasoning depth each delegated Codex role runs on
3
+ allowed-tools: Bash
4
+ argument-hint: "[list | set <role> <model> [effort] | unset <role> | speed <role> <tier> [confirm] | speed <role> unset]"
5
+ ---
6
+
7
+ <!-- Part of the agents/codex-bridge/ package. The file lives here out of necessity: the slash
8
+ command name is set by its location under commands/<namespace>/, and Claude Code understands neither
9
+ symlinks nor pointer files (verified 2026-08-02). Edit it here; it travels as a copy when the
10
+ package is installed. -->
11
+
12
+ Which model each delegated role runs on. The three roles are the scout, the builder and the
13
+ reviewer, and each carries its own model and reasoning depth.
14
+
15
+ Run exactly this command, substituting the user's arguments (`$ARGUMENTS`), and return its output
16
+ verbatim. Add nothing and recompute nothing:
17
+
18
+ ```bash
19
+ codex-bridge model $ARGUMENTS
20
+ ```
21
+
22
+ Unlike the older `/codex-bridge:env`, this calls the short package command rather than a path to a
23
+ file (Plan_56 D1). The operator's standing permission rule is written for that spelling, and moving
24
+ the installed files can no longer break the call.
25
+
26
+ Call forms:
27
+
28
+ - no arguments — the model, reasoning depth, pinned speed tier and provenance of each role, plus the path of the
29
+ config file;
30
+ - `list` — the live catalogue from Codex: every model, the reasoning levels it accepts, its default
31
+ level and its accelerated service tier identifiers (parallel speed labels are not extra tiers);
32
+ - `set <role> <model> [effort]` — pin one role, with `--model` and `--effort` accepted instead of
33
+ the positions. The pair is checked against that model's own catalogue entry before anything is
34
+ written, so an unsupported depth is refused with the depths that model does accept, and a role
35
+ whose existing depth or pinned speed tier the new model cannot keep is refused with its supported values;
36
+ - `unset <role>` — remove that role's profile, returning it to whatever Codex chooses;
37
+ - `speed <role> <tier>` — preview an accelerated tier from the pinned model's live catalogue entry.
38
+ Pin a model with `set` first. The preview quotes the tier description verbatim and separately links
39
+ to https://learn.chatgpt.com/docs/agent-configuration/speed for credit multipliers; it writes nothing;
40
+ - `speed <role> <tier> confirm` — fetch and validate again, then pin that exact tier and report the
41
+ previous and new profile. Unknown tiers or an unavailable catalogue are refused without a write;
42
+ - `speed <role> unset` — remove only the speed pin immediately, preserving model and effort.
43
+ This needs no confirmation or catalogue; with no pin the package does not interfere with speed.
44
+
45
+ Two things the output says that are worth repeating to the operator if they ask:
46
+
47
+ - The profile is **machine-wide**. One config file serves every project on this machine, so
48
+ changing a role's model changes it for all of them, not for the repository you are standing in.
49
+ - The catalogue is asked live every time. There is no cache and no bundled copy, because the copy
50
+ inside the Codex binary lists models the server no longer has — an operator who picked one from a
51
+ stale list would lose the quota before the run started.
52
+
53
+ `set` and `unset` apply immediately; pinning a paid speed tier requires the second call ending in
54
+ `confirm`. Never edit the config file directly to do what these commands do — model, effort and
55
+ tier pins are checked against the live catalogue before writing.
@@ -14,6 +14,7 @@
14
14
  import fs from 'node:fs';
15
15
  import os from 'node:os';
16
16
  import path from 'node:path';
17
+ import { AGENTS } from '../lib/agents.mjs';
17
18
  import { readJsonFileSync } from '../lib/json-file.mjs';
18
19
  import {
19
20
  diagnoseInput,
@@ -31,7 +32,7 @@ import { parseTaskDocument } from '../lib/runner/task-file.mjs';
31
32
 
32
33
  const HOME = os.homedir();
33
34
  const LOG_DIR = path.join(HOME, '.claude', 'logs');
34
- const GUARDED = new Set(['codex-scout', 'codex-build', 'codex-review']);
35
+ const GUARDED = new Set(Object.keys(AGENTS));
35
36
  /**
36
37
  * Both spellings of the subagent-launching tool, from the same list the installer builds its
37
38
  * matcher from. Recognising only the name this host happens to use would make the gate silent
@@ -29,6 +29,7 @@
29
29
  import fs from 'node:fs';
30
30
  import os from 'node:os';
31
31
  import path from 'node:path';
32
+ import { AGENTS } from '../lib/agents.mjs';
32
33
  import { runOrderMismatch, transcriptOrderId } from '../lib/dispatcher-order.mjs';
33
34
  import { recognizeHostRefusal } from '../lib/host-refusal.mjs';
34
35
  import { readJsonFileSync } from '../lib/json-file.mjs';
@@ -65,7 +66,7 @@ import {
65
66
  // The broader hook-directory scan protects the extracted verdict text from the same incident.
66
67
  const HOME = os.homedir();
67
68
  const LOG_DIR = path.join(HOME, '.claude', 'logs');
68
- const GUARDED = new Set(['codex-scout', 'codex-build', 'codex-review']);
69
+ const GUARDED = new Set(Object.keys(AGENTS));
69
70
 
70
71
  /**
71
72
  * How many times a single agent may be blocked over the SHAPE of its reply — no RUN= line,