codex-model-router 2.1.0 → 2.2.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/README.md +42 -4
- package/lib/agent-reasoning-cli.js +23 -2
- package/lib/experimental-v2.js +566 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -60,6 +60,36 @@ codex-model-router install --terra-reasoning xhigh --luna-reasoning low --sol-re
|
|
|
60
60
|
|
|
61
61
|
Supported values are `none`, `low`, `medium`, `high`, `xhigh`, and `max`. Per-agent options override `--agent-reasoning`. Later installs preserve unchanged package-managed reasoning choices; manually edited agent files remain protected.
|
|
62
62
|
|
|
63
|
+
## Optional experimental multi-agent V2
|
|
64
|
+
|
|
65
|
+
Codex currently has an experimental `multi_agent_v2` configuration that is not part of the public configuration reference. It may be unavailable for some accounts or Codex builds, and its keys or behavior may change without notice.
|
|
66
|
+
|
|
67
|
+
A normal install never enables it. Enable it explicitly for the current project only after accepting that compatibility risk:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
codex-model-router v2 enable --dry-run
|
|
71
|
+
codex-model-router v2 enable
|
|
72
|
+
codex-model-router v2 status
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The managed block is:
|
|
76
|
+
|
|
77
|
+
```toml
|
|
78
|
+
[features.multi_agent_v2]
|
|
79
|
+
hide_spawn_agent_metadata = false
|
|
80
|
+
tool_namespace = "agents"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Disable only the unchanged block created by this package:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
codex-model-router v2 disable
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Use `--global` with `enable`, `disable`, or `status` to target the user-level Codex configuration. Pre-existing V2 settings are treated as user-managed and are never overwritten. A normal package uninstall also removes an unchanged package-managed V2 block; modified V2 content is preserved and reported.
|
|
90
|
+
|
|
91
|
+
Enabling this setting does not guarantee that every child agent resolves to the requested model. Verify actual subagent model usage from Codex session metadata when cost control depends on Luna being selected.
|
|
92
|
+
|
|
63
93
|
## Install
|
|
64
94
|
|
|
65
95
|
Run from npm:
|
|
@@ -80,7 +110,7 @@ Installing the CLI globally only makes the command available system-wide. Use `i
|
|
|
80
110
|
Install a specific GitHub release:
|
|
81
111
|
|
|
82
112
|
```sh
|
|
83
|
-
npm install -g https://github.com/Honguan/codex-model-router/archive/refs/tags/v2.
|
|
113
|
+
npm install -g https://github.com/Honguan/codex-model-router/archive/refs/tags/v2.2.0.tar.gz
|
|
84
114
|
codex-model-router install
|
|
85
115
|
```
|
|
86
116
|
|
|
@@ -89,11 +119,12 @@ codex-model-router install
|
|
|
89
119
|
Project scope is the default and manages only:
|
|
90
120
|
|
|
91
121
|
```text
|
|
92
|
-
.codex/config.toml # only with --set-default or a managed migration
|
|
122
|
+
.codex/config.toml # only with --set-default, V2 opt-in, or a managed migration
|
|
93
123
|
.codex/agents/terra.toml
|
|
94
124
|
.codex/agents/luna.toml
|
|
95
125
|
.codex/agents/sol.toml
|
|
96
126
|
.codex/model-router-state.json
|
|
127
|
+
.codex/model-router-v2-state.json # only after explicit `v2 enable`
|
|
97
128
|
.codex/config.toml.codex-model-router.bak # only when needed
|
|
98
129
|
.agents/skills/model-router/SKILL.md
|
|
99
130
|
.agents/skills/implementation-planning/SKILL.md
|
|
@@ -104,6 +135,7 @@ Use global scope with:
|
|
|
104
135
|
```sh
|
|
105
136
|
codex-model-router install --global
|
|
106
137
|
codex-model-router doctor --global
|
|
138
|
+
codex-model-router v2 enable --global
|
|
107
139
|
```
|
|
108
140
|
|
|
109
141
|
Global scope stores agents under `$CODEX_HOME/agents` and skills under `~/.agents/skills`. When `CODEX_HOME` is unset, it defaults to `~/.codex`.
|
|
@@ -134,6 +166,9 @@ codex-model-router install [--global] [--set-default] [--dry-run]
|
|
|
134
166
|
[--terra-reasoning <effort>] [--luna-reasoning <effort>] [--sol-reasoning <effort>]
|
|
135
167
|
codex-model-router uninstall [--global] [--dry-run]
|
|
136
168
|
codex-model-router doctor [--global]
|
|
169
|
+
codex-model-router v2 enable [--global] [--dry-run]
|
|
170
|
+
codex-model-router v2 disable [--global] [--dry-run]
|
|
171
|
+
codex-model-router v2 status [--global]
|
|
137
172
|
codex-model-router --version
|
|
138
173
|
```
|
|
139
174
|
|
|
@@ -144,7 +179,8 @@ codex-model-router --version
|
|
|
144
179
|
- Preserves unrelated TOML settings, comments, BOM, ordering, and LF/CRLF line endings.
|
|
145
180
|
- Rejects malformed, non-UTF-8, duplicate, or unsafe configuration before writing.
|
|
146
181
|
- Rejects symlinks and Windows junctions in managed paths.
|
|
147
|
-
- Uses scope-level locking, atomic transactions, rollback, and conflict-safe recovery.
|
|
182
|
+
- Uses scope-level locking, atomic transactions, rollback, and conflict-safe recovery for normal routing installation.
|
|
183
|
+
- The optional V2 manager uses the same scope lock, atomic file replacement, an exact marked block, and a separate state file.
|
|
148
184
|
- Never overwrites post-interruption user changes.
|
|
149
185
|
- Dry-run creates no files, directories, backups, locks, journals, or state.
|
|
150
186
|
- Never modifies AGENTS.md, shell profiles, editor settings, hooks, MCP servers, telemetry, accounts, or environment variables.
|
|
@@ -153,6 +189,8 @@ codex-model-router --version
|
|
|
153
189
|
|
|
154
190
|
Compatibility is tested on Node.js 18, 20, 22, and 24 across Windows, Linux, and macOS runners. The selected Codex account must have access to gpt-5.6-terra, gpt-5.6-luna, and gpt-5.6-sol.
|
|
155
191
|
|
|
156
|
-
|
|
192
|
+
The optional V2 setting is intentionally excluded from compatibility guarantees because it is not publicly documented by OpenAI. Failure or removal of that experimental setting does not affect the normal V1/custom-agent installation.
|
|
193
|
+
|
|
194
|
+
Restart Codex after installation or V2 changes so new agents, skills, and configuration are discovered. Use `doctor` for missing, user-modified, unsafe-state, lock, interrupted-transaction, or managed V2 reports.
|
|
157
195
|
|
|
158
196
|
Security vulnerabilities should be reported privately according to [SECURITY.md](SECURITY.md). Maintainer setup and release steps are documented in [MAINTAINERS.md](MAINTAINERS.md).
|
|
@@ -9,6 +9,11 @@ import {
|
|
|
9
9
|
REASONING_EFFORTS,
|
|
10
10
|
VERSION
|
|
11
11
|
} from "./manifest.js";
|
|
12
|
+
import {
|
|
13
|
+
cleanupV2AfterUninstall,
|
|
14
|
+
reportV2Doctor,
|
|
15
|
+
runV2Command
|
|
16
|
+
} from "./experimental-v2.js";
|
|
12
17
|
import { run as runCore } from "./router.js";
|
|
13
18
|
|
|
14
19
|
const OPTION_TO_AGENT = Object.freeze({
|
|
@@ -28,10 +33,15 @@ Usage:
|
|
|
28
33
|
[--sol-reasoning <effort>]
|
|
29
34
|
codex-model-router uninstall [--global] [--dry-run]
|
|
30
35
|
codex-model-router doctor [--global]
|
|
36
|
+
codex-model-router v2 enable [--global] [--dry-run]
|
|
37
|
+
codex-model-router v2 disable [--global] [--dry-run]
|
|
38
|
+
codex-model-router v2 status [--global]
|
|
31
39
|
codex-model-router --version
|
|
32
40
|
|
|
33
41
|
Reasoning efforts: ${REASONING_EFFORTS.join(", ")}
|
|
34
|
-
Defaults: Terra=${DEFAULT_AGENT_REASONING.terra}, Luna=${DEFAULT_AGENT_REASONING.luna}, Sol=${DEFAULT_AGENT_REASONING.sol}
|
|
42
|
+
Defaults: Terra=${DEFAULT_AGENT_REASONING.terra}, Luna=${DEFAULT_AGENT_REASONING.luna}, Sol=${DEFAULT_AGENT_REASONING.sol}
|
|
43
|
+
|
|
44
|
+
V2 commands manage an experimental, undocumented Codex setting and are never enabled by a normal install.`;
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
function optionValue(argv, index, option) {
|
|
@@ -137,6 +147,7 @@ async function installedReasoning(argv, options) {
|
|
|
137
147
|
|
|
138
148
|
export async function runCli(argv, options = {}) {
|
|
139
149
|
const output = options.output ?? console.log;
|
|
150
|
+
if (argv[0] === "v2") return runV2Command(argv.slice(1), options);
|
|
140
151
|
if (!argv.length || argv.includes("--help") || argv.includes("-h")) {
|
|
141
152
|
output(usage());
|
|
142
153
|
return 0;
|
|
@@ -156,5 +167,15 @@ export async function runCli(argv, options = {}) {
|
|
|
156
167
|
}
|
|
157
168
|
Object.assign(selected, parsed.overrides);
|
|
158
169
|
configureAgentReasoning(selected, installed.content);
|
|
159
|
-
|
|
170
|
+
|
|
171
|
+
const result = await runCore(parsed.cleaned, options);
|
|
172
|
+
const command = parsed.cleaned[0];
|
|
173
|
+
if (command === "doctor") {
|
|
174
|
+
const v2Result = await reportV2Doctor(parsed.cleaned.slice(1), options);
|
|
175
|
+
return result || v2Result;
|
|
176
|
+
}
|
|
177
|
+
if (command === "uninstall" && result === 0) {
|
|
178
|
+
return cleanupV2AfterUninstall(parsed.cleaned.slice(1), options);
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
160
181
|
}
|
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, open, readFile, realpath, rename, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { homedir, hostname } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
|
|
6
|
+
const STATE_FILE = "model-router-v2-state.json";
|
|
7
|
+
const LOCK_FILE = "model-router.lock";
|
|
8
|
+
const START_MARKER = "# >>> codex-model-router experimental multi-agent-v2 >>>";
|
|
9
|
+
const END_MARKER = "# <<< codex-model-router experimental multi-agent-v2 <<<";
|
|
10
|
+
const STATE_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
export const EXPERIMENTAL_V2_SETTINGS = Object.freeze({
|
|
13
|
+
hide_spawn_agent_metadata: false,
|
|
14
|
+
tool_namespace: "agents"
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function digest(value) {
|
|
18
|
+
return createHash("sha256").update(value).digest("hex");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizePath(path) {
|
|
22
|
+
const value = resolve(path);
|
|
23
|
+
return process.platform === "win32" ? value.toLowerCase() : value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isWithin(path, root) {
|
|
27
|
+
const value = relative(resolve(root), resolve(path));
|
|
28
|
+
return value === "" || (!value.startsWith(`..${sep}`) && value !== ".." && !isAbsolute(value));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function nearestExistingAncestor(path) {
|
|
32
|
+
let current = resolve(path);
|
|
33
|
+
while (true) {
|
|
34
|
+
try {
|
|
35
|
+
await lstat(current);
|
|
36
|
+
return current;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error?.code !== "ENOENT") throw error;
|
|
39
|
+
const parent = dirname(current);
|
|
40
|
+
if (parent === current) throw new Error(`no existing ancestor for ${path}`);
|
|
41
|
+
current = parent;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function canonicalize(path) {
|
|
47
|
+
const absolute = resolve(path);
|
|
48
|
+
const ancestor = await nearestExistingAncestor(absolute);
|
|
49
|
+
return resolve(await realpath(ancestor), relative(ancestor, absolute));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function assertSafePath(target, root, anchor, label) {
|
|
53
|
+
const absoluteTarget = resolve(target);
|
|
54
|
+
const absoluteRoot = resolve(root);
|
|
55
|
+
const absoluteAnchor = resolve(anchor);
|
|
56
|
+
if (!isWithin(absoluteTarget, absoluteRoot) || !isWithin(absoluteTarget, absoluteAnchor)) {
|
|
57
|
+
throw new Error(`unsafe path for ${label}: ${absoluteTarget}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const physicalAnchor = await realpath(absoluteAnchor);
|
|
61
|
+
let current = absoluteAnchor;
|
|
62
|
+
const inspect = [current];
|
|
63
|
+
for (const component of relative(absoluteAnchor, absoluteTarget).split(sep).filter(Boolean)) {
|
|
64
|
+
current = join(current, component);
|
|
65
|
+
inspect.push(current);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (let index = 0; index < inspect.length; index += 1) {
|
|
69
|
+
const candidate = inspect[index];
|
|
70
|
+
let info;
|
|
71
|
+
try {
|
|
72
|
+
info = await lstat(candidate);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error?.code === "ENOENT") break;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
if (info.isSymbolicLink()) throw new Error(`unsafe symbolic link or junction: ${candidate}`);
|
|
78
|
+
if (index < inspect.length - 1 && !info.isDirectory()) {
|
|
79
|
+
throw new Error(`unsafe non-directory path component: ${candidate}`);
|
|
80
|
+
}
|
|
81
|
+
const physical = await realpath(candidate);
|
|
82
|
+
const expected = resolve(physicalAnchor, relative(absoluteAnchor, candidate));
|
|
83
|
+
if (normalizePath(physical) !== normalizePath(expected)) {
|
|
84
|
+
throw new Error(`unsafe redirected path: ${candidate}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function locationFor(options, global) {
|
|
90
|
+
const home = await canonicalize(options.home ?? homedir());
|
|
91
|
+
const cwd = await canonicalize(options.cwd ?? process.cwd());
|
|
92
|
+
const env = options.env ?? process.env;
|
|
93
|
+
const codexHome = global
|
|
94
|
+
? resolve(env.CODEX_HOME || join(home, ".codex"))
|
|
95
|
+
: join(cwd, ".codex");
|
|
96
|
+
const anchor = global ? await nearestExistingAncestor(codexHome) : cwd;
|
|
97
|
+
const location = {
|
|
98
|
+
scope: global ? "global" : "project",
|
|
99
|
+
codexHome,
|
|
100
|
+
config: join(codexHome, "config.toml"),
|
|
101
|
+
state: join(codexHome, STATE_FILE),
|
|
102
|
+
lock: join(codexHome, LOCK_FILE),
|
|
103
|
+
anchor
|
|
104
|
+
};
|
|
105
|
+
await validateLocation(location);
|
|
106
|
+
return location;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function validateLocation(location) {
|
|
110
|
+
for (const [label, target] of Object.entries({
|
|
111
|
+
"Codex home": location.codexHome,
|
|
112
|
+
"config.toml": location.config,
|
|
113
|
+
"experimental V2 state": location.state,
|
|
114
|
+
"scope lock": location.lock
|
|
115
|
+
})) {
|
|
116
|
+
await assertSafePath(target, location.codexHome, location.anchor, label);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function readText(path) {
|
|
121
|
+
try {
|
|
122
|
+
const bytes = await readFile(path);
|
|
123
|
+
const text = bytes.toString("utf8");
|
|
124
|
+
if (!Buffer.from(text, "utf8").equals(bytes)) throw new Error(`${path} is not valid UTF-8`);
|
|
125
|
+
if (text.includes("\0")) throw new Error(`${path} contains a NUL byte`);
|
|
126
|
+
return { exists: true, text };
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error?.code === "ENOENT") return { exists: false, text: "" };
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function atomicWrite(path, content) {
|
|
134
|
+
await mkdir(dirname(path), { recursive: true });
|
|
135
|
+
let mode = 0o600;
|
|
136
|
+
try {
|
|
137
|
+
mode = (await stat(path)).mode;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error?.code !== "ENOENT") throw error;
|
|
140
|
+
}
|
|
141
|
+
const temporary = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
142
|
+
let handle;
|
|
143
|
+
try {
|
|
144
|
+
handle = await open(temporary, "wx", mode);
|
|
145
|
+
await handle.writeFile(content, "utf8");
|
|
146
|
+
await handle.sync();
|
|
147
|
+
await handle.close();
|
|
148
|
+
handle = undefined;
|
|
149
|
+
await rename(temporary, path);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
try { await handle?.close(); } catch {}
|
|
152
|
+
try { await rm(temporary, { force: true }); } catch {}
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function newlineFor(content) {
|
|
158
|
+
return content.includes("\r\n") ? "\r\n" : "\n";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function managedBlock(newline) {
|
|
162
|
+
return [
|
|
163
|
+
START_MARKER,
|
|
164
|
+
"[features.multi_agent_v2]",
|
|
165
|
+
"hide_spawn_agent_metadata = false",
|
|
166
|
+
'tool_namespace = "agents"',
|
|
167
|
+
END_MARKER,
|
|
168
|
+
""
|
|
169
|
+
].join(newline);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function escapeRegExp(value) {
|
|
173
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function findManagedV2Block(content) {
|
|
177
|
+
const starts = [...content.matchAll(new RegExp(`^${escapeRegExp(START_MARKER)}\\r?$`, "gm"))];
|
|
178
|
+
const ends = [...content.matchAll(new RegExp(`^${escapeRegExp(END_MARKER)}\\r?$`, "gm"))];
|
|
179
|
+
if (!starts.length && !ends.length) return null;
|
|
180
|
+
if (starts.length !== 1 || ends.length !== 1 || ends[0].index <= starts[0].index) {
|
|
181
|
+
return { error: "duplicate, incomplete, or misordered experimental V2 markers" };
|
|
182
|
+
}
|
|
183
|
+
const start = starts[0].index;
|
|
184
|
+
let end = ends[0].index + ends[0][0].length;
|
|
185
|
+
if (content[end] === "\n") end += 1;
|
|
186
|
+
return { start, end, content: content.slice(start, end) };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function stripInlineComment(line) {
|
|
190
|
+
let quote = null;
|
|
191
|
+
let escaped = false;
|
|
192
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
193
|
+
const character = line[index];
|
|
194
|
+
if (quote) {
|
|
195
|
+
if (quote === '"' && character === "\\" && !escaped) {
|
|
196
|
+
escaped = true;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (character === quote && !escaped) quote = null;
|
|
200
|
+
escaped = false;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (character === '"' || character === "'") quote = character;
|
|
204
|
+
else if (character === "#") return line.slice(0, index);
|
|
205
|
+
}
|
|
206
|
+
return line;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function hasUnmanagedV2(content) {
|
|
210
|
+
return content.split(/\r?\n/).some((line) => /\bmulti_agent_v2\b/.test(stripInlineComment(line)));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function appendBlock(original) {
|
|
214
|
+
const newline = newlineFor(original);
|
|
215
|
+
const block = managedBlock(newline);
|
|
216
|
+
const hasBom = original.charCodeAt(0) === 0xfeff;
|
|
217
|
+
const body = original.slice(hasBom ? 1 : 0);
|
|
218
|
+
let separator = "";
|
|
219
|
+
if (body.length > 0) {
|
|
220
|
+
if (body.endsWith(`${newline}${newline}`)) separator = "";
|
|
221
|
+
else if (body.endsWith(newline)) separator = newline;
|
|
222
|
+
else separator = `${newline}${newline}`;
|
|
223
|
+
} else if (hasBom) {
|
|
224
|
+
separator = newline;
|
|
225
|
+
}
|
|
226
|
+
return { content: `${original}${separator}${block}`, block, separator };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function validateState(raw, location) {
|
|
230
|
+
if (!raw || typeof raw !== "object" || raw.version !== STATE_VERSION) {
|
|
231
|
+
throw new Error("experimental V2 state is invalid or unsupported");
|
|
232
|
+
}
|
|
233
|
+
if (raw.scope !== location.scope) throw new Error("experimental V2 state scope mismatch");
|
|
234
|
+
if (normalizePath(raw.configPath) !== normalizePath(location.config)) {
|
|
235
|
+
throw new Error("experimental V2 state path mismatch");
|
|
236
|
+
}
|
|
237
|
+
if (typeof raw.createdConfig !== "boolean") throw new Error("experimental V2 createdConfig is invalid");
|
|
238
|
+
if (typeof raw.separator !== "string" || !["", "\n", "\n\n", "\r\n", "\r\n\r\n"].includes(raw.separator)) {
|
|
239
|
+
throw new Error("experimental V2 separator is invalid");
|
|
240
|
+
}
|
|
241
|
+
if (!/^[a-f0-9]{64}$/.test(raw.blockHash || "")) throw new Error("experimental V2 block hash is invalid");
|
|
242
|
+
return raw;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function loadState(location) {
|
|
246
|
+
const file = await readText(location.state);
|
|
247
|
+
if (!file.exists) return { exists: false, state: null };
|
|
248
|
+
let raw;
|
|
249
|
+
try {
|
|
250
|
+
raw = JSON.parse(file.text);
|
|
251
|
+
} catch {
|
|
252
|
+
throw new Error("experimental V2 state is not valid JSON");
|
|
253
|
+
}
|
|
254
|
+
return { exists: true, state: validateState(raw, location) };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function inspect(location) {
|
|
258
|
+
const state = await loadState(location);
|
|
259
|
+
const config = await readText(location.config);
|
|
260
|
+
const block = findManagedV2Block(config.text);
|
|
261
|
+
if (block?.error) return { status: "invalid", detail: block.error, state, config, block };
|
|
262
|
+
if (!state.exists) {
|
|
263
|
+
if (block) return { status: "untracked", detail: "package markers exist without state", state, config, block };
|
|
264
|
+
if (hasUnmanagedV2(config.text)) {
|
|
265
|
+
return { status: "unmanaged", detail: "pre-existing V2 configuration is preserved", state, config, block };
|
|
266
|
+
}
|
|
267
|
+
return { status: "disabled", state, config, block };
|
|
268
|
+
}
|
|
269
|
+
if (!config.exists) return { status: "missing", detail: "config.toml is missing", state, config, block };
|
|
270
|
+
if (!block) return { status: "missing", detail: "managed V2 block is missing", state, config, block };
|
|
271
|
+
if (digest(block.content) !== state.state.blockHash) {
|
|
272
|
+
return { status: "user-modified", detail: "managed V2 block changed", state, config, block };
|
|
273
|
+
}
|
|
274
|
+
return { status: "managed", state, config, block };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function inspectLock(location) {
|
|
278
|
+
const file = await readText(location.lock);
|
|
279
|
+
if (!file.exists) return { status: "none" };
|
|
280
|
+
let metadata;
|
|
281
|
+
try {
|
|
282
|
+
metadata = JSON.parse(file.text);
|
|
283
|
+
} catch {
|
|
284
|
+
return { status: "corrupt", detail: "lock file is not valid JSON" };
|
|
285
|
+
}
|
|
286
|
+
if (
|
|
287
|
+
!Number.isInteger(metadata.pid) ||
|
|
288
|
+
metadata.pid <= 0 ||
|
|
289
|
+
typeof metadata.hostname !== "string" ||
|
|
290
|
+
typeof metadata.token !== "string"
|
|
291
|
+
) {
|
|
292
|
+
return { status: "corrupt", detail: "lock metadata is invalid" };
|
|
293
|
+
}
|
|
294
|
+
if (metadata.hostname !== hostname()) {
|
|
295
|
+
return { status: "active", metadata, detail: "lock belongs to another host" };
|
|
296
|
+
}
|
|
297
|
+
let alive = true;
|
|
298
|
+
try {
|
|
299
|
+
process.kill(metadata.pid, 0);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (["ESRCH", "EINVAL"].includes(error?.code)) alive = false;
|
|
302
|
+
else if (error?.code !== "EPERM") throw error;
|
|
303
|
+
}
|
|
304
|
+
return alive ? { status: "active", metadata } : { status: "stale", metadata };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function acquireLock(location, command) {
|
|
308
|
+
await mkdir(location.codexHome, { recursive: true });
|
|
309
|
+
await validateLocation(location);
|
|
310
|
+
|
|
311
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
312
|
+
const token = randomUUID();
|
|
313
|
+
let handle;
|
|
314
|
+
try {
|
|
315
|
+
handle = await open(location.lock, "wx", 0o600);
|
|
316
|
+
await handle.writeFile(`${JSON.stringify({
|
|
317
|
+
version: 1,
|
|
318
|
+
token,
|
|
319
|
+
pid: process.pid,
|
|
320
|
+
hostname: hostname(),
|
|
321
|
+
command,
|
|
322
|
+
scope: location.scope,
|
|
323
|
+
startedAt: new Date().toISOString()
|
|
324
|
+
}, null, 2)}\n`, "utf8");
|
|
325
|
+
await handle.sync();
|
|
326
|
+
await handle.close();
|
|
327
|
+
return token;
|
|
328
|
+
} catch (error) {
|
|
329
|
+
try { await handle?.close(); } catch {}
|
|
330
|
+
if (error?.code !== "EEXIST") throw error;
|
|
331
|
+
const lock = await inspectLock(location);
|
|
332
|
+
if (lock.status === "none") continue;
|
|
333
|
+
if (lock.status === "stale") {
|
|
334
|
+
const before = await readText(location.lock);
|
|
335
|
+
const again = await readText(location.lock);
|
|
336
|
+
if (before.exists && again.exists && before.text === again.text) {
|
|
337
|
+
await rm(location.lock, { force: true });
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const detail = lock.metadata
|
|
342
|
+
? `${lock.metadata.command || "operation"} pid ${lock.metadata.pid}`
|
|
343
|
+
: lock.detail || "unknown lock owner";
|
|
344
|
+
throw new Error(`scope is locked by ${detail}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
throw new Error("unable to acquire scope lock");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function releaseLock(location, token) {
|
|
351
|
+
if (!token) return;
|
|
352
|
+
try {
|
|
353
|
+
const lock = await readText(location.lock);
|
|
354
|
+
if (!lock.exists) return;
|
|
355
|
+
const metadata = JSON.parse(lock.text);
|
|
356
|
+
if (metadata.token === token) await rm(location.lock, { force: true });
|
|
357
|
+
} catch {}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function print(output, status, detail) {
|
|
361
|
+
output?.(`${status}: experimental-v2${detail ? ` (${detail})` : ""}`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function enablePlan(current, location) {
|
|
365
|
+
const next = appendBlock(current.config.text);
|
|
366
|
+
return {
|
|
367
|
+
configContent: next.content,
|
|
368
|
+
stateContent: `${JSON.stringify({
|
|
369
|
+
version: STATE_VERSION,
|
|
370
|
+
scope: location.scope,
|
|
371
|
+
configPath: location.config,
|
|
372
|
+
createdConfig: !current.config.exists,
|
|
373
|
+
separator: next.separator,
|
|
374
|
+
blockHash: digest(next.block),
|
|
375
|
+
enabledAt: new Date().toISOString()
|
|
376
|
+
}, null, 2)}\n`,
|
|
377
|
+
configStatus: current.config.exists ? "update" : "create"
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function disablePlan(current) {
|
|
382
|
+
let before = current.config.text.slice(0, current.block.start);
|
|
383
|
+
const separator = current.state.state.separator;
|
|
384
|
+
if (separator && before.endsWith(separator)) before = before.slice(0, -separator.length);
|
|
385
|
+
const restored = `${before}${current.config.text.slice(current.block.end)}`;
|
|
386
|
+
const empty = restored.replace(/^\uFEFF/, "").trim() === "";
|
|
387
|
+
return {
|
|
388
|
+
restored,
|
|
389
|
+
deleteConfig: current.state.state.createdConfig && empty
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function restoreOriginalConfig(location, config) {
|
|
394
|
+
if (config.exists) await atomicWrite(location.config, config.text);
|
|
395
|
+
else await rm(location.config, { force: true });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function enable(location, options) {
|
|
399
|
+
const preview = await inspect(location);
|
|
400
|
+
if (preview.status === "managed") {
|
|
401
|
+
print(options.output, "skip", "already enabled and managed");
|
|
402
|
+
return 0;
|
|
403
|
+
}
|
|
404
|
+
if (preview.status === "unmanaged") {
|
|
405
|
+
print(options.output, "preserve", preview.detail);
|
|
406
|
+
return 0;
|
|
407
|
+
}
|
|
408
|
+
if (preview.status !== "disabled") {
|
|
409
|
+
print(options.output, "fail", preview.detail || preview.status);
|
|
410
|
+
return 1;
|
|
411
|
+
}
|
|
412
|
+
if (options.dryRun) {
|
|
413
|
+
const plan = enablePlan(preview, location);
|
|
414
|
+
print(options.output, `would-${plan.configStatus}`, "config.toml");
|
|
415
|
+
print(options.output, "would-create", "experimental V2 state");
|
|
416
|
+
return 0;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
let token;
|
|
420
|
+
try {
|
|
421
|
+
token = await acquireLock(location, "v2-enable");
|
|
422
|
+
const current = await inspect(location);
|
|
423
|
+
if (current.status !== "disabled") {
|
|
424
|
+
print(options.output, "fail", `state changed during enable: ${current.status}`);
|
|
425
|
+
return 1;
|
|
426
|
+
}
|
|
427
|
+
const plan = enablePlan(current, location);
|
|
428
|
+
await atomicWrite(location.config, plan.configContent);
|
|
429
|
+
try {
|
|
430
|
+
await atomicWrite(location.state, plan.stateContent);
|
|
431
|
+
} catch (error) {
|
|
432
|
+
await restoreOriginalConfig(location, current.config);
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
print(options.output, plan.configStatus, "config.toml");
|
|
436
|
+
print(options.output, "create", "experimental V2 state");
|
|
437
|
+
return 0;
|
|
438
|
+
} finally {
|
|
439
|
+
await releaseLock(location, token);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function disable(location, options) {
|
|
444
|
+
const preview = await inspect(location);
|
|
445
|
+
if (preview.status === "disabled" || preview.status === "unmanaged") {
|
|
446
|
+
if (!options.onlyManaged) print(options.output, preview.status === "disabled" ? "skip" : "preserve", preview.detail || "not enabled by this package");
|
|
447
|
+
return 0;
|
|
448
|
+
}
|
|
449
|
+
if (preview.status !== "managed") {
|
|
450
|
+
print(options.output, "preserve", preview.detail || preview.status);
|
|
451
|
+
return 1;
|
|
452
|
+
}
|
|
453
|
+
if (options.dryRun) {
|
|
454
|
+
const plan = disablePlan(preview);
|
|
455
|
+
print(options.output, plan.deleteConfig ? "would-remove" : "would-update", "config.toml");
|
|
456
|
+
print(options.output, "would-remove", "experimental V2 state");
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
let token;
|
|
461
|
+
try {
|
|
462
|
+
token = await acquireLock(location, "v2-disable");
|
|
463
|
+
const current = await inspect(location);
|
|
464
|
+
if (current.status !== "managed") {
|
|
465
|
+
print(options.output, "preserve", `state changed during disable: ${current.status}`);
|
|
466
|
+
return 1;
|
|
467
|
+
}
|
|
468
|
+
const plan = disablePlan(current);
|
|
469
|
+
if (plan.deleteConfig) await rm(location.config, { force: true });
|
|
470
|
+
else await atomicWrite(location.config, plan.restored);
|
|
471
|
+
try {
|
|
472
|
+
await rm(location.state, { force: true });
|
|
473
|
+
} catch (error) {
|
|
474
|
+
await atomicWrite(location.config, current.config.text);
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
print(options.output, plan.deleteConfig ? "remove" : "update", "config.toml");
|
|
478
|
+
print(options.output, "remove", "experimental V2 state");
|
|
479
|
+
return 0;
|
|
480
|
+
} finally {
|
|
481
|
+
await releaseLock(location, token);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function status(location, options) {
|
|
486
|
+
const current = await inspect(location);
|
|
487
|
+
const mapping = {
|
|
488
|
+
managed: ["healthy", "managed experimental setting"],
|
|
489
|
+
disabled: ["disabled", "not enabled"],
|
|
490
|
+
unmanaged: ["preserve", current.detail],
|
|
491
|
+
untracked: ["unsafe-state", current.detail],
|
|
492
|
+
missing: ["missing", current.detail],
|
|
493
|
+
"user-modified": ["user-modified", current.detail],
|
|
494
|
+
invalid: ["invalid", current.detail]
|
|
495
|
+
};
|
|
496
|
+
const [label, detail] = mapping[current.status] || ["invalid", current.status];
|
|
497
|
+
if (!(options.quietIfDisabled && current.status === "disabled")) print(options.output, label, detail);
|
|
498
|
+
return ["managed", "disabled", "unmanaged"].includes(current.status) ? 0 : 1;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function parseArgs(argv) {
|
|
502
|
+
if (!argv.length || argv.includes("--help") || argv.includes("-h")) return { help: true };
|
|
503
|
+
const [action, ...flags] = argv;
|
|
504
|
+
if (!["enable", "disable", "status"].includes(action)) return { error: "unknown V2 action" };
|
|
505
|
+
const parsed = { action, global: false, dryRun: false };
|
|
506
|
+
for (const flag of flags) {
|
|
507
|
+
if (flag === "--global") parsed.global = true;
|
|
508
|
+
else if (flag === "--dry-run" && action !== "status") parsed.dryRun = true;
|
|
509
|
+
else return { error: `unsupported option for v2 ${action}: ${flag}` };
|
|
510
|
+
}
|
|
511
|
+
return parsed;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function v2Usage() {
|
|
515
|
+
return `Experimental Codex multi-agent V2 settings\n\nUsage:\n codex-model-router v2 enable [--global] [--dry-run]\n codex-model-router v2 disable [--global] [--dry-run]\n codex-model-router v2 status [--global]\n\nThis is an undocumented Codex setting and may change or stop working without notice.`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export async function runV2Command(argv, options = {}) {
|
|
519
|
+
const output = options.output ?? console.log;
|
|
520
|
+
const parsed = parseArgs(argv);
|
|
521
|
+
if (parsed.help) {
|
|
522
|
+
output(v2Usage());
|
|
523
|
+
return 0;
|
|
524
|
+
}
|
|
525
|
+
if (parsed.error) {
|
|
526
|
+
output(`${parsed.error}\n\n${v2Usage()}`);
|
|
527
|
+
return 1;
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
const location = await locationFor(options, parsed.global);
|
|
531
|
+
if (parsed.action === "enable") return enable(location, { output, dryRun: parsed.dryRun });
|
|
532
|
+
if (parsed.action === "disable") return disable(location, { output, dryRun: parsed.dryRun });
|
|
533
|
+
return status(location, { output });
|
|
534
|
+
} catch (error) {
|
|
535
|
+
print(output, "fail", error.message);
|
|
536
|
+
return 1;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function scopeFlags(argv) {
|
|
541
|
+
return { global: argv.includes("--global"), dryRun: argv.includes("--dry-run") };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export async function reportV2Doctor(argv, options = {}) {
|
|
545
|
+
const output = options.output ?? console.log;
|
|
546
|
+
try {
|
|
547
|
+
const location = await locationFor(options, scopeFlags(argv).global);
|
|
548
|
+
return status(location, { output, quietIfDisabled: true });
|
|
549
|
+
} catch (error) {
|
|
550
|
+
print(output, "fail", error.message);
|
|
551
|
+
return 1;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export async function cleanupV2AfterUninstall(argv, options = {}) {
|
|
556
|
+
const output = options.output ?? console.log;
|
|
557
|
+
try {
|
|
558
|
+
const flags = scopeFlags(argv);
|
|
559
|
+
const location = await locationFor(options, flags.global);
|
|
560
|
+
if (!(await readText(location.state)).exists) return 0;
|
|
561
|
+
return disable(location, { output, dryRun: flags.dryRun, onlyManaged: true });
|
|
562
|
+
} catch (error) {
|
|
563
|
+
print(output, "fail", error.message);
|
|
564
|
+
return 1;
|
|
565
|
+
}
|
|
566
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-model-router",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Safely install adaptive free-mode Codex routing for Terra, Luna, and Sol.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"codex-model-router": "./bin/codex-model-router.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"check": "node --check bin/codex-model-router.js && node --check lib/agent-reasoning-cli.js && node --check lib/manifest.js && node --check lib/router.js && node --check lib/router-core.js && node --check lib/toml.js && node --check scripts/release-metadata.js && node --check scripts/verify-published.js",
|
|
11
|
-
"test": "node
|
|
10
|
+
"check": "node --check bin/codex-model-router.js && node --check lib/agent-reasoning-cli.js && node --check lib/experimental-v2.js && node --check lib/manifest.js && node --check lib/router.js && node --check lib/router-core.js && node --check lib/toml.js && node --check scripts/run-tests.js && node --check scripts/release-metadata.js && node --check scripts/verify-published.js",
|
|
11
|
+
"test": "node scripts/run-tests.js",
|
|
12
12
|
"test:package": "node scripts/package-smoke.js"
|
|
13
13
|
},
|
|
14
14
|
"engines": {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"codex-cli",
|
|
28
28
|
"openai",
|
|
29
29
|
"model-router",
|
|
30
|
+
"multi-agent",
|
|
30
31
|
"subagent",
|
|
31
32
|
"subagents",
|
|
32
33
|
"agent-routing",
|