@lyupro/codex-bridge 0.5.5 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,66 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.6.0] - 2026-09-07
8
+
9
+ ### Added
10
+
11
+ - `codex-bridge model` answers which model and reasoning depth each of the three roles runs on, and
12
+ where each value comes from — the operator's config file or the package default. The config is one
13
+ file per machine, shared by every project on it, and the command says so: changing a model here
14
+ changes the executor for every repository this host delegates from.
15
+ - `codex-bridge model list` prints the live catalogue, hidden models included and marked as hidden.
16
+ A model Codex does not advertise is still a model the operator may choose; omitting it would mean
17
+ deciding for them and then explaining why the system has a model the list denies.
18
+ - `codex-bridge model set <role> <model> [effort]` and `model unset <role>` write that choice. The
19
+ pair is checked against the catalogue entry of that very model at the moment of writing, so
20
+ `ultra` — supported by the newest model and rejected by the package's own flat list until now —
21
+ became reachable, and a depth the chosen model does not offer is refused with its supported
22
+ depths named. Nothing is substituted silently: the command never changes what it was not asked to.
23
+ - `codex-bridge model speed <role> <tier>` pins a paid acceleration tier, and `model speed <role>
24
+ unset` removes it. The tier is spelled with the identifier the catalogue reports for that model,
25
+ never a word held in the package, and the command writes nothing until a second call ends in
26
+ `confirm` — the first prints the catalogue's own description of the tier and a link to the page
27
+ carrying the credit multipliers. The installer now also places one `ask` rule of its own, so the
28
+ host asks the operator before an agent can run that command at all.
29
+ - A pinned tier reaches the run: the launcher passes `-c service_tier=<id>` when, and only when, a
30
+ role has one pinned.
31
+
32
+ ### Changed
33
+
34
+ - The depth of a run is no longer validated against a list held in the package. That list had
35
+ drifted from the server in both directions — it offered a depth no model accepts and refused one
36
+ the newest model supports — so the writing command asks the catalogue and the hot path checks only
37
+ the shape of the value. The arbiter at run time is Codex itself, whose refusal is more accurate
38
+ than a copy of its data.
39
+ - One boundary now owns every write to the run config, and it persists only the edit it was given.
40
+ Reading merges the package defaults, and the previous writer saved that merged object, so a single
41
+ edit would have frozen every default into the operator's file — worst of all the list of
42
+ environment paths, which the package extends as it learns of new ones and which decides whether a
43
+ change belongs to the run or to the environment.
44
+ - The word `mode` no longer names three different things in this package. The reviewer's flag is
45
+ `--changeset`, the prune says which strategy, the table which truncation, and one registry answers
46
+ who the three agents are.
47
+ - The README states the suite size it actually has.
48
+
49
+ ### Fixed
50
+
51
+ - Two edits of the config racing each other can no longer lose one of them. Both commands read the
52
+ role profiles, waited seconds for the catalogue, then wrote what they had read, and three
53
+ concurrent processes all reported success while one edit was missing from the file. The edit
54
+ boundary now reads the file itself, hands the fresh value to the caller's transformer, compares
55
+ the bytes immediately before publishing, and holds the lock this package already used for the
56
+ rules-ownership registry — one definition now serves both writers.
57
+ - The worktree lock reads the shell rather than the prose someone wrote into a document. A plain
58
+ `cat > file << EOF` handed the whole command, heredoc body included, to the analysis, so the word
59
+ `touch` inside an English sentence made the next words look like files being written. The reverse
60
+ hole closed with it: the tail after a heredoc's closing marker was not parsed at all.
61
+ - A dead run stays dead even when Windows hands its process id to someone else.
62
+ - The reader of the Codex user configuration answers about configuration, not about prose inside it.
63
+ A multi-line TOML literal could contain a line indistinguishable from an assignment, and it was
64
+ read as the setting; an empty argument threw past the module's own error boundary, which exists so
65
+ the table renders whatever state the file is in.
66
+
7
67
  ## [0.5.5] - 2026-08-28
8
68
 
9
69
  ### Fixed
package/README.md CHANGED
@@ -26,6 +26,8 @@ Shipped agents invoke delegated runs through `codex-bridge run`. This is a permi
26
26
  not a style choice: the stable package command lets host permission rules match the invocation
27
27
  without exposing an installation-specific absolute path and prompting on every delegation.
28
28
 
29
+ Installing optional permission rules now adds `model speed` rules to `ask`, previously left to the operator, alongside `allow` and `deny`. These narrow prompts outrank the broad command allow rules and are checked before hooks, so an agent must ask the operator before switching tiers even when acting in good faith. Removing the rules takes back these exact strings while preserving other operator settings.
30
+
29
31
  Use `--scope project` to install the Claude Code-facing files under `<repo>/.claude`; the default user scope uses `~/.claude`.
30
32
 
31
33
  Installation has two roots:
@@ -60,7 +62,7 @@ If a global install and a clone coexist, you have two package copies. `codex-bri
60
62
  - **Every run leaves an audit folder.** The verbatim task, scope, before/after git state, events, report, verdict, and reason remain together.
61
63
  - **Zero runtime dependencies and zero build steps.** The package is plain `.mjs` on the Node.js standard library.
62
64
 
63
- The current suite contains **817 automated tests: 816 passing and 1 skipped**.
65
+ The current suite contains **961 automated tests: 960 passing and 1 skipped**.
64
66
 
65
67
  ## Verify
66
68
 
@@ -128,6 +130,7 @@ Transport files from runs at least **30 days** old are pruned automatically when
128
130
  | `uninstall [--scope user\|project] [--host <path>] [--dry-run]` | Remove recorded package files while preserving run artifacts. |
129
131
  | `doctor [--scope user\|project] [--host <path>] [--probe-contract]` | Diagnose the selected host and Codex connection. `--probe-contract` measures on a live host whether it still honours a hook refusal, and records the answer. |
130
132
  | `run <runner options> --task-file <abs path>` | Start or attach to a delegated run through the permission-stable package command. The task file carries the statement, the scout questions and the verification command; its path must be absolute. Piping the statement on stdin is the alternative channel, and the two cannot be combined. |
133
+ | `model [list\|set\|unset\|speed]` | Show the model, effort, pinned speed tier and provenance of each role, with the config path; `model list` prints the live catalogue from Codex, hidden models included and marked; `model set <role> <model> [effort]` checks the pair and any retained speed tier against that model's catalogue entry, and `model unset <role>` removes the profile. `model speed <role> <tier>` previews a live accelerated tier and its quoted cost description; repeat with trailing `confirm` to pin it. `model speed <role> unset` removes only speed, without confirmation. The profile is machine-wide, shared by every project on this machine. |
131
134
  | `projects [<name>] [--json]` | List projects or runs in the run store. |
132
135
  | `prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]`<br>`prune --all-projects [--older-than <age>] [-f] [--json]` | Plan or perform operator-confirmed cleanup, for one project or across the whole run store. |
133
136
  | `unlock [<project>\|--all]` | Close records whose runner is gone. |
@@ -5,8 +5,9 @@ import { probeContract } from '../cli/probe-contract.mjs';
5
5
  import { isInvokedDirectly } from '../cli/invoked-directly.mjs';
6
6
  import { hook } from '../cli/hook.mjs';
7
7
  import { resolveHost } from '../cli/hosts.mjs';
8
- import { install } from '../cli/install.mjs';
9
- import { projects } from '../cli/projects.mjs';
8
+ import { install } from '../cli/install.mjs';
9
+ import { model } from '../cli/model.mjs';
10
+ import { projects } from '../cli/projects.mjs';
10
11
  import { read } from '../cli/read.mjs';
11
12
  import { runCodexCommand } from '../src/home/lib/run-codex.mjs';
12
13
  import { packageInfo } from '../cli/manifest.mjs';
@@ -25,8 +26,9 @@ Usage:
25
26
  codex-bridge permissions [add|remove] [--scope user|project] [--host <path>]
26
27
  codex-bridge uninstall [--scope user|project] [--host <path>] [--dry-run]
27
28
  codex-bridge doctor [--scope user|project] [--host <path>] [--probe-contract]
28
- codex-bridge run <runner options> --task-file <path>
29
- codex-bridge projects [<name>] [--json]
29
+ codex-bridge run <runner options> --task-file <path>
30
+ codex-bridge model [list]
31
+ codex-bridge projects [<name>] [--json]
30
32
  codex-bridge prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]
31
33
  codex-bridge prune --all-projects [--older-than <age>] [-f] [--json]
32
34
  codex-bridge unlock [<project>|--all]
@@ -43,8 +45,9 @@ Commands:
43
45
  permissions Show, add, or remove optional shell permission rules
44
46
  uninstall Remove installed files while preserving run artifacts
45
47
  doctor Diagnose the selected Claude Code host (--probe-contract measures on a live host)
46
- run Start or attach to a delegated Codex run
47
- projects List projects or runs from the run store
48
+ run Start or attach to a delegated Codex run
49
+ model Show machine-wide model profiles or list the live catalogue
50
+ projects List projects or runs from the run store
48
51
  prune Remove archived transport, or purge selected run folders
49
52
  unlock Close running records whose runner is gone
50
53
  read Render a run's structured event stream
@@ -127,12 +130,17 @@ export async function main(argv, io = console) {
127
130
  io.log(result.output);
128
131
  return result.exitCode;
129
132
  }
130
- if (command === 'projects') {
133
+ if (command === 'projects') {
131
134
  const result = projects(rest);
132
135
  io.log(result.output);
133
- return result.exitCode;
134
- }
135
- if (command === 'prune') {
136
+ return result.exitCode;
137
+ }
138
+ if (command === 'model') {
139
+ const result = await model(rest);
140
+ io.log(result.output);
141
+ return result.exitCode;
142
+ }
143
+ if (command === 'prune') {
136
144
  const result = await prune(rest);
137
145
  io.log(result.output);
138
146
  return result.exitCode;
@@ -0,0 +1,36 @@
1
+ /** Reads only the operator's root tier; Plan_56 keeps this separate from package profile pins. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { resolveHost } from './hosts.mjs';
5
+
6
+ export function readCodexUserTier(options) {
7
+ // Destructured inside the boundary, not in the signature: a null argument would throw past the
8
+ // catch below, and this reader exists so the profile table renders whatever the file holds.
9
+ try {
10
+ const { codexHome, homedir } = options ?? {};
11
+ const { codexRulesDir } = resolveHost({ codexHome, homedir });
12
+ const configPath = path.join(codexRulesDir, '..', 'config.toml');
13
+ const content = fs.readFileSync(configPath, 'utf8');
14
+ // A multi-line literal can hold anything, including a line that looks like a table header or
15
+ // like this very assignment. Tracking the delimiter keeps text inside a value from being read
16
+ // as configuration — the alternative is answering confidently with someone's prose.
17
+ let openDelimiter = '';
18
+ for (const line of content.split(/\r?\n/)) {
19
+ if (openDelimiter) {
20
+ if (line.includes(openDelimiter)) openDelimiter = '';
21
+ continue;
22
+ }
23
+ const opening = /(?:^|=)\s*("""|''')/.exec(line);
24
+ if (opening && line.split(opening[1]).length === 2) {
25
+ openDelimiter = opening[1];
26
+ continue;
27
+ }
28
+ if (/^\s*\[/.test(line)) break;
29
+ const match = line.match(/^\s*service_tier\s*=\s*(?:"([^"]*)"|'([^']*)')\s*(?:#.*)?$/);
30
+ if (match) return match[1] ?? match[2];
31
+ }
32
+ } catch {
33
+ // Plan_56: optional user-config provenance must not prevent the profile table from rendering.
34
+ }
35
+ return '';
36
+ }
@@ -7,7 +7,7 @@ import {
7
7
  fileFingerprint,
8
8
  } from './manifest.mjs';
9
9
  import { plannedContent } from './copy.mjs';
10
- import { inspectPermissions } from './permissions.mjs';
10
+ import { RULE_LIST_NAMES, inspectPermissions } from './permissions.mjs';
11
11
  import { readRulesRegistry } from './rules-owners.mjs';
12
12
  import { parseFrontmatter } from './frontmatter.mjs';
13
13
  import { check } from './doctor-format.mjs';
@@ -104,7 +104,7 @@ export async function permissionsCheck(host) {
104
104
  // is not: the live run of Plan_22-1 found this line saying `installed (24/24)` over it.
105
105
  const shadow = status.askCount ? `, ${status.askCount} shadowed by ask` : '';
106
106
  return check('permissions', status.state === 'installed' ? 'ok' : 'warn',
107
- `${status.state} (${status.present}/${status.total} own strings in allow/deny${shadow})`);
107
+ `${status.state} (${status.present}/${status.total} own strings in ${RULE_LIST_NAMES}${shadow})`);
108
108
  } catch (err) {
109
109
  return check('permissions', 'warn', `cannot inspect permission rules: ${err.message}`);
110
110
  }
@@ -4,6 +4,17 @@ import path from 'node:path';
4
4
  import { readJsonFile } from '../src/home/lib/json-file.mjs';
5
5
  import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
6
6
 
7
+ /**
8
+ * The record's own spelling of the field saying how the package was installed, and its one value.
9
+ *
10
+ * Plan_56 D11 keeps that field spelled `mode` on disk: install records already sit on three hosts,
11
+ * and renaming a field nobody reads by eye would cost compatibility for tidiness. D20 keeps the
12
+ * word out of the code regardless — one constant knows the spelling, reader and writer go through
13
+ * it, and the guard forbidding `mode` as a name needs no exclusion list to stay quiet here.
14
+ */
15
+ export const INSTALL_METHOD_KEY = 'mode';
16
+ export const INSTALL_METHOD_COPY = 'copy';
17
+
7
18
  export const INSTALL_RECORD_NAME = '.installed.json';
8
19
  export const LEGACY_INSTALL_RECORD_NAME = '.codex-bridge-install.json';
9
20
  export const RULES_NAME = 'codex-bridge.rules';
@@ -145,10 +156,12 @@ export function validateInstallRecord(record) {
145
156
  if (!record || typeof record !== 'object' || Array.isArray(record)) {
146
157
  throw new Error('installation record must be an object');
147
158
  }
148
- for (const key of ['name', 'version', 'installedAt', 'mode']) {
159
+ for (const key of ['name', 'version', 'installedAt', INSTALL_METHOD_KEY]) {
149
160
  if (typeof record[key] !== 'string' || !record[key]) throw new Error(`installation record has invalid ${key}`);
150
161
  }
151
- if (record.mode !== 'copy') throw new Error('installation record mode must be copy');
162
+ if (record[INSTALL_METHOD_KEY] !== INSTALL_METHOD_COPY) {
163
+ throw new Error(`installation record ${INSTALL_METHOD_KEY} must be ${INSTALL_METHOD_COPY}`);
164
+ }
152
165
  if (Number.isNaN(Date.parse(record.installedAt))) throw new Error('installation record installedAt is invalid');
153
166
  const files = normalizedFiles(record);
154
167
  if (record.fingerprints !== undefined) fingerprintPairs(files, record.fingerprints);
package/cli/install.mjs CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  } from './manifest.mjs';
17
17
  import { copyPlannedFile, targetMatches } from './copy.mjs';
18
18
  import { hookTargets } from './hook-targets.mjs';
19
- import { fingerprintFor } from './install-record.mjs';
19
+ import { fingerprintFor, INSTALL_METHOD_COPY, INSTALL_METHOD_KEY } from './install-record.mjs';
20
20
  import {
21
21
  commandFor,
22
22
  hookRegistration,
@@ -255,7 +255,7 @@ async function installInRun({
255
255
  await writeInstallRecord(host, {
256
256
  ...currentPackage,
257
257
  installedAt: new Date().toISOString(),
258
- mode: 'copy',
258
+ [INSTALL_METHOD_KEY]: INSTALL_METHOD_COPY,
259
259
  files: plan.map((item) => ({ root: item.root, path: item.relativeToRoot })),
260
260
  fingerprints,
261
261
  rules: { path: rule.target, fingerprint: await fileFingerprint(rule.target) },
package/cli/manifest.mjs CHANGED
@@ -51,7 +51,7 @@ const RULES_SOURCE = `src/rules/${RULES_NAME}`;
51
51
  /**
52
52
  * Files the operator owns once they exist. Seeded on first install so the defaults are
53
53
  * visible and editable, then never written, never compared, never removed: config.json
54
- * is where a host states which model each mode runs on, conventions.md is the rule set it hands
54
+ * is where a host states which model each role runs on, conventions.md is the rule set it hands
55
55
  * every run, and an installer that overwrites either would erase host decisions on the next
56
56
  * update — the same way overwriting a .env would.
57
57
  */
@@ -0,0 +1,81 @@
1
+ /** Fetches and validates the raw Codex catalogue for Plan_56 step 2. */
2
+ import { spawnSync } from 'node:child_process';
3
+ import { parseJsonText } from '../src/home/lib/json-file.mjs';
4
+
5
+ export function fetchCatalogue({ run = spawnSync } = {}) {
6
+ const command = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex';
7
+ const args = process.platform === 'win32'
8
+ ? ['/d', '/s', '/c', 'codex debug models'] : ['debug', 'models'];
9
+ // Plan_56: each invocation asks Codex again; its bundled catalogue is not live availability.
10
+ const result = run(command, args, {
11
+ encoding: 'utf8', windowsHide: true, timeout: 30_000, maxBuffer: 16 * 1024 * 1024,
12
+ });
13
+ // Plan_56: Codex can emit bundled JSON with status 0 after reporting a refresh failure.
14
+ const refreshFailed = /failed to refresh available models/i.test(result.stderr || '');
15
+ if (result.error || result.signal || result.status !== 0 || refreshFailed) {
16
+ const cause = [
17
+ result.error && `${result.error.code || 'process error'}: ${result.error.message}`,
18
+ result.stderr?.trim(),
19
+ result.signal && `signal ${result.signal}`,
20
+ result.status != null && `exit code ${result.status}`,
21
+ ].filter(Boolean).join('; ');
22
+ throw new Error(`codex debug models failed: ${cause || 'no process exit status'}`);
23
+ }
24
+ return result.stdout;
25
+ }
26
+
27
+ function textField(value, field) {
28
+ if (typeof value !== 'string' || !value.trim()) {
29
+ throw new Error(`codex debug models: ${field} must be a non-empty string`);
30
+ }
31
+ return value;
32
+ }
33
+
34
+ function listField(value, field) {
35
+ if (!Array.isArray(value)) throw new Error(`codex debug models: ${field} must be an array`);
36
+ return value;
37
+ }
38
+
39
+ function catalogueRow(entry, index) {
40
+ const field = `models[${index}]`;
41
+ const slug = textField(entry?.slug, `${field}.slug`);
42
+ const levels = listField(entry.supported_reasoning_levels, `${field}.supported_reasoning_levels`)
43
+ .map((level, position) => textField(level?.effort, `${field}.supported_reasoning_levels[${position}].effort`));
44
+ const defaultLevel = entry.default_reasoning_level == null
45
+ ? 'not specified' : textField(entry.default_reasoning_level, `${field}.default_reasoning_level`);
46
+ const visibility = textField(entry.visibility, `${field}.visibility`);
47
+ if (!['list', 'hide', 'none'].includes(visibility)) {
48
+ throw new Error(`codex debug models: ${field}.visibility is unknown: ${visibility}`);
49
+ }
50
+ if (entry.additional_speed_tiers !== undefined) {
51
+ listField(entry.additional_speed_tiers, `${field}.additional_speed_tiers`)
52
+ .forEach((tier, position) => textField(tier, `${field}.additional_speed_tiers[${position}]`));
53
+ }
54
+ const serviceTiers = entry.service_tiers === undefined ? []
55
+ : listField(entry.service_tiers, `${field}.service_tiers`)
56
+ .map((tier, position) => ({
57
+ id: textField(tier?.id, `${field}.service_tiers[${position}].id`),
58
+ description: textField(tier?.description, `${field}.service_tiers[${position}].description`),
59
+ }));
60
+ // Plan_56 step 4: parallel speed labels describe the same tiers; only service IDs are pins.
61
+ const fastTiers = [...new Set(serviceTiers.map(({ id }) => id))];
62
+ return {
63
+ slug,
64
+ supportedReasoningLevels: levels,
65
+ serviceTiers,
66
+ hidden: visibility !== 'list',
67
+ 'reasoning levels': levels.length ? levels.join(', ') : 'not specified',
68
+ 'default level': defaultLevel,
69
+ 'fast tier': fastTiers.length ? `yes (${fastTiers.join(', ')})` : 'no',
70
+ visibility: visibility === 'list' ? 'listed' : `hidden (${visibility})`,
71
+ };
72
+ }
73
+
74
+ export function parseCatalogue(json) {
75
+ // Through the shared reader, not JSON.parse: one place strips the byte-order mark and names the
76
+ // source in the error, and a gate keeps every module on it. The "file" here is the command whose
77
+ // output failed, which is what the operator needs to see.
78
+ const catalogue = parseJsonText('codex debug models', json);
79
+ // Plan_56: invalid metadata refuses the whole list instead of silently dropping working models.
80
+ return listField(catalogue?.models, 'models').map(catalogueRow);
81
+ }
@@ -0,0 +1,141 @@
1
+ /** Writes only the requested role; Plan_56 step 3 validates its pair before the shared edit. */
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 { readJsonFileSync } from '../src/home/lib/json-file.mjs';
6
+ import { editRunConfig } from '../src/home/lib/config-edit.mjs';
7
+ import { fetchCatalogue, parseCatalogue } from './model-catalogue.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
+ function parseChanges(args) {
13
+ const changes = {};
14
+ for (let index = 0; index < args.length; index += 1) {
15
+ const argument = args[index];
16
+ let field;
17
+ let value = argument;
18
+ if (argument.startsWith('-')) {
19
+ if (argument !== '--model' && argument !== '--effort') {
20
+ throw new Error(`unexpected argument "${argument}".`);
21
+ }
22
+ field = argument.slice(2);
23
+ value = args[++index];
24
+ if (value === undefined || value.startsWith('-')) throw new Error(`missing value for ${argument}.`);
25
+ } else {
26
+ field = ['model', 'effort'].find((key) => changes[key] === undefined);
27
+ if (!field) throw new Error(`unexpected argument "${argument}".`);
28
+ }
29
+ if (changes[field] !== undefined) throw new Error(`${field} was supplied more than once.`);
30
+ if (!value || /\s/.test(value)) {
31
+ throw new Error(`${field} must be a non-empty single word with no whitespace.`);
32
+ }
33
+ changes[field] = value;
34
+ }
35
+ if (!Object.keys(changes).length) throw new Error('set requires a model or --effort value.');
36
+ return changes;
37
+ }
38
+
39
+ export function readProfiles(configPath) {
40
+ let config;
41
+ try {
42
+ config = readJsonFileSync(configPath);
43
+ } catch (error) {
44
+ if (error.code !== 'ENOENT') throw error;
45
+ config = {};
46
+ }
47
+ validateRunConfig(configPath, config);
48
+ // The reader trims fields and drops empty profiles. Those are not edits the operator asked for.
49
+ return { ...config.models };
50
+ }
51
+
52
+ // The same sentence the config module already prints for a profile. A serialized object here
53
+ // would be the second spelling of one idea, and the operator reading it is a person, not a parser.
54
+ export function describe(profile) {
55
+ if (!profile || !Object.keys(profile).length) return 'not set (Codex chooses)';
56
+ const model = profile.model || 'default model';
57
+ return `${model}${profile.effort ? ` at ${profile.effort} effort` : ''}`
58
+ + (profile.speed ? ` on ${profile.speed} tier` : '');
59
+ }
60
+
61
+ export async function editModelProfile(action, argv, options) {
62
+ const [role, ...args] = argv;
63
+ if (!ROLES.includes(role)) {
64
+ return failure(2, `${role ? `unknown role "${role}"` : 'role is required'}. Allowed roles: ${ROLES.join(', ')}.`);
65
+ }
66
+ let changes;
67
+ try {
68
+ if (action === 'unset' && args.length) throw new Error(`unexpected argument "${args[0]}".`);
69
+ if (action === 'set') changes = parseChanges(args);
70
+ } catch (error) {
71
+ return failure(2, error.message);
72
+ }
73
+
74
+ try {
75
+ const configPath = path.resolve(options.configPath ?? CONFIG_PATH);
76
+ let catalogue;
77
+ if (action === 'set') {
78
+ try {
79
+ catalogue = parseCatalogue(await (options.fetchCatalogue ?? fetchCatalogue)());
80
+ } catch (error) {
81
+ return failure(1, `live catalogue unavailable; refusing to set ${role}: ${error?.message ?? String(error)}`);
82
+ }
83
+ }
84
+ let profiles;
85
+ let before;
86
+ let hiddenNotice = '';
87
+ let catalogueModel;
88
+ await editRunConfig('models', (current) => {
89
+ // An absent key is a config that has no profiles yet, not a malformed one: validating
90
+ // {models: undefined} turned "no configured model" into the validator's own refusal.
91
+ if (current !== undefined) validateRunConfig(configPath, { models: current });
92
+ profiles = { ...current };
93
+ before = profiles[role];
94
+ hiddenNotice = '';
95
+ if (action === 'set') {
96
+ const after = { ...before, ...changes };
97
+ const modelId = after.model?.trim();
98
+ const effort = after.effort?.trim();
99
+ // D40/D41: a retry cannot apply a catalogue decision to a different role model.
100
+ if (catalogueModel !== undefined && modelId !== catalogueModel) {
101
+ throw new Error('The profile changed while the catalogue was being read; nothing was written. Run the command again.');
102
+ }
103
+ if (!modelId) throw refusal(`${role} has no configured model; supply a model or --model.`);
104
+ const entry = catalogue.find(({ slug }) => slug === modelId);
105
+ if (!entry) {
106
+ throw refusal(`unknown model "${after.model}". Available models: ${catalogue.map(({ slug }) => slug).join(', ') || '(empty catalogue)'}. Nothing written.`);
107
+ }
108
+ if (effort !== undefined && !entry.supportedReasoningLevels.includes(effort)) {
109
+ const origin = changes.effort === undefined ? 'existing effort' : 'effort';
110
+ throw refusal(`${origin} "${after.effort}" is not supported by model "${after.model}". `
111
+ + `Supported depths: ${entry.supportedReasoningLevels.join(', ') || '(no depths advertised)'}. `
112
+ + 'Supply a supported effort explicitly. Nothing written.');
113
+ }
114
+ if (after.speed !== undefined && !entry.serviceTiers.some(({ id }) => id === after.speed.trim())) {
115
+ // Two sentences rather than a list that reads "tiers: (no tier)": a model offering none at
116
+ // all is a different answer from one offering others, and the operator acts on it differently.
117
+ const offered = entry.serviceTiers.length
118
+ ? `Accelerated tiers it does offer: ${entry.serviceTiers.map(({ id }) => id).join(', ')}.`
119
+ : 'That model offers no accelerated tier at all.';
120
+ throw refusal(`existing speed "${after.speed}" is not supported by model "${after.model}". `
121
+ + `${offered} `
122
+ + `Remove the pin with model speed ${role} unset first. Nothing written.`);
123
+ }
124
+ catalogueModel = entry.slug;
125
+ if (entry.hidden) hiddenNotice = `Model "${entry.slug}" is hidden in the Codex catalogue.\n`;
126
+ profiles[role] = after;
127
+ } else {
128
+ delete profiles[role];
129
+ }
130
+ return profiles;
131
+ }, configPath);
132
+ return {
133
+ exitCode: 0,
134
+ output: `${hiddenNotice}${role}: ${describe(before)} -> ${describe(profiles[role])}\nConfig file: ${configPath}\n`
135
+ + 'Machine-wide: shared by every project on this machine, not per-project.',
136
+ };
137
+ } catch (error) {
138
+ if (error.exitCode === 2) return failure(2, error.message);
139
+ return failure(1, `cannot ${action} profile: ${error?.message ?? String(error)}`);
140
+ }
141
+ }
@@ -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
+ }