Picks the model for each prompt and each subagent (never above /model and /effort for prompts), with Jev, OpenAI or Claude as the picker, and shows a colored…

Claude Code mods by Nereo Valeriano. Each one is a plugin in this marketplace.
| Mod | What it does |
|---|---|
| auto-model | Picks the model and effort for each of your prompts, and the model for each subagent. Shows a colored bar under the input with the model, effort, context and limits. |
| where-am-i | A card above the prompt with where the session stands: goal, status and recent progress. Fork of Hamza Zafar's where-am-i. |
| next-steps | Suggested next steps, shown in the where-am-i card with clickable number badges. Fork of Hamza Zafar's next-steps. |
/plugin marketplace add Luminous13/claude-mods
/plugin install auto-model@claude-mods
/plugin install where-am-i@claude-mods
/plugin install next-steps@claude-mods
Install where-am-i and next-steps together: the where-am-i card shows what next-steps suggests.
Don't also install Hamza's originals of where-am-i or next-steps. They have the same names and would clash.
Set picker in the plugin settings:
TYPESAFE_API_KEY.OPENAI_API_KEY.Subagent picking was ported from Hamza Zafar's switchboard. See NOTICE.md.
MIT. See LICENSE. The where-am-i and next-steps forks keep Hamza Zafar's copyright; see their NOTICE.md files.
hooks/register.tsx 447 lines1// Auto model: a picker (Jev by default, or OpenAI, or Claude) picks the main model for each prompt, and a colored line under the input
2// says which model each call actually runs on, subagents included.
3// As a turn starts, Jev reads the prompt (secrets cut, its first 1,500 characters) and picks
4// Haiku, Sonnet or Opus and an effort, never above what /model and /effort set. Every request
5// of the turn then names them. Moving down with a large context keeps the last turn's model
6// and effort, since a change re-reads the conversation without the cache. With no key, no answer in 3 s or
7// a pick under 50% sure, nothing changes. /auto-model on|off, and /auto-model alone for what it did.
8// Subagents too: before one starts that names no model, the picker chooses from a ladder by its type and
9// short label alone (never the task text). /route lists every subagent, its pick and its cost.
10// That half is ported from Hamza Zafar's switchboard 0.3.1 (MIT, see NOTICE.md).
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, Register } from 'claude-code'
13
14import type { Line, Note, Route, Stats, Tier } from '../types'
15import { LADDERS, PRICES_AS_OF, addUsage, agentPickOf, agentQuestions, agentState, baseline, costOf, isShown, move, totals, trim, usdOrUnknown } from './agents'
16import type { Ladder } from './agents'
17import { choicesUpTo, decide, effortOf, label, limitOf, mainPickOf, mainQuestions, mainState, meterOf, segments, tierOf, usd } from './pick'
18import type { Decision, Pick, Seg, Style } from './pick'
19import { CLAUDE_PICK_MODEL, JEV_URL, OPENAI_URL, PICKER_LABEL, claudeAsk, jevBody, openaiBody, parseClaudeAnswers, parseJevAnswers, parseOpenaiAnswers, pickerOf } from './pickers'
20import type { Answered, PickerName, Question } from './pickers'
21
22const PICK_TIMEOUT_MS = 3_000 // the turn waits this long for Jev at most
23const MIN_CONFIDENCE = 0.5 // below this, a pick is shown but not applied
24const SUBAGENT_STALE_MS = 15 * 60_000 // a subagent silent this long leaves the line
25const ENABLED = 'enabled' // in $.store, so off stays off across sessions
26const AGENT_PICK_TIMEOUT_MS = 4_000 // a spawn waits this long for the picker at most
27const CLAUDE_PICK_TIMEOUT_MS = 5_000 // Claude answers through a full model call, so it gets longer
28const ROUTE_PANE = 'auto-model-route'
29const BAND_ROWS = 3
30const PANE_ROWS = 12
31
32// Held by the host, so a hot reload keeps them; the line redraws when `line` is written.
33const stats = atom({ plugin: 'auto-model', key: 'stats' } as const, { turns: {}, switched: 0, jevUsd: 0 } as Stats)
34const line = atom({ plugin: 'auto-model', key: 'line' } as const, { subagents: {} } as Line)
35const routes = atom({ plugin: 'auto-model', key: 'routes' } as const, [] as Route[])
36const now = atom({ plugin: 'auto-model', key: 'now' } as const, 0)
37
38// The module's own: a reload loses it, which costs at most one turn's pick.
39type Live = {
40 style: Style
41 switchDownUntil: number
42 picker: PickerName
43 configuredKey: string // the TypeSafe key set in /config
44 openaiKey: string // the OpenAI key set in /config
45 agents: 'auto' | 'suggest' | 'off' // what happens to a subagent's model
46 ladder: Ladder
47 keepNamed: boolean
48 picks: Map<string, { pick: Pick | null; noPick?: string }>
49 decisions: Map<string, Decision>
50 previous?: string // what the last main turn ran on
51 previousEffort?: string // and at what effort
52}
53
54export const register: Register = (on, options) => {
55 const live: Live = {
56 style: options.style === 'quality' ? 'quality' : 'saver',
57 switchDownUntil: limitOf(options.switchDownUntil),
58 picker: pickerOf(options.picker),
59 configuredKey: typeof options.jevApiKey === 'string' ? options.jevApiKey.trim() : '',
60 openaiKey: typeof options.openaiApiKey === 'string' ? options.openaiApiKey.trim() : '',
61 agents: options.agents === 'suggest' || options.agents === 'off' ? options.agents : 'auto',
62 ladder: options.agentModels === 'sonnet-opus-fable' ? 'sonnet-opus-fable' : 'haiku-sonnet-opus',
63 keepNamed: options.keepNamed !== false,
64 picks: new Map(),
65 decisions: new Map(),
66 }
67
68 on('session.start', async ($, e, next) => {
69 const r = await next(e)
70 $.ui.status(undefined) // the plain line 0.1.0 pinned; the colored one below the hint replaces it
71 await $.command.register({ name: 'auto-model', description: 'Turn auto model picking on or off, or show what it picked', argumentHint: '[on|off]' }).catch(() => {}) // a name already taken is refused: start anyway
72 await $.command.register({ name: 'route', description: 'Show which model auto-model picked for each subagent, and what it cost' }).catch(() => {})
73 // Redraws the band so a finished subagent leaves it after 30 s; quiet when nothing is shown.
74 $.clock.every(5_000, () => void tickBand($).catch(() => {}))
75 const model = await $.session.model().catch(() => undefined)
76 const note: Note = { kind: (await isOn($)) ? 'on' : 'off' }
77 await setLine($, l => (model && l.main?.phase !== 'running' ? { ...l, main: { ...l.main, model, note, phase: 'start' } } : l))
78 await refreshMeter($)
79 return r
80 })
81
82 on('session.end', async ($, e, next) => {
83 if (e.reason === 'clear') {
84 live.previous = undefined
85 live.previousEffort = undefined
86 await setLine($, l => ({ ...l, subagents: {} }))
87 }
88 return next(e)
89 })
90
91 // Before a subagent starts: the picker chooses its model from the ladder, by its type and label alone.
92 on('agent.spawn', async ($, e, next) => {
93 if (live.agents === 'off' || e.fork || e.isTeammate) return next(e) // a fork runs on its parent's model; a teammate keeps the one it was given
94 if (!(await isOn($))) return next(e) // /auto-model off leaves subagents alone too
95 const asked = baseline(e)
96 const named = live.keepNamed && !!e.model && e.model !== 'inherit'
97 const pick: AgentPick = named ? { by: 'none', reason: 'named by the caller, kept' } : await pickForAgent($, live, e).catch(() => ({ by: 'none' as const, reason: 'picker failed, kept' }))
98 const sure = pick.confidence === undefined || pick.confidence >= MIN_CONFIDENCE
99 const apply = live.agents === 'auto' && sure && !!pick.tier && asked !== undefined && pick.tier !== tierOf(asked)
100 const r = await next(apply ? { ...e, model: pick.tier } : e)
101 if (r.deny !== undefined) return r
102 const one: Route = {
103 id: e.tool_use_id,
104 agentId: r.agentId,
105 description: e.description || e.name || e.subagentType,
106 type: e.subagentType,
107 asked,
108 picked: pick.tier,
109 by: pick.by,
110 confidence: pick.confidence,
111 reason: sure ? pick.reason : `${pick.reason}, too unsure to switch`,
112 applied: apply,
113 status: 'running',
114 startedAt: Date.now(),
115 model: r.model,
116 pickerUsd: pick.pickerUsd,
117 }
118 await update($, routes, list => trim([one, ...list.filter(x => x.id !== one.id)])).catch(() => {})
119 if (pick.pickerUsd) await update($, stats, s => ({ ...s, jevUsd: s.jevUsd + (pick.pickerUsd ?? 0) })).catch(() => {})
120 return r
121 })
122
123 // Asks Jev before the turn's first request, so every request of the turn can name the pick.
124 on('turn.start', async ($, e, next) => {
125 live.picks.set(e.turnId, await pickFor($, live, e.text))
126 return next(e)
127 })
128
129 on('turn.step', async function* ($, e, next) {
130 // A subagent's request: shown, never changed.
131 if (e.agentId) {
132 const id = e.agentId
133 await setLine($, l => ({ ...l, subagents: { ...l.subagents, [id]: { model: e.model, at: Date.now() } } }))
134 const r = yield* next(e)
135 const ran = r.usage?.model
136 if (ran) await setLine($, l => (l.subagents[id] ? { ...l, subagents: { ...l.subagents, [id]: { model: ran, at: Date.now() } } } : l))
137 return r
138 }
139 let d = live.decisions.get(e.turnId)
140 if (!d) {
141 d = await firstStep($, live, e.turnId, e.model, e.effort)
142 live.decisions.set(e.turnId, d)
143 }
144 const model = d.rewrite && tierOf(e.model) !== d.tier ? d.rewrite : e.model
145 const effort = d.effortRewrite && effortOf(e.effort) && e.effort !== d.effortRewrite ? d.effortRewrite : e.effort
146 const main = { model, note: d.note, phase: 'running' as const, ...(typeof effort === 'string' ? { effort, effortNote: d.effortNote ?? ('kept' as const) } : {}) }
147 await setLine($, l => ({ ...l, main }))
148 const r = yield* next(model === e.model && effort === e.effort ? e : { ...e, model, effort })
149 const ran = r.usage?.model // the API's word on what answered
150 if (ran && ran !== model) await setLine($, l => (l.main ? { ...l, main: { ...l.main, model: ran } } : l))
151 await refreshMeter($)
152 return r
153 })
154
155 on('turn.complete', async ($, e, next) => {
156 const r = await next(e)
157 if (e.agentId) {
158 const id = e.agentId
159 await setLine($, l => {
160 const { [id]: _, ...rest } = l.subagents
161 return { ...l, subagents: rest }
162 })
163 await recordAgentTurn($, id, e.reason === 'error' || e.reason === 'aborted', r.usage)
164 return r
165 }
166 live.picks.delete(e.turnId)
167 const stepped = live.decisions.delete(e.turnId)
168 const main = (await read($, line)).main
169 if (stepped && main) {
170 const ran = e.usage?.model ?? main.model
171 live.previous = ran
172 live.previousEffort = main.effort
173 await setLine($, l => ({ ...l, main: { ...main, model: ran, phase: 'done' } }))
174 const l = label(ran)
175 await update($, stats, s => ({ ...s, turns: { ...s.turns, [l]: (s.turns[l] ?? 0) + 1 } })).catch(() => {})
176 }
177 await refreshMeter($)
178 return r
179 })
180
181 // The bar under the prompt, drawn above the engine's own hint line.
182 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
183 const hint = await next(e)
184 const { main, meter, subagents } = await read($, line)
185 const now = Date.now()
186 const running = Object.values(subagents)
187 .filter(s => now - s.at < SUBAGENT_STALE_MS)
188 .map(s => s.model)
189 const { Box, Text } = $.ui.resolve(e)
190 return (
191 <Box flexDirection="column">
192 <Text wrap="truncate-end">
193 {segments({ main, meter }, running).map(s => (
194 <Text {...style(s)}>{s.text}</Text>
195 ))}
196 </Text>
197 {hint}
198 </Box>
199 )
200 })
201
202 // The subagents running now, or finished in the last 30 s, above the prompt.
203 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
204 const rest = await next(e) // what other mods and Claude Code draw here stays
205 await read($, now) // subscribes the band to the clock
206 const shown = (await read($, routes)).filter(r => isShown(r))
207 if (e.props.hasSurvey || shown.length === 0) return rest
208 const { Box, Text } = $.ui.resolve(e)
209 const mode = live.agents === 'suggest' ? 'suggest' : 'auto'
210 return (
211 <Box flexDirection="column">
212 {shown.slice(0, BAND_ROWS).map(r => (
213 <Text wrap="truncate-end">
214 <Text color={routeColor(r)} bold>{` ⇄ ${r.description}`}</Text>
215 <Text>{` ${move(r, mode)}`}</Text>
216 <Text dimColor>{` ${source(r)}${r.status === 'running' ? '' : ` · ${usdOrUnknown(r.costUsd)}`}`}</Text>
217 </Text>
218 ))}
219 {shown.length > BAND_ROWS && <Text dimColor>{` +${shown.length - BAND_ROWS} more · /route`}</Text>}
220 {rest}
221 </Box>
222 )
223 })
224
225 on('command.run', { command: 'route' }, async $ => {
226 await $.ui.open({ id: ROUTE_PANE, title: 'Subagent models', focus: true })
227 const t = totals(await read($, routes))
228 return { text: `${t.count} subagents, ${t.switched} switched · ${usdOrUnknown(t.cost)} spent, ${usdOrUnknown(t.asked)} at the asked models` }
229 })
230
231 on('ui.render', { component: 'Pane', requestId: ROUTE_PANE }, async ($, e) => {
232 const { Box, Text, Button } = $.ui.resolve(e)
233 const list = await read($, routes)
234 const t = totals(list)
235 const keyless = live.agents !== 'off' && !(await keyOf($, live))
236 const name = PICKER_LABEL[live.picker]
237 const mode = live.agents === 'suggest' ? 'suggest' : 'auto'
238 return (
239 <Box flexDirection="column">
240 <Text bold>{`${t.count} subagents · ${t.switched} switched · subagents: ${live.agents} · picker ${name}${keyless ? ` (${noKey(live.picker)}, nothing changes)` : ''}`}</Text>
241 <Text>
242 <Text>{`spent ${usdOrUnknown(t.cost)}`}</Text>
243 <Text dimColor>{` · at the asked models ${usdOrUnknown(t.asked)} · picks ${usdOrUnknown(t.picker)}`}</Text>
244 </Text>
245 <Text dimColor>{`API price estimates as of ${PRICES_AS_OF}, not plan charges. ? = not known yet.`}</Text>
246 <Text dimColor>{'─'.repeat(Math.max(10, e.props.bodyColumns - 2))}</Text>
247 {list.length === 0 && <Text dimColor>No subagents yet this session.</Text>}
248 {list.slice(0, PANE_ROWS).map(r => (
249 <Box flexDirection="column">
250 <Text wrap="truncate-end">
251 <Text color={routeColor(r)} bold>{`${routeIcon(r)} ${r.description}`}</Text>
252 <Text>{` ${move(r, mode)}`}</Text>
253 <Text dimColor>{` ${r.status === 'running' ? 'running' : `${usdOrUnknown(r.costUsd)} (asked ${usdOrUnknown(r.askedUsd)})`}`}</Text>
254 </Text>
255 <Text dimColor wrap="truncate-end">{` ${r.type} · ${r.by === 'none' ? r.reason : `${source(r)} · ${r.reason}`}${r.model ? ` · ran on ${r.model}` : ''}`}</Text>
256 </Box>
257 ))}
258 {list.length > PANE_ROWS && <Text dimColor>{`… ${list.length - PANE_ROWS} older`}</Text>}
259 <Box flexDirection="row" gap={1}>
260 <Button key="clear" label="Clear finished" hotkey="c" onPress={() => update($, routes, all => all.filter(x => x.status === 'running'))} />
261 <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: ROUTE_PANE })} />
262 </Box>
263 </Box>
264 )
265 })
266
267 on('command.run', { command: 'auto-model' }, async ($, e) => {
268 const arg = e.args.trim().toLowerCase()
269 if (arg === 'on' || arg === 'off') {
270 await $.store.set(ENABLED, arg === 'on')
271 const note: Note = { kind: arg }
272 await setLine($, l => (l.main && l.main.phase !== 'running' ? { ...l, main: { ...l.main, note } } : l))
273 return { text: arg === 'on' ? `auto-model on: ${PICKER_LABEL[live.picker]} picks the main model for each prompt.` : 'auto-model off: prompts and subagents run on the models they were given.' }
274 }
275 const s = await read($, stats)
276 const turns = Object.entries(s.turns).sort((a, b) => b[1] - a[1]).map(([m, n]) => `${m} ×${n}`).join(', ') || 'none yet'
277 const keyless = !(await keyOf($, live))
278 const limit = Number.isFinite(live.switchDownUntil) ? `${live.switchDownUntil / 1000}k` : 'any'
279 return {
280 text: [
281 `auto-model ${(await isOn($)) ? 'on' : 'off'} · picker ${PICKER_LABEL[live.picker]}${keyless ? ` (${noKey(live.picker)}: nothing changes)` : ''} · style ${live.style} · switches down until ${limit} context tokens`,
282 `turns this session: ${turns} · ${s.switched} on a cheaper model · ${s.lowered ?? 0} at lower effort · picks ${usd(s.jevUsd)}`,
283 `subagents: ${live.agents} · ${agentSummary(await read($, routes))} · /route for each`,
284 ].join('\n'),
285 }
286 })
287}
288
289// A segment's style as Text props, leaving out what it does not set.
290function style(s: Seg) {
291 return {
292 ...(s.color ? { color: s.color } : {}),
293 ...(s.bg ? { backgroundColor: s.bg } : {}),
294 ...(s.bold ? { bold: true } : {}),
295 ...(s.dim ? { dimColor: true } : {}),
296 ...(s.strike ? { strikethrough: true } : {}),
297 }
298}
299
300// The context and 7-day figures, read after each request and turn.
301async function refreshMeter($: EngineInterface) {
302 const meter = meterOf(await $.session.usage().catch(() => undefined))
303 if (meter) await setLine($, l => ({ ...l, meter }))
304}
305
306async function setLine($: EngineInterface, fn: (l: Line) => Line) {
307 await update($, line, l => fn(l ?? { subagents: {} })).catch(() => {})
308}
309
310async function isOn($: EngineInterface) {
311 return (await $.store.get(ENABLED).catch(() => undefined)) !== false
312}
313
314// The chosen picker's key: the one set in /config, else the environment's. Claude needs none: it
315// answers through the session's own client.
316async function keyOf($: EngineInterface, live: Live) {
317 if (live.picker === 'claude') return 'session'
318 if (live.picker === 'openai') return live.openaiKey || ((await $.env.get('OPENAI_API_KEY').catch(() => undefined)) ?? '').trim()
319 return live.configuredKey || ((await $.env.get('TYPESAFE_API_KEY').catch(() => undefined)) ?? '').trim()
320}
321
322function noKey(picker: PickerName) {
323 return picker === 'openai' ? 'no OpenAI key' : 'no TypeSafe key'
324}
325
326// Asks the chosen picker the questions over the state; its answers when it answers in time,
327// otherwise null and nothing changes.
328async function askPicker($: EngineInterface, live: Live, key: string, state: object, questions: Question[], timeoutMs: number): Promise<Answered | null> {
329 if (live.picker === 'claude') {
330 const { system, prompt } = claudeAsk(state, questions)
331 const r = await $.model.complete({ model: CLAUDE_PICK_MODEL, system, prompt, maxTokens: 300, effort: 'low', timeoutMs: CLAUDE_PICK_TIMEOUT_MS }).catch(() => null)
332 return r?.isAnswered ? parseClaudeAnswers(r.text, questions, r.usage) : null
333 }
334 const openai = live.picker === 'openai'
335 const timeout = $.clock.sleep(timeoutMs).then(
336 () => null,
337 () => null,
338 )
339 const answer = $.http
340 .fetch(openai ? OPENAI_URL : JEV_URL, {
341 method: 'POST',
342 headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
343 body: JSON.stringify(openai ? openaiBody(state, questions) : jevBody(state, questions)),
344 })
345 .then(r => (r.ok ? (openai ? parseOpenaiAnswers : parseJevAnswers)(r.text, questions) : null))
346 .catch(() => null)
347 return Promise.race([answer, timeout])
348}
349
350async function pickFor($: EngineInterface, live: Live, text: string): Promise<{ pick: Pick | null; noPick?: string }> {
351 if (!(await isOn($))) return { pick: null, noPick: 'off' }
352 if (!text.trim()) return { pick: null, noPick: 'no prompt to judge' }
353 const base = (await $.session.model().catch(() => undefined)) ?? ''
354 const ceiling = tierOf(base)
355 if (!ceiling) return { pick: null, noPick: 'a model it does not know' }
356 if (ceiling === 'haiku') return { pick: null, noPick: 'nothing below Haiku' }
357 const key = await keyOf($, live)
358 if (!key) return { pick: null, noPick: noKey(live.picker) }
359 const model = live.previous ?? base
360 await setLine($, l => ({ ...l, main: { model, note: { kind: 'asking', by: live.picker }, phase: 'running' } }))
361 const choices = choicesUpTo(ceiling)
362 const state = mainState(text, { tier: tierOf(live.previous), effort: effortOf(live.previousEffort) })
363 const pick = mainPickOf(await askPicker($, live, key, state, mainQuestions(choices, live.style), PICK_TIMEOUT_MS), choices)
364 if (!pick) return { pick: null, noPick: `${PICKER_LABEL[live.picker]} did not answer` }
365 await update($, stats, s => ({ ...s, jevUsd: s.jevUsd + pick.costUsd })).catch(() => {})
366 return { pick }
367}
368
369// The turn's decision, made once at its first request, from the model the engine resolved.
370async function firstStep($: EngineInterface, live: Live, turnId: string, model: string, effort: unknown): Promise<Decision> {
371 const p = live.picks.get(turnId)
372 live.picks.delete(turnId)
373 const asIs = effortOf(effort) ? { effort: effortOf(effort), effortNote: 'kept' as const } : {}
374 if (p?.noPick === 'off') return { tier: tierOf(model), base: model, note: { kind: 'off' }, ...asIs }
375 if (!p) return { tier: tierOf(model), base: model, note: { kind: 'none', reason: 'no pick for this turn' }, ...asIs }
376 const usage = await $.session.usage().catch(() => undefined)
377 const d = decide({
378 base: model,
379 baseEffort: effort,
380 previous: live.previous,
381 previousEffort: live.previousEffort,
382 pick: p.pick,
383 noPick: p.noPick,
384 contextTokens: usage?.context.tokens,
385 switchDownUntil: live.switchDownUntil,
386 minConfidence: MIN_CONFIDENCE,
387 by: live.picker,
388 })
389 if (d.rewrite || d.effortRewrite) await update($, stats, s => ({ ...s, switched: s.switched + (d.rewrite ? 1 : 0), lowered: (s.lowered ?? 0) + (d.effortRewrite ? 1 : 0) })).catch(() => {})
390 return d
391}
392
393type AgentPick = { tier?: Tier; by: Route['by']; confidence?: number; reason: string; pickerUsd?: number }
394
395// The picker's choice for a subagent when it answers in time; otherwise no pick, and nothing changes.
396async function pickForAgent($: EngineInterface, live: Live, e: { subagentType: string; description: string }): Promise<AgentPick> {
397 const key = await keyOf($, live)
398 if (!key) return { by: 'none', reason: noKey(live.picker) }
399 const name = PICKER_LABEL[live.picker]
400 const got = agentPickOf(await askPicker($, live, key, agentState(e), agentQuestions(live.ladder, live.style), AGENT_PICK_TIMEOUT_MS), LADDERS[live.ladder])
401 if (!got) return { by: 'none', reason: `${name} did not answer` }
402 return { tier: got.tier, by: live.picker, confidence: got.confidence, reason: `${name} picked ${got.tier}`, pickerUsd: got.costUsd }
403}
404
405// One subagent turn's tokens, counted once: a turn.complete is one turn.
406async function recordAgentTurn($: EngineInterface, id: string, failed: boolean, usage: (Route['usage'] & { model?: string }) | undefined) {
407 await update($, routes, list =>
408 list.map(x => {
409 if (x.agentId !== id) return x
410 const total = usage ? addUsage(x.usage, usage) : x.usage
411 // What it ran on: the API's word, else the switch we made, else what was asked.
412 const model = usage?.model ?? x.model ?? (x.applied ? x.picked : x.asked) ?? x.picked ?? ''
413 return {
414 ...x,
415 status: failed ? 'failed' : 'done',
416 endedAt: Date.now(),
417 model,
418 usage: total,
419 costUsd: total ? costOf(model, total) : x.costUsd,
420 // Not switched, it ran on what was asked; switched, the same tokens at the asked model's prices.
421 askedUsd: !total ? x.askedUsd : x.applied && x.asked ? costOf(x.asked, total) : costOf(model, total),
422 }
423 }),
424 ).catch(() => {})
425}
426
427async function tickBand($: EngineInterface) {
428 if ((await read($, routes)).some(r => isShown(r, Date.now() - 5_000))) await update($, now, () => Date.now())
429}
430
431function agentSummary(list: Route[]) {
432 const t = totals(list)
433 return `${t.count} this session, ${t.switched} switched`
434}
435
436function source(r: Route) {
437 return r.by === 'none' ? r.reason : `${r.by} ${Math.round((r.confidence ?? 0) * 100)}% sure`
438}
439
440function routeIcon(r: Route) {
441 return r.status === 'running' ? '●' : r.status === 'done' ? '✓' : '✗'
442}
443
444function routeColor(r: Route) {
445 return r.status === 'running' ? 'yellow' : r.status === 'done' ? 'green' : 'red'
446}
447hooks/agents.ts 156 lines1// The subagent half, kept free of the engine so tests call it directly: which model a
2// subagent should run on, what Jev is sent to decide it, and what each run cost.
3// Ported from Hamza Zafar's switchboard 0.3.1 (MIT, see NOTICE.md), with Jev as the only picker.
4import type { Route, Tier, Usage } from '../types'
5import { tierOf } from './pick'
6import type { Style } from './pick'
7import { jevBody, parseJevAnswers } from './pickers'
8import type { Answered, Question } from './pickers'
9
10// The models Jev may choose between for a subagent, cheapest first. A ladder is a setting.
11export type Ladder = 'haiku-sonnet-opus' | 'sonnet-opus-fable'
12export const LADDERS: Record<Ladder, readonly Tier[]> = {
13 'haiku-sonnet-opus': ['haiku', 'sonnet', 'opus'],
14 'sonnet-opus-fable': ['sonnet', 'opus', 'fable'],
15}
16
17const QUESTION: Record<Style, string> = {
18 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?',
19 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.',
20}
21const DESCRIBE: Record<Style, Record<Ladder, Partial<Record<Tier, string>>>> = {
22 saver: {
23 'haiku-sonnet-opus': {
24 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.',
25 sonnet: 'Everyday coding: implementing a feature, fixing an ordinary bug, writing or updating tests, reviewing a diff, editing a few files.',
26 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.',
27 },
28 'sonnet-opus-fable': {
29 sonnet: 'Everything routine: lookups, reading, summarizing, research, ordinary coding, tests, reviews and verification of ordinary diffs, docs, small refactors, ordinary bug fixes.',
30 opus: 'Hard thinking: planning and architecture, subtle or intermittent bugs, security reviews, large multi-file refactors, work where a wrong answer is costly.',
31 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.',
32 },
33 },
34 quality: {
35 'haiku-sonnet-opus': {
36 haiku: 'Only trivial lookups: listing or finding files, reading one file, a quick factual check.',
37 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.',
38 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.',
39 },
40 'sonnet-opus-fable': {
41 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.',
42 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.',
43 fable: 'Rare: long autonomous runs left alone for hours, or research-grade problems where even Opus is likely to fall short.',
44 },
45 },
46}
47
48// API prices in USD per million tokens. Estimates, not plan charges.
49export const PRICES_AS_OF = '2026-10-06'
50const PRICES: { match: RegExp; input: number; output: number; cacheRead: number }[] = [
51 { match: /haiku-5/i, input: 0.1, output: 0.5, cacheRead: 0.01 },
52 { match: /haiku/i, input: 1, output: 5, cacheRead: 0.1 },
53 { match: /sonnet/i, input: 2, output: 10, cacheRead: 0.2 },
54 { match: /opus/i, input: 4, output: 20, cacheRead: 0.2 },
55 { match: /fable|mythos/i, input: 10, output: 50, cacheRead: 0.25 },
56]
57// Only the agent type and its short label go out: never the task text, which can hold code,
58// paths, hostnames or a pasted key.
59type Task = { subagentType: string; description: string }
60
61export function agentState(task: Task) {
62 return { agent_type: task.subagentType, description: task.description }
63}
64
65// One question: which model on the ladder.
66export function agentQuestions(ladder: Ladder, style: Style): Question[] {
67 const describe = DESCRIBE[style][ladder]
68 return [{ name: 'tier', instructions: QUESTION[style], criteria: Object.fromEntries(LADDERS[ladder].map(t => [t, describe[t] ?? t])) }]
69}
70
71// The question in Jev's shape.
72export function agentRequest(task: Task, ladder: Ladder, style: Style) {
73 return jevBody(agentState(task), agentQuestions(ladder, style))
74}
75
76export type AgentAnswer = { tier: Tier; confidence: number; costUsd: number }
77
78// Any picker's answer as a model on the ladder, or null.
79export function agentPickOf(a: Answered | null, tiers: readonly Tier[]): AgentAnswer | null {
80 const t = a?.answers.tier
81 if (!a || !t || !tiers.includes(t.choice as Tier)) return null
82 return { tier: t.choice as Tier, confidence: t.confidence, costUsd: a.costUsd }
83}
84
85// Jev's answer, or null when it is not one we can use.
86export function parseAgentPick(text: string, tiers: readonly Tier[]): AgentAnswer | null {
87 return agentPickOf(parseJevAnswers(text, [{ name: 'tier', instructions: '', criteria: Object.fromEntries(tiers.map(t => [t, t])) }]), tiers)
88}
89
90// The model the spawn would run on without us, when we can know it: what the caller named,
91// or the parent's for an agent that inherits. Undefined when the agent's own definition
92// decides, which a hook can't see; such a spawn gets a suggestion, never a switch.
93export function baseline(e: { model?: string; parentModel: string; subagentType: string }): string | undefined {
94 if (e.model && e.model !== 'inherit') return e.model
95 if (e.model === 'inherit' || e.subagentType === 'general-purpose') return e.parentModel
96 return undefined
97}
98
99// What a run cost at API prices; undefined for a model we have no price for.
100export function costOf(model: string, u: Usage): number | undefined {
101 const p = PRICES.find(x => x.match.test(model))
102 if (!p) return undefined
103 const write = p.input * 1.25 // 5-minute cache writes
104 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
105}
106
107export function addUsage(a: Usage | undefined, b: Usage): Usage {
108 if (!a) return { ...b }
109 return {
110 input_tokens: a.input_tokens + b.input_tokens,
111 output_tokens: a.output_tokens + b.output_tokens,
112 cache_read_input_tokens: a.cache_read_input_tokens + b.cache_read_input_tokens,
113 cache_creation_input_tokens: a.cache_creation_input_tokens + b.cache_creation_input_tokens,
114 }
115}
116
117// The newest 50, but never a running one: its cost and status are still to come.
118export function trim(list: Route[]) {
119 return list.filter((r, i) => i < 50 || r.status === 'running')
120}
121
122// Running, or finished in the last 30 s.
123export function isShown(r: Route, at = Date.now()) {
124 return r.status === 'running' || (r.endedAt !== undefined && at - r.endedAt < 30_000)
125}
126
127// "opus → sonnet", "sonnet (kept)", "opus · try sonnet" in suggest mode, or, with no pick, the
128// model it runs on.
129export function move(r: Route, mode: 'auto' | 'suggest') {
130 const from = r.asked === undefined ? 'own model' : (tierOf(r.asked) ?? r.asked)
131 if (!r.picked) return from
132 if (r.asked === undefined) return `own model · try ${r.picked}`
133 if (r.applied) return `${from} → ${r.picked}`
134 if (tierOf(r.asked) === r.picked) return `${r.picked} (kept)`
135 return mode === 'suggest' ? `${from} · try ${r.picked}` : `${from} (kept)`
136}
137
138export function totals(list: Route[]) {
139 const sum = (f: (r: Route) => number | undefined) => list.reduce((n, r) => n + (f(r) ?? 0), 0)
140 return {
141 count: list.length,
142 switched: list.filter(r => r.applied).length,
143 cost: sum(r => r.costUsd) + sum(r => r.pickerUsd),
144 asked: sum(r => r.askedUsd),
145 picker: sum(r => r.pickerUsd),
146 }
147}
148
149// A cost, or "?" while it is not known yet.
150export function usdOrUnknown(n: number | undefined): string {
151 if (n === undefined) return '?'
152 if (n === 0) return '$0'
153 if (n < 0.01) return '<$0.01'
154 return `$${n.toFixed(2)}`
155}
156hooks/pick.ts 271 lines1// The deciding half, kept free of the engine so tests call it directly: what goes to Jev,
2// how its answer is read, which model and effort a turn runs on, and the line under the prompt.
3import type { Effort, EffortNote, Main, Meter, Note, Picker, Tier } from '../types'
4import { PICKER_LABEL, jevBody, parseJevAnswers } from './pickers'
5import type { Answered, Question } from './pickers'
6
7export { JEV_URL } from './pickers'
8
9export const TIERS: readonly Tier[] = ['haiku', 'sonnet', 'opus', 'fable']
10export const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
11export const MODEL_ID: Record<Tier, string> = {
12 haiku: 'claude-haiku-5-5',
13 sonnet: 'claude-sonnet-5-5',
14 opus: 'claude-opus-5-5',
15 fable: 'claude-fable-5-1',
16}
17const PROMPT_CHARS = 1_500 // what a picker reads of a prompt at most
18
19export type Style = 'saver' | 'quality'
20const FOLLOW_UP =
21 'A short follow-up that approves or continues earlier work ("yes", "go ahead", "do it", "continue", "fix that") continues that work: pick previous_turn_model and previous_turn_effort for it.'
22const QUESTION: Record<Style, string> = {
23 saver: `A developer sent this prompt to their coding agent. Which is the cheapest model that will still do this turn well? ${FOLLOW_UP}`,
24 quality: `A developer sent this prompt to their coding agent. They pay for quality on anything they will rely on and use a cheaper model only for quick lookups and routine chores. Which model fits this turn? ${FOLLOW_UP}`,
25}
26const EFFORT_QUESTION: Record<Style, string> = {
27 saver: `How much should the model think on this turn? Pick the lowest effort that will still do it well. ${FOLLOW_UP}`,
28 quality: `How much should the model think on this turn? Think harder on anything the developer will rely on; keep it low only for quick lookups and chores. ${FOLLOW_UP}`,
29}
30const DESCRIBE: Record<Tier, string> = {
31 haiku: 'Quick questions and lookups: a fact, a count, a status check, reading or summarizing something short, a one-line change, a rename, one simple shell command.',
32 sonnet: 'Everyday coding and writing: implementing a planned change, fixing an ordinary bug, writing tests, editing a few files, drafting a message or a doc.',
33 opus: 'Hard or high-stakes work: planning, architecture, debugging something subtle, reviews and audits, building something new end to end, anything where a wrong answer is costly.',
34 fable: 'The hardest few: long autonomous work, research-grade problems, or decisions where even Opus is likely to fall short. Costs 2.5x Opus.',
35}
36const DESCRIBE_EFFORT: Record<Effort, string> = {
37 low: 'Answer straight away: a fact, a count, a lookup, a one-line edit, one simple command. Little or no thinking.',
38 medium: 'Some thought: ordinary coding, a small feature, a bug with a clear cause, a short document.',
39 high: 'Careful thought: multi-step work, unfamiliar code, a tricky bug, a review that has to catch problems.',
40 xhigh: 'Deep thought: planning or architecture, a subtle or intermittent bug, a large refactor, a high-stakes decision.',
41 max: 'The hardest problems, where getting it right matters far more than time or cost.',
42}
43
44export function tierOf(model: string | undefined): Tier | undefined {
45 if (!model) return undefined
46 const m = model.toLowerCase()
47 return TIERS.find(t => m.includes(t))
48}
49
50export function effortOf(v: unknown): Effort | undefined {
51 return EFFORTS.find(x => x === v)
52}
53
54const rank = (t: Tier) => TIERS.indexOf(t)
55const rankE = (x: Effort) => EFFORTS.indexOf(x)
56
57// "claude-opus-5-5" → "Opus 5.5", "claude-haiku-4-5-20251001" → "Haiku 4.5"; anything else as given.
58export function label(model: string | undefined): string {
59 if (!model) return '?'
60 const m = /(haiku|sonnet|opus|fable|mythos)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(model)
61 if (!m?.[1]) return model
62 const name = m[1].charAt(0).toUpperCase() + m[1].slice(1).toLowerCase()
63 return m[3] ? `${name} ${m[2]}.${m[3]}` : `${name} ${m[2]}`
64}
65
66// Keys, tokens and other long secrets are cut from a prompt before it leaves the machine.
67export function redact(text: string): string {
68 return text
69 .replace(/\b(sk-[\w-]{16,}|ghp_\w{20,}|github_pat_\w{20,}|AKIA[0-9A-Z]{16}|xox[abpr]-[\w-]{10,}|eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{10,})/g, '[redacted]')
70 .replace(/[A-Za-z0-9+/_=-]{32,}/g, s => (/\d/.test(s) && /[A-Za-z]/.test(s) ? '[redacted]' : s))
71}
72
73// The models Jev may choose between: every tier up to the one /model set.
74export function choicesUpTo(ceiling: Tier): Tier[] {
75 return TIERS.filter(t => rank(t) <= rank(ceiling))
76}
77
78// What a picker reads for a prompt: the prompt (secrets cut, its first 1,500 characters) and
79// what the last turn ran on.
80export function mainState(text: string, previous: { tier?: Tier; effort?: Effort }) {
81 return { prompt: redact(text).slice(0, PROMPT_CHARS), previous_turn_model: previous.tier ?? null, previous_turn_effort: previous.effort ?? null }
82}
83
84// Two questions: the model, and the effort (clamped to /effort's when the turn starts).
85export function mainQuestions(choices: readonly Tier[], style: Style): Question[] {
86 return [
87 { name: 'tier', instructions: QUESTION[style], criteria: Object.fromEntries(choices.map(t => [t, DESCRIBE[t]])) },
88 { name: 'effort', instructions: EFFORT_QUESTION[style], criteria: DESCRIBE_EFFORT },
89 ]
90}
91
92// The questions in Jev's shape.
93export function jevRequest(text: string, choices: readonly Tier[], previous: { tier?: Tier; effort?: Effort }, style: Style) {
94 return jevBody(mainState(text, previous), mainQuestions(choices, style))
95}
96
97export type Pick = { tier: Tier; confidence: number; effort?: Effort; effortSure?: number; costUsd: number }
98
99// Any picker's answers as a pick, or null without a model it may choose. Effort is optional.
100export function mainPickOf(a: Answered | null, choices: readonly Tier[]): Pick | null {
101 const t = a?.answers.tier
102 if (!a || !t || !choices.includes(t.choice as Tier)) return null
103 const e = a.answers.effort
104 const effort = effortOf(e?.choice)
105 return { tier: t.choice as Tier, confidence: t.confidence, ...(effort && e ? { effort, effortSure: e.confidence } : {}), costUsd: a.costUsd }
106}
107
108// Jev's answer, or null when it is not one we can use.
109export function parseJev(text: string, choices: readonly Tier[]): Pick | null {
110 return mainPickOf(parseJevAnswers(text, mainQuestions(choices, 'saver')), choices)
111}
112
113export type Decision = {
114 tier?: Tier // what the turn runs on; undefined for a model we don't know
115 rewrite?: string // the model id each request of the turn names instead; undefined leaves it
116 base: string // the model /model set, which a pick never goes above
117 pick?: Pick
118 note: Note // what the line says about the model
119 effort?: Effort // what the turn thinks at; undefined where the model takes no effort
120 effortRewrite?: Effort // what each request asks for instead; undefined leaves it
121 effortNote?: EffortNote
122}
123
124export type DecideInput = {
125 base: string
126 baseEffort?: unknown // the step's effort as the engine resolved it: /effort's, or the model's default
127 previous?: string // what the last main turn ran on
128 previousEffort?: string
129 pick: Pick | null
130 noPick?: string // why there is no pick
131 contextTokens?: number
132 switchDownUntil: number // context tokens; Infinity for always
133 minConfidence: number
134 by?: Picker // who picked, for the line
135}
136
137export function decide(i: DecideInput): Decision {
138 const ceiling = tierOf(i.base)
139 if (!ceiling) return { base: i.base, note: { kind: 'none', reason: 'a model it does not know' }, ...effortAsIs(i) }
140 if (!i.pick) return { tier: ceiling, base: i.base, note: { kind: 'none', reason: i.noPick ?? 'no pick' }, ...effortAsIs(i) }
141 const sure = i.pick.confidence
142 const big = (i.contextTokens ?? 0) > i.switchDownUntil
143 // Moving down re-reads the whole conversation without the cache: past the limit, stay put.
144 const prev = tierOf(i.previous) ?? ceiling
145 const heldAt = rank(prev) <= rank(ceiling) ? prev : ceiling
146 let tier = ceiling
147 const by = i.by ? { by: i.by } : {}
148 let note: Note = { kind: 'unsure', sure, ...by }
149 if (sure >= i.minConfidence) {
150 const wanted = rank(i.pick.tier) > rank(ceiling) ? ceiling : i.pick.tier
151 const isHeld = rank(wanted) < rank(heldAt) && big
152 tier = isHeld ? heldAt : wanted
153 note = isHeld ? { kind: 'held', sure, wanted: MODEL_ID[wanted], tokens: i.contextTokens ?? 0, ...by } : tier !== ceiling ? { kind: 'switched', from: i.base, sure, ...by } : { kind: 'kept', sure, ...by }
154 }
155 const rewrite = tier === ceiling ? undefined : MODEL_ID[tier]
156
157 // Effort the same way: /effort is the ceiling, and a change drops the cache too, so with a
158 // large context it is held where the last turn left it, unless the model changes anyway.
159 const top = effortOf(i.baseEffort)
160 if (!top) return { tier, rewrite, base: i.base, pick: i.pick, note }
161 const asked = i.pick.effort && (i.pick.effortSure ?? 0) >= i.minConfidence ? i.pick.effort : top
162 const wantedE = rankE(asked) > rankE(top) ? top : asked
163 const prevE = effortOf(i.previousEffort) ?? top
164 const heldE = rankE(prevE) <= rankE(top) ? prevE : top
165 const modelMoves = tier !== prev
166 const effortHeld = !modelMoves && rankE(wantedE) < rankE(heldE) && big
167 const effort = effortHeld ? heldE : wantedE
168 const effortNote: EffortNote = effortHeld ? 'held' : effort !== top ? 'auto' : 'kept'
169 return { tier, rewrite, base: i.base, pick: i.pick, note, effort, effortRewrite: effort === top ? undefined : effort, effortNote }
170}
171
172function effortAsIs(i: DecideInput): { effort?: Effort; effortNote?: EffortNote } {
173 const e = effortOf(i.baseEffort)
174 return e ? { effort: e, effortNote: 'kept' } : {}
175}
176
177export function limitOf(setting: unknown): number {
178 return setting === 'always' ? Infinity : setting === '250k' ? 250_000 : 100_000
179}
180
181// The figures beside the model, from `$.session.usage()`; the 7-day window exists only on a subscription.
182export function meterOf(u: { context?: { percent?: number }; rateLimits?: readonly { kind: string; percentUsed: number }[] } | undefined): Meter | undefined {
183 if (!u) return undefined
184 const sevenDay = u.rateLimits?.find(r => r.kind === 'seven_day')?.percentUsed
185 return {
186 ...(u.context?.percent !== undefined ? { ctx: u.context.percent } : {}),
187 ...(sevenDay !== undefined ? { sevenDay } : {}),
188 }
189}
190
191// One run of text on the line, styled. Colors are theme keys (light and dark alike) or hex.
192export type Seg = { text: string; color?: string; bg?: string; bold?: boolean; dim?: boolean; strike?: boolean }
193
194export const TIER_COLOR: Record<Tier, string> = { haiku: '#34d399', sonnet: '#60a5fa', opus: 'claude', fable: '#c084fc' }
195export const EFFORT_COLOR: Record<Effort, string> = { low: '#34d399', medium: '#60a5fa', high: '#fbbf24', xhigh: 'claude', max: '#f87171' }
196export function colorOf(model: string | undefined): string {
197 const t = tierOf(model)
198 return t ? TIER_COLOR[t] : 'inactive'
199}
200
201const pct = (n: number) => `${Math.round(n * 100)}%`
202const level = (percent: number) => (percent < 50 ? 'success' : percent < 80 ? 'warning' : 'error')
203const sureSeg = (sure: number, by?: Picker): Seg => ({ text: `${PICKER_LABEL[by ?? 'jev']} ${pct(sure)}`, color: sure >= 0.8 ? 'success' : sure >= 0.5 ? 'warning' : 'error' })
204const dot: Seg = { text: ' · ', dim: true }
205const SEP: Seg = { text: ' │ ', dim: true }
206const TROUBLE = /key|answer/
207
208// What auto-model did with the model, after its name.
209export function noteSegs(n: Note): Seg[] {
210 switch (n.kind) {
211 case 'on':
212 return [{ text: ' · auto', color: 'success' }]
213 case 'off':
214 return [{ text: ' · auto off', dim: true }]
215 case 'asking':
216 return [{ text: ` ⟳ ${PICKER_LABEL[n.by ?? 'jev']}…`, color: 'warning', bold: true }]
217 case 'switched':
218 return [{ text: ' ↓ ', color: 'success', bold: true }, { text: label(n.from), dim: true, strike: true }, dot, sureSeg(n.sure, n.by)]
219 case 'kept':
220 return [{ text: ' ✓ ', color: 'success', bold: true }, sureSeg(n.sure, n.by)]
221 case 'unsure':
222 return [{ text: ' ? ', color: 'warning', bold: true }, sureSeg(n.sure, n.by), { text: ' unsure', dim: true }]
223 case 'held':
224 return [{ text: ' ⏸ ', color: 'warning', bold: true }, { text: 'held, wanted ', dim: true }, { text: label(n.wanted), color: colorOf(n.wanted) }, dot, sureSeg(n.sure, n.by)]
225 case 'none':
226 return [{ text: ` · auto: ${n.reason}`, ...(TROUBLE.test(n.reason) ? { color: 'warning' } : { dim: true }) }]
227 }
228}
229
230// Ten cells, the filled ones in the meter's color.
231function bar(percent: number): Seg[] {
232 const filled = Math.max(percent > 0 ? 1 : 0, Math.min(10, Math.round(percent / 10)))
233 return [
234 { text: '▰'.repeat(filled), color: level(percent) },
235 { text: '▱'.repeat(10 - filled), dim: true },
236 ]
237}
238
239function agentSegs(subagents: readonly string[]): Seg[] {
240 if (subagents.length === 0) return [{ text: '◇ 0 agents', dim: true }]
241 const out: Seg[] = [{ text: '◆ ', color: 'warning' }, { text: `${subagents.length} agent${subagents.length > 1 ? 's' : ''}` }]
242 const counts = new Map<string, { model: string; n: number }>()
243 for (const m of subagents) counts.set(label(m), { model: m, n: (counts.get(label(m))?.n ?? 0) + 1 })
244 for (const [l, { model, n }] of counts) out.push({ text: ` ● ${l}${n > 1 ? ` ×${n}` : ''}`, color: colorOf(model) })
245 return out
246}
247
248// The bar under the prompt: model and what auto did │ effort │ ctx │ 7d │ agents.
249export function segments(line: { main?: Main; meter?: Meter }, subagents: readonly string[]): Seg[] {
250 const { main, meter } = line
251 const parts: Seg[][] = []
252 parts.push(main ? [{ text: '◆ ', color: colorOf(main.model) }, { text: label(main.model), bold: true }, ...noteSegs(main.note)] : [{ text: '◇ auto-model', dim: true }])
253 const effort = effortOf(main?.effort)
254 if (effort) {
255 const mark: Seg[] = main?.effortNote === 'auto' ? [{ text: ' ↓', color: 'success', bold: true }] : main?.effortNote === 'held' ? [{ text: ' ⏸', color: 'warning', bold: true }] : []
256 parts.push([{ text: 'effort ' }, { text: effort, color: EFFORT_COLOR[effort], bold: true }, ...mark])
257 }
258 if (meter?.ctx !== undefined) parts.push([{ text: 'ctx ' }, ...bar(meter.ctx), { text: ` ${Math.round(meter.ctx)}%`, color: level(meter.ctx), bold: true }])
259 if (meter?.sevenDay !== undefined) parts.push([{ text: '7d ' }, { text: `${Math.round(meter.sevenDay)}%`, color: level(meter.sevenDay), bold: true }])
260 parts.push(agentSegs(subagents))
261 return parts.flatMap((p, i) => (i === 0 ? p : [SEP, ...p]))
262}
263
264export const plain = (segs: readonly Seg[]) => segs.map(s => s.text).join('')
265
266export function usd(n: number): string {
267 if (n === 0) return '$0'
268 if (n < 0.01) return '<$0.01'
269 return `$${n.toFixed(2)}`
270}
271hooks/pickers.ts 131 lines1// Who picks, and how each picker is asked: the same multiple-choice questions over the same
2// state, sent in each API's own shape and read back into one form. Kept free of the engine so
3// tests call it directly.
4// Jev (TypeSafe) and OpenAI's Decisions API are decision models: they answer a choice with
5// probabilities and a confidence. Claude has no such endpoint, so it is asked through the
6// session's own client for JSON naming its choices and how sure it is, a self-rating rather
7// than a calibrated probability.
8import type { Usage } from '../types'
9
10export type PickerName = 'jev' | 'openai' | 'claude'
11export const PICKER_LABEL: Record<PickerName, string> = { jev: 'Jev', openai: 'OpenAI', claude: 'Claude' }
12
13export function pickerOf(setting: unknown): PickerName {
14 return setting === 'openai' || setting === 'claude' ? setting : 'jev'
15}
16
17export const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
18export const OPENAI_URL = 'https://api.openai.com/v1/decisions'
19const OPENAI_MODEL = 'gpt-6-luna'
20export const CLAUDE_PICK_MODEL = 'haiku' // an alias the session resolves, so it follows the current Haiku
21const USD_PER_INPUT_TOKEN: Record<'jev' | 'openai', number> = { jev: 0.042 / 1_000_000, openai: 0.1 / 1_000_000 } // neither bills output
22const CLAUDE_HAIKU = { input: 0.1 / 1_000_000, output: 0.5 / 1_000_000, cacheRead: 0.01 / 1_000_000 } // Haiku 5.5, as of 2026-10-06
23
24// One multiple-choice question: its name, what to weigh, and what each choice is for.
25export type Question = { name: string; instructions: string; criteria: Record<string, string> }
26
27// A picker's answers, by question name, each a valid choice; and what asking cost.
28export type Answer = { choice: string; confidence: number }
29export type Answered = { answers: Record<string, Answer>; costUsd: number }
30
31// ---------------------------------------------------------------- Jev
32
33export function jevBody(state: object, questions: readonly Question[]) {
34 return {
35 model: 'jev-latest',
36 state,
37 questions: Object.fromEntries(questions.map(q => [q.name, { type: 'choice', instructions: q.instructions, criteria: q.criteria }])),
38 }
39}
40
41// Jev keys its answers by question name, with a confidence or, failing that, probabilities.
42export function parseJevAnswers(text: string, questions: readonly Question[]): Answered | null {
43 const body = json(text)
44 if (!body) return null
45 const answers: Record<string, Answer> = {}
46 for (const q of questions) {
47 const a = body.answers?.[q.name]
48 if (!a || !isChoice(q, a.choice)) continue
49 const sure = typeof a.confidence === 'number' ? a.confidence : typeof a.probabilities?.[a.choice] === 'number' ? a.probabilities[a.choice] : 0
50 answers[q.name] = { choice: a.choice, confidence: sure }
51 }
52 const tokens = body.usage?.input_tokens ?? body.usage?.inputTokens
53 return { answers, costUsd: typeof tokens === 'number' ? tokens * USD_PER_INPUT_TOKEN.jev : 0 }
54}
55
56// ---------------------------------------------------------------- OpenAI
57
58export function openaiBody(state: object, questions: readonly Question[]) {
59 return {
60 model: OPENAI_MODEL,
61 input: JSON.stringify(state),
62 questions: questions.map(q => ({
63 type: 'choice',
64 name: q.name,
65 instructions: q.instructions,
66 choices: Object.entries(q.criteria).map(([value, description]) => ({ value, description })),
67 })),
68 }
69}
70
71// OpenAI lists its answers, each named, with probabilities as a list.
72export function parseOpenaiAnswers(text: string, questions: readonly Question[]): Answered | null {
73 const body = json(text)
74 if (!body || !Array.isArray(body.answers)) return null
75 const answers: Record<string, Answer> = {}
76 for (const q of questions) {
77 const a = body.answers.find((x: any) => x?.name === q.name)
78 if (!a || !isChoice(q, a.choice)) continue
79 const probability = Array.isArray(a.probabilities) ? a.probabilities.find((p: any) => p?.value === a.choice)?.probability : undefined
80 answers[q.name] = { choice: a.choice, confidence: typeof a.confidence === 'number' ? a.confidence : typeof probability === 'number' ? probability : 0 }
81 }
82 const tokens = body.usage?.input_tokens
83 return { answers, costUsd: typeof tokens === 'number' ? tokens * USD_PER_INPUT_TOKEN.openai : 0 }
84}
85
86// ---------------------------------------------------------------- Claude
87
88// The system prompt and message for one pick: the state, then each question and its choices.
89export function claudeAsk(state: object, questions: readonly Question[]): { system: string; prompt: string } {
90 const shape = `{${questions.map(q => `"${q.name}": "<one of ${Object.keys(q.criteria).join(', ')}>"`).join(', ')}, "confidence": <0 to 1>}`
91 return {
92 system:
93 'You choose settings for a coding agent. Answer each question with exactly one of its choices, ' +
94 'and say how sure you are of the answers as a number from 0 to 1. Reply with JSON only, no prose: ' +
95 shape,
96 prompt: [
97 `<state>\n${JSON.stringify(state)}\n</state>`,
98 ...questions.map(q => `Question "${q.name}": ${q.instructions}\nChoices:\n${Object.entries(q.criteria).map(([k, v]) => `- ${k}: ${v}`).join('\n')}`),
99 ].join('\n\n'),
100 }
101}
102
103// Claude's JSON reply. Its confidence is its own rating, shared by every answer; missing, 0.5.
104export function parseClaudeAnswers(text: string, questions: readonly Question[], usage?: Usage): Answered | null {
105 const match = /\{[\s\S]*\}/.exec(text)
106 const body = match ? json(match[0]) : null
107 if (!body) return null
108 const rated = typeof body.confidence === 'number' ? Math.min(1, Math.max(0, body.confidence)) : 0.5
109 const answers: Record<string, Answer> = {}
110 for (const q of questions) {
111 const choice = body[q.name]
112 if (isChoice(q, choice)) answers[q.name] = { choice, confidence: rated }
113 }
114 const costUsd = usage
115 ? (usage.input_tokens + usage.cache_creation_input_tokens * 1.25) * CLAUDE_HAIKU.input + usage.cache_read_input_tokens * CLAUDE_HAIKU.cacheRead + usage.output_tokens * CLAUDE_HAIKU.output
116 : 0
117 return { answers, costUsd }
118}
119
120function isChoice(q: Question, choice: unknown): choice is string {
121 return typeof choice === 'string' && Object.prototype.hasOwnProperty.call(q.criteria, choice)
122}
123
124function json(text: string): any {
125 try {
126 return JSON.parse(text)
127 } catch {
128 return null
129 }
130}
131types/index.d.ts 76 lines1export type Tier = 'haiku' | 'sonnet' | 'opus' | 'fable'
2export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
3
4// What the line says auto-model did with a turn's model.
5// `by` names the picker that answered; absent, Jev (what notes from before 0.4.0 meant).
6export type Picker = 'jev' | 'openai' | 'claude'
7export type Note =
8 | { kind: 'on' }
9 | { kind: 'off' }
10 | { kind: 'asking'; by?: Picker }
11 | { kind: 'switched'; from: string; sure: number; by?: Picker } // ran below the model /model set
12 | { kind: 'kept'; sure: number; by?: Picker } // the picker chose the model /model set
13 | { kind: 'unsure'; sure: number; by?: Picker } // too unsure to switch
14 | { kind: 'held'; sure: number; wanted: string; tokens: number; by?: Picker } // too much context to move down
15 | { kind: 'none'; reason: string } // no pick: no key, no answer, no prompt…
16
17// What auto-model did with a turn's effort: lowered it, held it for the cache, or left it.
18export type EffortNote = 'auto' | 'held' | 'kept'
19
20// The main loop as the line shows it: before any turn, during one, or after the last.
21export type Main = { model: string; note: Note; phase: 'start' | 'running' | 'done'; effort?: string; effortNote?: EffortNote }
22
23// The session's figures beside it: the context window's fill and the 7-day window.
24export type Meter = { ctx?: number; sevenDay?: number }
25
26// What the line under the prompt draws from.
27export type Line = {
28 main?: Main
29 meter?: Meter
30 subagents: Record<string, { model: string; at: number }> // by agent id, while running
31}
32
33// What the mod has done this session, for /auto-model.
34export type Stats = {
35 turns: Record<string, number> // by the label of the model the turn ran on
36 switched: number
37 lowered?: number // turns run below the effort /effort set
38 jevUsd: number // what the picks cost, whichever picker answered (named before other pickers existed)
39}
40
41export type Usage = {
42 input_tokens: number
43 output_tokens: number
44 cache_read_input_tokens: number
45 cache_creation_input_tokens: number
46}
47
48// One subagent spawn and what auto-model did with its model.
49export type Route = {
50 id: string // the Agent tool call's id
51 agentId?: string
52 description: string
53 type: string
54 asked?: string // what it would run on without us; undefined when its own agent definition decides
55 picked?: Tier // undefined when nothing picked: a named model, no key, or no answer
56 by: Picker | 'none'
57 confidence?: number // the picker's, 0 to 1 (Claude's is its own rating)
58 reason: string
59 applied: boolean // false in suggest mode, or when the pick matched what was asked
60 status: 'running' | 'done' | 'failed'
61 startedAt: number
62 endedAt?: number
63 model?: string // what it ran on, as the API reports it
64 usage?: Usage
65 costUsd?: number // at API prices, for the tokens it used
66 askedUsd?: number // the same tokens at the asked model's prices
67 pickerUsd?: number // what the pick itself cost
68}
69
70declare module 'claude-code' {
71 interface PluginState {
72 // routes: the subagents this session and their picks, for /route; now: the band's clock.
73 'auto-model': { stats: Stats; line: Line; routes: Route[]; now: number }
74 }
75}
76