// model-router: routes each model request (main loop and sub-agents) to the // model and effort its phase deserves. Phases, agents, skills and prompt // rules come from DEFAULT_CONFIG, overridable by ~/.claude/model-router.json. // One writer per concern: the Agent tool's own params are never rewritten, // the spawn hook sets an agent's model once, turn.step sets efforts. import type { EngineInterface, On, Register, TurnStepInput } from 'claude-code' type Api = EngineInterface type Level = 'low' | 'medium' | 'high' | 'xhigh' | 'max' type Route = { model?: string; effort?: Level } // model: alias or full id type PromptRule = { pattern: string; phase: string } type Config = { models: Record // alias -> full id windows: Record // full id -> context window (tokens) phases: Record agents: Record // built-in subagentType -> phase skills: Record // skill name -> phase prompt: PromptRule[] mainModelSwitch: boolean verbose: boolean spinner: boolean enabled: boolean // false: every hook passes through (per machine) } type Rule = { re: RegExp; phase: string } type Source = 'user' | 'model' | 'skill' | 'prompt' | 'slash' type Routed = { phase: string; route: Route; source: Source } type Loop = { effort?: Level // an agent's model is fixed at spawn: effort is its only axis explicitEffort: boolean // Agent call gave an effort: axis frozen } type State = { cfg: Config rules: Rule[] source: string // 'defaults' or the override path userMain: Routed | null // /route by the user, sticky until /route clear turnMain: Routed | null // model route tool, skill table row, Skill(effort-*) // bridge; dropped at turn end turnFloor: Routed | null // user-explicit level for this turn (prompt rule, // typed /effort-): a floor, main loop only pendingPrompt: Routed | null // typed mid-turn: the next turn's floor typedSlash: boolean // one-shot: prompt.submit saw a typed /effort- loops: Map // agentId -> that loop's routing explicitEffort: Map // Agent tool_use_id -> effort param skillCalls: number // Skill tool calls in flight off: boolean // /route off or config: every hook passes through offConfig: boolean // `off` comes from the config key `enabled` lastMain: string // "model/effort" of the last main step (spinner) windowWarned: boolean // context-window warning already logged this turn warned: Set // hooks whose fail-open was already logged } type Log = (text: string) => void type StepIn = Readonly type Plan = { model: string; effort: StepIn['effort'] } type RouteInput = { phase?: unknown effort?: unknown clear?: unknown agentId?: string } type Picked = { phase: string; route: Route } type Effort = StepIn['effort'] type EffortBy = 'floor' | 'sticky' | 'turn' | 'engine' type Decision = { effort: Effort; by: EffortBy } const LEVELS: readonly Level[] = ['low', 'medium', 'high', 'xhigh', 'max'] const MODEL_ID = /^claude-[a-z0-9.-]+$/ const TOOL = 'mcp__model-router__route' const EFFORT_SKILL = /^effort-(low|medium|high|xhigh|max)$/ const OVERRIDE = '.claude/model-router.json' const HAIKU = 'claude-haiku' const MAX_PATTERN = 200 // chars of a prompt-rule pattern const MAX_PROMPT_SCAN = 4096 // chars of a prompt a rule is run against const MAX_CONFIG_BYTES = 65536 // override file size const PHASE_KEY = /^[a-z][a-z0-9_-]{0,31}$/ const DEFAULT_CONFIG: Config = { models: { haiku: 'claude-haiku-4-5-20251001', sonnet: 'claude-sonnet-5-5', opus: 'claude-opus-5-5', fable: 'claude-fable-5-1', }, windows: { 'claude-haiku-4-5-20251001': 200000 }, phases: { plan: { effort: 'xhigh' }, reflect: { effort: 'high' }, orchestrate: { effort: 'medium' }, escalate: { effort: 'max' }, judge: { model: 'opus', effort: 'xhigh' }, implement: { model: 'sonnet', effort: 'medium' }, write: { model: 'sonnet', effort: 'medium' }, verify: { model: 'sonnet', effort: 'xhigh' }, explore: { model: 'sonnet', effort: 'medium' }, mechanical: { model: 'haiku', effort: 'low' }, }, // Built-ins only: repo agents keep their frontmatter pin (wave 2). agents: { Explore: 'explore', Plan: 'judge' }, skills: {}, prompt: [{ pattern: '\\bultrathink\\b', phase: 'escalate' }], mainModelSwitch: false, verbose: false, spinner: true, enabled: true, } // ---- config ---------------------------------------------------------- const isRecord = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v) const isLevel = (v: unknown): v is Level => LEVELS.some(l => l === v) const isFullId = (v: unknown): v is string => typeof v === 'string' && MODEL_ID.test(v) const hasKey = (table: object, key: string): boolean => Object.hasOwn(table, key) function isModelName(models: Record, v: unknown): v is string { return typeof v === 'string' && (hasKey(models, v) || isFullId(v)) } function resolveModel(cfg: Config, name: string): string { return hasKey(cfg.models, name) ? (cfg.models[name] ?? name) : name } function phaseRoute(cfg: Config, phase: string): Route | undefined { return hasKey(cfg.phases, phase) ? cfg.phases[phase] : undefined } /** Merges a user table over a default one, dropping invalid entries. */ function mergeTable( base: Record, user: unknown, name: string, accept: (key: string, value: unknown) => T | undefined, log: Log, ): Record { const out = { ...base } if (!isRecord(user)) { if (user !== undefined) { log(`model-router: config ${name} ignored: not an object`) } return out } for (const [key, value] of Object.entries(user)) { const ok = key === '__proto__' ? undefined : accept(key, value) if (ok === undefined) log(`model-router: config ${name}.${key} ignored`) else out[key] = ok } return out } const acceptModel = (_key: string, v: unknown): string | undefined => isFullId(v) ? v : undefined const acceptWindow = (key: string, v: unknown): number | undefined => isFullId(key) && typeof v === 'number' && Number.isInteger(v) && v > 0 ? v : undefined function acceptPhase(models: Record) { return (key: string, v: unknown): Route | undefined => { if (!PHASE_KEY.test(key) || !isRecord(v)) return undefined const route: Route = {} if (v.effort !== undefined) { if (!isLevel(v.effort)) return undefined route.effort = v.effort } if (v.model !== undefined) { if (!isModelName(models, v.model)) return undefined route.model = v.model } return route.effort || route.model ? route : undefined } } function acceptPhaseRef(phases: Record) { return (_key: string, v: unknown): string | undefined => typeof v === 'string' && PHASE_KEY.test(v) && hasKey(phases, v) ? v : undefined } function compiles(pattern: string): boolean { try { new RegExp(pattern, 'i') return true } catch { return false } } function acceptRule(phases: Record, v: unknown) { if (!isRecord(v)) return undefined const { pattern, phase } = v if (typeof pattern !== 'string' || typeof phase !== 'string') return undefined if (pattern.length > MAX_PATTERN) return undefined return hasKey(phases, phase) && compiles(pattern) ? { pattern, phase } : undefined } /** A user prompt array replaces the default rules; bad rules are dropped. */ function mergePrompt( base: PromptRule[], user: unknown, phases: Record, log: Log, ): PromptRule[] { if (!Array.isArray(user)) { if (user !== undefined) { log('model-router: config prompt ignored: not a list') } return base } const rules: PromptRule[] = [] for (const item of user as unknown[]) { const rule = acceptRule(phases, item) if (rule) rules.push(rule) else log('model-router: config prompt rule ignored') } return rules } const pickBool = (v: unknown, fallback: boolean): boolean => typeof v === 'boolean' ? v : fallback /** The kill switch: a non-boolean value is dropped, and said so. */ function pickEnabled(v: unknown, fallback: boolean, log: Log): boolean { if (v !== undefined && typeof v !== 'boolean') { log('model-router: config "enabled" is not a boolean; ignored') } return pickBool(v, fallback) } /** Defaults overlaid with the user's entries, each validated first. */ function mergeConfig(user: unknown, log: Log): Config { const base = structuredClone(DEFAULT_CONFIG) if (!isRecord(user)) { if (user !== undefined) log('model-router: config ignored: not an object') return base } const models = mergeTable( base.models, user.models, 'models', acceptModel, log) const phases = mergeTable( base.phases, user.phases, 'phases', acceptPhase(models), log) const ref = acceptPhaseRef(phases) return { models, windows: mergeTable( base.windows, user.windows, 'windows', acceptWindow, log), phases, agents: mergeTable(base.agents, user.agents, 'agents', ref, log), skills: mergeTable(base.skills, user.skills, 'skills', ref, log), prompt: mergePrompt(base.prompt, user.prompt, phases, log), mainModelSwitch: pickBool(user.mainModelSwitch, base.mainModelSwitch), verbose: pickBool(user.verbose, base.verbose), spinner: pickBool(user.spinner, base.spinner), enabled: pickEnabled(user.enabled, base.enabled, log), } } /** The file's text, or undefined (logged) when it exceeds the cap. */ async function readCapped( $: Api, path: string, log: Log, ): Promise { const tooBig = `model-router: ${OVERRIDE} over ${MAX_CONFIG_BYTES} bytes` if ((await $.fs.stat(path)).size > MAX_CONFIG_BYTES) { log(tooBig) return undefined } const text = await $.fs.read(path) if (text.length <= MAX_CONFIG_BYTES) return text log(tooBig) return undefined } type Override = { path: string; data: unknown } | 'absent' | 'failed' /** 'absent': no file (defaults apply). 'failed': present but unusable. */ async function readOverride($: Api, log: Log): Promise { try { const home = await $.env.get('HOME') if (!home) return 'absent' const path = `${home}/${OVERRIDE}` if (!(await $.fs.exists(path))) return 'absent' const text = await readCapped($, path, log) if (text === undefined) return 'failed' const data: unknown = JSON.parse(text) if (isRecord(data)) return { path, data } log(`model-router: ${OVERRIDE} is not an object`) return 'failed' } catch (err) { log(`model-router: ${OVERRIDE} unreadable (${String(err)})`) return 'failed' } } /** Never throws; undefined when the override exists but cannot be used. */ async function loadConfig( $: Api, log: Log, ): Promise<{ cfg: Config; source: string } | undefined> { const found = await readOverride($, log) if (found === 'failed') return undefined if (found === 'absent') { return { cfg: mergeConfig(undefined, log), source: 'defaults' } } return { cfg: mergeConfig(found.data, log), source: found.path } } function compileRules(cfg: Config): Rule[] { return cfg.prompt.map(r => ({ re: new RegExp(r.pattern, 'i'), phase: r.phase, })) } // ---- state ----------------------------------------------------------- function newState(cfg: Config, source: string): State { return { cfg, rules: compileRules(cfg), source, userMain: null, turnMain: null, turnFloor: null, pendingPrompt: null, typedSlash: false, loops: new Map(), explicitEffort: new Map(), skillCalls: 0, off: false, offConfig: false, lastMain: '', windowWarned: false, warned: new Set(), } } /** Logs a hook's fail-open once per session; never throws itself. */ function warnOnce(st: State, $: Api, hook: string, kind: string): void { if (st.warned.has(hook)) return st.warned.add(hook) try { $.ui.log(`model-router: ${hook} failed (${kind}): ` + 'routing skipped for this event') } catch { // a failing log must not break the fail-open itself } } /** Runs post-`next` bookkeeping so its failure can never re-run `next`. */ function safely(st: State, $: Api, hook: string, work: () => void): void { try { work() } catch { warnOnce(st, $, hook, 'bookkeeping') } } /** The main loop's effective route: user /route > latest turn route. */ const mainRoute = (st: State): Routed | null => st.userMain ?? st.turnMain const rank = (l: Level | undefined): number => l === undefined ? -1 : LEVELS.indexOf(l) /** Lifts `effort` to `floor`; a lower level, a number or none is replaced. */ function floored(effort: Effort, floor: Level | undefined): Effort { if (floor === undefined) return effort return isLevel(effort) && rank(effort) >= rank(floor) ? effort : floor } /** Two floors in one turn: the higher level stays (a tie takes the new). */ function higherFloor(cur: Routed | null, next: Routed): Routed { return cur && rank(cur.route.effort) > rank(next.route.effort) ? cur : next } /** * The one decision of the main loop's effort. The user's floor is the turn's * default (no sticky or turn route names an effort) and its minimum. * `by` names who set the value: the floor when it raised or supplied it. */ function mainEffort(st: State, engine: Effort): Decision { const sticky = st.userMain?.route.effort const named = sticky ?? st.turnMain?.route.effort const floor = st.turnFloor?.route.effort const base = named ?? floor ?? engine const effort = floored(base, floor) if (floor !== undefined && (named === undefined || effort !== base)) { return { effort, by: 'floor' } } if (named === undefined) return { effort, by: 'engine' } return { effort, by: sticky === undefined ? 'turn' : 'sticky' } } /** Model axis: sticky, then turn route, then the floor's own model. */ const mainModel = (st: State): string | undefined => st.userMain?.route.model ?? st.turnMain?.route.model ?? st.turnFloor?.route.model const floorSource = (f: Routed): string => f.source === 'prompt' ? `prompt rule ${f.phase}` : `typed /${f.phase}` const floorWord = (f: Routed): string => `user floor ${f.route.effort ?? '-'} (${floorSource(f)})` /** * Why main will not run at `asked`, or '' when it will. Truthful tail of * every answer that records an effort for the main loop. */ function mainNote(st: State, asked: Level | undefined): string { const d = mainEffort(st, undefined) if (d.by === 'floor') { if (!st.turnFloor || asked === undefined || d.effort === asked) return '' return `${floorWord(st.turnFloor)} keeps main at ${String(d.effort)}; ` + '/route clear to drop it' } return d.by === 'sticky' && st.userMain ? `a sticky /route ${st.userMain.phase} is in force and wins until ` + '/route clear' : '' } function loopOf(st: State, agentId: string): Loop { const known = st.loops.get(agentId) if (known) return known const fresh: Loop = { explicitEffort: false, } st.loops.set(agentId, fresh) return fresh } /** Writes a route on an agent loop, never on an axis given explicitly. */ function writeLoop(loop: Loop, route: Route): void { if (!loop.explicitEffort) loop.effort = route.effort } function clearRoutes(st: State): void { st.userMain = null st.turnMain = null st.turnFloor = null st.pendingPrompt = null } /** Config `enabled: false` switches the router off; true lifts only that. */ function applyEnabled(st: State, enabled: boolean): void { if (!enabled) { st.off = true st.offConfig = true } else if (st.offConfig) { st.off = false st.offConfig = false } } const routerWord = (st: State): string => st.off ? (st.offConfig ? 'off (config)' : 'off') : 'on' // ---- text ------------------------------------------------------------ const modelText = (cfg: Config, model: string | undefined): string => model === undefined ? '-' : resolveModel(cfg, model) /** The floor's level when it carries one and the router is on. */ function liveFloor(st: State): { f: Routed; level: Level } | undefined { const f = st.turnFloor const level = f?.route.effort return st.off || !f || level === undefined ? undefined : { f, level } } /** True when the switch would put the main loop on a haiku model. */ function mainOnHaiku(st: State): boolean { const model = mainModel(st) return st.cfg.mainModelSwitch && model !== undefined && resolveModel(st.cfg, model).startsWith(HAIKU) } function effortWord(st: State): string { if (mainOnHaiku(st)) return '- (haiku takes none)' return String(mainEffort(st, undefined).effort ?? '-') } function mainText(st: State): string { const r = mainRoute(st) const live = liveFloor(st) const floor = live ? ` · floor ${live.level} (${live.f.phase})` : '' if (!r) return 'main: session defaults' + floor const model = modelText(st.cfg, mainModel(st)) return `main: ${r.source} ${r.phase} · model ${model} · effort ${ effortWord(st)}${floor}` } function phasesText(cfg: Config): string { const entries = Object.entries(cfg.phases).map(([name, r]) => `${name}=${r.model ? resolveModel(cfg, r.model) : 'session'}/${ r.effort ?? 'session'}`) return `phases: ${entries.join(' ')}` } function show(st: State): string { const c = st.cfg const flag = (b: boolean) => (b ? 'on' : 'off') return [ mainText(st), `router: ${routerWord(st)} · switch: ${flag(c.mainModelSwitch)} · ` + `verbose: ${flag(c.verbose)} · spinner: ${flag(c.spinner)}`, `live loops: ${st.loops.size}`, phasesText(c), `config: ${st.source}`, ].join('\n') } function statusLine(st: State): string { const r = mainRoute(st) const now = st.off ? routerWord(st) : r ? `${r.source} ${r.phase}` : 'session defaults' const floor = liveFloor(st) return `route: ${now}${floor ? ` · floor ${floor.level}` : ''}${ st.cfg.mainModelSwitch ? ' · switch on' : ''}` } const refresh = ($: Api, st: State): void => $.ui.status(statusLine(st)) function vlog($: Api, st: State, text: string): void { if (st.cfg.verbose) $.ui.log(text) } const unknownText = (cfg: Config, token: string): string => `unknown token "${token}"; phases: ${Object.keys(cfg.phases).join(' ')}; ` + `levels: ${LEVELS.join(' ')}; models: ${Object.keys(cfg.models).join(' ')} ` + 'or a full claude-* id' // ---- /route command -------------------------------------------------- /** One token: `model=x`, `effort=y`, a bare alias, id or level. */ function applyToken(cfg: Config, route: Route, token: string): boolean { const eq = token.indexOf('=') const key = eq < 0 ? '' : token.slice(0, eq) const value = eq < 0 ? token : token.slice(eq + 1) if (key !== '' && key !== 'model' && key !== 'effort') return false if (key !== 'model' && isLevel(value)) route.effort = value else if (key !== 'effort' && isModelName(cfg.models, value)) { route.model = value } else return false return true } function parseRoute(cfg: Config, args: string): Routed | string { const words = args.trim().split(/\s+/) const only = words.length === 1 ? words[0] : undefined const named = only === undefined ? undefined : phaseRoute(cfg, only) if (only !== undefined && named) { return { phase: only, route: { ...named }, source: 'user' } } const route: Route = {} for (const word of words) { if (!applyToken(cfg, route, word)) return unknownText(cfg, word) } return { phase: 'custom', route, source: 'user' } } function toggle(st: State, what: string, arg: string | undefined): string { if (arg !== 'on' && arg !== 'off') return `usage: /route ${what} on|off` if (what === 'switch') st.cfg.mainModelSwitch = arg === 'on' else st.cfg.verbose = arg === 'on' return show(st) } function setUserRoute($: Api, st: State, args: string): string { const parsed = parseRoute(st.cfg, args) if (typeof parsed === 'string') return parsed st.userMain = parsed refresh($, st) return show(st) } /** * (Re)loads the config into the state and re-registers the route tool. * An unusable override keeps the previous config: the kill switch fails * closed, never back to the defaults. */ async function reloadConfig($: Api, st: State): Promise { const loaded = await loadConfig($, text => $.ui.log(text)) if (loaded) { st.cfg = loaded.cfg st.rules = compileRules(loaded.cfg) st.source = loaded.source applyEnabled(st, loaded.cfg.enabled) } else { $.ui.log('model-router: override unreadable; keeping the previous config') } await registerTool($, st) } async function handleCommand($: Api, st: State, args: string): Promise { const [head = '', ...rest] = args.trim().split(/\s+/) switch (head) { case '': case 'show': return show(st) case 'clear': clearRoutes(st) refresh($, st) return 'route cleared\n' + show(st) case 'on': case 'off': st.off = head === 'off' st.offConfig = false refresh($, st) return show(st) case 'reload': await reloadConfig($, st) refresh($, st) return 'config reloaded\n' + show(st) case 'switch': case 'verbose': return toggle(st, head, rest[0]) default: return setUserRoute($, st, args) } } // ---- route tool ------------------------------------------------------ async function registerTool($: Api, st: State): Promise { try { await $.tool.register({ name: 'route', description: 'Declare the phase of the work ahead so the next requests of THIS ' + 'loop run at the right effort (and, on the main loop, the model ' + 'when the switch is on). Call it before a span of work changes ' + 'nature (planning, orchestrating, mechanical work). Effort only ' + 'for a sub-agent; the model of a sub-agent is fixed at spawn. ' + `Phases: ${Object.keys(st.cfg.phases).join(', ')}.`, inputSchema: { type: 'object', properties: { phase: { type: 'string', enum: Object.keys(st.cfg.phases) }, effort: { type: 'string', enum: [...LEVELS] }, clear: { type: 'boolean', description: 'drop this loop\'s route' }, }, additionalProperties: false, }, }) } catch (err) { $.ui.log(`model-router: route tool not registered: ${String(err)}`) } } async function registerCommand($: Api): Promise { try { await $.command.register({ name: 'route', description: 'model-router: show or set the model and effort route', argumentHint: '[show|clear|off|on|reload||model= effort=' + '|switch on|off|verbose on|off]', immediate: true, }) } catch (err) { $.ui.log(`model-router: /route not registered: ${String(err)}`) } } function pickRoute(cfg: Config, phase: unknown, effort: unknown) { const phases = Object.keys(cfg.phases).join(', ') const named = typeof phase === 'string' ? phaseRoute(cfg, phase) : undefined if (phase !== undefined && !named) { return `unknown phase "${String(phase)}"; phases: ${phases}` } if (effort !== undefined && !isLevel(effort)) { return `unknown effort "${String(effort)}"; levels: ${LEVELS.join(', ')}` } if (phase === undefined && effort === undefined) { return `give a phase (${phases}), an effort, or clear` } const route: Route = { ...named, ...(isLevel(effort) ? { effort } : {}) } const name = typeof phase === 'string' ? phase : `effort-${String(effort)}` return { phase: name, route } } function applyRoute(st: State, agentId: string | undefined, p: Picked): void { if (agentId === undefined) { st.turnMain = { phase: p.phase, route: p.route, source: 'model' } } else { writeLoop(loopOf(st, agentId), p.route) } } function clearLoop(st: State, agentId: string | undefined): string { if (agentId === undefined) { st.turnMain = null const f = st.turnFloor const held = f && f.route.effort !== undefined ? `; ${floorWord(f)} still holds, /route clear drops it` : '' return 'route cleared for main' + held } const loop = st.loops.get(agentId) if (loop) writeLoop(loop, {}) return 'route cleared for this agent' } /** Truthful answer: states what the calling loop will actually do. */ function routedText(st: State, agentId: string | undefined, p: Picked): string { const note = agentId === undefined ? mainNote(st, p.route.effort) : '' if (note) return `recorded ${p.phase} for this turn, but ${note}` const loop = agentId === undefined ? undefined : st.loops.get(agentId) const effort = loop?.explicitEffort ? undefined : p.route.effort const model = agentId === undefined && st.cfg.mainModelSwitch ? p.route.model : undefined const modelNote = !p.route.model || model !== undefined ? '' : agentId === undefined ? ' (switch off)' : ' (fixed at spawn)' return `routed ${agentId === undefined ? 'main' : 'this agent'} to ` + `${p.phase}: effort ${effort ?? 'unchanged'}, model ` + `${model === undefined ? 'unchanged' : resolveModel(st.cfg, model)}` + modelNote } function handleRouteTool(st: State, e: RouteInput) { if (st.off) { return { result: 'model-router is off (/route on to resume); nothing routed', } } if (e.clear === true) return { result: clearLoop(st, e.agentId) } const picked = pickRoute(st.cfg, e.phase, e.effort) if (typeof picked === 'string') return { deny: picked } applyRoute(st, e.agentId, picked) return { result: routedText(st, e.agentId, picked) } } // ---- skills ---------------------------------------------------------- const skillResult = (skill: string, line: string) => ({ result: { success: true, commandName: skill, status: 'inline' as const }, context: [line], }) /** Answers Skill(effort-) in place: one writer, the skill never loads. */ function effortBridge(st: State, agentId: string | undefined, skill: string, level: Level) { if (agentId === undefined) { const route = { ...st.turnMain?.route, effort: level } st.turnMain = { phase: skill, route, source: 'skill' } const note = mainNote(st, level) return skillResult(skill, note ? `model-router: ${skill} recorded, but ${note}; the ${skill} skill ` + 'text was not loaded.' : `model-router: effort → ${level} for this loop from the next ` + `request on; the ${skill} skill text was not loaded.`) } const loop = loopOf(st, agentId) if (loop.explicitEffort) { return skillResult(skill, 'model-router: this agent was dispatched with ' + 'an explicit effort; the shift does not apply.') } loop.effort = level return skillResult(skill, `model-router: effort → ${level} for this ` + `loop from the next request on; the ${skill} skill text was not loaded.`) } /** A non-effort skill load: resets the loop's route, applies its table row. */ function onSkillLoad(st: State, skill: string, agentId: string | undefined) { const table = hasKey(st.cfg.skills, skill) ? st.cfg.skills[skill] : undefined const route = table === undefined ? undefined : phaseRoute(st.cfg, table) if (agentId === undefined) { st.turnMain = null if (table !== undefined && route) { st.turnMain = { phase: table, route, source: 'skill' } } return } const loop = route ? loopOf(st, agentId) : st.loops.get(agentId) if (loop) writeLoop(loop, { effort: route?.effort }) } /** A user-typed /effort-: a floor for the turn, prepends one line. */ function slashEffort(st: State, skill: string, text: string) { const level = EFFORT_SKILL.exec(skill)?.[1] if (!isLevel(level)) return undefined const route: Route = { effort: level } const slash: Routed = { phase: skill, route, source: 'slash' } st.turnFloor = higherFloor(st.turnFloor, slash) const note = mainNote(st, level) const line = `Effort ${level} set by model-router for the main loop this ` + 'turn (minimum; a higher route still applies).' + (note ? ` But ${note}.` : '') return { text: line + '\n' + text } } /** * Floor write for a skill.prompt. Only a typed slash may write it: the * marker from prompt.submit attests the typing. Without it, a live * sub-agent means the prompt is a preload inside that agent: ignored. */ function guardedSlash(st: State, skill: string, text: string) { if (!EFFORT_SKILL.test(skill)) return undefined if (st.typedSlash) { st.typedSlash = false } else if (st.loops.size > 0) { return { text: `model-router: ${skill} preload inside a live sub-agent is ` + 'ignored on the main loop.\n' + text, } } return slashEffort(st, skill, text) } // ---- agents ---------------------------------------------------------- type SpawnIn = { tool_use_id: string subagentType: string provider: { plugin: string } model?: string fork: boolean workflow?: unknown } /** * The table row of a built-in agent. Known limit: provider.plugin === * 'engine' is the best built-in test at spawn; a user agent named Explore * in a foreign project also matches (wave 1: sonnet/medium on it). */ function spawnRoute(cfg: Config, e: SpawnIn, frozen: boolean) { if (frozen || e.provider.plugin !== 'engine') return undefined if (!hasKey(cfg.agents, e.subagentType)) return undefined const phase = cfg.agents[e.subagentType] return phase === undefined ? undefined : phaseRoute(cfg, phase) } function trackLoop(st: State, e: SpawnIn, started: { agentId?: string }, route: Route | undefined): void { const given = st.explicitEffort.get(e.tool_use_id) st.explicitEffort.delete(e.tool_use_id) if (started.agentId === undefined) return st.loops.set(started.agentId, { explicitEffort: given !== undefined, effort: given ? undefined : route?.effort, }) } // ---- turn steps ------------------------------------------------------ function agentPlan(st: State, e: StepIn): Plan { const loop = e.agentId === undefined ? undefined : st.loops.get(e.agentId) return { model: e.model, effort: loop?.effort ?? e.effort } } /** True when the context still fits the target model's known window. */ async function windowOk($: Api, st: State, id: string): Promise { const limit = hasKey(st.cfg.windows, id) ? st.cfg.windows[id] : undefined if (limit === undefined) return true let tokens: number | undefined try { tokens = (await $.session.usage()).context.tokens } catch { tokens = undefined } if (typeof tokens === 'number' && tokens < limit) return true if (!st.windowWarned) { st.windowWarned = true $.ui.log(`model-router: no switch to ${id}: context not known to fit`) } return false } async function mainPlan($: Api, st: State, e: StepIn): Promise { const { effort } = mainEffort(st, e.effort) const wanted = mainModel(st) if (wanted === undefined || !st.cfg.mainModelSwitch) { return { model: e.model, effort } } const id = resolveModel(st.cfg, wanted) return { model: (await windowOk($, st, id)) ? id : e.model, effort } } async function planStep($: Api, st: State, e: StepIn): Promise { const plan = e.agentId === undefined ? await mainPlan($, st, e) : agentPlan(st, e) // Haiku takes no effort: omit it rather than send a hook-set value. return plan.model.startsWith(HAIKU) ? { ...plan, effort: undefined } : plan } function withPlan(e: StepIn, plan: Plan): StepIn { const { effort: _replaced, ...rest } = e const base = { ...rest, model: plan.model } return plan.effort === undefined ? base : { ...base, effort: plan.effort } } function stepLog(e: StepIn, plan: Plan): string { const id = e.agentId const loop = id === undefined ? 'main' : `agent ${id.slice(0, 8)}` return `step ${e.index} ${loop}: ${e.model}/${String(e.effort)} → ` + `${plan.model}/${String(plan.effort)}` } function noteMain($: Api, st: State, plan: Plan): void { st.lastMain = `${plan.model.replace(/^claude-/, '')}/${plan.effort ?? '-'}` refresh($, st) } function endMainTurn($: Api, st: State): void { st.turnFloor = st.pendingPrompt st.pendingPrompt = null st.turnMain = null st.typedSlash = false st.explicitEffort.clear() st.lastMain = '' st.windowWarned = false refresh($, st) } // ---- registration ---------------------------------------------------- function registerSession(on: On, st: State): void { on('session.start', async ($, e, next) => { await reloadConfig($, st) await registerCommand($) refresh($, st) return next(e) }).catch(($, e, next) => { warnOnce(st, $, 'session.start', next.error.kind) return next(e) }) on('session.end', async ($, e, next) => { Object.assign(st, newState(st.cfg, st.source)) // session.start never fires after /clear: re-apply the config's // `enabled` so a config-disabled router (offConfig) stays off. applyEnabled(st, st.cfg.enabled) return next(e) }).catch(($, e, next) => { warnOnce(st, $, 'session.end', next.error.kind) return next(e) }) } function registerCommandHook(on: On, st: State): void { on('command.run', { command: 'route' }, async ($, e) => { if (e.origin.kind !== 'composer') { return { text: 'route: user-only command' } } return { text: await handleCommand($, st, e.args) } }).catch(($, e, next) => { warnOnce(st, $, 'command.run', next.error.kind) return { text: `route failed (${next.error.kind})` } }) } function registerRouteTool(on: On, st: State): void { on('tool.call', { tool: TOOL }, async ($, e) => { const out = handleRouteTool(st, e) refresh($, st) vlog($, st, `route ${e.agentId ?? 'main'}: ${JSON.stringify(out)}`) return out }).catch(($, e, next) => { warnOnce(st, $, 'route tool', next.error.kind) return { result: `route failed (${next.error.kind}); nothing routed` } }) } function registerSkills(on: On, st: State): void { on('tool.call', { tool: 'Skill' }, async ($, e, next) => { const skill = typeof e.skill === 'string' ? e.skill : undefined if (st.off || skill === undefined) return next(e) const level = EFFORT_SKILL.exec(skill)?.[1] if (isLevel(level)) return effortBridge(st, e.agentId, skill, level) st.skillCalls += 1 try { safely(st, $, 'Skill', () => onSkillLoad(st, skill, e.agentId)) return await next(e) } finally { st.skillCalls -= 1 } }).catch(($, e, next) => { warnOnce(st, $, 'Skill', next.error.kind) return next(e) }) on('skill.prompt', async ($, e, next) => { if (st.off || st.skillCalls > 0) return next(e) const out = guardedSlash(st, e.skill, e.text) if (!out) return next(e) refresh($, st) return out }).catch(($, e, next) => { warnOnce(st, $, 'skill.prompt', next.error.kind) return next(e) }) } function registerAgents(on: On, st: State): void { on('tool.call', { tool: 'Agent' }, async ($, e, next) => { if (!st.off && isLevel(e.effort) && typeof e.tool_use_id === 'string') { st.explicitEffort.set(e.tool_use_id, e.effort) } return next(e) }).catch(($, e, next) => { warnOnce(st, $, 'Agent', next.error.kind) return next(e) }) } function registerSpawn(on: On, st: State): void { on('agent.spawn', async ($, e, next) => { if (st.off) return next(e) const frozen = e.fork || e.workflow !== undefined const route = spawnRoute(st.cfg, e, frozen) // An explicit model param on the Agent call always wins. const wanted = e.model === undefined ? route?.model : undefined const started = await next(wanted === undefined ? e : { ...e, model: resolveModel(st.cfg, wanted) }) if (typeof started.agentId === 'string') { safely(st, $, 'agent.spawn', () => { trackLoop(st, e, started, route) vlog($, st, `spawn ${e.subagentType}: ${e.model ?? '-'} → ${started.model}`) }) } return started }).catch(($, e, next) => { warnOnce(st, $, 'agent.spawn', next.error.kind) return next(e) }) } function registerTurns(on: On, st: State): void { on('turn.step', async function* ($, e, next) { if (st.off) return yield* next(e) const plan = await planStep($, st, e) const changed = plan.model !== e.model || plan.effort !== e.effort if (e.agentId === undefined) noteMain($, st, plan) vlog($, st, stepLog(e, plan)) const result = yield* next(changed ? withPlan(e, plan) : e) safely(st, $, 'turn.step', () => vlog($, st, `step ${e.index} answered by ${result.usage?.model ?? '?'}`)) return result }).catch(async function* ($, e, next) { warnOnce(st, $, 'turn.step', next.error.kind) return yield* next(e) }) on('turn.complete', async ($, e, next) => { if (e.agentId !== undefined) st.loops.delete(e.agentId) else endMainTurn($, st) return next(e) }).catch(($, e, next) => { warnOnce(st, $, 'turn.complete', next.error.kind) return next(e) }) } /** * A prompt's level is a floor now; typed mid-turn (`wait` is ignored, the * engine queues either way) it is also kept for the next turn. */ function floorFromPrompt(st: State, midTurn: boolean, routed: Routed): void { st.turnFloor = higherFloor(st.turnFloor, routed) if (midTurn) st.pendingPrompt = higherFloor(st.pendingPrompt, routed) } function registerPrompt(on: On, st: State): void { on('prompt.submit', async ($, e, next) => { if (st.off || e.origin.kind !== 'composer') return next(e) const scanned = e.text.slice(0, MAX_PROMPT_SCAN) const rule = st.rules.find(r => r.re.test(scanned)) const route = rule ? phaseRoute(st.cfg, rule.phase) : undefined if (rule && route) { const routed: Routed = { phase: rule.phase, route, source: 'prompt' } floorFromPrompt(st, e.turnId !== undefined, routed) refresh($, st) } if (e.text.trimStart().startsWith('/effort-')) st.typedSlash = true return next(e) }).catch(($, e, next) => { warnOnce(st, $, 'prompt.submit', next.error.kind) return next(e) }) on('ui.render', { component: 'Spinner' }, async ($, e, next) => { if (st.off || !st.cfg.spinner || !st.lastMain) return next(e) const suffix = ` · ${st.lastMain}…` return next({ ...e, props: { ...e.props, suffix } }) }).catch(($, e, next) => { warnOnce(st, $, 'ui.render', next.error.kind) return next(e) }) } export const register: Register = on => { const st = newState(mergeConfig(undefined, () => undefined), 'defaults') registerSession(on, st) registerCommandHook(on, st) registerRouteTool(on, st) registerSkills(on, st) registerAgents(on, st) registerSpawn(on, st) registerTurns(on, st) registerPrompt(on, st) }