codex-model-router 1.1.0 → 1.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/lib/router.js CHANGED
@@ -1,210 +1 @@
1
- import { createHash } from "node:crypto";
2
- import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
3
- import { homedir } from "node:os";
4
- import { dirname, join, resolve } from "node:path";
5
- import { run as runCore } from "./router-core.js";
6
-
7
- export const VERSION = "1.1.0";
8
-
9
- const SOL_TEMPLATE = `name = "sol"
10
- description = "High-capability specialist for security-sensitive or high-regression-risk work."
11
- model = "gpt-5.6-sol"
12
- model_reasoning_effort = "medium"
13
- # v1.0.0 used sandbox_mode = "read-only"
14
- sandbox_mode = "workspace-write"
15
- developer_instructions = """
16
- Review or implement only when explicitly delegated by Terra.
17
- For review-only tasks, report concrete findings without changing files.
18
- For implementation tasks, make focused changes, run relevant checks, and report the result to Terra.
19
- Focus on security, authentication, permissions, secrets, destructive actions, financial logic, SQL writes, concurrency, complex state, and regression risk.
20
- Do not expand the scope beyond the delegated task.
21
- """
22
- `;
23
-
24
- const SKILL_TEMPLATE = `---
25
- name: model-router
26
- description: Route Codex work between Terra, Luna, and Sol with the fewest required agents.
27
- ---
28
-
29
- Terra handles ordinary questions, coding, debugging, fixes, testing, and implementation. Never create a Terra subagent.
30
- Use Luna only for deterministic low-risk repeated edits, bulk patterns, read-heavy searches, formatting, counting, extraction, or summaries; prefer Luna when the same clear operation repeats at least three times.
31
- Use Sol for security, authentication, authorization, permissions, secrets, destructive actions, financial logic, SQL writes, concurrency, complex state changes, high-regression-risk logic, or an explicit review. Prefer review-only delegation first; when implementation or a confirmed fix is explicitly required, Sol may edit files in workspace-write mode and run relevant checks.
32
- Do not spawn a subagent for a simple question. Use the minimum number of agents and run Luna with Sol only when both independent tasks are required.
33
- `;
34
-
35
- function hash(content) {
36
- return createHash("sha256").update(content).digest("hex");
37
- }
38
-
39
- function paths(options, global) {
40
- const cwd = resolve(options.cwd ?? process.cwd());
41
- const home = resolve(options.home ?? homedir());
42
- const env = options.env ?? process.env;
43
- const codexHome = global ? resolve(env.CODEX_HOME || join(home, ".codex")) : join(cwd, ".codex");
44
- const skillsHome = global ? join(home, ".agents", "skills") : join(cwd, ".agents", "skills");
45
- return {
46
- state: join(codexHome, "model-router-state.json"),
47
- sol: join(codexHome, "agents", "sol.toml"),
48
- skill: join(skillsHome, "model-router", "SKILL.md")
49
- };
50
- }
51
-
52
- async function readText(path) {
53
- try { return await readFile(path, "utf8"); }
54
- catch (error) {
55
- if (error?.code === "ENOENT") return undefined;
56
- throw error;
57
- }
58
- }
59
-
60
- async function atomicWrite(path, content) {
61
- await mkdir(dirname(path), { recursive: true });
62
- let mode = 0o600;
63
- try { mode = (await stat(path)).mode; } catch (error) { if (error?.code !== "ENOENT") throw error; }
64
- const temporary = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
65
- let handle;
66
- try {
67
- handle = await open(temporary, "wx", mode);
68
- await handle.writeFile(content, "utf8");
69
- await handle.sync();
70
- await handle.close();
71
- handle = undefined;
72
- await rename(temporary, path);
73
- } catch (error) {
74
- try { await handle?.close(); } catch {}
75
- try { await rm(temporary, { force: true }); } catch {}
76
- throw error;
77
- }
78
- }
79
-
80
- async function upgradeManagedTemplates(options, global) {
81
- const location = paths(options, global);
82
- const stateText = await readText(location.state);
83
- if (!stateText) return;
84
-
85
- const state = JSON.parse(stateText);
86
- const updates = [];
87
- for (const [name, path, template] of [
88
- ["sol", location.sol, SOL_TEMPLATE],
89
- ["skill", location.skill, SKILL_TEMPLATE]
90
- ]) {
91
- const current = await readText(path);
92
- const tracked = state.files?.[name];
93
- if (!current || !tracked || hash(current) !== tracked.hash || current === template) continue;
94
- updates.push({ name, path, before: current, after: template });
95
- }
96
-
97
- const nextState = structuredClone(state);
98
- nextState.packageVersion = VERSION;
99
- for (const update of updates) nextState.files[update.name].hash = hash(update.after);
100
- const nextStateText = `${JSON.stringify(nextState, null, 2)}\n`;
101
- if (!updates.length && state.packageVersion === VERSION) return;
102
-
103
- const snapshots = new Map([[location.state, stateText]]);
104
- for (const update of updates) snapshots.set(update.path, update.before);
105
- const applied = [];
106
- try {
107
- for (const update of updates) {
108
- await atomicWrite(update.path, update.after);
109
- applied.push(update.path);
110
- }
111
- await atomicWrite(location.state, nextStateText);
112
- applied.push(location.state);
113
- } catch (error) {
114
- for (const path of applied.reverse()) {
115
- try { await atomicWrite(path, snapshots.get(path)); } catch {}
116
- }
117
- throw error;
118
- }
119
- }
120
-
121
- function parseGlobal(argv) {
122
- return argv.includes("--global");
123
- }
124
-
125
- function activeValue(content, key) {
126
- const match = content.match(new RegExp(`^\\s*${key}\\s*=\\s*"([^"]+)"\\s*$`, "m"));
127
- return match?.[1];
128
- }
129
-
130
- async function validateV11(options, global) {
131
- const location = paths(options, global);
132
- const stateText = await readText(location.state);
133
- if (!stateText) return null;
134
-
135
- let state;
136
- try { state = JSON.parse(stateText); }
137
- catch { return { sol: { status: "unsafe-state", detail: "state file is not valid JSON" }, skill: null }; }
138
-
139
- const result = {};
140
- const sol = await readText(location.sol);
141
- if (!sol) result.sol = { status: "missing" };
142
- else if (state.files?.sol?.hash !== hash(sol)) result.sol = { status: "user-modified", detail: "managed file hash changed" };
143
- else if (
144
- activeValue(sol, "name") !== "sol" ||
145
- activeValue(sol, "model") !== "gpt-5.6-sol" ||
146
- activeValue(sol, "model_reasoning_effort") !== "medium" ||
147
- activeValue(sol, "sandbox_mode") !== "workspace-write"
148
- ) result.sol = { status: "invalid", detail: "expected Sol/medium/workspace-write" };
149
- else result.sol = { status: "healthy" };
150
-
151
- const skill = await readText(location.skill);
152
- if (!skill) result.skill = { status: "missing" };
153
- else if (state.files?.skill?.hash !== hash(skill)) result.skill = { status: "user-modified", detail: "managed file hash changed" };
154
- else {
155
- const required = [/^name:\s*model-router\s*$/m, /Terra/i, /Luna/i, /Sol/i, /workspace-write/i, /simple question/i, /minimum number|fewest/i];
156
- result.skill = required.every((pattern) => pattern.test(skill))
157
- ? { status: "healthy" }
158
- : { status: "invalid", detail: "missing required routing rule" };
159
- }
160
- return result;
161
- }
162
-
163
- function format(status, label, detail) {
164
- return `${status}: ${label}${detail ? ` (${detail})` : ""}`;
165
- }
166
-
167
- async function runDoctor(argv, options, output) {
168
- const lines = [];
169
- await runCore(argv, { ...options, output: (line) => lines.push(String(line)) });
170
- if (lines.some((line) => line.startsWith("unsafe-state: state") || line.startsWith("missing: state"))) {
171
- for (const line of lines) output(line);
172
- return 1;
173
- }
174
-
175
- const v11 = await validateV11(options, parseGlobal(argv));
176
- const filtered = lines.filter((line) => !/^(?:healthy|invalid|user-modified|missing): (?:sol|skill)\b/.test(line));
177
- for (const line of filtered) output(line);
178
- if (v11?.sol) output(format(v11.sol.status, "sol", v11.sol.detail));
179
- if (v11?.skill) output(format(v11.skill.status, "skill", v11.skill.detail));
180
-
181
- const statuses = [
182
- ...filtered.map((line) => line.split(":", 1)[0]),
183
- v11?.sol?.status,
184
- v11?.skill?.status
185
- ].filter(Boolean);
186
- return statuses.every((status) => status === "healthy") ? 0 : 1;
187
- }
188
-
189
- export async function run(argv, options = {}) {
190
- const output = options.output ?? console.log;
191
- if (argv.length === 1 && ["--version", "-v"].includes(argv[0])) {
192
- output(VERSION);
193
- return 0;
194
- }
195
- if (argv[0] === "doctor") return runDoctor(argv, options, output);
196
-
197
- const code = await runCore(argv, {
198
- ...options,
199
- output: (line) => output(String(line).replace("codex-model-router 1.0.0", `codex-model-router ${VERSION}`))
200
- });
201
- if (code !== 0 || argv[0] !== "install" || argv.includes("--dry-run")) return code;
202
-
203
- try {
204
- await upgradeManagedTemplates(options, parseGlobal(argv));
205
- return 0;
206
- } catch (error) {
207
- output(`fail: Sol upgrade (${error.message})`);
208
- return 1;
209
- }
210
- }
1
+ export { run, VERSION } from "./router-core.js";
package/lib/toml.js CHANGED
@@ -15,10 +15,6 @@ function consumeNewline(text, index) {
15
15
  return index + 1;
16
16
  }
17
17
 
18
- function countNewline(text, index) {
19
- return text[index] === "\r" && text[index + 1] === "\n" ? 2 : 1;
20
- }
21
-
22
18
  function skipComment(text, index) {
23
19
  while (index < text.length && !isNewline(text, index)) index += 1;
24
20
  return index;
@@ -81,7 +77,7 @@ function parseKey(raw, line) {
81
77
  const segments = [];
82
78
  let index = 0;
83
79
  while (index < raw.length) {
84
- while (index < raw.length && /[ \t]/.test(raw[index])) index += 1;
80
+ while (index < raw.length && /[ ]/.test(raw[index])) index += 1;
85
81
  if (index >= raw.length) throw syntax("empty key", line);
86
82
  let segment;
87
83
  if (raw[index] === '"' || raw[index] === "'") {
@@ -113,7 +109,7 @@ function parseKey(raw, line) {
113
109
  segment = raw.slice(start, index);
114
110
  }
115
111
  segments.push(segment);
116
- while (index < raw.length && /[ \t]/.test(raw[index])) index += 1;
112
+ while (index < raw.length && /[ ]/.test(raw[index])) index += 1;
117
113
  if (index >= raw.length) break;
118
114
  if (raw[index] !== ".") throw syntax("invalid dotted key", line);
119
115
  index += 1;
@@ -134,7 +130,7 @@ function consumeQuoted(text, index, line, quote, multiline) {
134
130
  if (multiline && isNewline(text, index + 1)) {
135
131
  index = consumeNewline(text, index + 1);
136
132
  line += 1;
137
- while (index < text.length && (/[ \t]/.test(text[index]) || isNewline(text, index))) {
133
+ while (index < text.length && (/[ ]/.test(text[index]) || isNewline(text, index))) {
138
134
  if (isNewline(text, index)) { index = consumeNewline(text, index); line += 1; }
139
135
  else index += 1;
140
136
  }
@@ -160,12 +156,13 @@ function consumeQuoted(text, index, line, quote, multiline) {
160
156
  line += 1;
161
157
  } else {
162
158
  const code = text.charCodeAt(index);
163
- if (code < 0x20 && character !== "\t") throw syntax("control character in string", line);
159
+ if (code < 0x20 && character !== " ") throw syntax("control character in string", line);
164
160
  index += 1;
165
161
  }
166
162
  }
167
163
  throw syntax("unterminated string", line);
168
164
  }
165
+
169
166
  function scanValue(text, start, line) {
170
167
  let index = start;
171
168
  let square = 0;
@@ -209,7 +206,7 @@ function scanValue(text, start, line) {
209
206
  curly -= 1;
210
207
  if (curly < 0) throw syntax("unexpected }", line);
211
208
  }
212
- if (!/[ \t]/.test(character)) lastSignificant = index;
209
+ if (!/[ ]/.test(character)) lastSignificant = index;
213
210
  index += 1;
214
211
  }
215
212
 
@@ -222,8 +219,7 @@ function scanValue(text, start, line) {
222
219
  commentStart,
223
220
  lineEnd,
224
221
  statementEnd,
225
- next: statementEnd,
226
- newlines: lineEnd < text.length ? 1 : 0
222
+ next: statementEnd
227
223
  };
228
224
  }
229
225
 
@@ -262,11 +258,11 @@ function scanHeader(text, start, line) {
262
258
  if (!keyRaw) throw syntax("empty table header", line);
263
259
  parseKey(keyRaw, line);
264
260
  index += closing.length;
265
- while (index < text.length && /[ \t]/.test(text[index])) index += 1;
261
+ while (index < text.length && /[ ]/.test(text[index])) index += 1;
266
262
  if (text[index] === "#") index = skipComment(text, index);
267
263
  if (index < text.length && !isNewline(text, index)) throw syntax("unexpected content after table header", line);
268
264
  const next = index < text.length ? consumeNewline(text, index) : index;
269
- return { next, lineStart: start };
265
+ return { next };
270
266
  }
271
267
 
272
268
  function validateValueShape(raw, line) {
@@ -298,7 +294,7 @@ export function scanToml(text) {
298
294
 
299
295
  while (index < text.length) {
300
296
  const physicalLineStart = index;
301
- while (index < text.length && /[ \t]/.test(text[index])) index += 1;
297
+ while (index < text.length && /[ ]/.test(text[index])) index += 1;
302
298
  if (index >= text.length) break;
303
299
  if (isNewline(text, index)) {
304
300
  index = consumeNewline(text, index);
@@ -353,7 +349,7 @@ export function scanToml(text) {
353
349
  const keyRaw = text.slice(keyStart, index).trim();
354
350
  const keySegments = parseKey(keyRaw, line);
355
351
  index += 1;
356
- while (index < text.length && /[ \t]/.test(text[index])) index += 1;
352
+ while (index < text.length && /[ ]/.test(text[index])) index += 1;
357
353
  const valueStart = index;
358
354
  const value = scanValue(text, valueStart, line);
359
355
  const rawValue = text.slice(valueStart, value.valueEnd);
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "codex-model-router",
3
- "version": "1.1.0",
4
- "description": "Safely install a small Codex model-routing configuration.",
3
+ "version": "1.2.0",
4
+ "description": "Safely install Codex subagent 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/router.js && node --check lib/router-core.js && node --check lib/toml.js",
10
+ "check": "node --check bin/codex-model-router.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
11
  "test": "node --test",
12
12
  "test:package": "node scripts/package-smoke.js"
13
13
  },
@@ -18,14 +18,24 @@
18
18
  "bin",
19
19
  "lib",
20
20
  "README.md",
21
+ "SECURITY.md",
22
+ "MAINTAINERS.md",
21
23
  "LICENSE"
22
24
  ],
23
25
  "keywords": [
24
26
  "codex",
27
+ "codex-cli",
25
28
  "openai",
29
+ "model-router",
30
+ "subagent",
26
31
  "subagents",
27
- "skills"
32
+ "agent-routing",
33
+ "skills",
34
+ "terra",
35
+ "luna",
36
+ "sol"
28
37
  ],
38
+ "author": "Honguan",
29
39
  "repository": {
30
40
  "type": "git",
31
41
  "url": "https://github.com/Honguan/codex-model-router.git"
@@ -35,7 +45,8 @@
35
45
  "url": "https://github.com/Honguan/codex-model-router/issues"
36
46
  },
37
47
  "publishConfig": {
38
- "access": "public"
48
+ "access": "public",
49
+ "provenance": true
39
50
  },
40
51
  "license": "MIT"
41
52
  }