codex-model-router 1.1.0 → 2.0.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.
@@ -1,138 +1,103 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import {
3
3
  access,
4
+ lstat,
4
5
  mkdir,
5
6
  open,
6
7
  readFile,
8
+ realpath,
7
9
  rename,
8
10
  rm,
9
11
  rmdir,
10
- stat
12
+ stat,
13
+ writeFile
11
14
  } from "node:fs/promises";
12
- import { homedir } from "node:os";
15
+ import { homedir, hostname } from "node:os";
13
16
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
14
- import { applyEdits, insertionEdit, parseStringValue, removalEdit, scanToml } from "./toml.js";
15
-
16
- export const VERSION = "1.0.0";
17
-
18
- const DEFAULTS = {
19
- model: "gpt-5.6-terra",
20
- model_reasoning_effort: "high"
21
- };
22
-
23
- const TEMPLATES = {
24
- luna: {
25
- relative: ["agents", "luna.toml"],
26
- content: `name = "luna"
27
- description = "Low-risk helper for repeated edits, searches, formatting, extraction, counting, and summaries."
28
- model = "gpt-5.6-luna"
29
- model_reasoning_effort = "high"
30
- developer_instructions = """
31
- Handle only deterministic, low-risk work delegated by Terra.
32
- Follow the assigned pattern exactly and return a concise result.
33
- Escalate ambiguous, security-sensitive, or logic-heavy decisions to Terra.
34
- """
35
- `
36
- },
37
- sol: {
38
- relative: ["agents", "sol.toml"],
39
- content: `name = "sol"
40
- description = "Read-only reviewer for security-sensitive or high-regression-risk logic."
41
- model = "gpt-5.6-sol"
42
- model_reasoning_effort = "medium"
43
- sandbox_mode = "read-only"
44
- developer_instructions = """
45
- Review only. Focus on security, authentication, permissions, secrets, destructive actions, financial logic, SQL writes, concurrency, complex state, and regression risk.
46
- Report concrete findings to Terra; do not apply fixes.
47
- """
48
- `
49
- },
50
- skill: {
51
- content: `---
52
- name: model-router
53
- description: Route Codex work between Terra, Luna, and Sol with the fewest required agents.
54
- ---
55
-
56
- Terra handles ordinary questions, coding, debugging, fixes, testing, and implementation. Never create a Terra subagent.
57
- 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.
58
- Use Sol only as a read-only reviewer for security, authentication, authorization, permissions, secrets, destructive actions, financial logic, SQL writes, concurrency, complex state changes, high-regression-risk logic, or an explicit review. Terra applies fixes.
59
- 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.
60
- `
61
- }
62
- };
63
-
64
- function hash(content) {
65
- return createHash("sha256").update(content).digest("hex");
17
+ import { applyEdits, insertionEdit, removalEdit, scanToml } from "./toml.js";
18
+ import {
19
+ AGENT_EXPECTATIONS,
20
+ AGENT_NAMES,
21
+ DEFAULTS,
22
+ LEGACY_TEMPLATES,
23
+ MANAGED_FILE_NAMES,
24
+ SKILL_EXPECTATIONS,
25
+ TEMPLATES,
26
+ VERSION
27
+ } from "./manifest.js";
28
+
29
+ export { VERSION };
30
+
31
+ const STATE_VERSION = 4;
32
+ const LOCK_FILE = "model-router.lock";
33
+ const JOURNAL_FILE = "model-router-transaction.json";
34
+ const TRANSACTION_DIR = "model-router-transaction-data";
35
+ const TEST_CRASH_ENV = "CODEX_MODEL_ROUTER_TEST_CRASH_AFTER";
36
+ const TEST_HOLD_LOCK_ENV = "CODEX_MODEL_ROUTER_TEST_HOLD_LOCK_MS";
37
+ const TESTING_ENV = "CODEX_MODEL_ROUTER_TESTING";
38
+
39
+ function digest(value) {
40
+ return createHash("sha256").update(value).digest("hex");
66
41
  }
67
42
 
68
43
  function quoteToml(value) {
69
44
  return JSON.stringify(value);
70
45
  }
71
46
 
72
- function expectedPaths({ cwd, home, global, env }) {
73
- const userHome = resolve(home ?? homedir());
74
- const projectRoot = resolve(cwd);
75
- const codexHome = global
76
- ? resolve(env?.CODEX_HOME || join(userHome, ".codex"))
77
- : join(projectRoot, ".codex");
78
- const skillsHome = global
79
- ? join(userHome, ".agents", "skills")
80
- : join(projectRoot, ".agents", "skills");
81
- return {
82
- scope: global ? "global" : "project",
83
- projectRoot,
84
- userHome,
85
- codexHome,
86
- skillsHome,
87
- skillsRoot: dirname(skillsHome),
88
- config: join(codexHome, "config.toml"),
89
- state: join(codexHome, "model-router-state.json"),
90
- backup: join(codexHome, "config.toml.codex-model-router.bak"),
91
- luna: join(codexHome, ...TEMPLATES.luna.relative),
92
- sol: join(codexHome, ...TEMPLATES.sol.relative),
93
- skill: join(skillsHome, "model-router", "SKILL.md")
94
- };
47
+ function normalizeForCompare(path) {
48
+ const value = resolve(path);
49
+ return process.platform === "win32" ? value.toLowerCase() : value;
50
+ }
51
+
52
+ function isWithin(path, root) {
53
+ const value = relative(resolve(root), resolve(path));
54
+ return value === "" || (!value.startsWith(`..${sep}`) && value !== ".." && !isAbsolute(value));
95
55
  }
96
56
 
97
57
  async function pathExists(path) {
98
- try { await access(path); return true; } catch (error) {
58
+ try {
59
+ await access(path);
60
+ return true;
61
+ } catch (error) {
99
62
  if (error?.code === "ENOENT") return false;
100
63
  throw error;
101
64
  }
102
65
  }
103
66
 
104
- async function readText(path) {
67
+ async function readBytes(path) {
105
68
  try {
106
- const bytes = await readFile(path);
107
- const text = bytes.toString("utf8");
108
- if (!Buffer.from(text, "utf8").equals(bytes)) {
109
- const error = new Error(`${path} is not valid UTF-8`);
110
- error.code = "INVALID_UTF8";
111
- throw error;
112
- }
113
- return { exists: true, text };
69
+ return { exists: true, bytes: await readFile(path) };
114
70
  } catch (error) {
115
- if (error?.code === "ENOENT") return { exists: false, text: undefined };
71
+ if (error?.code === "ENOENT") return { exists: false, bytes: null };
116
72
  throw error;
117
73
  }
118
74
  }
119
75
 
120
- async function directoryExists(path) {
121
- try { return (await stat(path)).isDirectory(); } catch (error) {
122
- if (error?.code === "ENOENT") return false;
76
+ async function readText(path) {
77
+ const file = await readBytes(path);
78
+ if (!file.exists) return { exists: false, text: undefined };
79
+ const text = file.bytes.toString("utf8");
80
+ if (!Buffer.from(text, "utf8").equals(file.bytes)) {
81
+ const error = new Error(`${path} is not valid UTF-8`);
82
+ error.code = "INVALID_UTF8";
123
83
  throw error;
124
84
  }
85
+ return { exists: true, text };
125
86
  }
126
87
 
127
- async function atomicWrite(path, content) {
88
+ async function atomicWriteBytes(path, bytes) {
128
89
  await mkdir(dirname(path), { recursive: true });
129
90
  let mode = 0o600;
130
- try { mode = (await stat(path)).mode; } catch (error) { if (error?.code !== "ENOENT") throw error; }
91
+ try {
92
+ mode = (await stat(path)).mode;
93
+ } catch (error) {
94
+ if (error?.code !== "ENOENT") throw error;
95
+ }
131
96
  const temporary = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
132
97
  let handle;
133
98
  try {
134
99
  handle = await open(temporary, "wx", mode);
135
- await handle.writeFile(content, "utf8");
100
+ await handle.writeFile(bytes);
136
101
  await handle.sync();
137
102
  await handle.close();
138
103
  handle = undefined;
@@ -144,22 +109,178 @@ async function atomicWrite(path, content) {
144
109
  }
145
110
  }
146
111
 
147
- function isWithin(path, root) {
148
- const value = relative(resolve(root), resolve(path));
149
- return value === "" || (!value.startsWith(`..${sep}`) && value !== ".." && !isAbsolute(value));
112
+ async function atomicWrite(path, content) {
113
+ return atomicWriteBytes(path, Buffer.from(content, "utf8"));
114
+ }
115
+
116
+ async function directoryExists(path) {
117
+ try {
118
+ return (await stat(path)).isDirectory();
119
+ } catch (error) {
120
+ if (error?.code === "ENOENT") return false;
121
+ throw error;
122
+ }
123
+ }
124
+
125
+ async function nearestExistingAncestor(path) {
126
+ let current = resolve(path);
127
+ while (true) {
128
+ try {
129
+ await lstat(current);
130
+ return current;
131
+ } catch (error) {
132
+ if (error?.code !== "ENOENT") throw error;
133
+ const parent = dirname(current);
134
+ if (parent === current) throw new Error(`no existing ancestor for ${path}`);
135
+ current = parent;
136
+ }
137
+ }
138
+ }
139
+
140
+ async function canonicalizeRoot(path) {
141
+ const absolute = resolve(path);
142
+ const ancestor = await nearestExistingAncestor(absolute);
143
+ const physicalAncestor = await realpath(ancestor);
144
+ return resolve(physicalAncestor, relative(ancestor, absolute));
145
+ }
146
+
147
+ async function canonicalizeScope(scope) {
148
+ return {
149
+ ...scope,
150
+ cwd: await canonicalizeRoot(scope.cwd),
151
+ home: await canonicalizeRoot(scope.home ?? homedir())
152
+ };
153
+ }
154
+
155
+ async function assertNoIndirection(target, root, anchor, label) {
156
+ const absoluteTarget = resolve(target);
157
+ const absoluteRoot = resolve(root);
158
+ const absoluteAnchor = resolve(anchor);
159
+ if (!isWithin(absoluteTarget, absoluteRoot)) {
160
+ throw new Error(`unsafe path for ${label}: ${absoluteTarget} is outside ${absoluteRoot}`);
161
+ }
162
+ if (!isWithin(absoluteTarget, absoluteAnchor)) {
163
+ throw new Error(`unsafe path for ${label}: ${absoluteTarget} is outside anchor ${absoluteAnchor}`);
164
+ }
165
+
166
+ const rel = relative(absoluteAnchor, absoluteTarget);
167
+ const components = rel ? rel.split(sep) : [];
168
+ const physicalAnchor = await realpath(absoluteAnchor);
169
+ let current = absoluteAnchor;
170
+ const inspect = [current];
171
+ for (const component of components) {
172
+ current = join(current, component);
173
+ inspect.push(current);
174
+ }
175
+
176
+ for (let index = 0; index < inspect.length; index += 1) {
177
+ const candidate = inspect[index];
178
+ let info;
179
+ try {
180
+ info = await lstat(candidate);
181
+ } catch (error) {
182
+ if (error?.code === "ENOENT") break;
183
+ throw error;
184
+ }
185
+ if (info.isSymbolicLink()) {
186
+ throw new Error(`unsafe path component for ${label}: ${candidate} is a symbolic link or junction`);
187
+ }
188
+ if (index < inspect.length - 1 && !info.isDirectory()) {
189
+ throw new Error(`unsafe path component for ${label}: ${candidate} is not a directory`);
190
+ }
191
+ const physical = await realpath(candidate);
192
+ const expectedPhysical = resolve(physicalAnchor, relative(absoluteAnchor, candidate));
193
+ if (normalizeForCompare(physical) !== normalizeForCompare(expectedPhysical)) {
194
+ throw new Error(`unsafe path component for ${label}: ${candidate} resolves to ${physical}`);
195
+ }
196
+ }
197
+ }
198
+
199
+ function expectedPaths({ cwd, home, global, env }) {
200
+ const userHome = resolve(home ?? homedir());
201
+ const projectRoot = resolve(cwd);
202
+ const codexHome = global
203
+ ? resolve(env?.CODEX_HOME || join(userHome, ".codex"))
204
+ : join(projectRoot, ".codex");
205
+ const skillsHome = global
206
+ ? join(userHome, ".agents", "skills")
207
+ : join(projectRoot, ".agents", "skills");
208
+ const skillsRoot = dirname(skillsHome);
209
+ return {
210
+ scope: global ? "global" : "project",
211
+ projectRoot,
212
+ userHome,
213
+ codexHome,
214
+ skillsHome,
215
+ skillsRoot,
216
+ config: join(codexHome, "config.toml"),
217
+ state: join(codexHome, "model-router-state.json"),
218
+ backup: join(codexHome, "config.toml.codex-model-router.bak"),
219
+ terra: join(codexHome, ...TEMPLATES.terra.relative),
220
+ luna: join(codexHome, ...TEMPLATES.luna.relative),
221
+ sol: join(codexHome, ...TEMPLATES.sol.relative),
222
+ skill: join(skillsHome, ...TEMPLATES.skill.relative),
223
+ planning: join(skillsHome, ...TEMPLATES.planning.relative),
224
+ lock: join(codexHome, LOCK_FILE),
225
+ journal: join(codexHome, JOURNAL_FILE),
226
+ transactionData: join(codexHome, TRANSACTION_DIR)
227
+ };
228
+ }
229
+
230
+ async function safetyAnchors(location) {
231
+ const codexAnchor = location.scope === "project"
232
+ ? location.projectRoot
233
+ : await nearestExistingAncestor(location.codexHome);
234
+ const skillsAnchor = location.scope === "project"
235
+ ? location.projectRoot
236
+ : location.userHome;
237
+ return { codexAnchor, skillsAnchor };
238
+ }
239
+
240
+ async function validateLocationSafety(location) {
241
+ const { codexAnchor, skillsAnchor } = await safetyAnchors(location);
242
+ const codexTargets = [
243
+ location.codexHome,
244
+ location.config,
245
+ location.state,
246
+ location.backup,
247
+ location.terra,
248
+ location.luna,
249
+ location.sol,
250
+ location.lock,
251
+ location.journal,
252
+ location.transactionData
253
+ ];
254
+ for (const target of codexTargets) {
255
+ await assertNoIndirection(target, location.codexHome, codexAnchor, target);
256
+ }
257
+ const skillsTargets = [location.skillsRoot, location.skillsHome, location.skill, location.planning];
258
+ for (const target of skillsTargets) {
259
+ await assertNoIndirection(target, location.skillsRoot, skillsAnchor, target);
260
+ }
150
261
  }
151
262
 
152
263
  function assertExactPath(actual, expected, label) {
153
- if (resolve(actual) !== resolve(expected)) throw new Error(`unsafe state path for ${label}`);
264
+ if (normalizeForCompare(actual) !== normalizeForCompare(expected)) {
265
+ throw new Error(`unsafe state path for ${label}`);
266
+ }
154
267
  }
155
268
 
156
269
  function freshState(location) {
157
270
  return {
158
- version: 2,
271
+ version: STATE_VERSION,
159
272
  packageVersion: VERSION,
160
273
  scope: location.scope,
161
- roots: { codex: location.codexHome, skills: location.skillsHome, skillsRoot: location.skillsRoot },
162
- config: { path: location.config, createdFile: false, values: {} },
274
+ roots: {
275
+ codex: location.codexHome,
276
+ skills: location.skillsHome,
277
+ skillsRoot: location.skillsRoot
278
+ },
279
+ config: {
280
+ path: location.config,
281
+ createdFile: false,
282
+ values: {}
283
+ },
163
284
  files: {},
164
285
  backup: null,
165
286
  createdDirs: []
@@ -170,16 +291,17 @@ function normalizeLegacyState(raw, location) {
170
291
  const state = freshState(location);
171
292
  if (!raw?.config || !raw?.files) throw new Error("invalid legacy state structure");
172
293
  assertExactPath(raw.config.path, location.config, "config");
173
- state.config.values = {};
174
294
  for (const [key, item] of Object.entries(raw.config.values || {})) {
175
- if (!Object.hasOwn(DEFAULTS, key) || item?.installed !== DEFAULTS[key]) throw new Error(`invalid legacy state value: ${key}`);
295
+ if (!Object.hasOwn(DEFAULTS, key) || item?.installed !== DEFAULTS[key]) {
296
+ throw new Error(`invalid legacy state value: ${key}`);
297
+ }
176
298
  state.config.values[key] = {
177
299
  installed: item.installed,
178
300
  previousRaw: item.previous == null ? null : quoteToml(String(item.previous)),
179
301
  source: item.previous == null ? "inserted" : "replaced"
180
302
  };
181
303
  }
182
- for (const name of ["luna", "sol", "skill"]) {
304
+ for (const name of MANAGED_FILE_NAMES) {
183
305
  const item = raw.files[name];
184
306
  if (!item) continue;
185
307
  assertExactPath(item.path, location[name], name);
@@ -194,18 +316,26 @@ function normalizeLegacyState(raw, location) {
194
316
 
195
317
  function validateState(raw, location) {
196
318
  if (!raw || typeof raw !== "object") throw new Error("state is not an object");
197
- const state = raw.version ? structuredClone(raw) : normalizeLegacyState(raw, location);
198
- if (state.version !== 2) throw new Error(`unsupported state version: ${state.version}`);
319
+ let state;
320
+ if (!raw.version) state = normalizeLegacyState(raw, location);
321
+ else if ([2, 3, STATE_VERSION].includes(raw.version)) state = structuredClone(raw);
322
+ else throw new Error(`unsupported state version: ${raw.version}`);
323
+
324
+ state.version = STATE_VERSION;
199
325
  if (state.scope !== location.scope) throw new Error("state scope does not match this command");
200
326
  assertExactPath(state.roots?.codex, location.codexHome, "Codex root");
201
327
  assertExactPath(state.roots?.skills, location.skillsHome, "skills root");
202
328
  assertExactPath(state.roots?.skillsRoot ?? dirname(state.roots?.skills || ""), location.skillsRoot, "skills parent root");
203
329
  assertExactPath(state.config?.path, location.config, "config");
204
- if (typeof state.config.createdFile !== "boolean" || typeof state.config.values !== "object") throw new Error("invalid config state");
330
+ if (typeof state.config.createdFile !== "boolean" || typeof state.config.values !== "object") {
331
+ throw new Error("invalid config state");
332
+ }
205
333
  for (const [key, item] of Object.entries(state.config.values)) {
206
334
  if (!Object.hasOwn(DEFAULTS, key)) throw new Error(`unknown managed config key: ${key}`);
207
335
  if (item?.installed !== DEFAULTS[key]) throw new Error(`invalid installed value for ${key}`);
208
- if (!(item.previousRaw === null || typeof item.previousRaw === "string")) throw new Error(`invalid previous value for ${key}`);
336
+ if (!(item.previousRaw === null || typeof item.previousRaw === "string")) {
337
+ throw new Error(`invalid previous value for ${key}`);
338
+ }
209
339
  }
210
340
  state.files ||= {};
211
341
  for (const [name, item] of Object.entries(state.files)) {
@@ -231,7 +361,11 @@ async function loadState(location) {
231
361
  const file = await readText(location.state);
232
362
  if (!file.exists) return { exists: false, state: freshState(location) };
233
363
  let raw;
234
- try { raw = JSON.parse(file.text); } catch { throw new Error("state file is not valid JSON"); }
364
+ try {
365
+ raw = JSON.parse(file.text);
366
+ } catch {
367
+ throw new Error("state file is not valid JSON");
368
+ }
235
369
  return { exists: true, state: validateState(raw, location) };
236
370
  }
237
371
 
@@ -256,7 +390,7 @@ function addMessage(plan, status, label, detail) {
256
390
  }
257
391
 
258
392
  function addWrite(plan, path, content, status, label) {
259
- plan.operations.push({ kind: "write", path, content });
393
+ plan.operations.push({ kind: "write", path, content: Buffer.from(content, "utf8") });
260
394
  addMessage(plan, status, label);
261
395
  }
262
396
 
@@ -282,14 +416,18 @@ function stateJson(state) {
282
416
  async function missingDirectories(location) {
283
417
  const candidates = [
284
418
  location.codexHome,
419
+ dirname(location.terra),
285
420
  dirname(location.luna),
286
421
  location.skillsRoot,
287
422
  location.skillsHome,
288
- dirname(location.skill)
423
+ dirname(location.skill),
424
+ dirname(location.planning)
289
425
  ];
290
426
  const unique = [...new Set(candidates.map((value) => resolve(value)))].sort((a, b) => a.length - b.length);
291
427
  const missing = [];
292
- for (const directory of unique) if (!(await directoryExists(directory))) missing.push(directory);
428
+ for (const directory of unique) {
429
+ if (!(await directoryExists(directory))) missing.push(directory);
430
+ }
293
431
  return missing;
294
432
  }
295
433
 
@@ -299,33 +437,57 @@ function patchInstallConfig(original, state, setDefault) {
299
437
  const additions = [];
300
438
  const nextValues = structuredClone(state.config.values || {});
301
439
 
440
+ if (!setDefault) {
441
+ for (const [key, item] of Object.entries(nextValues)) {
442
+ const assignment = scan.targets.get(key);
443
+ if (!assignment) {
444
+ delete nextValues[key];
445
+ continue;
446
+ }
447
+ if (assignment.parsedValue.kind !== "string") {
448
+ throw new Error(`${key} must be a TOML string`);
449
+ }
450
+ if (semanticValue(assignment) === item.installed) {
451
+ if (item.previousRaw === null) edits.push(removalEdit(assignment));
452
+ else edits.push({ start: assignment.valueStart, end: assignment.valueEnd, text: item.previousRaw });
453
+ }
454
+ delete nextValues[key];
455
+ }
456
+ return {
457
+ content: edits.length ? applyEdits(original, edits) : original,
458
+ values: nextValues,
459
+ changed: edits.length > 0
460
+ };
461
+ }
462
+
302
463
  for (const [key, installed] of Object.entries(DEFAULTS)) {
303
464
  const assignment = scan.targets.get(key);
304
465
  const tracked = nextValues[key];
305
- if (assignment && assignment.parsedValue.kind !== "string") throw new Error(`${key} must be a TOML string`);
466
+ if (assignment && assignment.parsedValue.kind !== "string") {
467
+ throw new Error(`${key} must be a TOML string`);
468
+ }
306
469
  const current = semanticValue(assignment);
307
-
308
470
  if (!assignment) {
309
471
  additions.push(`${key} = ${quoteToml(installed)}`);
310
472
  nextValues[key] = tracked || { installed, previousRaw: null, source: "inserted" };
311
473
  continue;
312
474
  }
313
-
314
- if (!setDefault) {
315
- if (tracked && current !== installed) {
316
- // Keep ownership metadata so doctor can identify the later user edit.
317
- }
318
- continue;
319
- }
320
-
321
475
  if (current === installed) continue;
322
476
  edits.push({ start: assignment.valueStart, end: assignment.valueEnd, text: quoteToml(installed) });
323
- nextValues[key] = tracked || { installed, previousRaw: assignment.rawValue, source: "replaced" };
477
+ nextValues[key] = tracked || {
478
+ installed,
479
+ previousRaw: assignment.rawValue,
480
+ source: "replaced"
481
+ };
324
482
  }
325
483
 
326
484
  const insert = insertionEdit(original, scan, additions);
327
485
  if (insert) edits.push(insert);
328
- return { content: edits.length ? applyEdits(original, edits) : original, values: nextValues, changed: edits.length > 0 };
486
+ return {
487
+ content: edits.length ? applyEdits(original, edits) : original,
488
+ values: nextValues,
489
+ changed: edits.length > 0
490
+ };
329
491
  }
330
492
 
331
493
  function patchUninstallConfig(original, stateValues) {
@@ -342,20 +504,33 @@ function patchUninstallConfig(original, stateValues) {
342
504
  if (item.previousRaw === null) edits.push(removalEdit(assignment));
343
505
  else edits.push({ start: assignment.valueStart, end: assignment.valueEnd, text: item.previousRaw });
344
506
  }
345
- return { content: edits.length ? applyEdits(original, edits) : original, changed: edits.length > 0, preserved };
507
+ return {
508
+ content: edits.length ? applyEdits(original, edits) : original,
509
+ changed: edits.length > 0,
510
+ preserved
511
+ };
346
512
  }
347
513
 
348
514
  async function planInstall(scope, flags) {
349
515
  const location = expectedPaths(scope);
350
516
  const plan = { command: "install", location, operations: [], messages: [], failed: false };
351
517
  let loaded;
352
- try { loaded = await loadState(location); } catch (error) { failPlan(plan, error.message); return plan; }
518
+ try {
519
+ loaded = await loadState(location);
520
+ } catch (error) {
521
+ failPlan(plan, error.message);
522
+ return plan;
523
+ }
353
524
  const state = loaded.state;
354
525
  const config = await readText(location.config);
355
526
  const original = config.exists ? config.text : "";
356
527
  let patched;
357
- try { patched = patchInstallConfig(original, state, flags.setDefault); }
358
- catch (error) { failPlan(plan, error.message); return plan; }
528
+ try {
529
+ patched = patchInstallConfig(original, state, flags.setDefault);
530
+ } catch (error) {
531
+ failPlan(plan, error.message);
532
+ return plan;
533
+ }
359
534
 
360
535
  const createdDirs = await missingDirectories(location);
361
536
  plan.createdDirs = createdDirs;
@@ -364,34 +539,69 @@ async function planInstall(scope, flags) {
364
539
  state.config.values = patched.values;
365
540
 
366
541
  if (patched.changed) {
367
- if (!state.backup && config.exists) {
368
- const backup = await readText(location.backup);
369
- if (backup.exists) {
370
- failPlan(plan, `untracked backup already exists: ${location.backup}`);
371
- return plan;
542
+ const empty = patched.content.replace(/^\uFEFF/, "").trim() === "";
543
+ if (!flags.setDefault && state.config.createdFile && empty) {
544
+ addDelete(plan, location.config, "config.toml");
545
+ state.config.createdFile = false;
546
+ } else {
547
+ if (flags.setDefault && !state.backup && config.exists) {
548
+ const backup = await readText(location.backup);
549
+ if (backup.exists) {
550
+ failPlan(plan, `untracked backup already exists: ${location.backup}`);
551
+ return plan;
552
+ }
553
+ state.backup = { path: location.backup, hash: digest(Buffer.from(original, "utf8")) };
554
+ addWrite(plan, location.backup, original, "create", "config backup");
372
555
  }
373
- state.backup = { path: location.backup, hash: hash(original) };
374
- addWrite(plan, location.backup, original, "create", "config backup");
556
+ addWrite(plan, location.config, patched.content, config.exists ? "update" : "create", "config.toml");
375
557
  }
376
- addWrite(plan, location.config, patched.content, config.exists ? "update" : "create", "config.toml");
377
558
  } else {
378
- addMessage(plan, "preserve", "config.toml", flags.setDefault ? "already matches or contains protected values" : "existing defaults preserved");
559
+ addMessage(
560
+ plan,
561
+ "preserve",
562
+ "config.toml",
563
+ flags.setDefault ? "already matches or contains protected values" : "primary model preserved"
564
+ );
379
565
  }
380
566
 
381
- for (const name of ["luna", "sol", "skill"]) {
567
+ if (!flags.setDefault && Object.keys(state.config.values).length === 0 && state.backup) {
568
+ const backup = await readText(location.backup);
569
+ if (!backup.exists) addMessage(plan, "skip", "config backup", "already missing");
570
+ else if (digest(Buffer.from(backup.text, "utf8")) === state.backup.hash) {
571
+ addDelete(plan, location.backup, "config backup");
572
+ } else {
573
+ addMessage(plan, "preserve", "config backup", "user-modified");
574
+ }
575
+ state.backup = null;
576
+ }
577
+
578
+ for (const name of MANAGED_FILE_NAMES) {
382
579
  const current = await readText(location[name]);
383
580
  const tracked = state.files[name];
384
581
  const template = TEMPLATES[name].content;
582
+
385
583
  if (!current.exists) {
386
- state.files[name] = { path: location[name], hash: hash(template) };
584
+ state.files[name] = { path: location[name], hash: digest(Buffer.from(template, "utf8")) };
387
585
  addWrite(plan, location[name], template, "create", name);
388
- } else if (tracked && hash(current.text) === tracked.hash) {
389
- addMessage(plan, "skip", name, "already managed");
586
+ continue;
587
+ }
588
+
589
+ const currentHash = digest(Buffer.from(current.text, "utf8"));
590
+ if (tracked && currentHash === tracked.hash) {
591
+ if (current.text === template) {
592
+ addMessage(plan, "skip", name, "already managed");
593
+ } else if ((LEGACY_TEMPLATES[name] || []).includes(current.text)) {
594
+ state.files[name].hash = digest(Buffer.from(template, "utf8"));
595
+ addWrite(plan, location[name], template, "update", `${name} migration`);
596
+ } else {
597
+ addMessage(plan, "preserve", name, "managed content is not a recognized package template");
598
+ }
390
599
  } else {
391
600
  addMessage(plan, "preserve", name, tracked ? "user-modified" : "pre-existing");
392
601
  }
393
602
  }
394
603
 
604
+ state.version = STATE_VERSION;
395
605
  state.packageVersion = VERSION;
396
606
  const existingState = await readText(location.state);
397
607
  addWrite(plan, location.state, stateJson(state), existingState.exists ? "update" : "create", "state");
@@ -402,17 +612,27 @@ async function planUninstall(scope) {
402
612
  const location = expectedPaths(scope);
403
613
  const plan = { command: "uninstall", location, operations: [], messages: [], failed: false };
404
614
  let loaded;
405
- try { loaded = await loadState(location); } catch (error) { failPlan(plan, error.message); return plan; }
615
+ try {
616
+ loaded = await loadState(location);
617
+ } catch (error) {
618
+ failPlan(plan, error.message);
619
+ return plan;
620
+ }
406
621
  if (!loaded.exists) {
407
622
  addMessage(plan, "skip", "state", "not installed");
408
623
  return plan;
409
624
  }
410
625
  const state = loaded.state;
411
626
  const config = await readText(location.config);
627
+
412
628
  if (Object.keys(state.config.values || {}).length && config.exists) {
413
629
  let patched;
414
- try { patched = patchUninstallConfig(config.text, state.config.values); }
415
- catch (error) { failPlan(plan, error.message); return plan; }
630
+ try {
631
+ patched = patchUninstallConfig(config.text, state.config.values);
632
+ } catch (error) {
633
+ failPlan(plan, error.message);
634
+ return plan;
635
+ }
416
636
  for (const key of patched.preserved) addMessage(plan, "preserve", key, "user-modified");
417
637
  if (patched.changed) {
418
638
  const empty = patched.content.replace(/^\uFEFF/, "").trim() === "";
@@ -426,73 +646,397 @@ async function planUninstall(scope) {
426
646
  }
427
647
  state.config.values = {};
428
648
 
429
- for (const name of ["luna", "sol", "skill"]) {
649
+ for (const name of MANAGED_FILE_NAMES) {
430
650
  const tracked = state.files[name];
431
651
  if (!tracked) continue;
432
652
  const current = await readText(location[name]);
433
- if (!current.exists) {
434
- addMessage(plan, "skip", name, "already missing");
435
- } else if (hash(current.text) === tracked.hash) {
436
- addDelete(plan, location[name], name);
437
- } else {
438
- addMessage(plan, "preserve", name, "user-modified");
439
- }
653
+ if (!current.exists) addMessage(plan, "skip", name, "already missing");
654
+ else if (digest(Buffer.from(current.text, "utf8")) === tracked.hash) addDelete(plan, location[name], name);
655
+ else addMessage(plan, "preserve", name, "user-modified");
440
656
  delete state.files[name];
441
657
  }
442
658
 
443
659
  if (state.backup) {
444
660
  const backup = await readText(location.backup);
445
661
  if (!backup.exists) addMessage(plan, "skip", "config backup", "already missing");
446
- else if (hash(backup.text) === state.backup.hash) addDelete(plan, location.backup, "config backup");
447
- else addMessage(plan, "preserve", "config backup", "user-modified");
662
+ else if (digest(Buffer.from(backup.text, "utf8")) === state.backup.hash) {
663
+ addDelete(plan, location.backup, "config backup");
664
+ } else {
665
+ addMessage(plan, "preserve", "config backup", "user-modified");
666
+ }
448
667
  state.backup = null;
449
668
  }
450
669
 
451
670
  addDelete(plan, location.state, "state");
452
- for (const directory of [...state.createdDirs].sort((a, b) => b.length - a.length)) addRemoveDirectory(plan, directory);
453
- state.createdDirs = [];
671
+ for (const directory of [...state.createdDirs].sort((a, b) => b.length - a.length)) {
672
+ addRemoveDirectory(plan, directory);
673
+ }
454
674
  return plan;
455
675
  }
456
676
 
457
- async function snapshotPath(path) {
458
- const current = await readText(path);
459
- return current.exists ? { exists: true, text: current.text } : { exists: false };
677
+ function signatureFromBytes(file) {
678
+ return file.exists
679
+ ? { exists: true, hash: digest(file.bytes) }
680
+ : { exists: false, hash: null };
460
681
  }
461
682
 
462
- async function executePlan(plan) {
463
- if (plan.failed) return { ok: false };
464
- const mutable = plan.operations.filter((operation) => operation.kind !== "rmdir");
465
- const snapshots = new Map();
466
- const applied = [];
683
+ async function signature(path) {
684
+ return signatureFromBytes(await readBytes(path));
685
+ }
686
+
687
+ function sameSignature(left, right) {
688
+ return Boolean(left?.exists) === Boolean(right?.exists) &&
689
+ (!left?.exists || left.hash === right.hash);
690
+ }
691
+
692
+ async function inspectLock(location) {
693
+ const lock = await readText(location.lock);
694
+ if (!lock.exists) return { status: "none" };
695
+ let metadata;
696
+ try {
697
+ metadata = JSON.parse(lock.text);
698
+ } catch {
699
+ return { status: "corrupt", detail: "lock file is not valid JSON" };
700
+ }
701
+ if (!Number.isInteger(metadata.pid) || metadata.pid <= 0 || typeof metadata.hostname !== "string") {
702
+ return { status: "corrupt", detail: "lock metadata is invalid" };
703
+ }
704
+ if (metadata.hostname !== hostname()) {
705
+ return { status: "active", metadata, detail: "lock belongs to another host" };
706
+ }
707
+ let alive = true;
467
708
  try {
468
- for (const operation of mutable) {
469
- if (!snapshots.has(operation.path)) snapshots.set(operation.path, await snapshotPath(operation.path));
470
- }
471
- for (const operation of plan.operations) {
472
- if (operation.kind === "write") await atomicWrite(operation.path, operation.content);
473
- else if (operation.kind === "delete") await rm(operation.path, { force: true });
474
- else if (operation.kind === "rmdir") {
475
- try { await rmdir(operation.path); } catch (error) {
476
- if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error?.code)) throw error;
709
+ process.kill(metadata.pid, 0);
710
+ } catch (error) {
711
+ if (["ESRCH", "EINVAL"].includes(error?.code)) alive = false;
712
+ else if (error?.code !== "EPERM") throw error;
713
+ }
714
+ return alive
715
+ ? { status: "active", metadata }
716
+ : { status: "stale", metadata };
717
+ }
718
+
719
+ async function acquireLock(location, command, output, env) {
720
+ let createdRoot = false;
721
+ if (!(await directoryExists(location.codexHome))) {
722
+ await mkdir(location.codexHome, { recursive: true });
723
+ createdRoot = true;
724
+ }
725
+ await validateLocationSafety(location);
726
+
727
+ for (let attempt = 0; attempt < 2; attempt += 1) {
728
+ const token = randomUUID();
729
+ let handle;
730
+ try {
731
+ handle = await open(location.lock, "wx", 0o600);
732
+ const metadata = {
733
+ version: 1,
734
+ token,
735
+ pid: process.pid,
736
+ hostname: hostname(),
737
+ command,
738
+ scope: location.scope,
739
+ startedAt: new Date().toISOString()
740
+ };
741
+ await handle.writeFile(`${JSON.stringify(metadata, null, 2)}\n`, "utf8");
742
+ await handle.sync();
743
+ await handle.close();
744
+ handle = undefined;
745
+ const hold = Number.parseInt(env?.[TEST_HOLD_LOCK_ENV] || "", 10);
746
+ if (env?.[TESTING_ENV] === "1" && Number.isFinite(hold) && hold > 0) {
747
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, hold));
748
+ }
749
+ return { token, createdRoot, command };
750
+ } catch (error) {
751
+ try { await handle?.close(); } catch {}
752
+ if (error?.code !== "EEXIST") throw error;
753
+ const lock = await inspectLock(location);
754
+ if (lock.status === "stale") {
755
+ const before = await readText(location.lock);
756
+ const again = await readText(location.lock);
757
+ if (before.exists && again.exists && before.text === again.text) {
758
+ await rm(location.lock, { force: true });
759
+ output?.(`recover: stale lock (${lock.metadata.command || "unknown"} pid ${lock.metadata.pid})`);
760
+ continue;
477
761
  }
478
762
  }
479
- applied.push(operation);
763
+ const detail = lock.metadata
764
+ ? `${lock.metadata.command || "operation"} pid ${lock.metadata.pid} since ${lock.metadata.startedAt || "unknown"}`
765
+ : lock.detail || "unknown lock owner";
766
+ throw new Error(`scope is locked by ${detail}`);
480
767
  }
481
- return { ok: true };
768
+ }
769
+ throw new Error("unable to acquire scope lock");
770
+ }
771
+
772
+ async function releaseLock(location, lock) {
773
+ if (!lock) return;
774
+ try {
775
+ await validateLocationSafety(location);
776
+ const current = await readText(location.lock);
777
+ if (!current.exists) return;
778
+ const metadata = JSON.parse(current.text);
779
+ if (metadata.token === lock.token) await rm(location.lock, { force: true });
780
+ } catch {}
781
+ if (lock.createdRoot || lock.command === "uninstall") {
782
+ try { await rmdir(location.codexHome); } catch {}
783
+ }
784
+ }
785
+
786
+ function operationAfterSignature(operation) {
787
+ if (operation.kind === "write") {
788
+ return { exists: true, hash: digest(operation.content) };
789
+ }
790
+ if (operation.kind === "delete") {
791
+ return { exists: false, hash: null };
792
+ }
793
+ throw new Error(`unknown operation kind: ${operation.kind}`);
794
+ }
795
+
796
+ async function validateJournal(raw, location) {
797
+ if (!raw || typeof raw !== "object" || raw.version !== 1) {
798
+ throw new Error("unsupported transaction journal");
799
+ }
800
+ if (raw.scope !== location.scope || typeof raw.id !== "string" || !Array.isArray(raw.operations)) {
801
+ throw new Error("invalid transaction journal structure");
802
+ }
803
+ if (!["preparing", "ready", "applying"].includes(raw.status)) {
804
+ throw new Error("invalid transaction status");
805
+ }
806
+ for (let index = 0; index < raw.operations.length; index += 1) {
807
+ const operation = raw.operations[index];
808
+ if (!["write", "delete"].includes(operation.kind)) {
809
+ throw new Error(`invalid transaction operation ${index}`);
810
+ }
811
+ if (typeof operation.path !== "string" || !isAbsolute(operation.path)) {
812
+ throw new Error(`invalid transaction path ${index}`);
813
+ }
814
+ const expected = isWithin(operation.path, location.skillsRoot)
815
+ ? location.skillsRoot
816
+ : location.codexHome;
817
+ if (!isWithin(operation.path, expected)) throw new Error(`unsafe transaction path: ${operation.path}`);
818
+ if (!operation.before || typeof operation.before.exists !== "boolean") {
819
+ throw new Error(`invalid transaction snapshot ${index}`);
820
+ }
821
+ if (operation.before.exists && !/^[a-f0-9]{64}$/.test(operation.before.hash || "")) {
822
+ throw new Error(`invalid transaction hash ${index}`);
823
+ }
824
+ if (operation.before.exists && !operation.snapshot) {
825
+ throw new Error(`missing transaction snapshot ${index}`);
826
+ }
827
+ if (operation.snapshot) {
828
+ if (!/^[0-9]{4}\.snapshot$/.test(operation.snapshot)) {
829
+ throw new Error(`invalid transaction snapshot name ${index}`);
830
+ }
831
+ const snapshotRoot = join(location.transactionData, raw.id);
832
+ const snapshot = join(snapshotRoot, operation.snapshot);
833
+ if (!isWithin(snapshot, snapshotRoot)) throw new Error(`unsafe transaction snapshot ${index}`);
834
+ await assertNoIndirection(snapshot, snapshotRoot, location.codexHome, `transaction snapshot ${index}`);
835
+ const snapshotFile = await readBytes(snapshot);
836
+ if (!snapshotFile.exists || digest(snapshotFile.bytes) !== operation.before.hash) {
837
+ throw new Error(`corrupt transaction snapshot ${index}`);
838
+ }
839
+ }
840
+ if (!operation.after || typeof operation.after.exists !== "boolean") {
841
+ throw new Error(`invalid transaction target ${index}`);
842
+ }
843
+ if (operation.after.exists && !/^[a-f0-9]{64}$/.test(operation.after.hash || "")) {
844
+ throw new Error(`invalid transaction target hash ${index}`);
845
+ }
846
+ }
847
+ return raw;
848
+ }
849
+
850
+ async function readJournal(location) {
851
+ const file = await readText(location.journal);
852
+ if (!file.exists) return { exists: false };
853
+ let raw;
854
+ try {
855
+ raw = JSON.parse(file.text);
856
+ } catch {
857
+ return { exists: true, classification: "corrupt", detail: "journal is not valid JSON" };
858
+ }
859
+ try {
860
+ const journal = await validateJournal(raw, location);
861
+ return { exists: true, journal };
482
862
  } catch (error) {
483
- for (const operation of [...applied].reverse()) {
484
- if (operation.kind === "rmdir") {
485
- try { await mkdir(operation.path, { recursive: true }); } catch {}
486
- continue;
863
+ return { exists: true, classification: "corrupt", detail: error.message };
864
+ }
865
+ }
866
+
867
+ async function classifyJournal(location) {
868
+ const loaded = await readJournal(location);
869
+ if (!loaded.exists || loaded.classification === "corrupt") return loaded;
870
+ const journal = loaded.journal;
871
+ if (journal.status === "preparing") {
872
+ return {
873
+ exists: true,
874
+ classification: "unfinished",
875
+ detail: "preparation was interrupted before managed writes",
876
+ journal
877
+ };
878
+ }
879
+ const states = [];
880
+ for (const operation of journal.operations) {
881
+ const current = await signature(operation.path);
882
+ if (sameSignature(current, operation.before)) states.push("before");
883
+ else if (sameSignature(current, operation.after)) states.push("after");
884
+ else states.push("conflict");
885
+ }
886
+ const conflictIndex = states.indexOf("conflict");
887
+ if (conflictIndex >= 0) {
888
+ return {
889
+ exists: true,
890
+ classification: "conflicting",
891
+ detail: `managed path changed after interruption: ${journal.operations[conflictIndex].path}`,
892
+ journal,
893
+ states
894
+ };
895
+ }
896
+ return {
897
+ exists: true,
898
+ classification: "recoverable",
899
+ detail: `${states.filter((state) => state === "after").length} applied operation(s) can be rolled back`,
900
+ journal,
901
+ states
902
+ };
903
+ }
904
+
905
+ async function cleanupTransaction(location, journal) {
906
+ if (journal?.id) await rm(join(location.transactionData, journal.id), { recursive: true, force: true });
907
+ await rm(location.journal, { force: true });
908
+ try { await rmdir(location.transactionData); } catch {}
909
+ }
910
+
911
+ async function recoverTransaction(location, output) {
912
+ const classified = await classifyJournal(location);
913
+ if (!classified.exists) return;
914
+ if (classified.classification === "corrupt") {
915
+ throw new Error(`corrupt transaction: ${classified.detail}`);
916
+ }
917
+ if (classified.classification === "conflicting") {
918
+ throw new Error(`conflicting transaction: ${classified.detail}`);
919
+ }
920
+ const journal = classified.journal;
921
+ if (classified.classification === "unfinished") {
922
+ await cleanupTransaction(location, journal);
923
+ output?.("recover: unfinished transaction (no managed writes were applied)");
924
+ return;
925
+ }
926
+
927
+ for (let index = journal.operations.length - 1; index >= 0; index -= 1) {
928
+ await validateLocationSafety(location);
929
+ const operation = journal.operations[index];
930
+ const current = await signature(operation.path);
931
+ if (sameSignature(current, operation.before)) continue;
932
+ if (!sameSignature(current, operation.after)) {
933
+ throw new Error(`conflicting transaction during recovery: ${operation.path}`);
934
+ }
935
+ if (!operation.before.exists) {
936
+ await rm(operation.path, { force: true });
937
+ continue;
938
+ }
939
+ const snapshot = join(location.transactionData, journal.id, operation.snapshot);
940
+ const bytes = await readFile(snapshot);
941
+ if (digest(bytes) !== operation.before.hash) {
942
+ throw new Error(`corrupt transaction snapshot: ${operation.path}`);
943
+ }
944
+ await atomicWriteBytes(operation.path, bytes);
945
+ }
946
+ await cleanupTransaction(location, journal);
947
+ output?.("recover: interrupted transaction rolled back");
948
+ }
949
+
950
+ async function prepareJournal(plan) {
951
+ const id = randomUUID();
952
+ const dataRoot = join(plan.location.transactionData, id);
953
+ const operations = [];
954
+ const journal = {
955
+ version: 1,
956
+ id,
957
+ scope: plan.location.scope,
958
+ command: plan.command,
959
+ createdAt: new Date().toISOString(),
960
+ status: "preparing",
961
+ recovery: "rollback",
962
+ operations
963
+ };
964
+ await atomicWrite(plan.location.journal, `${JSON.stringify(journal, null, 2)}\n`);
965
+ await mkdir(dataRoot, { recursive: true });
966
+
967
+ const transactionalOperations = plan.operations.filter((operation) => operation.kind !== "rmdir");
968
+ for (let index = 0; index < transactionalOperations.length; index += 1) {
969
+ const operation = transactionalOperations[index];
970
+ let before;
971
+ let snapshot = null;
972
+ const current = await readBytes(operation.path);
973
+ before = signatureFromBytes(current);
974
+ if (current.exists) {
975
+ snapshot = `${String(index).padStart(4, "0")}.snapshot`;
976
+ const snapshotPath = join(dataRoot, snapshot);
977
+ await writeFile(snapshotPath, current.bytes, { mode: 0o600, flag: "wx" });
978
+ }
979
+ operations.push({
980
+ kind: operation.kind,
981
+ path: operation.path,
982
+ label: operation.label || "",
983
+ before,
984
+ after: operationAfterSignature(operation),
985
+ snapshot,
986
+ done: false
987
+ });
988
+ await atomicWrite(plan.location.journal, `${JSON.stringify(journal, null, 2)}\n`);
989
+ }
990
+ journal.status = "ready";
991
+ await atomicWrite(plan.location.journal, `${JSON.stringify(journal, null, 2)}\n`);
992
+ return journal;
993
+ }
994
+
995
+ async function applyOperation(operation) {
996
+ if (operation.kind === "write") await atomicWriteBytes(operation.path, operation.content);
997
+ else if (operation.kind === "delete") await rm(operation.path, { force: true });
998
+ else if (operation.kind === "rmdir") {
999
+ try {
1000
+ await rmdir(operation.path);
1001
+ } catch (error) {
1002
+ if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error?.code)) throw error;
1003
+ }
1004
+ } else {
1005
+ throw new Error(`unknown operation kind: ${operation.kind}`);
1006
+ }
1007
+ }
1008
+
1009
+ async function executePlan(plan, env) {
1010
+ if (plan.failed) return { ok: false };
1011
+ if (!plan.operations.length) return { ok: true };
1012
+ let journal;
1013
+ try {
1014
+ const transactionalOperations = plan.operations.filter((operation) => operation.kind !== "rmdir");
1015
+ const directoryOperations = plan.operations.filter((operation) => operation.kind === "rmdir");
1016
+ if (transactionalOperations.length) {
1017
+ journal = await prepareJournal(plan);
1018
+ journal.status = "applying";
1019
+ await atomicWrite(plan.location.journal, `${JSON.stringify(journal, null, 2)}\n`);
1020
+ for (let index = 0; index < transactionalOperations.length; index += 1) {
1021
+ await validateLocationSafety(plan.location);
1022
+ await applyOperation(transactionalOperations[index]);
1023
+ journal.operations[index].done = true;
1024
+ await atomicWrite(plan.location.journal, `${JSON.stringify(journal, null, 2)}\n`);
1025
+ const crashAfter = Number.parseInt(env?.[TEST_CRASH_ENV] || "", 10);
1026
+ if (env?.[TESTING_ENV] === "1" && crashAfter === index + 1) process.exit(91);
487
1027
  }
488
- const snapshot = snapshots.get(operation.path);
489
- try {
490
- if (snapshot?.exists) await atomicWrite(operation.path, snapshot.text);
491
- else await rm(operation.path, { force: true });
492
- } catch {}
1028
+ await cleanupTransaction(plan.location, journal);
1029
+ }
1030
+ for (const operation of directoryOperations) {
1031
+ await validateLocationSafety(plan.location);
1032
+ await applyOperation(operation);
493
1033
  }
494
- for (const directory of [...(plan.createdDirs || [])].sort((a, b) => b.length - a.length)) {
495
- try { await rmdir(directory); } catch {}
1034
+ return { ok: true };
1035
+ } catch (error) {
1036
+ try {
1037
+ await recoverTransaction(plan.location);
1038
+ } catch (recoveryError) {
1039
+ return { ok: false, error: new Error(`${error.message}; recovery failed: ${recoveryError.message}`) };
496
1040
  }
497
1041
  return { ok: false, error };
498
1042
  }
@@ -510,8 +1054,7 @@ function printPlan(plan, output, dryRun) {
510
1054
  }
511
1055
 
512
1056
  function getAgentValues(content) {
513
- const scan = scanToml(content);
514
- return rootAssignments(scan);
1057
+ return rootAssignments(scanToml(content));
515
1058
  }
516
1059
 
517
1060
  function getString(assignments, key) {
@@ -519,16 +1062,15 @@ function getString(assignments, key) {
519
1062
  return assignment?.parsedValue?.kind === "string" ? assignment.parsedValue.value : null;
520
1063
  }
521
1064
 
522
- function validateSkill(content) {
1065
+ function validateSkill(content, expectation) {
523
1066
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
524
1067
  if (!match) return "invalid front matter";
525
1068
  const front = match[1];
526
1069
  const body = match[2];
527
- if (!/^name:\s*model-router\s*$/m.test(front)) return "incorrect skill name";
1070
+ if (!front.split(/\r?\n/).some((line) => line.trim() === `name: ${expectation.name}`)) return "incorrect skill name";
528
1071
  if (!/^description:\s*\S.+$/m.test(front)) return "missing skill description";
529
- const requirements = [/Terra/i, /Luna/i, /Sol/i, /read-only/i, /simple question/i, /minimum number|fewest/i];
530
- if (requirements.some((pattern) => !pattern.test(body))) return "missing required routing rule";
531
- return null;
1072
+ const missing = expectation.required.find((term) => !body.toLowerCase().includes(term.toLowerCase()));
1073
+ return missing ? `missing required rule: ${missing}` : null;
532
1074
  }
533
1075
 
534
1076
  function doctorStatus(messages, status, label, detail) {
@@ -540,9 +1082,47 @@ async function doctor(scope) {
540
1082
  const location = expectedPaths(scope);
541
1083
  const messages = [];
542
1084
  let healthy = true;
1085
+ try {
1086
+ await validateLocationSafety(location);
1087
+ } catch (error) {
1088
+ doctorStatus(messages, "unsafe-state", "path", error.message);
1089
+ return { healthy: false, messages };
1090
+ }
1091
+
1092
+ const lock = await inspectLock(location);
1093
+ if (lock.status === "active") {
1094
+ healthy = doctorStatus(
1095
+ messages,
1096
+ "locked",
1097
+ "scope",
1098
+ `${lock.metadata.command || "operation"} pid ${lock.metadata.pid}`
1099
+ ) && healthy;
1100
+ } else if (lock.status === "stale") {
1101
+ healthy = doctorStatus(messages, "stale-lock", "scope", `pid ${lock.metadata.pid}`) && healthy;
1102
+ } else if (lock.status === "corrupt") {
1103
+ healthy = doctorStatus(messages, "unsafe-state", "lock", lock.detail) && healthy;
1104
+ }
1105
+
1106
+ const transaction = await classifyJournal(location);
1107
+ if (transaction.exists) {
1108
+ const mapping = {
1109
+ unfinished: "unfinished",
1110
+ recoverable: "recoverable",
1111
+ conflicting: "conflicting",
1112
+ corrupt: "corrupt"
1113
+ };
1114
+ healthy = doctorStatus(
1115
+ messages,
1116
+ mapping[transaction.classification] || "invalid",
1117
+ "transaction",
1118
+ transaction.detail
1119
+ ) && healthy;
1120
+ }
1121
+
543
1122
  let loaded;
544
- try { loaded = await loadState(location); }
545
- catch (error) {
1123
+ try {
1124
+ loaded = await loadState(location);
1125
+ } catch (error) {
546
1126
  doctorStatus(messages, "unsafe-state", "state", error.message);
547
1127
  return { healthy: false, messages };
548
1128
  }
@@ -554,20 +1134,22 @@ async function doctor(scope) {
554
1134
  doctorStatus(messages, "healthy", "state", `schema v${state.version}`);
555
1135
 
556
1136
  const config = await readText(location.config);
557
- if (!config.exists) {
558
- healthy = doctorStatus(messages, "missing", "config.toml");
1137
+ const managedConfigKeys = Object.keys(state.config.values || {});
1138
+ if (!managedConfigKeys.length) {
1139
+ doctorStatus(messages, "healthy", "config.toml", config.exists ? "primary model preserved" : "free mode");
1140
+ } else if (!config.exists) {
1141
+ healthy = doctorStatus(messages, "missing", "config.toml") && healthy;
559
1142
  } else {
560
1143
  try {
561
1144
  const scan = scanToml(config.text);
562
- for (const [key, expected] of Object.entries(DEFAULTS)) {
1145
+ for (const key of managedConfigKeys) {
1146
+ const expected = DEFAULTS[key];
563
1147
  const assignment = scan.targets.get(key);
564
- if (!assignment) {
565
- healthy = doctorStatus(messages, "missing", key) && healthy;
566
- } else if (assignment.parsedValue.kind !== "string") {
1148
+ if (!assignment) healthy = doctorStatus(messages, "missing", key) && healthy;
1149
+ else if (assignment.parsedValue.kind !== "string") {
567
1150
  healthy = doctorStatus(messages, "invalid", key, "must be a TOML string") && healthy;
568
1151
  } else if (assignment.parsedValue.value !== expected) {
569
- const status = state.config.values[key] ? "user-modified" : "user-override";
570
- healthy = doctorStatus(messages, status, key, assignment.parsedValue.value ?? assignment.rawValue) && healthy;
1152
+ healthy = doctorStatus(messages, "user-modified", key, assignment.parsedValue.value ?? assignment.rawValue) && healthy;
571
1153
  } else {
572
1154
  doctorStatus(messages, "healthy", key, expected);
573
1155
  }
@@ -577,7 +1159,7 @@ async function doctor(scope) {
577
1159
  }
578
1160
  }
579
1161
 
580
- for (const name of ["luna", "sol"]) {
1162
+ for (const name of AGENT_NAMES) {
581
1163
  const current = await readText(location[name]);
582
1164
  if (!current.exists) {
583
1165
  healthy = doctorStatus(messages, "missing", name) && healthy;
@@ -585,11 +1167,9 @@ async function doctor(scope) {
585
1167
  }
586
1168
  try {
587
1169
  const values = getAgentValues(current.text);
588
- const expected = name === "luna"
589
- ? { name: "luna", model: "gpt-5.6-luna", model_reasoning_effort: "high" }
590
- : { name: "sol", model: "gpt-5.6-sol", model_reasoning_effort: "medium", sandbox_mode: "read-only" };
591
- const invalid = Object.entries(expected).find(([key, value]) => getString(values, key) !== value);
592
- if (state.files[name] && hash(current.text) !== state.files[name].hash) {
1170
+ const invalid = Object.entries(AGENT_EXPECTATIONS[name])
1171
+ .find(([key, value]) => getString(values, key) !== value);
1172
+ if (state.files[name] && digest(Buffer.from(current.text, "utf8")) !== state.files[name].hash) {
593
1173
  const detail = invalid ? `${invalid[0]} is no longer ${invalid[1]}` : "managed file hash changed";
594
1174
  healthy = doctorStatus(messages, "user-modified", name, detail) && healthy;
595
1175
  } else if (invalid) {
@@ -602,28 +1182,42 @@ async function doctor(scope) {
602
1182
  }
603
1183
  }
604
1184
 
605
- const skill = await readText(location.skill);
606
- if (!skill.exists) healthy = doctorStatus(messages, "missing", "skill") && healthy;
607
- else {
608
- const invalid = validateSkill(skill.text);
609
- if (state.files.skill && hash(skill.text) !== state.files.skill.hash) {
610
- healthy = doctorStatus(messages, "user-modified", "skill", invalid || "managed file hash changed") && healthy;
611
- } else if (invalid) healthy = doctorStatus(messages, "invalid", "skill", invalid) && healthy;
612
- else doctorStatus(messages, "healthy", "skill");
1185
+ for (const [name, expectation] of Object.entries(SKILL_EXPECTATIONS)) {
1186
+ const current = await readText(location[name]);
1187
+ if (!current.exists) {
1188
+ healthy = doctorStatus(messages, "missing", name) && healthy;
1189
+ continue;
1190
+ }
1191
+ const invalid = validateSkill(current.text, expectation);
1192
+ if (state.files[name] && digest(Buffer.from(current.text, "utf8")) !== state.files[name].hash) {
1193
+ healthy = doctorStatus(messages, "user-modified", name, invalid || "managed file hash changed") && healthy;
1194
+ } else if (invalid) {
1195
+ healthy = doctorStatus(messages, "invalid", name, invalid) && healthy;
1196
+ } else {
1197
+ doctorStatus(messages, "healthy", name);
1198
+ }
613
1199
  }
614
1200
 
615
1201
  if (state.backup) {
616
1202
  const backup = await readText(location.backup);
617
1203
  if (!backup.exists) healthy = doctorStatus(messages, "missing", "config backup") && healthy;
618
- else if (hash(backup.text) !== state.backup.hash) healthy = doctorStatus(messages, "user-modified", "config backup") && healthy;
619
- else doctorStatus(messages, "healthy", "config backup");
1204
+ else if (digest(Buffer.from(backup.text, "utf8")) !== state.backup.hash) {
1205
+ healthy = doctorStatus(messages, "user-modified", "config backup") && healthy;
1206
+ } else {
1207
+ doctorStatus(messages, "healthy", "config backup");
1208
+ }
620
1209
  }
621
-
622
1210
  return { healthy, messages };
623
1211
  }
624
1212
 
625
1213
  function usage() {
626
- return `codex-model-router ${VERSION}\n\nUsage:\n codex-model-router install [--global] [--set-default] [--dry-run]\n codex-model-router uninstall [--global] [--dry-run]\n codex-model-router doctor [--global]\n codex-model-router --version`;
1214
+ return `codex-model-router ${VERSION}
1215
+
1216
+ Usage:
1217
+ codex-model-router install [--global] [--set-default] [--dry-run]
1218
+ codex-model-router uninstall [--global] [--dry-run]
1219
+ codex-model-router doctor [--global]
1220
+ codex-model-router --version`;
627
1221
  }
628
1222
 
629
1223
  function parseArgs(argv) {
@@ -641,37 +1235,85 @@ function parseArgs(argv) {
641
1235
  return { command, flags };
642
1236
  }
643
1237
 
1238
+ async function reportNonMutatingBlockers(location, output) {
1239
+ const lock = await inspectLock(location);
1240
+ if (lock.status !== "none") {
1241
+ const detail = lock.metadata
1242
+ ? `${lock.metadata.command || "operation"} pid ${lock.metadata.pid}`
1243
+ : lock.detail;
1244
+ const status = lock.status === "stale"
1245
+ ? "stale-lock"
1246
+ : lock.status === "corrupt" ? "unsafe-state" : "locked";
1247
+ output(`${status}: scope (${detail})`);
1248
+ return false;
1249
+ }
1250
+ const transaction = await classifyJournal(location);
1251
+ if (transaction.exists) {
1252
+ output(`${transaction.classification}: transaction (${transaction.detail})`);
1253
+ return false;
1254
+ }
1255
+ return true;
1256
+ }
1257
+
644
1258
  export async function run(argv, options = {}) {
645
1259
  const output = options.output ?? console.log;
646
1260
  const parsed = parseArgs(argv);
647
- if (parsed.version) { output(VERSION); return 0; }
648
- if (parsed.help) { output(usage()); return 0; }
649
- if (parsed.error) { output(`${parsed.error}\n\n${usage()}`); return 1; }
650
- const scope = {
1261
+ if (parsed.version) {
1262
+ output(VERSION);
1263
+ return 0;
1264
+ }
1265
+ if (parsed.help) {
1266
+ output(usage());
1267
+ return 0;
1268
+ }
1269
+ if (parsed.error) {
1270
+ output(`${parsed.error}\n\n${usage()}`);
1271
+ return 1;
1272
+ }
1273
+
1274
+ try {
1275
+ const scope = await canonicalizeScope({
651
1276
  cwd: options.cwd ?? process.cwd(),
652
1277
  home: options.home,
653
1278
  env: options.env ?? process.env,
654
1279
  global: parsed.flags.global
655
- };
656
-
657
- try {
1280
+ });
1281
+ const location = expectedPaths(scope);
1282
+ await validateLocationSafety(location);
658
1283
  if (parsed.command === "doctor") {
659
1284
  const result = await doctor(scope);
660
1285
  for (const message of result.messages) output(formatMessage(message, false));
661
1286
  return result.healthy ? 0 : 1;
662
1287
  }
663
- const plan = parsed.command === "install"
664
- ? await planInstall(scope, parsed.flags)
665
- : await planUninstall(scope);
666
- printPlan(plan, output, parsed.flags.dryRun);
667
- if (plan.failed) return 1;
668
- if (parsed.flags.dryRun) return 0;
669
- const result = await executePlan(plan);
670
- if (!result.ok) {
671
- output(`fail: execution (${result.error?.message || "unknown error"})`);
672
- return 1;
1288
+
1289
+ if (parsed.flags.dryRun) {
1290
+ if (!(await reportNonMutatingBlockers(location, output))) return 1;
1291
+ const plan = parsed.command === "install"
1292
+ ? await planInstall(scope, parsed.flags)
1293
+ : await planUninstall(scope);
1294
+ printPlan(plan, output, true);
1295
+ return plan.failed ? 1 : 0;
1296
+ }
1297
+
1298
+ let lock;
1299
+ try {
1300
+ lock = await acquireLock(location, parsed.command, output, scope.env);
1301
+ await validateLocationSafety(location);
1302
+ await recoverTransaction(location, output);
1303
+ const plan = parsed.command === "install"
1304
+ ? await planInstall(scope, parsed.flags)
1305
+ : await planUninstall(scope);
1306
+ printPlan(plan, output, false);
1307
+ if (plan.failed) return 1;
1308
+ const result = await executePlan(plan, scope.env);
1309
+ if (!result.ok) {
1310
+ output(`fail: execution (${result.error?.message || "unknown error"})`);
1311
+ return 1;
1312
+ }
1313
+ return 0;
1314
+ } finally {
1315
+ await releaseLock(location, lock);
673
1316
  }
674
- return 0;
675
1317
  } catch (error) {
676
1318
  output(`fail: ${error.message}`);
677
1319
  return 1;