← Files TokenXARCHIVED FILE
scripts/doctor.mjs
6.42 KB · Oct 2, 2026 · 00:29 UTC
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import {
chmod,
mkdir,
readdir,
readFile,
unlink,
writeFile,
} from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { loadConfig } from "./lib/config.mjs";
import {
readCodexModelCatalog,
validateConfiguredModels,
} from "./lib/model-catalog.mjs";
import { SMART_CLASSIFIER_CONTRACT_VERSION } from "./lib/smart-classifier.mjs";
const execFileAsync = promisify(execFile);
const hookEvents = [
"PreToolUse",
"SessionEnd",
"SessionStart",
"UserPromptSubmit",
];
async function defaultRunVersion(command, args) {
const { stdout } = await execFileAsync(command, args, {
encoding: "utf8",
timeout: 5_000,
windowsHide: true,
});
return stdout.trim();
}
async function check(action) {
try {
return {
ok: true,
detail: await action(),
};
} catch (error) {
return {
ok: false,
detail:
error instanceof Error && error.message
? error.message
: "unknown diagnostic error",
};
}
}
async function inspectHookContract(pluginRoot) {
const hooks = JSON.parse(
await readFile(join(pluginRoot, "hooks", "hooks.json"), "utf8"),
);
const actual = Object.keys(hooks.hooks ?? {}).sort();
if (JSON.stringify(actual) !== JSON.stringify(hookEvents)) {
throw new RangeError("hook contract does not contain the four expected events");
}
for (const groups of Object.values(hooks.hooks)) {
for (const group of groups) {
for (const hook of group.hooks) {
const match = hook.command.match(
/\$\{PLUGIN_ROOT\}\/scripts\/([^"]+\.mjs)/,
);
if (!match) {
throw new RangeError("hook command does not resolve through PLUGIN_ROOT");
}
await readFile(join(pluginRoot, "scripts", match[1]), "utf8");
}
}
}
const sessionEnd = hooks.hooks.SessionEnd ?? [];
const sessionEndMatchers = sessionEnd
.map((group) => group.matcher)
.filter((matcher) => typeof matcher === "string");
if (
sessionEndMatchers.length !== 1 ||
sessionEndMatchers[0] !== "other"
) {
throw new RangeError(
"SessionEnd must remain matched to the probed host reason 'other'",
);
}
return "four registered hook events and scripts; SessionEnd reason=other only";
}
async function inspectSessionState(pluginData) {
const stateDir = join(pluginData, "state");
let entries = [];
try {
entries = await readdir(stateDir);
} catch (error) {
if (error?.code === "ENOENT") {
return (
"no session state directory; no decisions, outcomes, or model pins " +
"are stored"
);
}
throw error;
}
const pinFiles = entries.filter((name) =>
name.endsWith(".pin.json")
).length;
const outcomeFiles = entries.filter((name) =>
name.endsWith(".outcomes.json")
).length;
const decisionFiles = entries.filter(
(name) =>
name.endsWith(".json") &&
!name.endsWith(".pin.json") &&
!name.endsWith(".outcomes.json"),
).length;
const lockArtifacts = entries.filter(
(name) => name.endsWith(".lock") || name.endsWith(".recovery"),
).length;
return (
`${decisionFiles} decision file(s), ${outcomeFiles} outcome file(s), ` +
`${pinFiles} model pin file(s), ${lockArtifacts} lock/recovery ` +
"artifact(s); SessionEnd clears decision/outcome state for reason=other; " +
"model pins persist until explicit clear, exact-session cleanup, or update"
);
}
async function inspectPluginData(pluginData) {
await mkdir(pluginData, { recursive: true, mode: 0o700 });
await chmod(pluginData, 0o700);
const probePath = join(pluginData, `.doctor-${randomUUID()}.tmp`);
await writeFile(probePath, "tokenx\n", {
flag: "wx",
mode: 0o600,
});
await unlink(probePath);
return "writable with restrictive modes";
}
export async function runDoctor({
pluginRoot,
pluginData,
homeDir = homedir(),
runVersion = defaultRunVersion,
nodeVersion = process.version,
env = process.env,
}) {
const checks = {
node: {
ok: /^v(?:2[0-9]|[3-9][0-9])\./.test(nodeVersion),
detail: nodeVersion,
},
};
const [git, codex, hookContract, pluginDataCheck, sessionState] =
await Promise.all([
check(() => runVersion("git", ["--version"])),
check(() => runVersion("codex", ["--version"])),
check(() => inspectHookContract(pluginRoot)),
check(() => inspectPluginData(pluginData)),
check(() => inspectSessionState(pluginData)),
]);
checks.git = git;
checks.codex = codex;
checks.hookContract = hookContract;
checks.pluginData = pluginDataCheck;
checks.sessionState = sessionState;
let config;
checks.config = await check(async () => {
config = await loadConfig({ pluginData, env });
return "schema 3 Codex-only configuration";
});
checks.dynamicAgents = await check(async () => {
if (!config) {
throw new Error("configuration must be valid before dynamic-agent checks");
}
const dynamic = config.routing.dynamicAgents;
const profiles = Object.entries(dynamic.profiles)
.map(([role, route]) => `${role}=${route}`)
.join(", ");
return `enabled=${dynamic.enabled}; maxAssignments=${dynamic.maxAssignmentsPerDecision}; ${profiles}`;
});
checks.modelCatalog = await check(async () => {
if (!config) {
throw new Error("configuration must be valid before model catalog checks");
}
const catalog = await readCodexModelCatalog({ homeDir });
// The hooks fall back to the bundled catalog when this file is absent so
// routing keeps working. Diagnostics must still report it: the whole point
// of doctor is to surface what degraded.
if (catalog === null) {
throw new Error(
"Codex model catalog is unavailable at ~/.codex/models_cache.json; " +
"routing falls back to the bundled catalog and cannot confirm live " +
"model availability",
);
}
validateConfiguredModels(config, catalog);
return "all configured models and efforts are list-visible";
});
checks.nestingPolicy = {
ok: true,
detail:
"assignment dispatch is guard-injected; live PreToolUse depth is not " +
"exposed, so nesting is budget-capped rather than hard root-only denied",
};
return {
ok: Object.values(checks).every((item) => item.ok),
smartClassifierContract: SMART_CLASSIFIER_CONTRACT_VERSION,
checks,
};
}
SHA-256: 58c07304cb82a5c69f9708dd890c6f14af40adf8cd050d0c663867526648f52e