SLOPSHOPPER

model-router

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

newspinnerrowsguardtoaststatus
A shopper browsing a rack in a slop shop
README

model-router

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.

Setup

  1. Run 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" } }
  1. Load the plugin: add this directory to CLAUDE_CODE_PLUGIN_DIRS (colon-separated) in the same env block.
  2. Optional: write your own routing guide at ~/.config/submux/routing-guide.md. The plugin adds it to every session's prompt. routing-guide.example.md is a starting point.
  3. Run the tests: claude plugin test claude-plugin/model-router.

What it does

  • Live catalogue. The plugin reads submux models (every id each upstream serves today, grouped by subscription) and keeps the native /model picker equal to it. Nothing is hardcoded.
  • Routes. A route sets a model, an effort and an optional fallback chain for one tier (fable, opus, sonnet, haiku) or one subagent type.
  • Frozen per spawn. A subagent's route is decided once, when it spawns. Later changes reach new spawns only, unless you add --now.
  • Per-spawn tag. An Agent description that ends with @MODEL:EFFORT (for example fix fee rounding @gpt-6.1-sol:medium) routes that one spawn.
  • Visible. Each Agent row shows the real model, effort, provider and who picked it. /model agents lists every subagent of the session.
  • Pick log. One JSON line per finished subagent goes to ~/.local/state/model-router/picks.jsonl.
  • Who decides. The model may set subagent routes only. The main loop, the tiers and profiles stay yours.

Commands

/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.

Source 3 files
hooks/register.tsx 519 lines
1import { 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(', ')}.`
519
hooks/route.ts 448 lines
1// 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}
448
types/index.d.ts 23 lines
1export 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