SLOPSHOPPER

switchboard

Picks the model for each subagent that names none, with OpenAI's Decisions API or Jev, from its short label only. One line per spawn above the prompt; /route…

newpanebandcommandnetworktimer
★ 167v0.3.1MITupdated 2026-10-07hamzafer/claude-code-mods/mods/switchboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · switchboard
│ ┃ Switchboard ✕ › fix the failing auth test and add an audit log call │ ┃ 0 subagents · 0 switched · mode: auto · │ ┃ picker: off ⏺ Read(src/auth.ts) │ ┃ spent $0 · at the asked models $0 · picker ⎿ Read 6 lines │ ┃ $0 ⏺ Update(src/auth.ts) │ ┃ API price estimates as of 2026-09-25, not ⎿ Added 2 lines, removed 1 line │ ┃ plan charges. ? = not known yet. ⏺ Bash(bun test) │ ┃ ──────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ ────────── │ ┃ No subagents yet this session. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ Clear finished ] [ Close ] │ ✻ Worked for 42s · done 4:20 PM │ │ › /route │ ⎿ switchboard: 0 subagents, 0 switched · $0 spent, $0 at the asked │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Switchboard
0 subagents · 0 switched · mode: auto · picker: off spent $0 · at the asked models $0 · picker $0 API price estimates as of 2026-09-25, not plan charges. ? = not known yet. ────────────────────────────────────────────────────── No subagents yet this session. [ Clear finished ] [ Close ]
README

<h1 align="center">Claude Code mods</h1>

<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a> <a href="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml"><img src="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>

<a href="#-install">Install</a> · <a href="#-the-mods">All mods</a> · <a href="docs/mods.md">Docs</a> · <a href="https://claude.dev/blog/getting-started-with-claude-code-mods/">What are mods?</a>

<table> <tr> <td align="center" width="33%"><a href="docs/mods.md#-context-bar"><img src="images/context-bar.png" alt="context-bar: Claude Code context window usage as a stacked bar, a color per category" width="260"></a><br>📊 <b>context-bar</b><br>what fills your context</td> <td align="center" width="33%"><a href="docs/mods.md#-review-watch"><img src="images/review-watch.png" alt="review-watch: live lines for running Codex and subagent code reviews in Claude Code" width="260"></a><br>🔍 <b>review-watch</b><br>running code reviews, live</td> <td align="center" width="33%"><a href="docs/mods.md#-md-preview"><img src="images/md-preview.png" alt="md-preview: Markdown that Claude Code edits, rendered like GitHub next to the diff" width="260"></a><br>📝 <b>md-preview</b><br>Markdown rendered like GitHub</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-blast-radius"><img src="images/gallery/blast-radius.png" alt="blast-radius: a Claude Code hook holds rm -rf and lists the files it would delete" width="260"></a><br>💥 <b>blast-radius</b><br>see what <code>rm -rf</code> would delete</td> <td align="center" width="33%"><a href="docs/mods.md#-now-playing"><img src="images/now-playing.png" alt="now-playing: Spotify track, progress bar and synced lyrics inside Claude Code" width="260"></a><br>🎵 <b>now-playing</b><br>Spotify and its lyrics, live</td> <td align="center" width="33%"><a href="docs/mods.md#-reels-and-snake"><img src="images/reels-demo.gif" alt="reels: YouTube Shorts in a Claude Code pane while it works" width="260"></a><br>📱 <b>reels</b><br>Shorts while Claude works</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-where-am-i"><img src="images/gallery/where-am-i.png" alt="where-am-i: the session goal, current step and what waits on you, above the Claude Code prompt" width="260"></a><br>📍 <b>where-am-i</b><br>goal, now, waiting on you</td> <td align="center" width="33%"><a href="docs/mods.md#-lines-above-the-prompt"><img src="images/gallery/lines.png" alt="token-weather, usage-meter and other Claude Code status lines stacked above the prompt" width="260"></a><br>🌦️ <b>token-weather and friends</b><br>lines above the prompt</td> <td align="center" width="33%"><a href="#mission-control"><img src="images/gallery/mission-control.png" alt="mission-control: Claude Code subagents, tool calls and the files they touch, live" width="260"></a><br>🛰️ <b>mission-control</b><br>agents and the code they touch</td> </tr> </table>

🚀 Install

Add the marketplace once, then install any mod by name:

claude plugin marketplace add hamzafer/claude-code-mods
claude plugin install context-bar@claude-code-mods

Or install the general-purpose set in one go:

for m in context-bar token-weather usage-meter where-am-i next-steps agent-radar review-watch replay-theater md-preview blast-radius mission-control; do
  claude plugin install "$m@claude-code-mods"
done

Restart Claude Code after installing. To try one without installing:

git clone https://github.com/hamzafer/claude-code-mods && cd claude-code-mods
claude --plugin-dir mods/context-bar

Needs Claude Code 2.1.287+. A few mods need more (Chrome, gh, a connector); the tables say which.

🧩 The mods

👀 See what's happening

ModWhat it doesCommand
🛰️mission-controlLive map of agents, tool calls and the code they touch/mission
📊context-barYour context window as one stacked bar, a color per category, with token counts and where it compacts/context-bar
🌦️token-weatherContext fill from Clear to Compact soon, plus a prompt-cache countdown
⏱️cache-clockA prompt-cache line under your status line, from Claude Code's own figures: time left, hit rate and misses, and the tokens your next message re-caches once it goes cold. Needs Node and Claude Code 2.1.251+/cache-clock setup
📍where-am-iGoal, doing now, waiting on you, next step/where
➡️next-steps2 or 3 likely next prompts after each turn, one key to draft one1 2 3, 0 hides
💰usage-meter5-hour and 7-day plan usage, the reset countdown and the session's cost
💳openai-balanceYour OpenAI API credit: an estimated balance with a gauge, today's spend, where the money mostly went, and the last call. Needs an OpenAI organization Admin key/openai-balance
📡agent-radarOne live line per running subagent/radar
🔍review-watchOne live line per running code review (Codex or a review subagent) with the model, target, elapsed time and Codex's latest output. A toast lists the findings when it ends
🌐browser-lanesWhether this session has a browser, and who holds it/browser
🕌prayer-timesThe current prayer and how long is left, the next one, and zawal. Computed on your computer, Hanafi or standard Asr/prayers
🎬replay-theaterSteps through the last turn's edits, one diff at a time/replay
📝md-previewRenders the Markdown files Claude edits like GitHub does, with before and after side by side. Needs Chrome and a terminal that shows images/md

🛡️ Guard your repo

ModWhat it doesCommand
💥blast-radiusHolds rm -r, force pushes and migrations, shows what they'd delete, cancels after 60 s with no answer

💸 Spend less

ModWhat it doesCommand
🔀switchboardPicks the model for each subagent that doesn't name one, with OpenAI's Decisions API or Jev, from its short label only. Shows what every subagent cost/route

🔧 My setup (fork and adapt)

These are built around my own tools and rules. Fork them and change the rules to yours.

ModWhat it doesCommand
👀glanceOne line with what needs you: next meeting, PRs, Linear issues, Slack DMs. Needs gh and the Google Calendar, Linear and Slack connectors/glance
🚦merge-gateHolds gh pr merge until CI is green and Codex reviewed once. Needs gh and the Codex CLI. Reviews run on one fixed model; change it to yours/gate
📏rulebook-guardEnforces my writing and git rules: rewrites em dashes, asks before --amend, unformatted pushes, emails and phone numbers in notes
💾session-saverSaves where you left off, shows it on resume. Needs unpause/park [note]

🎮 For fun (opt-in)

ModWhat it doesCommand
📱reelsYouTube Shorts while Claude works, pauses when it's done/reels
🐍snakeSnake while Claude works/snake
🎵now-playingWhat Spotify is playing, with a progress bar, the lyric being sung, and ⏮ ⏸ ⏭ buttons. Needs macOS and the Spotify app/music

<a id="mission-control"></a>

🛰️ Flagship: mission-control

Every subagent, every tool call and every file they touch, in a pane next to the chat. Shown at 4x: two subagents building a logout feature across four files.

mission-control at 4x: two subagents and a logout feature landing across four files

Install it like any mod, restart, and type /mission (or /mission code to open the code map). q closes it.

  • 🤖 w shows the agents and every tool call, live
  • 🗺️ c shows the code map, with import arrows
  • 🔵 Blue while the agent reads a file
  • 🟠 Orange while it writes
  • 🟢 Green when done, with one line on what changed

The Code view also needs macOS, Google Chrome and a terminal that shows images (Ghostty, kitty, iTerm2). The Who view works everywhere.

📚 More

Source 3 files
hooks/register.tsx 271 lines
1// Switchboard: the right Claude model for each subagent, and what each one cost.
2//   Before a subagent starts that names no model, a picker chooses one from a ladder (Haiku,
3//   Sonnet, Opus, or Sonnet, Opus, Fable) by its short label alone: Jev (TypeSafe's decision
4//   model, directly or through Vercel AI Gateway) or OpenAI's Decisions API, each a fraction
5//   of a cent a pick. A model the caller named is kept. With no picker, no key, or no answer
6//   in time, nothing changes. In auto mode the pick replaces the model the spawn would have run
7//   on; in suggest mode it is only shown. /route lists every subagent, its pick and its cost.
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import type { Route, Tier } from '../types'
12import { LADDERS, PICKER_NAME, PICK_URL, PRICES_AS_OF, addUsage, costOf, parsePick, pickRequest, tierOf, usd } from './route'
13import type { Ladder, Style, Via } from './route'
14
15const PANE = 'switchboard'
16const PICK_TIMEOUT_MS = 4_000 // the spawn waits this long for the picker at most
17const MIN_CONFIDENCE = 0.5 // below this, a picker's pick is shown but not applied
18const SHOW_DONE_MS = 30_000 // a finished spawn stays in the band this long
19const BAND_ROWS = 3
20const PANE_ROWS = 12
21
22// Held by the host, so a hot reload keeps the log.
23const routes = atom({ plugin: 'switchboard', key: 'routes' } as const, [] as Route[])
24const now = atom({ plugin: 'switchboard', key: 'now' } as const, 0)
25
26export const register: Register = (on, options) => {
27  const mode = options.mode === 'suggest' ? 'suggest' : 'auto'
28  const text = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
29  const settings: Settings = {
30    picker: options.picker === 'jev' || options.picker === 'openai' ? options.picker : 'off',
31    ladder: options.models === 'sonnet-opus-fable' ? 'sonnet-opus-fable' : 'haiku-sonnet-opus',
32    style: options.style === 'quality' ? 'quality' : 'saver',
33    respectNamed: options.respectNamed !== false,
34    jev: text(options.jevApiKey),
35    gateway: text(options.gatewayApiKey),
36    openai: text(options.openaiApiKey),
37    zeroRetention: options.gatewayZeroRetention === true,
38  }
39
40  on('session.start', async ($, e, next) => {
41    const r = await next(e)
42    await $.command.register({ name: 'route', description: 'Show which model Switchboard picked for each subagent, and what it cost' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
43    // Redraws the band so a finished line leaves after 30 s; quiet when nothing is shown.
44    $.clock.every(5_000, () => {
45      void (async () => {
46        if ((await read($, routes)).some(r => isShown(r, Date.now() - 5_000))) await update($, now, () => Date.now())
47      })().catch(() => {})
48    })
49    return r
50  })
51
52  on('agent.spawn', async ($, e, next) => {
53    if (e.fork || e.isTeammate) return next(e) // a fork runs on its parent's model; a teammate keeps the one it was given
54    const asked = baseline(e)
55    const named = settings.respectNamed && !!e.model && e.model !== 'inherit'
56    const pick: Pick = named
57      ? { by: 'none', reason: 'named by the caller, kept' }
58      : await credential($, settings)
59          .then(c => decide($, e, c, settings))
60          .catch(() => ({ by: 'none' as const, reason: 'picker failed, kept' }))
61    const sure = pick.confidence === undefined || pick.confidence >= MIN_CONFIDENCE
62    const apply = mode === 'auto' && sure && !!pick.tier && asked !== undefined && pick.tier !== tierOf(asked)
63    const r = await next(apply ? { ...e, model: pick.tier } : e)
64    if (r.deny !== undefined) return r
65    const one: Route = {
66      id: e.tool_use_id,
67      agentId: r.agentId,
68      description: e.description || e.name || e.subagentType,
69      type: e.subagentType,
70      asked,
71      picked: pick.tier,
72      by: pick.by,
73      confidence: pick.confidence,
74      reason: sure ? pick.reason : `${pick.reason}, too unsure to switch`,
75      applied: apply,
76      status: 'running',
77      startedAt: Date.now(),
78      model: r.model,
79      pickerUsd: pick.pickerUsd,
80    }
81    await update($, routes, list => trim([one, ...list.filter(x => x.id !== one.id)])).catch(() => {})
82    return r
83  })
84
85  // Each subagent turn's tokens, counted once: a turn.complete is one turn.
86  on('turn.complete', async ($, e, next) => {
87    const r = await next(e)
88    if (!e.agentId) return r
89    const id = e.agentId
90    const failed = e.reason === 'error' || e.reason === 'aborted'
91    await update($, routes, list =>
92      list.map(x => {
93        if (x.agentId !== id) return x
94        const usage = r.usage ? addUsage(x.usage, r.usage) : x.usage
95        // What it ran on: the API's word, else the switch we made, else what was asked.
96        const model = r.usage?.model ?? x.model ?? (x.applied ? x.picked : x.asked) ?? x.picked ?? ''
97        return {
98          ...x,
99          status: failed ? 'failed' : 'done',
100          endedAt: Date.now(),
101          model,
102          usage,
103          costUsd: usage ? costOf(model, usage) : x.costUsd,
104          // Not switched, it ran on what was asked; switched, the same tokens at the asked model's prices.
105          askedUsd: !usage ? x.askedUsd : x.applied && x.asked ? costOf(x.asked, usage) : costOf(model, usage),
106        }
107      }),
108    )
109    return r
110  })
111
112  on('command.run', { command: 'route' }, async $ => {
113    await $.ui.open({ id: PANE, title: 'Switchboard', focus: true })
114    const t = totals(await read($, routes))
115    return { text: `${t.count} subagents, ${t.switched} switched · ${usd(t.cost)} spent, ${usd(t.asked)} at the asked models` }
116  })
117
118  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
119    const rest = await next(e) // what other mods and Claude Code draw here stays
120    await read($, now) // subscribes the band to the clock
121    const shown = (await read($, routes)).filter(r => isShown(r))
122    if (e.props.hasSurvey || shown.length === 0) return rest
123    const { Box, Text } = $.ui.resolve(e)
124    return (
125      <Box flexDirection="column">
126        {shown.slice(0, BAND_ROWS).map(r => (
127          <Text wrap="truncate-end">
128            <Text color={color(r)} bold>{` ⇄ ${r.description}`}</Text>
129            <Text>{`  ${move(r, mode)}`}</Text>
130            <Text dimColor>{`  ${source(r)}${r.status === 'running' ? '' : ` · ${usd(r.costUsd)}`}`}</Text>
131          </Text>
132        ))}
133        {shown.length > BAND_ROWS && <Text dimColor>{`   +${shown.length - BAND_ROWS} more · /route`}</Text>}
134        {rest}
135      </Box>
136    )
137  })
138
139  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
140    const { Box, Text, Button } = $.ui.resolve(e)
141    const list = await read($, routes)
142    const t = totals(list)
143    const keyless = settings.picker !== 'off' && !(await credential($, settings).catch(() => null))
144    return (
145      <Box flexDirection="column">
146        <Text bold>{`${t.count} subagents · ${t.switched} switched · mode: ${mode} · picker: ${settings.picker}${keyless ? ' (no key, nothing changes)' : ''}`}</Text>
147        <Text>
148          <Text>{`spent ${usd(t.cost)}`}</Text>
149          <Text dimColor>{` · at the asked models ${usd(t.asked)} · picker ${usd(t.picker)}`}</Text>
150        </Text>
151        <Text dimColor>{`API price estimates as of ${PRICES_AS_OF}, not plan charges. ? = not known yet.`}</Text>
152        <Text dimColor>{'─'.repeat(Math.max(10, e.props.bodyColumns - 2))}</Text>
153        {list.length === 0 && <Text dimColor>No subagents yet this session.</Text>}
154        {list.slice(0, PANE_ROWS).map(r => (
155          <Box flexDirection="column">
156            <Text wrap="truncate-end">
157              <Text color={color(r)} bold>{`${icon(r)} ${r.description}`}</Text>
158              <Text>{`  ${move(r, mode)}`}</Text>
159              <Text dimColor>{`  ${r.status === 'running' ? 'running' : `${usd(r.costUsd)} (asked ${usd(r.askedUsd)})`}`}</Text>
160            </Text>
161            <Text dimColor wrap="truncate-end">{`   ${r.type} · ${r.by === 'none' ? r.reason : `${source(r)} · ${r.reason}`}${r.model ? ` · ran on ${r.model}` : ''}`}</Text>
162          </Box>
163        ))}
164        {list.length > PANE_ROWS && <Text dimColor>{`… ${list.length - PANE_ROWS} older`}</Text>}
165        <Box flexDirection="row" gap={1}>
166          <Button key="clear" label="Clear finished" hotkey="c" onPress={() => update($, routes, all => all.filter(x => x.status === 'running'))} />
167          <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
168        </Box>
169      </Box>
170    )
171  })
172}
173
174type Pick = { tier?: Tier; by: Route['by']; confidence?: number; reason: string; pickerUsd?: number }
175type Settings = { picker: 'off' | 'jev' | 'openai'; ladder: Ladder; style: Style; respectNamed: boolean; jev: string; gateway: string; openai: string; zeroRetention: boolean }
176type Credential = { via: Via; key: string } | null
177
178// The picker the settings chose, with its key. A key set in /config wins over one from the
179// environment. Jev: a TypeSafe key goes straight to TypeSafe, a gateway key through Vercel AI
180// Gateway. OpenAI: the setting, then OPENAI_API_KEY. A key alone never turns a picker on:
181// many tools set these variables.
182async function credential($: EngineInterface, s: Settings): Promise<Credential> {
183  if (s.picker === 'openai') {
184    const key = s.openai || ((await $.env.get('OPENAI_API_KEY').catch(() => undefined)) ?? '').trim()
185    return key ? { via: 'openai', key } : null
186  }
187  if (s.picker !== 'jev') return null
188  if (s.jev) return { via: 'typesafe', key: s.jev }
189  if (s.gateway) return { via: 'gateway', key: s.gateway }
190  const typesafe = ((await $.env.get('TYPESAFE_API_KEY').catch(() => undefined)) ?? '').trim()
191  if (typesafe) return { via: 'typesafe', key: typesafe }
192  const gateway = ((await $.env.get('AI_GATEWAY_API_KEY').catch(() => undefined)) ?? '').trim()
193  return gateway ? { via: 'gateway', key: gateway } : null
194}
195
196// The picker when there is one and it answers in time; otherwise no pick, and nothing changes.
197async function decide($: EngineInterface, e: { subagentType: string; description: string }, c: Credential, s: Settings): Promise<Pick> {
198  if (!c) return { by: 'none', reason: s.picker === 'off' ? 'no picker' : `no ${s.picker === 'openai' ? 'OpenAI' : 'Jev'} key` }
199  const name = PICKER_NAME[c.via]
200  const timeout = $.clock.sleep(PICK_TIMEOUT_MS).then(() => null)
201  const asked = $.http
202    .fetch(PICK_URL[c.via], {
203      method: 'POST',
204      headers: { Authorization: `Bearer ${c.key}`, 'Content-Type': 'application/json' },
205      body: JSON.stringify(pickRequest(e, c.via, { ladder: s.ladder, style: s.style, zeroRetention: s.zeroRetention })),
206    })
207    .then(r => (r.ok ? parsePick(r.text, c.via, LADDERS[s.ladder]) : null))
208    .catch(() => null)
209  const got = await Promise.race([asked, timeout])
210  if (!got) return { by: 'none', reason: `${name} did not answer` }
211  const by: Pick['by'] = c.via === 'openai' ? 'openai' : 'jev'
212  return { tier: got.tier, by, confidence: got.confidence, reason: `${name} picked ${got.tier}`, pickerUsd: got.costUsd }
213}
214
215// The model the spawn would run on without us, when we can know it: what the caller named,
216// or the parent's for an agent that inherits. Undefined when the agent's own definition
217// decides, which a hook can't see; such a spawn gets a suggestion, never a switch.
218export function baseline(e: { model?: string; parentModel: string; subagentType: string }): string | undefined {
219  if (e.model && e.model !== 'inherit') return e.model
220  if (e.model === 'inherit' || e.subagentType === 'general-purpose') return e.parentModel
221  return undefined
222}
223
224// The newest 50, but never a running one: its cost and status are still to come.
225export function trim(list: Route[]) {
226  return list.filter((r, i) => i < 50 || r.status === 'running')
227}
228
229// Running, or finished in the last 30 s.
230export function isShown(r: Route, at = Date.now()) {
231  return r.status === 'running' || (r.endedAt !== undefined && at - r.endedAt < SHOW_DONE_MS)
232}
233
234// "opus → sonnet", "sonnet (kept)", "opus · try sonnet" in suggest mode, or, with no pick, the
235// model it runs on and why nothing was picked.
236export function move(r: Route, mode: 'auto' | 'suggest') {
237  const from = r.asked === undefined ? 'own model' : (tierOf(r.asked) ?? r.asked)
238  if (!r.picked) return from
239  if (r.asked === undefined) return `own model · try ${r.picked}`
240  if (r.applied) return `${from} → ${r.picked}`
241  if (tierOf(r.asked) === r.picked) return `${r.picked} (kept)`
242  return mode === 'suggest' ? `${from} · try ${r.picked}` : `${from} (kept)`
243}
244
245export function totals(list: Route[]) {
246  const sum = (f: (r: Route) => number | undefined) => list.reduce((n, r) => n + (f(r) ?? 0), 0)
247  return {
248    count: list.length,
249    switched: list.filter(r => r.applied).length,
250    cost: sum(r => r.costUsd) + sum(r => r.pickerUsd),
251    asked: sum(r => r.askedUsd),
252    picker: sum(r => r.pickerUsd),
253  }
254}
255
256function source(r: Route) {
257  return r.by === 'none' ? r.reason : `${r.by} ${pct(r.confidence ?? 0)} sure`
258}
259
260function pct(n: number) {
261  return `${Math.round(n * 100)}%`
262}
263
264function icon(r: Route) {
265  return r.status === 'running' ? '●' : r.status === 'done' ? '✓' : '✗'
266}
267
268function color(r: Route) {
269  return r.status === 'running' ? 'yellow' : r.status === 'done' ? 'green' : 'red'
270}
271
hooks/route.ts 147 lines
1// The routing brain, kept free of the engine so tests can call it directly:
2// the model ladders and styles, each picker's request and answer, and the cost of a run.
3import type { Tier, Usage } from '../types'
4
5// The models a picker may choose between, cheapest first. A ladder is a setting.
6export type Ladder = 'haiku-sonnet-opus' | 'sonnet-opus-fable'
7export const LADDERS: Record<Ladder, readonly Tier[]> = {
8  'haiku-sonnet-opus': ['haiku', 'sonnet', 'opus'],
9  'sonnet-opus-fable': ['sonnet', 'opus', 'fable'],
10}
11
12// How a picker weighs cost against quality, a setting too: the question it answers and what each
13// model is for. "quality" was tuned against one developer's own model choices (see docs/mods.md).
14export type Style = 'saver' | 'quality'
15const QUESTION: Record<Style, string> = {
16  saver: 'A coding agent is about to hand this task to a subagent. Which model is the cheapest one that will still do the task well?',
17  quality: 'Which model would this developer pick for this subagent? They pay for quality on anything they will rely on, and use the cheaper model for routine execution.',
18}
19const DESCRIBE: Record<Style, Record<Ladder, Partial<Record<Tier, string>>>> = {
20  saver: {
21    'haiku-sonnet-opus': {
22      haiku: 'Lookups and light work: searching or listing files, reading and summarizing code or docs, answering a factual question about the repo, small mechanical edits.',
23      sonnet: 'Everyday coding: implementing a feature, fixing an ordinary bug, writing or updating tests, reviewing a diff, editing a few files.',
24      opus: 'Hard thinking: planning or architecture, debugging a subtle or intermittent issue, security review, large multi-file refactors, work where a wrong answer is costly.',
25    },
26    'sonnet-opus-fable': {
27      sonnet: 'Everything routine: lookups, reading, summarizing, research, ordinary coding, tests, reviews and verification of ordinary diffs, docs, small refactors, ordinary bug fixes.',
28      opus: 'Hard thinking: planning and architecture, subtle or intermittent bugs, security reviews, large multi-file refactors, work where a wrong answer is costly.',
29      fable: 'The hardest few: long-horizon autonomous work running for hours, novel research-grade problems, or the highest-stakes calls where even Opus is likely to fall short. Costs 2.5x Opus, so only when clearly needed.',
30    },
31  },
32  quality: {
33    'haiku-sonnet-opus': {
34      haiku: 'Only trivial lookups: listing or finding files, reading one file, a quick factual check.',
35      sonnet: 'Routine execution: implementing or fixing something whose plan is already decided, routine ops and maintenance, searches and summaries, the standard pre-merge review of a pull request, and verifying that review fixes were applied.',
36      opus: 'Anything the developer will rely on: audits and quality checks of finished work, independent or fresh-eyes reviews, extra code reviews and regression hunts, planning, design and idea generation, building a new feature end to end on its own, and high-stakes or security-sensitive work.',
37    },
38    'sonnet-opus-fable': {
39      sonnet: 'Routine execution: implementing or fixing something whose plan is already decided, routine ops and maintenance (servers, disk, configs, sweeps, CI chores), lookups, searches and summaries, the standard pre-merge review of a pull request, and verifying that review fixes were applied.',
40      opus: 'Anything the developer will rely on: audits and quality checks of finished work (papers, docs, figures, numbers, references), independent or fresh-eyes reviews, code reviews and regression hunts asked for on top of the standard one, planning, design and idea generation, building a new feature end to end on its own, and high-stakes or security-sensitive work.',
41      fable: 'Rare: long autonomous runs left alone for hours, or research-grade problems where even Opus is likely to fall short.',
42    },
43  },
44}
45
46// API prices in USD per million tokens, as published on 2026-09-25. Estimates, not plan charges.
47export const PRICES_AS_OF = '2026-09-25'
48const PRICES: { match: RegExp; input: number; output: number; cacheRead: number }[] = [
49  { match: /haiku/i, input: 1, output: 5, cacheRead: 0.1 },
50  { match: /sonnet/i, input: 2, output: 10, cacheRead: 0.2 },
51  { match: /opus/i, input: 4, output: 20, cacheRead: 0.2 },
52  { match: /fable|mythos/i, input: 10, output: 50, cacheRead: 0.25 },
53]
54// Who picks: Jev, from TypeSafe itself or through Vercel AI Gateway, or OpenAI's Decisions API.
55export type Via = 'typesafe' | 'gateway' | 'openai'
56export const PICK_URL: Record<Via, string> = {
57  typesafe: 'https://api.typesafe.ai/v1/systemone',
58  gateway: 'https://ai-gateway.vercel.sh/v1/evaluate',
59  openai: 'https://api.openai.com/v1/decisions',
60}
61export const PICKER_NAME: Record<Via, 'Jev' | 'OpenAI'> = { typesafe: 'Jev', gateway: 'Jev', openai: 'OpenAI' }
62// Input tokens only; none of them bill output.
63const USD_PER_TOKEN: Record<Via, number> = { typesafe: 0.042 / 1_000_000, gateway: 0.042 / 1_000_000, openai: 0.1 / 1_000_000 }
64
65// Only the agent type and its short label go out: never the task text, which can hold code,
66// paths, hostnames or a pasted key.
67type Task = { subagentType: string; description: string }
68type Ask = { ladder: Ladder; style: Style; zeroRetention?: boolean }
69function stateOf(input: Task) {
70  return { agent_type: input.subagentType, description: input.description }
71}
72
73// The body of one pick: the label, and one choice over the ladder, in each API's own shape.
74// Through the gateway, only TypeSafe may serve it; zero data retention is opt-in, since the
75// gateway refuses it below the Pro plan.
76export function pickRequest(input: Task, via: Via, ask: Ask) {
77  const tiers = LADDERS[ask.ladder]
78  const describe = DESCRIBE[ask.style][ask.ladder]
79  if (via === 'openai') {
80    return {
81      model: 'gpt-6-luna',
82      input: JSON.stringify(stateOf(input)),
83      questions: [{ type: 'choice', name: 'tier', instructions: QUESTION[ask.style], choices: tiers.map(t => ({ value: t, description: describe[t] })) }],
84    }
85  }
86  return {
87    model: via === 'gateway' ? 'typesafe-ai/jev' : 'jev-latest',
88    ...(via === 'gateway' ? { providerOptions: { gateway: { only: ['typesafe-ai'], ...(ask.zeroRetention ? { zeroDataRetention: true } : {}) } } } : {}),
89    state: stateOf(input),
90    questions: { tier: { type: 'choice', instructions: QUESTION[ask.style], criteria: Object.fromEntries(tiers.map(t => [t, describe[t]])) } },
91  }
92}
93
94// Reads a picker's answer; null when it is not one we can use.
95export function parsePick(text: string, via: Via, tiers: readonly Tier[]): { tier: Tier; confidence: number; probabilities: Partial<Record<Tier, number>>; costUsd: number } | null {
96  let body: any
97  try {
98    body = JSON.parse(text)
99  } catch {
100    return null
101  }
102  // OpenAI lists answers and probabilities; Jev keys them by name.
103  const a = via === 'openai' ? (Array.isArray(body?.answers) ? body.answers.find((x: any) => x?.name === 'tier') : undefined) : body?.answers?.tier
104  if (!a || !tiers.includes(a.choice)) return null
105  const probabilities: Partial<Record<Tier, number>> = Array.isArray(a.probabilities)
106    ? Object.fromEntries(a.probabilities.filter((x: any) => tiers.includes(x?.value) && typeof x.probability === 'number').map((x: any) => [x.value, x.probability]))
107    : (a.probabilities ?? {})
108  // TypeSafe and OpenAI send a confidence; the gateway sends only the probabilities, so the pick's own stands in.
109  const confidence = typeof a.confidence === 'number' ? a.confidence : (probabilities[a.choice as Tier] ?? 0)
110  const tokens = body.usage?.input_tokens ?? body.usage?.inputTokens
111  const raw = body.providerMetadata?.gateway?.cost
112  const billed = typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN // the gateway's billed cost, when it sends one
113  const costUsd = Number.isFinite(billed) && billed >= 0 ? billed : typeof tokens === 'number' ? tokens * USD_PER_TOKEN[via] : 0
114  return { tier: a.choice, confidence, probabilities, costUsd }
115}
116
117// Which tier a model name or id belongs to, if any of ours.
118export function tierOf(model: string | undefined): Tier | undefined {
119  if (!model) return undefined
120  return (['haiku', 'sonnet', 'opus', 'fable'] as const).find(t => model.toLowerCase().includes(t))
121}
122
123// What a run cost at API prices; undefined for a model we have no price for.
124export function costOf(model: string, u: Usage): number | undefined {
125  const p = PRICES.find(x => x.match.test(model))
126  if (!p) return undefined
127  const write = p.input * 1.25 // 5-minute cache writes
128  return (u.input_tokens * p.input + u.output_tokens * p.output + u.cache_read_input_tokens * p.cacheRead + u.cache_creation_input_tokens * write) / 1_000_000
129}
130
131export function addUsage(a: Usage | undefined, b: Usage): Usage {
132  if (!a) return { ...b }
133  return {
134    input_tokens: a.input_tokens + b.input_tokens,
135    output_tokens: a.output_tokens + b.output_tokens,
136    cache_read_input_tokens: a.cache_read_input_tokens + b.cache_read_input_tokens,
137    cache_creation_input_tokens: a.cache_creation_input_tokens + b.cache_creation_input_tokens,
138  }
139}
140
141export function usd(n: number | undefined): string {
142  if (n === undefined) return '?'
143  if (n === 0) return '$0'
144  if (n < 0.01) return '<$0.01'
145  return `$${n.toFixed(2)}`
146}
147
types/index.d.ts 37 lines
1export type Tier = 'haiku' | 'sonnet' | 'opus' | 'fable'
2
3export type Usage = {
4  input_tokens: number
5  output_tokens: number
6  cache_read_input_tokens: number
7  cache_creation_input_tokens: number
8}
9
10// One subagent spawn and what the router did with it.
11export type Route = {
12  id: string // the Agent tool call's id
13  agentId?: string
14  description: string
15  type: string
16  asked?: string // what it would run on without us; undefined when its own agent definition decides
17  picked?: Tier // undefined when nothing picked: a named model, no picker, or no answer
18  by: 'jev' | 'openai' | 'none'
19  confidence?: number // the picker's, 0 to 1
20  reason: string
21  applied: boolean // false in suggest mode, or when the pick matched what was asked
22  status: 'running' | 'done' | 'failed'
23  startedAt: number
24  endedAt?: number
25  model?: string // what it ran on, as the API reports it
26  usage?: Usage
27  costUsd?: number // at API prices, for the tokens it used
28  askedUsd?: number // the same tokens at the asked model's prices
29  pickerUsd?: number // what the pick itself cost
30}
31
32declare module 'claude-code' {
33  interface PluginState {
34    switchboard: { routes: Route[]; now: number }
35  }
36}
37