SLOPSHOPPER

auto-model

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…

newpanebandspinnercommandstatus
v0.4.0MITupdated 2026-10-14Luminous13/claude-mods/mods/auto-model
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · auto-model
│ ┃ Subagent models ✕ › fix the failing auth test and add an audit log call │ ┃ 0 subagents · 0 switched · subagents: auto · │ ┃ picker Jev (no TypeSafe key, nothing ⏺ Read(src/auth.ts) │ ┃ changes) ⎿ Read 6 lines │ ┃ spent $0 · at the asked models $0 · picks $0 ⏺ Update(src/auth.ts) │ ┃ API price estimates as of 2026-10-06, 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 │ │ › /auto-model │ ⎿ auto-model: auto-model on · picker Jev (no TypeSafe key: nothing │ ⎿ auto-model: turns this session: none yet · 0 on a cheaper model │ ⎿ auto-model: subagents: auto · 0 this session, 0 switched · /rout │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ◆ Opus 5.5 · auto │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% │ ◇ 0 agents ⟨Claude Code's own drawing⟩

Draws

Pane · Subagent models
0 subagents · 0 switched · subagents: auto · picker Jev (no TypeSafe key, nothing changes) spent $0 · at the asked models $0 · picks $0 API price estimates as of 2026-10-06, not plan charges. ? = not known yet. ────────────────────────────────────────────────────── No subagents yet this session. [ Clear finished ] [ Close ]
Prompt hint
◆ Opus 5.5 · auto │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% │ ◇ 0 agents ⟨Claude Code's own drawing⟩
README

claude-mods

Claude Code mods by Nereo Valeriano. Each one is a plugin in this marketplace.

ModWhat it does
auto-modelPicks 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-iA card above the prompt with where the session stands: goal, status and recent progress. Fork of Hamza Zafar's where-am-i.
next-stepsSuggested next steps, shown in the where-am-i card with clickable number badges. Fork of Hamza Zafar's next-steps.

Install

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

auto-model picker

Set picker in the plugin settings:

  • jev (default): needs a TypeSafe API key at api.typesafe.ai. Set it in the settings or as TYPESAFE_API_KEY.
  • openai: needs an OpenAI API key, set in the settings or as OPENAI_API_KEY.
  • claude: asks Haiku through your own Claude Code session. Needs no key, and nothing goes to a third party. Its confidence is its own estimate and a pick takes longer.

Subagent picking was ported from Hamza Zafar's switchboard. See NOTICE.md.

Licence

MIT. See LICENSE. The where-am-i and next-steps forks keep Hamza Zafar's copyright; see their NOTICE.md files.

Source 5 files
hooks/register.tsx 447 lines
1// 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}
447
hooks/agents.ts 156 lines
1// 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}
156
hooks/pick.ts 271 lines
1// 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}
271
hooks/pickers.ts 131 lines
1// 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}
131
types/index.d.ts 76 lines
1export 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