@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 +60 -0
- package/README.md +4 -1
- package/bin/codex-bridge.mjs +18 -10
- package/cli/codex-user-config.mjs +36 -0
- package/cli/doctor-installation.mjs +2 -2
- package/cli/install-record.mjs +15 -2
- package/cli/install.mjs +2 -2
- package/cli/manifest.mjs +1 -1
- package/cli/model-catalogue.mjs +81 -0
- package/cli/model-set.mjs +141 -0
- package/cli/model-speed.mjs +96 -0
- package/cli/model.mjs +84 -0
- package/cli/permissions.mjs +25 -13
- package/cli/prune-plan.mjs +4 -4
- package/cli/prune.mjs +8 -8
- package/cli/rules-owners.mjs +3 -70
- package/cli/table.mjs +6 -6
- package/package.json +2 -2
- package/src/agents/codex-review.md +4 -4
- package/src/commands/model.md +55 -0
- package/src/home/hooks/order-gate.mjs +2 -1
- package/src/home/hooks/reply-guard.mjs +2 -1
- package/src/home/hooks/shell-write-intent.mjs +50 -45
- package/src/home/lib/agents.mjs +15 -0
- package/src/home/lib/config-edit.mjs +159 -0
- package/src/home/lib/config-validate.mjs +227 -0
- package/src/home/lib/file-lock.mjs +76 -0
- package/src/home/lib/meta/reply.mjs +7 -6
- package/src/home/lib/run-codex.mjs +1 -1
- package/src/home/lib/run-config.mjs +15 -247
- package/src/home/lib/runner/args.mjs +8 -10
- package/src/home/lib/runner/codex-args.mjs +16 -19
- package/src/home/lib/runner/git-state.mjs +5 -5
- package/src/home/lib/runner/launcher.mjs +4 -3
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 **
|
|
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. |
|
package/bin/codex-bridge.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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 === '
|
|
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
|
|
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
|
}
|
package/cli/install-record.mjs
CHANGED
|
@@ -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',
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|