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…

<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>
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.
| Mod | What it does | Command | |
|---|---|---|---|
| 🛰️ | mission-control | Live map of agents, tool calls and the code they touch | /mission |
| 📊 | context-bar | Your context window as one stacked bar, a color per category, with token counts and where it compacts | /context-bar |
| 🌦️ | token-weather | Context fill from Clear to Compact soon, plus a prompt-cache countdown | |
| ⏱️ | cache-clock | A 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-i | Goal, doing now, waiting on you, next step | /where |
| ➡️ | next-steps | 2 or 3 likely next prompts after each turn, one key to draft one | 1 2 3, 0 hides |
| 💰 | usage-meter | 5-hour and 7-day plan usage, the reset countdown and the session's cost | |
| 💳 | openai-balance | Your 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-radar | One live line per running subagent | /radar |
| 🔍 | review-watch | One 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-lanes | Whether this session has a browser, and who holds it | /browser |
| 🕌 | prayer-times | The current prayer and how long is left, the next one, and zawal. Computed on your computer, Hanafi or standard Asr | /prayers |
| 🎬 | replay-theater | Steps through the last turn's edits, one diff at a time | /replay |
| 📝 | md-preview | Renders the Markdown files Claude edits like GitHub does, with before and after side by side. Needs Chrome and a terminal that shows images | /md |
| Mod | What it does | Command | |
|---|---|---|---|
| 💥 | blast-radius | Holds rm -r, force pushes and migrations, shows what they'd delete, cancels after 60 s with no answer |
| Mod | What it does | Command | |
|---|---|---|---|
| 🔀 | switchboard | Picks 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 |
These are built around my own tools and rules. Fork them and change the rules to yours.
| Mod | What it does | Command | |
|---|---|---|---|
| 👀 | glance | One line with what needs you: next meeting, PRs, Linear issues, Slack DMs. Needs gh and the Google Calendar, Linear and Slack connectors | /glance |
| 🚦 | merge-gate | Holds 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-guard | Enforces my writing and git rules: rewrites em dashes, asks before --amend, unformatted pushes, emails and phone numbers in notes | |
| 💾 | session-saver | Saves where you left off, shows it on resume. Needs unpause | /park [note] |
| Mod | What it does | Command | |
|---|---|---|---|
| 📱 | reels | YouTube Shorts while Claude works, pauses when it's done | /reels |
| 🐍 | snake | Snake while Claude works | /snake |
| 🎵 | now-playing | What 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>
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.

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, livec shows the code map, with import arrowsThe Code view also needs macOS, Google Chrome and a terminal that shows images (Ghostty, kitty, iTerm2). The Who view works everywhere.
hooks/register.tsx 271 lines1// 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}
271hooks/route.ts 147 lines1// 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}
147types/index.d.ts 37 lines1export 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