Runs each skill and subagent on the right size of model, picked by Jev through OpenRouter and never above the session model. Shows each running subagent in a…

Ian's Claude Code mods. The repo is a plugin marketplace called ian-mods, and each mod lives in its own folder under mods/.
| Mod | What it does |
|---|---|
prompt-upgrade | /better <prompt> asks up to three quick questions, rewrites the prompt and shows the original and the rewrite side by side. Nothing runs until you press Run. /better on sends what you type to it first. |
work-report | /report opens a pane with the tasks Claude finished for you and an estimate of the hours saved and what that time is worth, for today, the last 7 days, the last 30 days and the year. /report settings shows and changes the minutes per task and the hourly rate. /report backfill fills in past days from this machine's session history (needs Python 3: /usr/bin/python3 on a Mac, py or python on Windows). Building software is estimated from the lines of code written (/report settings buildrate 25), and a By project section shows hours per repo. /report add "QBR builder" 160 2026-05-01 adds hours for older work (/report list, /report remove 1). Counts are kept per machine. |
skill-router | Runs each skill and subagent on the right size of model, and has Fable check the finished work. Simple fixed-step skills (docket, schedule, loop, keybindings-help, consolidate-memory) always run on Haiku. For the rest, Jev (TypeSafe's decision model on OpenRouter) picks Haiku, Sonnet or Opus, never above the session model, and the session model comes back on your next prompt. Every subagent gets the same treatment: the session picks the agent's role, Jev picks its size. Each session starts with conductor rules: when to hand work to subagents and when to do it inline. When a turn changed files (Edit, Write, NotebookEdit or an Artifact publish), a Stop hook keeps it going until the final-review agent has checked the work on Fable and answered PASS or FIX. It asks once per prompt: edits made after that review end without a second one (logged as review skipped). Files written by Bash don't trigger it. While subagents run, a band above the chat box shows one row per agent: a dot and chip in the model colour (Haiku green, Sonnet blue, Opus purple, Fable gold), the agent and its task, a thin progress bar, and what it is doing. The bar fills by tool calls against the usual count for that agent type and holds at 90% until the agent finishes. Finished rows clear on your next typed message, and the band goes with them. The band's [-] collapses it. /skill-router pane opens the full cards beside the chat, which also show how the model was chosen (what Jev was offered, its pick, how sure it was and how long it took). /skill-router view band, view pane or view off picks where agents show by themselves. Each pick and review is logged with its time, cost and tokens. /skill-router log, /skill-router off/on (skills), /skill-router agents off/on, /skill-router review off/on. Needs an OpenRouter key in the OPENROUTER_API_KEY environment variable or in the login Keychain (see below). |
The clone lives at ~/claude-mods on every machine. Install each mod once per machine at user scope. After that it loads in every local session: the desktop app's Code tab and the terminal.
git clone https://github.com/iansutherland75/claude-mods.git ~/claude-mods
claude plugin marketplace add ~/claude-mods
claude plugin install prompt-upgrade@ian-mods --scope user
claude plugin install work-report@ian-mods --scope user
claude plugin install skill-router@ian-mods --scope user
skill-router reads its OpenRouter key from the login Keychain. Copy the key, then run this once per machine. It never shows the key:
security add-generic-password -U -a "$USER" -s OPENROUTER_API_KEY -w "$(pbpaste)"
Run these with Claude Code 2.1.295 or newer. If claude --version shows an older build, run brew upgrade claude-code first.
The marketplace is a local folder, so sessions read each mod straight from ~/claude-mods. Nothing needs reinstalling after a change.
git pull in ~/claude-mods./reload-plugins in any open session. New sessions pick it up on their own.A new mod needs one extra step on each machine: claude plugin install <mod>@ian-mods --scope user.
mods/<mod-name>/ with its .claude-plugin/plugin.json, hooks/ and tests..claude-plugin/marketplace.json with "source": "./mods/<mod-name>".claude plugin validate mods/<mod-name> and claude plugin test mods/<mod-name>.Cloud sessions (claude.ai/code, cloud Cowork) do not see mods installed on a machine.
hooks/register.tsx 652 lines1import { atom, read, update } from 'claude-code'
2import type { AgentSpawnInput, EngineInterface, Register } from 'claude-code'
3
4import type { AgentCard, AgentsView } from '../types'
5
6import { CONDUCTOR } from './conductor'
7import {
8 JEV_URL,
9 PINS,
10 REVIEWER,
11 REVIEW_ASK,
12 TOUCH_TOOLS,
13 agentJevOptions,
14 agentJevRequest,
15 agentTierOf,
16 appendLog,
17 bareName,
18 clip,
19 colorOf,
20 decide,
21 decideAgent,
22 expectedTools,
23 jevOptions,
24 jevRequest,
25 logLine,
26 modelLabel,
27 parseCount,
28 paneHeading,
29 parseJev,
30 routeText,
31 statusText,
32 stopDecision,
33 summary,
34 toolLine,
35 verdictOf,
36} from './logic'
37import type { JevAnswer, LogEntry, Tier } from './logic'
38import { MUTED, TEXT, bandCards, bandLine, bandSvg, barText, cardSvg, pixelsFor } from './pane'
39
40type D = EngineInterface
41
42const COMMAND = 'skill-router'
43const ENABLED = 'enabled'
44const AGENTS = 'agents'
45const REVIEW = 'review'
46const LOG = 'log'
47const VIEW = 'view'
48
49const PANE = 'agents'
50const PANE_TITLE = 'Agents'
51const NARROW = 'The Agents pane needs a wider window. Run /skill-router pane to open it.'
52
53// The cards for the Agents band and pane, for this session.
54const cards = atom({ plugin: 'skill-router', key: 'agents' } as const, [] as AgentCard[])
55// Where agents show unasked: the band above the prompt (the default), the
56// pane, or nowhere. Kept in $.store across sessions, mirrored here so a
57// change redraws the band at once.
58const view = atom({ plugin: 'skill-router', key: 'view' } as const, 'band' as AgentsView)
59const VIEWS: readonly AgentsView[] = ['band', 'pane', 'off']
60
61const readView = async ($: D): Promise<AgentsView> => {
62 const kept = await $.store.get(VIEW)
63
64 return VIEWS.includes(kept as AgentsView) ? (kept as AgentsView) : 'band'
65}
66
67// The pick for the turn in progress. A reload mid-turn drops it, and the turn
68// finishes on the session model: the safe side.
69let pending: { model: string | null; entry: LogEntry } | null = null
70let lastPrompt = ''
71let apiKey: string | null = null
72
73// The review gate for the prompt in progress: the step of the last real work
74// and of the last review spawn. A reload drops both, so the gate lets the turn
75// end: the safe side.
76let step = 0
77let lastTouch = 0
78let lastReview = 0
79// Agents with a log line waiting for their turn's tokens.
80const tracked = new Set<string>()
81let cardCount = 0
82// One "too narrow" toast a session is enough.
83let isNarrowSaid = false
84
85// ---------------------------------------------------------------- store
86
87const isOn = async ($: D, key: string): Promise<boolean> => ((await $.store.get(key)) ?? true) === true
88
89const isEnabled = ($: D): Promise<boolean> => isOn($, ENABLED)
90
91const readLog = async ($: D): Promise<LogEntry[]> => ((await $.store.get(LOG)) as LogEntry[] | undefined) ?? []
92
93// Agents spawn in parallel, so log writes (read, change, write) take turns.
94let logQueue: Promise<unknown> = Promise.resolve()
95const serial = <T,>(work: () => Promise<T>): Promise<T> => {
96 const run = logQueue.then(work, work)
97 logQueue = run.catch(() => undefined)
98
99 return run
100}
101
102const writeEntry = ($: D, entry: LogEntry) =>
103 serial(async () => {
104 await $.store.set(LOG, appendLog(await readLog($), entry))
105 })
106
107// ---------------------------------------------------------------- key
108
109// Environment first, then the login Keychain. Only a found key is cached, so
110// one added mid-session is picked up on the next skill. A 401 or 403 from Jev
111// drops the cached key (see askJev).
112const readKey = async ($: D): Promise<string | null> => {
113 if (apiKey) return apiKey
114 const fromEnv = await $.env.get('OPENROUTER_API_KEY')
115 if (fromEnv) return (apiKey = fromEnv)
116 try {
117 const found = await $.process.run(['security', 'find-generic-password', '-s', 'OPENROUTER_API_KEY', '-w'], {
118 timeoutMs: 5000,
119 })
120 if (found.exitCode === 0 && found.stdout.trim()) apiKey = found.stdout.trim()
121 } catch {
122 // No Keychain here (not macOS, or no process access): routing falls back.
123 }
124
125 return apiKey
126}
127
128// ---------------------------------------------------------------- routing
129
130const askJev = async (
131 $: D,
132 request: object,
133 options: readonly Tier[],
134): Promise<{ answer: JevAnswer | null; ms: number; note?: string }> => {
135 const started = await $.clock.now()
136 const key = await readKey($)
137 if (!key) return { answer: null, ms: 0, note: 'no OPENROUTER_API_KEY' }
138 try {
139 const response = await $.http.fetch(JEV_URL, {
140 method: 'POST',
141 headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', 'X-Title': 'skill-router' },
142 body: JSON.stringify(request),
143 })
144 const ms = Math.round((await $.clock.now()) - started)
145 // A refused key is dropped, so a key rotated mid-session is read on the next call.
146 if (response.status === 401 || response.status === 403) apiKey = null
147 if (!response.ok) return { answer: null, ms, note: `Jev HTTP ${response.status}` }
148
149 return { answer: parseJev(response.text, options), ms }
150 } catch {
151 return { answer: null, ms: Math.round((await $.clock.now()) - started), note: 'Jev call failed' }
152 }
153}
154
155const route = async ($: D, skill: string, description: string) => {
156 if (!(await isEnabled($))) return
157 const session = await $.session.model()
158 const options = jevOptions(skill, session)
159 const asked = options ? await askJev($, jevRequest(skill, description, lastPrompt, options), options) : undefined
160 const decision = decide(skill, session, asked?.answer)
161
162 // A second skill in one turn: the first one's line goes in as it stands.
163 if (pending) await writeEntry($, pending.entry)
164
165 const entry: LogEntry = {
166 at: await $.clock.now(),
167 skill,
168 session,
169 source: decision.source,
170 pick: decision.pick,
171 switched: decision.model !== null,
172 ...(asked?.answer ? { probability: asked.answer.probability, jevCost: asked.answer.cost } : {}),
173 ...(asked ? { jevMs: asked.ms } : {}),
174 ...(decision.note || asked?.note ? { note: [asked?.note, decision.note].filter(Boolean).join('; ') } : {}),
175 }
176 pending = { model: decision.model, entry }
177 $.ui.status(decision.model ? `${bareName(skill)} on ${decision.pick}` : undefined)
178}
179
180// The model for one subagent, and its log line, or null to leave the spawn
181// alone and unlogged (agent routing off, and not the reviewer).
182const routeAgent = async (
183 $: D,
184 e: AgentSpawnInput,
185): Promise<{ model: string | null; entry: LogEntry; offered: Tier[] | null } | null> => {
186 const isReviewer = e.subagentType === REVIEWER
187 if (!isReviewer && !(await isOn($, AGENTS))) return null
188 const options = agentJevOptions(e, e.parentModel)
189 const asked = options
190 ? await askJev($, agentJevRequest(e.subagentType, e.description, e.prompt, options), options)
191 : undefined
192 const decision = decideAgent(e, e.parentModel, asked?.answer)
193
194 const entry: LogEntry = {
195 at: await $.clock.now(),
196 kind: isReviewer ? 'review' : 'agent',
197 skill: e.subagentType,
198 task: clip(e.description, 80),
199 session: e.parentModel,
200 source: decision.source,
201 pick: decision.pick,
202 switched: decision.model !== null && decision.pick !== agentTierOf(e.parentModel),
203 ...(asked?.answer ? { probability: asked.answer.probability, jevCost: asked.answer.cost } : {}),
204 ...(asked ? { jevMs: asked.ms } : {}),
205 ...(decision.note || asked?.note ? { note: [asked?.note, decision.note].filter(Boolean).join('; ') } : {}),
206 }
207
208 return { model: decision.model, entry, offered: options }
209}
210
211// An agent's turn ended: its tokens, and a review's verdict, go on its line.
212const finishAgent = async (
213 $: D,
214 agentId: string,
215 answer: string,
216 durationMs: number,
217 tokens: Pick<LogEntry, 'tokensIn' | 'tokensOut' | 'toolCalls'>,
218) =>
219 serial(async () => {
220 const list = await readLog($)
221 const at = list.findLastIndex(entry => entry.agentId === agentId)
222 const entry = list[at]
223 if (!entry) return
224 const verdict = entry.kind === 'review' ? verdictOf(answer) : undefined
225 list[at] = { ...entry, turnMs: durationMs, ...tokens, ...(verdict ? { verdict } : {}) }
226 await $.store.set(LOG, list)
227 })
228
229const isTouch = (e: { tool: string; action?: unknown }): boolean =>
230 (TOUCH_TOOLS as readonly string[]).includes(e.tool) &&
231 (e.tool !== 'Artifact' || e.action === undefined || e.action === 'publish')
232
233// ---------------------------------------------------------------- agents pane
234
235const changeCard = ($: D, match: (card: AgentCard) => boolean, change: (card: AgentCard) => Partial<AgentCard>) =>
236 update($, cards, list => list.map(card => (match(card) ? { ...card, ...change(card) } : card)))
237
238const byId = (id: string) => (card: AgentCard) => card.id === id
239
240// Unasked, the surface places a pane only when it is wide enough; the command
241// is the asked path, placed at any width.
242const openPane = async ($: D, isAsked: boolean) => {
243 const opened = await $.ui.open({ id: PANE, title: PANE_TITLE })
244 if (!opened.isPlaced && (isAsked || !isNarrowSaid)) {
245 isNarrowSaid = true
246 $.ui.toast(NARROW)
247 }
248
249 return opened.isPlaced
250}
251
252// A card for every spawn, routed or not, and the pane opened for it.
253const addCard = async ($: D, e: AgentSpawnInput): Promise<string> => {
254 // The start time keeps ids apart across a reload, which resets the count
255 // while the cards stay.
256 const startedAt = await $.clock.now()
257 const id = `card-${Math.round(startedAt)}-${++cardCount}`
258 const card: AgentCard = {
259 id,
260 type: e.subagentType,
261 task: e.description,
262 stage: 'routing',
263 model: null,
264 tools: 0,
265 expected: expectedTools(await readLog($), e.subagentType),
266 startedAt,
267 }
268 await update($, cards, list => [...list, card])
269 if ((await read($, view)) === 'pane') await openPane($, false)
270
271 return id
272}
273
274const routeFields = (routed: Awaited<ReturnType<typeof routeAgent>>): Partial<AgentCard> => {
275 if (!routed) return { source: undefined }
276 const { entry, offered } = routed
277
278 return {
279 source: entry.source,
280 pick: entry.pick,
281 ...(offered ? { offered } : {}),
282 ...(entry.probability !== undefined ? { probability: entry.probability } : {}),
283 ...(entry.jevMs !== undefined ? { jevMs: entry.jevMs } : {}),
284 ...(entry.note ? { note: entry.note } : {}),
285 }
286}
287
288// Pane trouble never stops an agent or a tool call.
289const quietly = async (work: () => Promise<unknown>) => {
290 try {
291 await work()
292 } catch {
293 // The pane misses one change; the agent runs on.
294 }
295}
296
297// ---------------------------------------------------------------- command
298
299const commandText = async ($: D, args: string): Promise<string> => {
300 const [word = 'status', rest = ''] = args.trim().split(/\s+/, 2)
301 if (word === 'on' || word === 'off') {
302 await $.store.set(ENABLED, word === 'on')
303
304 return `Skill router is ${word}.`
305 }
306 if ((word === 'agents' || word === 'review') && (rest === 'on' || rest === 'off')) {
307 await $.store.set(word === 'agents' ? AGENTS : REVIEW, rest === 'on')
308
309 return word === 'agents' ? `Agent routing is ${rest}.` : `Fable review is ${rest}.`
310 }
311 if (word === 'log') {
312 const list = await readLog($)
313 const lines = list.slice(-parseCount(rest, 20)).map(logLine)
314
315 return lines.length ? [...lines, '', summary(list)].join('\n') : 'No picks logged yet.'
316 }
317 if (word === 'pane') return (await openPane($, true)) ? 'Agents pane opened.' : NARROW
318 if (word === 'view' && (VIEWS as readonly string[]).includes(rest)) {
319 const next = rest as AgentsView
320 await $.store.set(VIEW, next)
321 await update($, view, () => next)
322
323 return {
324 band: 'Agents show in a band above the chat box. /skill-router pane opens the full cards.',
325 pane: 'Agents show in the pane beside the chat. It opens when an agent starts.',
326 off: 'Agents show nowhere until /skill-router pane.',
327 }[next]
328 }
329 if (word === 'pins') return `Pinned to Haiku: ${PINS.join(', ')}.`
330 if (word === 'clear') {
331 await serial(() => $.store.set(LOG, []))
332
333 return 'Log cleared.'
334 }
335 const key = (await readKey($)) ? 'found' : 'missing'
336
337 const state = async (k: string) => ((await isOn($, k)) ? 'on' : 'off')
338
339 return [
340 `Skill routing ${await state(ENABLED)}, agent routing ${await state(AGENTS)}, Fable review ${await state(REVIEW)}, agents view ${await readView($)}. OpenRouter key: ${key}.`,
341 summary(await readLog($)),
342 'Use: /skill-router on | off | agents on|off | review on|off | view band|pane|off | pane | status | log [n] | pins | clear',
343 ].join('\n')
344}
345
346// ---------------------------------------------------------------- hooks
347
348export const register: Register = on => {
349 on('session.start', async ($, e, next) => {
350 await $.command.register({
351 name: COMMAND,
352 description: 'Skill and agent model router, Agents band and pane, Fable review: on, off, agents, review, view, pane, status, log, pins',
353 })
354 const kept = await readView($)
355 await update($, view, () => kept)
356
357 return next(e)
358 })
359
360 // The conductor rules, for the main session only. Fires again after a
361 // compaction, so the rules survive it. A resume or fork carries the
362 // transcript, rules and all, so adding them there would double them.
363 on('classic.SessionStart', async ($, e, next) => {
364 const done = await next(e)
365 if (e.agent_id !== undefined || e.source === 'resume' || e.source === 'fork') return done
366
367 return { ...done, additionalContext: [...(done.additionalContext ?? []), CONDUCTOR] }
368 })
369
370 on('command.run', { command: COMMAND }, async ($, e) => ({ text: await commandText($, e.args) })).catch(() => ({
371 text: 'Skill router hit a problem reading its settings.',
372 }))
373
374 // A new prompt ends the last pick, so the session model is back.
375 on('prompt.submit', async ($, e, next) => {
376 if (!e.isMeta) {
377 pending = null
378 lastPrompt = e.command ? `/${e.command.name} ${e.command.args}`.trim() : e.text
379 lastTouch = 0
380 lastReview = 0
381 }
382 // A typed prompt clears the finished cards. A slash command doesn't:
383 // /skill-router pane would otherwise open on an empty pane.
384 if (!e.isMeta && !e.command) {
385 await quietly(async () => {
386 const live = (await read($, cards)).filter(card => card.stage === 'routing' || card.stage === 'running')
387 await update($, cards, () => live)
388 if (live.length === 0) await $.ui.close({ id: PANE })
389 })
390 }
391
392 return next(e)
393 }).catch(($, e, next) => next(e))
394
395 on('skill.prompt', async ($, e, next) => {
396 const description = typeof e.frontmatter.description === 'string' ? e.frontmatter.description : ''
397 try {
398 await route($, e.skill, description)
399 } catch {
400 // Routing never stops a skill from loading.
401 }
402
403 return next(e)
404 })
405
406 // Main loop only: a subagent keeps the model it was given.
407 on('turn.step', async function* ($, e, next) {
408 const model = e.agentId === undefined ? pending?.model : null
409
410 return yield* next(model ? { ...e, model } : e)
411 })
412
413 // Real work, in the main loop or any subagent, arms the review gate.
414 on('tool.call', async ($, e, next) => {
415 // A subagent's call moves its card. An id no card holds (a compaction or
416 // memory fork) is left alone.
417 const agentId = e.agentId
418 // Not awaited: the pane never holds up a tool call.
419 if (agentId !== undefined) {
420 void quietly(async () => {
421 if (!(await read($, cards)).some(card => card.agentId === agentId)) return
422 const last = toolLine(e as unknown as { tool: string } & Record<string, unknown>)
423 await changeCard($, card => card.agentId === agentId, card => ({ tools: card.tools + 1, lastTool: last }))
424 })
425 }
426 const ran = await next(e)
427 if (isTouch(e) && ran.deny === undefined && ran.isError !== true) lastTouch = ++step
428
429 return ran
430 })
431
432 on('agent.spawn', async ($, e, next) => {
433 let id: string | null = null
434 await quietly(async () => {
435 id = await addCard($, e)
436 })
437 let routed: Awaited<ReturnType<typeof routeAgent>> = null
438 try {
439 routed = await routeAgent($, e)
440 } catch {
441 // Routing never stops an agent from starting.
442 }
443 const card = id as string | null
444 if (card) await quietly(() => changeCard($, byId(card), () => routeFields(routed)))
445 const started = await next(routed?.model ? { ...e, model: routed.model } : e)
446 if (started.deny !== undefined) {
447 const reason = started.deny
448 const ended = await $.clock.now()
449 if (card) await quietly(() => changeCard($, byId(card), () => ({ stage: 'denied', note: reason, endedAt: ended })))
450
451 return started
452 }
453 if (card) {
454 // With no agentId (a workflow's remote agent) no event will ever end
455 // the card, so it is marked done at the hand-off.
456 const agentId = started.agentId
457 const ended = agentId ? undefined : await $.clock.now()
458 await quietly(() =>
459 changeCard($, byId(card), () => ({
460 model: started.model,
461 ...(agentId
462 ? { stage: 'running', agentId }
463 : { stage: 'done', endedAt: ended, note: 'runs remotely, not tracked here' }),
464 })),
465 )
466 }
467
468 // The spawn, not the answer, satisfies the gate. A reviewer run in the
469 // background still holds the turn open through `background_tasks`.
470 if (e.subagentType === REVIEWER) lastReview = ++step
471 if (routed) {
472 const agentId = started.agentId
473 if (agentId) tracked.add(agentId)
474 await writeEntry($, { ...routed.entry, ...(agentId ? { agentId } : {}) })
475 }
476
477 return started
478 })
479
480 // Real work and no review yet: keep the turn going until the reviewer runs.
481 on('classic.Stop', async ($, e, next) => {
482 const done = await next(e)
483 if (done.block) return done
484 const decision = stopDecision({
485 isReviewOn: await isOn($, REVIEW),
486 isStopHookActive: e.stop_hook_active,
487 backgroundTasks: e.background_tasks?.length ?? 0,
488 isTouched: lastTouch > 0,
489 isReviewed: lastReview > lastTouch,
490 })
491 if (decision.note === 'review skipped') {
492 lastTouch = 0
493 await writeEntry($, {
494 at: await $.clock.now(),
495 kind: 'review',
496 skill: REVIEWER,
497 session: await $.session.model(),
498 source: 'kept',
499 pick: null,
500 switched: false,
501 note: decision.note,
502 })
503 }
504
505 return decision.isBlocked ? { ...done, block: REVIEW_ASK } : done
506 })
507
508 on('turn.complete', async ($, e, next) => {
509 const done = await next(e)
510 const agentId = e.agentId
511 let toolCalls: number | undefined
512 if (agentId !== undefined) {
513 const ended = await $.clock.now()
514 await quietly(async () => {
515 toolCalls = (await read($, cards)).find(card => card.agentId === agentId)?.tools
516 await changeCard(
517 $,
518 card => card.agentId === agentId,
519 () => ({
520 stage: e.isAborted || e.reason !== 'answer' ? 'stopped' : 'done',
521 endedAt: ended,
522 ...(e.usage
523 ? {
524 tokensIn:
525 e.usage.input_tokens +
526 (e.usage.cache_read_input_tokens ?? 0) +
527 (e.usage.cache_creation_input_tokens ?? 0),
528 tokensOut: e.usage.output_tokens,
529 }
530 : {}),
531 }),
532 )
533 })
534 }
535 if (agentId !== undefined && tracked.delete(agentId)) {
536 const usage = {
537 ...(e.usage
538 ? {
539 tokensIn:
540 e.usage.input_tokens + (e.usage.cache_read_input_tokens ?? 0) + (e.usage.cache_creation_input_tokens ?? 0),
541 tokensOut: e.usage.output_tokens,
542 }
543 : {}),
544 ...(toolCalls !== undefined ? { toolCalls } : {}),
545 }
546 await finishAgent($, agentId, e.answer, e.durationMs, usage)
547 }
548 if (e.agentId === undefined && pending) {
549 const entry: LogEntry = {
550 ...pending.entry,
551 turnMs: e.durationMs,
552 // Input counts cache reads and writes too: that is where a switch costs.
553 ...(e.usage
554 ? {
555 tokensIn:
556 e.usage.input_tokens + (e.usage.cache_read_input_tokens ?? 0) + (e.usage.cache_creation_input_tokens ?? 0),
557 tokensOut: e.usage.output_tokens,
558 }
559 : {}),
560 }
561 pending = null
562 $.ui.status(undefined)
563 await writeEntry($, entry)
564 }
565
566 return done
567 })
568
569 // The default view: one slim row an agent above the chat box, while there
570 // are any. The engine gives the band its own [-] to collapse it.
571 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
572 if (e.props.hasSurvey || (await read($, view)) !== 'band') return next(e)
573 const list = await read($, cards)
574 if (list.length === 0) return next(e)
575 const table = $.ui.resolve(e)
576 const { Box, Text } = table
577 const heading = paneHeading(list)
578
579 if (e.surface !== 'terminal' && 'Svg' in table) {
580 const { Svg } = table
581 const drawing = bandSvg(pixelsFor(e.props.bodyColumns), list, heading)
582
583 return (
584 <Box>
585 <Svg source={drawing.source} alt={drawing.alt} />
586 </Box>
587 )
588 }
589
590 const width = Math.max(20, e.props.bodyColumns)
591 const { shown, more } = bandCards(list)
592
593 return (
594 <Box flexDirection="column" width={width}>
595 <Text color={MUTED}>{clip(`Agents · ${heading}${more > 0 ? ` · ${more} more in /skill-router pane` : ''}`, width)}</Text>
596 {shown.map(card => (
597 <Text key={card.id} color={colorOf(card.model)}>
598 {bandLine(width, card)}
599 </Text>
600 ))}
601 </Box>
602 )
603 })
604
605 // One card a subagent, in the order they started. Desktop and the other app
606 // surfaces get SVG cards; the terminal gets text rows.
607 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
608 const table = $.ui.resolve(e)
609 const { Box, Text } = table
610 const list = await read($, cards)
611 const heading = paneHeading(list)
612
613 if (e.surface !== 'terminal' && 'Svg' in table) {
614 const { Svg } = table
615 const px = pixelsFor(e.props.bodyColumns)
616
617 return (
618 <Box flexDirection="column">
619 <Text color={MUTED}>{heading}</Text>
620 {list.map(card => {
621 const drawing = cardSvg(px, card)
622
623 return (
624 <Box key={card.id} marginTop={1}>
625 <Svg source={drawing.source} alt={drawing.alt} />
626 </Box>
627 )
628 })}
629 </Box>
630 )
631 }
632
633 const width = Math.max(20, e.props.bodyColumns)
634
635 return (
636 <Box flexDirection="column" width={width}>
637 <Text color={MUTED}>{heading}</Text>
638 {list.map(card => (
639 <Box key={card.id} flexDirection="column" marginTop={1}>
640 <Text color={colorOf(card.model)} bold>
641 {clip(`${bareName(card.type)} · ${modelLabel(card.model)} · ${card.task}`, width)}
642 </Text>
643 <Text color={MUTED}>{clip(routeText(card), width)}</Text>
644 <Text color={colorOf(card.model)}>{barText(width, card)}</Text>
645 <Text color={TEXT}>{clip(statusText(card), width)}</Text>
646 </Box>
647 ))}
648 </Box>
649 )
650 })
651}
652hooks/conductor.ts 23 lines1// The conductor rules, handed to the main session at startup and again after
2// each compaction (classic.SessionStart). Subagents never get them.
3
4import { REVIEWER } from './logic'
5
6export const CONDUCTOR = `skill-router conductor rules (from Ian's skill-router mod):
7
8You are the conductor for this session. Split the goal into tasks and decide where each runs.
9
10Hand a task to a subagent when one of these is true:
11- Two or more pieces of work can run at the same time and do not depend on each other. Launch them in one message.
12- A search covers many files or repos and only the conclusion matters. Use Explore.
13- The task needs a design before code. Use Plan.
14- A draft in Ian's voice is finished. Use voice-check.
15
16Do the work yourself when it is a question, a lookup, an edit to one or two files, or anything a few tool calls settle. A subagent rebuilds its context from scratch, so small jobs cost more and run slower when handed off.
17
18Never let two agents edit the same files. Brief each agent with the goal, the files, what done looks like and the shape of the answer you want back.
19
20Do not set model on Agent calls. skill-router picks the size.
21
22When a turn changes files or produces a deliverable, run ${REVIEWER} before you finish. Brief it with Ian's request and what changed. Fix what it marks FIX, or tell Ian why you did not. If the deliverable came from a Bash command, run the review anyway: the Stop hook cannot see Bash writes.`
23hooks/logic.ts 447 lines1// Pure parts of skill-router: the pin list, the Jev request and answer, the
2// skill and agent decisions, the review gate, and the log. No `$` here, so the
3// tests run it directly.
4
5import type { AgentCard } from '../types'
6
7// ---------------------------------------------------------------- settings
8
9// Simple fixed-step skills, signed off by Ian 2026-10-21. Bare names: a synced
10// skill may arrive as `anthropic-skills:docket` or `docket`.
11export const PINS = ['docket', 'schedule', 'loop', 'keybindings-help', 'consolidate-memory'] as const
12
13export const TIERS = ['haiku', 'sonnet', 'opus'] as const
14export type Tier = (typeof TIERS)[number]
15
16export const MODEL_IDS: Record<Tier, string> = {
17 haiku: 'claude-haiku-5-5',
18 sonnet: 'claude-sonnet-5-5',
19 opus: 'claude-opus-5-5',
20}
21
22export const JEV_URL = 'https://openrouter.ai/api/alpha/decisions'
23export const JEV_MODEL = '~typesafe/jev-latest'
24
25// Jev's own `confidence` ran 0.38 on a clear call; the picked option's
26// probability is the steadier gate.
27export const MIN_PROBABILITY = 0.5
28export const LOG_LIMIT = 1000
29
30const CRITERIA: Record<Tier, string> = {
31 haiku: 'Fixed steps, lookups, formatting, filing, simple tool calls. No judgment, no published writing.',
32 sonnet: 'Normal writing and code edits touching a few files; moderate judgment.',
33 opus: "Multi-file design, hard judgment calls, or copy in Ian's voice that will be published.",
34}
35
36const AGENT_CRITERIA: Record<Tier, string> = {
37 haiku: 'Searching and reading files, lookups, listing, formatting, filing. No edits that need judgment.',
38 sonnet: 'Code or doc edits in a few files with a clear brief; moderate judgment.',
39 opus: "Design across many files, hard judgment calls, debugging with no clear cause, or copy in Ian's voice that will be published.",
40}
41
42// The Fable reviewer, as a plugin agent file names it.
43export const REVIEWER = 'skill-router:final-review'
44export const REVIEWER_MODEL = 'fable'
45
46// Built-ins that make their own model choice: never routed.
47export const AGENT_SKIPS = ['claude-code-guide', 'statusline-setup'] as const
48
49// Tools whose success means the turn did real work. Bash is left out: a read
50// and a write look the same from here.
51export const TOUCH_TOOLS = ['Edit', 'Write', 'NotebookEdit', 'Artifact'] as const
52
53// ---------------------------------------------------------------- names
54
55export const bareName = (skill: string): string => skill.slice(skill.lastIndexOf(':') + 1)
56
57export const isPinned = (skill: string): boolean => (PINS as readonly string[]).includes(bareName(skill))
58
59// The tier a model ID belongs to, or null for one not ranked here (fable).
60export const tierOf = (model: string): Tier | null => TIERS.find(tier => model.includes(tier)) ?? null
61
62// Down only: Jev chooses among the session's tier and those below it.
63export const tiersUpTo = (session: Tier): Tier[] => TIERS.slice(0, TIERS.indexOf(session) + 1)
64
65export const clip = (text: string, max: number): string => (text.length > max ? `${text.slice(0, max)}…` : text)
66
67// ---------------------------------------------------------------- Jev
68
69export const jevRequest = (skill: string, description: string, request: string, options: readonly Tier[]) => ({
70 model: JEV_MODEL,
71 state: {
72 skill: bareName(skill),
73 skill_description: clip(description, 600),
74 request: clip(request, 1500),
75 },
76 questions: {
77 model: {
78 type: 'choice',
79 instructions: 'Which is the smallest Claude model that will do this skill run well?',
80 criteria: Object.fromEntries(options.map(tier => [tier, CRITERIA[tier]])),
81 },
82 },
83})
84
85export type JevAnswer = { choice: Tier; probability: number; cost: number }
86
87export const parseJev = (text: string, options: readonly Tier[]): JevAnswer | null => {
88 try {
89 const data = JSON.parse(text)
90 const answer = data?.answers?.model
91 if (answer?.type !== 'choice' || !options.includes(answer.choice)) return null
92 const choice = answer.choice as Tier
93
94 return {
95 choice,
96 probability: Number(answer.probabilities?.[choice] ?? 0),
97 cost: Number(data.usage?.cost ?? 0),
98 }
99 } catch {
100 return null
101 }
102}
103
104// ---------------------------------------------------------------- decision
105
106// What to ask Jev, or null when the pin list or the session model settles it.
107export const jevOptions = (skill: string, session: string): Tier[] | null => {
108 const tier = tierOf(session)
109 if (isPinned(skill) || tier === null || tier === 'haiku') return null
110
111 return tiersUpTo(tier)
112}
113
114export type Source = 'pin' | 'jev' | 'kept' | 'explicit'
115
116export type Decision = {
117 source: Source
118 pick: Tier | null
119 // The model to switch to for the rest of the turn; null keeps the session's.
120 model: string | null
121 note?: string
122}
123
124// `jev` is undefined when Jev was not asked, null when the call failed.
125export const decide = (skill: string, session: string, jev?: JevAnswer | null): Decision => {
126 const tier = tierOf(session)
127
128 if (isPinned(skill)) {
129 return { source: 'pin', pick: 'haiku', model: tier === 'haiku' ? null : MODEL_IDS.haiku }
130 }
131 if (tier === null) return { source: 'kept', pick: null, model: null, note: 'session model not ranked' }
132 if (tier === 'haiku') return { source: 'kept', pick: 'haiku', model: null, note: 'already smallest' }
133 if (jev === undefined || jev === null) return { source: 'kept', pick: null, model: null, note: 'Jev unavailable' }
134 if (jev.probability < MIN_PROBABILITY) {
135 return { source: 'kept', pick: jev.choice, model: null, note: `Jev unsure (${jev.probability.toFixed(2)})` }
136 }
137
138 return { source: 'jev', pick: jev.choice, model: jev.choice === tier ? null : MODEL_IDS[jev.choice] }
139}
140
141// ---------------------------------------------------------------- agents
142
143// For agents Fable ranks above Opus, so a Fable session still routes its
144// subagents. Jev is never offered Fable.
145export type AgentTier = Tier | 'fable'
146const AGENT_RANK: readonly AgentTier[] = [...TIERS, 'fable']
147
148export const agentTierOf = (model: string): AgentTier | null =>
149 model.includes('fable') ? 'fable' : tierOf(model)
150
151export const agentJevRequest = (agentType: string, description: string, brief: string, options: readonly Tier[]) => ({
152 model: JEV_MODEL,
153 state: {
154 agent_type: bareName(agentType),
155 task: clip(description, 200),
156 brief: clip(brief, 1500),
157 },
158 questions: {
159 model: {
160 type: 'choice',
161 instructions: "Which is the smallest Claude model that will do this subagent's task well?",
162 criteria: Object.fromEntries(options.map(tier => [tier, AGENT_CRITERIA[tier]])),
163 },
164 },
165})
166
167// The facts of a spawn the decision reads, as `agent.spawn` carries them.
168export type Spawn = {
169 subagentType: string
170 model?: string
171 fork: boolean
172 resume?: unknown
173 workflow?: unknown
174 isTeammate?: true
175}
176
177export type AgentDecision = {
178 source: Source
179 pick: AgentTier | null
180 // The model to hand the spawn; null leaves the call as it came.
181 model: string | null
182 note?: string
183}
184
185// What to ask Jev for this spawn, or null when nothing needs asking.
186export const agentJevOptions = (spawn: Spawn, parent: string): Tier[] | null => {
187 if (agentSkip(spawn) || spawn.subagentType === REVIEWER || spawn.model) return null
188 const tier = agentTierOf(parent)
189 if (tier === null || tier === 'haiku') return null
190
191 return tier === 'fable' ? [...TIERS] : tiersUpTo(tier)
192}
193
194const agentSkip = (spawn: Spawn): string | null => {
195 if (spawn.fork) return 'fork inherits'
196 if (spawn.resume) return 'resumed run'
197 if (spawn.workflow) return 'workflow agent'
198 if (spawn.isTeammate) return 'teammate'
199 if ((AGENT_SKIPS as readonly string[]).includes(spawn.subagentType)) return 'picks its own model'
200
201 return null
202}
203
204// `jev` is undefined when Jev was not asked, null when the call failed.
205export const decideAgent = (spawn: Spawn, parent: string, jev?: JevAnswer | null): AgentDecision => {
206 const skip = agentSkip(spawn)
207 if (skip) return { source: 'kept', pick: null, model: null, note: skip }
208 if (spawn.subagentType === REVIEWER) return { source: 'pin', pick: 'fable', model: REVIEWER_MODEL }
209
210 const tier = agentTierOf(parent)
211 if (spawn.model) {
212 // Down only, even when the conductor named a model itself.
213 const asked = agentTierOf(spawn.model)
214 if (asked && tier && AGENT_RANK.indexOf(asked) > AGENT_RANK.indexOf(tier)) {
215 return { source: 'explicit', pick: tier, model: parent, note: `asked for ${asked}, held to ${tier}` }
216 }
217
218 return { source: 'explicit', pick: asked, model: null }
219 }
220 if (tier === null) return { source: 'kept', pick: null, model: null, note: 'session model not ranked' }
221 if (tier === 'haiku') return { source: 'kept', pick: 'haiku', model: null, note: 'already smallest' }
222 if (jev === undefined || jev === null) return { source: 'kept', pick: null, model: null, note: 'Jev unavailable' }
223 if (jev.probability < MIN_PROBABILITY) {
224 return { source: 'kept', pick: jev.choice, model: null, note: `Jev unsure (${jev.probability.toFixed(2)})` }
225 }
226
227 // Set even when it matches the session: left unset, the agent's own
228 // definition could pick something else.
229 return { source: 'jev', pick: jev.choice, model: MODEL_IDS[jev.choice] }
230}
231
232// ---------------------------------------------------------------- review gate
233
234export type StopFacts = {
235 isReviewOn: boolean
236 // The model is already going on because a Stop hook asked: never ask twice.
237 isStopHookActive: boolean
238 backgroundTasks: number
239 isTouched: boolean
240 isReviewed: boolean
241}
242
243export type StopDecision = { isBlocked: boolean; note?: string }
244
245export const stopDecision = (f: StopFacts): StopDecision => {
246 if (!f.isReviewOn || !f.isTouched || f.isReviewed) return { isBlocked: false }
247 if (f.isStopHookActive) return { isBlocked: false, note: 'review skipped' }
248 if (f.backgroundTasks > 0) return { isBlocked: false, note: 'waiting on background work' }
249
250 return { isBlocked: true }
251}
252
253export const REVIEW_ASK = `Run ${REVIEWER} on this turn's work before you finish. Brief it with Ian's request and the files that changed. Fix what it marks FIX, or tell Ian why you did not.`
254
255// PASS or FIX: the first word of the reviewer's answer, so "PASS (no fixes
256// needed)" still reads as PASS.
257export const verdictOf = (answer: string): 'PASS' | 'FIX' | undefined => {
258 const word = /^\W*(PASS|FIX)\b/i.exec(answer.trim())?.[1]?.toUpperCase()
259
260 return word === 'PASS' || word === 'FIX' ? word : undefined
261}
262
263// ---------------------------------------------------------------- log
264
265// `kind` is absent on entries from 0.1, which were all skills. For an agent or
266// a review, `skill` holds the agent type.
267export type Kind = 'skill' | 'agent' | 'review'
268
269export type LogEntry = {
270 at: number
271 kind?: Kind
272 skill: string
273 session: string
274 source: Source
275 pick: AgentTier | null
276 switched: boolean
277 agentId?: string
278 task?: string
279 verdict?: 'PASS' | 'FIX'
280 probability?: number
281 jevMs?: number
282 jevCost?: number
283 note?: string
284 turnMs?: number
285 tokensIn?: number
286 tokensOut?: number
287 // An agent's tool calls, for the Agents pane's progress estimate (0.3 on).
288 toolCalls?: number
289}
290
291export const appendLog = (list: readonly LogEntry[], entry: LogEntry): LogEntry[] => [...list, entry].slice(-LOG_LIMIT)
292
293export const kindOf = (e: LogEntry): Kind => e.kind ?? 'skill'
294
295const shortModel = (model: string): string => agentTierOf(model) ?? model
296
297const stamp = (at: number): string => {
298 const d = new Date(at)
299 const pad = (n: number) => String(n).padStart(2, '0')
300
301 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
302}
303
304export const logLine = (e: LogEntry): string => {
305 const route = e.switched ? `${shortModel(e.session)} → ${e.pick}` : `stayed ${shortModel(e.session)}`
306 const kind = kindOf(e)
307 const parts = [stamp(e.at), `${kind === 'skill' ? '' : `${kind} `}${bareName(e.skill)}`, e.source, route]
308 if (e.verdict) parts.push(e.verdict)
309 if (e.probability !== undefined) parts.push(`p ${e.probability.toFixed(2)}`)
310 if (e.jevMs !== undefined) parts.push(`Jev ${e.jevMs} ms`)
311 if (e.jevCost !== undefined) parts.push(`$${e.jevCost.toFixed(6)}`)
312 if (e.turnMs !== undefined) parts.push(`turn ${(e.turnMs / 1000).toFixed(1)} s`)
313 if (e.tokensIn !== undefined) parts.push(`${e.tokensIn} in / ${e.tokensOut ?? 0} out`)
314 if (e.note) parts.push(e.note)
315
316 return parts.join(' · ')
317}
318
319export const summary = (list: readonly LogEntry[]): string => {
320 const skills = list.filter(e => kindOf(e) === 'skill')
321 const agents = list.filter(e => kindOf(e) === 'agent')
322 const reviews = list.filter(e => kindOf(e) === 'review' && e.source === 'pin')
323 const count = (from: readonly LogEntry[], source: Source) => from.filter(e => e.source === source).length
324 const cost = list.reduce((sum, e) => sum + (e.jevCost ?? 0), 0)
325 const switched = skills.filter(e => e.switched).length
326 const verdicts = (v: string) => reviews.filter(e => e.verdict === v).length
327 const reviewIn = reviews.reduce((sum, e) => sum + (e.tokensIn ?? 0), 0)
328 const skipped = list.filter(e => kindOf(e) === 'review' && e.note === 'review skipped').length
329
330 return [
331 `${skills.length} skill picks: ${count(skills, 'pin')} pinned, ${count(skills, 'jev')} by Jev, ${count(skills, 'kept')} kept. ${switched} switched model.`,
332 `${agents.length} agents: ${count(agents, 'jev')} by Jev, ${count(agents, 'explicit')} named by the session, ${count(agents, 'kept')} kept.`,
333 `${reviews.length} Fable reviews: ${verdicts('PASS')} PASS, ${verdicts('FIX')} FIX, ${reviewIn} tokens in. ${skipped} skipped.`,
334 `Jev cost $${cost.toFixed(6)}.`,
335 ].join('\n')
336}
337
338export const parseCount = (args: string, fallback: number): number => {
339 const n = Number.parseInt(args, 10)
340
341 return Number.isFinite(n) && n > 0 ? Math.min(n, LOG_LIMIT) : fallback
342}
343
344// ---------------------------------------------------------------- agents pane
345
346export const MODEL_COLOR: Record<AgentTier, string> = {
347 haiku: '#3DD68C',
348 sonnet: '#58A6FF',
349 opus: '#BC8CFF',
350 fable: '#F2C46D',
351}
352export const UNKNOWN_COLOR = '#7D8590'
353
354export const colorOf = (model: string | null): string => {
355 const tier = model ? agentTierOf(model) : null
356
357 return tier ? MODEL_COLOR[tier] : UNKNOWN_COLOR
358}
359
360export const modelLabel = (model: string | null): string => (model ? (agentTierOf(model) ?? model) : '…')
361
362// Tool calls a run usually takes, until the log has runs of that type.
363const FIRST_GUESS: Record<string, number> = { Explore: 15, Plan: 15, 'general-purpose': 20, [REVIEWER]: 12 }
364const GUESS = 15
365const HISTORY = 20
366
367// The average tool calls of the last runs of this agent type, or a first
368// guess while fewer than three are logged.
369export const expectedTools = (list: readonly LogEntry[], agentType: string): number => {
370 const runs = list.filter(e => e.skill === agentType && e.toolCalls !== undefined).slice(-HISTORY)
371 if (runs.length < 3) return FIRST_GUESS[agentType] ?? GUESS
372 const mean = runs.reduce((sum, e) => sum + (e.toolCalls ?? 0), 0) / runs.length
373
374 return Math.max(3, Math.round(mean))
375}
376
377// How full the bar is. An agent reports no percent done, so a running bar
378// fills by tool calls against the usual count and holds at 90% until it ends.
379export const progressOf = (card: AgentCard): number => {
380 if (card.stage === 'done') return 1
381 if (card.stage === 'routing') return 0.03
382 if (card.stage === 'denied') return 0
383
384 return Math.min(0.9, Math.max(0.05, card.tools / Math.max(1, card.expected)))
385}
386
387// The last tool, as a short line: the tool and what it was pointed at.
388export const toolLine = (e: { tool: string } & Record<string, unknown>): string => {
389 const target = ['pattern', 'file_path', 'path', 'command', 'url', 'query', 'description', 'skill']
390 .map(key => e[key])
391 .find(value => typeof value === 'string' && value.trim() !== '') as string | undefined
392 const short = target ? (target.includes('/') && !target.includes(' ') ? target.slice(target.lastIndexOf('/') + 1) : target) : ''
393
394 return clip(short ? `${e.tool} ${short.replace(/\s+/g, ' ')}` : e.tool, 60)
395}
396
397// How the model was chosen, in one line.
398export const routeText = (card: AgentCard): string => {
399 if (card.stage === 'routing') return 'Asking Jev which model to use…'
400 const model = modelLabel(card.model)
401 if (card.source === 'jev') {
402 const offered = card.offered?.length ? ` from ${card.offered.join(' / ')}` : ''
403 const sure = card.probability !== undefined ? ` · p ${card.probability.toFixed(2)}` : ''
404 const ms = card.jevMs !== undefined ? ` · ${card.jevMs} ms` : ''
405
406 return `Jev picked ${card.pick ?? model}${offered}${sure}${ms}`
407 }
408 if (card.source === 'pin') return `Pinned to ${model}`
409 if (card.source === 'explicit') return card.note ? `Named by Claude, ${card.note}` : `Named by Claude: ${model}`
410 if (card.source === 'kept') {
411 const why = card.note ?? 'session model'
412 const jevSaid = card.pick && card.note?.startsWith('Jev unsure') ? ` (leaned ${card.pick})` : ''
413
414 return `Kept ${model}: ${why}${jevSaid}`
415 }
416
417 return `Agent routing off · runs on ${model}`
418}
419
420const seconds = (ms: number): string => (ms < 60_000 ? `${Math.round(ms / 1000)} s` : `${Math.floor(ms / 60_000)} m ${Math.round((ms % 60_000) / 1000)} s`)
421
422const thousands = (n: number): string => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
423
424const calls = (n: number): string => `${n} tool call${n === 1 ? '' : 's'}`
425
426// What the agent is doing, or how it ended.
427export const statusText = (card: AgentCard): string => {
428 if (card.stage === 'routing') return 'Starting'
429 if (card.stage === 'denied') return `Refused: ${card.note ?? 'spawn denied'}`
430 if (card.stage === 'running') return card.lastTool ? `${calls(card.tools)} · ${card.lastTool}` : 'Started'
431 const parts = [card.stage === 'done' ? 'Done' : 'Stopped']
432 if (card.endedAt !== undefined) parts.push(seconds(card.endedAt - card.startedAt))
433 parts.push(calls(card.tools))
434 if (card.tokensIn !== undefined) parts.push(`${thousands(card.tokensIn)} in / ${thousands(card.tokensOut ?? 0)} out`)
435
436 return parts.join(' · ')
437}
438
439export const isLive = (card: AgentCard): boolean => card.stage === 'routing' || card.stage === 'running'
440
441export const paneHeading = (cards: readonly AgentCard[]): string => {
442 const live = cards.filter(isLive).length
443 const ended = cards.length - live
444
445 return [live ? `${live} running` : '', ended ? `${ended} finished` : ''].filter(Boolean).join(' · ') || 'No agents yet'
446}
447hooks/pane.ts 169 lines1// The Agents pane's drawing. The desktop app sets text in a proportional font,
2// so a card there is one SVG sized in pixels, as work-report draws its charts.
3// The terminal gets rows of text with a block bar.
4
5import type { AgentCard } from '../types'
6import { bareName, clip, colorOf, isLive, modelLabel, progressOf, routeText, statusText } from './logic'
7
8const CARD = '#161B22'
9const LINE = '#262B33'
10const TRACK = '#21262D'
11const MUTED = '#7D8590'
12const TEXT = '#E6EDF3'
13const RED = '#F85149'
14
15const SANS = `-apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', sans-serif`
16
17export type Drawing = { source: string; alt: string }
18
19const esc = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
20
21// The desktop reports fewer columns than it shows; a column is about 13 pixels
22// there. An SVG drawn wider than the pane is scaled down to fit, never cut.
23export const pixelsFor = (columns: number): number => Math.max(280, Math.round(columns * 13))
24
25// About how many characters fit in a run of pixels at a font size.
26const fits = (pixels: number, size: number, isBold = false) => Math.max(4, Math.floor(pixels / (size * (isBold ? 0.62 : 0.55))))
27
28const label = (x: number, y: number, text: string, size: number, fill: string, extra = '') =>
29 `<text x="${x}" y="${y}" font-size="${size}" fill="${fill}"${extra}>${esc(text)}</text>`
30
31const HEIGHT = 112
32
33// One card: a coloured edge, the type and task, a model chip, how the model
34// was chosen, the progress bar, and what it is doing or how it ended.
35export const cardSvg = (width: number, card: AgentCard): Drawing => {
36 const color = card.stage === 'denied' ? RED : colorOf(card.model)
37 const chip = modelLabel(card.model).toUpperCase()
38 const chipWidth = Math.round(chip.length * 7.4 + 18)
39 const left = 18
40 const inner = width - left - 14
41 const type = bareName(card.type)
42 const typeWidth = Math.min(inner * 0.45, type.length * 13 * 0.62 + 4)
43 const taskRoom = inner - typeWidth - chipWidth - 16
44 const barWidth = inner
45 const fill = Math.round(barWidth * progressOf(card))
46 const isRunning = isLive(card)
47 const isDim = !isRunning
48
49 // A running bar breathes, so a quiet agent still looks alive.
50 const pulse = isRunning
51 ? `<animate attributeName="opacity" values="1;0.55;1" dur="1.6s" repeatCount="indefinite"/>`
52 : ''
53
54 const body =
55 `<rect x="0.5" y="0.5" width="${width - 1}" height="${HEIGHT - 1}" rx="10" fill="${CARD}" stroke="${LINE}"/>` +
56 `<rect x="0" y="0" width="5" height="${HEIGHT}" rx="2.5" fill="${color}"/>` +
57 label(left, 26, clip(type, fits(typeWidth, 13, true)), 13, TEXT, ` font-weight="700"`) +
58 label(left + typeWidth + 8, 26, clip(card.task, fits(taskRoom, 13)), 13, isDim ? MUTED : TEXT) +
59 `<rect x="${width - 14 - chipWidth}" y="11" width="${chipWidth}" height="21" rx="10.5" fill="${color}" fill-opacity="0.18" stroke="${color}" stroke-opacity="0.6"/>` +
60 label(width - 14 - chipWidth / 2, 26, chip, 11, color, ` text-anchor="middle" font-weight="700" letter-spacing="0.06em"`) +
61 label(left, 50, clip(routeText(card), fits(inner, 12)), 12, MUTED) +
62 `<rect x="${left}" y="62" width="${barWidth}" height="8" rx="4" fill="${TRACK}"/>` +
63 (fill > 0 ? `<rect x="${left}" y="62" width="${fill}" height="8" rx="4" fill="${color}">${pulse}</rect>` : '') +
64 label(left, 94, clip(statusText(card), fits(inner, 12)), 12, card.stage === 'denied' || card.stage === 'stopped' ? RED : TEXT)
65
66 return {
67 source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${HEIGHT}" viewBox="0 0 ${width} ${HEIGHT}" font-family="${esc(SANS)}">${body}</svg>`,
68 alt: `${type} on ${modelLabel(card.model)}: ${card.task}. ${routeText(card)}. ${statusText(card)}.`,
69 }
70}
71
72// The terminal's bar: blocks for the share done.
73export const barText = (columns: number, card: AgentCard): string => {
74 const width = Math.max(6, columns)
75 const full = Math.round(width * progressOf(card))
76
77 return '█'.repeat(full) + '░'.repeat(width - full)
78}
79
80// ---------------------------------------------------------------- band
81
82// The band above the prompt shows this many rows; the rest are counted.
83export const BAND_ROWS = 6
84
85// The rows the band shows: every live agent first, then the latest finished
86// ones, each group in the order they started.
87export const bandCards = (cards: readonly AgentCard[]): { shown: AgentCard[]; more: number } => {
88 const live = cards.filter(isLive)
89 const ended = cards.filter(card => !isLive(card))
90 const room = Math.max(0, BAND_ROWS - live.length)
91 const shown = [...live.slice(0, BAND_ROWS), ...(room > 0 ? ended.slice(-room) : [])]
92
93 return { shown, more: cards.length - shown.length }
94}
95
96// A band row's status, shorter than a card's.
97export const bandStatus = (card: AgentCard): string => {
98 const calls = `${card.tools} call${card.tools === 1 ? '' : 's'}`
99 if (card.stage === 'routing') return 'Asking Jev…'
100 if (card.stage === 'denied') return 'Refused'
101 if (card.stage === 'running') return card.lastTool ? `${calls} · ${card.lastTool}` : 'Started'
102 const time = card.endedAt !== undefined ? ` · ${Math.round((card.endedAt - card.startedAt) / 1000)} s` : ''
103
104 return `${card.stage === 'done' ? 'Done' : 'Stopped'}${time} · ${calls}`
105}
106
107const ROW = 28
108const BAND_HEAD = 22
109
110// The whole band as one SVG: a heading, then one row an agent with a dot and
111// chip in the model colour, the type and task, a thin bar and the status.
112export const bandSvg = (width: number, cards: readonly AgentCard[], heading: string): Drawing => {
113 const { shown, more } = bandCards(cards)
114 const isWide = width >= 560
115 const barWidth = Math.round(Math.min(120, width * 0.16))
116 const chipWidth = 64
117 // On a narrow band the status gives way first, so the type keeps about 60
118 // pixels left of the chip and nothing overlaps.
119 const fixed = 20 + 60 + 6 + chipWidth + 12 + barWidth + 12
120 const statusWidth = Math.max(40, Math.min(280, Math.round(width * (isWide ? 0.3 : 0.38)), width - fixed))
121 const chipX = width - statusWidth - 12 - barWidth - 12 - chipWidth
122 const typeWidth = Math.max(24, Math.min(130, Math.round(width * 0.2), chipX - 26))
123 const taskX = 20 + typeWidth + 6
124 const taskRoom = chipX - 10 - taskX
125 const height = BAND_HEAD + shown.length * ROW + 4
126
127 const rows = shown
128 .map((card, i) => {
129 const y = BAND_HEAD + i * ROW
130 const mid = y + ROW / 2
131 const color = card.stage === 'denied' ? RED : colorOf(card.model)
132 const fill = Math.round(barWidth * progressOf(card))
133 const barX = chipX + chipWidth + 12
134 const isEnded = !isLive(card)
135 const pulse = isEnded ? '' : `<animate attributeName="opacity" values="1;0.55;1" dur="1.6s" repeatCount="indefinite"/>`
136 const statusColor = card.stage === 'denied' || card.stage === 'stopped' ? RED : isEnded ? MUTED : TEXT
137
138 return (
139 `<circle cx="8" cy="${mid}" r="4" fill="${color}"${isEnded ? ' fill-opacity="0.5"' : ''}/>` +
140 label(20, mid + 4.5, clip(bareName(card.type), fits(typeWidth, 13, true)), 13, isEnded ? MUTED : TEXT, ` font-weight="700"`) +
141 (taskRoom > 40 ? label(taskX, mid + 4.5, clip(card.task, fits(taskRoom, 13)), 13, MUTED) : '') +
142 `<rect x="${chipX}" y="${mid - 9}" width="${chipWidth}" height="18" rx="9" fill="${color}" fill-opacity="0.18" stroke="${color}" stroke-opacity="0.6"/>` +
143 label(chipX + chipWidth / 2, mid + 4, modelLabel(card.model).toUpperCase(), 10, color, ` text-anchor="middle" font-weight="700" letter-spacing="0.06em"`) +
144 `<rect x="${barX}" y="${mid - 3}" width="${barWidth}" height="6" rx="3" fill="${TRACK}"/>` +
145 (fill > 0 ? `<rect x="${barX}" y="${mid - 3}" width="${fill}" height="6" rx="3" fill="${color}">${pulse}</rect>` : '') +
146 label(width, mid + 4.5, clip(bandStatus(card), fits(statusWidth, 12)), 12, statusColor, ` text-anchor="end"`)
147 )
148 })
149 .join('')
150
151 const head =
152 label(0, 14, 'AGENTS', 11, MUTED, ` font-weight="600" letter-spacing="0.08em"`) +
153 label(62, 14, more > 0 ? `${heading} · ${more} more in /skill-router pane` : heading, 11, MUTED)
154
155 return {
156 source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="${esc(SANS)}">${head}${rows}</svg>`,
157 alt: [`Agents: ${heading}.`, ...shown.map(card => `${bareName(card.type)} on ${modelLabel(card.model)}, ${bandStatus(card)}.`)].join(' '),
158 }
159}
160
161// A terminal band row: dot, type, model, a short bar and the status.
162export const bandLine = (columns: number, card: AgentCard): string => {
163 const bar = barText(10, card)
164
165 return clip(`● ${bareName(card.type)} · ${modelLabel(card.model)} ${bar} ${bandStatus(card)}`, Math.max(20, columns))
166}
167
168export { MUTED, TEXT }
169types/index.d.ts 45 lines1// Where a subagent's card stands: Jev is being asked, the agent runs, it
2// finished, it stopped early, or the spawn was refused.
3export type CardStage = 'routing' | 'running' | 'done' | 'stopped' | 'denied'
4
5// One subagent on the Agents pane.
6export type AgentCard = {
7 // This mod's own id for the card. A workflow's agents share one tool_use_id,
8 // so that can't be the key.
9 id: string
10 agentId?: string
11 type: string
12 task: string
13 stage: CardStage
14 // The model it runs on, as the spawn resolved it; null while routing.
15 model: string | null
16 // How the model was chosen; absent when agent routing is off.
17 source?: 'pin' | 'jev' | 'kept' | 'explicit'
18 // The tiers Jev was offered, its pick and how sure it was.
19 offered?: string[]
20 pick?: string | null
21 probability?: number
22 jevMs?: number
23 note?: string
24 tools: number
25 lastTool?: string
26 // Tool calls a run of this agent type usually takes, from the log.
27 expected: number
28 startedAt: number
29 endedAt?: number
30 tokensIn?: number
31 tokensOut?: number
32}
33
34// Where agents show unasked: the band above the chat box, the pane, or nowhere.
35export type AgentsView = 'band' | 'pane' | 'off'
36
37declare module 'claude-code' {
38 interface PluginState {
39 'skill-router': {
40 agents: AgentCard[]
41 view: AgentsView
42 }
43 }
44}
45