SLOPSHOPPER

skill-router

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…

newpanebandguardcommandtoast
v0.3.1no licenseupdated 2026-10-22iansutherland75/claude-mods/mods/skill-router
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skill-router
│ ┃ agents ✕ › fix the failing auth test and add an audit log call │ ┃ No agents yet │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /skill-router │ ⎿ skill-router: Skill routing on, agent routing on, Fable review o │ ⎿ skill-router: 0 skill picks: 0 pinned, 0 by Jev, 0 kept. 0 switc │ ⎿ skill-router: 0 agents: 0 by Jev, 0 named by the session, 0 kept │ ⎿ skill-router: 0 Fable reviews: 0 PASS, 0 FIX, 0 tokens in. 0 ski │ ⎿ skill-router: Jev cost $0.000000. │ ⎿ skill-router: Use: /skill-router on | off | agents on|off | revi │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · agents
No agents yet
README

claude-mods

Ian's Claude Code mods. The repo is a plugin marketplace called ian-mods, and each mod lives in its own folder under mods/.

ModWhat 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-routerRuns 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).

Set up a machine

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.

Keep machines in step

The marketplace is a local folder, so sessions read each mod straight from ~/claude-mods. Nothing needs reinstalling after a change.

  1. git pull in ~/claude-mods.
  2. Run /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.

Add a mod

  1. Put it in mods/<mod-name>/ with its .claude-plugin/plugin.json, hooks/ and tests.
  2. Add an entry to .claude-plugin/marketplace.json with "source": "./mods/<mod-name>".
  3. Check it: claude plugin validate mods/<mod-name> and claude plugin test mods/<mod-name>.
  4. Commit, push, then install it on each machine.

Not covered

Cloud sessions (claude.ai/code, cloud Cowork) do not see mods installed on a machine.

Source 5 files
hooks/register.tsx 652 lines
1import { 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}
652
hooks/conductor.ts 23 lines
1// 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.`
23
hooks/logic.ts 447 lines
1// 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}
447
hooks/pane.ts 169 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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 }
169
types/index.d.ts 45 lines
1// 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