SLOPSHOPPER

auto-model

Picks the main conversation's model for each typed prompt, and caps reasoning effort at xhigh.

newspinnercommandtoastpromptmodel
★ 3v0.1.0no licenseupdated 2026-10-16Integralist/agent-skills/.claude/plugins/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 is off. Current model: Opus 5.5. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits auto-model-toggle
README

auto-model

Picks the main conversation's model for each typed prompt or skill; caps effort at xhigh.

  • Turn it on or off: run /automodel on, /automodel off or /automodel status, or click the Auto Model: On/Off chip at the right of the prompt footer. The setting is saved across sessions.
  • What it does: before each prompt you type is sent, the smallest model sorts it as trivial, moderate, complex or frontier (8 tokens, 4 seconds). Those tiers map to the haiku, sonnet, opus and fable lines that the /config model row offers. A 10-second toast names the new model.
  • Skills: running a skill by slash command uses the tier its SKILL.md declares, read from the project's .claude/skills/ first, then ~/.claude/skills/:
  metadata:
    model-tier: trivial # trivial | moderate | complex | frontier
TierModel line
trivialhaiku
moderatesonnet
complexopus
frontierfable

A skill without a tier, or a plugin's skill, is sorted once by its description, and that tier is kept until the description changes. For how to pick a tier, see model tier.

  • When it leaves the model alone: short replies (fewer than four words, or replies such as "yes" or "keep going"), built-in slash commands, messages sent while Claude is working, and helper agents.
  • Fallbacks: if the quick check fails, a keyword guess is used, with complex when in doubt. If the plan refuses a model, it steps down a line.
  • Turning it off: restores the model you used before. Picking a model by hand (/model or /config) also turns it off, and keeps the model you picked.
  • Effort cap: /effort max becomes xhigh, and every request, helper agents included, is sent at xhigh at most, even while Auto Model is off.

[!NOTE] Switching models writes model to ~/.claude/settings.json, as /model does. A new session starts on the last model Auto Model picked. The next make install-claude resets it to the template's default.

Source 3 files
hooks/register.tsx 269 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AutoModelLevel } from '../types'
5import {
6  CLASSIFIER_SYSTEM,
7  availableLines,
8  candidatesFor,
9  clampEffort,
10  clampEffortArgs,
11  displayName,
12  guessLevel,
13  isShortReply,
14  lineOf,
15  parseLevel,
16  skillPaths,
17  tierFromFrontmatter,
18} from './logic'
19
20const COMMAND = 'automodel'
21const STORE_ON = 'isOn'
22const STORE_PREVIOUS = 'previousModel'
23const TOAST_MS = 10_000
24
25const GOLD = '#F2C46D'
26const SLATE = '#262B33'
27const INK = '#0D1117'
28const TEXT = '#E6EDF3'
29const MUTED = '#7D8590'
30
31const isOn = atom({ plugin: 'auto-model', key: 'isOn' } as const, false)
32const pendingModel = atom({ plugin: 'auto-model', key: 'pendingModel' } as const, null)
33
34type $ = EngineInterface
35
36async function modelRow($: $) {
37  return (await $.config.list()).find(row => row.key === 'model')
38}
39
40async function classify($: $, text: string, smallest: string): Promise<AutoModelLevel> {
41  try {
42    const reply = await $.model.complete({
43      model: smallest,
44      system: CLASSIFIER_SYSTEM,
45      prompt: text,
46      maxTokens: 8,
47      timeoutMs: 4000,
48    })
49    return (reply.isAnswered ? parseLevel(reply.text) : undefined) ?? guessLevel(text)
50  } catch {
51    return guessLevel(text)
52  }
53}
54
55/** The `model-tier` the skill's own SKILL.md declares, project copy first. */
56async function declaredTier($: $, name: string): Promise<AutoModelLevel | undefined> {
57  for (const path of skillPaths(name, await $.session.cwd(), await $.env.get('HOME'))) {
58    try {
59      return tierFromFrontmatter(await $.fs.read(path))
60    } catch {
61      // No skill file here; try the next place.
62    }
63  }
64  return undefined
65}
66
67/**
68 * The tier for a slash command: the skill's `metadata.model-tier`, else its
69 * description, classified once and kept until the description changes.
70 * Built-in and MCP commands, and this mod's own, have none.
71 */
72async function commandLevel($: $, command: string, smallest: string): Promise<AutoModelLevel | undefined> {
73  if (command === COMMAND) return undefined
74  const info = (await $.command.list()).find(c => c.name === command)
75  if (info === undefined || (info.source !== 'user' && info.source !== 'plugin')) return undefined
76  const declared = await declaredTier($, info.name)
77  if (declared !== undefined) return declared
78  const key = `skill:${info.name}:${info.description}`
79  const kept = await $.store.get(key)
80  if (typeof kept === 'string' && parseLevel(kept) !== undefined) return parseLevel(kept)
81  const level = await classify($, `/${info.name}: ${info.description}`, smallest)
82  await $.store.set(key, level)
83  return level
84}
85
86/** Switches to the best line for `level` the plan accepts; quiet when already there. */
87async function switchTo($: $, level: AutoModelLevel, current: string, lines: readonly string[]) {
88  for (const line of candidatesFor(level, lines)) {
89    if (lineOf(current) === line) return
90    const set = await $.config.set({ key: 'model', value: line })
91    if (set.deny !== undefined) continue
92    const model = await $.session.model()
93    await update($, pendingModel, () => model)
94    $.ui.toast(`Auto Model: ${displayName(model)} (${level})`, { timeoutMs: TOAST_MS })
95    return
96  }
97}
98
99async function turnOn($: $): Promise<string> {
100  if (await read($, isOn)) return 'Auto Model is already on.'
101  const row = await modelRow($)
102  await $.store.set(STORE_PREVIOUS, row?.value ?? null)
103  await $.store.set(STORE_ON, true)
104  await update($, isOn, () => true)
105  return 'Auto Model is on. Each new prompt picks its model.'
106}
107
108/** Turns it off; `restore` puts back the model in use before it was turned on. */
109async function turnOff($: $, restore: boolean): Promise<string> {
110  if (!(await read($, isOn))) return 'Auto Model is already off.'
111  await $.store.set(STORE_ON, false)
112  await update($, isOn, () => false)
113  await update($, pendingModel, () => null)
114  const previous = await $.store.get(STORE_PREVIOUS)
115  await $.store.delete(STORE_PREVIOUS)
116  if (!restore || typeof previous !== 'string') return 'Auto Model is off.'
117  let set = await $.config.set({ key: 'model', value: previous })
118  const line = lineOf(previous)
119  if (set.deny !== undefined && line !== undefined) {
120    set = await $.config.set({ key: 'model', value: line })
121  }
122  return set.deny === undefined
123    ? `Auto Model is off. Model is back to ${displayName(previous)}.`
124    : `Auto Model is off. Could not restore ${previous}: ${set.deny}`
125}
126
127async function status($: $): Promise<string> {
128  const on = await read($, isOn)
129  const model = displayName(await $.session.model())
130  const previous = await $.store.get(STORE_PREVIOUS)
131  const before = on && typeof previous === 'string' ? ` It restores ${displayName(previous)} when turned off.` : ''
132  return `Auto Model is ${on ? 'on' : 'off'}. Current model: ${model}.${before}`
133}
134
135export const register: Register = on => {
136  on('session.start', async ($, e, next) => {
137    try {
138      await update($, isOn, () => false)
139      if ((await $.store.get(STORE_ON)) === true) await update($, isOn, () => true)
140      await $.command.register({
141        name: COMMAND,
142        description: 'Auto Model: pick the model for each prompt (on, off, status)',
143        argumentHint: 'on|off|status',
144        immediate: true,
145      })
146    } catch (err) {
147      $.ui.log(`auto-model: could not start: ${String(err)}`, { to: 'debug' })
148    }
149    return next(e)
150  })
151
152  on('command.run', { command: COMMAND }, async ($, e) => {
153    try {
154      const arg = e.args.trim().toLowerCase()
155      if (arg === 'on') return { text: await turnOn($) }
156      if (arg === 'off') return { text: await turnOff($, true) }
157      if (arg === '' || arg === 'status') return { text: await status($) }
158      return { text: 'Use /automodel on, /automodel off or /automodel status.' }
159    } catch (err) {
160      return { text: `Auto Model could not do that: ${String(err)}` }
161    }
162  }).catch(() => ({ text: 'Auto Model could not do that. Try again.' }))
163
164  on('prompt.submit', async ($, e, next) => {
165    try {
166      const skip =
167        !(await read($, isOn)) ||
168        e.origin.kind !== 'composer' ||
169        e.turnId !== undefined ||
170        e.text.trimStart().startsWith('/') ||
171        isShortReply(e.text)
172      if (!skip) {
173        const row = await modelRow($)
174        const lines = availableLines(row?.options)
175        if (lines.length > 0) {
176          const level = await classify($, e.text, lines[0] ?? 'haiku')
177          await switchTo($, level, await $.session.model(), lines)
178        }
179      }
180    } catch (err) {
181      $.ui.log(`auto-model: prompt.submit: ${String(err)}`, { to: 'debug' })
182    }
183    return next(e)
184  }).catch(($, e, next) => next(e))
185
186  // A skill (`/commit`) switches by its level; typed prompts are handled above.
187  on('command.run', async ($, e, next) => {
188    try {
189      if (e.origin.kind === 'composer' && (await read($, isOn))) {
190        const lines = availableLines((await modelRow($))?.options)
191        const level = lines.length > 0 ? await commandLevel($, e.command, lines[0] ?? 'haiku') : undefined
192        if (level !== undefined) await switchTo($, level, await $.session.model(), lines)
193      }
194    } catch (err) {
195      $.ui.log(`auto-model: command.run: ${String(err)}`, { to: 'debug' })
196    }
197    return next(e)
198  }).catch(($, e, next) => next(e))
199
200  // Picking a model by hand turns Auto Model off, so the two never fight.
201  on('config.set', { key: 'model' }, async ($, e, next) => {
202    try {
203      if (e.origin.kind !== 'plugin' && (await read($, isOn))) await turnOff($, false)
204    } catch (err) {
205      $.ui.log(`auto-model: config.set: ${String(err)}`, { to: 'debug' })
206    }
207    return next(e)
208  }).catch(($, e, next) => next(e))
209
210  on('command.run', { command: 'model' }, async ($, e, next) => {
211    try {
212      if (e.origin.kind !== 'plugin' && (await read($, isOn))) await turnOff($, false)
213    } catch (err) {
214      $.ui.log(`auto-model: /model: ${String(err)}`, { to: 'debug' })
215    }
216    return next(e)
217  }).catch(($, e, next) => next(e))
218
219  // Effort never goes above xhigh: `/effort max` is rewritten, and every
220  // request (main loop and helper agents) is clamped on its way out.
221  on('command.run', { command: 'effort' }, ($, e, next) =>
222    next({ ...e, args: clampEffortArgs(e.args) }),
223  ).catch(($, e, next) => next(e))
224
225  on('turn.step', async function* ($, e, next) {
226    let model = e.model
227    if (e.agentId === undefined) {
228      const pending = await read($, pendingModel)
229      if (pending !== null) model = pending
230    }
231    return yield* next({ ...e, model, effort: clampEffort(e.effort) })
232  })
233
234  on('turn.complete', async ($, e, next) => {
235    if (e.agentId === undefined) await update($, pendingModel, () => null)
236    return next(e)
237  })
238
239  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
240    const enabled = await read($, isOn)
241    const { Box, Text, Button } = $.ui.resolve(e)
242    const columns = e.viewport?.columns ?? 80
243    const state = enabled ? 'On' : 'Off'
244    const label = columns < 60 ? `Auto: ${state}` : `Auto Model: ${state}`
245    const modes = e.props.modes.join(' & ')
246    return (
247      <Box flexDirection="row">
248        {modes !== '' && (
249          <Text color={MUTED} wrap="truncate">
250            {modes}{'  '}
251          </Text>
252        )}
253        <Button
254          key="auto-model-toggle"
255          plain
256          onPress={async () => {
257            if (await read($, isOn)) await turnOff($, true)
258            else await turnOn($)
259          }}
260        >
261          <Text backgroundColor={enabled ? GOLD : SLATE} color={enabled ? INK : TEXT} bold={enabled}>
262            {` ${label} `}
263          </Text>
264        </Button>
265      </Box>
266    )
267  })
268}
269
hooks/logic.ts 106 lines
1import type { AutoModelLevel } from '../types'
2
3export const LEVELS: readonly AutoModelLevel[] = ['trivial', 'moderate', 'complex', 'frontier']
4
5// The four model lines, smallest first, as the /config model row names them.
6// Aliases, not dated ids, so they follow whatever this install ships.
7export const LINES = ['haiku', 'sonnet', 'opus', 'fable'] as const
8
9export const CLASSIFIER_SYSTEM = [
10  'Sort the request into exactly one tier. Answer with the one word only.',
11  'trivial: quick, mechanical jobs. Commit, branch, summarize, sort, extract, rename, translate, reformat, a short factual answer.',
12  'moderate: short writing or a small, contained task. An email, a post, a PR body, a ticket, a small edit, a simple question about one file.',
13  'complex: the default for anything that matters. Building or changing software, debugging, research, analysis, long documents, planning, anything touching several files or tools.',
14  'frontier: only the very hardest. Deep research that runs for hours, a very large build or migration, a problem the complex tier would likely fall short on.',
15  'When unsure, say complex.',
16].join('\n')
17
18/** Reads the classifier's one-word answer; undefined when it named no tier. */
19export function parseLevel(text: string): AutoModelLevel | undefined {
20  const word = text.toLowerCase().match(/\b(trivial|moderate|complex|frontier)\b/)
21  return word ? (word[1] as AutoModelLevel) : undefined
22}
23
24const ACKS = new Set([
25  'yes', 'y', 'yep', 'yeah', 'ok', 'okay', 'k', 'sure', 'no', 'nope', 'thanks',
26  'thank you', 'go', 'go ahead', 'go on', 'continue', 'keep going', 'do it',
27  'proceed', 'lgtm', 'sounds good', 'looks good', 'carry on', 'ship it',
28  'yes please', 'please do', 'next',
29])
30
31/** A reply to what Claude just said: fewer than four words, or a plain go-ahead. */
32export function isShortReply(text: string): boolean {
33  const plain = text.toLowerCase().replace(/[^\p{L}\p{N}\s']/gu, ' ').replace(/\s+/g, ' ').trim()
34  if (plain === '') return true
35  if (ACKS.has(plain)) return true
36  return plain.split(' ').length < 4
37}
38
39const FRONTIER = /\b(migrat\w*|deep research|for hours|entire (codebase|repo\w*|system)|whole (codebase|repo\w*)|large[- ]scale|re-?architect\w*|ground[- ]up|from scratch)\b/i
40const COMPLEX = /\b(build|implement\w*|refactor\w*|debug\w*|fix\w*|bug|code|test\w*|research|analy[sz]\w*|plan\w*|design\w*|architect\w*|review\w*|investigat\w*|deploy\w*|feature|function|module|files|repo\w*|pr|diff|spec|doc(ument)?s?)\b/i
41const MODERATE = /\b(email|e-mail|post|tweet|slack|message|draft|letter|reply|edit|tweak|chart|graph|explain|what does|how do)\b/i
42const TRIVIAL = /\b(summari[sz]e|tl;?dr|sort|extract|rename|translate|reformat|format|convert|list|spell\w*|define|what is|who is|when (is|was))\b/i
43
44/** The word-based guess used when the quick check fails; complex when in doubt. */
45export function guessLevel(text: string): AutoModelLevel {
46  if (FRONTIER.test(text)) return 'frontier'
47  if (COMPLEX.test(text)) return 'complex'
48  if (MODERATE.test(text)) return 'moderate'
49  if (TRIVIAL.test(text)) return 'trivial'
50  return 'complex'
51}
52
53/** The model lines this install offers, smallest first. */
54export function availableLines(options: readonly string[] | undefined): string[] {
55  return LINES.filter(line => options?.includes(line) ?? false)
56}
57
58/**
59 * The lines to try for a level, best fit first, then each smaller one: the
60 * step-down order when the plan refuses a model. A level whose own line is
61 * missing starts at the nearest smaller line.
62 */
63export function candidatesFor(level: AutoModelLevel, lines: readonly string[]): string[] {
64  const order = LINES.slice(0, LEVELS.indexOf(level) + 1).reverse()
65  const found = order.filter(line => lines.includes(line))
66  return found.length > 0 ? found : [...lines]
67}
68
69/** The line a model id or alias belongs to (`claude-opus-5-5` is `opus`). */
70export function lineOf(model: string): string | undefined {
71  const lower = model.toLowerCase()
72  return LINES.find(line => lower === line || lower.includes(`-${line}`) || lower.startsWith(`${line}`))
73}
74
75/** `claude-opus-5-5` reads `Opus 5.5`; anything else is shown as given. */
76export function displayName(model: string): string {
77  const [, line, version] = model.match(/^claude-([a-z]+)-(\d+(?:-\d+)*)/i) ?? []
78  if (line === undefined || version === undefined) return model
79  return `${line.charAt(0).toUpperCase()}${line.slice(1)} ${version.replace(/-/g, '.')}`
80}
81
82/** Never send `max`: it becomes `xhigh`. Other values pass unchanged. */
83export function clampEffort<T>(effort: T): T | 'xhigh' {
84  return effort === 'max' ? 'xhigh' : effort
85}
86
87/** `/effort max` (in any case) is rewritten to `/effort xhigh`. */
88export function clampEffortArgs(args: string): string {
89  return args.replace(/\bmax\b/gi, 'xhigh')
90}
91
92/** The `metadata.model-tier` a SKILL.md declares; undefined when absent or unknown. */
93export function tierFromFrontmatter(text: string): AutoModelLevel | undefined {
94  const frontmatter = text.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1]
95  const metadata = frontmatter?.match(/^metadata:[ \t]*\r?\n((?:[ \t]+.*(?:\r?\n|$))*)/m)?.[1]
96  const value = metadata?.match(/^[ \t]+model-tier:[ \t]*["']?([A-Za-z]+)["']?[ \t]*$/m)?.[1]?.toLowerCase()
97  return LEVELS.find(level => level === value)
98}
99
100/** Where a skill named `name` may live, project first; none for a non-plain name. */
101export function skillPaths(name: string, cwd: string, home: string | undefined): string[] {
102  if (!/^[A-Za-z0-9_-]+$/.test(name)) return []
103  const file = `.claude/skills/${name}/SKILL.md`
104  return home === undefined ? [`${cwd}/${file}`] : [`${cwd}/${file}`, `${home}/${file}`]
105}
106
types/index.d.ts 11 lines
1export type AutoModelLevel = 'trivial' | 'moderate' | 'complex' | 'frontier'
2
3declare module 'claude-code' {
4  interface PluginState {
5    'auto-model': {
6      isOn: boolean
7      pendingModel: string | null
8    }
9  }
10}
11