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