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

Picks the main conversation's model for each typed prompt or skill; caps effort at xhigh.
/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.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.SKILL.md declares, read from the project's .claude/skills/ first, then ~/.claude/skills/: metadata:
model-tier: trivial # trivial | moderate | complex | frontier
| Tier | Model line |
|---|---|
trivial | haiku |
moderate | sonnet |
complex | opus |
frontier | fable |
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.
complex when in doubt. If the plan refuses a model, it steps down a line./model or /config) also turns it off, and keeps the model you picked./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
modelto~/.claude/settings.json, as/modeldoes. A new session starts on the last model Auto Model picked. The nextmake install-clauderesets it to the template's default.
hooks/register.tsx 269 lines1import { 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}
269hooks/logic.ts 106 lines1import 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}
106types/index.d.ts 11 lines1export 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