SLOPSHOPPER

smart-model-router

Routes subagents to Sonnet or Haiku under rules you set with /router (on, off, sonnet, haiku, both), and logs every routing decision

newcommandtoaststatusagents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · smart-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 › /router ⎿ smart-model-router: Router: off — subagents use their own or the parent model ⎿ smart-model-router: Routed this session: 0 to Sonnet, 0 to Haiku, 0 running. ⎿ smart-model-router: Usage: /router on|off|sonnet|haiku|both|status|log|clear ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods-public

Three Claude Code mods from Thalix, packaged as one plugin marketplace, with cmods, a small Rust CLI that installs, lists and upgrades them. Each mod is a hook-module plugin: a register(on) module that hooks engine events and draws into Claude Code as a band above the prompt, a side pane or a slash command.

The mods

ModWhat it doesCommandChanges behaviour
prompt-enhancerAn Enhance button above the prompt. It rewrites your rough prompt into a specific one, using the project's instructions, your installed skills and the recent conversation, and puts it back in the box to edit. Then Send sends it.`/enhance <text> \send \undo \status \hide \show`Yes: it calls a model (Sonnet) to rewrite the prompt
design-pickerWhen Claude asks you to choose a design direction, it opens a pane that draws each option as an SVG mockup with a Choose button, and sends your pick as the next prompt.`/design-picker show \toggle \on \off \status`It adds a short system-prompt section steering Claude to the picker; /design-picker off removes it
smart-model-routerRoutes subagents to Sonnet or Haiku under rules you set, and logs every routing decision. Off until you turn it on.`/router on \off \sonnet \haiku \both \status \log \clear`Yes: it changes which model subagents run on

Requirements: Claude Code 2.1.285 or newer, and design-picker needs 2.1.286 or newer. cmods warns if the claude on your PATH is older.

Install

1. Get cmods

macOS / Linux (prebuilt binary):

curl -fsSL https://raw.githubusercontent.com/thalixinc/claude-mods-public/main/install.sh | sh

This installs to ~/.local/bin. Set CMODS_INSTALL_DIR to choose another folder, or CMODS_VERSION=v0.1.0 to pin a release. The script checks the archive's SHA-256 before installing.

With Rust (any platform):

cargo install --locked --git https://github.com/thalixinc/claude-mods-public claude-mods-public

Windows: download cmods-x86_64-pc-windows-msvc.zip from Releases and put cmods.exe on your PATH, or use the cargo command above.

2. Install the mods

cmods install

With no names it opens a menu. Or name the mods you want, or take all three:

cmods install prompt-enhancer design-picker
cmods install all

Mods install at user scope by default, so they load in every project. Pass --project to share them through .claude/settings.json, or --local for just you in this folder. all asks before including the mods that change behaviour, and --yes skips that question.

Start a new Claude Code session (or restart the app) to load them.

Without the CLI

Inside Claude Code:

/plugin marketplace add thalixinc/claude-mods-public
/plugin install prompt-enhancer@claude-mods-public

Everyday use

CommandWhat it does
cmods listEvery mod: the available version, the installed version and its scope
cmods updateUpgrades cmods itself, then every installed mod that has a newer version (--check to only report)
`cmods uninstall [names\all]`Removes mods (--keep-data keeps their saved data)
cmods doctorChecks that claude, git and the marketplace are set up
cmods versionShows the versions of cmods, claude and the marketplace

If you also have these mods from the private claude-mods marketplace, cmods install warns you. Two copies of the same mod would both hook the same events.

Versioning

  • Each mod has its own version, in plugins/<name>/.claude-plugin/plugin.json, mirrored in .claude-plugin/marketplace.json. Claude Code only offers an update when that version goes up.
  • The CLI is versioned in cli/Cargo.toml and released as vX.Y.Z tags.
  • CI (cmods check) fails a change that touches a mod or the CLI without bumping its version, or when the two manifests disagree.

Releasing

  1. Bump the version: plugins/<name>/.claude-plugin/plugin.json plus its marketplace.json entry for a mod, and cli/Cargo.toml for the CLI (then run cargo build so Cargo.lock follows).
  2. Add a section to CHANGELOG.md and merge to main. Mod updates reach people as soon as they're on main.
  3. For a CLI release, tag it and push the tag:
git tag v0.1.0 && git push origin v0.1.0

The release workflow builds macOS (arm64, x86_64), Linux (x86_64, arm64, static musl) and Windows (x86_64) binaries, and publishes them with SHA-256 files to a GitHub Release.

Developing a mod

Load a mod live from this checkout. Installed copies are cached and don't hot-reload.

claude --plugin-dir plugins/prompt-enhancer

Check before a PR:

claude plugin validate . && claude plugin test plugins/prompt-enhancer
cd cli && cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test

License

MIT

Source 2 files
hooks/register.ts 238 lines
1import type { AgentSpawnInput, EngineInterface, Register } from 'claude-code'
2
3import type { RoutedModel, RouteRecord, RouterMode } from '../types'
4
5const MODE = { plugin: 'smart-model-router', key: 'mode' } as const
6const ROUTES = { plugin: 'smart-model-router', key: 'routes' } as const
7
8async function getMode($: EngineInterface): Promise<RouterMode> {
9  return (await $.state.get(MODE)).value ?? 'off'
10}
11
12async function getRoutes($: EngineInterface): Promise<RouteRecord[]> {
13  return (await $.state.get(ROUTES)).value ?? []
14}
15
16async function editRoutes($: EngineInterface, fn: (list: RouteRecord[]) => RouteRecord[]) {
17  await $.state.set(ROUTES, fn(await getRoutes($)))
18}
19
20// The mode survives sessions through the plugin's store.
21const STORE_MODE = 'mode'
22const MAX_ROUTES = 200
23const LOG_LINES = 15
24
25const MODES: readonly RouterMode[] = ['off', 'sonnet', 'haiku', 'both']
26const ARGS = 'on|off|sonnet|haiku|both|status|log|clear'
27
28const MODE_LABEL: Record<RouterMode, string> = {
29  off: 'off — subagents use their own or the parent model',
30  sonnet: 'sonnet — every subagent runs on Sonnet',
31  haiku: 'haiku — every subagent runs on Haiku',
32  both: 'both — Haiku for lookups and inspection, Sonnet for building and reasoning',
33}
34
35// Agent types that only read and report: Haiku handles them in `both`.
36const LIGHT_AGENTS = new Set(['Explore', 'claude-code-guide', 'statusline-setup'])
37// Agent types that plan or judge: Sonnet in `both`.
38const HEAVY_AGENTS = new Set(['Plan'])
39
40// For general-purpose and plugin agents, the task's own words decide.
41const HEAVY_WORDS =
42  /\b(implement|write|build|create|refactor|fix|debug|design|architect|migrate|rewrite|review|plan|optimi[sz]e|edit|modify|test strategy|diagnos)/i
43const LIGHT_WORDS =
44  /\b(find|search|locate|list|inspect|look up|lookup|grep|read|scan|summari[sz]e|count|check|explore|map|survey|where|which|what)/i
45
46function classify(e: AgentSpawnInput): { routed: RoutedModel; reason: string } {
47  if (LIGHT_AGENTS.has(e.subagentType)) {
48    return { routed: 'haiku', reason: `${e.subagentType} is read-only` }
49  }
50  if (HEAVY_AGENTS.has(e.subagentType)) {
51    return { routed: 'sonnet', reason: `${e.subagentType} needs reasoning` }
52  }
53
54  const task = `${e.description}\n${e.prompt}`
55  const heavy = HEAVY_WORDS.exec(task)
56  if (heavy) {
57    return { routed: 'sonnet', reason: `task says "${heavy[1].toLowerCase()}"` }
58  }
59  const light = LIGHT_WORDS.exec(task)
60  if (light) {
61    return { routed: 'haiku', reason: `task says "${light[1].toLowerCase()}"` }
62  }
63
64  return { routed: 'sonnet', reason: 'no lookup signal; default to Sonnet' }
65}
66
67function route(m: RouterMode, e: AgentSpawnInput): { routed: RoutedModel; reason: string } {
68  if (m === 'both') {
69    return classify(e)
70  }
71  return { routed: m as RoutedModel, reason: `mode ${m}` }
72}
73
74function tally(list: readonly RouteRecord[]) {
75  const haiku = list.filter(r => r.routed === 'haiku').length
76  const sonnet = list.length - haiku
77  const running = list.filter(r => r.isRunning).length
78  return { haiku, sonnet, running }
79}
80
81async function showStatus($: EngineInterface) {
82  const m = await getMode($)
83
84  if (m === 'off') {
85    $.ui.status(undefined)
86    return
87  }
88
89  const { haiku, sonnet, running } = tally(await getRoutes($))
90  const live = running > 0 ? ` • ${running} running` : ''
91  $.ui.status(`router ${m} • ${sonnet} sonnet / ${haiku} haiku${live}`)
92}
93
94// What the model is told when the mode changes, so it plans around it.
95function modelNote(m: RouterMode) {
96  if (m === 'off') {
97    return 'The model router is off: subagents run on the model you pass or their default.'
98  }
99  return [
100    `The model router is on (mode: ${m}). Every subagent you start with the Agent tool is assigned a model by the router; any \`model\` you pass is overridden.`,
101    'So delegate freely: hand self-contained lookups, inspections and summaries to subagents, and start independent subagents in parallel (several Agent calls in one message) rather than one after another.',
102  ].join(' ')
103}
104
105function formatLog(list: readonly RouteRecord[]) {
106  if (list.length === 0) {
107    return 'No subagents routed yet.'
108  }
109  return list
110    .slice(-LOG_LINES)
111    .map(r => {
112      const time = new Date(r.at).toISOString().slice(11, 19)
113      const state = r.isRunning ? 'running' : 'done'
114      const asked = r.requested && r.requested !== r.routed ? ` (asked ${r.requested})` : ''
115      return `${time}  ${r.routed.padEnd(6)} ${r.subagentType}: ${r.description} — ${r.reason}${asked} [${state}]`
116    })
117    .join('\n')
118}
119
120async function setMode($: EngineInterface, m: RouterMode) {
121  await $.state.set(MODE, m)
122  await $.store.set(STORE_MODE, m)
123  await showStatus($)
124}
125
126export const register: Register = on => {
127  on('session.start', async ($, e, next) => {
128    const result = await next(e)
129
130    const saved = await $.store.get(STORE_MODE)
131    if (typeof saved === 'string' && (MODES as readonly string[]).includes(saved)) {
132      await $.state.set(MODE, saved as RouterMode)
133    }
134
135    await $.command.register({
136      name: 'router',
137      description: 'Smart Model Router: choose which model subagents run on',
138      argumentHint: `[${ARGS}]`,
139      immediate: true,
140    })
141    await showStatus($)
142
143    return result
144  })
145
146  on('command.run', { command: 'router' }, async ($, e) => {
147    const arg = e.args.trim().toLowerCase()
148    const current = await getMode($)
149
150    if (arg === '' || arg === 'status') {
151      const { haiku, sonnet, running } = tally(await getRoutes($))
152      return {
153        text: [
154          `Router: ${MODE_LABEL[current]}`,
155          `Routed this session: ${sonnet} to Sonnet, ${haiku} to Haiku, ${running} running.`,
156          `Usage: /router ${ARGS}`,
157        ].join('\n'),
158      }
159    }
160
161    if (arg === 'log') {
162      return { text: formatLog(await getRoutes($)) }
163    }
164
165    if (arg === 'clear') {
166      await $.state.set(ROUTES, [])
167      await showStatus($)
168      return { text: 'Router log cleared.' }
169    }
170
171    const chosen: RouterMode | undefined =
172      arg === 'on' ? (current === 'off' ? 'both' : current) : MODES.find(m => m === arg)
173
174    if (chosen === undefined) {
175      return { text: `Unknown option "${arg}". Usage: /router ${ARGS}` }
176    }
177
178    await setMode($, chosen)
179    return { text: `Router: ${MODE_LABEL[chosen]}`, context: [modelNote(chosen)] }
180  })
181
182  // The routing itself: the Agent tool's spawn, before its model resolves.
183  on('agent.spawn', async ($, e, next) => {
184    const m = await getMode($)
185
186    // Forks always inherit the parent's model; nothing to route.
187    if (m === 'off' || e.fork) {
188      return next(e)
189    }
190
191    const { routed, reason } = route(m, e)
192    const record: RouteRecord = {
193      toolUseId: e.tool_use_id,
194      at: await $.clock.now(),
195      subagentType: e.subagentType,
196      description: e.description,
197      requested: e.model,
198      routed,
199      reason,
200      isRunning: true,
201    }
202    await editRoutes($, list => [...list, record].slice(-MAX_ROUTES))
203
204    const result = await next({ ...e, model: routed })
205
206    if (result.deny !== undefined) {
207      await editRoutes($, list => list.filter(r => r.toolUseId !== record.toolUseId))
208    } else {
209      await editRoutes($, list =>
210        list.map(r => (r.toolUseId === record.toolUseId ? { ...r, agentId: result.agentId } : r)),
211      )
212      $.ui.toast(`Router → ${routed}: ${e.subagentType} "${e.description}"`)
213    }
214
215    await showStatus($)
216    return result
217  })
218
219  // A subagent's last turn marks its route done.
220  on('turn.complete', async ($, e, next) => {
221    const result = await next(e)
222
223    if (e.agentId === undefined) {
224      return result
225    }
226
227    const list = await getRoutes($)
228    if (list.some(r => r.agentId === e.agentId && r.isRunning)) {
229      await editRoutes($, all =>
230        all.map(r => (r.agentId === e.agentId ? { ...r, isRunning: false } : r)),
231      )
232      await showStatus($)
233    }
234
235    return result
236  })
237}
238
types/index.d.ts 33 lines
1/** How subagents are routed. `both` (what `on` turns on) picks per task. */
2export type RouterMode = 'off' | 'sonnet' | 'haiku' | 'both'
3
4export type RoutedModel = 'sonnet' | 'haiku'
5
6export type RouteRecord = {
7  /** The Agent tool call the spawn belongs to. */
8  toolUseId: string
9  /** When the spawn was routed, ms since epoch. */
10  at: number
11  subagentType: string
12  description: string
13  /** The model the caller asked for, if any, before routing. */
14  requested?: string
15  /** The model the router assigned. */
16  routed: RoutedModel
17  /** Why: the rule that picked it. */
18  reason: string
19  /** True while the subagent runs; false once its turn completed. */
20  isRunning: boolean
21  /** The subagent's id once started, to match its turn.complete. */
22  agentId?: string
23}
24
25declare module 'claude-code' {
26  interface PluginState {
27    'smart-model-router': {
28      mode: RouterMode
29      routes: RouteRecord[]
30    }
31  }
32}
33