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 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.1.0.tar.gz
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
- Restart Codex after installation so new agents and skills are discovered. Use `doctor` for missing, user-modified, unsafe-state, lock, or interrupted-transaction reports.
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
- return runCore(parsed.cleaned, options);
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.1.0",
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 --test",
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",