/model subcommands: switch model, provider (via submux) and effort live for each tier and each subagent type; bare /model stays the built-in picker

A Claude Code plugin that drives submux from inside a normal claude session. You switch model, provider and effort for the main loop, each subagent tier and each subagent type with /model, with no separate launcher.
submux serve (see the top-level README) and point Claude Code at it in ~/.claude/settings.json: { "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:8787" } }
CLAUDE_CODE_PLUGIN_DIRS (colon-separated) in the same env block.~/.config/submux/routing-guide.md. The plugin adds it to every session's prompt. routing-guide.example.md is a starting point.claude plugin test claude-plugin/model-router.submux models (every id each upstream serves today, grouped by subscription) and keeps the native /model picker equal to it. Nothing is hardcoded.fable, opus, sonnet, haiku) or one subagent type.--now.@MODEL:EFFORT (for example fix fee rounding @gpt-6.1-sol:medium) routes that one spawn./model agents lists every subagent of the session.~/.local/state/model-router/picks.jsonl./model built-in picker for the main loop
/model ID main loop to ID (built-in)
/model routes routes table plus live models
/model list model ids by provider (the id picks the provider)
/model agents every subagent this session: status, real model, effort, provider, who picked
/model sonnet ID [LVL] a tier: fable, opus, sonnet, haiku, nomodel; or an agent type (coder ID high)
/model sonnet LVL | sonnet off effort only | clear the rule
/model --model ID --effort LVL main loop through submux, as a route
/model --agent Explore=ID[:LVL] one subagent type (ID may be empty: --agent coder=:high)
/model --profile NAME load a saved profile --save NAME / --delete NAME
/model --default save current as default for new sessions
/model --fallback TARGET=ID,ID try these ids in order when a request fails (TARGET: slot or agent type)
/model ... --now also switch running subagents (default: new spawns only)
/model --reset
A model or effort change re-reads the whole context once, because the prompt cache is per model.
hooks/register.tsx 519 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, TurnStepChunk, TurnStepResult } from 'claude-code'
3
4import type { Effort, Provider, Routes, Rule, Seen, Slot, Spawn } from '../types'
5import {
6 EFFORTS, EMPTY, SLOTS, contextWarning, describe, fromSubmuxProfile, isEmpty, isRetired, parseArgs, parseCard, parseCatalogue,
7 modelArgs, parseShort, parseTag, providerOf, providersText, resolve, routesTable, seenLine, spawnLine, resolveSpawn, retiredIds, routesInText, sessionSet, staleLogin, statusText, tailLine, unknownIds,
8} from './route'
9
10const routes = atom({ plugin: 'model-router', key: 'routes' } as const, EMPTY)
11const catalogue = atom({ plugin: 'model-router', key: 'catalogue' } as const, [] as Provider[])
12const profiles = atom({ plugin: 'model-router', key: 'profiles' } as const, {} as Record<string, Routes>)
13
14const spawns = atom({ plugin: 'model-router', key: 'spawns' } as const, {} as Record<string, Spawn>)
15const seen = atom({ plugin: 'model-router', key: 'seen' } as const, {} as Record<string, Seen>)
16
17const GUIDE = '.config/submux/routing-guide.md'
18
19async function guideText($: EngineInterface): Promise<string> {
20 try {
21 return await $.fs.read(`${(await $.env.get('HOME')) ?? ''}/${GUIDE}`)
22 } catch {
23 return ''
24 }
25}
26
27/** Routes frozen at spawn but not yet tied to an agentId (the first step can come first). */
28const pendingA = atom({ plugin: 'model-router', key: 'pending' } as const, [] as Spawn[])
29const health = atom({ plugin: 'model-router', key: 'health' } as const, {} as Record<string, { ok: number; failed: number }>)
30
31/** Failed vs ok requests per provider this session, plus submux's cooling list. */
32async function healthText($: EngineInterface): Promise<string> {
33 const h = await read($, health)
34 const parts = Object.entries(h).map(([p, c]) => `${p} ${c.ok} ok/${c.failed} failed`)
35 try {
36 const r = await $.process.run(['submux', 'status'])
37 const cooling = r.stdout.match(/^cooling: (.*)$/m)?.[1]
38 if (cooling && cooling !== 'none') parts.push(`cooling: ${cooling}`)
39 } catch {}
40 return parts.join('; ') || 'no requests yet'
41}
42
43async function agentTypes($: EngineInterface): Promise<string[]> {
44 const home = (await $.env.get('HOME')) ?? ''
45 const out = ['general-purpose', 'Explore', 'Plan']
46 try {
47 for (const f of await $.fs.list(`${home}/.claude/agents`)) if (f.name.endsWith('.md')) out.push(f.name.slice(0, -3))
48 } catch {}
49 return out
50}
51
52const retired$ =async ($: EngineInterface) => retiredIds(await guideText($))
53
54/** An agent card's own model and effort: user cards, then project cards. */
55async function cardOf($: EngineInterface, type: string): Promise<{ model?: string; effort?: Effort }> {
56 const home = (await $.env.get('HOME')) ?? ''
57 const cwd = (await $.env.get('PWD')) ?? ''
58 for (const dir of [`${cwd}/.claude/agents`, `${home}/.claude/agents`]) {
59 try {
60 return parseCard(await $.fs.read(`${dir}/${type}.md`))
61 } catch {}
62 }
63 return {}
64}
65
66async function bump($: EngineInterface, provider: string | undefined, ok: boolean) {
67 if (!provider) return
68 await update($, health, h => {
69 const c = h[provider] ?? { ok: 0, failed: 0 }
70 return { ...h, [provider]: ok ? { ...c, ok: c.ok + 1 } : { ...c, failed: c.failed + 1 } }
71 })
72}
73
74/** What one request really ran on: the answering model, tokens, the fallback trail. */
75async function record($: EngineInterface, agentId: string | undefined, asked: string, effort: Effort | number | undefined, res: TurnStepResult, trail: string[]) {
76 const u = (res.usage ?? {}) as { model?: string; input_tokens?: number; output_tokens?: number }
77 const real = u.model ?? asked
78 const provider = providerOf(await read($, catalogue), asked) ?? providerOf(await read($, catalogue), real)
79 const tokens = (u.input_tokens ?? 0) + (u.output_tokens ?? 0)
80 for (const id of trail.slice(0, -1)) await bump($, providerOf(await read($, catalogue), id), false)
81 await bump($, provider, res.stopReason !== null)
82 if (agentId) {
83 await update($, spawns, all => {
84 const s = all[agentId]
85 if (!s) return all
86 return { ...all, [agentId]: { ...s, real, ...(provider ? { provider } : {}), tokens: s.tokens + tokens, trail: trail.length ? trail : s.trail } }
87 })
88 return
89 }
90 await update($, seen, all => ({
91 ...all,
92 main: {
93 key: 'main', type: 'main', description: 'main loop', model: real,
94 ...(effort === undefined ? {} : { effort: String(effort) }), ...(provider ? { provider } : {}),
95 ...(trail.length ? { fellBackFrom: trail[0] } : {}), at: Date.now(),
96 },
97 }))
98}
99
100const SLOT_HELP: Record<Slot, string> = {
101 main: 'main loop',
102 inherit: 'subagents with no model (default: same as main)',
103 fable: 'fable tier',
104 opus: 'opus tier',
105 sonnet: 'sonnet tier',
106 haiku: 'haiku tier (subagents only)',
107}
108
109async function loadCatalogue($: EngineInterface): Promise<Provider[]> {
110 const home = (await $.env.get('HOME')) ?? ''
111 for (const bin of ['submux', `${home}/bin/submux`]) {
112 try {
113 const r = await $.process.run([bin, 'models'])
114 if (r.exitCode === 0) return parseCatalogue(r.stdout)
115 } catch {}
116 }
117 return []
118}
119
120/** Saved profiles; on first use, seeded from submux-claude's profiles.json. */
121async function loadProfiles($: EngineInterface): Promise<Record<string, Routes>> {
122 const saved = (await $.store.get('profiles')) as Record<string, Routes> | undefined
123 if (saved) return saved
124 const seeded: Record<string, Routes> = {}
125 try {
126 const home = (await $.env.get('HOME')) ?? ''
127 const raw = JSON.parse(await $.fs.read(`${home}/.config/submux/profiles.json`)) as { profiles?: ({ name?: string } & Parameters<typeof fromSubmuxProfile>[0])[] }
128 for (const p of raw.profiles ?? []) if (p.name) seeded[p.name] = fromSubmuxProfile(p)
129 } catch {}
130 await $.store.set('profiles', seeded)
131 return seeded
132}
133
134async function prelaunch($: EngineInterface) {
135 const home = (await $.env.get('HOME')) ?? ''
136 const path = `${home}/.config/submux/prelaunch`
137 try {
138 await $.fs.stat(path)
139 } catch {
140 return
141 }
142 try {
143 const r = await $.process.run([path])
144 if (r.exitCode !== 0) $.ui.toast(`submux prelaunch exited ${r.exitCode}: Kiro models may be stale`)
145 } catch {
146 $.ui.toast('submux prelaunch failed to run: Kiro models may be stale')
147 }
148}
149
150async function staleLogins($: EngineInterface): Promise<string[]> {
151 const home = (await $.env.get('HOME')) ?? ''
152 const dir = `${home}/.cli-proxy-api`
153 const out: string[] = []
154 try {
155 for (const f of await $.fs.list(dir)) {
156 if (!f.name.endsWith('.json')) continue
157 try {
158 const line = staleLogin(JSON.parse(await $.fs.read(`${dir}/${f.name}`)), Date.now())
159 if (line) out.push(line)
160 } catch {}
161 }
162 } catch {}
163 return out
164}
165
166async function apply($: EngineInterface, next: Routes) {
167 await update($, routes, () => next)
168 $.ui.status(statusText(next))
169}
170
171/**
172 * /model and /effort overwrite `model` and `effortLevel` in settings.json as the default for
173 * new sessions. The pin file holds the defaults the user wants; the guard restores them, so a
174 * per-session /model change never sticks. First run pins whatever settings.json holds.
175 */
176async function guardDefaults($: EngineInterface) {
177 const home = await $.env.get('HOME')
178 const settingsPath = `${home}/.claude/settings.json`
179 const pinPath = `${home}/.claude/pinned-defaults.json`
180 const KEYS = ['model', 'effortLevel'] as const
181 try {
182 const settings = JSON.parse(await $.fs.read(settingsPath) as string)
183 if (!(await $.fs.exists(pinPath))) {
184 const pin = Object.fromEntries(KEYS.filter(k => settings[k] !== undefined).map(k => [k, settings[k]]))
185 await $.fs.write(pinPath, JSON.stringify(pin, null, 2) + '\n')
186 return
187 }
188 const pin = JSON.parse(await $.fs.read(pinPath) as string)
189 const drift = KEYS.filter(k => pin[k] !== undefined && settings[k] !== pin[k])
190 if (!drift.length) return
191 for (const k of drift) settings[k] = pin[k]
192 await $.fs.write(settingsPath, JSON.stringify(settings, null, 2) + '\n')
193 } catch { /* settings or pin unreadable: leave both alone */ }
194}
195
196/** Claude Max ids the native rows already cover, and ids the routing guide retires. */
197const PICKER_SKIP = new Set(['claude-opus-5', 'claude-sonnet-5', 'gpt-6-sol', 'gpt-5.6-luna', 'claude-gpt-5.6-luna'])
198
199/**
200 * Keeps settings.json `modelPicker` equal to the live submux catalogue, so the native /model
201 * picker lists every model submux serves, tagged with its provider. Writes only on a change.
202 */
203async function syncPicker($: EngineInterface) {
204 const cat = await loadCatalogue($)
205 if (!cat.length) return
206 const options = cat.flatMap(p => {
207 const tag = p.name.replace(/ \(.*\)/, '').replace('Google AI Pro', 'Antigravity').replace('ChatGPT', 'Codex').replace(' Coding Plan', '')
208 return p.ids
209 .filter(id => !(id.startsWith('claude-') && !id.startsWith('claude-gpt')) && !PICKER_SKIP.has(id.split('/').pop()!))
210 .map(id => ({ model: id, label: `${tag} · ${id}`, description: p.name }))
211 })
212 if (!options.length) return
213 const home = await $.env.get('HOME')
214 const settingsPath = `${home}/.claude/settings.json`
215 try {
216 const settings = JSON.parse(await $.fs.read(settingsPath) as string)
217 if (JSON.stringify(settings.modelPicker?.options) === JSON.stringify(options)) return
218 settings.modelPicker = { ...settings.modelPicker, options }
219 await $.fs.write(settingsPath, JSON.stringify(settings, null, 2) + '\n')
220 } catch { /* settings unreadable: leave it alone */ }
221}
222
223/** One at a time: both rewrite settings.json, so they must not interleave. */
224async function tick($: EngineInterface) {
225 await guardDefaults($)
226 await syncPicker($)
227}
228
229export const register: Register = on => {
230 on('session.start', async ($, e, next) => {
231 void tick($)
232 $.clock.every(5000, () => void guardDefaults($))
233 $.clock.every(60000, () => void tick($))
234 await $.tool.register({
235 name: 'route',
236 description: 'Set a subagent route for this session: args like "--agent coder=gpt-6.1-sol:medium" or "--fallback review-agent=kiro/claude-opus-5.5,glm-5.3". Subagent types only; main and anything the user set are refused. Prefer a per-spawn @MODEL:EFFORT tag for one spawn.',
237 inputSchema: { type: 'object', properties: { args: { type: 'string' } }, required: ['args'] },
238 })
239 const start = ((await $.store.get('default')) as Routes | undefined) ?? EMPTY
240 await apply($, start)
241 // What submux-claude did before launch: prelaunch (Kiro catalogue sync, kirocc up), then the
242 // stale-login check. Never blocks the session; the catalogue loads after prelaunch settles.
243 void prelaunch($).then(() => loadCatalogue($)).then(c => update($, catalogue, () => c))
244 void staleLogins($).then(lines => lines.forEach(l => $.ui.toast(l)))
245 void loadProfiles($).then(p => update($, profiles, () => p))
246 return next(e)
247 })
248
249 // Your words in chat ("review-agent=gpt-6.1-sol:medium as the reviewer") set your rules
250 // directly; the model never relays them, so it cannot pass its own pick off as yours.
251 on('prompt.submit', async ($, e, next) => {
252 const flags = routesInText(e.text, await agentTypes($), await read($, catalogue))
253 if (flags.length) {
254 const p = parseArgs(flags.join(' '), await read($, routes))
255 if (!p.errors.length) {
256 await apply($, p.routes)
257 $.ui.toast(`Route set by you: ${flags.join(' ').replace(/--agent |--/g, '')}`)
258 }
259 }
260 return next(e)
261 })
262
263 // The pick log: one line per finished subagent, so the guide can learn from outcomes.
264 on('turn.complete', async ($, e, next) => {
265 const res = await next(e)
266 const id = (e as { agentId?: string }).agentId
267 const s = id ? (await read($, spawns))[id] : undefined
268 if (s) {
269 const home = (await $.env.get('HOME')) ?? ''
270 const path = `${home}/.local/state/model-router/picks.jsonl`
271 const line = JSON.stringify({ at: new Date().toISOString(), type: s.type, description: s.description, model: s.real ?? s.model, effort: s.effort, provider: s.provider, by: s.by, trail: s.trail, tokens: s.tokens, end: (e as { reason?: string }).reason })
272 let prev = ''
273 try { prev = await $.fs.read(path) } catch {}
274 try { await $.fs.write(path, `${prev}${line}\n`) } catch {}
275 }
276 return res
277 })
278
279 on('prompt.compose', async ($, e, next) => {
280 const res = await next(e)
281 const guide = await guideText($)
282 if (!guide) return res
283 const r = await read($, routes)
284 const cat = await read($, catalogue)
285 const text = [
286 'MODEL ROUTER. Subagent routes are yours to pick where the user set none; main is the user\'s only.',
287 'Per spawn: end the Agent description with @MODEL:EFFORT (either part optional). For a whole agent type or a fallback chain, call the model-router route tool.',
288 `Current routes:\n${describe(r, cat)}`,
289 `Models by provider (live from submux):\n${cat.map(p => `${p.name}: ${p.ids.join(', ')}`).join('\n')}`,
290 `Provider health: ${await healthText($)}`,
291 'Pick log of past spawns and outcomes: ~/.local/state/model-router/picks.jsonl. At session end you may propose one guide line from it; the user approves.',
292 guide,
293 ].join('\n\n')
294 return { ...res, sections: [...res.sections, { id: 'model-router', text, scope: 'session' }] }
295 })
296
297 on('tool.call', { tool: 'mcp__model-router__route' }, async ($, e) => {
298 const args = String((e as unknown as { args?: unknown }).args ?? '')
299 const out = sessionSet(await read($, routes), args)
300 if (!out.routes) return { result: `refused: ${out.error}`, isError: true }
301 await apply($, out.routes)
302 $.ui.toast(`Route set by session: ${args}`)
303 return { result: `ok. New spawns of these types use it.\n${describe(out.routes, await read($, catalogue))}` }
304 })
305
306 // A spawn's route is decided once, here, and frozen: later route changes reach new
307 // spawns only, unless `/model ... --now`.
308 on('agent.spawn', async ($, e, next) => {
309 if (e.fork) return next(e)
310 const tag = parseTag(e.description)
311 const out = resolveSpawn({
312 routes: await read($, routes), type: e.subagentType, tag, card: await cardOf($, e.subagentType),
313 ...(e.model ? { param: e.model } : {}), parentModel: e.parentModel,
314 })
315 const retired = await retired$($)
316 if (isRetired(out.model, retired)) return { deny: `model-router: ${out.model} is retired (routing guide). Use a current id; /model list shows them.` }
317 const s: Spawn = {
318 agentId: '', toolUseId: e.tool_use_id, type: e.subagentType, description: tag.description,
319 model: out.model, ...(out.effort ? { effort: out.effort } : {}), ...(out.fallback ? { fallback: out.fallback } : {}),
320 by: out.by, trail: [], tokens: 0, at: Date.now(),
321 }
322 // The agent's first step can run before next() resolves; turn.step claims it from here.
323 await update($, pendingA, all => [...all, s])
324 // The engine's own agent list labels the agent by this model, so hand it the routed id.
325 const r = await next({ ...e, description: tag.description, model: out.model })
326 await update($, pendingA, all => all.filter(x => x.toolUseId !== s.toolUseId))
327 if (r.deny !== undefined || !r.agentId) return r
328 await update($, spawns, all => (all[r.agentId!] ? all : { ...all, [r.agentId!]: { ...s, agentId: r.agentId! } }))
329 return r
330 })
331
332 on('turn.step', async function* ($, e, next) {
333 const now = await read($, routes)
334 let s = e.agentId ? (await read($, spawns))[e.agentId] : undefined
335 const pending = !s && e.agentId ? await read($, pendingA) : []
336 if (pending.length) {
337 const a = (await $.agent.list()).find(x => x.id === e.agentId)
338 const p = a && pending.find(x => x.type === a.type && x.description === a.description)
339 if (p) {
340 await update($, pendingA, all => all.filter(x => x.toolUseId !== p.toolUseId))
341 s = { ...p, agentId: e.agentId! }
342 await update($, spawns, all => ({ ...all, [e.agentId!]: s! }))
343 }
344 }
345 let model = e.model, effort = e.effort, fallback: string[] = []
346 if (s) {
347 model = s.model
348 if (s.effort) effort = s.effort
349 fallback = s.fallback ?? []
350 } else if (!isEmpty(now)) {
351 const type = e.agentId ? (await $.agent.list()).find(a => a.id === e.agentId)?.type : undefined
352 const out = resolve(now, e, type, await $.session.model())
353 model = out.model
354 if (out.effort !== undefined) effort = out.effort
355 fallback = out.fallback ?? []
356 }
357 const ids = [model, ...fallback]
358 for (let k = 0; k < ids.length; k++) {
359 const it = next({ ...e, model: ids[k]!, ...(effort === undefined ? {} : { effort }) })[Symbol.asyncIterator]()
360 const held: TurnStepChunk[] = []
361 let live = false
362 while (true) {
363 const c = await it.next()
364 if (c.done) {
365 const last = k === ids.length - 1
366 if (!live && c.value.stopReason === null && !last && !next.signal?.aborted) break
367 for (const h of held) yield h
368 await record($, e.agentId, ids[k]!, effort, c.value, k > 0 ? ids.slice(0, k + 1) : [])
369 return c.value
370 }
371 if (live) { yield c.value; continue }
372 held.push(c.value)
373 if (c.value.kind !== 'engine') { live = true; for (const h of held) yield h; held.length = 0 }
374 }
375 }
376 throw new Error('model-router: fallback loop ended without a result')
377 })
378
379 // The built-in /model keeps main: bare opens its picker, `/model <id>` sets it. Everything
380 // the router did (subagent routes, providers, profiles, fallbacks) is a /model subcommand.
381 on('command.run', { command: 'model' }, async ($, e, next) => {
382 const kind = modelArgs(e.args)
383 if (kind === 'builtin') {
384 // Refresh the picker's rows from submux just before the native picker opens.
385 if (e.args.trim() === '') await tick($)
386 return next(e)
387 }
388 if (kind === 'help' || /(^|\s)(--help|-h)(\s|$)/.test(e.args)) return { text: HELP }
389 // Always the live catalogue (`submux models`), never the one cached at session start.
390 const fresh = await loadCatalogue($)
391 if (fresh.length) await update($, catalogue, () => fresh)
392 const cat = fresh.length ? fresh : await read($, catalogue)
393 if (kind === 'list') return { text: providersText(cat) }
394 if (kind === 'table' || kind === 'short') {
395 let r = await read($, routes)
396 let note = ''
397 if (kind === 'short') {
398 const out = parseShort(e.args, r, cat)
399 if (!out.routes) return { text: `model: ${out.error}` }
400 await apply($, out.routes)
401 r = out.routes
402 note = 'Changed. New subagents use it from now on; running ones keep their model.\n\n'
403 }
404 const m = (await read($, seen)).main
405 const main = m ? { model: m.model, ...(m.effort ? { effort: m.effort } : {}) } : { model: await $.session.model() }
406 const recent = Object.values(await read($, spawns)).sort((x, y) => y.at - x.at).slice(0, 5)
407 return { text: `${note}${routesTable(r, cat, main, recent)}${kind === 'table' ? `\n\nLive models\n${providersText(cat)}` : ''}` }
408 }
409 const cur = await read($, routes)
410 const parsed = parseArgs(kind === 'agents' ? '--agents' : e.args, cur)
411 if (parsed.errors.length) return { text: `model: ${parsed.errors.join('; ')}\n\n${HELP}` }
412 const a = parsed.actions
413 if (a.list) return { text: providersText(cat) }
414 if (a.agents) {
415 const live = new Map((await $.agent.list()).map(x => [x.id, x.status]))
416 const m = (await read($, seen)).main
417 const lines = [`main ${m ? seenLine(m) : 'no reply yet'}`]
418 for (const s of Object.values(await read($, spawns)).sort((x, y) => x.at - y.at)) {
419 lines.push(`${s.type}(${s.description}) ${live.get(s.agentId) ?? 'gone'} ${spawnLine(s, cat)}`)
420 }
421 return { text: lines.join('\n') }
422 }
423 let routesNext = parsed.routes
424 const profs = await read($, profiles)
425 const notes: string[] = []
426 if (a.loadProfile) {
427 const p = profs[a.loadProfile]
428 if (!p) return { text: `model: no profile ${a.loadProfile}. Saved: ${Object.keys(profs).join(', ') || 'none'}` }
429 routesNext = p
430 notes.push(`Loaded profile ${a.loadProfile}.`)
431 }
432 await apply($, routesNext)
433 if (a.now) {
434 const running = new Set((await $.agent.list()).filter(x => x.status === 'running').map(x => x.id))
435 const all = await read($, spawns)
436 const moved: string[] = []
437 for (const s of Object.values(all).filter(x => running.has(x.agentId))) {
438 const tag = { ...(s.by.model === 'session' ? { model: s.model } : {}), ...(s.by.effort === 'session' && s.effort ? { effort: s.effort } : {}) }
439 const out = resolveSpawn({ routes: routesNext, type: s.type, tag, card: await cardOf($, s.type), parentModel: await $.session.model() })
440 if (out.model === s.model && out.effort === s.effort) continue
441 all[s.agentId] = { ...s, model: out.model, ...(out.effort ? { effort: out.effort } : {}), by: out.by, trail: [...s.trail, `${s.model}/${s.effort ?? 'default'} → ${out.model}/${out.effort ?? 'default'} (you, now)`] }
442 moved.push(`${s.type}(${s.description})`)
443 }
444 await update($, spawns, () => ({ ...all }))
445 notes.push(moved.length ? `Switched now: ${moved.join(', ')}. Each re-reads its context once.` : 'No running subagent changed.')
446 }
447 if (a.saveProfile) {
448 const all = { ...profs, [a.saveProfile]: routesNext }
449 await $.store.set('profiles', all)
450 await update($, profiles, () => all)
451 notes.push(`Saved profile ${a.saveProfile}.`)
452 }
453 if (a.deleteProfile) {
454 const all = { ...profs }
455 delete all[a.deleteProfile]
456 await $.store.set('profiles', all)
457 await update($, profiles, () => all)
458 notes.push(`Deleted profile ${a.deleteProfile}.`)
459 }
460 if (a.saveDefault) {
461 await $.store.set('default', routesNext)
462 notes.push('Saved as the default for new sessions.')
463 }
464 const unknown = unknownIds(routesNext, cat)
465 if (unknown.length) notes.push(`Not in the submux catalogue (check spelling): ${unknown.join(', ')}`)
466 const warn = contextWarning(routesNext, await $.session.model())
467 if (warn) notes.push(warn)
468 notes.push('Applies from the next request. A model or effort change re-reads the whole context once (no cache).')
469 return { text: `${describe(routesNext, cat)}\n\n${notes.join('\n')}` }
470 })
471
472 // The spawn row: `coder(fix fee · gpt-6.1-sol/medium · ChatGPT Plus · by session · 41k tok)`.
473 on('ui.render', { component: 'ToolUse', props: { tool: 'Agent' } }, async ($, e, next) => {
474 const all = await read($, spawns)
475 const s = Object.values(all).find(x => x.toolUseId === e.props.tool_use_id)
476 const input = e.props.input as { description?: string } | undefined
477 if (!s || !input) return next(e)
478 const line = spawnLine(s, await read($, catalogue))
479 return next({ ...e, props: { ...e.props, input: { ...input, description: `${s.description} · ${line}` } } })
480 })
481
482 // One compact tail on the hint line under the prompt, next to the engine's agent list:
483 // main only when overridden, then each running subagent's real model. The engine's list
484 // already shows names, time and tokens, so no band above the prompt repeats them.
485 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
486 if (e.surface === 'mobile') return next(e)
487 const cat = await read($, catalogue)
488 const all = await read($, spawns)
489 const running = new Set((await $.agent.list()).filter(a => a.status === 'running').map(a => a.id))
490 const m = (await read($, seen)).main
491 const r = await read($, routes)
492 const parts: string[] = []
493 if (m?.fellBackFrom || r.slots.main?.model || r.slots.main?.effort) {
494 parts.push(`main ${m ? `${m.model}/${m.effort ?? 'default'}${m.fellBackFrom ? ` (fallback from ${m.fellBackFrom})` : ''}` : r.slots.main?.model ?? 'waiting'}`)
495 }
496 for (const s of Object.values(all).filter(x => running.has(x.agentId))) parts.push(`${s.type} ${tailLine(s, cat)}`)
497 if (!parts.length) return next(e)
498 return next({ ...e, props: { ...e.props, tail: ` ${parts.join(' · ')}` } })
499 })
500
501}
502
503const HELP = `/model built-in picker for the main loop
504/model ID main loop to ID (built-in)
505/model routes routes table plus live models
506/model list model ids by provider (the id picks the provider)
507/model agents every subagent this session: status, real model, effort, provider, who picked
508/model sonnet ID [LVL] a tier: fable, opus, sonnet, haiku, nomodel; or an agent type (coder ID high)
509/model sonnet LVL | sonnet off effort only | clear the rule
510/model --model ID --effort LVL main loop through submux, as a route
511/model --agent Explore=ID[:LVL] one subagent type (ID may be empty: --agent coder=:high)
512/model --profile NAME load a saved profile --save NAME / --delete NAME
513/model --default save current as default for new sessions
514/model --fallback TARGET=ID,ID try these ids in order when a request fails (TARGET: slot or agent type)
515/model ... --now also switch running subagents (default: new spawns only)
516/model --reset
517Per spawn, the session ends an Agent description with @MODEL:EFFORT (e.g. "fix fee @gpt-6.1-sol:medium").
518Value "off" clears one field (back to the launch default). Effort: ${EFFORTS.join(', ')}.`
519hooks/route.ts 448 lines1// Pure routing logic: no `$`, so tests import it directly.
2import type { Effort, Provider, Routes, Rule, Seen, Slot, Spawn, Who } from '../types'
3
4export const SLOTS: readonly Slot[] = ['main', 'inherit', 'fable', 'opus', 'sonnet', 'haiku']
5export const TIERS: readonly Slot[] = ['fable', 'opus', 'sonnet', 'haiku']
6export const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
7export const EMPTY: Routes = { slots: {}, agents: {} }
8
9/** `submux models` prints `Provider (N ids): a, b, c` per line. */
10export function parseCatalogue(text: string): Provider[] {
11 const out: Provider[] = []
12 for (const line of text.split('\n')) {
13 const m = line.match(/^(.*) \(\d+ ids\): (.*)$/)
14 if (m) out.push({ name: (m[1] ?? "").trim(), ids: (m[2] ?? "").split(',').map(s => s.trim()).filter(Boolean) })
15 }
16 return out
17}
18
19export function providerOf(catalogue: readonly Provider[], id: string): string | undefined {
20 return catalogue.find(p => p.ids.includes(id))?.name
21}
22
23/** Which tier a resolved model id belongs to, from its family name. */
24export function tierOf(model: string): Slot | undefined {
25 const m = model.match(/(fable|opus|sonnet|haiku)/)
26 return m ? (m[1] as Slot) : undefined
27}
28
29export type Step = { model: string; effort?: Effort | number; agentId?: string; fallback?: string[] }
30
31/**
32 * The model and effort one request goes out with. Each field falls through
33 * independently: agent type, then tier (or inherit, then main for a subagent
34 * running the session's own model), then the engine's own value.
35 */
36export function resolve(routes: Routes, step: Step, agentType: string | undefined, sessionModel: string): Step {
37 const chain: (Rule | undefined)[] = []
38 if (!step.agentId) chain.push(routes.slots.main)
39 else {
40 // A subagent with no spawn record (spawned before this mod loaded). Equal to the
41 // session model does not mean inheriting: cards pin the same model with their own effort.
42 if (agentType) chain.push(routes.agents[agentType])
43 const tier = tierOf(step.model)
44 if (tier) chain.push(routes.slots[tier])
45 void sessionModel
46 }
47 const pick = <K extends keyof Rule>(k: K) => chain.find(r => r?.[k] !== undefined)?.[k]
48 const model = pick('model') ?? step.model
49 const effort = pick('effort') ?? step.effort
50 const fallback = pick('fallback')?.filter(id => id !== model)
51 return { ...step, model, ...(effort === undefined ? {} : { effort }), ...(fallback?.length ? { fallback } : {}) }
52}
53
54/** `fix fee @gpt-6.1-sol:medium`: the session's per-spawn route, stripped from the description. */
55export function parseTag(description: string): { description: string; model?: string; effort?: Effort } {
56 const m = description.match(/^(.*?)(?:^|\s+)@([^\s@:]+)?(?::([a-z]+))?\s*$/)
57 if (!m || (!m[2] && !m[3])) return { description }
58 const effort = EFFORTS.includes(m[3] as Effort) ? (m[3] as Effort) : undefined
59 return { description: m[1]!.trim() || description, ...(m[2] ? { model: m[2] } : {}), ...(effort ? { effort } : {}) }
60}
61
62/** `model:` and `effort:` from an agent card's frontmatter. */
63export function parseCard(text: string): { model?: string; effort?: Effort } {
64 const fm = text.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? ''
65 const get = (k: string) => fm.match(new RegExp(`^${k}:\\s*["']?([^"'\\s#]+)`, 'm'))?.[1]
66 const model = get('model')
67 const effort = get('effort')
68 return { ...(model && model !== 'inherit' ? { model } : {}), ...(EFFORTS.includes(effort as Effort) ? { effort: effort as Effort } : {}) }
69}
70
71/** `retired: a, b` lines in the routing guide. */
72export function retiredIds(guide: string): string[] {
73 return guide.split('\n').flatMap(l => l.match(/^\s*-?\s*retired:\s*(.*)$/i)?.[1]?.split(',').map(s => s.trim()).filter(Boolean) ?? [])
74}
75
76/**
77 * Routes the user typed in a chat message: `review-agent=gpt-6.1-sol:medium`, `coder=:high`,
78 * `sonnet=kiro/claude-sonnet-5.5`. Only names that are a slot or a known agent type,
79 * and only ids in the catalogue (or an effort alone), so code like `x=1` never matches.
80 */
81export function routesInText(text: string, agentTypes: readonly string[], catalogue: readonly Provider[]): string[] {
82 const ids = new Set(catalogue.flatMap(p => p.ids))
83 const flags: string[] = []
84 for (const m of text.matchAll(/(?:^|\s)([A-Za-z][\w-]*)=([^\s:,;]*)(?::(low|medium|high|xhigh|max))?(?=[\s,;.]|$)/g)) {
85 const [, name, id, effort] = m as unknown as [string, string, string, string | undefined]
86 if (id && !ids.has(id)) continue
87 if (!id && !effort) continue
88 if ((SLOTS as readonly string[]).includes(name)) {
89 if (id) flags.push(`--${name} ${id}`)
90 if (effort) flags.push(`--${name}-effort ${effort}`)
91 } else if (agentTypes.includes(name)) flags.push(`--agent ${name}=${id}${effort ? `:${effort}` : ''}`)
92 }
93 return flags
94}
95
96/**
97 * What the session may set through its tool: subagent rules only, never main or a
98 * tier, never a field the user set. Returns the merged routes or the refusal.
99 */
100export function sessionSet(cur: Routes, args: string): { routes?: Routes; error?: string } {
101 const p = parseArgs(args, EMPTY)
102 if (p.errors.length) return { error: p.errors.join('; ') }
103 if (Object.keys(p.routes.slots).length || Object.keys(p.actions).some(k => k !== 'now')) {
104 return { error: 'the session may set subagent routes only (--agent, --fallback); main, tiers and profiles are the user\'s' }
105 }
106 const agents = { ...cur.agents }
107 for (const [type, r] of Object.entries(p.routes.agents)) {
108 const have = cur.agents[type]
109 if (have && !have.by) return { error: `${type} route was set by the user (${ruleText(have)}); ask the user to change it` }
110 agents[type] = { ...have, ...r, by: 'session' }
111 }
112 return { routes: { ...cur, agents } }
113}
114
115/** Retired on any provider and context size: `kiro/claude-opus-5[1m]` matches `claude-opus-5`. */
116export function isRetired(id: string, retired: readonly string[]): boolean {
117 const bare = id.replace(/\[1m\]$/, '')
118 return retired.some(r => bare === r || bare.endsWith(`/${r}`))
119}
120
121export type SpawnIn = {
122 routes: Routes; type: string; tag: { model?: string; effort?: Effort }; card: { model?: string; effort?: Effort }
123 param?: string; parentModel: string
124}
125
126/**
127 * A subagent's route, decided once at spawn. Per field: your agent-type rule, the
128 * session's @tag, the session's agent-type rule, then the agent's own choice (Agent
129 * `model` param, card), mapped through your tier map. Only an agent with no model
130 * and no effort of its own follows `inherit`, then main: a card effort never leaks.
131 */
132export function resolveSpawn(i: SpawnIn): { model: string; effort?: Effort; fallback?: string[]; by: { model: Who; effort: Who } } {
133 const rule = i.routes.agents[i.type]
134 const mine = rule && !rule.by ? rule : undefined
135 const theirs = rule?.by === 'session' ? rule : undefined
136 const own = i.param ?? i.card.model
137 const inherits = !own && !i.card.effort
138 let model: string, mBy: Who
139 if (mine?.model) [model, mBy] = [mine.model, 'you']
140 else if (i.tag.model) [model, mBy] = [i.tag.model, 'session']
141 else if (theirs?.model) [model, mBy] = [theirs.model, 'session']
142 else if (own) [model, mBy] = [own, 'engine']
143 else if (i.routes.slots.inherit?.model) [model, mBy] = [i.routes.slots.inherit.model, 'you']
144 else [model, mBy] = [i.routes.slots.main?.model ?? i.parentModel, 'main']
145 const tier = mBy === 'engine' || mBy === 'main' ? tierOf(model) : undefined
146 const tierRule = tier ? i.routes.slots[tier] : undefined
147 if (tierRule?.model) [model, mBy] = [tierRule.model, 'tier']
148 let effort: Effort | undefined, eBy: Who = 'engine'
149 if (mine?.effort) [effort, eBy] = [mine.effort, 'you']
150 else if (i.tag.effort) [effort, eBy] = [i.tag.effort, 'session']
151 else if (theirs?.effort) [effort, eBy] = [theirs.effort, 'session']
152 else if (i.card.effort) [effort, eBy] = [i.card.effort, 'card']
153 else if (tierRule?.effort) [effort, eBy] = [tierRule.effort, 'tier']
154 else if (inherits && i.routes.slots.inherit?.effort) [effort, eBy] = [i.routes.slots.inherit.effort, 'you']
155 else if (inherits && i.routes.slots.main?.effort) [effort, eBy] = [i.routes.slots.main.effort, 'main']
156 const fallback = (mine?.fallback ?? theirs?.fallback ?? tierRule?.fallback)?.filter(id => id !== model)
157 return { model, ...(effort ? { effort } : {}), ...(fallback?.length ? { fallback } : {}), by: { model: mBy, effort: eBy } }
158}
159
160/** The compact spawn-row and band line. */
161export function spawnLine(s: Spawn, catalogue: readonly Provider[], quota?: Record<string, number>): string {
162 const real = s.real ?? s.model
163 const prov = s.provider ?? providerOf(catalogue, real)
164 const q = prov && quota?.[prov] !== undefined ? ` ${quota[prov]}%` : ''
165 const by = s.by.model === s.by.effort ? s.by.model : `${s.by.model}/${s.by.effort}`
166 const parts = [`${real}/${s.effort ?? 'default'}`, prov ? `${prov}${q}` : undefined, `by ${by}`]
167 if (s.trail.length) parts.push(`trail ${s.trail.join(' → ')}`)
168 if (s.tokens) parts.push(`${Math.round(s.tokens / 1000)}k tok`)
169 return parts.filter(Boolean).join(' · ')
170}
171
172/** The hint-line tail for one subagent: `grok-4.7-build/med (SuperGrok)`. */
173export function tailLine(s: Spawn, catalogue: readonly Provider[]): string {
174 const prov = s.provider ?? providerOf(catalogue, s.real ?? s.model)
175 const effort = s.effort ? `/${s.effort === 'medium' ? 'med' : s.effort}` : ''
176 return `${s.real ?? s.model}${effort}${prov ? ` (${shortProvider(prov)})` : ''}`
177}
178
179/** One line for a subagent (or main): what it really ran on. */
180export function seenLine(s: Seen): string {
181 const parts = [s.model, s.effort ?? 'default effort', s.provider].filter(Boolean)
182 const fb = s.fellBackFrom ? ` (fallback from ${s.fellBackFrom})` : ''
183 return `${parts.join(' · ')}${fb}`
184}
185
186export function isEmpty(routes: Routes): boolean {
187 return Object.values(routes.slots).every(r => !r || (r.model === undefined && r.effort === undefined && !r.fallback?.length)) && Object.keys(routes.agents).length === 0
188}
189
190function ruleText(r: Rule | undefined): string {
191 const t = [r?.model, r?.effort].filter(Boolean).join('/')
192 return r?.fallback?.length ? `${t}>${r.fallback.join('>')}` : t
193}
194
195/** Short status-line text; undefined when nothing is overridden. */
196export function statusText(routes: Routes): string | undefined {
197 if (isEmpty(routes)) return undefined
198 const parts: string[] = []
199 for (const s of SLOTS) {
200 const t = ruleText(routes.slots[s])
201 if (t) parts.push(`${s}:${t}`)
202 }
203 for (const [a, r] of Object.entries(routes.agents)) {
204 const t = ruleText(r)
205 if (t) parts.push(`@${a}:${t}`)
206 }
207 return `⇄ ${parts.join(' · ')}`
208}
209
210/** Plain-text table of the current routing, after a `/model --flag` change. */
211export function describe(routes: Routes, catalogue: readonly Provider[]): string {
212 const row = (name: string, r: Rule | undefined) => {
213 const prov = r?.model ? providerOf(catalogue, r.model) ?? 'unknown provider' : ''
214 return `${name.padEnd(16)} ${(r?.model ?? '(launch default)').padEnd(30)} ${(r?.effort ?? '(default)').padEnd(10)} ${prov}${r?.fallback?.length ? ` fallback: ${r.fallback.join(' > ')}` : ''}`
215 }
216 const lines = SLOTS.map(s => row(s, routes.slots[s]))
217 for (const [a, r] of Object.entries(routes.agents)) lines.push(row(`agent ${a}`, r))
218 return lines.join('\n')
219}
220
221/** A main model with a context window other than the launch model's. */
222export function contextWarning(routes: Routes, sessionModel: string): string | undefined {
223 const m = routes.slots.main?.model
224 if (!m || m === sessionModel) return undefined
225 const big = (id: string) => id.endsWith('[1m]')
226 if (big(m) !== big(sessionModel) || !/claude-/.test(m)) {
227 return `Context is still counted as ${sessionModel}. If ${m} has a smaller window, run /compact early.`
228 }
229 return undefined
230}
231
232export type Parsed = {
233 routes: Routes
234 actions: { show?: boolean; list?: boolean; agents?: boolean; now?: boolean; saveDefault?: boolean; saveProfile?: string; loadProfile?: string; deleteProfile?: string }
235 errors: string[]
236}
237
238const OFF = new Set(['off', 'default', 'none', '-'])
239
240function setField(routes: Routes, target: { slot?: Slot; agent?: string }, field: keyof Rule, value: string, errors: string[]): Routes {
241 if (field === 'effort' && !OFF.has(value) && !EFFORTS.includes(value as Effort)) {
242 errors.push(`effort must be one of ${EFFORTS.join(', ')} or off, got ${value}`)
243 return routes
244 }
245 const cur = target.slot ? routes.slots[target.slot] : routes.agents[target.agent!]
246 const next: Rule = { ...cur }
247 delete next.by // a rule the user touches becomes the user's
248 if (OFF.has(value)) delete next[field]
249 else if (field === 'fallback') next.fallback = value.split(',').map(s => s.trim()).filter(Boolean)
250 else (next as Record<string, string>)[field] = value
251 if (target.slot) return { ...routes, slots: { ...routes.slots, [target.slot]: next } }
252 const agents = { ...routes.agents, [target.agent!]: next }
253 if (next.model === undefined && next.effort === undefined && !next.fallback?.length) delete agents[target.agent!]
254 return { ...routes, agents }
255}
256
257/**
258 * Flags in `claude` style: `--model X --effort Y`, `--sonnet X --sonnet-effort Y`
259 * (any slot), `--agent Explore=MODEL[:EFFORT]`, `--profile NAME`, `--save NAME`,
260 * `--delete NAME`, `--default`, `--reset`, `--show`, `--list`. `off` clears a field.
261 */
262export function parseArgs(args: string, start: Routes): Parsed {
263 const t = args.trim().split(/\s+/).filter(Boolean)
264 let routes = start
265 const actions: Parsed['actions'] = {}
266 const errors: string[] = []
267 if (t.length === 0) return { routes, actions: { show: true }, errors }
268 for (let i = 0; i < t.length; i++) {
269 const flag = t[i]!
270 const val = () => {
271 const v = t[++i]
272 if (v === undefined || v.startsWith('--')) {
273 errors.push(`${flag} needs a value`)
274 if (v !== undefined) i--
275 return undefined
276 }
277 return v
278 }
279 const m = flag.match(/^--(main|inherit|fable|opus|sonnet|haiku)(-effort)?$/)
280 if (flag === '--model' || flag === '--effort') {
281 const v = val()
282 if (v) routes = setField(routes, { slot: 'main' }, flag === '--model' ? 'model' : 'effort', v, errors)
283 } else if (m) {
284 const v = val()
285 if (v) routes = setField(routes, { slot: m[1] as Slot }, m[2] ? 'effort' : 'model', v, errors)
286 } else if (flag === '--agent') {
287 const v = val()
288 const am = v?.match(/^([^=]+)=([^:]*)(?::(.+))?$/)
289 if (v && !am) errors.push(`--agent wants TYPE=MODEL[:EFFORT], got ${v}`)
290 if (am) {
291 if (am[2]) routes = setField(routes, { agent: am[1] }, 'model', am[2], errors)
292 if (am[3]) routes = setField(routes, { agent: am[1] }, 'effort', am[3], errors)
293 }
294 } else if (flag === '--fallback') {
295 const v = val()
296 const fm = v?.match(/^([^=]+)=(.+)$/)
297 if (v && !fm) errors.push(`--fallback wants TARGET=ID,ID (TARGET a slot or agent type), got ${v}`)
298 if (fm) {
299 const name = fm[1]!
300 const target = (SLOTS as readonly string[]).includes(name) ? { slot: name as Slot } : { agent: name }
301 routes = setField(routes, target, 'fallback', fm[2]!, errors)
302 }
303 } else if (flag === '--reset') routes = EMPTY
304 else if (flag === '--profile') actions.loadProfile = val()
305 else if (flag === '--save') actions.saveProfile = val()
306 else if (flag === '--delete') actions.deleteProfile = val()
307 else if (flag === '--default') actions.saveDefault = true
308 else if (flag === '--show') actions.show = true
309 else if (flag === '--list') actions.list = true
310 else if (flag === '--agents') actions.agents = true
311 else if (flag === '--now') actions.now = true
312 else if (!flag.startsWith('--') && i === 0 && t.length === 1) actions.loadProfile = flag
313 else errors.push(`unknown flag ${flag}`)
314 }
315 return { routes, actions, errors }
316}
317
318/** A submux-claude profile (`~/.config/submux/profiles.json`) as routes. */
319export function fromSubmuxProfile(p: { main?: string; fable?: string; opus?: string; sonnet?: string; haiku?: string; effort?: string }): Routes {
320 const slots: Routes['slots'] = {}
321 const eff = EFFORTS.includes(p.effort as Effort) ? (p.effort as Effort) : undefined
322 if (p.main || eff) slots.main = { ...(p.main ? { model: p.main } : {}), ...(eff ? { effort: eff } : {}) }
323 for (const s of TIERS) {
324 const id = (p as Record<string, string | undefined>)[s]
325 if (id) slots[s] = { model: id }
326 }
327 return { slots, agents: {} }
328}
329
330/** Ids a rule names that the catalogue does not list. */
331export function unknownIds(routes: Routes, catalogue: readonly Provider[]): string[] {
332 if (catalogue.length === 0) return []
333 const ids = [...Object.values(routes.slots), ...Object.values(routes.agents)].map(r => r?.model).filter((x): x is string => !!x)
334 return [...new Set(ids)].filter(id => !providerOf(catalogue, id))
335}
336
337/** A cliproxy login whose access token expired over a day ago (refresh likely dead). */
338export function staleLogin(auth: { disabled?: boolean; expired?: string; type?: string; email?: string }, nowMs: number): string | undefined {
339 if (auth.disabled || !auth.expired) return undefined
340 const t = Date.parse(auth.expired)
341 if (Number.isNaN(t) || nowMs - t <= 86_400_000) return undefined
342 const type = auth.type ?? '?'
343 return `${type} login (${auth.email ?? '?'}) stopped refreshing, expired ${auth.expired}; re-login: cliproxyapi -${type}-login`
344}
345
346/** `Kiro (kirocc :3456)` → `Kiro`. */
347export function shortProvider(name: string): string {
348 return name.replace(/\s*\(.*\)\s*$/, '')
349}
350
351const ROW_NAMES: Record<Slot, string> = { main: 'Main loop', inherit: 'Agents with no model', fable: 'Fable agents', opus: 'Opus agents', sonnet: 'Sonnet agents', haiku: 'Haiku agents' }
352const TARGET_ALIASES: Record<string, Slot> = { main: 'main', nomodel: 'inherit', inherit: 'inherit', fable: 'fable', opus: 'opus', sonnet: 'sonnet', haiku: 'haiku' }
353
354/**
355 * `/model routes` text. A subagent's model, provider and effort are picked per spawn, so no row
356 * claims one: main, then the fixed rules you set, then what recent subagents really ran on.
357 */
358export function routesTable(routes: Routes, catalogue: readonly Provider[], main: { model: string; effort?: string }, recent: readonly Spawn[]): string {
359 const prov = (id: string) => { const p = providerOf(catalogue, id); return p ? ` · ${shortProvider(p)}` : '' }
360 const ruleCell = (r: Rule) => `${r.model ?? 'model picked per spawn'}${r.effort ? ` · ${r.effort}` : ''}${r.model ? prov(r.model) : ''}`
361 const pad = (rows: [string, string][]) => {
362 const w = Math.max(...rows.map(r => r[0].length)) + 3
363 return rows.map(([n, v]) => ` ${n.padEnd(w)}${v}`)
364 }
365 const m = routes.slots.main
366 const mainCell = m?.model || m?.effort ? `${ruleCell(m)} (your rule)` : `${main.model}${main.effort ? ` · ${main.effort}` : ''}${prov(main.model)}`
367 const rules: [string, string][] = []
368 for (const s of SLOTS) {
369 const r = routes.slots[s]
370 if (s !== 'main' && (r?.model || r?.effort)) rules.push([ROW_NAMES[s], ruleCell(r!)])
371 }
372 for (const [a, r] of Object.entries(routes.agents)) if (r.model || r.effort) rules.push([a, ruleCell(r)])
373 const ran: [string, string][] = recent.map(s => {
374 const real = s.real ?? s.model
375 const p = s.provider ?? providerOf(catalogue, real)
376 return [`${s.type} (${s.description})`, `${real} · ${s.effort ?? 'default effort'}${p ? ` · ${shortProvider(p)}` : ''} · picked by ${s.by.model}`]
377 })
378 return [
379 `Main loop: ${mainCell}`,
380 'Subagents: picked per spawn (your rule, the session\'s @MODEL tag, or the agent card)',
381 '',
382 'Your fixed rules for subagents',
383 ...(rules.length ? pad(rules) : [' none']),
384 '',
385 'Recent subagents (what really ran)',
386 ...(ran.length ? pad(ran) : [' none yet this session']),
387 '',
388 'Change main: /model <id> (bare /model opens the picker)',
389 'Change subagents: /model sonnet gpt-6.1-sol high (or: nomodel, opus, fable, haiku, an agent type)',
390 'Undo: /model sonnet off',
391 'Model ids: /model list More: /model help',
392 ].join('\n')
393}
394
395/** `/model list`: one line per provider, short names. */
396export function providersText(catalogue: readonly Provider[]): string {
397 if (!catalogue.length) return 'submux catalogue is empty or submux is not running.'
398 const w = Math.max(...catalogue.map(p => shortProvider(p.name).length)) + 3
399 return catalogue.map(p => `${shortProvider(p.name).padEnd(w)}${p.ids.join(', ')}`).join('\n')
400}
401
402/**
403 * Who serves a `/model` run. Bare and a single id stay the built-in picker for main;
404 * `routes`, `list`, `agents`, `help`, any `--flag` and `<target> <model|effort|off> [effort]`
405 * are the router's.
406 */
407export function modelArgs(args: string): 'builtin' | 'table' | 'list' | 'agents' | 'help' | 'flags' | 'short' {
408 const t = args.trim().split(/\s+/).filter(Boolean)
409 if (t.length === 0) return 'builtin'
410 const w = t[0]!.toLowerCase()
411 if (t.length === 1) {
412 if (w === 'routes' || w === 'show') return 'table'
413 if (w === 'list') return 'list'
414 if (w === 'agents') return 'agents'
415 if (w === 'help' || w === '-h') return 'help'
416 }
417 if (t.some(x => x.startsWith('--'))) return 'flags'
418 return t.length === 1 ? 'builtin' : 'short'
419}
420
421/**
422 * `/model <target> <model|off> [effort]` or `/model <target> <effort>`. A target is a tier,
423 * `nomodel`, or an agent type; `main` takes only `off` (main is the built-in /model's).
424 */
425export function parseShort(args: string, cur: Routes, catalogue: readonly Provider[]): { routes?: Routes; error?: string } {
426 const [rawTarget, a, b, ...rest] = args.trim().split(/\s+/)
427 if (!rawTarget || !a || rest.length) return { error: 'use: /model <tier or agent type> <model id | off> [effort]' }
428 const slot = TARGET_ALIASES[rawTarget.toLowerCase()]
429 const isEffort = (x: string) => (EFFORTS as readonly string[]).includes(x)
430 let rule: Rule
431 if (a === 'off') rule = {}
432 else if (slot === 'main') return { error: 'change the main loop with /model <id>; "/model main off" clears an old route' }
433 else if (isEffort(a) && !b) rule = { ...(slot ? cur.slots[slot] : cur.agents[rawTarget]), effort: a as Effort }
434 else {
435 if (!catalogue.some(p => p.ids.includes(a))) return { error: `${a} is not a model id. See /model list` }
436 if (b && !isEffort(b)) return { error: `${b} is not an effort. Use: ${EFFORTS.join(', ')}` }
437 rule = { model: a, ...(b ? { effort: b as Effort } : {}) }
438 }
439 if (slot) {
440 const slots = { ...cur.slots }
441 if (rule.model || rule.effort) slots[slot] = rule; else delete slots[slot]
442 return { routes: { ...cur, slots } }
443 }
444 const agents = { ...cur.agents }
445 if (rule.model || rule.effort) agents[rawTarget] = rule; else delete agents[rawTarget]
446 return { routes: { ...cur, agents } }
447}
448types/index.d.ts 23 lines1export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
2export type Slot = 'main' | 'inherit' | 'fable' | 'opus' | 'sonnet' | 'haiku'
3/** One override; a field left out passes the engine's own choice through. */
4export type Rule = { model?: string; effort?: Effort; fallback?: string[]; by?: 'session' }
5/** Who decided one field of a spawn's route. */
6export type Who = 'you' | 'session' | 'card' | 'tier' | 'main' | 'engine'
7/** One subagent's route, frozen when it spawned, plus what really ran. */
8export type Spawn = {
9 agentId: string; toolUseId: string; type: string; description: string
10 model: string; effort?: Effort; fallback?: string[]; by: { model: Who; effort: Who }
11 real?: string; provider?: string; trail: string[]; tokens: number; at: number
12}
13/** What one subagent (or main) really ran on, from its last request. */
14export type Seen = { key: string; type: string; description: string; model: string; effort?: string; provider?: string; fellBackFrom?: string; at: number }
15export type Routes = { slots: Partial<Record<Slot, Rule>>; agents: Record<string, Rule> }
16export type Provider = { name: string; ids: string[] }
17
18declare module 'claude-code' {
19 interface PluginState {
20 'model-router': { routes: Routes; catalogue: Provider[]; profiles: Record<string, Routes>; seen: Record<string, Seen>; spawns: Record<string, Spawn>; pending: Spawn[]; health: Record<string, { ok: number; failed: number }>; 'pane-provider': Record<string, string> }
21 }
22}
23