Auto Model: picks the main model once per fresh conversation, locks it to protect the prompt cache, and hands small self-contained jobs to a helper.

A Claude Code mod that picks the right model at the start of a conversation, then keeps the main conversation on that one model, so the prompt cache is never thrown away by switching back and forth.
Works on macOS, Windows and Linux, in the terminal and in the Claude desktop app's Code tab.
Check your version:
claude --version
If it is older, update it:
claude update
If claude is not installed yet, install it first. On macOS or Linux:
curl -fsSL https://claude.ai/install.sh | bash
On Windows (PowerShell):
irm https://claude.ai/install.ps1 | iex
Either type this inside a running claude session:
/plugin install automodel --marketplace mugenracerman/automodel
Answer y to add the marketplace, then choose the user scope so it loads in every session.
Or run these two commands from a terminal (Terminal on macOS, PowerShell on Windows), which do the same:
claude plugin marketplace add mugenracerman/automodel
claude plugin install automodel@automodel --scope user
A user-scope install made from the terminal also loads in the desktop app's Code tab.
Start a new session and type /automodel on, or click Auto Model: Off in the status bar. It starts off on every machine; the switch is remembered per machine.
claude plugin update automodel@automodel
Then start a new session, or run /reload-plugins in a running one.
claude plugin uninstall automodel@automodel
Turn it off first (/automodel off) so your next session starts on the model you used before.
Auto Model starts off. Turn it on with /automodel on, or click Auto Model: Off in the status bar.
| Command | What it does |
|---|---|
/automodel on | Starts sorting your typed prompts. |
/automodel off | Stops sorting. The current session keeps its model; your next new session starts on the model you used before. |
/automodel status | Shows the locked model, the level that chose it, and how many jobs went to helpers this session. |
/automodel repick | Sorts your next prompt fresh, up or down. A change of model rebuilds the cache once. |
small, everyday, strong or strongest (8 tokens, 4 seconds). If that fails, a guess from the prompt's words decides, strong when in doubt. The four levels map to the model lines your install lists (Haiku, Sonnet, Opus, Fable), stepping down when your plan lacks one./clear or a compaction, it picks fresh. After that the model is locked. A later prompt that sorts higher steps up once; it never steps back down in the same session. A resumed session keeps the model it had. Turning it on mid-session locks to the model already in use./model, /config, the app's picker) turns Auto Model off so the two never fight.It sets the model through your model setting, so nothing appears in the chat; the model name in the status line is the only sign.
claude plugin validate .
claude plugin test .
claude --plugin-dir <this folder> runs it from disk for one session.
MIT. See LICENSE.
hooks/register.tsx 482 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Level, Lock } from '../types'
5import {
6 EMPTY_LOCK,
7 LINE_ORDER,
8 SORTER_SYSTEM,
9 bigNumber,
10 decide,
11 guessLevel,
12 helperNote,
13 isShortReply,
14 kindOf,
15 learnId,
16 letGo,
17 levelOfLine,
18 lineFor,
19 linesDownFrom,
20 linesIn,
21 parseLevel,
22 stepModel,
23 title,
24 turnedOff,
25} from './logic'
26import type { KnownIds } from './logic'
27
28type Engine = EngineInterface
29
30const isOnAtom = atom({ plugin: 'automodel', key: 'isOn' } as const, false)
31const lockAtom = atom({ plugin: 'automodel', key: 'lock' } as const, EMPTY_LOCK)
32
33const GOLD = '#F2C46D'
34const HAIR = '#262B33'
35const MUTED = '#7D8590'
36const TEXT = '#E6EDF3'
37const GOOD = '#3DD68C'
38const WARN = '#E3B341'
39
40const TYPED = new Set(['composer', 'bridge', 'sdk'])
41const SORT_TIMEOUT_MS = 4000
42const SORT_MAX_TOKENS = 8
43
44/** The `/config` row that holds the session's model, when this install has one. */
45async function modelRow($: Engine) {
46 try {
47 const rows = await $.config.list()
48 return rows.find(row => row.key === 'model' && !row.isLocked)
49 } catch {
50 return undefined
51 }
52}
53
54/** The model lines this install offers: read from its model picker, else the Agent tool's lines. */
55async function installLines($: Engine): Promise<string[]> {
56 const row = await modelRow($)
57 const names = [...(row?.options ?? []), typeof row?.value === 'string' ? row.value : '']
58 const fromPicker = linesIn(names)
59 if (fromPicker.length >= 2) return fromPicker
60 try {
61 const agent = (await $.tool.list()).find(tool => tool.name === 'Agent')
62 const fromAgent = linesIn([agent?.description ?? ''])
63 if (fromAgent.length >= 2) return fromAgent
64 } catch {
65 // fall through to the lines the engine's aliases name
66 }
67 return [...LINE_ORDER]
68}
69
70/** Asks the smallest model for one level; the words of the prompt decide when it cannot. */
71export async function sortPrompt($: Engine, text: string, lines: readonly string[]): Promise<Level> {
72 const small = lineFor('small', lines) ?? 'haiku'
73 try {
74 const reply = await $.model.complete({
75 model: small,
76 system: SORTER_SYSTEM,
77 prompt: `Request:\n${text.slice(0, 6000)}\n\nLevel:`,
78 maxTokens: SORT_MAX_TOKENS,
79 timeoutMs: SORT_TIMEOUT_MS,
80 })
81 if (reply.isAnswered) {
82 const level = parseLevel(reply.text)
83 if (level !== undefined) return level
84 }
85 } catch {
86 // the guess below answers
87 }
88 return guessLevel(text)
89}
90
91/**
92 * Puts the main conversation on `wanted`, without a word in the chat. When the
93 * model setting refuses it (a plan without that line), the next line down that
94 * the setting takes is the one locked.
95 */
96async function setMain($: Engine, wanted: string, level: Level, lines: readonly string[]) {
97 const row = await modelRow($)
98 let line = wanted
99 let isViaConfig = false
100 if (row !== undefined) {
101 for (const candidate of linesDownFrom(wanted, lines)) {
102 const option = row.options?.find(o => o.toLowerCase().includes(candidate)) ?? candidate
103 try {
104 const done = await $.config.set({ key: 'model', value: option })
105 isViaConfig = done.deny === undefined
106 } catch {
107 isViaConfig = false
108 }
109 if (isViaConfig) {
110 line = candidate
111 break
112 }
113 }
114 }
115 const seenModel = await $.session.model()
116 await learnModel($, seenModel)
117 await update($, lockAtom, lock => ({ ...lock, line, level, isFresh: false, seenModel, pendingHelper: null }))
118 $.ui.status(isViaConfig ? undefined : `Model: ${title(line)}`)
119 await rememberLock($, line, level)
120}
121
122async function knownIds($: Engine): Promise<KnownIds> {
123 return ((await $.store.get('ids')) ?? {}) as KnownIds
124}
125
126/** Remembers the full id behind a model the engine named, so a step can name it later. */
127async function learnModel($: Engine, model: string) {
128 const ids = await knownIds($)
129 const learned = learnId(ids, model)
130 if (learned !== ids) await $.store.set('ids', learned)
131}
132
133async function rememberLock($: Engine, line: string, level: Level) {
134 const id = await $.session.id()
135 const saved = ((await $.store.get('locks')) ?? {}) as Record<string, { line: string; level: Level }>
136 const kept = Object.entries(saved).filter(([key]) => key !== id).slice(-49)
137 await $.store.set('locks', Object.fromEntries([...kept, [id, { line, level }]]))
138}
139
140/** Saves the switch and remembers which model to give back once it is off. */
141async function setOn($: Engine, isOn: boolean) {
142 await $.store.set('isOn', isOn)
143 await update($, isOnAtom, () => isOn)
144 if (isOn) {
145 const isRestorePending = (await $.store.get('restorePending')) === true
146 if (!isRestorePending) {
147 const own = await usersModel($)
148 const row = own === undefined ? await modelRow($) : undefined
149 const previous = own ?? (typeof row?.value === 'string' ? row.value : undefined)
150 if (previous !== undefined) await $.store.set('restoreModel', previous)
151 }
152 await $.store.set('restorePending', false)
153 const isUnderWay = (await $.session.turns()) > 0
154 const seenModel = await $.session.model()
155 const current = linesIn([seenModel])[0] ?? null
156 await update($, lockAtom, lock =>
157 lock.line === null && isUnderWay && current !== null
158 ? { ...lock, line: current, level: levelOfLine(current), isFresh: false, isPinned: false, seenModel }
159 : { ...lock, isPinned: false },
160 )
161 return
162 }
163 await update($, lockAtom, turnedOff)
164 await $.store.set('restorePending', true)
165 const lock = await read($, lockAtom)
166 if (lock.line === null) await restoreModel($)
167}
168
169/** The model as the person wrote it in their own settings (`opus`, not a pinned id). */
170async function usersModel($: Engine): Promise<string | undefined> {
171 try {
172 const own = (await $.settings.read({ source: 'user' })) as { model?: unknown }
173 return typeof own.model === 'string' && own.model !== '' ? own.model : undefined
174 } catch {
175 return undefined
176 }
177}
178
179/** Gives the person's own model back for new sessions, once Auto Model is off. */
180async function restoreModel($: Engine) {
181 const previous = await $.store.get('restoreModel')
182 if ((await $.store.get('restorePending')) !== true) return
183 if (typeof previous === 'string' && previous !== '') {
184 try {
185 await $.config.set({ key: 'model', value: previous })
186 } catch {
187 // nothing to give back on an install without the row
188 }
189 }
190 await $.store.set('restorePending', false)
191}
192
193/** A model picked by hand wins: Auto Model turns off and lets go. */
194async function handPicked($: Engine) {
195 await $.store.set('isOn', false)
196 await $.store.set('restorePending', false)
197 await update($, isOnAtom, () => false)
198 await update($, lockAtom, letGo)
199 $.ui.status(undefined)
200}
201
202async function toggle($: Engine) {
203 await setOn($, !(await read($, isOnAtom)))
204}
205
206function statusText(isOn: boolean, lock: Lock): string {
207 return [
208 `Auto Model is ${isOn ? 'on' : 'off'}.`,
209 `Locked model: ${title(lock.line)}.`,
210 `Level that chose it: ${lock.level ?? 'none yet'}.`,
211 `Jobs sent to helpers this session: ${lock.helpers}.`,
212 ].join(' ')
213}
214
215async function runCommand($: Engine, args: string): Promise<string> {
216 const word = args.trim().toLowerCase().split(/\s+/)[0] ?? ''
217 const isOn = await read($, isOnAtom)
218 const lock = await read($, lockAtom)
219 if (word === 'on') {
220 if (isOn) return 'Auto Model is already on.'
221 await setOn($, true)
222 return lock.line === null || lock.isFresh
223 ? 'Auto Model is on. Your next typed prompt picks the model.'
224 : `Auto Model is on. This session stays on ${title(lock.line)} and steps up only for a harder job.`
225 }
226 if (word === 'off') {
227 if (!isOn) return 'Auto Model is already off.'
228 await setOn($, false)
229 return lock.line === null
230 ? 'Auto Model is off.'
231 : `Auto Model is off. This session stays on ${title(lock.line)}; your next new session starts on the model you used before.`
232 }
233 if (word === 'status' || word === '') return statusText(isOn, lock)
234 if (word === 'repick') {
235 if (!isOn) return 'Auto Model is off. Turn it on with /automodel on first.'
236 await update($, lockAtom, now => ({ ...now, isFresh: true }))
237 return 'Your next prompt is sorted fresh and may move the model up or down. A change of model rebuilds the cache once.'
238 }
239 return 'Use /automodel on, /automodel off, /automodel status or /automodel repick.'
240}
241
242// Damon's Tools (it registers /damons-tools) shows every plugin's switch in its
243// own dashboard and keeps the footer to one button; while it is installed this
244// plugin leaves its footer toggle out. Looked up at most every few seconds.
245let dashboardSeen: { at: number; isThere: boolean } | undefined
246async function hasDashboard($: {
247 clock: { now: () => Promise<number> }
248 command: { list: () => Promise<readonly { name: string }[]> }
249}): Promise<boolean> {
250 // Where the lookup cannot run, keep the toggle.
251 try {
252 const now = await $.clock.now()
253 if (dashboardSeen && now - dashboardSeen.at < 5000) return dashboardSeen.isThere
254 const isThere = (await $.command.list()).some(c => c.name === 'damons-tools')
255 dashboardSeen = { at: now, isThere }
256 return isThere
257 } catch {
258 return false
259 }
260}
261
262export const register: Register = on => {
263 on('session.start', async ($, e, next) => {
264 await $.command.register({
265 name: 'automodel',
266 description: 'Auto Model: on, off, status, or repick the model on your next prompt',
267 argumentHint: 'on | off | status | repick',
268 immediate: true,
269 })
270 const isOn = (await $.store.get('isOn')) === true
271 // Saved even when unchanged: Damon's Tools reads `isOn` as this plugin's switch.
272 await $.store.set('isOn', isOn)
273 await update($, isOnAtom, () => isOn)
274 if (!isOn) await restoreModel($)
275
276 return next(e)
277 })
278
279 on('classic.SessionStart', async ($, e, next) => {
280 if (e.source === 'clear') {
281 await update($, lockAtom, lock => ({ ...lock, isFresh: true, helpers: 0, pendingHelper: null }))
282 } else if (e.source === 'compact') {
283 await update($, lockAtom, lock => ({ ...lock, isFresh: true }))
284 } else if (e.source === 'resume') {
285 const saved = ((await $.store.get('locks')) ?? {}) as Record<string, { line: string; level: Level }>
286 const had = saved[e.session_id]
287 const seenModel = await $.session.model()
288 const line = had?.line ?? linesIn([seenModel])[0] ?? null
289 await update($, lockAtom, () => ({
290 ...EMPTY_LOCK,
291 line,
292 level: had?.level ?? (line === null ? null : levelOfLine(line)),
293 isFresh: false,
294 seenModel,
295 }))
296 }
297
298 return next(e)
299 }).catch(($, e, next) => next(e))
300
301 on('session.end', async ($, e, next) => {
302 if (e.reason === 'clear') {
303 await update($, lockAtom, lock => ({ ...lock, isFresh: true, helpers: 0, pendingHelper: null }))
304 }
305
306 return next(e)
307 })
308
309 on('session.compact', async ($, e, next) => {
310 const done = await next(e)
311 if (e.agentId === undefined) await update($, lockAtom, lock => ({ ...lock, isFresh: true }))
312
313 return done
314 }).catch(($, e, next) => next(e))
315
316 on('prompt.submit', async ($, e, next) => {
317 if (!TYPED.has(e.origin.kind) || e.turnId !== undefined) return next(e)
318
319 const before = await read($, lockAtom)
320 if (before.seenModel !== null && (await $.session.model()) !== before.seenModel) {
321 await handPicked($)
322 return next(e)
323 }
324 if (!(await read($, isOnAtom))) return next(e)
325
326 const text = e.text.trim()
327 if (text.startsWith('/') || isShortReply(text)) return next(e)
328
329 const lines = await installLines($)
330 const level = await sortPrompt($, text, lines)
331 const lock = await read($, lockAtom)
332 const decision = decide(lock, level, kindOf(text))
333
334 if (decision.kind === 'set') {
335 const line = lineFor(decision.level, lines)
336 if (line !== undefined) await setMain($, line, decision.level, lines)
337 return next(e)
338 }
339 if (decision.kind === 'note') {
340 const line = lineFor(decision.level, lines)
341 if (line !== undefined && line !== lock.line) {
342 await update($, lockAtom, now => ({ ...now, pendingHelper: line }))
343 return next({ ...e, context: [...(e.context ?? []), helperNote(line)] })
344 }
345 }
346 await update($, lockAtom, now => ({ ...now, pendingHelper: null }))
347
348 return next(e)
349 }).catch(($, e, next) => next(e))
350
351 on('turn.step', async function* ($, e, next) {
352 if (e.agentId !== undefined) return yield* next(e)
353 await learnModel($, e.model)
354 const lock = await read($, lockAtom)
355 const isHeld = lock.isPinned || (await read($, isOnAtom))
356 // A step's model goes to the API as written, so it must be a full id:
357 // a bare alias (`haiku`) is refused with a 404.
358 const model = isHeld ? stepModel(e.model, lock.line, await knownIds($)) : undefined
359
360 return yield* next(model === undefined ? e : { ...e, model })
361 })
362
363 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
364 const lock = await read($, lockAtom)
365 if (lock.pendingHelper !== null && e.model === lock.pendingHelper) {
366 await update($, lockAtom, now => ({ ...now, helpers: now.helpers + 1, pendingHelper: null }))
367 }
368
369 return next(e)
370 }).catch(($, e, next) => next(e))
371
372 on('command.run', { command: 'model' }, async ($, e, next) => {
373 const before = await $.session.model()
374 const done = await next(e)
375 const isPerson = e.origin?.kind === 'composer' || e.origin?.kind === 'bridge'
376 if (isPerson && (await $.session.model()) !== before) await handPicked($)
377
378 return done
379 }).catch(($, e, next) => next(e))
380
381 on('config.set', { key: 'model' }, async ($, e, next) => {
382 const done = await next(e)
383 const isPerson = e.origin?.kind === 'composer' || e.origin?.kind === 'bridge'
384 if (isPerson && done.deny === undefined) await handPicked($)
385
386 return done
387 }).catch(($, e, next) => next(e))
388
389 on('command.run', { command: 'automodel' }, async ($, e) => ({ text: await runCommand($, e.args) })).catch(
390 ($, e, next) => (next.called ? next(e) : { text: 'Auto Model could not do that just now. Try again in a moment.' }),
391 )
392
393 // The button sits in the footer beside whatever else is drawn there: the
394 // engine's mode labels and other mods' buttons all come through `next`.
395 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
396 if (await hasDashboard($)) return next(e)
397 const { Box, Button } = $.ui.resolve(e)
398 const isOn = await read($, isOnAtom)
399 const theirs = await next(e)
400
401 return (
402 <Box flexDirection="row" columnGap={1}>
403 {theirs}
404 <Box backgroundColor={HAIR} paddingX={1}>
405 <Button key="automodel-toggle" plain label={`Auto Model: ${isOn ? 'On' : 'Off'}`} onPress={() => toggle($)} />
406 </Box>
407 </Box>
408 )
409 })
410
411 on('ui.render', { component: 'CommandOutput', props: { command: 'automodel' } }, async ($, e, next) => {
412 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
413 if (!/^\s*(status)?\s*$/i.test(e.props.args)) return next(e)
414
415 const { Box, Text, Button, Code } = $.ui.resolve(e)
416 const isOn = await read($, isOnAtom)
417 const lock = await read($, lockAtom)
418 const width = Math.max(24, (e.viewport?.columns ?? 80) - 4)
419 const rule = (lead: string, color: string) => (
420 <Box flexDirection="row" width={width}>
421 <Box flexShrink={0}>
422 <Text color={color}>{lead}</Text>
423 </Box>
424 <Box flexGrow={1} flexShrink={1} overflow="hidden">
425 <Text color={HAIR} wrap="truncate">{'─'.repeat(width)}</Text>
426 </Box>
427 </Box>
428 )
429 const row = (label: string, value: string, color: string) => (
430 <Box flexDirection="row" height={1} overflow="hidden">
431 <Box width={15} flexShrink={0}>
432 <Text color={MUTED} wrap="truncate">{label}</Text>
433 </Box>
434 <Text color={color} wrap="truncate">{value}</Text>
435 </Box>
436 )
437 const digits = bigNumber(lock.helpers)
438
439 return (
440 <Box flexDirection="column" width={width}>
441 <Text bold color={TEXT} wrap="truncate">AUTO MODEL</Text>
442 {rule('━'.repeat(10), GOLD)}
443 <Box marginTop={1} flexDirection="column">
444 {rule('SWITCH ', MUTED)}
445 {row('Auto Model', isOn ? 'On' : 'Off', isOn ? GOOD : WARN)}
446 {row('Locked model', title(lock.line), TEXT)}
447 {row('Chosen by', lock.level ?? 'none yet', TEXT)}
448 </Box>
449 <Box marginTop={1} flexDirection="column">
450 {rule('HELPERS ', MUTED)}
451 <Box flexDirection="row">
452 {e.surface === 'desktop' ? (
453 <Box flexShrink={0}>
454 <Code source={digits.join('\n')} wrap="truncate-end" />
455 </Box>
456 ) : (
457 <Box borderStyle="round" borderColor={HAIR} paddingX={1} flexDirection="column" flexShrink={0}>
458 <Text color={TEXT}>{digits[0]}</Text>
459 <Text color={TEXT}>{digits[1]}</Text>
460 <Text color={TEXT}>{digits[2]}</Text>
461 </Box>
462 )}
463 <Box paddingLeft={2} flexDirection="column" justifyContent="center">
464 <Text color={MUTED} wrap="truncate">jobs sent to helpers this session</Text>
465 </Box>
466 </Box>
467 </Box>
468 <Box marginTop={1} flexDirection="row" columnGap={1}>
469 <Box backgroundColor={GOLD} paddingX={1}>
470 <Button key="automodel-switch" plain label={isOn ? 'Turn off' : 'Turn on'} onPress={() => toggle($)} />
471 </Box>
472 {isOn && (
473 <Box backgroundColor={HAIR} paddingX={1}>
474 <Button key="automodel-repick" plain label="Repick next prompt" onPress={() => runCommand($, 'repick')} />
475 </Box>
476 )}
477 </Box>
478 </Box>
479 )
480 })
481}
482hooks/logic.ts 210 lines1import type { Level, Lock } from '../types'
2
3export const LEVELS: readonly Level[] = ['small', 'everyday', 'strong', 'strongest']
4
5/** The four model lines, least to most capable, as the engine names their aliases. */
6export const LINE_ORDER: readonly string[] = ['haiku', 'sonnet', 'opus', 'fable']
7
8export const EMPTY_LOCK: Lock = {
9 line: null,
10 level: null,
11 isFresh: true,
12 isPinned: false,
13 seenModel: null,
14 helpers: 0,
15 pendingHelper: null,
16}
17
18export const SORTER_SYSTEM = [
19 'Sort the request into exactly one level. Answer with the one word only.',
20 'small: quick, simple jobs. Summarize, sort, extract, rename, translate, reformat, a short factual answer.',
21 'everyday: an email, a post, a short draft, a small edit, a simple question about one file, a quick chart.',
22 'strong: the default for anything that matters. Building or changing software, multi-step projects, research, analysis, long documents, planning, anything touching several files or tools.',
23 'strongest: only the very hardest. Deep research that runs for hours, a very large build or migration, a problem the strong model would likely fall short on.',
24 'When unsure, say strong.',
25].join('\n')
26
27export const rank = (level: Level): number => LEVELS.indexOf(level)
28
29/** Reads the sorter's one-word answer; undefined when it named no level. */
30export function parseLevel(text: string): Level | undefined {
31 const word = text.toLowerCase().match(/\b(strongest|strong|everyday|small)\b/)
32 return word ? (word[1] as Level) : undefined
33}
34
35const SHORT_REPLIES = new Set([
36 'yes', 'y', 'yep', 'yeah', 'sure', 'ok', 'okay', 'k', 'no', 'nope', 'thanks', 'thank you',
37 'go', 'go ahead', 'do it', 'keep going', 'continue', 'carry on', 'sounds good', 'looks good',
38 'lgtm', 'perfect', 'great', 'nice', 'please do', 'yes please', 'ok do it', 'proceed', 'next',
39])
40
41/** A short reply to what Claude just said: fewer than four words, or a known go-ahead. */
42export function isShortReply(text: string): boolean {
43 const clean = text.trim().toLowerCase().replace(/[.!?,]+$/g, '').replace(/\s+/g, ' ')
44 if (clean === '') return true
45 if (SHORT_REPLIES.has(clean)) return true
46 return clean.split(' ').length < 4
47}
48
49const STRONGEST = /\b(for hours|overnight|deep research|entire (codebase|system|repo)|whole (codebase|system|repo)|large[- ]scale|migrat(e|ion) (the|our|all|every)|from scratch.*(platform|system)|very large)\b/
50const STRONG = /\b(build|implement|refactor|debug|fix|architect\w*|design|research|analy[sz]\w*|plan\w*|project|app|application|feature|bug|test(s|ing)?|deploy|migrat\w*|several files|multiple files|codebase|repo|pipeline|investigate|integrat\w*|optimi[sz]\w*)\b/
51const EVERYDAY = /\b(email|e-mail|post|tweet|letter|reply to|draft|note|message|chart|graph|small edit|tweak|this file|one file|blurb|caption|bio)\b/
52const SMALL = /\b(summari[sz]e|sort|extract|rename|translate|reformat|format|convert|list|define|what is|what's|who is|when (is|was|did)|spell|capitali[sz]e|count|tl;?dr)\b/
53
54/** Guesses the level from the prompt's words when the sorter cannot answer: strong when in doubt. */
55export function guessLevel(text: string): Level {
56 const t = text.toLowerCase()
57 if (STRONGEST.test(t)) return 'strongest'
58 if (STRONG.test(t)) return 'strong'
59 if (EVERYDAY.test(t)) return 'everyday'
60 if (SMALL.test(t) && t.split(/\s+/).length <= 40) return 'small'
61 return 'strong'
62}
63
64export type Kind = 'answer' | 'work' | 'contextual'
65
66const ACTION = /\b(read|open|summari[sz]e|rename|reformat|format|convert|translate|extract|sort|clean up|tidy|list|count|draft|rewrite|proofread|merge|split)\b/
67const MATERIAL = /\b(file|files|folder|folders|directory|csv|docx|pdf|spreadsheet|xlsx|logs?)\b|[\w-]+\.(md|txt|ts|tsx|js|py|json|csv|docx|pdf|xlsx|log|html|ya?ml)\b|[\w.-]+[\\/][\w.-]+/
68const CONTEXTUAL = /\b(this|that|it|these|those|above|earlier|before|previous|again|same|we|our|us|you said|you just|your (last|previous)|the plan|the change|the fix|continue|so far)\b/
69
70/** A pasted text this long is worth a helper's fresh start. */
71export const LONG_TEXT = 1500
72
73/**
74 * What a later, smaller prompt needs: a one-line answer, a self-contained job,
75 * or the conversation. A job is only `work` when it has material to get through
76 * (files, or a long pasted text): a helper starts cold, loading its tools and
77 * instructions, so a short translation or rewrite is cheaper from the locked
78 * model's cache.
79 */
80export function kindOf(text: string): Kind {
81 const t = text.toLowerCase()
82 const hasMaterial = MATERIAL.test(t) || text.length >= LONG_TEXT
83 if (CONTEXTUAL.test(t) && text.length < LONG_TEXT) return 'contextual'
84 if (ACTION.test(t) && hasMaterial) return 'work'
85 return 'answer'
86}
87
88/** Picks the line for a level from the lines this install offers, stepping down when it lacks one. */
89export function lineFor(level: Level, lines: readonly string[]): string | undefined {
90 const ordered = LINE_ORDER.filter(line => lines.includes(line))
91 if (ordered.length === 0) return undefined
92 const wanted = Math.min(rank(level), LINE_ORDER.length - 1)
93 for (let i = wanted; i >= 0; i -= 1) {
94 const line = LINE_ORDER[i]
95 if (line !== undefined && ordered.includes(line)) return line
96 }
97 return ordered[0]
98}
99
100/** The full model ids seen for each line (`opus` → `claude-opus-5-5`), learned from the engine. */
101export type KnownIds = Readonly<Record<string, string>>
102
103/** True for an id the API takes, as against a bare alias (`haiku`) that it rejects. */
104export function isFullId(model: string): boolean {
105 return !LINE_ORDER.includes(model.trim().toLowerCase()) && linesIn([model]).length === 1
106}
107
108/** Adds the line `model` serves to `ids` when it is a full id not known yet; the same object otherwise. */
109export function learnId(ids: KnownIds, model: string): KnownIds {
110 const line = linesIn([model])[0]
111 if (line === undefined || !isFullId(model) || ids[line] === model) return ids
112 return { ...ids, [line]: model }
113}
114
115/**
116 * The model a main-loop step should name to stay on `line`: undefined when it
117 * already does, or when no full id is known for the line yet. Never a bare
118 * alias, which a turn step sends to the API as it is.
119 */
120export function stepModel(model: string, line: string | null, ids: KnownIds): string | undefined {
121 if (line === null || model.toLowerCase().includes(line)) return undefined
122 return ids[line]
123}
124
125/**
126 * `line` and the lines below it that `lines` offers, most capable first: the
127 * order to try them in when the model setting refuses one (a plan without Fable).
128 */
129export function linesDownFrom(line: string, lines: readonly string[]): string[] {
130 const at = LINE_ORDER.indexOf(line)
131 return LINE_ORDER.slice(0, at + 1).filter(l => l === line || lines.includes(l)).reverse()
132}
133
134/** The level a line serves, for a lock read back. */
135export function levelOfLine(line: string): Level {
136 const at = LINE_ORDER.indexOf(line)
137 return LEVELS[Math.max(0, Math.min(at, LEVELS.length - 1))] ?? 'strong'
138}
139
140/** Finds the model lines named in a list of model names or ids, least capable first. */
141export function linesIn(names: readonly string[]): string[] {
142 const text = names.join(' ').toLowerCase()
143 return LINE_ORDER.filter(line => text.includes(line))
144}
145
146export type Decision =
147 | { kind: 'set'; level: Level; reason: 'fresh' | 'step-up' }
148 | { kind: 'note'; level: Level }
149 | { kind: 'keep' }
150
151/**
152 * What to do with one sorted prompt. Fresh: pick, up or down. Locked: step up
153 * once when it sorts higher, never down; a smaller self-contained job gets a
154 * helper note; everything else stays on the locked model.
155 */
156export function decide(lock: Lock, level: Level, kind: Kind): Decision {
157 if (lock.isFresh || lock.level === null) return { kind: 'set', level, reason: 'fresh' }
158 if (rank(level) > rank(lock.level)) return { kind: 'set', level, reason: 'step-up' }
159 if (rank(level) < rank(lock.level) && rank(level) <= rank('everyday') && kind === 'work') {
160 return { kind: 'note', level }
161 }
162 return { kind: 'keep' }
163}
164
165/** The note that asks Claude to hand a small self-contained job to one helper. */
166export function helperNote(line: string): string {
167 return [
168 'Auto Model note: this request is a small, self-contained job.',
169 `Hand it to exactly one helper agent with the Agent tool, model "${line}".`,
170 'Give the helper only what it needs for this job (the file paths or the text itself), wait for it, then pass its result back to the user in your own reply.',
171 'If the job turns out to depend on this conversation, do it yourself instead.',
172 ].join(' ')
173}
174
175/** The state after the plugin turns Auto Model off: sorting stops, the model stays. */
176export function turnedOff(lock: Lock): Lock {
177 return { ...lock, isPinned: lock.line !== null, pendingHelper: null }
178}
179
180/** The state after a pick by hand: Auto Model lets go of the model entirely. */
181export function letGo(lock: Lock): Lock {
182 return { ...lock, line: null, level: null, isPinned: false, seenModel: null, pendingHelper: null }
183}
184
185/**
186 * Heavy box-drawing digits, three rows tall, from the common heavy glyphs only
187 * (no half-line glyphs, which the desktop's fonts lack).
188 */
189const DIGITS: Record<string, readonly [string, string, string]> = {
190 '0': ['┏━┓', '┃ ┃', '┗━┛'],
191 '1': ['━┓ ', ' ┃ ', '━┻━'],
192 '2': ['━━┓', '┏━┛', '┗━━'],
193 '3': ['━━┓', ' ━┫', '━━┛'],
194 '4': ['┃ ┃', '┗━┫', ' ┃'],
195 '5': ['┏━━', '┗━┓', '━━┛'],
196 '6': ['┏━━', '┣━┓', '┗━┛'],
197 '7': ['━━┓', ' ┃', ' ┃'],
198 '8': ['┏━┓', '┣━┫', '┗━┛'],
199 '9': ['┏━┓', '┗━┫', '━━┛'],
200}
201
202export function bigNumber(n: number): [string, string, string] {
203 const glyphs = String(Math.max(0, Math.floor(n))).split('').map(d => DIGITS[d] ?? DIGITS['0']!)
204 return [0, 1, 2].map(row => glyphs.map(g => g[row]).join(' ')) as [string, string, string]
205}
206
207export function title(line: string | null): string {
208 return line === null ? 'not picked yet' : line.charAt(0).toUpperCase() + line.slice(1)
209}
210types/index.d.ts 25 lines1export type Level = 'small' | 'everyday' | 'strong' | 'strongest'
2
3export type Lock = {
4 /** The model line the main conversation is locked to (`opus`), or null before the first pick. */
5 line: string | null
6 /** The level the sorter gave when that line was chosen. */
7 level: Level | null
8 /** True when the next typed prompt may pick fresh: a new session, /clear, a compaction, /automodel repick. */
9 isFresh: boolean
10 /** True while the main thread should stay on `line` even with Auto Model off (turned off mid-session). */
11 isPinned: boolean
12 /** The session model as last seen after Auto Model set it; a different one later means a pick by hand. */
13 seenModel: string | null
14 /** Jobs handed to helper agents this session. */
15 helpers: number
16 /** The helper line a note just asked for, until the Agent call that answers it. */
17 pendingHelper: string | null
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 automodel: { isOn: boolean; lock: Lock }
23 }
24}
25