Picks the model per prompt with local rules: quick questions to Haiku, writing and explaining to Sonnet, building and fixing to Opus. The whole conversation…

Personal Claude Code mods, built or vendored at a reviewed commit. Each mod lives in its own folder and is listed in .claude-plugin/marketplace.json.
hooks/register.tsx 331 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CacheUse, Family } from '../types'
5
6// Picks the model per prompt with local rules, no model calls: quick questions
7// to Haiku, writing and explaining to Sonnet, building and fixing to Opus. It
8// rewrites the main loop's requests for that one turn (turn.step), so /model
9// keeps the person's own pick and subagents keep theirs. A Fable session is the
10// person's deliberate pick and is never rerouted.
11// The conversation always goes whole to the other model; its prompt cache does
12// not (each model keeps its own), so the other model re-reads at the
13// cache-write price whatever its cache lacks: all of the chat when it is cold,
14// what was added since its last use when it is warm. It switches only where
15// that costs little: the other model's cache misses no more of the chat than
16// yours, yours went cold anyway, Haiku under haikuUnderTokens (its cheap tier),
17// Opus re-reading under switchUnderTokens. Sonnet with Opus's cache warm
18// re-reads at ~20x the price of staying, so it waits.
19// Only Anthropic's own model ids are routed (not Bedrock, Vertex or a gateway's
20// names), only on the session's model (a fallback the engine retries on is left
21// alone), and a routed turn that ends in an error or a refusal stops routing
22// for the rest of the session.
23// $.state, $.store, $.clock, $.session (usage, model, turns), $.prompt.submit
24// and $.ui only: no files, network, processes or model calls.
25
26const last = atom({ plugin: 'auto-model', key: 'last' } as const, null)
27// family -> the main loop's last request to it this session: when (its cache is warm for CACHE_MS after) and the context it cached
28const caches = atom({ plugin: 'auto-model', key: 'caches' } as const, {})
29
30export type Tier = 'quick' | 'medium' | 'heavy' | 'keep'
31
32const ID: Record<Family, string> = { haiku: 'claude-haiku-5-5', sonnet: 'claude-sonnet-5-5', opus: 'claude-opus-5-5' }
33// the ids ID's are siblings of; a provider's own spelling (Bedrock's us.anthropic.claude-…, Vertex's …@date, a gateway alias) is not rewritten
34const FIRST_PARTY = /^claude-(?:opus|sonnet|haiku|fable)-[0-9][a-z0-9-]*(?:\[\w+\])?$/
35const TARGET: Record<Exclude<Tier, 'keep'>, Family> = { quick: 'haiku', medium: 'sonnet', heavy: 'opus' }
36// a person's prompt: typed, from a phone, or a plugin's button (next-steps); not a peer, a schedule or a notification
37const ROUTED = new Set(['composer', 'bridge', 'plugin'])
38const DEFAULT_LIMIT = 50_000
39// Haiku 5.5 bills a prompt of 100K tokens or fewer at its low tier, and counts the same text as ~30% more tokens
40const DEFAULT_HAIKU_LIMIT = 75_000
41const HAIKU_TOKENS = 1.3
42const HAIKU_TIER = 100_000
43// Claude Code writes the cache with the 1-hour TTL (as cache-tax counts it)
44const CACHE_MS = 60 * 60 * 1000
45// $ per million input tokens, list prices September 2026: [cache read, 1-hour cache write]; Haiku's low tier first
46const PRICE: Record<Family, { read: number; write: number; overRead?: number; overWrite?: number }> = {
47 opus: { read: 0.2, write: 8 },
48 sonnet: { read: 0.2, write: 4 },
49 haiku: { read: 0.01, write: 0.2, overRead: 0.05, overWrite: 1 },
50}
51const QUICK_MAX = 160
52const LONG_MIN = 600
53
54const HEAVY =
55 /\b(?:build|built|implement|create|scaffold|refactor|debug|fix|rewrite|migrate|optimi[sz]e|architect|design|plan|planning|research|investigate|analy[sz]e|audit|review|deploy|install|configure|set ?up|test|benchmark|profile)(?:s|es|ed|d|ing)?\b/i
56const WRITE_THING =
57 /\b(?:write|make)\b.{0,40}\b(?:script|function|program|app|dashboard|website|page|component|tests?|class|module|plugin|mod|query|regex|code)\b/i
58const MEDIUM =
59 /\b(?:explain|why|summari[sz]e|compare|draft|translate|describe|outline|brainstorm|suggest|recommend|improve|polish|reword|proofread|rephrase)(?:s|es|ed|d|ing)?\b|\bhow (?:do|does|did|would|should|can|could)\b/i
60const QUICK_START = /^(?:what(?:'s|s)?|how (?:many|much|long|old|big)|when|where|who|which|is|are|was|were|does|do|did|has|have|list|show|count|check|tell me|find)\b/i
61// "find the old logs and delete them", "check COM3 and reset the board": a request that changes something, not a question
62const ACTION =
63 /\b(?:delete|remove|reset|restart|reboot|shut ?down|run|send|push|pull|write|flash|kill|stop|start|launch|execute|move|rename|overwrite|upload|deploy|commit|merge|rebase|revert|restore|clean|wipe|erase|purge|drop|truncate|format|install|uninstall|reinstall|upgrade|downgrade|enable|disable|change|set|update|edit|apply|publish|cancel|archive|calibrate|trigger)(?:s|es|ed|d|ing)?\b/i
64
65/** The prompt's tier and the signal that set it. 'keep' runs it on the session's own model. */
66export function tierOf(text: string, hasAttachments = false): { tier: Tier; why: string } {
67 const t = text.trim()
68 if (hasAttachments) return { tier: 'heavy', why: 'has an attachment' }
69 if (t.includes('```')) return { tier: 'heavy', why: 'has code' }
70 if (t.length > LONG_MIN) return { tier: 'heavy', why: 'long prompt' }
71 const heavy = HEAVY.exec(t) ?? WRITE_THING.exec(t)
72 if (heavy) return { tier: 'heavy', why: `"${heavy[0].toLowerCase()}"` }
73 const medium = MEDIUM.exec(t)
74 if (medium) return { tier: 'medium', why: `"${medium[0].toLowerCase()}"` }
75 if (t.length <= QUICK_MAX && !t.includes('\n') && (QUICK_START.test(t) || t.endsWith('?'))) {
76 // Haiku would carry it out with every tool: an action stays on the person's own model
77 const action = ACTION.exec(t)
78 if (action) return { tier: 'keep', why: `"${action[0].toLowerCase()}" asks for an action` }
79 return { tier: 'quick', why: 'quick question' }
80 }
81 // "yes", "push it", "do the next one": a follow-up carries on the task on the model it was on
82 return { tier: 'keep', why: 'follow-up or no clear signal' }
83}
84
85export function familyOf(model: string): Family | 'fable' | undefined {
86 const m = model.toLowerCase()
87 if (m.includes('fable')) return 'fable'
88 if (m.includes('opus')) return 'opus'
89 if (m.includes('sonnet')) return 'sonnet'
90 if (m.includes('haiku')) return 'haiku'
91 return undefined
92}
93
94const k = (n: number) => `${Math.round(n / 1000)}k`
95const money = (n: number) => `$${n < 0.1 ? n.toFixed(3) : n.toFixed(2)}`
96
97/**
98 * What one request's input costs on `family` over `tokens` of context (counted as Opus counts them):
99 * the `cached` part its warm cache holds at the read price, the rest at the write price.
100 */
101export function inputCost(family: Family, tokens: number, cached: number): number {
102 const p = PRICE[family]
103 const hit = Math.min(cached, tokens)
104 if (family !== 'haiku') return (hit * p.read + (tokens - hit) * p.write) / 1e6
105 const over = tokens * HAIKU_TOKENS > HAIKU_TIER
106 return ((hit * (over ? p.overRead! : p.read) + (tokens - hit) * (over ? p.overWrite! : p.write)) * HAIKU_TOKENS) / 1e6
107}
108
109export type Cache = {
110 /** the context now, in tokens; undefined when the engine has no reading */
111 tokens?: number
112 now: number
113 /** family -> the main loop's last request to it: when, and the context it cached */
114 caches: Record<string, CacheUse>
115 haikuLimit: number
116 opusLimit: number
117}
118
119/** Which family a turn runs on (`family` absent: the session's own model) and the line /automodel shows. */
120export function decide(tier: Tier, model: string, c: Cache): { family?: Family; note: string } {
121 const base = familyOf(model)
122 if (base === undefined || !FIRST_PARTY.test(model)) return { note: `kept ${model}: auto-model routes only Anthropic's own model ids (claude-opus-…)` }
123 if (base === 'fable') return { note: 'kept fable: your own pick' }
124 if (tier === 'keep') return { note: `kept ${base}` }
125 const target = TARGET[tier]
126 if (target === base) return { note: `kept ${base}, the right fit` }
127 const tokens = c.tokens ?? 0
128 const isWarm = (f: string) => c.now - (c.caches[f]?.at ?? -Infinity) < CACHE_MS
129 // how much of the chat a warm cache still holds: what its last use cached
130 const held = (f: string) => (isWarm(f) ? Math.min(c.caches[f]?.tokens ?? 0, tokens) : 0)
131 // what each would re-read at the write price: the target by switching, your model by staying
132 const fresh = tokens - held(target)
133 const stale = tokens - held(base)
134 const cost = ` (input ≈ ${money(inputCost(target, tokens, held(target)))} vs ${money(inputCost(base, tokens, held(base)))} staying)`
135 const go = (why: string) => ({ family: target, note: `${target} instead of ${base}: ${why}${cost}` })
136 if (isWarm(target) && fresh <= stale) return go(`${target}'s cache is still warm`)
137 if (!isWarm(base)) return go(`${base}'s cache was cold too, so staying would re-read the chat as well`)
138 if (target === 'haiku' && tokens <= c.haikuLimit) return go(`under ${k(c.haikuLimit)} tokens a Haiku re-read costs about what staying does`)
139 if (target === 'opus' && fresh <= c.opusLimit) return go(`under the ${k(c.opusLimit)} switch limit`)
140 const missed = fresh < tokens ? `${k(fresh)} of the chat` : 'the chat'
141 if (target === 'sonnet') return { note: `kept ${base}: its cache is warm, and Sonnet would re-read ${missed} cold${cost}` }
142 if (target === 'haiku') return { note: `kept ${base}: the context (${k(tokens)}) is past the ${k(c.haikuLimit)} limit for haiku${cost}` }
143 return { note: `kept ${base}: opus would re-read ${missed} cold, past the ${k(c.opusLimit)} switch limit${cost}` }
144}
145
146type Plan = {
147 tier: Tier
148 why: string
149 text: string
150 isDecided: boolean
151 family?: Family
152 base?: string
153 /** the session's model as the turn's first request named it: a later step naming another is a fallback, left alone */
154 model?: string
155 /** the routed model refused a request: the rest of the turn runs as the engine sends it */
156 isRefused?: boolean
157}
158
159/** The model a step is sent to instead of `model`, if the turn is routed and the step is still on the session's model. */
160const routedModel = (plan: Plan | undefined, model: string) => (plan?.family && !plan.isRefused && model === plan.model ? ID[plan.family] : undefined)
161
162const isOff = async ($: EngineInterface) => (await $.store.get('isOff')) === true
163
164export const register: Register = (on, options) => {
165 const setting = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback)
166 const opusLimit = setting(options?.switchUnderTokens, DEFAULT_LIMIT)
167 const haikuLimit = setting(options?.haikuUnderTokens, DEFAULT_HAIKU_LIMIT)
168 let pending: { tier: Tier; why: string; text: string } | undefined
169 let note = 'no prompt seen yet'
170 // why routing stopped for the rest of the session: a switched turn ended in an error or a refusal
171 let halted: string | undefined
172 const plans = new Map<string, Plan>()
173
174 on('session.start', async ($, e, next) => {
175 await $.command.register({
176 name: 'automodel',
177 description: 'Auto Model: picks Haiku, Sonnet or Opus per prompt (status, on, off)',
178 argumentHint: '[status | on | off]',
179 immediate: true,
180 })
181 return next(e)
182 })
183
184 // Read the prompt before it enters: its turn starts inside next(e).
185 on('prompt.submit', async ($, e, next) => {
186 if (e.turnId === undefined) {
187 void update($, last, () => null).catch(() => undefined)
188 pending = ROUTED.has(e.origin.kind) && !halted && !(await isOff($)) ? { ...tierOf(e.text, (e.attachments?.length ?? 0) > 0), text: e.text } : undefined
189 }
190 return next(e)
191 }).catch(($, e, next) => {
192 // never hold a prompt back: it runs on the session's own model
193 pending = undefined
194 return next(e)
195 })
196
197 // A prompt this plugin submits (Redo) skips its own prompt.submit hook: no pending, so it runs on the session's model.
198 on('turn.start', async ($, e, next) => {
199 const p = pending
200 pending = undefined
201 if (p && p.tier !== 'keep') plans.set(e.turnId, { ...p, isDecided: false })
202 else if (p) note = `kept your model (${p.why})`
203 return next(e)
204 })
205
206 on('turn.step', async function* ($, e, next) {
207 // a subagent's requests carry its own transcript, not the main loop's cached one
208 if (e.agentId !== undefined) return yield* next(e)
209 const plan = plans.get(e.turnId)
210 try {
211 const now = await $.clock.now()
212 const tokens = await $.session.usage().then(
213 u => u.context.tokens,
214 () => undefined,
215 )
216 if (plan && !plan.isDecided) {
217 plan.isDecided = true
218 const base = familyOf(e.model)
219 const own = familyOf(await $.session.model().catch(() => ''))
220 if (own !== undefined && base !== own) note = `left ${e.model} alone: a fallback for your ${own}`
221 else {
222 // a resumed chat (or one auto-model joined late) has no record of your model's cache: count it warm from now
223 if (base && (await read($, caches))[base] === undefined && (await $.session.turns().catch(() => 0)) > 1)
224 await update($, caches, all => ({ ...all, [base]: { at: now, tokens: tokens ?? 0 } }))
225 const d = decide(plan.tier, e.model, { tokens, now, caches: await read($, caches), haikuLimit, opusLimit })
226 note = d.note
227 if (d.family) {
228 plan.family = d.family
229 plan.base = base
230 plan.model = e.model
231 void $.ui.toast(`auto-model: ${d.family} for this one (${plan.why}). /automodel off to stop`)
232 void $.ui.status(`auto-model: ${d.family}`)
233 }
234 }
235 }
236 // the model this request warms, and the context it caches
237 const sent = familyOf(routedModel(plan, e.model) ?? e.model)
238 if (sent) await update($, caches, all => ({ ...all, [sent]: { at: now, tokens: tokens ?? all[sent]?.tokens ?? 0 } }))
239 } catch {
240 // bookkeeping never holds a request back
241 }
242 const model = routedModel(plan, e.model)
243 if (!plan || !model) return yield* next(e)
244 // the model's own default effort: the session's may be one the other model doesn't take
245 const r = yield* next({ ...e, model, effort: undefined })
246 // the engine retries a refusal on a fallback model: never force the refused one back
247 if (r.stopReason === 'refusal') plan.isRefused = true
248 return r
249 })
250
251 on('turn.complete', async ($, e, next) => {
252 const done = await next(e)
253 const plan = e.agentId === undefined ? plans.get(e.turnId) : undefined
254 if (plan) {
255 plans.delete(e.turnId)
256 const { family } = plan
257 if (family) {
258 void $.ui.status(undefined)
259 if (e.reason === 'error' || e.reason === 'refusal') {
260 // the model may be closed to this account or provider: no more switching this session
261 halted = `a turn on ${family} ended with ${e.reason === 'error' ? 'an API error' : 'a refusal'}`
262 void $.ui.toast(`auto-model: ${halted}, so every prompt runs on your own model for the rest of this session. /automodel status`)
263 } else if (!e.isAborted) await update($, last, () => ({ family, base: plan.base ?? 'your model', why: plan.why, text: plan.text }))
264 }
265 }
266 return done
267 })
268
269 on('command.run', { command: 'automodel' }, async ($, e) => {
270 const verb = String(e.args ?? '').trim().toLowerCase() || 'status'
271 if (verb === 'off') {
272 await $.store.set('isOff', true)
273 await update($, last, () => null)
274 return { text: 'auto-model is off: every prompt runs on your own /model pick. /automodel on to resume.' }
275 }
276 if (verb === 'on') {
277 await $.store.set('isOff', false)
278 halted = undefined
279 return {
280 text: 'auto-model is on: quick questions go to Haiku, writing and explaining to Sonnet, building and fixing to Opus, when the switch costs little. /automodel status says when.',
281 }
282 }
283 if (verb !== 'status') return { text: 'Usage: /automodel [status | on | off]' }
284 const now = await $.clock.now()
285 const used = Object.entries(await read($, caches))
286 .sort((a, b) => b[1].at - a[1].at)
287 .map(([f, u]) => (now - u.at < CACHE_MS ? `${f} warm (${k(u.tokens)}, used ${Math.round((now - u.at) / 60_000)}m ago)` : `${f} cold`))
288 const state = (await isOff($)) ? 'off' : halted ? `paused for the rest of this session: ${halted} (/automodel on resumes)` : 'on'
289 return {
290 text: [
291 `auto-model: ${state}. The conversation always goes whole to the other model; its prompt cache doesn't, so it switches only when that costs little:`,
292 ` the other model's cache misses no more of the chat than yours (a cache holds the chat as of its last use, for ${CACHE_MS / 3_600_000}h), or yours went cold anyway (new session, or ${CACHE_MS / 3_600_000}h+ idle; a resumed chat counts as warm);`,
293 ` Haiku under ${k(haikuLimit)} tokens (haikuUnderTokens in /config), Opus when it would re-read under ${k(opusLimit)} cold (switchUnderTokens);`,
294 ' Sonnet only when your model\'s cache is cold: a cold Sonnet re-read costs ~20x a warm Opus one.',
295 " Only Anthropic's own model ids, only on your session's model (a fallback is left alone); an error or refusal on a switched turn pauses it.",
296 `Caches this session: ${used.length > 0 ? used.join(', ') : 'none used yet'}.`,
297 `Last prompt: ${note}.`,
298 ].join('\n'),
299 }
300 })
301
302 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
303 const below = await next(e)
304 if (e.props.hasSurvey) return below
305 const r = await read($, last)
306 if (!r) return below
307 const { Box, Button, Text } = $.ui.resolve(e)
308 const redo = () => {
309 void update($, last, () => null)
310 void $.prompt.submit({ text: r.text, asUser: true })
311 }
312 return (
313 <Box flexDirection="column">
314 <Box flexDirection="row" columnGap={1} width={e.props.bodyColumns}>
315 <Box flexShrink={1}>
316 <Text wrap="truncate-end">
317 <Text color="#a78bfa" bold>
318 [AUTO]{' '}
319 </Text>
320 {r.family} answered that ({r.why}).
321 </Text>
322 </Box>
323 <Button key="redo" label={`Redo with ${r.base}`} onPress={redo} />
324 <Button key="dismiss" label="Dismiss" onPress={() => void update($, last, () => null)} />
325 </Box>
326 {below}
327 </Box>
328 )
329 })
330}
331types/index.d.ts 34 lines1/** A model family auto-model routes to. */
2export type Family = 'haiku' | 'sonnet' | 'opus'
3
4/** A model family's prompt cache as the main loop last left it. */
5export type CacheUse = {
6 /** clock time of the main loop's last request to it: its cache is warm for an hour after */
7 at: number
8 /** the context then, in tokens: what that cache holds */
9 tokens: number
10}
11
12/** The last turn auto-model answered on another model than the session's own. */
13export type Routed = {
14 /** who answered */
15 family: Family
16 /** the session's own model family, what Redo runs on */
17 base: string
18 /** the rule that picked it: 'quick question', '"build"', ... */
19 why: string
20 /** the prompt as sent, for Redo */
21 text: string
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 'auto-model': {
27 /** drawn as the [AUTO] row until the next prompt or Dismiss */
28 last: Routed | null
29 /** model family -> its prompt cache as the main loop's last request to it left it */
30 caches: Record<string, CacheUse>
31 }
32 }
33}
34