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
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

claude-code-mods-collection

A private reference collection of public Claude Code mods, with a security-oriented analysis.

  • ANALYSIS.md: what each mod can reach, which to install, which to avoid
  • SOURCES.md: upstream repo and pinned commit for every folder in mods/

Nothing here is installed or executed. All code is by its upstream authors under their own licences (MIT, one Apache-2.0); each folder keeps its original LICENSE.

Source 4 files
hooks/register.tsx 286 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 { bandsFile, parseOrder, stackBands } from './bands'
13import { LADDERS, PICKER_NAME, PICK_URL, PRICES_AS_OF, addUsage, costOf, parsePick, pickRequest, tierOf, usd } from './route'
14import type { Ladder, Style, Via } from './route'
15
16const PANE = 'switchboard'
17const PICK_TIMEOUT_MS = 4_000 // the spawn waits this long for the picker at most
18const MIN_CONFIDENCE = 0.5 // below this, a picker's pick is shown but not applied
19const SHOW_DONE_MS = 30_000 // a finished spawn stays in the band this long
20const BAND_ROWS = 3
21const PANE_ROWS = 12
22
23// Held by the host, so a hot reload keeps the log.
24const routes = atom({ plugin: 'switchboard', key: 'routes' } as const, [] as Route[])
25const now = atom({ plugin: 'switchboard', key: 'now' } as const, 0)
26
27let bandOrder: Promise<string[]> | undefined // read once per session: restart to apply
28
29// The band order file, as the claude-code-mods checklist saves it; [] on any error.
30async function readBandOrder($: EngineInterface): Promise<string[]> {
31  const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'))
32  return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
33}
34
35// Forgets the cached band order, for tests.
36export function resetBandOrder() {
37  bandOrder = undefined
38}
39
40export const register: Register = (on, options) => {
41  const mode = options.mode === 'suggest' ? 'suggest' : 'auto'
42  const text = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
43  const settings: Settings = {
44    picker: options.picker === 'jev' || options.picker === 'openai' ? options.picker : 'off',
45    ladder: options.models === 'sonnet-opus-fable' ? 'sonnet-opus-fable' : 'haiku-sonnet-opus',
46    style: options.style === 'quality' ? 'quality' : 'saver',
47    respectNamed: options.respectNamed !== false,
48    jev: text(options.jevApiKey),
49    gateway: text(options.gatewayApiKey),
50    openai: text(options.openaiApiKey),
51    zeroRetention: options.gatewayZeroRetention === true,
52  }
53
54  on('session.start', async ($, e, next) => {
55    const r = await next(e)
56    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
57    // Redraws the band so a finished line leaves after 30 s; quiet when nothing is shown.
58    $.clock.every(5_000, () => {
59      void (async () => {
60        if ((await read($, routes)).some(r => isShown(r, Date.now() - 5_000))) await update($, now, () => Date.now())
61      })().catch(() => {})
62    })
63    return r
64  })
65
66  on('agent.spawn', async ($, e, next) => {
67    if (e.fork || e.isTeammate) return next(e) // a fork runs on its parent's model; a teammate keeps the one it was given
68    const asked = baseline(e)
69    const named = settings.respectNamed && !!e.model && e.model !== 'inherit'
70    const pick: Pick = named
71      ? { by: 'none', reason: 'named by the caller, kept' }
72      : await credential($, settings)
73          .then(c => decide($, e, c, settings))
74          .catch(() => ({ by: 'none' as const, reason: 'picker failed, kept' }))
75    const sure = pick.confidence === undefined || pick.confidence >= MIN_CONFIDENCE
76    const apply = mode === 'auto' && sure && !!pick.tier && asked !== undefined && pick.tier !== tierOf(asked)
77    const r = await next(apply ? { ...e, model: pick.tier } : e)
78    if (r.deny !== undefined) return r
79    const one: Route = {
80      id: e.tool_use_id,
81      agentId: r.agentId,
82      description: e.description || e.name || e.subagentType,
83      type: e.subagentType,
84      asked,
85      picked: pick.tier,
86      by: pick.by,
87      confidence: pick.confidence,
88      reason: sure ? pick.reason : `${pick.reason}, too unsure to switch`,
89      applied: apply,
90      status: 'running',
91      startedAt: Date.now(),
92      model: r.model,
93      pickerUsd: pick.pickerUsd,
94    }
95    await update($, routes, list => trim([one, ...list.filter(x => x.id !== one.id)])).catch(() => {})
96    return r
97  })
98
99  // Each subagent turn's tokens, counted once: a turn.complete is one turn.
100  on('turn.complete', async ($, e, next) => {
101    const r = await next(e)
102    if (!e.agentId) return r
103    const id = e.agentId
104    const failed = e.reason === 'error' || e.reason === 'aborted'
105    await update($, routes, list =>
106      list.map(x => {
107        if (x.agentId !== id) return x
108        const usage = r.usage ? addUsage(x.usage, r.usage) : x.usage
109        // What it ran on: the API's word, else the switch we made, else what was asked.
110        const model = r.usage?.model ?? x.model ?? (x.applied ? x.picked : x.asked) ?? x.picked ?? ''
111        return {
112          ...x,
113          status: failed ? 'failed' : 'done',
114          endedAt: Date.now(),
115          model,
116          usage,
117          costUsd: usage ? costOf(model, usage) : x.costUsd,
118          // Not switched, it ran on what was asked; switched, the same tokens at the asked model's prices.
119          askedUsd: !usage ? x.askedUsd : x.applied && x.asked ? costOf(x.asked, usage) : costOf(model, usage),
120        }
121      }),
122    )
123    return r
124  })
125
126  on('command.run', { command: 'route' }, async $ => {
127    await $.ui.open({ id: PANE, title: 'Switchboard', focus: true })
128    const t = totals(await read($, routes))
129    return { text: `${t.count} subagents, ${t.switched} switched · ${usd(t.cost)} spent, ${usd(t.asked)} at the asked models` }
130  })
131
132  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
133    const rest = await next(e) // what other mods and Claude Code draw here stays
134    await read($, now) // subscribes the band to the clock
135    const shown = (await read($, routes)).filter(r => isShown(r))
136    if (e.props.hasSurvey || shown.length === 0) return rest
137    const { Box, Text } = $.ui.resolve(e)
138    const mine = (
139      <Box flexDirection="column">
140        {shown.slice(0, BAND_ROWS).map(r => (
141          <Text wrap="truncate-end">
142            <Text color={color(r)} bold>{` ⇄ ${r.description}`}</Text>
143            <Text>{`  ${move(r, mode)}`}</Text>
144            <Text dimColor>{`  ${source(r)}${r.status === 'running' ? '' : ` · ${usd(r.costUsd)}`}`}</Text>
145          </Text>
146        ))}
147        {shown.length > BAND_ROWS && <Text dimColor>{`   +${shown.length - BAND_ROWS} more · /route`}</Text>}
148      </Box>
149    )
150    bandOrder ??= readBandOrder($).catch(() => [])
151    return stackBands(rest, 'switchboard', mine, 'above', await bandOrder) // above what other mods draw, unless the band order says otherwise
152  })
153
154  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
155    const { Box, Text, Button } = $.ui.resolve(e)
156    const list = await read($, routes)
157    const t = totals(list)
158    const keyless = settings.picker !== 'off' && !(await credential($, settings).catch(() => null))
159    return (
160      <Box flexDirection="column">
161        <Text bold>{`${t.count} subagents · ${t.switched} switched · mode: ${mode} · picker: ${settings.picker}${keyless ? ' (no key, nothing changes)' : ''}`}</Text>
162        <Text>
163          <Text>{`spent ${usd(t.cost)}`}</Text>
164          <Text dimColor>{` · at the asked models ${usd(t.asked)} · picker ${usd(t.picker)}`}</Text>
165        </Text>
166        <Text dimColor>{`API price estimates as of ${PRICES_AS_OF}, not plan charges. ? = not known yet.`}</Text>
167        <Text dimColor>{'─'.repeat(Math.max(10, e.props.bodyColumns - 2))}</Text>
168        {list.length === 0 && <Text dimColor>No subagents yet this session.</Text>}
169        {list.slice(0, PANE_ROWS).map(r => (
170          <Box flexDirection="column">
171            <Text wrap="truncate-end">
172              <Text color={color(r)} bold>{`${icon(r)} ${r.description}`}</Text>
173              <Text>{`  ${move(r, mode)}`}</Text>
174              <Text dimColor>{`  ${r.status === 'running' ? 'running' : `${usd(r.costUsd)} (asked ${usd(r.askedUsd)})`}`}</Text>
175            </Text>
176            <Text dimColor wrap="truncate-end">{`   ${r.type} · ${r.by === 'none' ? r.reason : `${source(r)} · ${r.reason}`}${r.model ? ` · ran on ${r.model}` : ''}`}</Text>
177          </Box>
178        ))}
179        {list.length > PANE_ROWS && <Text dimColor>{`… ${list.length - PANE_ROWS} older`}</Text>}
180        <Box flexDirection="row" gap={1}>
181          <Button key="clear" label="Clear finished" hotkey="c" onPress={() => update($, routes, all => all.filter(x => x.status === 'running'))} />
182          <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
183        </Box>
184      </Box>
185    )
186  })
187}
188
189type Pick = { tier?: Tier; by: Route['by']; confidence?: number; reason: string; pickerUsd?: number }
190type Settings = { picker: 'off' | 'jev' | 'openai'; ladder: Ladder; style: Style; respectNamed: boolean; jev: string; gateway: string; openai: string; zeroRetention: boolean }
191type Credential = { via: Via; key: string } | null
192
193// The picker the settings chose, with its key. A key set in /config wins over one from the
194// environment. Jev: a TypeSafe key goes straight to TypeSafe, a gateway key through Vercel AI
195// Gateway. OpenAI: the setting, then OPENAI_API_KEY. A key alone never turns a picker on:
196// many tools set these variables.
197async function credential($: EngineInterface, s: Settings): Promise<Credential> {
198  if (s.picker === 'openai') {
199    const key = s.openai || ((await $.env.get('OPENAI_API_KEY').catch(() => undefined)) ?? '').trim()
200    return key ? { via: 'openai', key } : null
201  }
202  if (s.picker !== 'jev') return null
203  if (s.jev) return { via: 'typesafe', key: s.jev }
204  if (s.gateway) return { via: 'gateway', key: s.gateway }
205  const typesafe = ((await $.env.get('TYPESAFE_API_KEY').catch(() => undefined)) ?? '').trim()
206  if (typesafe) return { via: 'typesafe', key: typesafe }
207  const gateway = ((await $.env.get('AI_GATEWAY_API_KEY').catch(() => undefined)) ?? '').trim()
208  return gateway ? { via: 'gateway', key: gateway } : null
209}
210
211// The picker when there is one and it answers in time; otherwise no pick, and nothing changes.
212async function decide($: EngineInterface, e: { subagentType: string; description: string }, c: Credential, s: Settings): Promise<Pick> {
213  if (!c) return { by: 'none', reason: s.picker === 'off' ? 'no picker' : `no ${s.picker === 'openai' ? 'OpenAI' : 'Jev'} key` }
214  const name = PICKER_NAME[c.via]
215  const timeout = $.clock.sleep(PICK_TIMEOUT_MS).then(() => null)
216  const asked = $.http
217    .fetch(PICK_URL[c.via], {
218      method: 'POST',
219      headers: { Authorization: `Bearer ${c.key}`, 'Content-Type': 'application/json' },
220      body: JSON.stringify(pickRequest(e, c.via, { ladder: s.ladder, style: s.style, zeroRetention: s.zeroRetention })),
221    })
222    .then(r => (r.ok ? parsePick(r.text, c.via, LADDERS[s.ladder]) : null))
223    .catch(() => null)
224  const got = await Promise.race([asked, timeout])
225  if (!got) return { by: 'none', reason: `${name} did not answer` }
226  const by: Pick['by'] = c.via === 'openai' ? 'openai' : 'jev'
227  return { tier: got.tier, by, confidence: got.confidence, reason: `${name} picked ${got.tier}`, pickerUsd: got.costUsd }
228}
229
230// The model the spawn would run on without us, when we can know it: what the caller named,
231// or the parent's for an agent that inherits. Undefined when the agent's own definition
232// decides, which a hook can't see; such a spawn gets a suggestion, never a switch.
233export function baseline(e: { model?: string; parentModel: string; subagentType: string }): string | undefined {
234  if (e.model && e.model !== 'inherit') return e.model
235  if (e.model === 'inherit' || e.subagentType === 'general-purpose') return e.parentModel
236  return undefined
237}
238
239// The newest 50, but never a running one: its cost and status are still to come.
240export function trim(list: Route[]) {
241  return list.filter((r, i) => i < 50 || r.status === 'running')
242}
243
244// Running, or finished in the last 30 s.
245export function isShown(r: Route, at = Date.now()) {
246  return r.status === 'running' || (r.endedAt !== undefined && at - r.endedAt < SHOW_DONE_MS)
247}
248
249// "opus → sonnet", "sonnet (kept)", "opus · try sonnet" in suggest mode, or, with no pick, the
250// model it runs on and why nothing was picked.
251export function move(r: Route, mode: 'auto' | 'suggest') {
252  const from = r.asked === undefined ? 'own model' : (tierOf(r.asked) ?? r.asked)
253  if (!r.picked) return from
254  if (r.asked === undefined) return `own model · try ${r.picked}`
255  if (r.applied) return `${from} → ${r.picked}`
256  if (tierOf(r.asked) === r.picked) return `${r.picked} (kept)`
257  return mode === 'suggest' ? `${from} · try ${r.picked}` : `${from} (kept)`
258}
259
260export function totals(list: Route[]) {
261  const sum = (f: (r: Route) => number | undefined) => list.reduce((n, r) => n + (f(r) ?? 0), 0)
262  return {
263    count: list.length,
264    switched: list.filter(r => r.applied).length,
265    cost: sum(r => r.costUsd) + sum(r => r.pickerUsd),
266    asked: sum(r => r.askedUsd),
267    picker: sum(r => r.pickerUsd),
268  }
269}
270
271function source(r: Route) {
272  return r.by === 'none' ? r.reason : `${r.by} ${pct(r.confidence ?? 0)} sure`
273}
274
275function pct(n: number) {
276  return `${Math.round(n * 100)}%`
277}
278
279function icon(r: Route) {
280  return r.status === 'running' ? '●' : r.status === 'done' ? '✓' : '✗'
281}
282
283function color(r: Route) {
284  return r.status === 'running' ? 'yellow' : r.status === 'done' ? 'green' : 'red'
285}
286
hooks/bands.ts 73 lines
1// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
2//   The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
3//   (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
4//   `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
5//   column, adds its own band and sorts again, so the last one to draw sorts them all.
6//   Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
7//   Reading the file needs $, which Claude Code follows only within one file, so each mod
8//   reads it in its own register file with bandsFile and parseOrder, once per session.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const COLUMN = 'bands'
12const PREFIX = 'band:'
13
14// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
15export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
16  const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
17  return dir ? `${dir}/claude-code-mods/bands.json` : undefined
18}
19
20// The names in an order file; [] when it is no JSON or not { order: string[] }.
21export function parseOrder(text: string): string[] {
22  try {
23    const order = (JSON.parse(text) as { order?: unknown } | null)?.order
24    return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
25  } catch {
26    return []
27  }
28}
29
30/**
31 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
32 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
33 * With an order, items that are no band come first, then bands not in the
34 * order (in their current order), then the listed ones in file order. With
35 * no order nothing moves, so the screen looks as it did before.
36 */
37export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
38  const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
39  const others = unpack(rest)
40  const items = at === 'above' ? [own, ...others] : [...others, own]
41  return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
42}
43
44// Another band mod's column gives up its items; anything else is one item.
45function unpack(rest: RenderElement | null | undefined): RenderNode[] {
46  if (!rest) return []
47  if (!isColumn(rest)) return [rest]
48  const kids = rest.children ?? []
49  const owner = rest.part?.plugin
50  // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
51  return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
52}
53
54function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
55  return el.type === 'Box' && el.props?.key === COLUMN
56}
57
58function bandName(node: RenderNode): string | undefined {
59  if (typeof node === 'string' || node.type !== 'Box') return undefined
60  const key = node.props?.key
61  return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
62}
63
64function sorted(items: RenderNode[], order: string[]): RenderNode[] {
65  const rank = (n: RenderNode) => {
66    const name = bandName(n)
67    if (name === undefined) return -2 // not a band: first
68    const i = order.indexOf(name)
69    return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
70  }
71  return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
72}
73
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