Checks every prompt locally (zero tokens). When the current model is likely too weak or overkill, it blocks the prompt and shows a dialog ("Don't use X for…

Install (Claude Code 2.1.287 or later): /plugin install model-picker --marketplace samuelbfernandes/moddeck
Checks every prompt you type and tells you when your current model looks wrong for it, and which model to use instead. Nothing changes unless you pick it.
/clear), when the rules are confident a cheaper model will do, the whole conversation runs on it: every request names that model, so the cache is never thrown away. A toast says so, and the moddeck Session box shows your model → Haiku (routed). It never goes above the model you chose, and /model ends it until the next conversation. Setting autoRoute.routeSubagents.Haiku: weak, 1x; Sonnet: ok, 20x; Opus: best, 40x. If you are on Fable, a cheaper model can be recommended for light or standard work; Fable is never a target.codex --version works, the dialog says "A different model (Codex) catches more than Opus reviewing its own work" and offers Keep or Ask Codex; the status line says model-picker: review, Codex available. Without Codex it only says your model fits.effortWatch, on): the status line adds a note when the session's effort level does not fit the task, such as effort max is more than this needs · /effort medium for light or standard work, or effort low may be too little for this · /effort high for hard work. Advice only, never a dialog. The level is read from each model request; on Opus 5.5, Sonnet 5.5, Haiku 5.5 and Fable 5.1 changing effort keeps the prompt cache.model-picker: Opus fits, model-picker: overkill, Haiku would do (~40x cheaper), model-picker: weak for this, use Opus./clear. A task too hard for your model still offers Switch, with the re-cache cost stated.onDismiss to send if you prefer the old behaviour). The only ways the dialog stops asking are your own choices: "don't suggest again this session", model-picker off, or enabled = false.askOnStandard to true and they block too (a borderline or roomier-than-needed model).Don't use Sonnet for this task
Deep task · proof or derivation, REML
Haiku 1x weak · Sonnet 20x weak · Opus 40x best
Opus is the cheapest model that should handle it. Costlier and slower.
Options: Keep Sonnet · Switch session to Opus · Run this task as a subagent on Opus · Keep and don't suggest again.
The on/off switch is in the moddeck side bar (green suggest on, red suggest off; click it to flip). It is the same switch as /model-picker on|off and is remembered across sessions.
/model-picker [status|on|off|explain <text>]: status with the last 5 checks, the switch, and explain (a free dry run that prints the cues, the per-model ratings, costs and what it would do).
/model-picker off, enabled = false in /config, or /plugin disable model-picker.
minConfidence (0.6; a mismatch needs at least this much confidence to block), askOnStandard (false), onDismiss (hold or send), minWords (3; shorter prompts are ignored unless a strong rule matches), statusLine (true), enabled, useClassifier (false; when on, an unplaced prompt is sent once to haiku, which costs a small call). costRatios (haiku=1,sonnet=3,opus=5, fable= optional) is a rough ratio of list prices; it goes stale, so edit it. Bad pieces are ignored.
codex.cmd on PATH).hooks/register.tsx 500 lines1// model-picker: every prompt the person types is checked locally (keyword rules, no model call by
2// default, zero tokens) for two risks: the current model is likely too WEAK for the task, or it is
3// OVERKILL for it. Each check refreshes a one-line status ($.ui.status); only a mismatch at or above
4// the confidence bar opens a dialog and BLOCKS the prompt until it is answered: switch the session,
5// run just this task as a subagent, ask Codex for a read-only review, or keep going. There is no
6// cooldown and no "already asked about this" memory: every mismatch above the bar asks, every time. The
7// recommendation is the cheapest model predicted to be at least "ok" for the task, from rough, editable cost ratios. All of it is a prediction, never a
8// measurement.
9//
10// HOW THE DIALOG HOLDS THE PROMPT: a hook has 10 s of its OWN time, but the clock stops while
11// a `$` call is in flight, and `$.ui.ask` is one (the pattern command-guard uses). So the hook
12// simply awaits the question; the person may take minutes at no cost.
13//
14// FAIL OPEN for everything except the question itself: an error in the predictor, state, switch,
15// spawn or Codex sends the prompt exactly as typed. The deliberate drops are: a prompt sent to a subagent the person asked for, and a prompt
16// HELD because the person dismissed the dialog (Esc) with `onDismiss` = hold, the default. A held
17// prompt is echoed back in full so nothing typed is lost; `/model-picker off` ends any trap.
18import { atom, read, update } from 'claude-code'
19import type { EngineInterface, Register } from 'claude-code'
20
21import type { Run } from './actions'
22import { codexContext, delegateText, manualSwitchHint, probeCodex, runCodex, spawnSubagent, switchModel } from './actions'
23import { buildDialog, choiceOf, compactTable, isKeepOnly } from './dialog'
24import { dropWith } from './reply'
25import type { Choice } from './dialog'
26import { cheaperBy, effortAdvice, effortNote, judge, nameOf, parseCosts, ratingTable, recommend, statusText } from './fit'
27import type { Costs, Fit } from './fit'
28import { canRunAlone, familyOf, pushHistory, snippet } from './gate'
29import type { Alias, Family } from './gate'
30import { isNotATask } from './input'
31import { TIER_WORD, isStrong, predict, wordCount } from './predict'
32import type { Prediction } from './predict'
33import { MODEL_ID, cheaperFit } from './route'
34import type { SuggestModelEntry } from '../types'
35
36type Dollar = EngineInterface
37
38const seen = atom({ plugin: 'model-picker', key: 'seen' } as const, 0)
39const isMuted = atom({ plugin: 'model-picker', key: 'isMuted' } as const, false)
40/** Mirror of the persisted on/off switch (`$.store` 'off'), read by the moddeck side bar so it redraws when ANY path flips it. */
41const isOff = atom({ plugin: 'model-picker', key: 'isOff' } as const, false)
42const codexOk = atom({ plugin: 'model-picker', key: 'codexOk' } as const, null)
43const recent = atom({ plugin: 'model-picker', key: 'recent' } as const, [])
44/** The session's effort level as the last model request named it (empty: none seen, or a model without effort). */
45/** What the side bar's model row shows beside the effort when it does not fit the last task (`medium would do`). */
46/** The routed model's name for the moddeck Session box ('' when not routed). */
47const routedTo = atom({ plugin: 'model-picker', key: 'routedTo' } as const, '')
48const effortHint = atom({ plugin: 'model-picker', key: 'effortAdvice' } as const, '')
49const effortNow = atom({ plugin: 'model-picker', key: 'effort' } as const, '')
50
51// Only the person typing is ever analysed. 'bridge' is the same person on a phone or the web.
52const PERSON = new Set(['composer', 'bridge'])
53const KEEP_RECENT = 5
54const CLASSIFIER_MODEL = 'haiku'
55
56type Cfg = {
57 minConfidence: number
58 minWords: number
59 /** Ordinary (standard-tier) tasks also open the dialog when the model is borderline or has spare capacity. */
60 askOnStandard: boolean
61 /** What Esc does: 'hold' keeps the prompt from being sent, 'send' sends it as typed. */
62 onDismiss: 'hold' | 'send'
63 useClassifier: boolean
64 isEnabled: boolean
65 statusLine: boolean
66 /** Add a short note to the status line when the effort level does not match the task. */
67 effortWatch: boolean
68 /** Draw the on/off button in the band above the prompt. */
69 costs: Costs
70 /** The first prompt of a conversation picks a cheaper model for the whole conversation when one will do. */
71 autoRoute: boolean
72 /** A subagent started without a model gets the cheapest one that fits its task (never above the session's). */
73 routeSubagents: boolean
74 /** Pieces of the cost setting that were ignored (shown by explain and status). */
75 costProblems: string[]
76}
77
78function numberOf(v: unknown, fallback: number, lo: number, hi: number): number {
79 return typeof v === 'number' && Number.isFinite(v) ? Math.min(hi, Math.max(lo, v)) : fallback
80}
81
82export function readConfig(o: Readonly<Record<string, unknown>>): Cfg {
83 const { costs, problems } = parseCosts(o.costRatios)
84 return {
85 minConfidence: numberOf(o.minConfidence, 0.6, 0.5, 0.95),
86 minWords: Math.round(numberOf(o.minWords, 3, 1, 50)),
87 askOnStandard: o.askOnStandard === true,
88 onDismiss: o.onDismiss === 'send' ? 'send' : 'hold',
89 useClassifier: o.useClassifier === true,
90 isEnabled: o.enabled !== false,
91 statusLine: o.statusLine !== false,
92 effortWatch: o.effortWatch !== false,
93 costs,
94 autoRoute: o.autoRoute !== false,
95 routeSubagents: o.routeSubagents !== false,
96 costProblems: problems,
97 }
98}
99
100async function quietly<T>(f: () => T | Promise<T>): Promise<T | undefined> {
101 try {
102 return await f()
103 } catch {
104 return undefined
105 }
106}
107
108// ------------------------------------------------------------------ persisted choices
109
110type Counters = { accepted: number; dismissed: number; history: string[] }
111
112async function readCounters($: Dollar): Promise<Counters> {
113 const n = async (k: string) => {
114 const v = await quietly(() => $.store.get(k))
115 return typeof v === 'number' ? v : 0
116 }
117 const h = await quietly(() => $.store.get('history'))
118 const history = Array.isArray(h) ? h.filter((x): x is string => x === 'a' || x === 'd') : []
119 return { accepted: await n('accepted'), dismissed: await n('dismissed'), history }
120}
121
122/** Counts an answer: totals for the status report, a short history for the confidence nudge. */
123async function tally($: Dollar, kind: 'a' | 'd'): Promise<void> {
124 await quietly(async () => {
125 const c = await readCounters($)
126 await $.store.set(kind === 'a' ? 'accepted' : 'dismissed', (kind === 'a' ? c.accepted : c.dismissed) + 1)
127 await $.store.set('history', pushHistory(c.history, kind))
128 })
129}
130
131const OFF_STATUS = 'model-picker: off · type /model-picker on'
132
133/** The one place the switch changes, for the moddeck side bar and /model-picker alike. */
134async function setSwitch($: Dollar, cfg: Cfg, off: boolean): Promise<void> {
135 if (off) {
136 await $.store.set('off', true)
137 await showStatus($, cfg, OFF_STATUS) // visible even where the band is not drawn
138 } else {
139 await $.store.delete('off')
140 await showStatus($, cfg, undefined)
141 await update($, isMuted, () => false) // turning it back on also lifts a session mute
142 }
143 await quietly(() => update($, isOff, () => off))
144}
145
146async function isOn($: Dollar, cfg: Cfg): Promise<boolean> {
147 if (!cfg.isEnabled) return false
148 return (await quietly(() => $.store.get('off'))) !== true
149}
150
151// ------------------------------------------------------------------------ bookkeeping
152
153async function note($: Dollar, text: string, p: Prediction, outcome: string): Promise<void> {
154 const entry: SuggestModelEntry = { snippet: snippet(text), tier: p.tier, confidence: p.confidence, reasons: p.reasons.slice(0, 3), outcome }
155 await quietly(() => update($, recent, list => [...list, entry].slice(-KEEP_RECENT)))
156}
157
158const setOutcome = ($: Dollar, outcome: string) =>
159 quietly(() => update($, recent, list => list.map((x, i) => (i === list.length - 1 ? { ...x, outcome } : x))))
160
161/** The always-on one-liner. Non-blocking: a failure here changes nothing. */
162const showStatus = ($: Dollar, cfg: Cfg, text: string | undefined) =>
163 cfg.statusLine ? quietly(() => $.ui.status(text)) : Promise.resolve()
164
165// ------------------------------------------------------------------------ prediction
166
167/** One haiku call for a prompt the rules could not place; it can only confirm or replace the tier. */
168async function refine($: Dollar, text: string, p: Prediction, minConfidence: number): Promise<Prediction> {
169 const label = await quietly(() => $.model.classify(text, ['light', 'standard', 'deep'], { model: CLASSIFIER_MODEL }))
170 if (label !== 'light' && label !== 'standard' && label !== 'deep') return p
171 const confidence = Math.max(p.confidence, minConfidence) // the classifier settles the doubt either way
172 const reasons = [`small-model classifier says ${label}`, ...(label === p.tier ? p.reasons : [])]
173 return { tier: label, score: p.score, confidence, reasons }
174}
175
176const isAmbiguous = (p: Prediction, minConfidence: number): boolean => p.tier !== 'review' && p.confidence < minConfidence
177
178type Outcome = { kind: 'pass' } | { kind: 'drop'; reason: string } | { kind: 'context'; context: string[] } | { kind: 'rewrite'; text: string }
179const PASS: Outcome = { kind: 'pass' }
180
181type Mismatch = Extract<Fit, { kind: 'mismatch' }>
182type Ask = Mismatch | { kind: 'review' }
183
184// ------------------------------------------------------------------------ the hook
185
186async function review($: Dollar, e: { text: string; origin: { kind: string }; turnId?: string }, cfg: Cfg): Promise<Outcome> {
187 if (!(await isOn($, cfg))) return PASS
188 if (!PERSON.has(e.origin.kind) || e.turnId !== undefined) return PASS
189 const text = e.text.trim()
190 if (text === '' || isNotATask(text)) return PASS // slash commands, "yes", pasted output: nothing to judge
191
192 await update($, seen, n => n + 1)
193 let p = predict(text)
194 if (wordCount(text) < cfg.minWords && !isStrong(p)) return PASS
195
196 const chosen = familyOf((await quietly(() => $.session.model())) ?? '')
197 // The first prompt of a conversation: nothing is cached yet, so running it on a cheaper model costs nothing extra.
198 // The pick holds for the whole conversation (turn.step names it on every request), so the cache is never thrown away.
199 if (cfg.autoRoute && routed === undefined && lastResponseAt === undefined && !routeOff) {
200 const pick = cheaperFit(p, chosen, cfg.costs, cfg.minConfidence)
201 if (pick !== undefined) {
202 routed = pick
203 routedFrom = chosen
204 await quietly(() => update($, routedTo, () => nameOf(pick)))
205 await note($, text, p, `routed to ${pick}`)
206 await setOutcome($, `routed this conversation to ${pick}`)
207 $.ui.toast(`model-picker · this conversation runs on ${nameOf(pick)} (${p.tier} task) · /model to choose yourself`, { timeoutMs: 10_000 })
208 await showStatus($, cfg, `model-picker: on ${nameOf(pick)} for this conversation`)
209 return PASS
210 }
211 }
212 const current = routed ?? chosen
213 const minConfidence = cfg.minConfidence // fixed: dismissing a dialog never raises the bar
214 let fit = judge(p, current, minConfidence, cfg.costs, cfg.askOnStandard)
215
216 if (fit.kind === 'none' && cfg.useClassifier && current !== undefined && isAmbiguous(p, minConfidence)) {
217 p = await refine($, text, p, minConfidence)
218 fit = judge(p, current, minConfidence, cfg.costs, cfg.askOnStandard)
219 }
220 // A review on a model that fits is only worth a word when Codex can really do it; the probe runs once.
221 if (fit.kind === 'review' && !(await quietly(() => hasCodex($)))) fit = { kind: 'fits' }
222 await note($, text, p, 'not suggested')
223 const verdict = statusText(fit, current, cfg.costs)
224 const effortLevel = await read($, effortNow)
225 const effort = cfg.effortWatch && p.confidence >= minConfidence ? effortNote(p.tier, effortLevel) : undefined
226 await quietly(() => update($, effortHint, () => (cfg.effortWatch && p.confidence >= minConfidence ? (effortAdvice(p.tier, effortLevel) ?? '') : '')))
227 await showStatus($, cfg, verdict !== undefined && effort !== undefined ? `${verdict} · ${effort}` : (verdict ?? (effort !== undefined ? `model-picker: ${effort}` : undefined))) // a prompt the rules cannot place clears the line
228 if (fit.kind !== 'mismatch' && fit.kind !== 'review') {
229 return await skip($, fit.kind === 'none' ? `no: ${fit.why}` : fit.kind === 'fits' ? 'fits: no dialog' : 'hint on the status line only')
230 }
231 if (current === undefined) return PASS
232 if (await read($, isMuted)) return await skip($, 'no: muted this session')
233
234 const surfaces = await quietly(() => $.session.surfaces())
235 if (surfaces !== undefined && surfaces.length === 0) return await skip($, 'no: nobody to ask (headless run)')
236
237 return await offer($, cfg, e.text, p, fit, current)
238}
239
240const skip = async ($: Dollar, outcome: string): Promise<Outcome> => {
241 await setOutcome($, outcome)
242 return PASS
243}
244
245const runner = ($: Dollar): Run => (argv, init) => $.process.run(argv, init)
246
247async function hasCodex($: Dollar): Promise<boolean> {
248 const known = await read($, codexOk)
249 if (known !== null) return known
250 const ok = await probeCodex(runner($))
251 await update($, codexOk, () => ok)
252 return ok
253}
254
255/** When the main conversation last got a response: its cache lives a TTL from there. undefined = none yet (nothing to lose). */
256let lastResponseAt: number | undefined
257
258/** The cache is probably warm: a response inside the TTL (60 min on a subscription, 5 otherwise, as Claude Code defaults). */
259async function isCacheWarm($: Dollar, windows: readonly { kind: string }[]): Promise<boolean> {
260 if (lastResponseAt === undefined) return false
261 const ttlMs = (windows.some(w => w.kind === 'five_hour' || w.kind === 'seven_day') ? 60 : 5) * 60_000
262 const now = await $.clock.now().catch(() => Date.now())
263 return now - lastResponseAt < ttlMs
264}
265
266/** The model this conversation was routed to at its first prompt; undefined = the session's own. */
267let routed: Alias | undefined
268/** A model you picked yourself (/model) ends routing until the next conversation. */
269let routeOff = false
270/** The session model when routing began: a different one later means you switched with /model. */
271let routedFrom: Family | undefined
272
273async function offer($: Dollar, cfg: Cfg, text: string, p: Prediction, m: Ask, current: Family): Promise<Outcome> {
274 const usage = await quietly(() => $.session.usage())
275 const dialog = buildDialog({
276 prediction: p,
277 current,
278 risk: m.kind === 'review' ? 'self' : m.risk,
279 target: m.kind === 'review' ? undefined : m.recommended,
280 table: ratingTable(p.tier, current, cfg.costs),
281 cheaper: m.kind === 'review' ? '' : cheaperBy(cfg.costs, current, m.recommended),
282 contextTokens: usage?.context.tokens ?? 0,
283 isCacheWarm: await isCacheWarm($, usage?.rateLimits ?? []),
284 canSubagent: canRunAlone(text),
285 hasCodex: p.tier === 'review' && (await hasCodex($)),
286 })
287
288 if (isKeepOnly(dialog)) {
289 // The only real move would be a switch, and mid-session it costs more than it saves: no question, the prompt goes as typed.
290 await setOutcome($, 'kept the model: a switch would re-cache the conversation')
291 return PASS
292 }
293 let answer: string
294 try {
295 answer = await $.ui.ask(dialog.question, { options: dialog.order.map(c => dialog.labels[c]), header: 'Model choice' })
296 } catch {
297 // Esc or an interrupt. By default the prompt is HELD (not sent), and echoed so nothing typed is lost.
298 if (cfg.onDismiss === 'send') {
299 await setOutcome($, 'dialog dismissed: sent as typed')
300 return PASS
301 }
302 await setOutcome($, 'dialog dismissed: prompt held')
303 return { kind: 'drop', reason: heldMessage(text) }
304 }
305 const choice: Choice = choiceOf(dialog, answer) ?? 'keep' // free text under "Other" is read as keep
306 await setOutcome($, `chose: ${choice}`)
307 return await act($, cfg, choice, text, m.kind === 'review' ? undefined : m.recommended)
308}
309
310/** After Esc: a one-line reason (the drop box); the prompt text goes to the transcript so nothing typed is lost. */
311export function heldMessage(text: string): string {
312 const shown = text.length > 2000 ? `${text.slice(0, 2000)}...` : text
313 return ['model-picker blocked your prompt · nothing was sent. Send it again to choose, or type /model-picker off.', shown].join('\n')
314}
315
316async function act($: Dollar, cfg: Cfg, choice: Choice, text: string, alias: Alias | undefined): Promise<Outcome> {
317 if (choice === 'mute') {
318 await update($, isMuted, () => true)
319 await tally($, 'd')
320 $.ui.toast('model-picker · muted for this session. The status line stays.')
321 return PASS
322 }
323 if (choice === 'switch' && alias !== undefined) {
324 await tally($, 'a')
325 const r = await switchModel({ list: () => $.config.list(), set: (key, value) => $.config.set({ key, value }) }, alias)
326 $.ui.toast(r.isSwitched ? `model-picker · switched to ${nameOf(alias)}` : manualSwitchHint(alias))
327 if (r.isSwitched) await showStatus($, cfg, `model-picker: switched to ${nameOf(alias)}`)
328 return PASS // the prompt itself continues; the new model reads it if the switch took
329 }
330 if (choice === 'subagent' && alias !== undefined) {
331 await tally($, 'a')
332 const started = await spawnSubagent(args => $.agent.spawn(args), text, alias)
333 if (started.ok) return { kind: 'drop', reason: `Sent to a ${alias} subagent; its answer will appear when done` }
334 // A subagent a mod starts is refused where your own prompt did not ask for one (auto mode's check, for one). So the
335 // request goes into your prompt instead: Claude starts the subagent itself, as you asked.
336 $.ui.log(`model-picker · could not start the subagent directly (${started.why}); asking Claude to delegate instead`)
337 $.ui.toast(`Asking Claude to run this as a ${nameOf(alias)} subagent`, { timeoutMs: 6000 })
338 return { kind: 'rewrite', text: delegateText(text, alias) }
339 }
340 if (choice === 'codex') {
341 await tally($, 'a')
342 $.ui.toast('model-picker · asking Codex, read-only (can take a few minutes)', { timeoutMs: 8000 })
343 const r = await runCodex(runner($), await quietly(() => $.session.cwd()), text)
344 if (!r.ok) {
345 $.ui.toast(`model-picker · Codex did not answer (${r.why}), sending as typed`, { timeoutMs: 8000 })
346 return PASS
347 }
348 $.ui.log(`Codex review · read-only\n${r.text}`)
349 return { kind: 'context', context: [codexContext(r.text)] }
350 }
351 await tally($, 'd') // keep (or free text)
352 return PASS
353}
354
355// ----------------------------------------------------------------------- commands
356
357function verdictText(fit: Fit, current: Family | undefined): string {
358 if (fit.kind === 'none') return `no suggestion (${fit.why})`
359 const me = current === undefined ? 'the current model' : nameOf(current)
360 if (fit.kind === 'fits') return `${me} fits`
361 if (fit.kind === 'review') return `offers a Codex review (if installed): ${me} should not approve its own work`
362 const rec = nameOf(fit.recommended)
363 if (fit.kind === 'hint') return `status line only (${fit.why === 'borderline' ? `${me} is borderline` : 'spare capacity'}, ${rec} is the alternative; askOnStandard makes it ask)`
364 return fit.risk === 'under' ? `blocks with "Don't use ${me} for this task" and offers ${rec}` : `blocks with "${me} is overkill for this" and offers ${rec}`
365}
366
367async function explain($: Dollar, text: string, cfg: Cfg): Promise<string> {
368 if (text.trim() === '') return 'Usage: /model-picker explain <text to test>'
369 const p = predict(text)
370 const current = familyOf((await quietly(() => $.session.model())) ?? '')
371 const fit = judge(p, current, cfg.minConfidence, cfg.costs, cfg.askOnStandard)
372 const short = wordCount(text) < cfg.minWords && !isStrong(p) ? ' · too short, would be ignored' : ''
373 const skipped = isNotATask(text.trim()) ? ' · not a task, would not be checked' : ''
374 return [
375 `${TIER_WORD[p.tier].charAt(0)}${TIER_WORD[p.tier].slice(1).toLowerCase()} task · confidence ${p.confidence.toFixed(2)}${short}${skipped}`,
376 `Because ${p.reasons.join(', ')}`,
377 compactTable(ratingTable(p.tier, current, cfg.costs)),
378 `On ${current === undefined ? 'an unrecognised model' : nameOf(current)}: ${verdictText(fit, current)}.`,
379 `Dry run, nothing sent. Rough predictions, not measurements. Cheapest good enough: ${nameOf(recommend(p.tier, cfg.costs))}.`,
380 ...(cfg.costProblems.length > 0 ? [`Ignored in costRatios: ${cfg.costProblems.join(', ')}.`] : []),
381 ].join('\n')
382}
383
384async function status($: Dollar, cfg: Cfg): Promise<string> {
385 const on = await isOn($, cfg)
386 const c = await readCounters($)
387 const total = c.accepted + c.dismissed
388 const model = familyOf((await quietly(() => $.session.model())) ?? '')
389 const list = await read($, recent)
390 const rows = list.map(r => `${TIER_WORD[r.tier].toLowerCase()} ${r.confidence.toFixed(2)} “${r.snippet}” · ${r.reasons.join(', ')} → ${r.outcome}`)
391 const costs = (['haiku', 'sonnet', 'opus', 'fable'] as const).filter(f => cfg.costs[f] !== undefined).map(f => `${nameOf(f)} ${cfg.costs[f]}x`).join(' · ')
392 return [
393 `model-picker · ${on ? 'on' : 'off'} · ${model === undefined ? 'unrecognised model' : nameOf(model)}`,
394 on
395 ? `Blocks and asks on a mismatch (confidence ≥ ${cfg.minConfidence.toFixed(2)})${cfg.askOnStandard ? ', ordinary tasks too' : ''}. Esc ${cfg.onDismiss === 'hold' ? 'holds the prompt' : 'sends it'}.`
396 : 'Not checking prompts. Type /model-picker on to resume.',
397 `${costs} (rough, set in costRatios)`,
398 total === 0 ? 'No answers yet' : `${c.accepted} accepted · ${c.dismissed} dismissed`,
399 ...((await read($, isMuted)) ? ['Questions muted this session. Type /model-picker on to lift it.'] : []),
400 ...(cfg.costProblems.length > 0 ? [`Ignored in costRatios: ${cfg.costProblems.join(', ')}.`] : []),
401 ...(rows.length > 0 ? ['Recent', ...rows.map(r => ` ${r}`)] : []),
402 ].join('\n')
403}
404
405async function runCommand($: Dollar, args: string, cfg: Cfg): Promise<string> {
406 const [word = '', ...rest] = args.trim().split(/\s+/)
407 const arg = word.toLowerCase()
408 if (arg === 'explain') return explain($, rest.join(' '), cfg)
409 if (arg === 'off') {
410 await setSwitch($, cfg, true)
411 return 'model-picker · off\nNo prompt is checked. Type /model-picker on to resume.'
412 }
413 if (arg === 'on') {
414 if (!cfg.isEnabled) return 'model-picker · disabled\nThe enabled setting is false. Change it in /config.'
415 await setSwitch($, cfg, false)
416 }
417 const text = await status($, cfg)
418 return arg === '' || arg === 'on' || arg === 'status' ? text : `${text}\nTry: /model-picker · /model-picker on · /model-picker off · /model-picker explain <text>`
419}
420
421// --------------------------------------------------------------------- register
422
423export const register: Register = (on, options) => {
424 const cfg = readConfig(options)
425
426 // The main conversation's last response: its cache is warm for a TTL from here (subagents keep caches of their own).
427 // A subagent Claude starts without naming a model gets the cheapest one its task needs (never above the session's).
428 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
429 const input = e as unknown as { prompt?: unknown; model?: unknown; subagent_type?: unknown }
430 if (!cfg.routeSubagents || input.model !== undefined || input.subagent_type === 'fork' || typeof input.prompt !== 'string') return next(e)
431 const parent = routed ?? familyOf((await quietly(() => $.session.model())) ?? '')
432 const pick = cheaperFit(predict(input.prompt), parent, cfg.costs, cfg.minConfidence)
433 return next(pick === undefined ? e : ({ ...e, model: pick } as typeof e))
434 }).catch(($, e, next) => next(e))
435
436 // /clear starts an empty conversation: nothing cached to lose, and routing may pick again.
437 on('session.end', async ($, e, next) => {
438 if (e.reason === 'clear') {
439 lastResponseAt = undefined
440 routed = undefined
441 routeOff = false
442 await quietly(() => update($, routedTo, () => ''))
443 }
444 return next(e)
445 })
446
447 on('turn.complete', async ($, e, next) => {
448 if (e.agentId === undefined) lastResponseAt = await $.clock.now().catch(() => Date.now())
449 return next(e)
450 })
451
452 on('session.start', async ($, e, next) => {
453 await quietly(async () => {
454 const stored = (await $.store.get('off')) === true // the switch outlives the session
455 await update($, isOff, () => stored)
456 if (stored && cfg.isEnabled) await showStatus($, cfg, OFF_STATUS)
457 })
458 await quietly(() =>
459 $.command.register({
460 name: 'model-picker',
461 description: 'Suggest model: status, on, off, or explain <text>',
462 argumentHint: 'status|on|off|explain <text>',
463 }),
464 )
465 return next(e)
466 }).catch(($, e, next) => next(e))
467
468 on('command.run', { command: 'model-picker' }, async ($, e) => ({ text: await runCommand($, e.args, cfg) })).catch(() => ({
469 text: 'model-picker · that did not work, nothing changed',
470 }))
471
472 // Effort-watch only reads: the level of each main-thread request is kept (off the request's path) for the next prompt's status line.
473 // Each main-loop request: note its effort for the status line, and in a routed conversation name the routed model, so
474 // the conversation stays on one model (and one cache) throughout. A model you pick yourself with /model wins: routing
475 // stops until the next conversation.
476 on('turn.step', async function* ($, e, next) {
477 if (e.agentId !== undefined) return yield* next(e)
478 const level = typeof e.effort === 'string' ? e.effort : '' // state holds JSON data: empty means none
479 $.clock.after(1, () => void quietly(() => update($, effortNow, () => level)))
480 if (routed === undefined) return yield* next(e)
481 const now = familyOf((await quietly(() => $.session.model())) ?? '')
482 if (now !== routedFrom) {
483 routed = undefined
484 routeOff = true
485 await quietly(() => update($, routedTo, () => ''))
486 return yield* next(e)
487 }
488 return yield* next({ ...e, model: MODEL_ID[routed] })
489 })
490
491 // Exactly one `next` per path. A drop is answered WITHOUT `next`: after a subagent really started
492 // (the current model must not also answer).
493 on('prompt.submit', async ($, e, next) => {
494 const out = await review($, e, cfg).catch((): Outcome => PASS)
495 if (out.kind === 'drop') return dropWith(note => $.ui.log(note), out.reason)
496 if (out.kind === 'rewrite') return next({ ...e, text: out.text })
497 return next(out.kind === 'context' ? { ...e, context: [...(e.context ?? []), ...out.context] } : e)
498 }).catch(($, e, next) => next(e)) // fail open; if `next` already ran this replays its result
499}
500hooks/actions.ts 134 lines1// The three things a person can choose, each written to fail soft: an error is reported in
2// a toast or the log and the prompt then goes on untouched.
3import type { ConfigRow, ConfigSetResult, ProcessRunInit, ProcessRunResult } from 'claude-code'
4
5import { describeTask } from './gate'
6import type { Alias } from './gate'
7
8// The engine validator follows `$` only inside the file that names it, so these helpers take
9// small closures built in register.tsx (`run`, `config`, `spawn`) instead of `$` itself.
10export type Run = (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
11export type ConfigApi = { list: () => Promise<ConfigRow[]>; set: (key: string, value: string) => Promise<ConfigSetResult> }
12export type Spawn = (args: { prompt: string; description: string; model: string }) => Promise<{ deny?: string }>
13
14const CODEX_TIMEOUT_MS = 300_000
15const CODEX_PROBE_TIMEOUT_MS = 8_000
16const CODEX_MAX_PROMPT_CHARS = 8_000 // argv limits are tight on Windows
17const CODEX_MAX_REPLY_CHARS = 20_000
18
19export const CODEX_INSTRUCTIONS =
20 'Please review the following request. Try to falsify it rather than confirm it. Cite file:line for every finding, ' +
21 'and give PASS, FAIL or UNVERIFIABLE for each claim. Do not edit anything.'
22
23/**
24 * The auto-mode classifier sometimes answers an action started by a hook (not by a model request) with "gave no verdict ...
25 * Issue the action again once, as-is". That is a transient refusal: the same action is tried once more, never a third time.
26 */
27export const isNoVerdict = (why: string | undefined): boolean => why !== undefined && /no verdict|issue the action again/i.test(why)
28
29async function retriedOnce<T extends { deny?: string }>(attempt: () => Promise<T>): Promise<T> {
30 const first = await attempt()
31 return isNoVerdict(first.deny) ? await attempt() : first
32}
33
34// ------------------------------------------------------------------- model switch
35
36/**
37 * The /config row that holds the session model. Prefer the engine's `model` row; otherwise
38 * an ENGINE row whose key or label mentions "model" (never another plugin's `reflect.model`
39 * or similar) and which is not about fast mode, subagents or pricing.
40 */
41export function findModelRow(rows: readonly ConfigRow[]): ConfigRow | undefined {
42 const exact = rows.find(r => r.key === 'model')
43 if (exact) return exact
44 return rows.find(
45 r =>
46 r.provider.plugin === 'engine' &&
47 (r.kind === 'choice' || r.kind === 'text') &&
48 /model/i.test(`${r.key} ${r.label}`) &&
49 !/fast|small|subagent|agent|plan|pricing|effort|override/i.test(`${r.key} ${r.label}`),
50 )
51}
52
53/** The row's value for an alias: an exact option, else the shortest option containing it; the alias itself for a free-text row. */
54export function valueFor(row: ConfigRow, alias: Alias): string | undefined {
55 if (row.options === undefined) return row.kind === 'text' ? alias : undefined
56 const lower = alias.toLowerCase()
57 const options = row.options
58 return (
59 options.find(o => o.toLowerCase() === lower) ??
60 [...options.filter(o => o.toLowerCase().includes(lower))].sort((a, b) => a.length - b.length)[0]
61 )
62}
63
64export type SwitchResult = { isSwitched: true } | { isSwitched: false; why: string }
65
66/** Sets the model row; never throws. Callers toast `/model <alias>` on a failure. */
67export async function switchModel(config: ConfigApi, alias: Alias): Promise<SwitchResult> {
68 try {
69 const row = findModelRow(await config.list())
70 if (row === undefined) return { isSwitched: false, why: 'no model row in /config' }
71 if (row.isLocked) return { isSwitched: false, why: 'the model is set by policy' }
72 const value = valueFor(row, alias)
73 if (value === undefined) return { isSwitched: false, why: `no ${alias} option in /config` }
74 const result = await retriedOnce(() => config.set(row.key, value))
75 return result.deny === undefined ? { isSwitched: true } : { isSwitched: false, why: result.deny }
76 } catch (error) {
77 return { isSwitched: false, why: error instanceof Error ? error.message : 'the change was refused' }
78 }
79}
80
81export const manualSwitchHint = (alias: Alias): string =>
82 `model-picker: could not switch automatically. Type /model ${alias} yourself.`
83
84// ---------------------------------------------------------------------- subagent
85
86/** Starts the subagent in the background. Never throws: `ok`, or `why` (the engine's own reason, cut short). */
87export async function spawnSubagent(spawn: Spawn, text: string, alias: Alias): Promise<{ ok: true } | { ok: false; why: string }> {
88 try {
89 const r = await retriedOnce(() => spawn({ prompt: text, description: describeTask(text), model: alias }))
90 return r.deny === undefined ? { ok: true } : { ok: false, why: String(r.deny).slice(0, 160) }
91 } catch (error) {
92 return { ok: false, why: (error instanceof Error ? error.message : 'the call failed').slice(0, 160) }
93 }
94}
95
96/** Your prompt with your choice spelled out, so Claude delegates it: the subagent is then one you asked for. */
97export const delegateText = (text: string, alias: Alias): string =>
98 `${text}\n\n(I chose to run this as a subagent on ${alias}: use the Agent tool with model "${alias}" and give it this task in full; do not do the task yourself.)`
99
100// ------------------------------------------------------------------------- codex
101
102/** `codex --version` works? Both a missing program (rejects) and a failing one count as no. */
103export async function probeCodex(run: Run): Promise<boolean> {
104 try {
105 return (await run(['codex', '--version'], { timeoutMs: CODEX_PROBE_TIMEOUT_MS })).exitCode === 0
106 } catch {
107 return false
108 }
109}
110
111export type CodexResult = { ok: true; text: string } | { ok: false; why: string }
112
113/** One read-only, ephemeral Codex review of the person's prompt. Best effort: never throws. */
114export async function runCodex(run: Run, cwd: string | undefined, prompt: string): Promise<CodexResult> {
115 try {
116 const body = prompt.length > CODEX_MAX_PROMPT_CHARS ? `${prompt.slice(0, CODEX_MAX_PROMPT_CHARS)} [cut]` : prompt
117 const r = await run(
118 ['codex', 'exec', '-s', 'read-only', '--ephemeral', ...(cwd ? ['-C', cwd] : []), `${CODEX_INSTRUCTIONS}\n\n${body}`],
119 { timeoutMs: CODEX_TIMEOUT_MS },
120 )
121 const out = r.stdout.trim()
122 if (r.exitCode !== 0) return { ok: false, why: firstLine(r.stderr) || `exit code ${r.exitCode}` }
123 if (out === '') return { ok: false, why: 'Codex returned nothing' }
124 return { ok: true, text: out.length > CODEX_MAX_REPLY_CHARS ? `${out.slice(0, CODEX_MAX_REPLY_CHARS)}\n[cut]` : out }
125 } catch (error) {
126 return { ok: false, why: error instanceof Error ? error.message : 'could not run codex' }
127 }
128}
129
130const firstLine = (s: string): string => s.trim().split(/\r?\n/)[0]?.slice(0, 160) ?? ''
131
132export const codexContext = (reply: string): string =>
133 `A read-only Codex review of the request above, run before this turn. Treat it as a second opinion: check its claims before relying on them.\n\n${reply}`
134hooks/dialog.ts 109 lines1// The question and its options. Pure text, so the dialog is the same on every surface
2// (the engine draws `$.ui.ask` itself).
3import { fmtRatio, nameOf } from './fit'
4import type { Risk, Row } from './fit'
5import { BIG_CONTEXT_TOKENS, fmtTokens } from './gate'
6import type { Alias, Family } from './gate'
7import { TIER_WORD } from './predict'
8import type { Prediction } from './predict'
9
10export type DialogInput = {
11 prediction: Prediction
12 current: Family
13 /** 'self': a review by the model that would also have written the code; no model move is offered. */
14 risk: Risk | 'self'
15 /** The model to move to: the cheapest one predicted to handle the task. Absent for 'self'. */
16 target?: Alias
17 /** Per-model fit and rough relative cost, as shown in the question. */
18 table: readonly Row[]
19 /** "~40x cheaper" or ''. */
20 cheaper: string
21 contextTokens: number
22 /** The current model's prompt cache is probably still warm: a switch would throw it away and re-cache the context. */
23 isCacheWarm: boolean
24 canSubagent: boolean
25 /** Codex is offered (review work and `codex --version` worked). */
26 hasCodex: boolean
27}
28
29export type Choice = 'keep' | 'switch' | 'subagent' | 'codex' | 'mute'
30export type Dialog = { question: string; labels: Record<Choice, string>; order: Choice[] }
31
32export function buildDialog(d: DialogInput): Dialog {
33 const me = nameOf(d.current)
34 const to = d.target === undefined ? '' : nameOf(d.target)
35 const labels: Record<Choice, string> = {
36 keep: `Keep ${me}`,
37 switch: `Switch session to ${to}`,
38 subagent: `Run this task as a subagent on ${to}`,
39 codex: 'Ask Codex for a read-only review',
40 mute: `Keep ${me} and don't suggest again this session`,
41 }
42 const isBig = d.contextTokens > BIG_CONTEXT_TOKENS
43 const isReview = d.prediction.tier === 'review'
44 // Mid-session, a switch re-caches the whole conversation on the new model (and again on the way back), which costs more
45 // than a cheaper model saves on a task or two. So for an overkill task with a warm cache and a big context the move is
46 // a subagent (a fresh, small context) or nothing; a switch is offered when the cache is cold or the context small.
47 const isCostlySwitch = isBig && d.isCacheWarm
48 const noSwitch = isCostlySwitch && d.risk === 'over'
49 // The ask dialog takes at most 4 options, so a review trades the subagent for Codex.
50 const move: Choice[] =
51 d.target === undefined ? ['codex'] : isReview ? (noSwitch ? ['codex'] : ['switch', 'codex']) : noSwitch ? ['subagent'] : isBig && d.canSubagent ? ['subagent', 'switch'] : ['switch', 'subagent']
52 const order = ['keep' as const, ...move.filter(c => available(c, d)), 'mute' as const]
53 return { question: questionText(d, isBig, noSwitch), labels, order }
54}
55
56function available(c: Choice, d: DialogInput): boolean {
57 if (c === 'subagent') return d.canSubagent
58 if (c === 'codex') return d.hasCodex
59 return true
60}
61
62const taskWord = (p: Prediction): string => `${TIER_WORD[p.tier].charAt(0)}${TIER_WORD[p.tier].slice(1).toLowerCase()} task`
63
64/** "Haiku 1x weak · Sonnet 20x weak · Opus 40x best": one quiet line instead of a sentence. */
65export function compactTable(rows: readonly Row[]): string {
66 return rows.map(r => `${nameOf(r.family)} ${r.cost === undefined ? '' : `${fmtRatio(r.cost)} `}${r.rating}`).join(' · ')
67}
68
69function questionText(d: DialogInput, isBig: boolean, noSwitch: boolean): string {
70 const { prediction: p, current } = d
71 const me = nameOf(current)
72 if (d.risk === 'self' || d.target === undefined) return selfReviewText(d, me)
73 const target = nameOf(d.target)
74 const why = p.reasons.slice(0, 3).join(', ')
75 const lines = [
76 d.risk === 'under' ? `Don't use ${me} for this task` : `${me} is overkill for this`,
77 `${taskWord(p)} · ${why}`,
78 compactTable(d.table),
79 d.risk === 'under'
80 ? `${target} is the cheapest model that should handle it. Costlier and slower.`
81 : `${target} would do${d.cheaper ? ` · ${d.cheaper}` : ''}.`,
82 ]
83 const notes: string[] = []
84 if (noSwitch) notes.push(`Not switching: the session would re-cache ~${fmtTokens(d.contextTokens)} tokens on ${target}, more than this task saves. A subagent starts fresh. Switch at a natural break (after /clear, or once the cache has gone cold).`)
85 else if (isBig && d.isCacheWarm) notes.push(`Switching re-caches ~${fmtTokens(d.contextTokens)} tokens on ${target} (and again on the way back); a subagent starts fresh.`)
86 else if (isBig) notes.push(`The cache has gone cold, so switching now costs little extra; a subagent starts fresh.`)
87 else if (d.canSubagent && p.tier !== 'review') notes.push('A subagent has no memory of this chat.')
88 if (d.hasCodex) notes.push('Codex reviews read-only and uses Codex credit.')
89 return [...lines, ...(notes.length > 0 ? ['', ...notes] : [])].join('\n')
90}
91
92function selfReviewText(d: DialogInput, me: string): string {
93 return [
94 `Get a second opinion on this`,
95 `Review · ${d.prediction.reasons.slice(0, 3).join(', ')}`,
96 `A different model (Codex) catches more than ${me} reviewing its own work.`,
97 '',
98 'Read-only · uses Codex credit.',
99 ].join('\n')
100}
101
102/** True when the dialog offers nothing but keeping the model: not worth asking. */
103export const isKeepOnly = (dialog: Dialog): boolean => dialog.order.every(c => c === 'keep' || c === 'mute')
104
105/** Maps the exact label the person chose back to a choice; anything else (free text under Other) is undefined. */
106export function choiceOf(dialog: Dialog, answer: string): Choice | undefined {
107 return dialog.order.find(c => dialog.labels[c] === answer)
108}
109hooks/reply.ts 31 lines1/**
2 * A `prompt.submit` drop reason must be ONE line: the engine draws a "\n" in it as a stray "?" mark.
3 * `dropWith` splits a multi-line text: the first line becomes the reason, the rest goes to the transcript
4 * as one dim, multi-line note (`$.ui.log`, never sent to the model).
5 */
6
7// Control characters (tabs, carriage returns, escapes, ...) that would break a one-line reason.
8const CONTROL = /[\u0000-\u001f\u007f-\u009f]+/g
9
10/** A single line with control characters turned into spaces and the ends trimmed. */
11export const oneLine = (text: string): string => text.replace(CONTROL, ' ').replace(/ {2,}/g, ' ').trim()
12
13/** `log` is the caller's `(note) => $.ui.log(note)`: the engine refuses `$.ui` handed around as a value. */
14export function dropWith(log: (text: string) => void, text: string): { drop: string } {
15 const lines = text
16 .split(/\r\n|\r|\n/)
17 .map(line => line.trim())
18 .filter(line => line !== '')
19 const drop = oneLine(lines[0] ?? '') || 'model-picker'
20 const rest = lines.slice(1)
21 if (rest.length > 0) {
22 try {
23 // A note is a courtesy: a failing log must never undo the drop.
24 void Promise.resolve(log(rest.join('\n'))).catch(() => undefined)
25 } catch {
26 // keep the drop
27 }
28 }
29 return { drop }
30}
31hooks/fit.ts 171 lines1// Per-model fit and cost: for a predicted tier, rate every candidate model, recommend the
2// cheapest one rated at least "ok", and turn that into a verdict. Pure functions.
3//
4// Everything here is PREDICTED from keyword rules and ROUGH list-price ratios the person can edit.
5// Nothing is measured: no real prices, no benchmark scores.
6import type { Alias, Family } from './gate'
7import { RANK } from './gate'
8import type { Prediction, Tier } from './predict'
9
10export type Rating = 'weak' | 'ok' | 'best'
11export type Costs = Partial<Record<Family, number>>
12
13export const CANDIDATES: readonly Alias[] = ['haiku', 'sonnet', 'opus']
14export const DEFAULT_COSTS_TEXT = 'haiku=1,sonnet=20,opus=40'
15const DEFAULT_COSTS: Costs = { haiku: 1, sonnet: 20, opus: 40 }
16
17/** best = the most capable model; ok = predicted good enough; weak = likely to struggle. Review work is rated like deep work. */
18const RATING: Record<Exclude<Tier, 'review'>, Record<Family, Rating>> = {
19 light: { haiku: 'ok', sonnet: 'ok', opus: 'best', fable: 'best' },
20 standard: { haiku: 'weak', sonnet: 'ok', opus: 'best', fable: 'best' },
21 deep: { haiku: 'weak', sonnet: 'weak', opus: 'best', fable: 'best' },
22}
23
24export const ratingOf = (tier: Tier, family: Family): Rating => RATING[tier === 'review' ? 'deep' : tier][family]
25
26export const nameOf = (f: Family): string => f.charAt(0).toUpperCase() + f.slice(1)
27
28// ------------------------------------------------------------------------ cost config
29
30const FAMILIES: readonly Family[] = ['haiku', 'sonnet', 'opus', 'fable']
31
32/**
33 * Parses "haiku=1,sonnet=20,opus=40" (fable optional; commas, semicolons or spaces; "3x" is fine).
34 * Bad pieces are ignored, one by one, and reported in `problems`; a missing or bad model keeps its
35 * default (fable has none: its cost is simply unknown).
36 */
37export function parseCosts(raw: unknown): { costs: Costs; problems: string[] } {
38 const costs: Costs = { ...DEFAULT_COSTS }
39 const problems: string[] = []
40 if (typeof raw !== 'string' || raw.trim() === '') return { costs, problems }
41 for (const piece of raw.split(/[,;\s]+/).filter(Boolean)) {
42 const m = /^([a-z]+)=(\d+(?:\.\d+)?)x?$/i.exec(piece)
43 const family = FAMILIES.find(f => f === m?.[1]?.toLowerCase())
44 const value = Number(m?.[2])
45 if (m === null || family === undefined || !Number.isFinite(value) || value <= 0 || value > 10_000) {
46 problems.push(piece.slice(0, 30))
47 continue
48 }
49 costs[family] = value
50 }
51 return { costs, problems }
52}
53
54/** A model with no known cost (fable by default) counts as the most expensive. */
55const costOf = (costs: Costs, f: Family): number => costs[f] ?? Number.POSITIVE_INFINITY
56
57export function fmtRatio(n: number): string {
58 return `${Number.isInteger(n) ? n : n.toFixed(1).replace(/\.0$/, '')}x`
59}
60
61// ------------------------------------------------------------------ rating and choice
62
63export type Row = { family: Family; rating: Rating; cost: number | undefined }
64
65/** Haiku, Sonnet, Opus (and the current model if it is Fable), each with its predicted rating and cost. */
66export function ratingTable(tier: Tier, current: Family | undefined, costs: Costs): Row[] {
67 const families: Family[] = current === 'fable' ? [...CANDIDATES, 'fable'] : [...CANDIDATES]
68 return families.map(family => ({ family, rating: ratingOf(tier, family), cost: costs[family] }))
69}
70
71export function tableText(rows: readonly Row[]): string {
72 return rows.map(r => `${nameOf(r.family)}: ${r.rating}, ${r.cost === undefined ? 'cost not set' : fmtRatio(r.cost)}`).join('; ')
73}
74
75/** The cheapest candidate rated at least "ok" (ties go to the weaker model). Never fable. */
76export function recommend(tier: Tier, costs: Costs): Alias {
77 const fit = CANDIDATES.filter(a => ratingOf(tier, a) !== 'weak')
78 return [...fit].sort((a, b) => costOf(costs, a) - costOf(costs, b) || RANK[a] - RANK[b])[0] ?? 'opus'
79}
80
81/** "~40x cheaper", or '' when it cannot be said (unknown cost, or not actually cheaper). */
82export function cheaperBy(costs: Costs, from: Family, to: Family): string {
83 const a = costOf(costs, from)
84 const b = costOf(costs, to)
85 return Number.isFinite(a) && b > 0 && a > b ? `~${fmtRatio(Math.round((a / b) * 10) / 10)} cheaper` : ''
86}
87
88// ------------------------------------------------------------------------- the verdict
89
90export type Risk = 'under' | 'over'
91
92export type Fit =
93 | { kind: 'none'; why: string }
94 /** The current model suits the task. */
95 | { kind: 'fits' }
96 /** Worth a word on the status line, never a dialog. */
97 | { kind: 'hint'; recommended: Alias; why: 'borderline' | 'spare' }
98 /**
99 * A review-type task on a model that is strong enough: the point is that a DIFFERENT model reviews it,
100 * not capability. Only a Codex offer makes sense; the caller turns this into `fits` when Codex is absent.
101 */
102 | { kind: 'review' }
103 /** A mismatch: this is what may open the dialog. */
104 | { kind: 'mismatch'; risk: Risk; recommended: Alias }
105
106/** A standard task needs this much confidence before even a status-line hint is shown. */
107export const STANDARD_MIN = 0.7
108
109/**
110 * `strict` turns the two standard-tier hints into mismatches (borderline = 'under', spare capacity = 'over'),
111 * so ordinary tasks can block too. Off by default: a standard task is usually fine on Sonnet.
112 */
113export function judge(p: Prediction, current: Family | undefined, minConfidence: number, costs: Costs, strict = false): Fit {
114 if (current === undefined) return { kind: 'none', why: 'current model not recognised' }
115 const floor = p.tier === 'standard' ? Math.max(minConfidence, STANDARD_MIN) : minConfidence
116 if (p.confidence < floor) return { kind: 'none', why: `confidence ${p.confidence.toFixed(2)} below ${floor.toFixed(2)}` }
117 const recommended = recommend(p.tier, costs)
118 const rating = ratingOf(p.tier, current)
119 const isCheaper = costOf(costs, recommended) < costOf(costs, current)
120 if (p.tier === 'deep' || p.tier === 'review') {
121 if (rating === 'weak') return { kind: 'mismatch', risk: 'under', recommended }
122 return p.tier === 'review' ? { kind: 'review' } : { kind: 'fits' }
123 }
124 if (p.tier === 'light') return isCheaper ? { kind: 'mismatch', risk: 'over', recommended } : { kind: 'fits' }
125 if (rating === 'weak') return strict ? { kind: 'mismatch', risk: 'under', recommended } : { kind: 'hint', recommended, why: 'borderline' }
126 if (!isCheaper) return { kind: 'fits' }
127 return strict ? { kind: 'mismatch', risk: 'over', recommended } : { kind: 'hint', recommended, why: 'spare' }
128}
129
130// ------------------------------------------------------------------------- effort
131
132/**
133 * Effort-watch: a short note when the session's effort level does not match the task, or undefined. Advice only, on the status
134 * line, never a dialog. On Opus 5.5, Sonnet 5.5, Haiku 5.5 and Fable 5.1 changing effort keeps the prompt cache, so /effort is
135 * a cheap move there; on older models it re-reads the conversation once. Anthropic's guidance is medium for most work, high or
136 * xhigh when it matters, and max rarely (it "may show diminishing returns and is prone to overthinking").
137 */
138export function effortNote(tier: Tier, effort: unknown): string | undefined {
139 if (typeof effort !== 'string') return undefined
140 const level = effort.toLowerCase()
141 if (level === 'max' && (tier === 'light' || tier === 'standard')) return 'effort max is more than this needs · /effort medium'
142 if (level === 'xhigh' && tier === 'light') return 'effort xhigh is more than this needs · /effort medium'
143 if (level === 'low' && (tier === 'deep' || tier === 'review')) return 'effort low may be too little for this · /effort high'
144 return undefined
145}
146
147/** The same check as a few words for the moddeck's model row ("medium would do", "try high"), or undefined when the effort fits. */
148export function effortAdvice(tier: Tier, effort: unknown): string | undefined {
149 const note = effortNote(tier, effort)
150 return note === undefined ? undefined : note.includes('/effort high') ? 'try high' : 'medium would do'
151}
152
153// ------------------------------------------------------------------------- status line
154
155/** The always-on one-liner. Undefined clears the line (nothing placed, or the model is not recognised). */
156export function statusText(fit: Fit, current: Family | undefined, costs: Costs): string | undefined {
157 if (current === undefined || fit.kind === 'none') return undefined
158 const me = nameOf(current)
159 if (fit.kind === 'fits') return `model-picker: ${me} fits`
160 if (fit.kind === 'review') return 'model-picker: review, Codex available'
161 const rec = nameOf(fit.recommended)
162 if (fit.kind === 'hint') {
163 if (fit.why === 'borderline') return `model-picker: ${me} is borderline for this, ${rec} is safer`
164 const by = cheaperBy(costs, current, fit.recommended)
165 return `model-picker: ${rec} would do${by ? ` (${by})` : ''}`
166 }
167 if (fit.risk === 'under') return `model-picker: weak for this, use ${rec}`
168 const by = cheaperBy(costs, current, fit.recommended)
169 return `model-picker: overkill, ${rec} would do${by ? ` (${by})` : ''}`
170}
171hooks/gate.ts 50 lines1// Gating: whether a prediction is worth interrupting the person for. Pure functions.
2import { namedFiles, refsPriorContext } from './predict'
3
4export type Alias = 'haiku' | 'sonnet' | 'opus'
5/** What a resolved model id can map to; `fable` is known but never a target. */
6export type Family = Alias | 'fable'
7
8export const RANK: Record<Family, number> = { haiku: 1, sonnet: 2, opus: 3, fable: 4 }
9
10/** Maps a resolved model id (`claude-sonnet-4-5`, `opus`) to its family; undefined when unknown. */
11export function familyOf(modelId: string): Family | undefined {
12 const id = modelId.toLowerCase()
13 return (['haiku', 'sonnet', 'opus', 'fable'] as const).find(f => id.includes(f))
14}
15
16/** Pushes an answer ('a' accepted, 'd' dismissed) onto the short history kept for the status report. */
17export const HISTORY_WINDOW = 10
18
19export const pushHistory = (history: readonly string[], item: 'a' | 'd'): string[] =>
20 [...history, item].slice(-HISTORY_WINDOW)
21
22// ------------------------------------------------------------------ subagent fitness
23
24export { refsPriorContext }
25
26/** A subagent starts with no conversation: fine for a self-contained prompt or one that names files. */
27export const canRunAlone = (text: string): boolean => !refsPriorContext(text) || namedFiles(text).length > 0
28
29// ----------------------------------------------------------------- display helpers
30
31export function fmtTokens(n: number): string {
32 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
33 if (n >= 1000) return `${Math.round(n / 1000)}k`
34 return String(Math.round(n))
35}
36
37export const BIG_CONTEXT_TOKENS = 20_000
38
39/** First few words of a prompt, for a subagent's task label. */
40export function describeTask(text: string): string {
41 const words = text.trim().split(/\s+/).slice(0, 6).join(' ')
42 return words.length > 40 ? `${words.slice(0, 37)}...` : words
43}
44
45/** A short single-line snippet for the status list. */
46export function snippet(text: string, max = 50): string {
47 const one = text.replace(/\s+/g, ' ').trim()
48 return one.length > max ? `${one.slice(0, max - 3)}...` : one
49}
50hooks/input.ts 33 lines1// What is NOT worth evaluating. Pure functions, no I/O.
2
3const CONFIRMATIONS = new Set([
4 'y', 'yes', 'yep', 'yeah', 'yup', 'ok', 'okay', 'k', 'sure', 'no', 'n', 'nope', 'thanks', 'thank you', 'thx', 'ty',
5 'go', 'go ahead', 'go on', 'continue', 'proceed', 'do it', 'please do', 'sounds good', 'looks good', 'lgtm', 'great',
6 'perfect', 'cool', 'nice', 'done', 'stop', 'retry', 'again', 'next',
7])
8
9/** A bare yes/ok/thanks style reply: it answers the model, it is not a new task. */
10export function isConfirmation(text: string): boolean {
11 const t = text.trim().toLowerCase().replace(/[.!\s]+$/g, '').replace(/\s+/g, ' ')
12 return t.length <= 24 && CONFIRMATIONS.has(t)
13}
14
15// Deliberately narrow: markdown bullets, numbered lists and quotes are what a real request looks like.
16const OUTPUT_LINE =
17 /^\s*(?:\$ |>>> |\d+\s*[:|]\s|-->\s|\|\s*$|\[\d+\]|at \S+ \(|at \S+:\d+|File ".*", line \d+|Traceback|Error:|error(?:\[E\d+\])?:|warning:|Warning message|Compiling \S+ v|Caused by:|[─┌└│]|\+\+\+ |--- |@@ |\|.*\||=+$|-{3,}$|\S+\.\w+:\d+(?::\d+)?:? )/
18
19/**
20 * Pasted terminal or tool output rather than a request: at least five lines and most of them look
21 * like prompts, stack frames, numbered listings, diffs or tables. Also the wrapper tags the app
22 * uses for local command output.
23 */
24export function looksLikeToolOutput(text: string): boolean {
25 if (/^\s*<(local-command-std(out|err)|bash-std(out|err)|command-(name|message))>/.test(text)) return true
26 const lines = text.split(/\r?\n/).filter(l => l.trim() !== '')
27 if (lines.length < 5) return false
28 return lines.filter(l => OUTPUT_LINE.test(l)).length / lines.length >= 0.6
29}
30
31/** True for the prompts that are never judged: slash commands, confirmations, pasted output. */
32export const isNotATask = (text: string): boolean => text.startsWith('/') || isConfirmation(text) || looksLikeToolOutput(text)
33hooks/predict.ts 200 lines1// The predictor: a pure function from prompt text to a tier. It is a HEURISTIC (keyword
2// weights plus a few size signals), not a measured router. No model call, no I/O.
3
4export type Tier = 'light' | 'standard' | 'deep' | 'review'
5
6export type Prediction = {
7 tier: Tier
8 /** Points behind the chosen tier (for review/light/deep the winning side, for standard its own). */
9 score: number
10 /** 0.3 to 0.97; how decisively the winner beat the other tiers. Not a probability. */
11 confidence: number
12 /** Short human labels of what matched, strongest first. */
13 reasons: string[]
14}
15
16type Side = 'light' | 'standard' | 'deep' | 'review'
17type Rule = {
18 re: RegExp
19 side: Side
20 pts: number
21 label: string
22 /** The rule is off when this also matches (for example `translate` before a code word). */
23 unless?: RegExp
24}
25
26const CODE_WORDS = String.raw`(?:code|function|script|package|module|class|[rR]|rust|python|julia|c\+\+|typescript|javascript)`
27
28const RULES: Rule[] = [
29 // ---- light: trivial edits and lookups
30 { re: /\b(typos?|misspell\w*|spelling)\b/i, side: 'light', pts: 3, label: 'typo or spelling' },
31 { re: /\brename\b/i, side: 'light', pts: 3, label: 'rename' },
32 { re: /\b(re-?format|prettify|reindent|fix (the )?(indent\w*|whitespace|formatting))\b/i, side: 'light', pts: 3, label: 'formatting' },
33 { re: /\b(add|write|insert) (a |an |some )?(short |brief )?(code )?(comments?|docstrings?|roxygen)\b/i, side: 'light', pts: 3, label: 'add a comment' },
34 { re: /\b(change|set|bump|update) (the )?(constant|default|version|port|timeout|threshold|seed|color|colour|label|title)\b/i, side: 'light', pts: 3, label: 'change a constant' },
35 { re: /\b(git (status|log|diff --stat|branch)|list (the |all )?(files|folders|directories)|which files|ls\b)/i, side: 'light', pts: 3, label: 'list or git status' },
36 { re: /\bcommit message\b/i, side: 'light', pts: 3, label: 'commit message' },
37 { re: /\b(explain|what does) (this|that|the) (one )?(line|regex|snippet|command|flag|error message|warning)\b/i, side: 'light', pts: 3, label: 'explain one line' },
38 { re: new RegExp(String.raw`\btranslat\w+\b(?!.{0,40}\b${CODE_WORDS}\b)`, 'i'), side: 'light', pts: 3, label: 'translate text' },
39 { re: /\b(boilerplate|scaffold(ing)?|skeleton|stub out|template for)\b/i, side: 'light', pts: 2, label: 'boilerplate' },
40 { re: /\b(remove|delete|drop) (the )?(unused|dead|commented[- ]out|duplicate) \w+/i, side: 'light', pts: 3, label: 'remove unused code' },
41 { re: /\b(quick(ly)?|tiny|trivial|tl;?dr|briefly|in one sentence)\b/i, side: 'light', pts: 1, label: 'asked to be quick' },
42 { re: /\bone-?liner\b/i, side: 'light', pts: 2, label: 'one-liner' },
43 { re: /\bjust\b/i, side: 'light', pts: 1, label: '"just"' },
44 { re: /\b(what|which) (flag|option|argument|function|package|command|version|key) (does|do|is|should|am i|are we)\b/i, side: 'light', pts: 2, label: 'simple lookup' },
45 { re: /\b(show|print|display|cat) (me )?(the )?(contents? of|first \d+ lines|head of)\b/i, side: 'light', pts: 2, label: 'show a file' },
46 { re: /\b(add|remove|fix) (a |an |the )?(missing )?(import|library\(\w+\)|use statement|trailing (comma|whitespace|newline))/i, side: 'light', pts: 3, label: 'tiny edit' },
47 { re: /\b(sort|alphabeti[sz]e|capitali[sz]e|lowercase|uppercase) (the |these |this )?\w+/i, side: 'light', pts: 1, label: 'simple edit' },
48
49 // ---- standard: ordinary implementation
50 { re: /\b(write|add|create|extend|update|fix) (a |an |the |some |more |new )?(unit |regression |integration )?(tests?|test cases?|testthat|pytest|specs?)\b/i, side: 'standard', pts: 3, label: 'write tests' },
51 { re: /\b(testthat|pytest|cargo test|devtools::test|jest|vitest|expect_\w+)\b/i, side: 'standard', pts: 2, label: 'test framework' },
52 { re: /\b(implement|write|add|create|build) (a |an |the |new )?(small |simple |helper |utility )?(function|method|endpoint|command|class|module|feature|option|flag|argument|parameter|wrapper|cli|script)\b/i, side: 'standard', pts: 3, label: 'implement a function' },
53 { re: /\b(fix|debug|patch|resolve)\b.{0,40}\b(bug|error|failure|failing test|crash|exception|warning|issue)\b/i, side: 'standard', pts: 2, label: 'fix a bug' },
54 { re: /\b(refactor|clean up|simplify|extract|deduplicate|inline)\b/i, side: 'standard', pts: 2, label: 'small refactor' },
55 { re: /\b(ggplot2?|dplyr|tidyverse|data\.?frame|tibble|pandas|cargo|clippy|tokio|serde|lme4|sommer|asreml|rrBLUP)\b/i, side: 'standard', pts: 1, label: 'library task' },
56 { re: /\b(plot|figure|table|summari[sz]e|parse|read|load|export|import) (the |a |this |these )?\w+/i, side: 'standard', pts: 1, label: 'data handling' },
57
58 // ---- deep: proofs, design, hard debugging, method choice
59 { re: /\b(derive|derivation|prove|proof|theorem|lemma|corollary|closed[- ]form|show that|analytic(al)? (solution|expression))\b/i, side: 'deep', pts: 4, label: 'proof or derivation' },
60 { re: /\b(design|architect(ure)?|re-?architect)\b.{0,50}\b(algorithm|system|architecture|schema|api|pipeline|framework|data model|module structure)\b/i, side: 'deep', pts: 4, label: 'design work' },
61 { re: /\b(system design|architecture|trade-?offs?|design decision)\b/i, side: 'deep', pts: 2, label: 'architecture or trade-offs' },
62 { re: /\b(refactor|migrate|port|rewrite|restructure)\b.{0,60}\b(across|throughout|entire|whole|all (the )?(modules|files|packages|crates)|codebase|repo(sitory)?)\b/i, side: 'deep', pts: 4, label: 'cross-file refactor' },
63 { re: /\b(across|spanning) (all|many|several|multiple|every) (the )?(files|modules|packages|crates|scripts)\b/i, side: 'deep', pts: 3, label: 'many files' },
64 { re: /\b(port|rewrite|translate|convert)\b.{0,40}\b(to|into|in) (rust|c\+\+|julia)\b/i, side: 'deep', pts: 3, label: 'port to another language' },
65 { re: /\b(race conditions?|deadlock\w*|data race|memory leak|segfault|use-after-free|heisenbug|non-?determinis\w*|flaky|borrow checker|lifetimes?)\b/i, side: 'deep', pts: 4, label: 'hard debugging' },
66 { re: /\b(only sometimes|intermittent\w*|occasionally|randomly|only (when|on|after)\b|can'?t (reproduce|repro)|works on my machine)\b/i, side: 'deep', pts: 3, label: 'intermittent behaviour' },
67 { re: /\bwhy (does|do|is|are|would)\b.{0,80}\bonly\b/i, side: 'deep', pts: 3, label: '"why ... only"' },
68 { re: /\b(numerical(ly)? (stab\w+|precision|instab\w+|error)|ill-?conditioned|singular (matrix|information)|catastrophic cancellation|cholesky|eigen(value|vector|decomposition)?s?|convergence)\b/i, side: 'deep', pts: 3, label: 'numerical method' },
69 { re: /\b(which|what) (statistical |estimation |modelling |modeling )?(method|test|model|estimator|approach|prior|link)\b.{0,60}\b(should|best|appropriate|use|choose)\b/i, side: 'deep', pts: 3, label: 'method choice' },
70 { re: /\b(REML|BLUPs?|BLUE|variance components?|heritability|genomic (prediction|selection|relationship)|GBLUP|mixed models?|likelihood|posterior|MCMC|gibbs|bayesian|identifiab\w+|asymptotic\w*|unbiased(ness)?|multiple testing|false discovery|bootstrap)\b/i, side: 'deep', pts: 2, label: 'statistical theory' },
71 { re: /\b(kinship|relationship matrix|GWAS|genome-?wide association|linkage disequilibrium|population structure|epistasis|QTL|breeding values?|selection index)\b/i, side: 'deep', pts: 2, label: 'quantitative genetics' },
72 { re: /\b(experimental design|alpha[- ]lattice|incomplete block|split[- ]plot|augmented design|power analysis|sample size calculation|spatial (model|correlation))\b/i, side: 'deep', pts: 2, label: 'experimental design' },
73 { re: /\b(simulat(e|es|ed|ion|ions|ing)|monte carlo|stochastic)\b/i, side: 'deep', pts: 2, label: 'simulation' },
74 { re: /\b(change|switch|replace|swap|modify|rewrite|redo)\b.{0,30}\b(the )?(estimator|likelihood|prior|variance (model|structure)|link function|statistical method|statistical model|mixed model|model specification|test statistic)\b/i, side: 'deep', pts: 3, label: 'statistical method change' },
75 { re: /\b(unsafe|send\s*\+\s*sync|interior mutability|trait objects?|proc[- ]macro|macro_rules|ffi)\b/i, side: 'deep', pts: 3, label: 'advanced Rust' },
76 { re: /\b(non-?standard evaluation|tidy ?eval|rlang|Rcpp|RcppArmadillo|S4 (class|method)|R6)\b/i, side: 'deep', pts: 2, label: 'advanced R' },
77 { re: /\b(security|threat model|vulnerabilit\w+|exploit|injection|xss|csrf|cve-\d+)\b/i, side: 'deep', pts: 3, label: 'security' },
78 { re: /\bsecurity (review|audit)\b/i, side: 'deep', pts: 2, label: 'security review' },
79 { re: /\b(time complexity|big-?o|O\([nN\w ^*+log]+\)|invariants?|correctness|formally|amortized)\b/i, side: 'deep', pts: 3, label: 'complexity or correctness' },
80 { re: /\bimplement\b.{0,40}\b(algorithm|solver|sampler|optimi[sz]er|scheduler|parser|compiler|allocator|estimator)\b/i, side: 'deep', pts: 3, label: 'implement an algorithm' },
81 { re: /\b(think (hard|carefully|deeply|step by step)|ultrathink|take your time|be (very )?(careful|thorough|rigorous))\b/i, side: 'deep', pts: 3, label: 'asked to think hard' },
82 { re: /\b(thoroughly|rigorous(ly)?|in depth|in-depth|exhaustive(ly)?|carefully)\b/i, side: 'deep', pts: 1, label: 'asked to be careful' },
83
84 // ---- review: review, second opinion, audit, falsify
85 { re: /\b(second opinion|falsif\w+|try to (break|refute|disprove)|devil'?s advocate|red[- ]team)\b/i, side: 'review', pts: 7, label: 'second opinion or falsify' },
86 { re: /\bcheck (my|our|the|this) (derivation|proof|math|algebra|calculations?|reasoning|argument|results?|statistics|analysis)\b/i, side: 'review', pts: 7, label: 'check a derivation' },
87 { re: /\breview (my|our|the|this|these) (diff|pr|pull request|code|changes|patch|commit|branch|implementation|derivation|proof|analysis|manuscript|draft)\b/i, side: 'review', pts: 4, label: 'review request' },
88 { re: /\b(review|check|verify|validate)\b.{0,40}\b(REML|BLUPs?|mixed model|likelihood|estimator|statistical method|GWAS|kinship|variance components?|simulation|power analysis)\b/i, side: 'review', pts: 4, label: 'review of method code' },
89 { re: /\b(code review|peer review|audit)\b/i, side: 'review', pts: 3, label: 'review or audit', unless: /\bsecurity (review|audit)\b/i },
90 { re: /\b(sanity[- ]check|double[- ]check|cross-?check|verify (that|my|the|this))\b/i, side: 'review', pts: 1, label: 'verify' },
91]
92
93const PATH_RE = /(?:^|[\s"'`(\[])@?((?:[\w.-]+[/\\])*[\w.-]+\.(?:R|r|Rmd|qmd|rs|py|ts|tsx|js|jsx|mjs|json|toml|md|csv|tsv|txt|yml|yaml|c|cc|cpp|h|hpp|go|java|sh|ps1|tex|bib|sql|ipynb|lock|cfg|ini))\b/g
94const MENTION_RE = /(?:^|\s)@([\w./\\-]+)/g
95
96/** Distinct file paths and @mentions named in the text. */
97export function namedFiles(text: string): string[] {
98 const found = new Set<string>()
99 for (const m of text.matchAll(PATH_RE)) if (m[1]) found.add(m[1].toLowerCase())
100 for (const m of text.matchAll(MENTION_RE)) if (m[1]) found.add(m[1].toLowerCase())
101 return [...found]
102}
103
104const PRIOR_CONTEXT =
105 /\b(it|this|that|these|those|above|below|earlier|previous(ly)?|before|same|again)\b|\bthe (file|code|function|error|bug|test|script|plot|result|output)s? we\b|\bas (we )?(discussed|said|mentioned)\b|\bwe (just |already )?(did|discussed|wrote|made|ran|saw)\b|\byou (just )?(said|wrote|made|ran|suggested)\b/i
106
107/** Pronouns and phrases that point back at the conversation (the prompt is not self-contained). */
108export const refsPriorContext = (text: string): boolean => PRIOR_CONTEXT.test(text)
109
110export const wordCount = (text: string): number => text.trim().split(/\s+/).filter(Boolean).length
111
112const clamp = (n: number, lo: number, hi: number): number => Math.round(Math.min(hi, Math.max(lo, n)) * 100) / 100
113
114type Tally = Record<Side, number>
115type Hit = { label: string; side: Side; pts: number }
116
117function runRules(text: string): { tally: Tally; hits: Hit[] } {
118 const tally: Tally = { light: 0, standard: 0, deep: 0, review: 0 }
119 const hits: Hit[] = []
120 for (const rule of RULES) {
121 if (!rule.re.test(text) || (rule.unless && rule.unless.test(text))) continue
122 tally[rule.side] += rule.pts
123 hits.push({ label: rule.label, side: rule.side, pts: rule.pts })
124 }
125 return { tally, hits }
126}
127
128/** Size and shape signals: file count, length, code blocks, question marks. */
129function runShape(text: string, words: number, tally: Tally, hits: Hit[]): void {
130 const add = (side: Side, pts: number, label: string) => {
131 tally[side] += pts
132 hits.push({ label, side, pts })
133 }
134 const files = namedFiles(text).length
135 if (files >= 5) add('deep', 4, `names ${files} files`)
136 else if (files >= 3) add('deep', 2, `names ${files} files`)
137 else if (files >= 1) add('standard', 1, files === 1 ? 'names one file' : 'names two files')
138
139 if (words >= 500) add('deep', 3, `very long prompt (${words} words)`)
140 else if (words >= 250) add('deep', 2, `long prompt (${words} words)`)
141 else if (words >= 120) add('deep', 1, `longish prompt (${words} words)`)
142
143 const blocks = Math.floor((text.match(/```/g) ?? []).length / 2)
144 if (blocks >= 3) add('deep', 1, `${blocks} code blocks`)
145 else if (blocks >= 1) add('standard', 1, 'has a code block')
146
147 const questions = (text.match(/\?/g) ?? []).length
148 if (questions >= 3) add('deep', 1, `${questions} questions`)
149 // A short, plain question with nothing technical attached is a lookup, unless it leans on the conversation.
150 if (questions === 1 && words <= 14 && files === 0 && blocks === 0 && tally.deep === 0 && tally.review === 0 && !refsPriorContext(text))
151 add('light', 2, 'short question')
152}
153
154/** Lowercase tier words for the dialog and the status line. */
155export const TIER_WORD: Record<Tier, string> = { light: 'LIGHT', standard: 'STANDARD', deep: 'DEEP', review: 'REVIEW' }
156
157/**
158 * Predicts the tier of a prompt. Rules in short:
159 * - review wins when its points are at least 3 and at least the deep points;
160 * - deep needs 3+ points and a lead of 1 over standard and light;
161 * - light needs 2+ points and a lead of 1 over standard and deep;
162 * - anything else is standard.
163 * Confidence is 0.5 + 0.1 x the lead (capped 0.97) for the three decided tiers, so a lead of 1 is 0.6,
164 * 2 is 0.7 and 3 is 0.8.
165 */
166export function predict(text: string): Prediction {
167 const words = wordCount(text)
168 const { tally, hits } = runRules(text)
169 runShape(text, words, tally, hits)
170 const std = Math.min(tally.standard, 5) // many weak standard words must not outvote one clear signal
171
172 const reasonsFor = (side: Side): string[] =>
173 [...new Set(hits.filter(h => h.side === side).sort((a, b) => b.pts - a.pts).map(h => h.label))]
174
175 if (tally.review >= 3 && tally.review >= tally.deep) {
176 return { tier: 'review', score: tally.review, confidence: clamp(0.4 + 0.1 * tally.review, 0.5, 0.97), reasons: reasonsFor('review') }
177 }
178 const deepLead = tally.deep - Math.max(tally.light, std)
179 if (tally.deep >= 3 && deepLead >= 1) {
180 return { tier: 'deep', score: tally.deep, confidence: clamp(0.5 + 0.1 * deepLead, 0.5, 0.97), reasons: reasonsFor('deep') }
181 }
182 const lightLead = tally.light - Math.max(tally.deep, std)
183 if (tally.light >= 2 && lightLead >= 1) {
184 return { tier: 'light', score: tally.light, confidence: clamp(0.5 + 0.1 * lightLead, 0.5, 0.97), reasons: reasonsFor('light') }
185 }
186 const strongest = Math.max(tally.light, tally.deep)
187 const reasons = reasonsFor('standard')
188 return {
189 tier: 'standard',
190 score: std,
191 confidence: clamp(0.55 + 0.08 * std - 0.12 * strongest, 0.3, 0.95),
192 reasons: reasons.length > 0 ? reasons : ['no strong signal'],
193 }
194}
195
196/** True when a prompt matches a rule strong enough to be judged even though it is short. */
197export function isStrong(p: Prediction): boolean {
198 return p.tier !== 'standard' && p.score >= 4
199}
200hooks/route.ts 22 lines1// Model routing without a switch: pure helpers. Per request, a mod can name the model (turn.step), so a conversation can
2// run on a cheaper model chosen from its FIRST prompt (nothing is cached yet, so it costs nothing), and a subagent started
3// without a model can get one that fits its task. Never above the model you chose. Idea from laya-router (MIT); no code copied.
4import { recommend } from './fit'
5import type { Costs } from './fit'
6import { RANK } from './gate'
7import type { Alias, Family } from './gate'
8import type { Prediction } from './predict'
9
10/** The ids a request names (the current models; an alias is not accepted by turn.step). */
11export const MODEL_ID: Record<Alias, string> = { haiku: 'claude-haiku-5-5', sonnet: 'claude-sonnet-5-5', opus: 'claude-opus-5-5' }
12
13/**
14 * The cheaper model a confident prediction says will do, or undefined to keep the chosen one. Only ever down: a task that
15 * needs more than you chose is left to the dialog, which asks.
16 */
17export function cheaperFit(p: Prediction, chosen: Family | undefined, costs: Costs, minConfidence: number): Alias | undefined {
18 if (chosen === undefined || p.tier === 'review' || p.confidence < minConfidence) return undefined
19 const fit = recommend(p.tier, costs)
20 return RANK[fit] < RANK[chosen] ? fit : undefined
21}
22types/index.d.ts 33 lines1/** One prediction as /model-picker status lists it. The prompt is cut to a short snippet and lives in session memory only. */
2export type SuggestModelEntry = {
3 snippet: string
4 tier: 'light' | 'standard' | 'deep' | 'review'
5 confidence: number
6 reasons: string[]
7 /** What happened: why nothing was shown, or the person's answer. */
8 outcome: string
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'model-picker': {
14 /** Prompts from the person checked this session. */
15 seen: number
16 /** "Keep ... and don't suggest again this session" was chosen. */
17 isMuted: boolean
18 /** The persisted on/off switch, mirrored here so the band's button redraws when it flips. */
19 isOff: boolean
20 /** Result of the one `codex --version` probe; null = not probed yet. */
21 codexOk: boolean | null
22 /** The session's effort level as the last main-thread request named it; empty = none seen. Read by effort-watch. */
23 effort: string
24 /** Short effort advice for the moddeck's model row (`medium would do`, `try high`); empty = the effort fits. */
25 effortAdvice: string
26 /** The model this conversation was routed to at its first prompt (`Haiku`); '' when not routed. */
27 routedTo: string
28 /** The last predictions, newest last (at most 5). */
29 recent: SuggestModelEntry[]
30 }
31 }
32}
33