Picks the model and effort per request, and shows a live band above the prompt: routing, running agents, skills, MCP, context and plan usage, plus a…

A Claude Code mod that picks the model and effort for each request, and keeps a live band above your prompt showing what the session is doing.

haiku, sonnet or opus with a reasoning effort (low → xhigh) and a confidence score. Routing is local keyword/length/tool heuristics: no model is called to decide. A small hysteresis stops it flip-flopping between tiers./clauderouter pane: cost, tokens and elapsed time cards, plus the running and finished subagents with the model and effort each one got.hooks/*.ts modules and a types entry). git clone https://github.com/visualife/ClaudeRouter
/plugin marketplace add ./ClaudeRouter
/plugin install agent-ClaudeRouter@router-mods
Use the full path to the clone if it is not in the directory you launched Claude Code from.
/reload-plugins) or restart Claude Code. The band appears above the prompt./clauderouter to open it. Press Esc to close.Set them from the prompt; they persist across reloads.
| Command | Effect |
|---|---|
/clauderouter engine jev | Jev picks haiku / sonnet / opus. Needs TYPESAFE_API_KEY in the environment. |
/clauderouter engine rules | Local keyword rules (default). |
/clauderouter warm 2h | Keep the prompt cache warm for 2h (default 2h, max 6h). |
/clauderouter warm off | Stop. |
/clauderouter status | Show the current options. |
Jev: it only chooses the tier; Claude still does the work. Each routed request sends the prompt text (first 4000 characters) to api.typesafe.ai and costs one small Jev call (about 0.3 s). In the band, jev sits beside haiku sonnet opus on the Model route row: lit when Jev made the pick, dim when the rules did. Once Jev has been used, a Jev · context bar appears: the last call's input tokens against Jev's 64k per-request window, with the call count. A Jev pick also starts the band's second line with jev ·. With no key, an error or a 2.5 s timeout, the rules decide, so nothing blocks.
Keep warm: each ping is a short model call on the existing conversation (cache reads, cheap). It only runs while the window is open and no turn is running, and it ends the window if a ping fails, since the cache is then probably gone. Switching model tiers loses the cache of the previous tier, which is what this is for. If you already run the cache-tax mod's /keepwarm, use one of the two, not both.
git -C ClaudeRouter pull
/plugin uninstall agent-ClaudeRouter@router-mods
/plugin marketplace remove router-mods
Routing lives in hooks/route.ts: edit the HEAVY, MID and LIGHT keyword lists, or HYSTERESIS, to change how eagerly it moves between tiers.
| Path | Role |
|---|---|
hooks/register.ts | Hook and command registration, state, /clauderouter |
hooks/route.ts | Model and effort heuristics |
hooks/band.ts | The band above the prompt |
hooks/pane.ts | The /clauderouter pane |
hooks/pixel.ts, format.ts, lists.ts | Sprites, formatting, list rows |
tests/router.test.ts | Routing tests |
.claude-plugin/types/ (git-ignored).hooks/register.ts 270 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { Agent, Route, Task } from '../types'
4import { JEV_TIMEOUT_MS, askJev } from './jev'
5import { DEFAULT_OPTIONS, WARM_EVERY_MS, applyArgs } from './options'
6import { type Els, type Snapshot, drawBand } from './band'
7import { drawPane } from './pane'
8import { type Anim, animCells } from './pixel'
9import { effortFor, familyOf, idFor, pickRoute, roleOf, whyOf } from './route'
10import { attachAgent, addPlugin, bumpMcp, bumpSkill, nameOf, patchAgent, patchTask, toUsage } from './lists'
11
12const PANE = 'agent-ClaudeRouter'
13
14// The values ClaudeRouter draws, kept in $.state. A write redraws the sites that read it.
15// Declared here, beside their writes: the state scan reads each atom where it is written.
16const route = atom({ plugin: 'agent-ClaudeRouter', key: 'route' } as const, null)
17const tasks = atom({ plugin: 'agent-ClaudeRouter', key: 'tasks' } as const, [])
18const skills = atom({ plugin: 'agent-ClaudeRouter', key: 'skills' } as const, [])
19const mcp = atom({ plugin: 'agent-ClaudeRouter', key: 'mcp' } as const, [])
20const plugins = atom({ plugin: 'agent-ClaudeRouter', key: 'plugins' } as const, [])
21const usage = atom({ plugin: 'agent-ClaudeRouter', key: 'usage' } as const, null)
22const jev = atom({ plugin: 'agent-ClaudeRouter', key: 'jev' } as const, null)
23const options = atom({ plugin: 'agent-ClaudeRouter', key: 'options' } as const, DEFAULT_OPTIONS)
24const totals = atom({ plugin: 'agent-ClaudeRouter', key: 'totals' } as const, { tokens: 0, turns: 0 })
25
26// Shared by the hooks below. A reload starts them over; $.state keeps what the band shows.
27let busy = false
28let frame = 0
29let terminal = false
30let turnId = ''
31let turnText = ''
32let turnTools: string[] = []
33// When the main thread last sent a request, for keep-warm
34let lastRequestAt: number | null = null
35const subagents = new Map<string, { text: string; subagentType?: string }>()
36// The Rasters each drawn site mounted that shimmer or move: what the timer repaints with $.ui.blit
37const mounted = new Map<string, Anim[]>()
38
39// Gathers what a drawing reads. The validator follows $ only into functions declared at the top of the file.
40async function snapshot($: EngineInterface, surface: string, columns: number, rows: number): Promise<Snapshot> {
41 return {
42 route: await read($, route),
43 tasks: await read($, tasks),
44 skills: await read($, skills),
45 mcp: await read($, mcp),
46 plugins: await read($, plugins),
47 usage: await read($, usage),
48 jev: await read($, jev),
49 totals: await read($, totals),
50 now: await $.clock.now(),
51 frame,
52 terminal: surface === 'terminal',
53 columns,
54 rows,
55 anim: [],
56 }
57}
58
59export const register: Register = on => {
60 // Runs before your first prompt, and again after a reload
61 on('session.start', async ($, e, next) => {
62 await $.command.register({
63 name: 'clauderouter',
64 description: 'Open ClaudeRouter: cost, tokens, time, running and finished agents',
65 immediate: true,
66 })
67 const raw = await $.session.usage()
68 await update($, usage, () => toUsage(raw))
69 // Ten frames a second, and only while a turn is running
70 $.clock.every(100, async () => {
71 if (!busy) return
72 frame += 1
73 if (!terminal) {
74 // The desktop has no Raster: its Text bars are redrawn
75 $.ui.invalidate('ui.render')
76 return
77 }
78 for (const [requestId, list] of mounted) {
79 for (const a of list) {
80 if (a.kind === 'sprite' && frame % 4 !== 0) continue
81 await $.ui.blit({ requestId, key: a.key, cells: animCells(a, frame) })
82 }
83 }
84 // The seconds counters are Text, which a blit does not reach: redraw once a second
85 if (frame % 10 === 0) $.ui.invalidate('ui.render')
86 })
87 // Keep-warm: once a minute, ping the cache if the window is open and the main thread has been idle for 50 min
88 $.clock.every(60_000, async () => {
89 const now = await $.clock.now()
90 const { warmUntil } = await read($, options)
91 if (warmUntil <= now || busy || lastRequestAt === null || now - lastRequestAt < WARM_EVERY_MS) return
92 const reply = await $.model.fork({ prompt: 'Reply with the single word: warm' }).catch(() => null)
93 // A failed ping ends the window: the cache is probably gone, and retrying would only pay to write it again
94 if (!reply || !reply.isAnswered) await update($, options, o => ({ ...o, warmUntil: 0 }))
95 else lastRequestAt = now
96 })
97 return next(e)
98 })
99
100 // Runs when you type /ClaudeRouter. Opening the pane draws nothing by itself; ui.render fills it.
101 on('command.run', { command: 'clauderouter' }, async ($, e) => {
102 if (e.args.trim()) {
103 const now = await $.clock.now()
104 const { next, text } = applyArgs(e.args, await read($, options), now)
105 if (next) await update($, options, () => next)
106 return { text }
107 }
108 await $.ui.open({ id: PANE, title: 'ClaudeRouter', closeOnEscape: true })
109 return {}
110 })
111
112 // Runs when a turn begins: a new task row, named by the prompt
113 on('turn.start', async ($, e, next) => {
114 busy = true
115 turnId = e.turnId
116 turnText = e.text
117 turnTools = []
118 const task: Task = {
119 id: e.turnId,
120 name: nameOf(e.text),
121 status: 'running',
122 startedAt: await $.clock.now(),
123 seconds: null,
124 steps: 0,
125 model: null,
126 effort: null,
127 tokens: null,
128 agents: [],
129 }
130 await update($, tasks, list => [...list.slice(-19), task])
131 return next(e)
132 })
133
134 // Runs before each request to the model: picks the model and effort, then records what was chosen
135 on('turn.step', async function* ($, e, next) {
136 const sub = e.agentId ? subagents.get(e.agentId) : undefined
137 const input = {
138 text: e.agentId ? (sub?.text ?? '') : turnText,
139 tools: e.agentId ? [] : turnTools,
140 subagentType: sub?.subagentType,
141 current: familyOf(e.model),
142 }
143 let pick = pickRoute(input)
144 let by: Route['by'] = 'rules'
145 if (!e.agentId) lastRequestAt = await $.clock.now()
146 if ((await read($, options)).engine === 'jev') {
147 const key = await $.env.get('TYPESAFE_API_KEY')
148 const timeout = new Promise<null>(resolve => { $.clock.after(JEV_TIMEOUT_MS, () => resolve(null)) })
149 const asked = key ? await Promise.race([askJev((url, init) => $.http.fetch(url, init), key, input), timeout]) : null
150 if (asked) {
151 pick = { model: asked.model, conf: asked.conf, effort: effortFor(asked.model, input.text, input.subagentType) }
152 by = 'jev'
153 await update($, jev, u => ({ calls: (u?.calls ?? 0) + 1, input: asked.input, output: asked.output }))
154 }
155 }
156 const kept = familyOf(e.model) === pick.model
157 const result = yield* next({ ...e, model: idFor(pick.model, e.model), effort: pick.effort })
158
159 const decided: Route = {
160 model: pick.model,
161 effort: pick.effort,
162 conf: pick.conf,
163 kept,
164 by,
165 engineModel: e.model,
166 source: e.agentId ? 'subagent' : 'main',
167 why: (by === 'jev' ? 'jev · ' : '') + whyOf(input),
168 }
169 await update($, route, () => decided)
170 if (e.agentId) {
171 const agentId = e.agentId
172 await update($, tasks, list => patchAgent(list, agentId, { model: pick.model, effort: pick.effort }))
173 } else {
174 const turn = e.turnId
175 await update($, tasks, list => patchTask(list, turn, { steps: e.index + 1, model: pick.model, effort: pick.effort }))
176 }
177 return result
178 })
179
180 // Runs before each tool call: counts the turn's tools, and MCP calls by server and tool
181 on('tool.call', async ($, e, next) => {
182 if (!e.agentId) turnTools = [...turnTools.slice(-49), e.tool]
183 if (e.tool.startsWith('mcp__')) {
184 const [, server = '', ...rest] = e.tool.split('__')
185 const tool = rest.join('__')
186 await update($, mcp, list => bumpMcp(list, server, tool))
187 }
188 return next(e)
189 }).catch(($, e, next) => next(e))
190
191 // Runs when a skill's text is expanded for Claude: it has loaded and is in use
192 on('skill.prompt', async ($, e, next) => {
193 await update($, skills, list => bumpSkill(list, e.skill))
194 return next(e)
195 })
196
197 // Runs as each hooks module loads: the plugin names ClaudeRouter can see
198 on('plugin.register', async ($, e, next) => {
199 await update($, plugins, list => addPlugin(list, e.name))
200 return next(e)
201 }).catch(($, e, next) => next(e))
202
203 // Runs when a subagent is about to start. A model the Agent call already named stays as it is.
204 on('agent.spawn', async ($, e, next) => {
205 const text = `${e.description} ${e.prompt}`
206 const pick = pickRoute({ text, tools: [], subagentType: e.subagentType })
207 const result = await next({ ...e, model: e.model ?? pick.model })
208 if (result.deny) return result
209
210 if (result.agentId) subagents.set(result.agentId, { text, subagentType: e.subagentType })
211 const agent: Agent = {
212 id: result.agentId ?? null,
213 name: e.description || e.subagentType,
214 role: roleOf(e.subagentType, text),
215 model: result.model ?? null,
216 effort: null,
217 status: 'running',
218 startedAt: await $.clock.now(),
219 seconds: null,
220 tokens: 0,
221 }
222 await update($, tasks, list => attachAgent(list, turnId, agent))
223 return result
224 }).catch(($, e, next) => next(e))
225
226 // Runs when a turn ends, for the main conversation and for each subagent
227 on('turn.complete', async ($, e, next) => {
228 const tokens = e.usage ? e.usage.input_tokens + e.usage.output_tokens : 0
229 const status = e.reason === 'error' ? 'failed' : 'done'
230 const seconds = e.durationMs / 1000
231 const agentId = e.agentId
232 await update($, totals, t => ({ tokens: t.tokens + tokens, turns: t.turns + (agentId ? 0 : 1) }))
233
234 if (agentId) {
235 await update($, tasks, list => patchAgent(list, agentId, { status, seconds, tokens }))
236 } else {
237 busy = false
238 await update($, tasks, list => patchTask(list, e.turnId, { status, seconds, tokens }))
239 const raw = await $.session.usage()
240 await update($, usage, () => toUsage(raw))
241 }
242 return next(e)
243 })
244
245 // Runs when the session's figures change: context, rate limits, cost
246 on('session.measure', async ($, e, next) => {
247 await update($, usage, prev => toUsage({ ...e, startedAt: prev?.startedAt ?? 0 }))
248 return next(e)
249 })
250
251 // Draws the ClaudeRouter band above the prompt, one row per fact the events expose
252 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
253 if (e.props.hasSurvey) return next(e)
254 const snap = await snapshot($, e.surface, e.props.bodyColumns, e.props.maxRows)
255 terminal = snap.terminal
256 const tree = drawBand($.ui.resolve(e) as Els, snap)
257 mounted.set(e.requestId, snap.anim)
258 return tree
259 })
260
261 // Draws the /ClaudeRouter pane: the cards, the running and finished lists
262 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
263 const snap = await snapshot($, e.surface, e.props.bodyColumns, e.props.scroll.bodyRows)
264 terminal = snap.terminal
265 const tree = drawPane($.ui.resolve(e) as Els, snap)
266 mounted.set(e.requestId, snap.anim)
267 return tree
268 })
269}
270hooks/jev.ts 67 lines1import type { Model } from '../types'
2import type { RouteInput } from './route'
3
4// Jev (TypeSafe System One) as the judge: one Choice question over the three tiers.
5// It picks the tier; Claude still does the work. Any failure returns null and the caller falls back to the rules.
6const URL = 'https://api.typesafe.ai/v1/systemone'
7export const JEV_TIMEOUT_MS = 2500
8const TIERS: Model[] = ['haiku', 'sonnet', 'opus']
9
10export type JevFetch = (
11 url: string,
12 init: { method: string; headers: Record<string, string>; body: string },
13) => Promise<{ ok: boolean; text: string }>
14
15// jev-1.13: 64k tokens per request (docs.typesafe.ai/models)
16export const JEV_WINDOW = 64_000
17
18export type JevPick = { model: Model; conf: number; input: number; output: number }
19
20export function jevBody(input: RouteInput) {
21 return {
22 model: 'jev-latest',
23 state: {
24 request: input.text.slice(0, 4000),
25 agent: input.subagentType ?? 'main conversation',
26 tools_used_this_turn: [...new Set(input.tools)].slice(-8),
27 },
28 questions: {
29 route: {
30 type: 'choice',
31 instructions: 'Which Claude model tier is the cheapest one that will do this request well?',
32 choice: {
33 haiku: 'Lookups, listing, reading, renaming, formatting, short summaries; no real reasoning.',
34 sonnet: 'Ordinary implementation: writing code, fixing a known bug, tests, small multi-file edits.',
35 opus: 'Architecture, planning, hard debugging, security, ambiguous or cross-cutting work.',
36 },
37 },
38 },
39 }
40}
41
42export async function askJev(fetch: JevFetch, key: string, input: RouteInput): Promise<JevPick | null> {
43 try {
44 const res = await fetch(URL, {
45 method: 'POST',
46 headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
47 body: JSON.stringify(jevBody(input)),
48 })
49 if (!res.ok) return null
50 const body = JSON.parse(res.text)
51 const answer = body?.answers?.route
52 const model = TIERS.find(t => t === answer?.choice)
53 const p = answer?.probabilities?.[model ?? '']
54 if (!model || typeof p !== 'number' || !Number.isFinite(p)) return null
55 const inTok = Number(body?.usage?.input_tokens)
56 const outTok = Number(body?.usage?.output_tokens)
57 return {
58 model,
59 conf: Math.round(Math.min(0.99, Math.max(0.5, p)) * 100) / 100,
60 input: Number.isFinite(inTok) ? inTok : 0,
61 output: Number.isFinite(outTok) ? outTok : 0,
62 }
63 } catch {
64 return null
65 }
66}
67hooks/options.ts 51 lines1import type { Options } from '../types'
2
3export const DEFAULT_OPTIONS: Options = { engine: 'rules', warmUntil: 0 }
4// A ping every 50 minutes keeps a one-hour prompt cache read instead of re-written
5export const WARM_EVERY_MS = 50 * 60 * 1000
6const WARM_DEFAULT_MS = 2 * 60 * 60 * 1000
7const WARM_MAX_MS = 6 * 60 * 60 * 1000
8
9export const USAGE = [
10 '/clauderouter open the pane',
11 '/clauderouter engine jev let Jev pick the model tier (needs TYPESAFE_API_KEY)',
12 '/clauderouter engine rules use the local keyword rules (default)',
13 '/clauderouter warm [2h] keep the prompt cache warm for 2h (max 6h)',
14 '/clauderouter warm off stop keeping it warm',
15 '/clauderouter status show the current options',
16].join('\n')
17
18// "90m", "2h", "45" (minutes) -> ms, or null
19export function parseWindow(s: string): number | null {
20 const m = /^(\d+(?:\.\d+)?)\s*([mh]?)$/i.exec(s.trim())
21 if (!m) return null
22 const ms = Number(m[1]) * (m[2]?.toLowerCase() === 'h' ? 3_600_000 : 60_000)
23 return ms > 0 ? Math.min(ms, WARM_MAX_MS) : null
24}
25
26export function describe(o: Options, now: number): string {
27 const warm = o.warmUntil > now ? `on, ${Math.round((o.warmUntil - now) / 60_000)} min left` : 'off'
28 return `engine: ${o.engine} · keep-warm: ${warm}`
29}
30
31// Applies one /clauderouter argument string. next is null when the args are not an option change.
32export function applyArgs(args: string, o: Options, now: number): { next: Options | null; text: string } {
33 const [cmd = '', ...rest] = args.trim().split(/\s+/)
34 const arg = rest.join(' ')
35 switch (cmd.toLowerCase()) {
36 case 'engine':
37 if (arg !== 'jev' && arg !== 'rules') return { next: null, text: `engine takes jev or rules\n${USAGE}` }
38 return { next: { ...o, engine: arg }, text: `engine: ${arg}` }
39 case 'warm': {
40 if (arg === 'off') return { next: { ...o, warmUntil: 0 }, text: 'keep-warm: off' }
41 const ms = arg ? parseWindow(arg) : WARM_DEFAULT_MS
42 if (ms === null) return { next: null, text: `warm takes a duration like 90m or 2h, or off\n${USAGE}` }
43 return { next: { ...o, warmUntil: now + ms }, text: `keep-warm: on for ${Math.round(ms / 60_000)} min, a ping every 50 min while idle` }
44 }
45 case 'status':
46 return { next: null, text: describe(o, now) }
47 default:
48 return { next: null, text: USAGE }
49 }
50}
51hooks/band.ts 199 lines1import type { Elements, RenderElement } from 'claude-code'
2import type { JevUse, McpUse, Route, SkillUse, Task, Usage } from '../types'
3import { JEV_WINDOW } from './jev'
4import { familyOf } from './route'
5import { ACCENT, type Anim, type Role, type Tone, barCells, packCells, spriteCells, SPRITE_COLUMNS, SPRITE_ROWS } from './pixel'
6import { fmtFit, fmtPct, fmtTime, fmtTokens } from './format'
7
8export type Els = Elements['terminal']
9
10// Everything the drawings read, gathered by the hooks: the drawings take values, never $.
11// `anim` is the one thing they write: the Rasters they mount that the timer repaints.
12export type Snapshot = {
13 route: Route | null
14 tasks: Task[]
15 skills: SkillUse[]
16 mcp: McpUse[]
17 plugins: string[]
18 usage: Usage | null
19 jev: JevUse | null
20 totals: { tokens: number; turns: number }
21 now: number
22 frame: number
23 terminal: boolean
24 columns: number
25 rows: number
26 anim: Anim[]
27}
28
29// Text uses theme keys so light and dark both read; Rasters use the design's accents
30export const MODEL_TONE = { haiku: 'success', sonnet: 'suggestion', opus: 'warning' } as const
31const EFFORT_LEVEL = { low: 1, medium: 2, high: 3, xhigh: 4 } as const
32const MODELS = ['haiku', 'sonnet', 'opus'] as const
33const LABEL = 16
34// The crew needs this many rows above the band's own before it is drawn
35const CREW_MIN_ROWS = 22
36
37export function accentOf(model: string | null): number {
38 const family = model ? familyOf(model) : null
39 return family ? ACCENT[family] : ACCENT.brand
40}
41
42// A bar: a Raster in the terminal, a Text row on the desktop, which has no Raster.
43// A running bar with no total (pct null) shimmers; the terminal's timer repaints it with $.ui.blit.
44export function bar(els: Els, key: string, t: Snapshot, width: number, tone: Tone, pct: number | null, accent: number): RenderElement {
45 const cells = barCells(width, t.frame, tone, pct, accent)
46 if (!t.terminal) {
47 const color = tone === 'fail' ? 'error' : tone === 'done' ? 'success' : pct === null ? 'suggestion' : 'warning'
48 return els.Text({ children: [cells.map(c => (c[0] === '⠂' ? '·' : '█')).join('')], color })
49 }
50 if (tone === 'run' && pct === null) t.anim.push({ key, kind: 'bar', width, accent })
51 return els.Raster({ key, columns: width, rows: 1, cells: packCells([cells]) })
52}
53
54export function toneOf(status: Task['status']): Tone {
55 return status === 'running' ? 'run' : status === 'done' ? 'done' : 'fail'
56}
57
58// One crab of the crew: running ones step and wave, the rest stand still
59export function crab(els: Els, t: Snapshot, key: string, role: Role, running: boolean): RenderElement {
60 if (running) t.anim.push({ key, kind: 'sprite', role })
61 return els.Raster({ key, columns: SPRITE_COLUMNS, rows: SPRITE_ROWS, cells: spriteCells(role, 'still', t.frame) })
62}
63
64const barWidth = (t: Snapshot) => Math.max(8, Math.min(30, t.columns - 46))
65
66function label(els: Els, glyph: string, text: string, color = 'subtle'): RenderElement {
67 return els.Box({ width: LABEL, children: [els.Text({ children: [`${glyph} ${text}`], color })] })
68}
69
70function row(els: Els, children: RenderElement[]): RenderElement {
71 return els.Box({ flexDirection: 'row', columnGap: 2, children })
72}
73
74function header(els: Els, t: Snapshot): RenderElement[] {
75 const { Box, Text } = els
76 const running = t.tasks.filter(task => task.status === 'running')
77 const agents = running.flatMap(task => task.agents).filter(a => a.status === 'running')
78 const sub = running.length === 0 ? 'idle' : agents.length > 0 ? `working · ${agents.length} agent${agents.length === 1 ? '' : 's'}` : 'working'
79 const title = Box({
80 flexDirection: 'row',
81 columnGap: 2,
82 children: [Text({ children: ['ClaudeRouter'], bold: true, color: 'claude' }), Text({ children: [`${sub} · turn ${t.totals.turns + running.length}`], color: 'subtle' })],
83 })
84 if (!t.terminal || t.rows < CREW_MIN_ROWS) return [title]
85 const room = Math.max(0, Math.floor((t.columns - SPRITE_COLUMNS) / (SPRITE_COLUMNS + 1)))
86 const crew = [
87 crab(els, t, 'crab-router', 'router', running.length > 0),
88 ...agents.slice(0, Math.min(4, room)).map((a, i) => crab(els, t, `crab-${i}`, a.role, true)),
89 ]
90 return [Box({ flexDirection: 'row', columnGap: 1, children: crew }), title]
91}
92
93function routeRows(els: Els, r: Route): RenderElement[] {
94 const why = r.why
95 const { Box, Text } = els
96 const level = EFFORT_LEVEL[r.effort]
97 const prev = familyOf(r.engineModel)
98 const chips = MODELS.map(m =>
99 Text({
100 children: [m === prev && !r.kept ? `[${m}]` : ` ${m} `],
101 color: MODEL_TONE[m],
102 bold: m === r.model,
103 inverse: m === r.model,
104 dimColor: m !== r.model && m !== prev,
105 }),
106 )
107 // Jev sits beside the tiers: lit when it made this pick, dim when the rules did
108 chips.push(Text({ children: [' jev '], color: 'claude', bold: r.by === 'jev', inverse: r.by === 'jev', dimColor: r.by !== 'jev' }))
109 const signal = [1, 2, 3, 4].map(i => Text({ children: ['▂▄▆█'[i - 1] ?? ' '], color: i <= level ? MODEL_TONE[r.model] : 'inactive' }))
110 const confBar = '▬'.repeat(Math.round(r.conf * 5)).padEnd(5, '·')
111 return [
112 row(els, [
113 label(els, '⇄', 'Model route'),
114 Text({ children: [`${prev ?? '—'} =`], color: 'subtle' }),
115 Box({ flexDirection: 'row', children: chips }),
116 Box({ flexDirection: 'row', columnGap: 1, children: [Box({ flexDirection: 'row', children: signal }), Text({ children: [r.effort] })] }),
117 Text({ children: [`conf ${confBar} ${r.conf.toFixed(2)}`] }),
118 Text({ children: [r.kept ? '= kept' : '→ switched'], color: r.kept ? 'subtle' : 'claude' }),
119 ]),
120 ...(why ? [row(els, [Box({ width: LABEL }), Text({ children: [why], color: 'subtle', dimColor: true })])] : []),
121 ]
122}
123
124function taskRows(els: Els, t: Snapshot, task: Task): RenderElement[] {
125 const { Box, Text } = els
126 const done = task.agents.filter(a => a.status !== 'running').length
127 const status = task.status === 'running' ? `step ${task.steps}` : `${task.status} · ${fmtTime(task.seconds ?? 0)}`
128 const head = row(els, [
129 Box({
130 width: LABEL,
131 children: [Text({ children: [`● ${fmtFit(task.name, LABEL - 3)}`], bold: true, color: task.status === 'failed' ? 'error' : 'text' })],
132 }),
133 bar(els, `task-${task.id}`, t, barWidth(t), toneOf(task.status), null, accentOf(task.model)),
134 Text({ children: [status], color: 'subtle' }),
135 ...(task.agents.length > 0 ? [Text({ children: [`${done}/${task.agents.length} agents`] })] : []),
136 ])
137 const agents = task.agents.map(a => {
138 const seconds = a.status === 'running' ? (t.now - a.startedAt) / 1000 : (a.seconds ?? 0)
139 const family = a.model ? familyOf(a.model) : null
140 const state = a.status === 'running' ? 'running' : a.status === 'done' ? 'Done' : 'failed'
141 return Box({
142 flexDirection: 'row',
143 columnGap: 1,
144 children: [
145 Text({ children: [` └ ${fmtFit(a.name, 28)} ·`], color: 'subtle' }),
146 Text({ children: [state], color: a.status === 'failed' ? 'error' : a.status === 'done' ? 'success' : family ? MODEL_TONE[family] : 'text' }),
147 Text({ children: ['·'], color: 'subtle' }),
148 Text({ children: [` ${family ?? a.model ?? '—'} `], color: family ? MODEL_TONE[family] : 'subtle', inverse: family !== null }),
149 Text({ children: [`· ${fmtTime(seconds)}`], color: 'subtle' }),
150 ],
151 })
152 })
153 return [head, ...agents]
154}
155
156function meterRow(els: Els, t: Snapshot, glyph: string, name: string, detail: string, key: string, pct: number | null, accent: number): RenderElement {
157 // No reading means an empty track, never a shimmer or an invented figure
158 return row(els, [label(els, glyph, name), bar(els, key, t, barWidth(t), 'run', pct ?? 0, accent), els.Text({ children: [detail] })])
159}
160
161function listRow(els: Els, glyph: string, name: string, items: string[], empty: string, total: string): RenderElement {
162 const { Text } = els
163 return row(els, [
164 label(els, glyph, name),
165 ...(items.length > 0 ? items.map(s => Text({ children: [s] })) : [Text({ children: [empty], color: 'subtle' })]),
166 Text({ children: [total], color: 'subtle' }),
167 ])
168}
169
170// The band above the prompt. One row per fact the events expose; nothing is estimated.
171export function drawBand(els: Els, t: Snapshot): RenderElement {
172 const { Box, Text } = els
173 const shown = [...t.tasks.filter(task => task.status === 'running'), ...t.tasks.filter(task => task.status !== 'running').slice(-1)]
174 const extra = t.plugins.length - 5
175 const ctx = t.usage?.context ?? null
176 const five = t.usage?.rateLimits.find(r => r.kind === 'five_hour')
177 const week = t.usage?.rateLimits.find(r => r.kind === 'seven_day')
178 const calls = t.mcp.reduce((n, m) => n + m.calls, 0)
179
180 return Box({
181 flexDirection: 'column',
182 children: [
183 ...header(els, t),
184 ...(t.route ? routeRows(els, t.route) : [row(els, [label(els, '⇄', 'Model route'), Text({ children: ['waiting for the first request'], color: 'subtle' })])]),
185 ...shown.flatMap(task => taskRows(els, t, task)),
186 listRow(els, '✦', 'Skills', t.skills.slice(-4).map(s => `${s.name} used ×${s.uses}`), 'none used yet', `${t.skills.length} used`),
187 listRow(els, '◇', 'MCP', t.mcp.slice(-3).map(m => `${m.server} · ${m.tool} ×${m.calls}`), 'no calls yet', `${calls} calls`),
188 listRow(els, '▣', 'Plugins', [...t.plugins.slice(-5), ...(extra > 0 ? [`+${extra}`] : [])], 'none seen', `${t.plugins.length} mods`),
189 meterRow(els, t, '◆', 'Context', ctx ? `${fmtTokens(ctx.tokens)} / ${fmtTokens(ctx.window)} · ${fmtPct(ctx.percent)}` : '—', 'context', ctx?.percent ?? null, ACCENT.brand),
190 ...(t.jev
191 ? [meterRow(els, t, '◈', 'Jev · context', `${fmtTokens(t.jev.input)} / ${fmtTokens(JEV_WINDOW)} · ${fmtPct((t.jev.input / JEV_WINDOW) * 100)} · ${t.jev.calls} calls`, 'jev', (t.jev.input / JEV_WINDOW) * 100, ACCENT.brand)]
192 : []),
193 meterRow(els, t, '◷', 'Session · 5h', five ? `5h · ${fmtPct(five.percentUsed)} used` : 'no reading', 'session', five?.percentUsed ?? null, ACCENT.sonnet),
194 meterRow(els, t, '◷', 'Weekly · 7d', week ? `7d · ${fmtPct(week.percentUsed)} used` : 'no reading', 'weekly', week?.percentUsed ?? null, ACCENT.opus),
195 ],
196 })
197}
198
199hooks/pane.ts 144 lines1import type { RenderElement } from 'claude-code'
2import type { Agent, Task } from '../types'
3import { accentOf, bar, crab, MODEL_TONE, toneOf, type Els, type Snapshot } from './band'
4import { fmtFit, fmtPct, fmtTime, fmtTokens } from './format'
5import { familyOf } from './route'
6
7// One line of the Running or Finished list: a task, or a subagent under it
8type Row = {
9 key: string
10 name: string
11 role: Agent['role'] | 'router'
12 model: string | null
13 effort: string | null
14 ctx: string
15 tokens: number | null
16 status: Task['status']
17 seconds: number | null
18 startedAt: number
19 indent: boolean
20}
21
22// Context % is the main conversation's, so only its running task shows it; no agent has its own reading
23function rowsOf(tasks: Task[], ctx: string): Row[] {
24 return tasks.flatMap((task): Row[] => [
25 {
26 key: task.id,
27 name: task.name,
28 role: 'router',
29 model: task.model,
30 effort: task.effort,
31 ctx: task.status === 'running' ? ctx : '—',
32 tokens: task.tokens,
33 status: task.status,
34 seconds: task.seconds,
35 startedAt: task.startedAt,
36 indent: false,
37 },
38 ...task.agents.map((a, i): Row => ({
39 key: `${task.id}-${a.id ?? i}`,
40 name: a.name,
41 role: a.role,
42 model: a.model,
43 effort: a.effort,
44 ctx: '—',
45 tokens: a.status === 'running' ? null : a.tokens,
46 status: a.status,
47 seconds: a.seconds,
48 startedAt: a.startedAt,
49 indent: true,
50 })),
51 ])
52}
53
54function col(els: Els, width: number, text: string, props: { bold?: boolean; color?: string } = {}): RenderElement {
55 return els.Box({ width, children: [els.Text({ children: [text], ...props })] })
56}
57
58// A row: its crab beside two lines, name · role · model · effort · state over ctx · tokens · time and the bar
59function rowView(els: Els, t: Snapshot, r: Row, crabs: boolean): RenderElement {
60 const { Box, Text } = els
61 const seconds = r.status === 'running' ? (t.now - r.startedAt) / 1000 : (r.seconds ?? 0)
62 const family = r.model ? familyOf(r.model) : null
63 const running = r.status === 'running'
64 const mark = r.status === 'done' ? '✓' : r.status === 'failed' ? '✗' : t.frame % 10 < 5 ? '●' : '○'
65 const lines = Box({
66 flexDirection: 'column',
67 children: [
68 Box({
69 flexDirection: 'row',
70 columnGap: 1,
71 children: [
72 col(els, 26, `${r.indent && !crabs ? '└ ' : ''}${fmtFit(r.name, 24)}`, { bold: true }),
73 col(els, 11, r.role, { color: 'claude' }),
74 Text({ children: [`${family ?? '—'} · ${r.effort ?? '—'} · ${r.status}`], color: family ? MODEL_TONE[family] : 'subtle' }),
75 ],
76 }),
77 Box({
78 flexDirection: 'row',
79 columnGap: 1,
80 children: [
81 bar(els, `row-${r.key}`, t, Math.max(8, Math.min(36, t.columns - (crabs ? 52 : 30))), toneOf(r.status), null, accentOf(r.model)),
82 Text({ children: [`ctx ${r.ctx} · ${r.tokens === null ? '—' : fmtTokens(r.tokens)} · ${fmtTime(seconds)}`], color: 'subtle' }),
83 Text({ children: [mark], color: r.status === 'failed' ? 'error' : running ? 'claude' : 'success' }),
84 ],
85 }),
86 ],
87 })
88 if (!crabs) return lines
89 return Box({ flexDirection: 'row', columnGap: 1, children: [crab(els, t, `crab-${r.key}`, r.role, running), lines] })
90}
91
92function section(els: Els, t: Snapshot, title: string, rows: Row[], crabs: boolean): RenderElement[] {
93 const head = els.Text({ children: [`▾ ${title} · ${rows.length}`], bold: true })
94 if (rows.length === 0) return [head, els.Text({ children: ['none'], color: 'subtle' })]
95 return [head, ...rows.map(r => rowView(els, t, r, crabs))]
96}
97
98// The /ClaudeRouter pane: the current task and route, three cards, then the running and finished lists
99export function drawPane(els: Els, t: Snapshot): RenderElement {
100 const { Box, Text } = els
101 const card = (title: string, value: string) =>
102 Box({
103 flexDirection: 'column',
104 borderStyle: 'round',
105 paddingX: 1,
106 minWidth: 14,
107 children: [Text({ children: [title], color: 'subtle' }), Text({ children: [value], bold: true })],
108 })
109 const cost = t.usage?.cost ?? null
110 const started = t.usage?.startedAt ?? 0
111 const rows = rowsOf(t.tasks, fmtPct(t.usage?.context.percent))
112 const running = rows.filter(r => r.status === 'running')
113 // The crabs are terminal Rasters and need width; past a few rows they would bury the list
114 const crabs = t.terminal && t.columns >= 80 && rows.length <= 3
115 const current = t.tasks.filter(task => task.status === 'running').at(-1) ?? t.tasks.at(-1)
116 const r = t.route
117
118 return Box({
119 flexDirection: 'column',
120 gap: 1,
121 children: [
122 Box({
123 flexDirection: 'row',
124 justifyContent: 'space-between',
125 children: [
126 Text({ children: [current ? fmtFit(current.name, Math.max(20, t.columns - 40)) : 'No task yet'], bold: true }),
127 Text({ children: [r ? `route: ${r.model} · ${r.effort} · conf ${r.conf.toFixed(2)}` : 'route: —'], color: 'subtle' }),
128 ],
129 }),
130 Box({
131 flexDirection: 'row',
132 columnGap: 1,
133 children: [
134 card('Cost', cost === null ? '—' : `≈$${cost.toFixed(2)}`),
135 card('Tokens', fmtTokens(t.totals.tokens)),
136 card('Time', started > 0 ? fmtTime((t.now - started) / 1000) : '—'),
137 ],
138 }),
139 Box({ flexDirection: 'column', children: section(els, t, 'Running', running, crabs) }),
140 Box({ flexDirection: 'column', children: section(els, t, 'Finished', rows.filter(x => x.status !== 'running'), crabs) }),
141 ],
142 })
143}
144hooks/pixel.ts 133 lines1// Raster cells: the crab crew and the bars. A mod cannot read the theme, so Raster colours are
2// the design's fixed RGB; empty cells use the terminal's own default so light and dark both read.
3export const DEFAULT = 0x01000000
4
5// The design's accents: model chips, meters and the bars that follow them
6export const ACCENT = {
7 haiku: 0x55c99a,
8 sonnet: 0xa79cf2,
9 opus: 0xf0b84a,
10 brand: 0xe8835f,
11 fail: 0xd9534f,
12 track: 0x55535b,
13}
14
15// One cell: a character, its foreground colour and its background (the terminal's by default)
16export type Cell = [string, number, number?]
17
18// Pack rows of cells into the base64 string a Raster takes: per cell the code point, foreground, background
19export function packCells(rows: Cell[][]): string {
20 const words = Uint32Array.from(rows.flat().flatMap(([char, fg, bg]) => [char.codePointAt(0) ?? 32, fg, bg ?? DEFAULT]))
21 // toBase64 is in the mods runtime but not in the es2023 lib the types check against
22 return (new Uint8Array(words.buffer) as Uint8Array & { toBase64(): string }).toBase64()
23}
24
25// The crab, two frames (arm wave and step), from the design's BA / BB maps
26const BA = ['................', '................', '................', '..BBBBBBBBBBBB..', '..BBBBBBBBBBBB..', '..BBBEBBBBEBBB..', 'AABBBEBBBBEBBBAA', 'AABBBBBBBBBBBBAA', '..BRRBBBBBBRRB..', '..BBBBBBBBBBBB..', '...FF......FF...', '...FF......FF...']
27const BB = ['................', '................', '................', '..BBBBBBBBBBBB..', '..BBBBBBBBBBBBAA', '..BBBEBBBBEBBBAA', 'AABBBEBBBBEBBB..', 'AABBBBBBBBBBBB..', '..BRRBBBBBBRRB..', '..BBBBBBBBBBBB..', '...FF......FF...', '....FF....FF....']
28const PAL: Record<string, number> = {
29 A: 0xd06e54, R: 0xd9706f, F: 0xb4573f, B: 0xe07a5f, E: 0x1a1214, W: 0xece9e4, K: 0x2b2a30,
30 G: 0xa3a1a9, T: 0xb08a5a, P: 0xd9d6dd, N: 0x2f3a5c, Y: 0xe9c84a, Q: 0xa6d8ec,
31}
32
33type Part = [row: number, col: number, px: string]
34type RoleArt = { c: Record<string, number>; hat: Part[]; prop: Part[] }
35
36// Each role wears a hat and holds a tool: router headset + signpost, scout cap + flag,
37// builder hard hat + wrench, researcher bucket hat + magnifier, planner captain's cap + clipboard
38const ROLES = {
39 router: { c: { X: ACCENT.brand }, hat: [[0, 5, 'KKKKKK'], [1, 4, 'K......K'], [2, 3, 'XX......XX'], [3, 3, 'X........X']], prop: [[-3, 16, 'XXX'], [-2, 16, 'XXXX'], [-1, 16, 'XXX'], [0, 17, 'T'], [1, 17, 'T'], [2, 17, 'T']] },
40 scout: { c: { H: 0x2fa374, V: 0x1f6b4d }, hat: [[0, 5, 'HHHHHH'], [1, 4, 'HHHWWHHH'], [2, 4, 'HHHHHHHHV']], prop: [[-4, 16, 'PKWK'], [-3, 16, 'PWKW'], [-2, 16, 'PKWK'], [-1, 16, 'P'], [0, 16, 'P'], [1, 16, 'P']] },
41 builder: { c: { H: 0xe9c84a, h: 0xb88a2a }, hat: [[0, 6, 'HHHH'], [1, 4, 'HHHhHHHH'], [2, 3, 'hhhhhhhhhh']], prop: [[-3, 16, 'G.G'], [-2, 16, 'GGG'], [-1, 17, 'G'], [0, 17, 'G'], [1, 17, 'G']] },
42 researcher: { c: { H: 0x7a5236, h: 0xa8744c }, hat: [[0, 5, 'HhHHhH'], [1, 4, 'HHHHHHHH'], [2, 3, 'HHHHHHHHHH']], prop: [[-4, 17, 'GG'], [-3, 16, 'GQQG'], [-2, 16, 'GQQG'], [-1, 17, 'GG'], [0, 16, 'T'], [1, 15, 'T']] },
43 planner: { c: {}, hat: [[0, 5, 'WWWWWW'], [1, 4, 'WWWYYWWW'], [2, 4, 'NNNNNNNN']], prop: [[-3, 17, 'T'], [-2, 16, 'WWW'], [-1, 16, 'WKW'], [0, 16, 'WWW'], [1, 16, 'WKW']] },
44} satisfies Record<string, RoleArt>
45
46export type Role = keyof typeof ROLES
47export type Mode = 'run' | 'still'
48
49// The used part of the design's grid: body rows -4 (the tallest prop) to 11, columns 0 to 19
50const TOP = -4
51const PX_ROWS = 16
52export const SPRITE_COLUMNS = 20
53export const SPRITE_ROWS = PX_ROWS / 2
54
55function pixels(role: Role, step: boolean): (number | null)[][] {
56 const art: RoleArt = ROLES[role]
57 const pal: Record<string, number> = { ...PAL, ...art.c }
58 const grid = Array.from({ length: PX_ROWS }, () => Array<number | null>(SPRITE_COLUMNS).fill(null))
59 const put = (r: number, c: number, s: string) =>
60 [...s].forEach((ch, i) => {
61 const row = grid[r - TOP]
62 if (ch !== '.' && row && c + i < SPRITE_COLUMNS) row[c + i] = pal[ch] ?? null
63 })
64 ;(step ? BB : BA).forEach((row, r) => put(r, 0, row))
65 art.hat.forEach(([r, c, s]) => put(r, c, s))
66 // The tool bobs with the step, as the design's two frames do
67 art.prop.forEach(([r, c, s]) => put(r + (step ? 0 : 1), c, s))
68 return grid
69}
70
71// Two pixel rows per terminal row: '▀' holds the top pixel in its foreground and the bottom in its background
72export function spriteCells(role: Role, mode: Mode, frame: number): string {
73 const px = pixels(role, mode === 'run' && Math.floor(frame / 4) % 2 === 1)
74 const rows: Cell[][] = []
75 for (let y = 0; y + 1 < px.length; y += 2) {
76 const top = px[y] ?? []
77 const bottom = px[y + 1] ?? []
78 rows.push(
79 top.map((t, x): Cell => {
80 const b = bottom[x] ?? null
81 if (t === null && b === null) return [' ', DEFAULT]
82 if (t === b) return ['█', t ?? DEFAULT]
83 if (b === null) return ['▀', t ?? DEFAULT]
84 if (t === null) return ['▄', b]
85 return ['▀', t, b]
86 }),
87 )
88 }
89 return packCells(rows)
90}
91
92export type Tone = 'run' | 'done' | 'fail'
93
94// Dither ramp: sparse at the left, dense at the leading edge, like the design's fading dot field
95const RAMP = ['⡀', '⡄', '⡆', '⡇', '⣿']
96const TRACK: Cell = ['⠂', ACCENT.track]
97
98function lighten(c: number): number {
99 const ch = (s: number) => Math.min(255, (((c >> s) & 0xff) + 255) >> 1) << s
100 return ch(16) | ch(8) | ch(0)
101}
102
103// One row of a bar in an accent colour. A known percent fills to it along the ramp; 'run' with
104// none shimmers (a twinkling dithered field with a bright head sweeping through); 'done' and 'fail' are solid.
105export function barCells(width: number, frame: number, tone: Tone, pct: number | null, accent: number): Cell[] {
106 if (tone === 'fail') return Array.from({ length: width }, (): Cell => ['█', ACCENT.fail])
107 if (tone === 'done') return Array.from({ length: width }, (): Cell => ['█', accent])
108 if (pct !== null) {
109 const filled = Math.round((Math.min(100, Math.max(0, pct)) / 100) * width)
110 return Array.from({ length: width }, (_, x): Cell => {
111 if (x >= filled) return TRACK
112 const level = Math.min(RAMP.length - 1, Math.floor(((x + 1) / filled) * RAMP.length))
113 return [RAMP[level] ?? '⣿', accent]
114 })
115 }
116 const head = (frame % (width + 6)) - 3
117 return Array.from({ length: width }, (_, x): Cell => {
118 const d = x - head
119 if (d >= 0 && d < 3) return ['⣿', lighten(accent)]
120 const twinkle = (x * 7 + frame) % 5
121 return [RAMP[twinkle < 2 ? 1 : twinkle < 4 ? 2 : 3] ?? '⡆', accent]
122 })
123}
124
125// A Raster the timer repaints with $.ui.blit: a shimmering bar, or a running crab
126export type Anim =
127 | { key: string; kind: 'bar'; width: number; accent: number }
128 | { key: string; kind: 'sprite'; role: Role }
129
130export function animCells(a: Anim, frame: number): string {
131 return a.kind === 'sprite' ? spriteCells(a.role, 'run', frame) : packCells([barCells(a.width, frame, 'run', null, a.accent)])
132}
133hooks/route.ts 101 lines1import type { Agent, Effort, Model } from '../types'
2
3// The mascot a subagent wears: Explore scouts, Plan plans, research reads, the rest build
4export function roleOf(subagentType: string, text: string): Agent['role'] {
5 if (/explore/i.test(subagentType)) return 'scout'
6 if (/plan/i.test(subagentType)) return 'planner'
7 if (/\b(research|investigat\w*|verify|trace|review|audit)\b/i.test(text)) return 'researcher'
8 return 'builder'
9}
10
11// Local heuristics only: no model is called to decide. Counts keyword hits per tier,
12// prompt length, the subagent's type and the tools this turn has used.
13export type RouteInput = {
14 text: string
15 tools: readonly string[]
16 subagentType?: string
17 current?: Model | null
18}
19
20// ponytail: fixed constant; move to $.state config if tuning is wanted
21export const HYSTERESIS = 0.08
22
23export type RoutePick = { model: Model; effort: Effort; conf: number }
24
25const HEAVY = /\b(plan|architect\w*|design|debug\w*|trace|root cause|refactor\w*|migrat\w*|security|race|deadlock|trade-?offs?|investigat\w*|why|performance|optimi[sz]e\w*|across|system)\b/gi
26const MID = /\b(write|implement\w*|fix\w*|test\w*|fixtures?|review\w*|add|update|build)\b/gi
27const LIGHT = /\b(list|read|show|rename|print|count|find|grep|format|typo|log|open|which|where|summari[sz]e)\b/gi
28const DEEP = /\b(plan|architect\w*)\b/i
29const EDITS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
30const SEARCH = ['Glob', 'Grep', 'Read']
31const FAMILIES: Model[] = ['haiku', 'sonnet', 'opus']
32
33// The model family a full model id belongs to, or null when it names none
34export function familyOf(id: string): Model | null {
35 return FAMILIES.find(f => id.includes(f)) ?? null
36}
37
38// The full model id for a family: the running id with its family swapped, so the version suffix is kept
39export function idFor(family: Model, running: string): string {
40 return familyOf(running) ? running.replace(/haiku|sonnet|opus/, family) : `claude-${family}-5-5`
41}
42
43const hits = (text: string, re: RegExp, cap: number) => Math.min(cap, (text.match(re) ?? []).length)
44
45// Scores each tier and picks the highest; a tie, or no signal at all, stays on sonnet
46export function pickRoute(input: RouteInput): RoutePick {
47 const { text, tools } = input
48 const heavy = hits(text, HEAVY, 3)
49 const score = { haiku: hits(text, LIGHT, 2), sonnet: 0.5 + hits(text, MID, 2), opus: heavy }
50
51 if (text.length > 400) score.opus += 1
52 else if (text.length < 60 && heavy === 0) score.haiku += 1
53 if (input.subagentType === 'Explore') score.haiku += 2
54 if (input.subagentType === 'Plan') score.opus += 2
55 if (heavy === 0 && tools.length > 0 && tools.every(t => SEARCH.includes(t))) score.haiku += 1
56 if (tools.some(t => EDITS.includes(t))) score.sonnet += 1
57 if (tools.includes('Bash') && tools.filter(t => EDITS.includes(t)).length >= 3) score.opus += 1
58
59 const ranked = FAMILIES.map(m => [m, score[m]] as const).sort((a, b) => b[1] - a[1])
60 const [top, second] = ranked
61 let model: Model = top && second && top[1] > second[1] ? top[0] : 'sonnet'
62 // Hysteresis: stay on the current model unless the new one beats it by more than HYSTERESIS (softmax share)
63 const total = FAMILIES.reduce((s, m) => s + Math.exp(score[m]), 0)
64 const share = (m: Model) => Math.exp(score[m]) / total
65 if (input.current && input.current !== model && share(model) - share(input.current) <= HYSTERESIS) model = input.current
66 const effort: Effort =
67 model === 'haiku' ? 'low'
68 : model === 'sonnet' ? (score.sonnet >= 2 ? 'medium' : 'low')
69 : DEEP.test(text) || input.subagentType === 'Plan' ? 'xhigh'
70 : heavy >= 2 ? 'high'
71 : 'medium'
72 // Confidence from the margin over the runner-up, kept to 0.50–0.99
73 const margin = (top?.[1] ?? 0) - (second?.[1] ?? 0)
74 const conf = Math.min(0.99, Math.max(0.5, 0.5 + 0.5 * (1 - Math.exp(-margin))))
75
76 return { model, effort, conf: Math.round(conf * 100) / 100 }
77}
78
79// The effort for a tier chosen by something other than pickRoute (Jev): the same ladder, without the scores
80export function effortFor(model: Model, text: string, subagentType?: string): Effort {
81 if (model === 'haiku') return 'low'
82 if (model === 'sonnet') return 'medium'
83 return DEEP.test(text) || subagentType === 'Plan' ? 'xhigh' : 'high'
84}
85
86// The signals behind a pick, as the band's dim second line: “excerpt” · kw:debug · len 212 · tools Bash·Edit
87export function whyOf(input: RouteInput): string {
88 const kw = (input.text.match(HEAVY) ?? input.text.match(MID) ?? input.text.match(LIGHT) ?? [])[0]?.toLowerCase()
89 const excerpt = input.text.replace(/\s+/g, ' ').trim().slice(0, 40)
90 const tools = [...new Set(input.tools)].slice(-3)
91 return [
92 excerpt ? `“${excerpt}${input.text.length > 40 ? '…' : ''}”` : '',
93 input.subagentType ? `agent:${input.subagentType}` : '',
94 kw ? `kw:${kw}` : '',
95 `len ${input.text.length}`,
96 tools.length > 0 ? `tools ${tools.join('·')}` : 'no tools',
97 ]
98 .filter(Boolean)
99 .join(' · ')
100}
101hooks/lists.ts 57 lines1import type { Agent, McpUse, SkillUse, Task, Usage } from '../types'
2
3// Pure list and value helpers the hooks use. No mods API calls here.
4
5// The session usage the engine reports, in ClaudeRouter's shape
6export type RawUsage = {
7 startedAt: number
8 context: { tokens?: number; window: number; percent?: number }
9 rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
10 cost?: { usd: number }
11}
12
13export function toUsage(raw: RawUsage): Usage {
14 return {
15 startedAt: raw.startedAt,
16 context: { tokens: raw.context.tokens ?? null, window: raw.context.window, percent: raw.context.percent ?? null },
17 rateLimits: raw.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt ?? null })),
18 cost: raw.cost?.usd ?? null,
19 }
20}
21
22// A task's name is its prompt, shortened to one line
23export function nameOf(text: string): string {
24 return text.replace(/\s+/g, ' ').trim().slice(0, 60) || 'prompt'
25}
26
27export function bumpSkill(list: SkillUse[], name: string): SkillUse[] {
28 const hit = list.find(s => s.name === name)
29 if (!hit) return [...list.slice(-7), { name, uses: 1 }]
30 return list.map(s => (s === hit ? { ...s, uses: s.uses + 1 } : s))
31}
32
33export function bumpMcp(list: McpUse[], server: string, tool: string): McpUse[] {
34 const hit = list.find(m => m.server === server && m.tool === tool)
35 if (!hit) return [...list.slice(-7), { server, tool, calls: 1 }]
36 return list.map(m => (m === hit ? { ...m, calls: m.calls + 1 } : m))
37}
38
39export function addPlugin(list: string[], name: string): string[] {
40 return list.includes(name) ? list : [...list, name]
41}
42
43export function attachAgent(list: Task[], taskId: string, agent: Agent): Task[] {
44 return list.map(t => (t.id === taskId ? { ...t, agents: [...t.agents, agent] } : t))
45}
46
47export function patchAgent(list: Task[], agentId: string, patch: Partial<Agent>): Task[] {
48 return list.map(t => ({
49 ...t,
50 agents: t.agents.map(a => (a.id === agentId ? { ...a, ...patch } : a)),
51 }))
52}
53
54export function patchTask(list: Task[], taskId: string, patch: Partial<Task>): Task[] {
55 return list.map(t => (t.id === taskId ? { ...t, ...patch } : t))
56}
57hooks/format.ts 22 lines1// Compact figures for ClaudeRouter: 124k, 1.0M, 1:02
2
3export function fmtTokens(n: number | null | undefined): string {
4 if (n === null || n === undefined) return '—'
5 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
6 if (n >= 1000) return `${Math.round(n / 1000)}k`
7 return String(n)
8}
9
10export function fmtTime(seconds: number): string {
11 const s = Math.max(0, Math.round(seconds))
12 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
13}
14
15export function fmtFit(text: string, n: number): string {
16 return text.length <= n ? text : `${text.slice(0, n - 1)}…`
17}
18
19export function fmtPct(p: number | null | undefined): string {
20 return p === null || p === undefined ? '—' : `${Math.round(p)}%`
21}
22types/index.d.ts 82 lines1// The state contract: every value ClaudeRouter keeps in $.state, declared under the mod's name
2
3export type Model = 'haiku' | 'sonnet' | 'opus'
4export type Effort = 'low' | 'medium' | 'high' | 'xhigh'
5// Who picks the tier: the local keyword rules, or Jev (falls back to the rules when it is unreachable)
6export type Engine = 'rules' | 'jev'
7
8// The user's switches, set with /clauderouter. warmUntil is a clock time in ms; 0 means keep-warm is off.
9export type Options = { engine: Engine; warmUntil: number }
10
11// The latest routing decision, for the main conversation or one subagent request
12export type Route = {
13 model: Model
14 effort: Effort
15 conf: number
16 kept: boolean
17 // Who made the pick; 'rules' also when Jev was asked and failed
18 by: Engine
19 engineModel: string
20 source: 'main' | 'subagent'
21 // The signals it was picked on, for the band's second line
22 why: string
23}
24
25// A subagent of a task; its tokens and seconds arrive with its own turn.complete
26export type Agent = {
27 id: string | null
28 name: string
29 // The mascot it wears, from its type and task: scout, builder, researcher or planner
30 role: 'scout' | 'builder' | 'researcher' | 'planner'
31 model: string | null
32 effort: Effort | null
33 status: 'running' | 'done' | 'failed'
34 startedAt: number
35 seconds: number | null
36 tokens: number
37}
38
39// One user turn. There is no total for it, so its bar shimmers until it ends.
40// Its model and effort are the router's latest pick for it; its tokens arrive with turn.complete.
41export type Task = {
42 id: string
43 name: string
44 status: 'running' | 'done' | 'failed'
45 startedAt: number
46 seconds: number | null
47 steps: number
48 model: Model | null
49 effort: Effort | null
50 tokens: number | null
51 agents: Agent[]
52}
53
54// What Jev has cost this session: calls made, and the last call's tokens against its 64k window
55export type JevUse = { calls: number; input: number; output: number }
56
57export type SkillUse = { name: string; uses: number }
58export type McpUse = { server: string; tool: string; calls: number }
59
60export type Usage = {
61 startedAt: number
62 context: { tokens: number | null; window: number; percent: number | null }
63 rateLimits: { kind: string; percentUsed: number; resetsAt: string | null }[]
64 cost: number | null
65}
66
67declare module 'claude-code' {
68 interface PluginState {
69 'agent-ClaudeRouter': {
70 route: Route | null
71 tasks: Task[]
72 skills: SkillUse[]
73 mcp: McpUse[]
74 plugins: string[]
75 usage: Usage | null
76 options: Options
77 jev: JevUse | null
78 totals: { tokens: number; turns: number }
79 }
80 }
81}
82