Picks the model and effort per prompt, routes subagents to cheap models, and gates Fable and extra-usage spend.

<a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-34d399?style=flat-square"></a> <img alt="Claude Code 2.1.300+" src="https://img.shields.io/badge/Claude%20Code-2.1.300%2B-818cf8?style=flat-square"> <img alt="mods" src="https://img.shields.io/badge/mods-4-f472b6?style=flat-square">
A collection of small mods for Claude Code. Each mod is its own plugin, so you install only the ones you want.
Mods use Claude Code's hooks-module API, which is early access and may change between releases. They were built and tested on Claude Code 2.1.300.
| Mod | What it does |
|---|---|
| autoroute | Picks the model and effort for each prompt, sends subagents to cheaper models, and keeps Fable and extra-usage spend in check. |
| prompt-upgrade | Catches vague prompts, asks one quick multiple-choice question, rewrites the prompt with a working method, and lets you review it before it runs. |
| box-tools | A filled ✦ Tools button at the right end of the prompt footer. Click it for a small box where you switch the model, the effort, and each of your daymods on or off. |
| pace-statusline | A 4-line status line with limit bars, reset times and a pace prediction (⚠ runs out today 4:19pm) so you know before you hit the wall. Lives in its own repo. |
More mods will be added here as they are ready.
Add the marketplace once, then install any mod from it.
In a Claude Code session:
/plugin install autoroute --marketplace daydeda/daymods
Or from a shell:
claude plugin marketplace add daydeda/daymods
claude plugin install autoroute@daymods
claude plugin install prompt-upgrade@daymods
claude plugin install box-tools@daymods
claude plugin install pace-statusline@daymods
Answer y when asked to add the marketplace and pick a scope. The mod is active right away. Mods installed at the user scope load in every later session.
To remove one: claude plugin uninstall autoroute.
Routes each prompt to a suitable model and effort, so small jobs stop burning Opus tokens and big jobs get a plan.
| Tier | Typical prompts | Model | Effort |
|---|---|---|---|
| trivial | a question, typo, rename, quick lookup | Haiku | low |
| normal | a feature or fix in 1-3 files | Sonnet | medium |
| hard | tricky debugging, design, refactors | Opus | high |
| large | migrations, rewrites, architecture | Opus | high, plus a delegation plan |
Short follow-ups like "continue" or "yes" keep the previous route.
flowchart LR
A([your prompt]) --> B{rules}
B -->|clear| D[tier]
B -->|unclear| C[Haiku classifier] --> D
D --> E{plan and spend gate}
E -->|Fable, not Max| F[Opus]
E -->|limit near, extra usage on| G[Sonnet cap]
E -->|ok| H[chosen model]
These apply to every request, whatever chose its model, and stay on even when routing is turned off.
~/.claude.json). Anything other than a Max plan, or a plan it cannot read, is downgraded from Fable to Opus. This covers the router, !fable, Fable picked in /model, and Fable subagents.The plan check reads a local file the user can edit. It prevents accidental charges; it is not a security boundary.
~/.claude/autoroute.state, and a status line script can show it inline. pace-statusline does: Opus ▶ high ⇢ Haiku·low ▶ ⌂ daymods, plus a ⚠ note for downgrades or spend warnings. The ⇢ part is hidden once the running model matches the route. Set display to line to get autoroute's own row instead, for example autoroute: Haiku · low · "typo" (rules).STATUSLINE_COMPACT=1 folds ctx, 5-hour, weekly and session stats into one row with short bars. The reset countdown shows only past 50% used.!haiku, !sonnet, !opus, !fable or !max. !auto routes normally./route on|off|status: turn routing on or off, or show the last route, your plan, and the spend guard state./config):classifier: rules+haiku (default) or rules only. Rules-only makes no model call.display: statusline (default) writes the route for your status line to show; line shows autoroute's own row.spendGuardPercent: threshold for the spend guard, default 90.! overrides, or /route status to see why a route was chosen.mods/autoroute/hooks/route.ts. If your Claude Code build uses different IDs, change them there.Turns a vague prompt like "make a todo app" into a clear one before Claude starts, and lets you check it first.
The panel opens beside the transcript (or above the prompt). A step bar at the top shows where you are: ✓ Your prompt → ● Questions 2/3 → ○ Rewrite → ○ Review. Keys:
1-3 pick a choice, the next number types your own answer, s skips the question, x skips the rest and rewrites now, b goes back to the previous question (your earlier answer is marked ✓).1-4 as above, 5 asks for a change, b goes back to the questions with your answers kept, then rewrites again.Esc sends your prompt as typed. Back, Skip the rest and Esc sit together in a bar at the bottom of the panel.Each question is its own card (QUESTION 2 OF 3, one dot per question). The panel uses Claude's orange for what is current and green for what is done, and opens at about 60% of the terminal width once you have run /refine once (100 columns before that).
Typing your own answer. Claude Code's text field is one line and cuts long text off, so the panel draws its own wrapped text box instead. Press the typing key (or click the box), type, and press Enter. It supports typing, Backspace and Ctrl+U to clear; there is no cursor movement. It is not available in the VS Code and mobile layouts, which only get the numbered choices and Skip.
The panel needs a terminal about 110 columns wide; on a narrower one it falls back to Claude Code's question dialog, with the rewrite shown inside the question. Set ui to dialog to always use the dialog.
Each rewrite picks one working method and writes its step into the prompt:
| Method | Idea | What the prompt asks for |
|---|---|---|
| Direct | prompt engineering | A clear spec and nothing else, for small well-defined changes. |
| Context first | context engineering | Read specific code, docs or data before deciding. |
| Plan first | plan, then build | A short plan and a pause for your OK before editing, for big or open-ended work. |
| Verify loop | loop engineering | A named check (tests, build, lint) and iterating until it passes, stopping after 3 failed tries. |
| Make it a rule | harness engineering | Do the task now, then suggest making it permanent as a CLAUDE.md rule, hook, skill or /loop. |
/refine <prompt>: upgrade one prompt on demand, whatever the mode./refine auto|always|off: when it steps in. auto (default) handles vague prompts only, always every new task you type, off only /refine <prompt>./refine status: the mode, and why the last prompt was or wasn't upgraded./config): mode, detector (rules+haiku or rules alone), ui (panel or dialog), and rewriteModel (sonnet by default, or haiku or opus).!opus-style autoroute override is kept in front of the rewritten prompt.A filled orange ✦ Tools pill sits at the right end of the prompt footer. Click it and a small box opens beside the transcript:
✦ Tools Opus · high
╭─────────────────────────────────────────────────────────╮
│ ◆ Model 1-5 │
│ 1: ○ Default 2: ○ Haiku 3: ○ Sonnet 4: ● Opus 5: ○ Fable │
╰─────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────╮
│ ⚡ Effort q w e r t │
│ q: ○ low w: ○ medium e: ● high r: ○ xhigh t: ○ max │
╰─────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────╮
│ ❖ Mods a s d … │
│ a: ● on autoroute │
╰─────────────────────────────────────────────────────────╯
✦ Tools, or run /tools. Clicking the button again closes the box.1-5 for models, q w e r t for effort, a s d… for mods.x, or the button again.model row of /config, the same as picking it there. It becomes your default for new sessions too./effort <level>, as if you typed it. Claude Code saves the level for the current model, so it carries over to new sessions.claude plugin enable or claude plugin disable, then /reload-plugins, so the change applies right away and stays on or off in later sessions.--plugin-dir aren't installed, so they don't appear in the list./config): button (show or hide). With hide, the footer is left alone and /tools opens the box.Each mod lives in mods/<name>/ with the standard layout:
mods/<name>/
├── .claude-plugin/plugin.json
├── hooks/hooks.json
├── hooks/register.ts
└── tests/
Check and test a mod with:
claude plugin validate mods/autoroute
claude plugin test mods/autoroute
Try one without installing it: claude --plugin-dir mods/autoroute.
hooks/register.ts 186 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 CLASSIFIER_SYSTEM, DELEGATE_CONTEXT, DIRECT_CONTEXT, MODELS, TIER_ROUTE, UNKNOWN_PLAN,
5 classifyByRules, gate, gateAlias, isFable, parseOverride, parsePlan, parseTier, shortName,
6} from './route'
7import type { Effort, Plan, Route } from './route'
8
9const ENABLED_KEY = 'enabled'
10
11let useHaiku = true
12let guardPercent = 90
13let ownLine = false
14
15let plan: Plan = UNKNOWN_PLAN
16let isEnabled = true
17let route: Route | undefined
18let guardNote: string | undefined
19const agentEffort = new Map<string, Effort>()
20
21async function readPlan($: EngineInterface): Promise<Plan> {
22 try {
23 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (await $.env.get('HOME'))
24 const text = await $.fs.read(`${dir}/.claude.json`)
25 return typeof text === 'string' ? parsePlan(text) : UNKNOWN_PLAN
26 } catch {
27 return UNKNOWN_PLAN
28 }
29}
30
31// With extra usage on, a window near its limit means further work may be billed.
32async function isSpendCapped($: EngineInterface): Promise<boolean> {
33 if (!plan.hasExtraUsage) return false
34 try {
35 const { rateLimits } = await $.session.usage()
36 const hot = rateLimits.find(w => w.percentUsed >= guardPercent)
37 guardNote = hot ? `${hot.kind} at ${hot.percentUsed}% with extra usage on` : undefined
38 return hot !== undefined
39 } catch {
40 return false
41 }
42}
43
44// The statusline reads this file to show the route inline: one `key=value` per line.
45async function statePath($: EngineInterface): Promise<string> {
46 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
47 return `${dir}/autoroute.state`
48}
49
50let writing: Promise<void> = Promise.resolve()
51
52async function writeState($: EngineInterface, notes: readonly string[]) {
53 const clean = (v: string) => v.replace(/[\r\n]+/g, ' ')
54 const lines = route && isEnabled
55 ? [`model=${shortName(route.model)}`, `effort=${route.effort}`, `tier=${route.tier}`, `reason=${clean(route.reason)}`, `source=${route.source}`]
56 : ['off=1']
57 const warn = [...notes, ...(guardNote ? [guardNote] : [])]
58 if (warn.length > 0) lines.push(`warn=${clean(warn.join('; '))}`)
59 const text = `${lines.join('\n')}\n`
60 // Chained so a slow earlier write can never land after a newer one.
61 writing = writing.then(async () => $.fs.write(await statePath($), text)).catch(() => undefined)
62 await writing
63}
64
65function showStatus($: EngineInterface, notes: readonly string[] = []) {
66 void writeState($, notes)
67 if (!ownLine) return
68 const parts = [
69 route && isEnabled ? `${shortName(route.model)} · ${route.effort} · ${route.reason} (${route.source})` : 'routing off',
70 ...notes,
71 ...(guardNote ? [`⚠ ${guardNote}`] : []),
72 ]
73 $.ui.status(`autoroute: ${parts.join(' | ')}`)
74}
75
76async function classify($: EngineInterface, text: string): Promise<Route | undefined> {
77 const verdict = classifyByRules(text)
78 if (verdict.tier === 'keep') return route
79 if (verdict.tier !== 'unsure') return { tier: verdict.tier, ...TIER_ROUTE[verdict.tier], reason: verdict.reason, source: 'rules' }
80 if (useHaiku) {
81 const r = await $.model
82 .complete({ model: 'haiku', system: CLASSIFIER_SYSTEM, prompt: text.slice(0, 4000), effort: 'low', maxTokens: 8, timeoutMs: 8000 })
83 .catch(() => undefined)
84 const tier = r?.isAnswered ? parseTier(r.text) : undefined
85 if (tier) return { tier, ...TIER_ROUTE[tier], reason: tier, source: 'haiku' }
86 }
87 return route ?? { tier: 'normal', ...TIER_ROUTE.normal, reason: 'default', source: 'fallback' }
88}
89
90export const register: Register = (on, options) => {
91 useHaiku = options.classifier !== 'rules'
92 guardPercent = typeof options.spendGuardPercent === 'number' ? options.spendGuardPercent : 90
93 ownLine = options.display === 'line'
94
95 on('session.start', async ($, e, next) => {
96 plan = await readPlan($)
97 isEnabled = (await $.store.get(ENABLED_KEY)) !== false
98 await $.command.register({
99 name: 'route',
100 description: 'autoroute: turn auto model routing on or off, or show its status',
101 argumentHint: '[on|off|status]',
102 })
103 showStatus($)
104 return next(e)
105 })
106
107 on('command.run', { command: 'route' }, async ($, e) => {
108 const arg = e.args.trim().toLowerCase()
109 if (arg === 'on' || arg === 'off') {
110 isEnabled = arg === 'on'
111 await $.store.set(ENABLED_KEY, isEnabled)
112 showStatus($)
113 return { text: `autoroute: routing ${arg}. The Fable and spend rules stay on either way.` }
114 }
115 if (arg !== '' && arg !== 'status') return { text: 'Usage: /route [on|off|status]' }
116
117 plan = await readPlan($)
118 const isCapped = await isSpendCapped($)
119 return {
120 text: [
121 `Routing: ${isEnabled ? 'on' : 'off'} (classifier: ${useHaiku ? 'rules + Haiku' : 'rules only'})`,
122 `Last route: ${route ? `${shortName(route.model)} · ${route.effort} · ${route.tier} — ${route.reason} (${route.source})` : 'none yet'}`,
123 `Plan: ${plan.type ?? 'unknown'} → Fable ${plan.isMax ? 'allowed' : 'blocked (Max plan only)'}`,
124 `Extra usage: ${plan.hasExtraUsage ? 'on' : 'off'} → spend guard ${isCapped ? `ACTIVE (${guardNote}), capping at Sonnet` : `idle (caps at Sonnet past ${guardPercent}%)`}`,
125 'Override one prompt with !haiku, !sonnet, !opus, !fable or !max at its start.',
126 ].join('\n'),
127 }
128 })
129
130 on('prompt.submit', async ($, e, next) => {
131 if (e.command || e.isMeta) return next(e)
132
133 const override = parseOverride(e.text)
134 const text = override ? override.text : e.text
135 const notes: string[] = []
136
137 if (override && override.route !== 'auto') {
138 route = { ...override.route, reason: 'override', source: 'you' }
139 } else if (isEnabled) {
140 route = await classify($, text)
141 }
142
143 if (route && !plan.isMax && route.model === MODELS.fable) {
144 notes.push(`Fable is Max-only (${plan.type ?? 'plan unknown'}) → Opus`)
145 $.ui.toast('autoroute: Fable needs a Max plan; using Opus instead.')
146 }
147 await isSpendCapped($)
148 showStatus($, notes)
149
150 const context = !route || !isEnabled ? [] : route.tier === 'large' ? [DELEGATE_CONTEXT] : route.tier === 'top' ? [] : [DIRECT_CONTEXT(route.tier)]
151 return next({ ...e, text, context: [...(e.context ?? []), ...context] })
152 })
153
154 on('turn.step', async function* ($, e, next) {
155 const isMain = e.agentId === undefined
156 const wanted = isMain && isEnabled && route ? route : undefined
157 const model = wanted?.model ?? e.model
158 const effort = wanted?.effort ?? (e.agentId ? agentEffort.get(e.agentId) : undefined) ?? e.effort
159 const gated = gate(model, effort, plan, await isSpendCapped($))
160
161 if (gated.notes.length > 0 && isMain) showStatus($, gated.notes)
162 if (gated.model === e.model && gated.effort === e.effort) return yield* next(e)
163 return yield* next({ ...e, model: gated.model, ...(gated.effort === undefined ? {} : { effort: gated.effort }) })
164 })
165
166 on('agent.spawn', async ($, e, next) => {
167 if (e.fork || e.resume) return next(e)
168
169 let model = e.model
170 let effort: Effort | undefined
171 if (isEnabled) {
172 if (e.subagentType === 'Explore') [model, effort] = ['haiku', 'low']
173 else if (e.subagentType === 'Plan') [model, effort] = ['opus', 'high']
174 else if (model === undefined) [model, effort] = ['sonnet', 'medium']
175 }
176 model = gateAlias(model, e.parentModel, plan, await isSpendCapped($))
177
178 const started = await next(model === e.model ? e : { ...e, model })
179 if (effort && 'agentId' in started && started.agentId) agentEffort.set(started.agentId, effort)
180 return started
181 }).catch(($, e, next) =>
182 // Fail closed on Fable: without a plan reading, a Fable subagent runs on Opus.
183 next.called ? next(e) : next(isFable(e.model) ? { ...e, model: 'opus' } : e),
184 )
185}
186hooks/route.ts 125 lines1// Pure routing logic: no `$`, so it can be read and tested on its own.
2
3export type Tier = 'trivial' | 'normal' | 'hard' | 'large' | 'top'
4export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
5export type Route = { tier: Tier; model: string; effort: Effort; reason: string; source: string }
6export type Plan = { type: string | undefined; hasExtraUsage: boolean; isMax: boolean }
7
8export const MODELS = {
9 haiku: 'claude-haiku-5-5',
10 sonnet: 'claude-sonnet-5-5',
11 opus: 'claude-opus-5-5',
12 fable: 'claude-fable-5-1',
13} as const
14
15export const TIER_ROUTE: Record<Exclude<Tier, 'top'>, { model: string; effort: Effort }> = {
16 trivial: { model: MODELS.haiku, effort: 'low' },
17 normal: { model: MODELS.sonnet, effort: 'medium' },
18 hard: { model: MODELS.opus, effort: 'high' },
19 large: { model: MODELS.opus, effort: 'high' },
20}
21
22const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
23
24const LARGE = /\b(architect(ure)?|re-?design|rewrite|migrat(e|ion)|from scratch|end[- ]to[- ]end|across (the )?(whole |entire )?(codebase|repo|project)|(entire|whole) (codebase|repo|app|project|system)|multi-?(service|module|package)|monorepo|build (an?|the) (new )?(app|system|platform|service|backend|frontend))\b/i
25const HARD = /\b(debug|race condition|deadlock|leak|performance|optimi[sz]e|security|vulnerab\w*|concurren\w*|refactor\w*|investigate|root cause|flaky|intermittent|design|trade-?offs?|algorithm|plan|why (does|is|do|are))\b/i
26const TRIVIAL = /\b(typo|rename|spelling|what is|what's|what does|explain|how do i|where is|list|show me|format|lint|commit|bump|quick|small|one-?liner)\b/i
27const NORMAL = /\b(add|implement|fix|update|change|create|write|tests?|remove|delete|support|handle|wire|hook up)\b/i
28const FOLLOW_UP = /^(go|yes|y|no|n|ok(ay)?|sure|continue|proceed|do it|next|keep going|lgtm|sounds good|approved?|thanks?|thank you)\b[\s.!]*$/i
29
30export type Verdict = { tier: Exclude<Tier, 'top'> | 'keep' | 'unsure'; reason: string }
31
32export function classifyByRules(text: string): Verdict {
33 const t = text.trim()
34 if (FOLLOW_UP.test(t)) return { tier: 'keep', reason: 'follow-up' }
35 if (t.length > 2000) return { tier: 'large', reason: 'long brief' }
36 const large = t.match(LARGE)
37 if (large) return { tier: 'large', reason: `"${large[0]}"` }
38 const hard = t.match(HARD)
39 if (hard) return { tier: 'hard', reason: `"${hard[0]}"` }
40 const trivial = t.match(TRIVIAL)
41 if (trivial && t.length < 200) return { tier: 'trivial', reason: `"${trivial[0]}"` }
42 const normal = t.match(NORMAL)
43 if (normal && t.length < 800) return { tier: 'normal', reason: `"${normal[0]}"` }
44 return { tier: 'unsure', reason: 'no rule matched' }
45}
46
47export const CLASSIFIER_SYSTEM = `You route coding-assistant requests to a model tier. Reply with exactly one word:
48trivial - a question, explanation, typo, rename or tiny edit
49normal - a feature or bug fix touching about 1-3 files
50hard - tricky debugging, design decisions, or changes across many files
51large - architectural or multi-part work that needs planning before implementation`
52
53export function parseTier(reply: string): Exclude<Tier, 'top'> | undefined {
54 const word = reply.trim().toLowerCase().match(/trivial|normal|hard|large/)
55 return word ? (word[0] as Exclude<Tier, 'top'>) : undefined
56}
57
58export type Override = { route: Omit<Route, 'reason' | 'source'> | 'auto'; text: string }
59
60// `!haiku fix this` forces a route for that one prompt; `!auto` routes as usual.
61export function parseOverride(text: string): Override | undefined {
62 const m = text.match(/^!(haiku|sonnet|opus|fable|max|auto)\b\s*/i)
63 if (!m) return undefined
64 const rest = text.slice(m[0].length)
65 switch ((m[1] ?? '').toLowerCase()) {
66 case 'haiku': return { route: { tier: 'trivial', model: MODELS.haiku, effort: 'low' }, text: rest }
67 case 'sonnet': return { route: { tier: 'normal', model: MODELS.sonnet, effort: 'medium' }, text: rest }
68 case 'opus': return { route: { tier: 'hard', model: MODELS.opus, effort: 'high' }, text: rest }
69 case 'fable': return { route: { tier: 'top', model: MODELS.fable, effort: 'high' }, text: rest }
70 case 'max': return { route: { tier: 'top', model: MODELS.fable, effort: 'max' }, text: rest }
71 default: return { route: 'auto', text: rest }
72 }
73}
74
75export const isFable = (model: string | undefined) => model !== undefined && /fable/i.test(model)
76const isAboveSonnet = (model: string | undefined) => model !== undefined && /opus|fable/i.test(model)
77
78export function parsePlan(json: string): Plan {
79 const account = (JSON.parse(json) as { oauthAccount?: Record<string, unknown> }).oauthAccount ?? {}
80 const type = typeof account.organizationType === 'string' ? account.organizationType : undefined
81 return { type, hasExtraUsage: account.hasExtraUsageEnabled === true, isMax: type === 'claude_max' }
82}
83
84// Fails closed: an unknown plan is treated as not Max.
85export const UNKNOWN_PLAN: Plan = { type: undefined, hasExtraUsage: false, isMax: false }
86
87export type Gated = { model: string; effort?: Effort | number; notes: string[] }
88
89// The two money rules, applied to every request whatever chose its model.
90export function gate(model: string, effort: Effort | number | undefined, plan: Plan, isSpendCapped: boolean): Gated {
91 const notes: string[] = []
92 let m = model
93 let eff = effort
94 if (isFable(m) && !plan.isMax) {
95 m = MODELS.opus
96 notes.push(`Fable is Max-only (${plan.type ?? 'plan unknown'}) → Opus`)
97 }
98 if (isSpendCapped && isAboveSonnet(m)) {
99 m = MODELS.sonnet
100 if (typeof eff === 'string' && EFFORTS.indexOf(eff) > EFFORTS.indexOf('high')) eff = 'high'
101 notes.push('spend guard → Sonnet')
102 }
103 return { model: m, effort: eff, notes }
104}
105
106// The same rules for a subagent's `model` parameter, which takes aliases.
107export function gateAlias(model: string | undefined, parentModel: string, plan: Plan, isSpendCapped: boolean) {
108 const effective = model === undefined || model === 'inherit' ? parentModel : model
109 if (isFable(effective) && !plan.isMax) return isSpendCapped ? 'sonnet' : 'opus'
110 if (isSpendCapped && isAboveSonnet(effective)) return 'sonnet'
111 return model
112}
113
114export const shortName = (model: string) =>
115 (model.match(/haiku|sonnet|opus|fable/i)?.[0] ?? model).replace(/^./, c => c.toUpperCase())
116
117export const DELEGATE_CONTEXT = `[autoroute] This task was classified LARGE. Work it as a pipeline, skipping any step that does not pay for itself:
1181. Map the relevant code with Explore subagents (they run on Haiku) instead of reading broadly yourself.
1192. Write a concrete implementation plan to a file (or spawn a Plan subagent, which runs on Opus).
1203. Spawn a general-purpose subagent with model "sonnet", pointed at that plan file, to implement; keep delicate parts for yourself.
1214. Review the result against the plan before reporting.`
122
123export const DIRECT_CONTEXT = (tier: Tier) =>
124 `[autoroute] This task was classified ${tier.toUpperCase()}: handle it directly, without spawning subagents unless the user asks.`
125