SLOPSHOPPER

auto-model

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…

newbandcommandtoaststatusprompt
v0.3.0MITupdated 2026-10-15aecheman27-gif/claude-mods/auto-model
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · auto-model
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /automodel ⎿ auto-model: auto-model: on. The conversation always goes whole to the other model; its prompt cache doesn't, so it switche ⎿ auto-model: the other model's cache misses no more of the chat than yours (a cache holds the chat as of its last use, fo ⎿ auto-model: Haiku under 75k tokens (haikuUnderTokens in /config), Opus when it would re-read under 50k cold (switchUnder ⎿ auto-model: Sonnet only when your model's cache is cold: a cold Sonnet re-read costs ~20x a warm Opus one. ⎿ auto-model: Only Anthropic's own model ids, only on your session's model (a fallback is left alone); an error or refusal ⎿ auto-model: Caches this session: none used yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

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.

Source 2 files
hooks/register.tsx 331 lines
1import { 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}
331
types/index.d.ts 34 lines
1/** 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