Hooks
What model-routing runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
> /plugin marketplace add AqueGen/model-routing > /plugin install model-routing@model-routing
Ships with model-routing. Installing the plugin gets these hooks.
What fires, and when
SessionStart
Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.
cat "${CLAUDE_PLUGIN_ROOT}/hooks/routing-anchor.md"- Matches
startupnode "${CLAUDE_PLUGIN_ROOT}/hooks/update-check.mjs"
PostToolUse
- Matches
Agent|Tasknode "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-counter.mjs"
Where it lives
- hooks/dispatch-counter.mjsRunsGitHub
Read the script
#!/usr/bin/env node // Dispatch counter: logs every Agent-tool dispatch (PostToolUse hook) and // prints kept-off-strongest stats for a status line. // // node dispatch-counter.mjs <- hook mode: read event JSON on stdin, append // node dispatch-counter.mjs stats <- print "routed-down: N today · M 7d" // node dispatch-counter.mjs report <- per-agent dispatch breakdown // node dispatch-counter.mjs tokens <- real token volume per model from subagent transcripts // // Window flags (stats/report/tokens): --days N sizes the window (default 7), // --ago M shifts it back M days (--days 7 --ago 7 = the week before last // week's end); --session <family> scopes to sessions whose model matches // (e.g. "fable" when a fallback ladder mixes tiers into one window). // Dispatch history is retained 30 days; tokens reach as far back as // Claude Code keeps transcripts (cleanupPeriodDays). // // "Routed down" = the dispatch's effective model (explicit model param, else // the agent's frontmatter pin) ranks below the recorded session model. Entries // missing either side fall back to a cheap-agent/cheap-tier heuristic. Counts // dispatches, not tokens - honest bookkeeping, no dollar fiction. // Each entry also records the session's effort level and which source it came // from - CLAUDE_CODE_EFFORT_LEVEL, else the settings cascade, else the model's // documented default - so the report can show the second knob: dispatches on // agent types with no known pin inherit it, pinned agents do not. // Log lives in <config>/model-routing/dispatches.jsonl and self-prunes to 30d. import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { homedir } from "node:os"; const DAY_MS = 24 * 60 * 60 * 1000; const RETENTION_MS = 30 * DAY_MS; // Stamped on every report so a pasted screenshot answers "which version wrote // this" without a second round trip - the question that costs the most time // when someone reports a report that looks wrong. Read from the manifest next // to this script rather than CLAUDE_PLUGIN_ROOT, which the hook sets but a // direct `node hooks/dispatch-counter.mjs report` does not. Unreadable manifest // degrades to an unlabelled header; a version stamp must never break a report. const PLUGIN_VERSION = (() => { try { const here = dirname(fileURLToPath(import.meta.url)); return JSON.parse(readFileSync(join(here, "..", ".claude-plugin", "plugin.json"), "utf-8")).version ?? null; } catch { return null; } })(); const versionSuffix = PLUGIN_VERSION ? ` - model-routing ${PLUGIN_VERSION}` : ""; // Window flags: --days N (size, default 7) and --ago M (shift back M days). // Bad values fall back to the default rather than erroring - a stats tool // must never be harder to run than the thing it measures. function windowFromArgs(argv) { const flag = (name) => { const i = argv.indexOf(name); const v = i >= 0 ? Number(argv[i + 1]) : NaN; return Number.isFinite(v) && v >= 0 ? v : null; }; const days = flag("--days") ?? 7; const ago = flag("--ago") ?? 0; const end = Date.now() - ago * DAY_MS; return { start: end - days * DAY_MS, end, days, ago }; } // --session <family> scopes a report to sessions whose model matches the // substring (e.g. "fable", "opus") - useful when a fallbackModel ladder or // manual /model switches mix session tiers inside one window and you only // want the situation that matches your default. Case-insensitive. function sessionFilterFromArgs(argv) { const i = argv.indexOf("--session"); return i >= 0 && argv[i + 1] ? String(argv[i + 1]).toLowerCase() : null; } // Frontmatter pins of the bundled agents, model and effort together. A bare // dispatch (no model param) still runs on the pinned model, so classification // must resolve through this table or bare implementer dispatches (pin=sonnet // since 0.6.0) get miscounted as session-tier work; the effort column is the // second cost knob, which moves cost as hard as tier does. One table rather // than two because they are two columns of one fact - the agent's frontmatter - // and asking "is this agent pinned" through separate tables let them disagree. // Keep in sync with agents/*.md; one sync test guards both columns. const AGENT_PINS = { "model-routing:scout": { model: "sonnet", effort: "low" }, "model-routing:surveyor": { model: "haiku", effort: "low" }, "model-routing:test-runner": { model: "haiku", effort: "low" }, "model-routing:e2e-runner": { model: "sonnet", effort: "medium" }, "model-routing:verifier": { model: "haiku", effort: "low" }, "model-routing:implementer": { model: "sonnet", effort: "medium" }, "model-routing:reviewer": { model: "opus", effort: "high" }, }; // Model pins for OTHER plugins' agents, curated by hand from their frontmatter // the same way AGENT_PINS is - the difference is these files live outside this // repo, so nothing here can assert they stay in sync, and there is no effort // column because an agent's effort pin is not visible from outside its own // plugin. Every entry exists because it showed up as noise: a bare dispatch of // that exact agent type, on a session strong enough to make "did this inherit // the session model" ambiguous, when the frontmatter already answers the // question. This is deliberately small - add an entry when one causes a false // positive in YOUR logs, not preemptively for every plugin that might. // // A pin recorded here can go stale, and this is only about `report` (see // ownPinnedModel below for why `tokens` never trusts this table): the two // caveman entries ship a documented runtime override - // CAVECREW_REVIEWER_MODEL / CAVECREW_INVESTIGATOR_MODEL - that PATCHES the // installed agent's own frontmatter file, persisting until the plugin is // next updated or reinstalled. Set either on this machine and `report` ke - hooks/dispatch-counter.test.mjsGitHub
Read the script
// Smoke tests for dispatch-counter.mjs. Run: node --test hooks/ // Tests drive the CLI end-to-end with CLAUDE_CONFIG_DIR pointed at a temp // dir, so no exports or refactors of the script are needed. import { test } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from "node:fs"; import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "dispatch-counter.mjs"); function run(args, configDir, stdin, extraEnv, cwd) { return execFileSync(process.execPath, [SCRIPT, ...args].filter(Boolean), { // CLAUDE_CODE_SUBAGENT_MODEL, CLAUDE_CODE_SUBAGENT_MODEL_FORCE and // CLAUDE_CODE_EFFORT_LEVEL are blanked by default so a developer's own // overrides cannot leak into the hermetic tests; set any via extraEnv. env: { ...process.env, CLAUDE_CONFIG_DIR: configDir, CLAUDE_CODE_SUBAGENT_MODEL: "", CLAUDE_CODE_SUBAGENT_MODEL_FORCE: "", CLAUDE_CODE_EFFORT_LEVEL: "", ...(extraEnv ?? {}) }, // Default the working directory to the temp config dir, never the directory // the suite happens to run from: the hook reads <cwd>/.claude/settings*.json // for the session effort, so an ambient cwd would let a real settings file // two levels up decide a test outcome. Tests that need a specific project // cascade pass their own cwd. cwd: cwd ?? configDir, input: stdin ?? "", encoding: "utf-8", }); } function freshConfigDir() { return mkdtempSync(join(tmpdir(), "mr-test-")); } function writeLog(configDir, entries) { const dir = join(configDir, "model-routing"); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, "dispatches.jsonl"), entries.map((e) => JSON.stringify(e)).join("\n") + "\n"); } test("report with no log explains itself instead of printing nothing", () => { const cfg = freshConfigDir(); try { const out = run(["report"], cfg); assert.match(out, /No dispatches logged/); assert.match(out, /PostToolUse hook/); } finally { rmSync(cfg, { recursive: true, force: true }); } }); test("stats with no log prints a no-data marker", () => { const cfg = freshConfigDir(); try { assert.equal(run(["stats"], cfg), "routed-down: no data (7d)"); } finally { rmSync(cfg, { recursive: true, force: true }); } }); test("report groups by tier and never ranks unknown models", () => { const cfg = freshConfigDir(); const now = Date.now(); writeLog(cfg, [ // sonnet from an opus session: routed down. { ts: now, agent: "general-purpose", model: "sonnet", session: "claude-opus-4-8" }, // opus from an opus session: at tier, not down. { ts: now, agent: "model-routing:implementer", model: "opus", session: "claude-opus-4-8" }, // future model family: tier unknown - must NOT count as routed down. { ts: now, agent: "general-purpose", model: "zephyr-1", session: "claude-opus-4-8" }, ]); try { const out = run(["report"], cfg); // Unknown-tier entries are excluded from the denominator - one exotic // model must not drag the routed-down share down. assert.match(out, /1 of 2 comparable dispatches \(50%\) ran on a lower tier/); assert.match(out, /1 not tier-comparable excluded/); // Unknown-model rows land in their own section - honest unknown, // not silently counted as routed down or at-tier. assert.match(out, /Not tier-comparable[\s\S]*general-purpose \(model=zephyr-1\)/); assert.match(out, /Ran at the session tier[\s\S]*implementer \(model=opus\)/); } finally { rmSync(cfg, { recursive: true, force: true }); } }); test("bare pinned agents classify by their frontmatter pin", () => { const cfg = freshConfigDir(); const now = Date.now(); writeLog(cfg, [ // implementer pins sonnet: a bare dispatch from an opus session ran // sonnet, so it is routed down even without an explicit model param. { ts: now, agent: "model-routing:implementer", model: null, session: "claude-opus-4-8" }, // reviewer pins opus: bare on an opus session stays at the session tier. { ts: now, agent: "model-routing:reviewer", model: null, session: "claude-opus-4-8" }, ]); try { const out = run(["report"], cfg); assert.match(out, /1 of 2 dispatches \(50%\) ran on a lower tier/); assert.match(out, /Ran cheaper[\s\S]*implementer \(pin=sonnet\)/); assert.match(out, /Ran at the session tier[\s\S]*reviewer \(pin=opus\)/); } finally { rmSync(cfg, { recursive: true, force: true }); } }); test("a curated foreign agent pin classifies like a bundled one, not a leak", () => { const cfg = freshConfigDir(); const now = Date.now(); // codex:codex-rescue pins sonnet in its own frontmatter (openai-codex // plugin) - this plugin cannot see that file, but FOREIGN_AGENT_PINS // records it by hand because it was the exact agent causing false leak // warnings before this test existed. A bare dispatch on an opus session // should route down on the strength of that known pin, the same as a // bundled agent, and must not count toward "tier leaks" at all. writeLog(cfg, [ { ts: now, agent: "codex:codex-rescue", model: null, session: "claude-opus-5" }, // A genuinely unpinned type, for contrast - this one SHOULD leak. { ts: now, agent: "general-purpose", model: null, session: "claude-opus-5" }, ]); try { const out = run(["report"], cfg); assert.match(out, /1 of 2 dispatches \(50%\) ran on a lower tier/); assert.match(out, /Ran cheaper[\s\S]*codex:codex-rescue \(pin=sonnet\)/); assert.match(out, /Tier leaks: 1 of 1 dispatches on agent types with no MODEL pin this plugin knows \(100%\)/); } finally { rmSync(cfg, { recursive: true, force: true }); } }); test("a foreign-pinned agent dispatched below its own pin is still called out", () => { const cfg = freshConfigDir(); const no - hooks/update-check.mjsRunsGitHub
Read the script
#!/usr/bin/env node // SessionStart hook: print a one-line notice when a newer plugin version has // been published, so marketplace installs (which never auto-update) hear about // it. Checks the network at most once per 24h via a timestamp cache; every // failure path is silent - a version check must never break session start. import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; const FETCH_TIMEOUT_MS = 3000; const MANIFEST_URL = process.env.MODEL_ROUTING_UPDATE_URL ?? "https://raw.githubusercontent.com/AqueGen/model-routing/main/.claude-plugin/plugin.json"; const RELEASES_URL = "https://github.com/AqueGen/model-routing/releases"; function parseVersion(v) { const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v ?? ""); return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; } function isNewer(latest, installed) { const a = parseVersion(latest); const b = parseVersion(installed); if (!a || !b) return false; for (let i = 0; i < 3; i++) { if (a[i] !== b[i]) return a[i] > b[i]; } return false; } async function fetchLatestVersion() { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); try { const res = await fetch(MANIFEST_URL, { signal: ctrl.signal }); if (!res.ok) return null; const manifest = await res.json(); return parseVersion(manifest.version) ? manifest.version : null; } catch { return null; } finally { clearTimeout(timer); } } let installed; try { const manifestPath = join(process.env.CLAUDE_PLUGIN_ROOT ?? "", ".claude-plugin", "plugin.json"); installed = JSON.parse(readFileSync(manifestPath, "utf-8")).version; } catch { process.exit(0); } const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); const cacheDir = join(configDir, "model-routing"); const cachePath = join(cacheDir, "update-check.json"); let cache = null; try { cache = JSON.parse(readFileSync(cachePath, "utf-8")); } catch { // no cache yet } const now = Date.now(); const stale = !cache || typeof cache.checkedAt !== "number" || now - cache.checkedAt >= CHECK_INTERVAL_MS; if (stale) { const latest = await fetchLatestVersion(); // Stamp checkedAt even on failure so an offline machine is not probed on // every session start; keep the previously known latest in that case. cache = { checkedAt: now, latest: latest ?? cache?.latest ?? null }; try { mkdirSync(cacheDir, { recursive: true }); writeFileSync(cachePath, JSON.stringify(cache)); } catch { // read-only home - notice still works off the in-memory cache } } if (cache && isNewer(cache.latest, installed)) { // systemMessage is the only hook output field documented as shown to the USER. // Plain stdout on SessionStart is added to Claude's context instead: the model // cannot update the plugin, and whether it relays the line is up to it - so a // plain-text notice reaches the person only by luck. Valid JSON on stdout is // parsed as hook output rather than injected as context. // Both commands are needed and in this order: nothing refreshes the // marketplace catalog on its own, so `plugin update` alone can reinstall the // version already in the stale catalog. console.log( JSON.stringify({ systemMessage: `model-routing ${installed} installed, ${cache.latest} available. ` + `Update: claude plugin marketplace update model-routing && claude plugin update model-routing, then restart. ` + `Changes: ${RELEASES_URL}`, }) ); } - hooks/update-check.test.mjsGitHub
Read the script
// Smoke tests for update-check.mjs. Run: node --test hooks/ // Tests drive the script end-to-end: CLAUDE_PLUGIN_ROOT points at a temp // plugin dir, CLAUDE_CONFIG_DIR at a temp cache dir, and the remote manifest // is served by a throwaway local http server via MODEL_ROUTING_UPDATE_URL. import { test } from "node:test"; import assert from "node:assert/strict"; import { execFile, execFileSync } from "node:child_process"; import { promisify } from "node:util"; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { createServer } from "node:http"; const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "update-check.mjs"); const DAY_MS = 24 * 60 * 60 * 1000; function makePluginRoot(version) { const root = mkdtempSync(join(tmpdir(), "mr-upd-root-")); mkdirSync(join(root, ".claude-plugin")); writeFileSync(join(root, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "model-routing", version })); return root; } function envFor(pluginRoot, configDir, updateUrl) { return { ...process.env, CLAUDE_PLUGIN_ROOT: pluginRoot, CLAUDE_CONFIG_DIR: configDir, MODEL_ROUTING_UPDATE_URL: updateUrl, }; } // Sync runner for tests with no live server. Server-backed tests MUST use // runAsync: execFileSync blocks the parent's event loop, so the in-process // http server would never answer and the child would silently time out. function run(pluginRoot, configDir, updateUrl) { return execFileSync(process.execPath, [SCRIPT], { env: envFor(pluginRoot, configDir, updateUrl), encoding: "utf-8" }); } const execFileAsync = promisify(execFile); async function runAsync(pluginRoot, configDir, updateUrl) { const { stdout } = await execFileAsync(process.execPath, [SCRIPT], { env: envFor(pluginRoot, configDir, updateUrl), encoding: "utf-8", }); return stdout; } function serveOnce(body) { return new Promise((resolve) => { const server = createServer((_req, res) => { res.setHeader("content-type", "application/json"); res.end(body); }); server.listen(0, "127.0.0.1", () => { resolve({ url: `http://127.0.0.1:${server.address().port}/plugin.json`, close: () => server.close() }); }); }); } function readCache(configDir) { return JSON.parse(readFileSync(join(configDir, "model-routing", "update-check.json"), "utf-8")); } test("prints a notice and writes the cache when the remote is newer", async () => { const { url, close } = await serveOnce(JSON.stringify({ version: "9.9.9" })); try { const configDir = mkdtempSync(join(tmpdir(), "mr-upd-cfg-")); const out = await runAsync(makePluginRoot("1.0.0"), configDir, url); // The notice has to reach the USER, and systemMessage is the only output // field documented as doing that - plain stdout on SessionStart goes to // Claude's context, where reaching the person depends on the model relaying // it. Parsing here is the assertion: a bare string would throw. const notice = JSON.parse(out).systemMessage; assert.match(notice, /1\.0\.0 installed, 9\.9\.9 available/); // Both commands in full, catalog refresh first: nothing refreshes the catalog // on its own, so `plugin update` alone can reinstall the version already in a // stale one. The `claude plugin ` prefix is part of what must be asserted - // without it the assertion passes on a command nobody can paste. assert.match( notice, /claude plugin marketplace update model-routing && claude plugin update model-routing/ ); // README promises the release notes link, so the notice owes it. assert.match(notice, /https:\/\/github\.com\/AqueGen\/model-routing\/releases/); assert.equal(readCache(configDir).latest, "9.9.9"); } finally { close(); } }); test("stays silent when up to date", async () => { const { url, close } = await serveOnce(JSON.stringify({ version: "1.0.0" })); try { const out = await runAsync(makePluginRoot("1.0.0"), mkdtempSync(join(tmpdir(), "mr-upd-cfg-")), url); assert.equal(out, ""); } finally { close(); } }); test("uses a fresh cache without touching the network", async () => { // A live server that answers DIFFERENTLY from the cache is what makes this // test able to fail. A dead port cannot: the fetch would return null, the // cached latest would be restored, and the output would be identical whether // the freshness check ran or not - so the assertion held while the behaviour // it names was gone. The untouched checkedAt is the second half of the proof. const { url, close } = await serveOnce(JSON.stringify({ version: "9.9.9" })); try { const configDir = mkdtempSync(join(tmpdir(), "mr-upd-cfg-")); mkdirSync(join(configDir, "model-routing"), { recursive: true }); const checkedAt = Date.now(); writeFileSync( join(configDir, "model-routing", "update-check.json"), JSON.stringify({ checkedAt, latest: "2.0.0" }) ); const out = await runAsync(makePluginRoot("1.0.0"), configDir, url); assert.match(out, /1\.0\.0 installed, 2\.0\.0 available/); assert.doesNotMatch(out, /9\.9\.9/, "a fresh cache must not be refreshed from the network"); assert.equal(readCache(configDir).checkedAt, checkedAt, "checkedAt must be left alone"); } finally { close(); } }); test("a stale cache is refreshed from the network", async () => { const { url, close } = await serveOnce(JSON.stringify({ version: "9.9.9" })); try { const configDir = mkdtempSync(join(tmpdir(), "mr-upd-cfg-")); mkdirSync(join(configDir, "model-routing"), { recursive: true }); writeFileSync( join(configDir, "model-routing", "update-check.json"), JSON.stringify({ checkedAt: Date.now() - 2 * DAY_MS, latest: "2.0.0" }) ); const out = await runAsync(makePluginRoot("1.0.0"), configDir, url); assert.match(out, /1\.0\.0 installed,
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Tiered model routing for Claude Code token economy: **the strongest model thinks, cheaper models grind.** Planning and architecture stay in your main session on the best model you have.
Repo: AqueGen/model-routing

