@lyupro/codex-bridge 0.5.4 → 0.6.0

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.
@@ -0,0 +1,96 @@
1
+ /** Plan_56 step 4: paid tiers are catalogue decisions applied only by an explicit second call. */
2
+ import path from 'node:path';
3
+ import { CONFIG_PATH } from '../src/home/lib/run-config.mjs';
4
+ import { ROLES, validateRunConfig } from '../src/home/lib/config-validate.mjs';
5
+ import { editRunConfig } from '../src/home/lib/config-edit.mjs';
6
+ import { fetchCatalogue, parseCatalogue } from './model-catalogue.mjs';
7
+ import { describe, readProfiles } from './model-set.mjs';
8
+
9
+ const failure = (exitCode, message) => ({ exitCode, output: `codex-bridge model: ${message}` });
10
+ const refusal = (message) => Object.assign(new Error(message), { exitCode: 2 });
11
+
12
+ export async function editModelSpeed(argv, options) {
13
+ const [role, speed, confirmation, ...extra] = argv;
14
+ if (!ROLES.includes(role)) {
15
+ return failure(2, `${role ? `unknown role "${role}"` : 'role is required'}. Allowed roles: ${ROLES.join(', ')}.`);
16
+ }
17
+ if (typeof speed !== 'string' || !speed || /\s/.test(speed)) {
18
+ return failure(2, 'speed requires a tier identifier: a non-empty single word with no whitespace, or unset.');
19
+ }
20
+ if (confirmation !== undefined && (speed === 'unset' || confirmation !== 'confirm')) {
21
+ return failure(2, `unexpected argument "${confirmation}".`);
22
+ }
23
+ if (extra.length) return failure(2, `unexpected argument "${extra[0]}".`);
24
+
25
+ try {
26
+ const configPath = path.resolve(options.configPath ?? CONFIG_PATH);
27
+ let catalogue;
28
+ let tier;
29
+ if (speed !== 'unset') {
30
+ if (!readProfiles(configPath)[role]?.model) {
31
+ return failure(2, `${role} has no configured model; run model set ${role} <model> first. Nothing written.`);
32
+ }
33
+ try {
34
+ catalogue = parseCatalogue(await (options.fetchCatalogue ?? fetchCatalogue)());
35
+ } catch (error) {
36
+ return failure(1, `live catalogue unavailable; refusing to set ${role} speed: ${error?.message ?? String(error)}`);
37
+ }
38
+ }
39
+
40
+ let change;
41
+ let catalogueModel;
42
+ const transform = (current) => {
43
+ // Same absent-key rule as model-set: a missing config still answers about the role.
44
+ if (current !== undefined) validateRunConfig(configPath, { models: current });
45
+ const profiles = { ...current };
46
+ if (speed !== 'unset') {
47
+ const modelId = profiles[role]?.model?.trim();
48
+ // D40/D41: reusing a tier decision after a role changes model would validate the wrong pair.
49
+ if (catalogueModel !== undefined && modelId !== catalogueModel) {
50
+ throw new Error('The profile changed while the catalogue was being read; nothing was written. Run the command again.');
51
+ }
52
+ if (!modelId) {
53
+ throw refusal(`${role} has no configured model; run model set ${role} <model> first. Nothing written.`);
54
+ }
55
+ const entry = catalogue.find(({ slug }) => slug === modelId);
56
+ if (!entry) {
57
+ throw refusal(`unknown model "${modelId}". Available models: ${catalogue.map(({ slug }) => slug).join(', ') || '(empty catalogue)'}. Nothing written.`);
58
+ }
59
+ if (!entry.serviceTiers.length) {
60
+ throw refusal(`model "${modelId}" has no accelerated tier. Nothing written.`);
61
+ }
62
+ tier = entry.serviceTiers.find(({ id }) => id === speed);
63
+ if (!tier) {
64
+ throw refusal(`speed "${speed}" is not supported by model "${modelId}". `
65
+ + `Accelerated tiers: ${entry.serviceTiers.map(({ id }) => id).join(', ')}. Nothing written.`);
66
+ }
67
+ catalogueModel = entry.slug;
68
+ }
69
+ const before = profiles[role];
70
+ const after = { ...before };
71
+ if (speed === 'unset') delete after.speed;
72
+ else after.speed = speed;
73
+ change = `${role}: ${describe(before)} -> ${describe(after)}`;
74
+ if (before || speed !== 'unset') profiles[role] = after;
75
+ return profiles;
76
+ };
77
+ if (speed !== 'unset' && confirmation !== 'confirm') {
78
+ transform(readProfiles(configPath));
79
+ return {
80
+ exitCode: 0,
81
+ output: `Preview — ${change}\nCatalogue description: "${tier.description}"\n`
82
+ + 'Credit multipliers: https://learn.chatgpt.com/docs/agent-configuration/speed\n'
83
+ + `Run a second call ending in confirm to apply: codex-bridge model speed ${role} ${speed} confirm\nNothing written.`,
84
+ };
85
+ }
86
+ await editRunConfig('models', transform, configPath);
87
+ return {
88
+ exitCode: 0,
89
+ output: `${change}\nConfig file: ${configPath}\n`
90
+ + 'Machine-wide: shared by every project on this machine, not per-project.',
91
+ };
92
+ } catch (error) {
93
+ if (error.exitCode === 2) return failure(2, error.message);
94
+ return failure(1, `cannot ${speed === 'unset' ? 'unset' : 'set'} speed: ${error?.message ?? String(error)}`);
95
+ }
96
+ }
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
+ }
@@ -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')
@@ -108,7 +120,13 @@ export async function addPermissionRules(settingsPath) {
108
120
  added += 1;
109
121
  }
110
122
  }
111
- return { changed: added > 0, added };
123
+ const status = permissionStatus(settings);
124
+ return {
125
+ changed: added > 0,
126
+ added,
127
+ present: status.present,
128
+ total: status.total,
129
+ };
112
130
  });
113
131
  }
114
132
 
@@ -136,7 +154,7 @@ function statusOutput(status) {
136
154
  const moved = status.askCount
137
155
  ? `; ${status.askCount} own string(s) in ask, which outranks allow`
138
156
  : '';
139
- 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}).`;
140
158
  }
141
159
 
142
160
  async function permissionsInRun({ host, action } = {}) {
@@ -149,7 +167,7 @@ async function permissionsInRun({ host, action } = {}) {
149
167
  const result = await addPermissionRules(host.settingsPath);
150
168
  // A string the operator moved into `ask` outranks the copy `add` just put into `allow`, so the
151
169
  // command would report a complete set while the questions it was run to end keep appearing.
152
- // `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.
153
171
  const status = await inspectPermissions(host.settingsPath);
154
172
  const conflict = status.askCount
155
173
  ? ` ${status.askCount} own string(s) also sit in ask, which outranks allow; they were left there.`
@@ -23,6 +23,10 @@ const exists = async (target) => {
23
23
  }
24
24
  };
25
25
 
26
+ function lastNonEmptyLine(output) {
27
+ return String(output ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean).at(-1);
28
+ }
29
+
26
30
  export function judgeProbe({ markerExists, hookFired, hostResult }) {
27
31
  if (markerExists) {
28
32
  return { result: 'ignored', reason: 'The marker command ran despite the hook refusal.' };
@@ -41,7 +45,9 @@ export function judgeProbe({ markerExists, hookFired, hostResult }) {
41
45
  return { result: null, reason: `The host was terminated by signal ${hostResult.signal}.` };
42
46
  }
43
47
  if (hostResult.status !== 0) {
44
- return { result: null, reason: `The host exited with status ${String(hostResult.status)}.` };
48
+ const output = lastNonEmptyLine(hostResult.stderr) || lastNonEmptyLine(hostResult.stdout);
49
+ const detail = output ? `: ${output.slice(0, 200)}` : '';
50
+ return { result: null, reason: `The host exited with status ${String(hostResult.status)}${detail}.` };
45
51
  }
46
52
  if (!hookFired) {
47
53
  return { result: null, reason: 'The probe hook never received the marker command.' };
@@ -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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lyupro/codex-bridge",
3
- "version": "0.5.4",
4
- "description": "Claude Code-to-Codex delegation with 6 guards, 809 tests, zero runtime dependencies, and zero build steps.",
3
+ "version": "0.6.0",
4
+ "description": "Claude Code-to-Codex delegation with 6 guards, 961 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,