Files
claude/mods/model-router/hooks/register.ts
T
bchanot 604a6c4411 feat(model-router): wave 3-B — first-use dialog with context, model then effort on change
The dialog now explains what it asks about: the skill's description (from
its SKILL.md frontmatter, five YAML forms, first sentence, cleaned), the
agent's description (from agent.offer), the phase's new 'about' line in
routing.json, and the real model id and effort the next step runs on.
Options Later / Keep / Change; Change asks the model (fable, opus, sonnet,
haiku with their tier role), then the effort among those the phases of
that model offer, then the scope; the pair maps to an existing phase (rows
stay phase names; the row's current phase wins a tie; same phase = Keep).
A main-loop phase (T3) is Later / Keep only. A main-row change toasts the
real decision and the /route switch hint when the pick is a downgrade.
Descriptions pass a hardened read (name allowlist, stat kind, size cap)
and clean(). Kit suite 190 → 232; W3-A dialog tests migrated.

Contract .claude/tasks/contracts/2026-10-11-model-router-w3b-dialog-1240.md,
plan r3: 3 lenses (2 BLOCKERs: inline rows, phase edits) + 1 confirmation,
feater + 2 rounds, GATE 0 MET, verifier CONFORME (3rd pass), security PASS.
2026-10-11 14:10:24 +02:00

2815 lines
99 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
descs: Map<string, string> // subagentType -> cleaned one-sentence offer line
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
about: Table // phase -> its one-line meaning, shown in the dialog only
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: the answers, and what a question may carry of the outside.
const LATER = 'Later'
const KEEP = 'Keep'
const CHANGE = 'Change'
const EVERYWHERE = 'Everywhere'
const THIS_PROJECT = 'This project only'
const CHOICE_ALIASES: readonly string[] = ['fable', 'opus', 'sonnet', 'haiku']
const MAX_DESC = 140 // chars of a skill or agent description in a question
const MAX_ABOUT = 120 // chars of a phase `about`
const MAX_DESCS = 256 // agent descriptions kept (oldest dropped)
const SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/
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: {} },
about: {},
local: { skills: new Set(), agents: new Set() },
key: undefined,
}
}
/** The phases' `about` lines; a non-string or overlong one is dropped, said. */
function aboutOf(phases: unknown, log: Log): Table {
const out: Table = {}
if (!isRecord(phases)) return out
for (const [name, phase] of Object.entries(phases)) {
const about = isRecord(phase) ? phase.about : undefined
if (about === undefined || name === '__proto__') continue
if (typeof about === 'string' && about.length <= MAX_ABOUT) {
out[name] = about
} else {
log(`model-router: ${ROUTING} phases.${name}.about ignored`)
}
}
return out
}
/** What the file and the machine override say about decisions. */
function buildMemory(
file: Rec,
key: string | undefined,
override: Rec | undefined,
log: Log,
): 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),
},
about: aboutOf(file.phases, log),
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, log)
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(),
descs: 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,
descs: st.descs,
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, () => undefined)
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)
}
}
// What a question says about its row. Everything read from outside (a
// SKILL.md, an agent's listing line, routing.json) is cleaned and capped.
/**
* Text safe in a question: every whitespace folded to a space, then control
* and format characters (bidi included) stripped, cut to `n` code points
* with an ellipsis (total <= n).
*/
function clean(text: string, n: number): string {
const flat = text.replace(/[\s\p{Z}]+/gu, ' ')
.replace(/[\p{Cc}\p{Cf}]/gu, '').replace(/ {2,}/g, ' ').trim()
const points = [...flat]
return points.length <= n ? flat : `${points.slice(0, n - 1).join('')}…`
}
/** The first sentence of a description, cleaned; undefined when empty. */
function oneSentence(text: string): string | undefined {
const end = /\.(?:\s|$)/.exec(text)
const line = clean(end ? text.slice(0, end.index) : text, MAX_DESC)
return line === '' ? undefined : line
}
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/
const BLOCK_HEAD = /^[|>][+-]?\d*$/
/** A YAML scalar without its quotes: `''` and JSON escapes resolved. */
function unquote(value: string): string {
const quote = value[0]
if (value.length < 2 || value[value.length - 1] !== quote) return value
if (quote === "'") return value.slice(1, -1).replace(/''/g, "'")
if (quote !== '"') return value
try {
const parsed: unknown = JSON.parse(value)
return typeof parsed === 'string' ? parsed : value.slice(1, -1)
} catch {
return value.slice(1, -1)
}
}
/** `description:` of a SKILL.md frontmatter: plain, block or quoted. */
function frontDescription(text: string): string | undefined {
const front = FRONTMATTER.exec(text)?.[1]
const lines = front === undefined ? [] : front.split(/\r?\n/)
const at = lines.findIndex(line => line.startsWith('description:'))
if (at < 0) return undefined
const head = (lines[at] ?? '').slice('description:'.length).trim()
const body: string[] = []
for (const line of lines.slice(at + 1)) {
if (line.trim() !== '' && !/^\s/.test(line)) break
body.push(line.trim())
}
const first = BLOCK_HEAD.test(head) ? '' : head
return unquote([first, ...body].filter(w => w !== '').join(' '))
}
/**
* A skill's description from ${HOME}/.claude/skills/<name>/SKILL.md: a
* regular file under the size cap, a name from the allowlist. Any failure
* gives undefined: the question names the skill alone.
*/
async function skillDesc($: Api, name: string): Promise<string | undefined> {
if (!SKILL_NAME.test(name)) return undefined
try {
const home = await $.env.get('HOME')
if (!home) return undefined
const path = `${home}/.claude/skills/${name}/SKILL.md`
if ((await $.fs.stat(path)).kind !== 'file') return undefined
const text = await readCapped($, path, 'SKILL.md', () => undefined)
const raw = text === undefined ? undefined : frontDescription(text)
return raw === undefined ? undefined : oneSentence(raw)
} catch {
return undefined
}
}
/** The agent listing line, cleaned once at offer time; oldest dropped. */
function rememberOffer(
st: State,
e: { agent: string; source: string; description: string },
): void {
st.offers.set(e.agent, e.source)
const given: unknown = e.description
const line = typeof given === 'string'
? oneSentence(given.slice(0, 4096))
: undefined
st.descs.delete(e.agent)
if (line !== undefined) st.descs.set(e.agent, line)
if (st.descs.size <= MAX_DESCS) return
const oldest = st.descs.keys().next().value
if (oldest !== undefined) st.descs.delete(oldest)
}
const plural = (n: number, word: string): string =>
`${n} ${word}${n === 1 ? '' : 's'}`
const withDesc = (lead: string, desc: string | undefined): string =>
desc === undefined ? lead : `${lead} — ${desc}`
function aboutLine(st: State, phase: string): string | undefined {
const raw = own(st.mem.about, phase)
const line = raw === undefined ? '' : clean(raw, MAX_ABOUT)
return line === '' ? undefined : line
}
/** "Routed to <phase> (<about>)": the parenthesis only when about is set. */
function routedTo(st: State, phase: string): string {
const about = aboutLine(st, phase)
return `Routed to ${phase}${about === undefined ? '' : ` (${about})`}`
}
/** Who uses a phase: its rows, the derived dispatch route, prompt rules. */
function consumers(st: State, phase: string): string {
const { skills, agents, prompt } = st.cfg
const rows = [...Object.values(skills), ...Object.values(agents)]
.filter(p => p === phase).length
const rules = prompt.filter(r => r.phase === phase)
const floors = rules.filter(r => r.mode !== 'default').length
const parts = [plural(rows, 'row')]
if (phase === 'orchestrate') parts.push('the derived route of dispatches')
if (rules.length > floors) {
parts.push(plural(rules.length - floors, 'prompt rule'))
}
if (floors > 0) parts.push(plural(floors, 'floor rule'))
return `used by ${parts.join(', ')}`
}
// ---- the dialog's steps: ASK1 Later/Keep/Change, then model, effort, scope
const optionsFor = (req: Req): string[] =>
isRowReq(req) ? [LATER, KEEP, CHANGE] : [LATER, KEEP]
/**
* One step: the chosen label, or undefined (a Later). A dismissed or
* headless dialog is silent; any other answer is toasted, never echoed.
*/
async function askStep(
$: Api,
text: string,
labels: readonly string[],
): Promise<string | undefined> {
const answer = await askOr($, text, labels)
if (answer === LATER) return undefined
if (labels.includes(answer)) return answer
$.ui.toast('answer not recognised, default kept')
return undefined
}
/** The alias a phase's route leads with: its tier's head, or its model. */
function headAlias(cfg: Config, route: Route): string | undefined {
if (route.tier !== undefined) {
const list = hasKey(cfg.tiers, route.tier) ? cfg.tiers[route.tier] : []
return list?.[0]
}
if (route.model === undefined) return undefined
return hasKey(cfg.models, route.model)
? route.model
: aliasOf(cfg, canonical(cfg, route.model))
}
/** "fable (best)": the alias and the tier it heads ("custom" if none). */
function modelLabel(cfg: Config, alias: string): string {
const tier = Object.keys(cfg.tiers).find(t => cfg.tiers[t]?.[0] === alias)
return `${alias} (${tier ?? 'custom'})`
}
async function askModel($: Api, st: State, req: RowReq) {
const labels = CHOICE_ALIASES.map(a => modelLabel(st.cfg, a))
const label = await askStep($, `Which model for ${req.name}?`, labels)
return label === undefined ? undefined : CHOICE_ALIASES[labels.indexOf(label)]
}
/** The efforts of the phases headed by `alias`, ascending, four at most. */
function effortsOf(cfg: Config, alias: string): Level[] {
const found = Object.values(cfg.phases)
.filter(r => headAlias(cfg, r) === alias).map(r => r.effort)
return LEVELS.filter(level => found.includes(level)).slice(0, 4)
}
/** One effort is no question; none is a Later, said. */
async function askEffort($: Api, st: State, alias: string) {
const efforts = effortsOf(st.cfg, alias)
if (efforts.length === 0) $.ui.toast(`no phase uses ${alias}`)
if (efforts.length < 2) return efforts[0]
const label = await askStep($, `Which effort on ${alias}?`, efforts)
return efforts.find(effort => effort === label)
}
/** The row's current phase when it matches, else the first that does. */
function targetPhase(cfg: Config, now: string, alias: string, effort: Level) {
const fits = (name: string): boolean => {
const route = phaseRoute(cfg, name)
return route?.effort === effort && headAlias(cfg, route) === alias
}
return fits(now) ? now : Object.keys(cfg.phases).find(fits)
}
/** Change: model, then effort, mapped to a phase of the table. */
async function changeRoute($: Api, st: State, req: RowReq): Promise<Answer> {
const alias = await askModel($, st, req)
const effort = alias === undefined ? undefined : await askEffort($, st, alias)
if (alias === undefined || effort === undefined) return { act: 'later' }
const phase = targetPhase(st.cfg, req.phase, alias, effort)
if (phase === undefined) return { act: 'later' }
return phase === req.phase ? { act: 'keep' } : { act: 'change', phase }
}
async function decide($: Api, st: State, req: Req, text: string) {
const choice = await askStep($, text, optionsFor(req))
if (choice === KEEP) return { act: 'keep' } as const
if (choice === CHANGE && isRowReq(req)) return changeRoute($, st, req)
return { act: 'later' } as const
}
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 choice = await askStep($, text,
[EVERYWHERE, key === undefined ? LATER : THIS_PROJECT])
if (choice === EVERYWHERE) return 'everywhere'
return choice === 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) {
st.asked.add(keyOf(req))
return saveAnswer($, st, req, await decide($, st, req, text))
}
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)}. OK?`
}
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
const lead = withDesc(`First dispatch of ${req.name}`,
st.descs.get(req.name))
return `${lead}. ${routedTo(st, req.phase)}: ${id} at ${
effort ?? 'its own effort'}. OK?`
}
async function skillQuestion($: Api, st: State, skill: string, phase: string) {
const head = withDesc(`First route for /${skill}`, await skillDesc($, skill))
return mainQuestion($, st, `${head}. ${routedTo(st, phase)}`)
}
/** A downgrade the switch holds back: main keeps its model, said. */
function downgradeHeld(st: State, v: Verdict, cur: string): boolean {
return v.wanted !== undefined && v.call.why === 'switch off' &&
!ranksAbove(st, v.wanted, canonical(st.cfg, cur))
}
/** After a Change: what main will really run, and the switch hint. */
async function toastChange($: Api, st: State, skill: string) {
const cur = st.turnModel ?? st.sessionModel
const v = await decideFor($, st, cur)
const hint = downgradeHeld(st, v, cur)
? ', main moves up only: /route switch on allows a downgrade'
: ''
$.ui.toast(`/${skill} now runs ${idWord(st, v.call.model)} at ${
effortWord(st, v)}${hint}`)
}
/** 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 say = () => skillQuestion($, st, skill, row.phase)
if (!(await confirmFirst($, st, req, say))) return
const after = skillRow(st, skill)
if (after === undefined) return
routeMainBySkill(st, after, true)
refresh($, st)
if (after.phase !== row.phase) await toastChange($, st, skill)
}
/** 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 meaning = [aboutLine(st, phase), consumers(st, phase)]
.filter(part => part !== undefined).join('; ')
const lead = `First use of ${phase} on the main loop — ${meaning}`
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) rememberOffer(st, e)
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)
}