SLOPSHOPPER

model-router

Uses Haiku to pick Opus, Sonnet or Haiku for each sub-agent

newcommandtoaststatusmodelagents
v0.2.0no licenseupdated 2026-10-08KingMichaelPark/claude-mods/model-router
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · model-router
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /route ⎿ model-router: Sub-agent router: auto ⎿ model-router: Usage: /route [auto | off | opus | sonnet | haiku | status | stats | reset-stats] ⎿ model-router: auto let Haiku pick a model for each sub-agent (default) ⎿ model-router: off stop routing; sub-agents use their usual model ⎿ model-router: opus|sonnet|haiku run every sub-agent on that model ⎿ model-router: stats spawns and tokens per model, and the latest decisions ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Mods

A curated collection of lightweight, high-impact plugins for Claude Code designed to give you real-time visibility into session consumption and automatically optimize your token usage and API costs.


📦 Included Plugins

PluginDescriptionKey Highlight
usage-meterReal-time prompt header displaying model, context window capacity, and session cost.Color-coded context fill alerts & instant post-compaction estimates.
model-routerAutonomous model routing for spawned sub-agents based on task complexity.Keeps the main thread on your preferred model while routing sub-tasks to the cheapest capable tier.

📊 usage-meter

Adds an always-visible, minimal status bar positioned directly above the input prompt (AbovePrompt).

󱜙  opus-5-5 · 84k/1.0M (42%) ·  $1.23

Features

  • Context Window Monitor: Tracks token usage against total window capacity with percentage indicators.
  • Color-Coded Thresholds:
  • 🟢 Green (< 40%): Safe zone; ample room for extended context.
  • 🟡 Amber (40% - 69%): Moderate usage; considerations for upcoming compaction.
  • 🔴 Red (≥ 70%): Heavy fill; approaching window limits.
  • Smart Compaction Tracking: Immediately renders estimated sizes (~31k/200k (~16%)) right after /compact without waiting for the next turn measurement.
  • Session Cost Counter: Real-time USD spend display updated as prompts complete.
  • Instant Model Switching: Reacts immediately whenever /model is changed.

⚡ model-router

Optimizes costs and execution speed by dynamically routing sub-agents to the most cost-effective Claude model tier (opus, sonnet, or haiku).

How It Works

  1. Protects the Main Thread: The main conversation thread remains untouched on whatever /model you configure. This prevents cache thrashing and ensures high-level problem understanding stays consistent.
  2. Evaluates Sub-Agent Tasks: When a sub-agent spawns without an explicit model override, a fast Haiku classifier evaluates the sub-agent prompt and task description against a strict rubric.
  3. Runs on the Cheapest Capable Tier:
  4. haiku: Fast, lightweight lookups, file/symbol searches, listing usages, reading config files, summarizing command output, formatting, and one-line changes.
  5. sonnet: Standard feature work, bug fixes with clear causes, writing unit tests, code reviews, and routine refactorings.
  6. opus: Open-ended architecture, multi-file refactors, subtle concurrency/performance debugging, security-critical changes, and complex multi-step planning.
  7. Toast Alerts: Displays a subtle toast whenever a sub-agent is routed to a different tier than the main thread.
  8. Usage & Token Telemetry: Aggregates lifetime spawn counts, input/output tokens, and prompt cache hit/miss stats across tiers.

Slash Commands (/route)

Manage sub-agent routing behaviour in Claude Code using the /route command:

# Set routing mode
/route auto           # Haiku intelligently picks the model per sub-agent (default)
/route off            # Disables routing; sub-agents inherit the main thread model
/route sonnet         # Force all sub-agents to a specific tier (opus | sonnet | haiku)

# Check status & telemetry
/route status         # Displays the current mode and quick help
/route stats          # Shows spawn counts, token breakdowns, and recent decisions
/route reset-stats    # Clears historical routing statistics

Example stats output:

Sub-agent routing stats (all sessions)
  opus          3 spawns   in   12.4k   out    1.8k   cache read   48.2k   cache write    6.1k
  sonnet       18 spawns   in   64.1k   out   14.2k   cache read  180.5k   cache write   12.0k
  haiku        42 spawns   in   89.3k   out    8.7k   cache read  310.0k   cache write    4.2k

Latest decisions (model, why, agent):
  haiku   router    general-purpose: Find call sites of parseConfig
  sonnet  router    general-purpose: Add unit tests for auth middleware

🚀 Getting Started

1. Directory Setup

This repository is organized as a Claude Code plugin marketplace (mike-mods) defined in .claude-plugin/marketplace.json.

claude-mods/
├── .claude-plugin/
│   └── marketplace.json      # Marketplace manifest declaring both plugins
├── model-router/
│   ├── hooks/
│   │   ├── hooks.json        # Hook entrypoints (register.ts)
│   │   ├── register.ts       # Router implementation & command handling
│   │   └── router.test.ts    # Comprehensive test suite
│   └── types/
│       └── index.d.ts        # TypeScript declarations & state augmentations
└── usage-meter/
    ├── hooks/
    │   ├── hooks.json        # Hook entrypoints (register.tsx)
    │   ├── register.tsx      # UI hook & event subscriptions
    │   ├── format.ts         # Formatting & color thresholds
    │   └── format.test.ts    # Unit tests
    └── types/
        └── index.d.ts        # TypeScript declarations

2. Loading into Claude Code

You can enable these plugins in your Claude Code environment either via marketplace or by pointing directly to the plugin directories.

Option A: Local Marketplace

Add this repository directory as a plugin source in your Claude Code configuration:

# Add this directory as a plugin marketplace or install individual plugins
claude plugin add ./model-router
claude plugin add ./usage-meter
Option B: Project Configuration

To enable these plugins automatically for a specific project, you can reference them in your workspace's .claude/config.json or plugin manifest:

{
  "plugins": [
    "/path/to/claude-mods/model-router",
    "/path/to/claude-mods/usage-meter"
  ]
}

🧪 Running Tests

Both plugins include test suites using the claude-code/testing framework:

  • model-router/hooks/router.test.ts: Verifies routing logic, explicit model overrides, fork handling, toast triggering, mode toggling, and stats accumulation.
  • usage-meter/hooks/format.test.ts: Verifies context string formatting, post-compaction estimate indicators, cost formatting, and color threshold calculations.

🛠️ Requirements

  • Claude Code CLI
  • Nerd Font or Unicode-compatible terminal (for model icon 󱜙 and cost icon )
Source 2 files
hooks/register.ts 237 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, TurnUsage } from 'claude-code'
3
4import type { Mode } from '../types'
5
6// Routes sub-agents only. The main thread stays on whatever /model is set to:
7// switching it mid-conversation costs its prompt cache and risks handing a
8// hard task to a weaker model halfway through.
9
10const TIERS = ['opus', 'sonnet', 'haiku'] as const
11type Tier = (typeof TIERS)[number]
12
13// The model that makes the routing decision.
14const ROUTER_MODEL = 'haiku'
15const ROUTER_TIMEOUT_MS = 8000
16const MAX_TASK_CHARS = 6000
17const RECENT_KEPT = 50
18const STATS_KEY = 'stats'
19
20const RUBRIC = `You choose which model runs a coding sub-agent: the cheapest one that will do the job well.
21Reply with exactly one word: opus, sonnet or haiku.
22
23opus: hard or open-ended work. Architecture and design decisions, multi-file refactors,
24difficult debugging with unclear causes, security-sensitive changes, subtle concurrency or
25performance problems, long multi-step plans, or anything where a wrong answer is costly.
26
27sonnet: normal software work. Writing or changing a feature, fixing a bug with a clear cause,
28writing tests, code review, explaining a moderately complex piece of code, routine refactors.
29
30haiku: small, mechanical or lookup work. Finding files or symbols, listing usages, reading a few
31files and reporting what they say, running a command and summarising its output, renames,
32formatting, one-line edits.
33
34Examples:
35"Find every call site of parseConfig and list the files" -> haiku
36"Read the README and package.json and summarise the build steps" -> haiku
37"Add a --dry-run flag to the deploy script and update its tests" -> sonnet
38"Review this diff for correctness bugs" -> sonnet
39"Work out why the cache returns stale data under concurrent writes" -> opus
40"Design a migration plan from REST to event-driven messaging across the services" -> opus
41
42If unsure between two tiers, pick the higher one.`
43
44type Source = 'router' | 'pinned' | 'requested' | 'inherited'
45
46type Decision = {
47  at: number
48  agentId: string
49  subagentType: string
50  description: string
51  model: string
52  parentModel: string
53  source: Source
54}
55
56type TierTotals = {
57  spawns: number
58  input: number
59  output: number
60  cacheRead: number
61  cacheWrite: number
62}
63
64type Stats = { totals: Partial<Record<Tier, TierTotals>>; recent: Decision[] }
65
66const modeAtom = atom({ plugin: 'model-router', key: 'mode' } as const, 'auto' as Mode)
67
68const isTier = (s: string): s is Tier => (TIERS as readonly string[]).includes(s)
69const isMode = (s: string): s is Mode => s === 'auto' || s === 'off' || isTier(s)
70const tierOf = (model: string): Tier | undefined =>
71  TIERS.find(tier => model.toLowerCase().includes(tier))
72
73const emptyTotals = (): TierTotals => ({ spawns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
74
75const loadStats = async ($: EngineInterface): Promise<Stats> => {
76  const raw = (await $.store.get(STATS_KEY)) as Stats | undefined
77
78  return raw ?? { totals: {}, recent: [] }
79}
80
81// Stats are best effort: a failed write never gets in the way of a spawn.
82const changeStats = async ($: EngineInterface, fn: (stats: Stats) => void) => {
83  try {
84    const stats = await loadStats($)
85    fn(stats)
86    await $.store.set(STATS_KEY, stats)
87  } catch {
88    // ignored
89  }
90}
91
92// Which tier each running sub-agent was started on, for its usage at the end.
93const running = new Map<string, Tier>()
94
95const showStatus = ($: EngineInterface, mode: Mode) =>
96  $.ui.status(mode === 'auto' ? undefined : mode === 'off' ? 'route: off' : `route: sub-agents pinned ${mode}`)
97
98// Asks Haiku which tier fits `task`; undefined when it cannot say.
99const decide = async ($: EngineInterface, task: string): Promise<Tier | undefined> => {
100  const reply = await $.model
101    .complete({
102      model: ROUTER_MODEL,
103      system: RUBRIC,
104      prompt: `Sub-agent task to route:\n<task>\n${task.slice(0, MAX_TASK_CHARS)}\n</task>`,
105      maxTokens: 5,
106      timeoutMs: ROUTER_TIMEOUT_MS,
107    })
108    .catch(() => undefined)
109  if (reply === undefined || !reply.isAnswered) return undefined
110  const word = reply.text.toLowerCase().match(/\b(opus|sonnet|haiku)\b/)?.[1]
111
112  return word !== undefined && isTier(word) ? word : undefined
113}
114
115const fmt = (n: number) => (n >= 1_000_000 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${(n / 1e3).toFixed(1)}k` : `${n}`)
116
117const statsText = (stats: Stats) => {
118  const rows = TIERS.map(tier => {
119    const t = stats.totals[tier] ?? emptyTotals()
120
121    return `  ${tier.padEnd(7)} ${String(t.spawns).padStart(5)} spawns   in ${fmt(t.input).padStart(7)}   out ${fmt(t.output).padStart(7)}   cache read ${fmt(t.cacheRead).padStart(7)}   cache write ${fmt(t.cacheWrite).padStart(7)}`
122  })
123  const recent = stats.recent
124    .slice(-10)
125    .reverse()
126    .map(d => `  ${(tierOf(d.model) ?? d.model).padEnd(7)} ${d.source.padEnd(9)} ${d.subagentType}: ${d.description}`)
127
128  return [
129    'Sub-agent routing stats (all sessions)',
130    ...rows,
131    '',
132    recent.length === 0 ? 'No sub-agents routed yet.' : 'Latest decisions (model, why, agent):',
133    ...recent,
134  ].join('\n')
135}
136
137const HELP = [
138  'Usage: /route [auto | off | opus | sonnet | haiku | status | stats | reset-stats]',
139  '  auto    let Haiku pick a model for each sub-agent (default)',
140  '  off     stop routing; sub-agents use their usual model',
141  '  opus|sonnet|haiku   run every sub-agent on that model',
142  '  stats   spawns and tokens per model, and the latest decisions',
143].join('\n')
144
145export const register: Register = on => {
146  on('session.start', async ($, e, next) => {
147    await $.command.register({
148      name: 'route',
149      description: 'Sub-agent model router: auto, off, pin a model, or stats',
150      argumentHint: '[auto|off|opus|sonnet|haiku|status|stats|reset-stats]',
151    })
152    showStatus($, await read($, modeAtom))
153
154    return next(e)
155  })
156
157  on('command.run', { command: 'route' }, async ($, e) => {
158    const arg = e.args.trim().toLowerCase()
159    if (arg === 'stats') return { text: statsText(await loadStats($)) }
160    if (arg === 'reset-stats') {
161      await $.store.delete(STATS_KEY)
162
163      return { text: 'Sub-agent routing stats cleared.' }
164    }
165    if (arg === '' || arg === 'status') return { text: `Sub-agent router: ${await read($, modeAtom)}\n${HELP}` }
166    if (!isMode(arg)) return { text: HELP }
167    await update($, modeAtom, () => arg)
168    showStatus($, arg)
169
170    return { text: `Sub-agent router set to ${arg}.` }
171  })
172
173  on('agent.spawn', async ($, e, next) => {
174    const mode = await read($, modeAtom)
175    // Forks always inherit, and a model the caller named explicitly wins.
176    const canRoute = mode !== 'off' && !e.fork && e.model === undefined
177    const tier = !canRoute ? undefined : mode === 'auto' ? await decide($, `${e.description}\n\n${e.prompt}`) : mode
178    const started = await next(tier === undefined ? e : { ...e, model: tier })
179    if (started.deny !== undefined || started.model === undefined) return started
180
181    const source: Source =
182      tier !== undefined ? (mode === 'auto' ? 'router' : 'pinned') : e.model !== undefined ? 'requested' : 'inherited'
183    const ranOn = tierOf(started.model)
184    const parentTier = tierOf(e.parentModel)
185    // A teammate carries a teammateId rather than an agentId.
186    const agentId = started.agentId ?? started.teammateId ?? ''
187    if (ranOn !== undefined && started.agentId !== undefined) running.set(started.agentId, ranOn)
188
189    if (ranOn !== undefined && ranOn !== parentTier) {
190      const why = source === 'router' ? 'router picked' : source === 'pinned' ? 'pinned' : 'requested'
191      $.ui.toast(`Sub-agent "${e.description}" runs on ${ranOn} (${why}; main thread: ${parentTier ?? e.parentModel})`, {
192        timeoutMs: 6000,
193      })
194    }
195
196    const decision: Decision = {
197      at: await $.clock.now().catch(() => 0),
198      agentId,
199      subagentType: e.subagentType,
200      description: e.description,
201      model: started.model,
202      parentModel: e.parentModel,
203      source,
204    }
205    await changeStats($, stats => {
206      stats.recent = [...stats.recent, decision].slice(-RECENT_KEPT)
207      if (ranOn === undefined) return
208      const t = (stats.totals[ranOn] ??= emptyTotals())
209      t.spawns += 1
210    })
211
212    return started
213    // Only work before `next` can throw (the code after it is guarded), so a
214    // failure here never starts the sub-agent twice: it just runs unrouted.
215  }).catch(($, e, next) => next(e))
216
217  // Adds each routed sub-agent's token usage to its tier's totals.
218  on('turn.complete', async ($, e, next) => {
219    const tier = e.agentId === undefined ? undefined : running.get(e.agentId)
220    if (tier !== undefined && e.agentId !== undefined) {
221      running.delete(e.agentId)
222      const usage: TurnUsage | undefined = e.usage
223      if (usage !== undefined) {
224        await changeStats($, stats => {
225          const t = (stats.totals[tier] ??= emptyTotals())
226          t.input += usage.input_tokens
227          t.output += usage.output_tokens
228          t.cacheRead += usage.cache_read_input_tokens
229          t.cacheWrite += usage.cache_creation_input_tokens
230        })
231      }
232    }
233
234    return next(e)
235  })
236}
237
types/index.d.ts 8 lines
1export type Mode = 'auto' | 'off' | 'opus' | 'sonnet' | 'haiku'
2
3declare module 'claude-code' {
4  interface PluginState {
5    'model-router': { mode: Mode }
6  }
7}
8