Files
claude/mods/model-router/hooks/register.ts
T
bchanot 22455c051f feat(model-router): wave 3-A — first-use route confirmation, decision memory, project exceptions
routing.json (tracked, reached through the plugin directory) is now the
single source of the phase table and of every skill/agent row, plus the
decisions: confirmed rows/phases, changed rows (from/to) and projects
exceptions keyed by a normalized git remote (credentials never stored, no
machine paths). First use of a rowed typed skill, a rowed agent spawn or a
main-loop phase opens the engine's dialog (Later / Keep / two alternative
phases; Other = a phase name); a change asks Everywhere or This project
only. One dialog at a time, never in headless, never inside an agent,
never written by the model: only a dialog answer or /route ask writes,
serialized, size-capped, never creating the file. Layers: routing.json <
~/.claude/model-router.json; the project tree is never read. /route
pending, /route ask on|off. Census reads rows and phases from the file and
tolerates a user-changed row (WARN). Kit suite 86 → 190 tests.

Contract .claude/tasks/contracts/2026-10-10-model-router-w3a-confirm-1201.md,
plan r4: 3 lenses + 1 confirmation, feater + 4 rounds, GATE 0 MET,
verifier CONFORME then re-verify after security, security BLOCK(1) fixed
then PASS. Live: T2 dialogs answered by the user from the hot-loaded mod.
2026-10-11 11:48:25 +02:00

2576 lines
90 KiB
TypeScript

// model-router: routes each model request (main loop and sub-agents) to the
// model and effort its phase deserves. Phases name a TIER (an ordered list of
// aliases, the first available wins); a circuit breaker fed by the engine's
// own failure events marks a model down for a while. Phases, skill and agent
// rows come from routing.json (tracked next to this mod; the first use of a
// row asks the user once, see "first use" below), the rest from
// DEFAULT_CONFIG; ~/.claude/model-router.json overrides both per machine.
// 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 = {
tier?: string // a `tiers` key: the first AVAILABLE alias of its list
model?: string // alias or full id: explicit, never skipped when down
effort?: Level
}
type PromptMode = 'floor' | 'default'
type PromptRule = { pattern: string; phase: string; mode?: PromptMode }
type Config = {
models: Record<string, string> // alias -> full id
windows: Record<string, number> // full id -> context window (tokens)
phases: Record<string, Route>
agents: Record<string, string> // subagentType -> phase
skills: Record<string, string> // skill name -> phase
prompt: PromptRule[]
tiers: Record<string, string[]> // tier -> alias preference, best first
fallback: string[] // alias order, best first: the rank and the fallback chain
cooldownMinutes: number // breaker hold at the first strike
mainUpgrade: boolean // main may move UP to a phase's tier by itself
upgradeMaxTokens: number // above this context an upgrade is skipped
mainModelSwitch: boolean // gates DOWNGRADES only
verbose: boolean
spinner: boolean
enabled: boolean // false: every hook passes through (per machine)
}
type Rule = { re: RegExp; phase: string; mode: PromptMode }
type Source = 'user' | 'model' | 'skill' | 'prompt' | 'run' | 'derived'
type Routed = { phase: string; route: Route; source: Source }
type Hold = { until: number; reason: string } // until: ms, Infinity = reload
type Pushed = { prev: Routed | null; spawnIds: Set<string> }
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, prompt
// default rule, derived orchestrate; dropped at turn end
runMain: Routed | null // best-tier skill row: spans the turns of a run;
// only a skill, /route clear|off or a user /model write or drop it
turnFloor: Routed | null // user-explicit level for this turn (prompt rule):
// a floor, main loop only
pendingPrompt: Routed | null // typed mid-turn: the next turn's floor
typedSlash: string | null // the rowed skill the user typed at prompt.submit
pendingSlash: string | null // same, typed mid-turn: promoted at turn end
promptAllowed: boolean // this turn's prompt came from a typing origin
pendingAllowed: boolean // same, for the mid-turn prompt
spawning: number // agent.spawn hooks in flight (preloads fire inside)
offers: Map<string, string> // subagentType -> definition source
loops: Map<string, Loop> // agentId -> that loop's routing
explicitEffort: Map<string, Level> // 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`
spinner: string // "model/effort" of the last main step of this turn
lastPlan: Plan | null // the plan last sent on main: the breaker's target
turnModel: string | undefined // sticky main model, set when the router moved
sessionModel: string // the main loop's model as /model shows it, '' unknown
down: Map<string, Hold> // canonical id -> unavailable until (breaker)
strikes: Map<string, number> // canonical id -> episodes, drives the backoff
agentModels: Map<string, string> // agentId -> exact id sent at spawn
pushed: Pushed | null // derived orchestrate in force (background dispatch)
logged: Set<string> // session-once log lines
turnLogged: Set<string> // turn-once log lines
warned: Set<string> // hooks whose fail-open was already logged
mem: Memory // what routing.json says about decisions, as last read
override: Rec | undefined // the machine override's data, if it loaded
loaded: boolean // the layers were read once (else: lazily, on first use)
rebuildDue: boolean // /clear: re-read the layers at the next prompt
toggles: { switch?: boolean; verbose?: boolean } // session toggles
asking: string | null // key of the one first-use dialog in flight
asked: Set<string> // keys answered (or failed) this session
writes: Promise<unknown> // the serialized routing.json write chain
}
type Log = (text: string) => void
type StepIn = Readonly<TurnStepInput>
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 }
type Rec = Record<string, unknown>
type Table = Record<string, string>
type Row = 'skills' | 'agents' // a table of rows in routing.json
type Kind = Row | 'phases' // what a first-use decision is about
/** What routing.json says about decisions, as last read. */
type Memory = {
ok: boolean // the file was read: false = no dialog, no write
ask: boolean // dialogs on (the machine override wins over the file)
askLocal: boolean | undefined // the machine override's own `ask`
base: Record<Row, Table> // the file's rows, before any project layer
confirmed: Record<Kind, Table> // name -> the phase a Keep endorsed
local: Record<Row, Set<string>> // rows of projects[key] or the override
key: string | undefined // normalized repo key; undefined = no project layer
}
const LEVELS: readonly Level[] = ['low', 'medium', 'high', 'xhigh', 'max']
const MODEL_ID = /^claude-[a-z0-9.-]+$/
const TOOL = 'mcp__model-router__route'
const BEST = 'best' // the tier whose skill rows hold for a whole run
// Origins that are a person typing: only these arm a typed slash.
const TYPED_ORIGINS: ReadonlySet<string> = new Set([
'composer', 'sdk', 'bridge',
])
// Definition sources of a foreign repo's own agent: its row is skipped.
const PROJECT_SOURCES: ReadonlySet<string> = new Set([
'projectSettings', 'localSettings',
])
// PostModelSwitch sources a person chose (auto and resume are not).
const USER_SWITCH: ReadonlySet<string> = new Set([
'command', 'picker', 'sdk',
])
const OVERRIDE = '.claude/model-router.json'
const ROUTING = 'routing.json' // next to plugin.json: the tracked source
// Key order of a written routing.json; other keys follow, untouched.
const ROUTING_ORDER: readonly string[] = [
'phases', 'skills', 'agents', 'projects', 'confirmed', 'changed', 'ask',
]
// First-use dialog: answers, and the phases offered as alternatives.
const LATER = 'Later'
const KEEP = 'Keep'
const EVERYWHERE = 'Everywhere'
const THIS_PROJECT = 'This project only'
const ALTS: Record<Kind, readonly string[]> = {
skills: ['plan', 'reflect', 'implement', 'apply'],
agents: ['judge', 'implement', 'verify', 'apply'],
phases: [],
}
const ROWS: readonly Row[] = ['skills', 'agents']
const KINDS: readonly Kind[] = ['skills', 'agents', 'phases']
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 MAX_AGENT_MODELS = 256 // agent -> model entries kept (oldest dropped)
const PREFIX = 'claude-'
const ONE_M = /\[1m\]$/ // the long-context variant of a model id
// Hold = cooldownMinutes x step: 15 -> 30 -> 60 -> 120 -> 300 by default.
const BACKOFF_STEPS: readonly number[] = [1, 2, 4, 8, 20]
// StopFailure kinds that mean "this model cannot serve now". The others
// (context limit, network, auth...) are not availability.
const UNAVAILABLE: ReadonlySet<string> = new Set([
'rate_limit', 'overloaded', 'billing_error', 'model_not_found',
])
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 },
// A tier is an ordered alias list: the first one not down is used.
tiers: {
best: ['fable', 'opus', 'sonnet'],
big: ['opus', 'fable', 'sonnet'],
work: ['sonnet', 'opus'],
cheap: ['haiku', 'sonnet'],
},
fallback: ['fable', 'opus', 'sonnet', 'haiku'], // rank = index, best first
cooldownMinutes: 15,
mainUpgrade: true,
upgradeMaxTokens: 200000, // an upgrade re-reads the whole context cold
phases: {
plan: { tier: 'best', effort: 'xhigh' },
reflect: { tier: 'best', effort: 'high' },
orchestrate: { tier: 'best', effort: 'medium' },
escalate: { tier: 'best', effort: 'max' },
judge: { tier: 'big', effort: 'xhigh' },
implement: { tier: 'work', effort: 'medium' },
write: { tier: 'work', effort: 'high' },
verify: { tier: 'work', effort: 'xhigh' },
explore: { tier: 'work', effort: 'medium' },
apply: { tier: 'work', effort: 'low' },
mechanical: { tier: 'cheap', effort: 'low' },
},
// Rows (skill/agent -> phase) live in routing.json, the tracked source;
// a project-level agent of the same name shadows its row (see spawnRoute).
agents: {},
skills: {},
// `floor` rules set the turn's minimum effort; `default` rules set the
// turn's route, which a route call or a skill overrides. Neither lowers.
prompt: [
{ pattern: '\\bultrathink\\b', phase: 'escalate', mode: 'floor' },
{
pattern: '(?<![\\p{L}\\p{N}-])(plan|planifie|planning|brainstorm|' +
'architecture|con[c\u00e7]ois|design)(?![\\p{L}\\p{N}-])',
phase: 'plan',
mode: 'default',
},
{
pattern: '(?<![\\p{L}\\p{N}-])(pourquoi|why|explique|explain|analyse|' +
'analyze|comprendre|understand|review|audit)(?![\\p{L}\\p{N}-])',
phase: 'reflect',
mode: 'default',
},
],
mainModelSwitch: false,
verbose: false,
spinner: true,
enabled: true,
}
// ---- config ----------------------------------------------------------
const isRecord = (v: unknown): v is Record<string, unknown> =>
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<string, string>, v: unknown): v is string {
return typeof v === 'string' && (hasKey(models, v) || isFullId(v))
}
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<T>(
base: Record<string, T>,
user: unknown,
name: string,
accept: (key: string, value: unknown) => T | undefined,
log: Log,
): Record<string, T> {
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
/** A phase names a tier OR a model, never both; a tier must exist. */
function acceptPhase(
models: Record<string, string>,
tiers: Record<string, string[]>,
) {
return (key: string, v: unknown): Route | undefined => {
if (!PHASE_KEY.test(key) || !isRecord(v)) return undefined
if (v.tier !== undefined && v.model !== undefined) 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
}
if (v.tier !== undefined) {
if (typeof v.tier !== 'string' || !hasKey(tiers, v.tier)) return undefined
route.tier = v.tier
}
return route.effort || route.model || route.tier ? route : undefined
}
}
/** A tier: a non-empty alias list; unknown aliases dropped, duplicates too. */
function acceptTier(models: Record<string, string>, log: Log) {
return (key: string, v: unknown): string[] | undefined => {
// a tier named like an alias would make a bare name ambiguous
if (!PHASE_KEY.test(key) || hasKey(models, key)) return undefined
if (!Array.isArray(v)) return undefined
const items = v as unknown[]
const kept = items.filter(
(a): a is string => typeof a === 'string' && hasKey(models, a))
if (kept.length < items.length) {
log(`model-router: config tiers.${key} entries dropped`)
}
return kept.length > 0 ? [...new Set(kept)] : undefined
}
}
/** Appends the `models` aliases a user's chain omits, so all are ranked. */
function withEveryAlias(
chain: string[],
models: Record<string, string>,
log: Log,
): string[] {
const missing = Object.keys(models).filter(a => !chain.includes(a))
if (missing.length > 0) {
log(`model-router: config fallback lacks ${missing.join(', ')}; appended`)
}
return [...chain, ...missing]
}
/** The fallback chain: deduplicated aliases of `models`, else the default. */
function pickFallback(
user: unknown,
base: string[],
models: Record<string, string>,
log: Log,
): string[] {
if (user === undefined) return base
const items = Array.isArray(user) ? (user as unknown[]) : []
const kept = items.filter(
(a): a is string => typeof a === 'string' && hasKey(models, a))
if (kept.length > 0) return withEveryAlias([...new Set(kept)], models, log)
log('model-router: config fallback ignored: need a list of model aliases')
return base
}
function pickPositive(v: unknown, fallback: number, name: string, log: Log) {
if (v === undefined) return fallback
if (typeof v === 'number' && Number.isInteger(v) && v > 0) return v
log(`model-router: config ${name} ignored: not a positive integer`)
return fallback
}
function acceptPhaseRef(phases: Record<string, Route>) {
return (_key: string, v: unknown): string | undefined =>
typeof v === 'string' && PHASE_KEY.test(v) && hasKey(phases, v)
? v
: undefined
}
/** Unicode-aware first; a pattern only valid without `u` still works. */
function buildRegex(pattern: string): RegExp | undefined {
for (const flags of ['iu', 'i']) {
try {
return new RegExp(pattern, flags)
} catch {
// retry with the next flag set
}
}
return undefined
}
/** Absent `mode` = floor: override files written before modes keep meaning. */
function acceptRule(
phases: Record<string, Route>,
v: unknown,
): PromptRule | undefined {
if (!isRecord(v)) return undefined
const { pattern, phase, mode } = v
if (typeof pattern !== 'string' || typeof phase !== 'string') return undefined
if (mode !== undefined && mode !== 'floor' && mode !== 'default') {
return undefined
}
if (pattern.length > MAX_PATTERN) return undefined
return hasKey(phases, phase) && buildRegex(pattern)
? { pattern, phase, mode: mode ?? 'floor' }
: undefined
}
/** A user prompt array replaces the default rules; bad rules are dropped. */
function mergePrompt(
base: PromptRule[],
user: unknown,
phases: Record<string, Route>,
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)
}
type Routing = Pick<Config, 'tiers' | 'fallback' | 'cooldownMinutes' |
'mainUpgrade' | 'upgradeMaxTokens'>
/** Tiers, fallback chain and breaker knobs, validated against `models`. */
function mergeRouting(
base: Config,
user: Record<string, unknown>,
models: Record<string, string>,
log: Log,
): Routing {
const tiers = mergeTable(
base.tiers, user.tiers, 'tiers', acceptTier(models, log), log)
const fallback = pickFallback(user.fallback, base.fallback, models, log)
if (tiers.best?.[0] !== fallback[0]) {
log('model-router: tiers.best does not lead the fallback chain; rank ' +
'comes from the fallback list alone')
}
return {
tiers,
fallback,
cooldownMinutes: pickPositive(
user.cooldownMinutes, base.cooldownMinutes, 'cooldownMinutes', log),
mainUpgrade: pickBool(user.mainUpgrade, base.mainUpgrade),
upgradeMaxTokens: pickPositive(
user.upgradeMaxTokens, base.upgradeMaxTokens, 'upgradeMaxTokens', log),
}
}
/** A row table: `name: null` in the override drops a default row. */
function mergeRows(
base: Record<string, string>,
user: unknown,
name: string,
ref: (key: string, value: unknown) => string | undefined,
log: Log,
): Record<string, string> {
const entries = isRecord(user) ? Object.entries(user) : []
const kept = isRecord(user)
? Object.fromEntries(entries.filter(([, v]) => v !== null))
: user
const rows = mergeTable(base, kept, name, ref, log)
for (const [key, value] of entries) {
if (value === null && key !== '__proto__') delete rows[key]
}
return rows
}
/** `seed` (the defaults) overlaid with the user's entries, each validated. */
function mergeConfig(
user: unknown,
log: Log,
seed: Config = DEFAULT_CONFIG,
): Config {
const base = structuredClone(seed)
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 routing = mergeRouting(base, user, models, log)
const phases = mergeTable(base.phases, user.phases, 'phases',
acceptPhase(models, routing.tiers), log)
const ref = acceptPhaseRef(phases)
return {
models,
...routing,
windows: mergeTable(
base.windows, user.windows, 'windows', acceptWindow, log),
phases,
agents: mergeRows(base.agents, user.agents, 'agents', ref, log),
skills: mergeRows(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,
label: string,
log: Log,
): Promise<string | undefined> {
const tooBig = `model-router: ${label} 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 Layer = { path: string; data: Rec } | 'absent' | 'failed'
/** 'absent': no file (defaults apply). 'failed': present but unusable. */
async function readLayer(
$: Api,
path: string,
label: string,
log: Log,
): Promise<Layer> {
try {
if (!(await $.fs.exists(path))) return 'absent'
const text = await readCapped($, path, label, log)
if (text === undefined) return 'failed'
const data: unknown = JSON.parse(text)
if (isRecord(data)) return { path, data }
log(`model-router: ${label} is not an object`)
return 'failed'
} catch (err) {
log(`model-router: ${label} unreadable (${String(err)})`)
return 'failed'
}
}
const readRouting = ($: Api, log: Log): Promise<Layer> =>
readLayer($, `${$.plugin.root}/${ROUTING}`, ROUTING, log)
async function readOverride($: Api, log: Log): Promise<Layer> {
try {
const home = await $.env.get('HOME')
if (!home) return 'absent'
return await readLayer($, `${home}/${OVERRIDE}`, OVERRIDE, log)
} catch (err) {
log(`model-router: ${OVERRIDE} unreadable (${String(err)})`)
return 'failed'
}
}
// ---- routing.json: decision memory and project exceptions --------------
const strTable = (v: unknown): Table =>
isRecord(v)
? Object.fromEntries(Object.entries(v).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'))
: {}
const subRec = (v: unknown, key: string): Rec => {
const found = isRecord(v) && hasKey(v, key) ? v[key] : undefined
return isRecord(found) ? found : {}
}
/** The rows of `projects[key]`: this repo's exceptions. */
function projectRows(file: Rec, key: string | undefined): Record<Row, Table> {
const mine = key === undefined ? {} : subRec(file.projects, key)
return { skills: strTable(mine.skills), agents: strTable(mine.agents) }
}
function emptyMemory(): Memory {
return {
ok: false,
ask: true,
askLocal: undefined,
base: { skills: {}, agents: {} },
confirmed: { skills: {}, agents: {}, phases: {} },
local: { skills: new Set(), agents: new Set() },
key: undefined,
}
}
/** What the file and the machine override say about decisions. */
function buildMemory(
file: Rec,
key: string | undefined,
override: Rec | undefined,
): Memory {
const project = projectRows(file, key)
const flag = override?.ask
const askLocal = typeof flag === 'boolean' ? flag : undefined
const sure = subRec(file, 'confirmed')
const local = (row: Row): Set<string> => new Set([
...Object.keys(project[row]), ...Object.keys(strTable(override?.[row])),
])
return {
ok: true,
ask: askLocal ?? file.ask !== false,
askLocal,
base: { skills: strTable(file.skills), agents: strTable(file.agents) },
confirmed: {
skills: strTable(sure.skills),
agents: strTable(sure.agents),
phases: strTable(sure.phases),
},
local: { skills: local('skills'), agents: local('agents') },
key,
}
}
const SCP_REMOTE = /^[A-Za-z0-9._-]+@([A-Za-z0-9.-]+):([^:@]+)$/
const URL_REMOTE = /^[a-z][a-z0-9+.-]*:\/\//i
/** [host, path] of a remote, userinfo never read; undefined if unclear. */
function remoteParts(url: string): [string, string] | undefined {
const scp = SCP_REMOTE.exec(url)
if (scp) return [scp[1] as string, scp[2] as string]
if (!URL_REMOTE.test(url)) return undefined
// a second `@` in the authority hides a secret in the userinfo
const authority = url.replace(URL_REMOTE, '').split('/')[0] ?? ''
if (authority.split('@').length > 2) return undefined
try {
const u = new URL(url)
return [u.host, u.pathname]
} catch {
return undefined
}
}
/**
* A repo's identity across clones and machines: its origin remote reduced to
* `host/path` (scheme, userinfo, `.git` and trailing slash dropped, host
* lowercased). A remote that cannot be read with certainty, or that leaves
* an `@` or `:` in the host or an `@` in the path, gives undefined: no
* credential ever reaches the tracked file.
*/
function normalizeRemote(url: string): string | undefined {
const parts = remoteParts(url.trim())
if (parts === undefined) return undefined
const host = parts[0].toLowerCase()
const path = parts[1].replace(/^\/+/, '').replace(/\/+$/, '')
.replace(/\.git$/i, '')
if (host === '' || path === '' || path.includes('@')) return undefined
return /[@:]/.test(host.replace(/:\d+$/, '')) ? undefined : `${host}/${path}`
}
async function projectKey($: Api): Promise<string | undefined> {
try {
const repo = await $.session.repo()
return repo?.remote ? normalizeRemote(repo.remote) : undefined
} catch {
return undefined
}
}
/** routing.json's phases and rows as a user table, this repo's rows last. */
function routingTables(file: Rec, key: string | undefined): Rec {
const project = projectRows(file, key)
return {
phases: file.phases,
skills: { ...strTable(file.skills), ...project.skills },
agents: { ...strTable(file.agents), ...project.agents },
}
}
type Loaded = {
cfg: Config
source: string
mem: Memory
override: Rec | undefined
}
function sourceWord(file: Layer, over: Layer): string {
const fileWord = typeof file === 'string' ? undefined : ROUTING
const overWord = typeof over === 'string' ? undefined : over.path
if (fileWord && overWord) return `${fileWord} + ${overWord}`
return fileWord ?? overWord ?? 'defaults'
}
/** A phase routing.json lacks keeps the code default: one line names them. */
function logMissingPhases(phases: unknown, log: Log): void {
if (phases !== undefined && !isRecord(phases)) return // mergeTable says it
const missing = Object.keys(DEFAULT_CONFIG.phases)
.filter(name => !isRecord(phases) || !hasKey(phases, name))
if (missing.length === 0) return
log(`model-router: ${ROUTING} lacks phases ${missing.join(', ')}; ` +
'code defaults kept')
}
/**
* Defaults < routing.json < the machine override. The phases of both files
* merge first; every layer's rows then validate against that final table,
* so a routing.json row may name a phase only the override defines.
*/
function layerConfig(
tables: Rec | undefined,
override: Rec | undefined,
log: Log,
): Config {
const seeded = mergeConfig(tables && { phases: tables.phases }, log)
const cfg = mergeConfig(
override && { ...override, skills: undefined, agents: undefined },
log, seeded)
const ref = acceptPhaseRef(cfg.phases)
for (const row of ROWS) {
const base = mergeRows(cfg[row], tables?.[row], row, ref, log)
cfg[row] = mergeRows(base, override?.[row], row, ref, log)
}
return cfg
}
/** Defaults < routing.json (phases, rows, this repo's rows) < override. */
async function assemble(
$: Api,
file: Layer,
over: Layer,
log: Log,
): Promise<Loaded> {
const fileData = typeof file === 'string' ? undefined : file.data
const override = typeof over === 'string' ? undefined : over.data
if (file === 'absent') {
log(`model-router: ${ROUTING} missing; default phases, no rows, no asks`)
}
const key = fileData === undefined ? undefined : await projectKey($)
if (fileData !== undefined) logMissingPhases(fileData.phases, log)
const cfg = layerConfig(
fileData && routingTables(fileData, key), override, log)
const mem = fileData === undefined
? emptyMemory()
: buildMemory(fileData, key, override)
return { cfg, source: sourceWord(file, over), mem, override }
}
/**
* Reads the layers. At the first load a failed layer is skipped (logged); in
* every later read any failure, or a routing.json gone missing, returns
* undefined so the caller keeps the whole previous config.
*/
async function loadLayers(
$: Api,
first: boolean,
log: Log,
): Promise<Loaded | undefined> {
const file = await readRouting($, log)
const over = await readOverride($, log)
const broken = file === 'failed' || file === 'absent' || over === 'failed'
if (broken && !first) return undefined
return assemble($, file, over, log)
}
/** Compiles the prompt rules; one that fails to compile is dropped, logged. */
function compileRules(cfg: Config, log: Log): Rule[] {
const rules: Rule[] = []
for (const r of cfg.prompt) {
const re = buildRegex(r.pattern)
if (re) rules.push({ re, phase: r.phase, mode: r.mode ?? 'floor' })
else log(`model-router: prompt rule for ${r.phase} does not compile`)
}
return rules
}
// ---- state -----------------------------------------------------------
function newState(cfg: Config, source: string): State {
return {
cfg,
rules: compileRules(cfg, () => undefined),
source,
userMain: null,
turnMain: null,
runMain: null,
turnFloor: null,
pendingPrompt: null,
typedSlash: null,
pendingSlash: null,
promptAllowed: false,
pendingAllowed: false,
spawning: 0,
offers: new Map(),
loops: new Map(),
explicitEffort: new Map(),
skillCalls: 0,
off: false,
offConfig: false,
spinner: '',
lastPlan: null,
turnModel: undefined,
sessionModel: '',
down: new Map(),
strikes: new Map(),
agentModels: new Map(),
pushed: null,
logged: new Set(),
turnLogged: new Set(),
warned: new Set(),
mem: emptyMemory(),
override: undefined,
loaded: false,
rebuildDue: false,
toggles: {},
asking: null,
asked: new Set(),
writes: Promise.resolve(),
}
}
/**
* /clear rebuilds the state but keeps what is account- or process-wide:
* the breaker (a model's quota outlives the conversation), the model, the
* agent definitions' sources (the listing is not offered again), the config
* with its session toggles and the dialog in flight. The layers are read
* again at the next prompt; the config is swapped only if that read works.
*/
function resetSession(st: State): void {
const kept = {
down: st.down,
strikes: st.strikes,
agentModels: st.agentModels,
sessionModel: st.sessionModel,
offers: st.offers,
mem: st.mem,
override: st.override,
loaded: st.loaded,
toggles: st.toggles,
asking: st.asking,
writes: st.writes,
}
Object.assign(st, newState(st.cfg, st.source), kept)
st.rebuildDue = st.loaded
}
/** 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')
}
}
// ---- models: ids, tiers, breaker, the main decision -------------------
/** The table id an alias, a `[1m]` variant or a dated id stands for. */
function canonical(cfg: Config, id: string): string {
const bare = id.replace(ONE_M, '')
if (hasKey(cfg.models, bare)) return cfg.models[bare] ?? bare
if (!bare.startsWith(PREFIX) || bare.length <= PREFIX.length) return bare
const hit = Object.values(cfg.models).find(
known => bare.startsWith(known))
return hit ?? bare
}
/** The table alias of a canonical id; undefined for an id the table lacks. */
const aliasOf = (cfg: Config, id: string): string | undefined =>
Object.keys(cfg.models).find(alias => cfg.models[alias] === id)
/** Position in the fallback chain, best first; undefined when unranked. */
function modelRank(cfg: Config, id: string): number | undefined {
const alias = aliasOf(cfg, id)
const at = alias === undefined ? -1 : cfg.fallback.indexOf(alias)
return at < 0 ? undefined : at
}
const routeName = (r: Route | null | undefined): string | undefined =>
r?.tier ?? r?.model
/** First alias of the list whose model is not down. */
function availableIn(st: State, aliases: readonly string[]) {
for (const alias of aliases) {
const id = st.cfg.models[alias]
if (id !== undefined && !st.down.has(id)) return id
}
return undefined
}
/** The first model not down in the fallback chain, after `after`'s place. */
function nextAvailable(st: State, after: string | undefined) {
const { fallback, models } = st.cfg
const at = fallback.findIndex(alias => models[alias] === after)
return availableIn(st, fallback.slice(at + 1))
}
/**
* A tier, alias or id as an id to run on. A tier skips down aliases, then
* walks the global chain (from `after`, default the tier's last alias); an
* alias or id is explicit and never skipped.
*/
function resolveName(st: State, name: string, after?: string) {
const tier = hasKey(st.cfg.tiers, name) ? st.cfg.tiers[name] : undefined
if (tier === undefined) return canonical(st.cfg, name)
const last = tier[tier.length - 1]
const from = after ?? (last === undefined ? undefined : st.cfg.models[last])
return availableIn(st, tier) ?? nextAvailable(st, from)
}
function resolveRoute(st: State, route: Route | undefined) {
const name = routeName(route)
return name === undefined ? undefined : resolveName(st, name)
}
/** Drops lapsed holds; the clock is read only while something is held. */
async function prune($: Api, st: State): Promise<number> {
if (st.down.size === 0) return 0
const now = await $.clock.now()
for (const [id, hold] of st.down) {
if (hold.until <= now) st.down.delete(id)
}
return now
}
const holdWord = (until: number, now: number): string =>
until === Infinity
? 'until reload'
: `for ${Math.max(1, Math.ceil((until - now) / 60000))} min`
function holdMinutes(cfg: Config, strikes: number, reason: string): number {
if (reason === 'model_not_found') return Infinity
const step = BACKOFF_STEPS[Math.min(strikes, BACKOFF_STEPS.length) - 1] ?? 1
return cfg.cooldownMinutes * step
}
/**
* Marks a table model down for an episode. A mark on a model already down
* adds no strike and no log, except `model_not_found`, which lengthens a
* timed hold to "until reload". Inert while the router is off.
*/
function markDown(
$: Api,
st: State,
id: string,
reason: string,
now: number,
): void {
if (st.off || aliasOf(st.cfg, id) === undefined) return
const held = st.down.get(id)
const lengthen = reason === 'model_not_found' && held?.until !== Infinity
if (held && !lengthen) return
const strikes = (st.strikes.get(id) ?? 0) + (held ? 0 : 1)
st.strikes.set(id, strikes)
const until = now + holdMinutes(st.cfg, strikes, reason) * 60000
st.down.set(id, { until, reason })
$.ui.log(`model-router: ${id} unavailable (${reason}) ${
holdWord(until, now)}; routing falls back`)
}
/** `/route reload` and a user /model: the marks are stale. */
function clearBreaker(st: State, id?: string): void {
if (id === undefined) {
st.down.clear()
st.strikes.clear()
} else {
st.down.delete(id)
st.strikes.delete(id)
}
st.turnModel = undefined
}
type Call = {
model: string // exact string to send: `cur` verbatim when not moved
why: string
moved: boolean
log?: string // turn-once line
logKey?: string // its dedupe key when the text varies; default the text
once?: string // session-once line
}
const keep = (cur: string, why = 'unchanged', log?: string): Call =>
({ model: cur, why, moved: false, ...(log === undefined ? {} : { log }) })
/** The replacement keeps the session's `[1m]` tier, haiku has none. */
function moveTo(cur: string, target: string, why: string): Call {
const carried = ONE_M.test(cur) && !target.startsWith(HAIKU)
const model = carried ? `${target}[1m]` : target
const once = carried
? `model-router: ${why} to ${model}: the [1m] variant is carried over`
: undefined
return { model, why, moved: true, ...(once === undefined ? {} : { once }) }
}
/** True when the context still fits the target model's known window. */
function fits(st: State, id: string, tokens: number | undefined): boolean {
const limit = hasKey(st.cfg.windows, id) ? st.cfg.windows[id] : undefined
return limit === undefined || (tokens !== undefined && tokens < limit)
}
const noFit = (id: string): string =>
`model-router: no switch to ${id}: context not known to fit`
/** Why the upgrade cap blocks a move, or undefined. Unknown size = blocked. */
function capBlock(st: State, tokens: number | undefined): string | undefined {
if (tokens === undefined) return 'context size unknown'
const max = st.cfg.upgradeMaxTokens
return tokens > max ? `context ${tokens} tokens over ${max}` : undefined
}
/** True when `wanted` ranks above `id` in the fallback chain. */
function ranksAbove(st: State, wanted: string, id: string): boolean {
const rw = modelRank(st.cfg, wanted)
const rc = modelRank(st.cfg, id)
return rw !== undefined && rc !== undefined && rw < rc
}
/**
* `cur` is down: `wanted`, else the next model of the chain that fits. A
* better `wanted` is an upgrade and passes the same switch and cap.
*/
function leaveDown(
st: State,
cur: string,
wanted: string | undefined,
tokens: number | undefined,
): Call {
const id = canonical(st.cfg, cur)
const upOk = st.cfg.mainUpgrade && capBlock(st, tokens) === undefined
for (const option of [wanted, nextAvailable(st, id)]) {
if (option === undefined || st.down.has(option)) continue
if (aliasOf(st.cfg, option) === undefined) continue
if (option === wanted && !upOk && ranksAbove(st, option, id)) continue
if (fits(st, option, tokens)) return moveTo(cur, option, 'fallback')
}
return keep(cur, 'fallback unavailable')
}
/** `wanted` ranks above `cur`: upgrade, under the switch and the cap. */
function upgradeCall(
st: State,
cur: string,
wanted: string,
tokens: number | undefined,
): Call {
if (!st.cfg.mainUpgrade) return keep(cur, 'switch off')
const block = capBlock(st, tokens)
if (block !== undefined) {
const why = `upgrade skipped: ${block}`
const call = keep(cur, why, `model-router: ${why}`)
return { ...call, logKey: 'upgrade-skipped' }
}
if (!fits(st, wanted, tokens)) return keep(cur, 'no fit', noFit(wanted))
return moveTo(cur, wanted, 'upgrade')
}
/** `wanted` is cheaper (or unranked): only with the downgrade switch on. */
function downgradeCall(
st: State,
cur: string,
wanted: string,
tokens: number | undefined,
): Call {
if (!st.cfg.mainModelSwitch) return keep(cur, 'switch off')
if (!fits(st, wanted, tokens)) return keep(cur, 'no fit', noFit(wanted))
return moveTo(cur, wanted, 'downgrade')
}
/** A model the table does not rank is never switched; said once. */
function unknownCall(cur: string, id: string): Call {
const call = keep(cur, 'model unknown to the table')
if (id === '') return call
const once = `model-router: ${id} unknown to the models table; no switch`
return { ...call, once }
}
/**
* The one decision of the main loop's model, in this order: router off, a
* model the table does not rank (never touched), `cur` down (leave it,
* always), no or same wanted model, a better one (upgrade), a cheaper one
* (downgrade). Used by the step AND by every text, so they agree.
*/
function decideMain(
st: State,
cur: string,
wanted: string | undefined,
tokens: number | undefined,
): Call {
const id = canonical(st.cfg, cur)
if (st.off) return keep(cur)
if (modelRank(st.cfg, id) === undefined) return unknownCall(cur, id)
if (st.down.has(id)) return leaveDown(st, cur, wanted, tokens)
if (wanted === undefined) return keep(cur)
if (aliasOf(st.cfg, wanted) === aliasOf(st.cfg, id)) return keep(cur)
return ranksAbove(st, wanted, id)
? upgradeCall(st, cur, wanted, tokens)
: downgradeCall(st, cur, wanted, tokens)
}
/** Model axis: sticky, turn route, run slot, then the floor's own model. */
const mainModel = (st: State): string | undefined =>
routeName(st.userMain?.route) ??
routeName(st.turnMain?.route) ??
routeName(st.runMain?.route) ??
routeName(st.turnFloor?.route)
async function readTokens($: Api): Promise<number | undefined> {
try {
return (await $.session.usage()).context.tokens
} catch {
return undefined
}
}
type Verdict = { call: Call; wanted: string | undefined }
/** What the main loop would run on from `cur`: wanted model + decision. */
async function decideFor($: Api, st: State, cur: string): Promise<Verdict> {
const name = mainModel(st)
const wanted = name === undefined
? undefined
: resolveName(st, name, canonical(st.cfg, cur))
const needsTokens = wanted !== undefined || st.down.size > 0
const tokens = needsTokens ? await readTokens($) : undefined
return { call: decideMain(st, cur, wanted, tokens), wanted }
}
function logCall($: Api, st: State, call: Call): void {
const key = call.logKey ?? call.log
if (call.log !== undefined && key !== undefined && !st.turnLogged.has(key)) {
st.turnLogged.add(key)
$.ui.log(call.log)
}
if (call.once !== undefined && !st.logged.has(call.once)) {
st.logged.add(call.once)
$.ui.log(call.once)
}
}
/** The main loop's effective route: user /route > turn route > run slot. */
const mainRoute = (st: State): Routed | null =>
st.userMain ?? st.turnMain ?? st.runMain
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 ??
st.runMain?.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' }
}
const floorWord = (f: Routed): string =>
`user floor ${f.route.effort ?? '-'} (prompt rule ${f.phase})`
/**
* 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.runMain = 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 ------------------------------------------------------------
/** 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 }
}
/** An id the table knows, else "session model": '' and foreign ids alike. */
function idWord(st: State, model: string): string {
const known = aliasOf(st.cfg, canonical(st.cfg, model)) !== undefined
return known ? model : 'session model'
}
/** The router left a down model, or wanted to and found nowhere to go. */
const isFallback = (call: Call): boolean => call.why.startsWith('fallback')
/** Model words of the main line, from the decision a step would take. */
function modelWord(st: State, v: Verdict): string {
const id = idWord(st, v.call.model)
if (v.call.moved) return `${id} (${v.call.why})`
if (v.wanted === undefined) {
return isFallback(v.call) ? `${id} (${v.call.why})` : '-'
}
if (canonical(st.cfg, v.call.model) === v.wanted) return id
return `asked ${v.wanted}, keeps ${id} (${v.call.why})`
}
/** The tier a route names, shown beside the model it resolved to. */
function tierWord(st: State, word: string): string {
const name = mainModel(st)
const tier = name !== undefined && hasKey(st.cfg.tiers, name)
return tier ? `${word} [tier ${name}]` : word
}
/** Haiku takes no effort: the main line says so when it will run there. */
function effortWord(st: State, v: Verdict): string {
if (v.call.model.startsWith(HAIKU)) return '- (haiku takes none)'
return String(mainEffort(st, undefined).effort ?? '-')
}
function mainText(st: State, v: Verdict): string {
const r = mainRoute(st)
const live = liveFloor(st)
const floor = live ? ` · floor ${live.level} (${live.f.phase})` : ''
if (!r) {
const left = v.call.moved || isFallback(v.call)
? ` · model ${modelWord(st, v)}`
: ''
return 'main: session defaults' + left + floor
}
const model = tierWord(st, modelWord(st, v))
return `main: ${r.source} ${r.phase} · model ${model} · effort ${
effortWord(st, v)}${floor}`
}
/** `name=<tier or model>→<resolved id>/<effort>` for every phase. */
function phasesText(st: State): string {
const entries = Object.entries(st.cfg.phases).map(([name, r]) => {
const asked = routeName(r)
const id = asked === undefined ? undefined : resolveName(st, asked)
const model = asked === undefined
? 'session'
: asked === id ? asked : `${asked}→${id ?? '-'}`
return `${name}=${model}/${r.effort ?? 'session'}`
})
return `phases: ${entries.join(' ')}`
}
function downText(st: State, now: number): string {
const held = [...st.down].map(([id, h]) =>
`${id} ${holdWord(h.until, now)} (${h.reason})`)
return `down: ${held.length > 0 ? held.join(', ') : 'none'}`
}
/** The verdict a text reports: what the next main step would decide. */
async function snapshot($: Api, st: State) {
const now = await prune($, st)
const cur = st.turnModel ?? st.sessionModel
return { now, ...(await decideFor($, st, cur)) }
}
async function show($: Api, st: State): Promise<string> {
const c = st.cfg
const flag = (b: boolean) => (b ? 'on' : 'off')
const s = await snapshot($, st)
return [
mainText(st, s),
`router: ${routerWord(st)} · switch: ${flag(c.mainModelSwitch)} ` +
`(downgrade) · upgrade: ${flag(c.mainUpgrade)} · verbose: ${
flag(c.verbose)} · spinner: ${flag(c.spinner)}`,
downText(st, s.now),
`live loops: ${st.loops.size}`,
phasesText(st),
`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' }
}
async function toggle(
$: Api,
st: State,
what: string,
arg: string | undefined,
): Promise<string> {
if (arg !== 'on' && arg !== 'off') return `usage: /route ${what} on|off`
if (what === 'switch') st.toggles.switch = arg === 'on'
else st.toggles.verbose = arg === 'on'
applyToggles(st)
return show($, st)
}
async function setUserRoute($: Api, st: State, args: string) {
const parsed = parseRoute(st.cfg, args)
if (typeof parsed === 'string') return parsed
st.userMain = parsed
refresh($, st)
return show($, st)
}
/** Session toggles outlive any rebuild of the config. */
function applyToggles(st: State): void {
const { switch: downgrade, verbose } = st.toggles
if (downgrade !== undefined) st.cfg.mainModelSwitch = downgrade
if (verbose !== undefined) st.cfg.verbose = verbose
}
function applyLoaded(st: State, loaded: Loaded, log: Log): void {
st.cfg = loaded.cfg
st.rules = compileRules(loaded.cfg, log)
st.source = loaded.source
st.mem = loaded.mem
st.override = loaded.override
st.loaded = true
st.rebuildDue = false
applyToggles(st)
applyEnabled(st, loaded.cfg.enabled)
}
/**
* (Re)loads the layers into the state and re-registers the route tool.
* After the first load an unusable layer keeps the whole previous config:
* the kill switch fails closed, never back to the defaults. True when the
* new config was swapped in.
*/
async function reloadConfig($: Api, st: State): Promise<boolean> {
const log = (text: string): void => $.ui.log(text)
const loaded = await loadLayers($, !st.loaded, log)
if (loaded) applyLoaded(st, loaded, log)
else log('model-router: config unreadable; keeping the previous config')
await registerTool($, st)
return loaded !== undefined
}
/**
* The layers are read at session.start; a state that never saw it (a
* /reload-plugins re-runs register) loads on its first use. /clear leaves
* a re-read due, taken by the next prompt only (`atPrompt`).
*/
async function ensureConfig($: Api, st: State, atPrompt = false) {
if (st.loaded && !(atPrompt && st.rebuildDue)) return
st.rebuildDue = false
await reloadConfig($, st)
}
/** The breaker is cleared first, whatever the config read then does. */
async function reloadCommand($: Api, st: State): Promise<string> {
clearBreaker(st)
await reloadConfig($, st)
refresh($, st)
return 'config reloaded\n' + (await show($, st))
}
async function handleCommand($: Api, st: State, args: string): Promise<string> {
const [head = '', ...rest] = args.trim().split(/\s+/)
switch (head) {
case '':
case 'show':
return show($, st)
case 'clear': {
const run = st.runMain !== null
clearRoutes(st)
refresh($, st)
return 'route cleared' + (run ? '; run slot dropped' : '') +
'\n' + (await show($, st))
}
case 'on':
case 'off':
st.off = head === 'off'
st.offConfig = false
if (st.off) st.runMain = null
refresh($, st)
return show($, st)
case 'reload':
return reloadCommand($, st)
case 'pending':
return pendingText(st)
case 'ask':
return askCommand($, st, rest[0])
case 'switch':
case 'verbose':
return toggle($, st, head, rest[0])
default:
return setUserRoute($, st, args)
}
}
// ---- first use: one dialog per row, the answer kept in routing.json -----
// The first time a row routes (a typed skill, an agent spawn, a phase
// declared through the route tool) the user confirms it once, for every
// project; a change can be a project exception. Only a dialog answer or
// `/route ask` writes; nothing is asked on the step path or in an agent.
const HEADER = 'model-router'
const UPDATED = 'routing.json updated: commit it from the config repo ' +
'(chore branch)'
type Req = { kind: Kind; name: string; phase: string }
type RowReq = Req & { kind: Row }
type Answer =
| { act: 'later' }
| { act: 'keep' }
| { act: 'change'; phase: string }
type Scope = 'everywhere' | 'project' | 'later'
type Patch = (file: Rec) => void
const keyOf = (req: { kind: Kind; name: string }): string =>
`${req.kind}:${req.name}`
const isRowReq = (req: Req): req is RowReq => req.kind !== 'phases'
const own = (t: Table, name: string): string | undefined =>
hasKey(t, name) ? t[name] : undefined
/** Decided: a Keep or change confirmed the row, or a layer set it. */
function isDecided(mem: Memory, kind: Kind, name: string): boolean {
if (kind === 'phases') return own(mem.confirmed.phases, name) === name
if (mem.local[kind].has(name)) return true
const base = own(mem.base[kind], name)
return base !== undefined && own(mem.confirmed[kind], name) === base
}
const wantsAsk = (st: State, req: Req): boolean =>
!st.off && st.mem.ask && !st.asked.has(keyOf(req)) &&
!isDecided(st.mem, req.kind, req.name)
/** The file as it is now: another live session may have decided since. */
async function refreshMemory($: Api, st: State): Promise<boolean> {
const file = await readRouting($, text => $.ui.log(text))
if (typeof file === 'string') return false
st.mem = buildMemory(file.data, await projectKey($), st.override)
return true
}
async function askOr(
$: Api,
text: string,
options: readonly string[],
): Promise<string> {
try {
return await $.ui.ask(text, { options, header: HEADER })
} catch {
return LATER // dismissed, or no one to ask (-p run)
}
}
function optionsFor(st: State, req: Req): string[] {
const alts = ALTS[req.kind]
.filter(p => p !== req.phase && hasKey(st.cfg.phases, p))
return req.kind === 'phases'
? [LATER, KEEP]
: [LATER, KEEP, ...alts.slice(0, 2)]
}
/** Keep, a phase of the table (an alt or free text), else Later. */
function decide($: Api, st: State, req: Req, answer: string): Answer {
if (answer === KEEP) return { act: 'keep' }
if (answer === LATER || req.kind === 'phases') return { act: 'later' }
if (!hasKey(st.cfg.phases, answer)) {
const shown = answer.slice(0, 40).replace(/[\u0000-\u001f]/g, ' ')
$.ui.toast(`unknown phase "${shown}", default kept`)
return { act: 'later' }
}
return answer === req.phase
? { act: 'keep' }
: { act: 'change', phase: answer }
}
async function askScope(
$: Api,
st: State,
req: Req,
to: string,
): Promise<Scope> {
const key = st.mem.key
const text = `Use ${to} for ${req.name} everywhere, or only in this project?`
const answer = await askOr($, text,
[EVERYWHERE, key === undefined ? LATER : THIS_PROJECT])
if (answer === EVERYWHERE) return 'everywhere'
return answer === THIS_PROJECT && key !== undefined ? 'project' : 'later'
}
function table(parent: Rec, key: string): Rec {
const found = isRecord(parent[key]) ? parent[key] : undefined
if (found) return found
const fresh: Rec = {}
parent[key] = fresh
return fresh
}
function setKey(target: Rec, key: string, value: unknown): void {
if (key !== '__proto__') target[key] = value
}
const confirmIn = (file: Rec, kind: Kind, name: string, phase: string) =>
setKey(table(table(file, 'confirmed'), kind), name, phase)
/** A Keep endorses the row and its phase (no second ask at the route tool). */
function keepPatch(req: Req): Patch {
return file => {
if (isRowReq(req)) confirmIn(file, req.kind, req.name, req.phase)
confirmIn(file, 'phases', req.phase, req.phase)
}
}
/**
* Everywhere: the row moves, `changed` keeps the FIRST shipped phase for
* the census. This project only: the row stays, the exception goes under
* the repo key and the base row counts as confirmed (nobody else asks).
*/
function changePatch(req: RowReq, to: string, scope: Scope, key?: string) {
return (file: Rec): void => {
const rows = table(file, req.kind)
const was = typeof rows[req.name] === 'string' ? rows[req.name] : req.phase
if (scope === 'project' && key !== undefined) {
const mine = table(table(file, 'projects'), key)
setKey(table(mine, req.kind), req.name, to)
confirmIn(file, req.kind, req.name, String(was))
return
}
const changed = table(table(file, 'changed'), req.kind)
const first = subRec(changed, req.name).from ?? was
setKey(rows, req.name, to)
setKey(changed, req.name, { from: first, to })
confirmIn(file, req.kind, req.name, to)
}
}
function serializeRouting(file: Rec): string {
const known = ROUTING_ORDER.filter(k => hasKey(file, k))
const extra = Object.keys(file).filter(k => !ROUTING_ORDER.includes(k))
const ordered = Object.fromEntries(
[...known, ...extra].map(k => [k, file[k]]))
return JSON.stringify(ordered, null, 2) + '\n'
}
/**
* Read-modify-write of routing.json, one at a time (a chain). Refuses a
* missing or unparsable file and never creates it; rebuilds the config
* after, swapped only if the read works.
*/
async function writeNow($: Api, st: State, patch: Patch): Promise<boolean> {
const file = await readRouting($, text => $.ui.log(text))
if (typeof file === 'string') {
$.ui.toast(`${ROUTING} is missing or unreadable: nothing saved`)
return false
}
patch(file.data)
const out = serializeRouting(file.data)
if (out.length > MAX_CONFIG_BYTES) {
$.ui.toast(`${ROUTING} would exceed ${MAX_CONFIG_BYTES} bytes: ` +
'nothing saved')
return false
}
await $.fs.write(file.path, out)
const rebuilt = await reloadConfig($, st)
$.ui.toast(UPDATED)
if (!rebuilt) throw new Error('rebuild failed after the write')
return true
}
function writeRouting($: Api, st: State, patch: Patch): Promise<boolean> {
const run = st.writes.then(() => writeNow($, st, patch))
st.writes = run.catch(() => undefined)
return run
}
async function saveAnswer($: Api, st: State, req: Req, ans: Answer) {
if (ans.act === 'keep') return writeRouting($, st, keepPatch(req))
if (ans.act !== 'change' || !isRowReq(req)) return false
const scope = await askScope($, st, req, ans.phase)
if (scope === 'later') return false
const patch = changePatch(req, ans.phase, scope, st.mem.key)
return writeRouting($, st, patch)
}
/** The dialog and what follows; true when routing.json was rewritten. */
async function runDialog($: Api, st: State, req: Req, text: string) {
const answer = await askOr($, text, optionsFor(st, req))
st.asked.add(keyOf(req))
return saveAnswer($, st, req, decide($, st, req, answer))
}
function failOnce($: Api, st: State, err: unknown): void {
const text = `model-router: first-use dialog failed (${String(err)}); ` +
'default route kept'
if (st.logged.has(text)) return
st.logged.add(text)
$.ui.log(text)
}
/**
* Asks once whether `req.phase` is the right route for this row. At most
* one dialog is open per session: any other use finds `asking` set and
* routes unasked. The caller awaits it outside `safely` (it never throws).
* `say` builds the question from the real decision, only if one is asked.
* True when routing.json changed: the caller recomputes its route.
*/
async function confirmFirst(
$: Api,
st: State,
req: Req,
say: () => Promise<string>,
): Promise<boolean> {
if (st.asking !== null || !wantsAsk(st, req)) return false
st.asking = keyOf(req)
try {
if (!(await refreshMemory($, st)) || !wantsAsk(st, req)) return false
return await runDialog($, st, req, await say())
} catch (err) {
st.asked.delete(keyOf(req))
failOnce($, st, err)
return false
} finally {
st.asking = null
}
}
/** First-use question text: the id and level the next step really gets. */
async function mainQuestion($: Api, st: State, lead: string) {
const v = await snapshot($, st)
return `${lead} ${idWord(st, v.call.model)} at ${
effortWord(st, v)}. Keep it?`
}
function spawnQuestion(
$: Api,
st: State,
e: SpawnIn,
req: Req,
route: Route,
): string {
const id = spawnTarget($, st, e, route) ?? 'its own model'
const effort = st.explicitEffort.get(e.tool_use_id) ?? route.effort
return `First dispatch of ${req.name}: ${req.phase}, ${id} at ${
effort ?? 'its own effort'}. Keep it?`
}
/** Typed skill: a changed row re-routes the turn at once. */
async function confirmSkill($: Api, st: State, skill: string, row: Picked) {
const req: Req = { kind: 'skills', name: skill, phase: row.phase }
const lead = `First route for /${skill}: ${row.phase}, next step`
if (!(await confirmFirst($, st, req, () => mainQuestion($, st, lead)))) {
return
}
const after = skillRow(st, skill)
if (after === undefined) return
routeMainBySkill(st, after, true)
refresh($, st)
}
/** Rowed spawn: the route in force after the dialog (changed or not). */
async function routeSpawn($: Api, st: State, e: SpawnIn) {
const frozen = e.fork || e.workflow !== undefined
const route = spawnRoute(st, e, frozen)
const phase = hasKey(st.cfg.agents, e.subagentType)
? st.cfg.agents[e.subagentType]
: undefined
const inside = e.parentAgentId !== undefined || e.model !== undefined
if (!route || !phase || inside) return route
const req: Req = { kind: 'agents', name: e.subagentType, phase }
const say = async () => spawnQuestion($, st, e, req, route)
return (await confirmFirst($, st, req, say))
? spawnRoute(st, e, frozen)
: route
}
/** A phase declared through the route tool: confirm-only. */
async function confirmPhase($: Api, st: State, phase: string) {
const req: Req = { kind: 'phases', name: phase, phase }
const lead = `First use of ${phase} on the main loop:`
await confirmFirst($, st, req, () => mainQuestion($, st, lead))
}
// ---- first-use commands ----------------------------------------------
/** Rows and phases not confirmed yet, those asked this session first. */
function pendingText(st: State): string {
const m = st.mem
if (!m.ok) return `${ROUTING} missing or unreadable: nothing to confirm`
const names = (kind: Kind): string[] => Object.keys(
kind === 'phases' ? st.cfg.phases : m.base[kind])
.filter(name => !isDecided(m, kind, name))
const all = KINDS.flatMap(kind => names(kind).map(name => ({ kind, name })))
.map(keyOf)
const asked = all.filter(k => st.asked.has(k))
const rest = all.filter(k => !st.asked.has(k))
return [
`pending confirmation: ${all.length}` +
(m.ask ? '' : ' (asking is off: /route ask on)'),
`asked this session: ${asked.join(' ') || 'none'}`,
`not asked yet: ${rest.join(' ') || 'none'}`,
].join('\n')
}
async function askCommand($: Api, st: State, arg: string | undefined) {
if (arg !== 'on' && arg !== 'off') return 'usage: /route ask on|off'
const patch: Patch = file => { file.ask = arg === 'on' }
try {
if (!(await writeRouting($, st, patch))) {
return `${ROUTING} missing or unreadable: ask unchanged`
}
} catch (err) {
return `ask not applied (${String(err)})`
}
const local = st.mem.askLocal
const note = local === undefined ? '' : `; ~/${OVERRIDE} sets ask ${
local ? 'on' : 'off'} and wins here`
return `ask ${arg}${note}`
}
// ---- route tool ------------------------------------------------------
async function registerTool($: Api, st: State): Promise<void> {
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 model. Call it before a span of ' +
'work changes nature (planning, orchestrating, mechanical work). ' +
'The main loop moves up to a phase\'s tier by itself (below the ' +
'context cap), down only with the switch on; a sub-agent\'s model ' +
'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<void> {
try {
await $.command.register({
name: 'route',
description: 'model-router: show or set the model and effort route',
argumentHint:
'[show|clear|off|on|reload|pending|ask on|off|<phase>|' +
'model=<alias|id> effort=<level>|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`
: ''
const run = st.runMain
? `; run ${st.runMain.phase} still holds`
: ''
return 'route cleared for main' + held + run
}
const loop = st.loops.get(agentId)
if (loop) writeLoop(loop, {})
return 'route cleared for this agent'
}
/** The id the next main step runs on, with the decision's reason. */
async function mainAnswer($: Api, st: State): Promise<string> {
const { call } = await snapshot($, st)
return `${idWord(st, call.model)} (${call.why})`
}
/** Main's answer: effort and model asked, the sticky/floor note appended. */
async function mainRoutedText($: Api, st: State, p: Picked) {
const base = `routed main to ${p.phase}: effort ${
p.route.effort ?? 'unchanged'}, model ${await mainAnswer($, st)}`
const note = mainNote(st, p.route.effort)
return note ? `${base}; but ${note}` : base
}
/** Truthful answer: states what the calling loop will actually do. */
async function routedText(
$: Api,
st: State,
agentId: string | undefined,
p: Picked,
): Promise<string> {
if (agentId === undefined) return mainRoutedText($, st, p)
const explicit = st.loops.get(agentId)?.explicitEffort
const model = routeName(p.route) ? 'unchanged (fixed at spawn)' : 'unchanged'
return `routed this agent to ${p.phase}: effort ${
explicit ? 'unchanged' : p.route.effort ?? 'unchanged'}, model ${model}`
}
async function handleRouteTool($: Api, 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)
if (e.agentId === undefined && typeof e.phase === 'string') {
await confirmPhase($, st, picked.phase)
}
return { result: await routedText($, st, e.agentId, picked) }
}
// ---- skills ----------------------------------------------------------
/** A skill's table row: its phase and that phase's route, if both exist. */
function skillRow(st: State, skill: string): Picked | undefined {
const phase = hasKey(st.cfg.skills, skill) ? st.cfg.skills[skill] : undefined
const route = phase === undefined ? undefined : phaseRoute(st.cfg, phase)
return phase !== undefined && route ? { phase, route } : undefined
}
/**
* A rowed skill on main sets the turn route; a best-tier row also holds the
* run slot. A typed non-best row drops the run; one the model loads (a
* helper skill inside a run) leaves it.
*/
function routeMainBySkill(st: State, row: Picked, typed: boolean): void {
const turn: Routed = { phase: row.phase, route: { ...row.route },
source: 'skill' }
st.turnMain = turn
if (row.route.tier === BEST) st.runMain = { ...turn, source: 'run' }
else if (typed) st.runMain = null
}
/** A model-loaded skill: its row applies; an unrowed one changes nothing. */
function onSkillLoad(st: State, skill: string, agentId: string | undefined) {
const row = skillRow(st, skill)
if (agentId === undefined) {
if (row) routeMainBySkill(st, row, false)
return
}
if (row) writeLoop(loopOf(st, agentId), row.route)
}
type TypedPath = 'typed-marker' | 'typed-fallback'
/**
* Whether a skill.prompt on main is the user's own typing. The marker from
* prompt.submit names the skill; failing that, an allowed-origin prompt with
* no live or spawning sub-agent (a preload fires inside one) still counts.
*/
function typedPath(st: State, skill: string): TypedPath | undefined {
if (st.typedSlash === skill) {
st.typedSlash = null
return 'typed-marker'
}
const idle = st.loops.size === 0 && st.spawning === 0
return st.promptAllowed && idle ? 'typed-fallback' : undefined
}
/** Routes main by the typed skill's row; the row, for the first-use ask. */
function armTypedSkill($: Api, st: State, skill: string) {
const row = skillRow(st, skill)
const path = typedPath(st, skill)
if (row === undefined || path === undefined) return undefined
routeMainBySkill(st, row, true)
refresh($, st)
vlog($, st, `skill ${skill}: ${path} → ${row.phase}`)
return row
}
/** Bookkeeping that may fail without breaking the hook: fail-open. */
function armed($: Api, st: State, skill: string): Picked | undefined {
try {
return armTypedSkill($, st, skill)
} catch {
warnOnce(st, $, 'skill.prompt', 'bookkeeping')
return undefined
}
}
// ---- agents ----------------------------------------------------------
type SpawnIn = {
tool_use_id: string
subagentType: string
model?: string
parentAgentId?: string // set when the spawn happens inside an agent
fork: boolean
workflow?: unknown
}
/**
* The table row of an agent, for every provider. Skipped for a fork, a
* workflow agent, and an agent whose definition came from the project (a
* foreign repo's own verifier.md). Known limit: the source is recorded by
* name only, so an offer fired inside a sub-agent with another cwd
* overwrites it; no record = the row applies (fail-open on routing).
*/
function spawnRoute(st: State, e: SpawnIn, frozen: boolean) {
if (frozen || !hasKey(st.cfg.agents, e.subagentType)) return undefined
if (PROJECT_SOURCES.has(st.offers.get(e.subagentType) ?? '')) {
return undefined
}
const phase = st.cfg.agents[e.subagentType]
return phase === undefined ? undefined : phaseRoute(st.cfg, phase)
}
/** Once per turn: a spawn whose tier has nothing at or above its head. */
function tierDownLog($: Api, st: State, agent: string, tier: string): void {
const text = `model-router: ${agent} tier ${tier} down, ` +
'frontmatter model kept'
const key = `spawn-down:${agent}:${tier}`
if (st.turnLogged.has(key)) return
st.turnLogged.add(key)
$.ui.log(text)
}
/**
* The first model of the route's tier that is not down, only when it ranks
* at or above the tier's head: an agent moves UP from its frontmatter alias,
* never below. Nothing qualifies: no write, the frontmatter model runs.
*/
function inTier($: Api, st: State, agent: string, tier: string) {
const aliases = hasKey(st.cfg.tiers, tier) ? st.cfg.tiers[tier] : undefined
const head = aliases?.[0]
const headId = head === undefined ? undefined : st.cfg.models[head]
if (aliases === undefined || headId === undefined) return undefined
const id = availableIn(st, aliases)
if (id !== undefined && (id === headId || ranksAbove(st, id, headId))) {
return id
}
tierDownLog($, st, agent, tier)
return undefined
}
/** The model a spawn is rewritten to; an explicit `model` param wins. */
function spawnTarget($: Api, st: State, e: SpawnIn, route: Route | undefined) {
if (e.model !== undefined || route === undefined) return undefined
if (route.tier === undefined) return resolveRoute(st, route)
return inTier($, st, e.subagentType, route.tier)
}
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,
})
}
/** The breaker's target for an agent's failure; oldest entries dropped. */
function rememberAgent(st: State, agentId: string, model: string): void {
st.agentModels.set(agentId, model.replace(ONE_M, ''))
if (st.agentModels.size <= MAX_AGENT_MODELS) return
const oldest = st.agentModels.keys().next().value
if (oldest !== undefined) st.agentModels.delete(oldest)
}
// ---- derived orchestrate ---------------------------------------------
/**
* A main Agent dispatch is orchestration: the turn's route becomes
* `orchestrate` until the spawned agents end. A route the model or a skill
* declared is never replaced.
*/
function pushOrchestrate(st: State): void {
const src = st.turnMain?.source
const route = phaseRoute(st.cfg, 'orchestrate')
if (st.pushed !== null || !route) return
if (src === 'model' || src === 'skill') return
st.pushed = { prev: st.turnMain, spawnIds: new Set() }
st.turnMain = { phase: 'orchestrate', route: { ...route }, source: 'derived' }
}
/** Restores the route from before the dispatch, unless one replaced it. */
function popOrchestrate(st: State): void {
if (st.pushed && st.turnMain?.source === 'derived') {
st.turnMain = st.pushed.prev
}
st.pushed = null
}
/** The agent id of a background launch, from the Agent tool's result. */
function launchedId(result: unknown): string | undefined {
if (!isRecord(result) || result.status !== 'async_launched') return undefined
return typeof result.agentId === 'string' ? result.agentId : undefined
}
/** After the Agent call: wait for a background agent, else pop at once. */
function afterDispatch(st: State, result: unknown): void {
const id = launchedId(result)
if (id !== undefined) {
pushOrchestrate(st)
st.pushed?.spawnIds.add(id)
} else if (st.pushed && st.pushed.spawnIds.size === 0) {
popOrchestrate(st)
}
}
/** An agent's loop ended: forget it, pop when it was the last awaited. */
function endAgent(st: State, agentId: string): void {
st.loops.delete(agentId)
const awaited = st.pushed?.spawnIds
if (awaited?.delete(agentId) && awaited.size === 0) popOrchestrate(st)
}
// ---- 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 }
}
/**
* The main loop's plan: effort from the floor/sticky/turn decision, model
* from `decideMain`. `cur` is the model the router moved this turn, else
* the engine's own (verbatim: an engine fallback is respected).
*/
async function mainPlan($: Api, st: State, e: StepIn): Promise<Plan> {
const { effort } = mainEffort(st, e.effort)
await prune($, st)
const cur = st.turnModel ?? e.model
const { call } = await decideFor($, st, cur)
logCall($, st, call)
if (call.moved) st.turnModel = call.model
return { model: call.model, effort }
}
async function planStep($: Api, st: State, e: StepIn): Promise<Plan> {
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.lastPlan = plan
st.spinner = `${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.pushed = null
st.turnModel = undefined
st.typedSlash = st.pendingSlash
st.promptAllowed = st.pendingAllowed
st.pendingSlash = null
st.pendingAllowed = false
st.explicitEffort.clear()
st.spinner = ''
st.turnLogged.clear()
refresh($, st)
}
// ---- breaker inputs --------------------------------------------------
/** The exact id a failure is charged to: the agent's, else main's plan. */
function failureTarget(st: State, agentId: string | undefined) {
if (agentId !== undefined) return st.agentModels.get(agentId)
return st.lastPlan?.model.replace(ONE_M, '')
}
/** An engine StopFailure of an availability kind marks its model down. */
async function onStopFailure(
$: Api,
st: State,
e: { error: string; agent_id?: string },
): Promise<void> {
if (st.off || !UNAVAILABLE.has(e.error)) return
const sent = failureTarget(st, e.agent_id)
if (sent === undefined) return
if (e.error === 'model_not_found' && aliasOf(st.cfg, sent) === undefined) {
$.ui.log(`model-router: model_not_found for ${sent}: not a table id; ` +
'no mark')
return
}
await prune($, st)
markDown($, st, canonical(st.cfg, sent), e.error, await $.clock.now())
}
type SwitchIn = {
from_model: string
to_model: string
requested_model: string | null
source: string
}
/**
* The engine switched the model by itself. The model it left is marked, one
* strike, unless it landed where the router already was; the mark targets
* the model actually sent, else the model reported as left.
*/
async function onAutoSwitch($: Api, st: State, e: SwitchIn): Promise<void> {
const cfg = st.cfg
const from = canonical(cfg, e.from_model)
const to = canonical(cfg, e.to_model)
$.ui.log(`model-router: engine switched ${from} → ${to} ` +
`(requested ${e.requested_model ?? '-'})`)
const sent = st.lastPlan ? canonical(cfg, st.lastPlan.model) : undefined
if (to === sent) return
const target = sent !== undefined && aliasOf(cfg, sent) ? sent : from
await prune($, st)
markDown($, st, target, 'engine fallback', await $.clock.now())
}
/** Any model change: keeps `sessionModel`; a user choice clears its mark. */
async function onModelSwitch($: Api, st: State, e: SwitchIn): Promise<void> {
st.sessionModel = e.to_model
if (USER_SWITCH.has(e.source)) st.runMain = null
if (st.off || e.source === 'resume') return
if (e.source === 'auto') await onAutoSwitch($, st, e)
else clearBreaker(st, canonical(st.cfg, e.to_model))
}
// ---- registration ----------------------------------------------------
async function readSessionModel($: Api): Promise<string> {
try {
return await $.session.model()
} catch {
return ''
}
}
function registerSession(on: On, st: State): void {
on('session.start', async ($, e, next) => {
await reloadConfig($, st)
st.sessionModel = await readSessionModel($)
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) => {
resetSession(st)
// 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 registerBreaker(on: On, st: State): void {
on('classic.StopFailure', async ($, e, next) => {
await onStopFailure($, st, e)
return next(e)
}).catch(($, e, next) => {
warnOnce(st, $, 'StopFailure', next.error.kind)
return next(e)
})
on('classic.PostModelSwitch', async ($, e, next) => {
await onModelSwitch($, st, e)
return next(e)
}).catch(($, e, next) => {
warnOnce(st, $, 'PostModelSwitch', next.error.kind)
return next(e)
})
}
function registerCwd(on: On, st: State): void {
on('classic.CwdChanged', async ($, e, next) => {
// the repo key follows the directory: rebuild the layers
if (st.loaded) await reloadConfig($, st)
return next(e)
}).catch(($, e, next) => {
warnOnce(st, $, 'CwdChanged', 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' }
}
await ensureConfig($, st)
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) => {
await ensureConfig($, st)
const out = await 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) => {
await ensureConfig($, st)
const skill = typeof e.skill === 'string' ? e.skill : undefined
if (st.off || skill === undefined) return next(e)
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) => {
await ensureConfig($, st)
if (st.off || st.skillCalls > 0) return next(e)
const row = armed($, st, e.skill)
if (row) await confirmSkill($, st, e.skill, row)
return next(e)
}).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) return next(e)
if (isLevel(e.effort) && typeof e.tool_use_id === 'string') {
st.explicitEffort.set(e.tool_use_id, e.effort)
}
if (e.agentId !== undefined) return next(e)
pushOrchestrate(st)
const out = await next(e)
safely(st, $, 'Agent', () => afterDispatch(st, out.result))
return out
}).catch(($, e, next) => {
warnOnce(st, $, 'Agent', next.error.kind)
return next(e)
})
}
/** Bookkeeping once the agent started: its loop, its model, a log line. */
function afterSpawn(
$: Api,
st: State,
e: SpawnIn,
started: { agentId?: unknown; model: string },
route: Route | undefined,
): void {
if (typeof started.agentId !== 'string') return
const agentId = started.agentId
safely(st, $, 'agent.spawn', () => {
trackLoop(st, e, { agentId }, route)
rememberAgent(st, agentId, started.model)
vlog($, st,
`spawn ${e.subagentType}: ${e.model ?? '-'} → ${started.model}`)
})
}
function registerSpawn(on: On, st: State): void {
on('agent.offer', async ($, e, next) => {
if (!st.off) st.offers.set(e.agent, e.source)
return next(e)
}).catch(($, e, next) => {
warnOnce(st, $, 'agent.offer', next.error.kind)
return next(e)
})
on('agent.spawn', async ($, e, next) => {
await ensureConfig($, st)
if (st.off) return next(e)
st.spawning += 1
try {
await prune($, st)
const route = await routeSpawn($, st, e)
const wanted = spawnTarget($, st, e, route)
const started = await next(wanted === undefined
? e
: { ...e, model: wanted })
afterSpawn($, st, e, started, route)
return started
} finally {
st.spawning = Math.max(0, st.spawning - 1)
}
}).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) => {
const agentId = e.agentId
if (agentId !== undefined) safely(st, $, 'turn.complete', () =>
endAgent(st, 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)
}
const firstRule = (st: State, mode: PromptMode, text: string) =>
st.rules.find(r => r.mode === mode && r.re.test(text))
/** A default rule sets the idle turn's route; routes and skills override. */
function defaultFromPrompt(st: State, scanned: string): void {
const rule = firstRule(st, 'default', scanned)
const route = rule ? phaseRoute(st.cfg, rule.phase) : undefined
if (rule && route) {
st.turnMain = { phase: rule.phase, route: { ...route }, source: 'prompt' }
}
}
/**
* Floor rules first (the user's explicit minimum). Default rules run only
* on an idle, plain prompt: not mid-turn, not a slash command or skill
* (they route themselves), not one that already carries a floor.
*/
function routeFromPrompt(st: State, text: string, midTurn: boolean): void {
const scanned = text.slice(0, MAX_PROMPT_SCAN)
const floor = firstRule(st, 'floor', scanned)
const route = floor ? phaseRoute(st.cfg, floor.phase) : undefined
if (floor && route) {
const routed: Routed = { phase: floor.phase, route, source: 'prompt' }
floorFromPrompt(st, midTurn, routed)
}
const slash = text.trimStart().startsWith('/')
if (!midTurn && !slash && !floor) defaultFromPrompt(st, scanned)
}
/** The rowed skill a slash prompt names, else null. */
function typedSkill(st: State, text: string): string | null {
const typed = text.trimStart()
if (!typed.startsWith('/')) return null
const name = typed.slice(1).split(/\s/, 1)[0] ?? ''
return hasKey(st.cfg.skills, name) ? name : null
}
/**
* Records what skill.prompt needs to tell a typed slash from a preload: the
* skill named and whether a person typed it. A mid-turn prompt waits in the
* pending slots for the turn end; a foreign one leaves them alone. A later
* queued prompt replaces an earlier one (a shortcut: one pending slot).
*/
function noteTyped(st: State, kind: string, text: string, midTurn: boolean) {
const allowed = TYPED_ORIGINS.has(kind)
const slash = allowed ? typedSkill(st, text) : null
if (!midTurn) {
st.typedSlash = slash
st.promptAllowed = allowed
} else if (allowed) {
st.pendingSlash = slash
st.pendingAllowed = true
}
}
function registerPrompt(on: On, st: State): void {
on('prompt.submit', async ($, e, next) => {
await ensureConfig($, st, true)
if (st.off) return next(e)
const midTurn = e.turnId !== undefined
noteTyped(st, e.origin.kind, e.text, midTurn)
if (e.origin.kind === 'composer') routeFromPrompt(st, e.text, midTurn)
refresh($, st)
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.spinner) return next(e)
const suffix = ` · ${st.spinner}…`
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)
registerBreaker(on, st)
registerCwd(on, st)
registerCommandHook(on, st)
registerRouteTool(on, st)
registerSkills(on, st)
registerAgents(on, st)
registerSpawn(on, st)
registerTurns(on, st)
registerPrompt(on, st)
}