SLOPSHOPPER

model-router

Routes prompts and subagents to a cheaper or stronger model by what they ask

newcommandstatuspromptagents
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: Model router is on. light → claude-haiku-5-5; standard → claude-sonnet-5-5; heavy → session model; main thread ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods

Seven mods for Claude Code: a pet that grows while Claude works, live panes for agents and files, a boss fight against failing tests, a chime for long turns, and a model router. They work in any project and any language.

ModWhat it doesWhereCommand
cute-feedA pet that hatches, grows and reacts to what Claude does, over a live emoji feedpane/feed, /pat
dashboardProject, git, session cost, context, rate limits, subagents and recent activitypane/dashboard
swarmLive tree of subagents: who spawned whom, what each is running right nowpane/swarm
repo-radarMap of the repo that lights up as files are read, edited or failpane/radar
boss-fightFailing lint, tests or typechecks spawn a boss; each fix damages itabove the prompt/boss
chimeSound + notification when a long turn finishes or Claude waits on yousound/chime
model-routerSends routine prompts and read-only subagents to a cheaper modelstatus line/route

Install

Requires a Claude Code build with mods (function hooks). In a terminal session, install any mod by name:

/plugin install cute-feed --marketplace rabanderlipe/claude-code-mods

Answer y to add the marketplace, then pick a scope (user scope = every project). Repeat with dashboard, swarm, repo-radar, boss-fight, chime or model-router.

Panes open as tabs beside the transcript. One the mod opens on its own needs a terminal at least 144 columns wide; its slash command opens it at any width.

Settings

Every setting is a row in /config, or under pluginConfigs.<mod> in your settings.

cute-feed

  • autoOpen (true) — open the pane when a session starts
  • petName ("Mochi"), species ("cat") — for a brand-new pet: cat, chick, dog, slime, bunny, hamster, penguin, frog
  • dangerPatterns ("") — extra ;-separated regexes for commands that make the pet panic (it never blocks them)

dashboard

  • autoOpen (false), refreshSeconds (5)

swarm

  • autoOpen (true) — open when the first subagent starts

repo-radar

  • autoOpen (false), depth (2) — folder levels always shown

boss-fight

  • enabledSuites ("lint,unit,int,db,e2e,typecheck")
  • extraPatterns ("") — your own commands, suite=regex;suite=regex, e.g. unit=\bjust test\b;e2e=\bmake smoke\b

Recognised out of the box: npm / pnpm / yarn / bun / make scripts (test, test:int, e2e, lint, typecheck, …), eslint, oxlint, biome, ruff, flake8, pylint, rubocop, golangci-lint, clippy, vitest, jest, mocha, ava, pytest, go test, cargo test, rspec, phpunit, dotnet test, mix test, pg_prove, playwright, cypress, tsc, vue-tsc, mypy, pyright, flow.

chime

  • thresholdSeconds (45), notify (true), waitingSound (true)

model-router

  • lightModel ("claude-haiku-5-5"), standardModel ("claude-sonnet-5-5")
  • maxMessages (40) — past this, switching models costs a prompt-cache rebuild, so it stops routing
  • lightAgents ("Explore,claude-code-guide,statusline-setup")
  • routeMainThread (true), routeSubagents (true)

Prefix a prompt with !haiku, !sonnet or !opus to force a model for that turn.

Using the pet

  • It hatches from an egg after 3 turns, then grows baby → kid → teen → adult (level 7). The adult form depends on how it was raised: Golden, Chonk, Scholar, Gremlin, Shepherd, Owl, Hacker or Classic.
  • It eats the lines Claude writes, gets hungry over time (also between sessions), gets sick after repeated failures, panics at destructive commands, gets stuffed as context fills up.
  • Buttons in the pane: Pat ♥, Feed 🍙, Play 🎾. Commands: /pat, /feed name <name>, /feed species <name>, /feed feed.

Develop

claude --plugin-dir plugins/cute-feed      # load one mod from disk, hot-reloaded on save
claude plugin validate plugins/cute-feed
claude plugin test plugins/cute-feed

Mods are not sandboxed: read the source before installing any mod, these included.

License

MIT

Source 3 files
hooks/register.tsx 98 lines
1import { atom, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import {
5  configFrom,
6  describeConfig,
7  route,
8  stripOverride,
9  subagentModel,
10} from "./route";
11
12const last = atom({ plugin: "model-router", key: "last" } as const, null);
13
14async function isEnabled($: EngineInterface): Promise<boolean> {
15  return (await $.store.get("enabled")) !== false;
16}
17
18export const register: Register = (on, options) => {
19  const config = configFrom(options);
20  // The model picked for each main-thread turn, by turnId.
21  const picks = new Map<string, { model: string; reason: string }>();
22  let pending: { model: string; reason: string } | null = null;
23
24  on("session.start", async ($, e, next) => {
25    await $.command.register({
26      name: "route",
27      description:
28        "Model router: /route on | off | status (prefix a prompt with !haiku, !sonnet or !opus to force)",
29    });
30
31    return next(e);
32  });
33
34  on("command.run", { command: "route" }, async ($, e) => {
35    const arg = e.args.trim().toLowerCase();
36    if (arg === "on" || arg === "off") {
37      await $.store.set("enabled", arg === "on");
38      $.ui.status(undefined);
39      return { text: `Model router ${arg}.` };
40    }
41    return {
42      text: `Model router is ${(await isEnabled($)) ? "on" : "off"}. ${describeConfig(config)}.`,
43    };
44  });
45
46  on("prompt.submit", async ($, e, next) => {
47    pending = null;
48    if (!config.routeMainThread || !(await isEnabled($))) return next(e);
49
50    const picked = route(e.text);
51    const model = picked ? config.models[picked.tier] : null;
52    if (picked && model) pending = { model, reason: picked.reason };
53    $.ui.status(
54      picked && model
55        ? `↳ ${model.replace(/^claude-/, "")} · ${picked.reason}`
56        : undefined,
57    );
58
59    const text = stripOverride(e.text);
60    return next(text === e.text ? e : { ...e, text });
61  }).catch(($, e, next) => next(e));
62
63  on("turn.start", async ($, e, next) => {
64    if (pending) {
65      picks.set(e.turnId, pending);
66      await update($, last, () => ({ ...pending!, text: e.text.slice(0, 80) }));
67      pending = null;
68    }
69    return next(e);
70  });
71
72  on("turn.step", async function* ($, e, next) {
73    const pick = e.agentId === undefined ? picks.get(e.turnId) : undefined;
74    const isWorthIt =
75      pick &&
76      (pick.reason === "forced" || e.messageCount <= config.maxMessages);
77
78    return yield* next(isWorthIt ? { ...e, model: pick.model } : e);
79  });
80
81  on("turn.complete", ($, e, next) => {
82    if (e.agentId === undefined) picks.delete(e.turnId);
83    return next(e);
84  });
85
86  on("agent.spawn", async ($, e, next) => {
87    const model = subagentModel(config, {
88      subagentType: e.subagentType,
89      model: e.model,
90      fork: e.fork,
91      isWorkflow: e.workflow !== undefined,
92    });
93    if (model === undefined || !(await isEnabled($))) return next(e);
94
95    return next({ ...e, model });
96  }).catch(($, e, next) => next(e));
97};
98
hooks/route.ts 109 lines
1import type { PluginOptions } from "claude-code";
2
3export type Tier = "light" | "standard" | "heavy";
4
5export type Route = { tier: Tier; reason: string };
6
7export type RouterConfig = {
8  // Which model each tier runs on; `heavy` keeps the session's own model.
9  models: Record<Tier, string | null>;
10  // Past this many messages a model switch rewrites the whole prompt cache, which costs more than it saves.
11  maxMessages: number;
12  // Subagent types that only search or read: sent to the light model unless the call names one.
13  lightAgents: ReadonlySet<string>;
14  routeMainThread: boolean;
15  routeSubagents: boolean;
16};
17
18export const DEFAULTS = {
19  lightModel: "claude-haiku-5-5",
20  standardModel: "claude-sonnet-5-5",
21  maxMessages: 40,
22  lightAgents: "Explore,claude-code-guide,statusline-setup",
23  routeMainThread: true,
24  routeSubagents: true,
25} as const;
26
27const str = (v: unknown, fallback: string) =>
28  typeof v === "string" && v.trim() !== "" ? v.trim() : fallback;
29const num = (v: unknown, fallback: number) =>
30  typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : fallback;
31const bool = (v: unknown, fallback: boolean) =>
32  typeof v === "boolean" ? v : fallback;
33
34export const configFrom = (
35  options: PluginOptions | undefined,
36): RouterConfig => {
37  const o = options ?? {};
38  return {
39    models: {
40      light: str(o.lightModel, DEFAULTS.lightModel),
41      standard: str(o.standardModel, DEFAULTS.standardModel),
42      heavy: null,
43    },
44    maxMessages: num(o.maxMessages, DEFAULTS.maxMessages),
45    lightAgents: new Set(
46      str(o.lightAgents, DEFAULTS.lightAgents)
47        .split(",")
48        .map((s) => s.trim())
49        .filter(Boolean),
50    ),
51    routeMainThread: bool(o.routeMainThread, DEFAULTS.routeMainThread),
52    routeSubagents: bool(o.routeSubagents, DEFAULTS.routeSubagents),
53  };
54};
55
56export const describeConfig = (c: RouterConfig) =>
57  [
58    `light → ${c.models.light}`,
59    `standard → ${c.models.standard}`,
60    "heavy → session model",
61    `main thread ${c.routeMainThread ? `routed (up to ${c.maxMessages} messages)` : "not routed"}`,
62    `subagents ${c.routeSubagents ? `routed: ${[...c.lightAgents].join(", ") || "none"}` : "not routed"}`,
63  ].join("; ");
64
65const OVERRIDE = /^\s*!(haiku|sonnet|opus)\b/i;
66const HEAVY =
67  /\b(debug|bug|broken|fail|refactor|architect|design|plan|implement|migrat|security|race condition|perf|optimi[sz]|why does|root cause)/i;
68const LIGHT =
69  /^\s*(git (status|log|diff|pull|push)|commit|push|run (the )?(tests?|lint|format|build)|lint|format|typecheck|rename|bump|open (the )?pr|status)\b/i;
70const QUESTION =
71  /^\s*(what|where|which|who|when|how (do|does|can|many|much)|is|are|does|do|can|list|show|explain|summari[sz]e)\b/i;
72
73export const route = (text: string): Route | null => {
74  const forced = OVERRIDE.exec(text)?.[1]?.toLowerCase();
75  if (forced === "haiku") return { tier: "light", reason: "forced" };
76  if (forced === "sonnet") return { tier: "standard", reason: "forced" };
77  if (forced === "opus") return { tier: "heavy", reason: "forced" };
78
79  if (text.trimStart().startsWith("/")) return null;
80  if (HEAVY.test(text)) return { tier: "heavy", reason: "complex task" };
81  if (text.length <= 160 && LIGHT.test(text))
82    return { tier: "light", reason: "routine command" };
83  if (text.length <= 300 && QUESTION.test(text))
84    return { tier: "standard", reason: "question" };
85
86  return null;
87};
88
89export const stripOverride = (text: string) =>
90  text.replace(OVERRIDE, "").trimStart();
91
92// The model a subagent spawn is rewritten to, or undefined to leave it as called.
93export const subagentModel = (
94  c: RouterConfig,
95  spawn: {
96    subagentType: string;
97    model?: string;
98    fork: boolean;
99    isWorkflow: boolean;
100  },
101): string | undefined =>
102  c.routeSubagents &&
103  c.lightAgents.has(spawn.subagentType) &&
104  spawn.model === undefined &&
105  !spawn.fork &&
106  !spawn.isWorkflow
107    ? (c.models.light ?? undefined)
108    : undefined;
109
types/index.d.ts 8 lines
1export type LastRoute = { model: string; reason: string; text: string };
2
3declare module "claude-code" {
4  interface PluginState {
5    "model-router": { last: LastRoute | null };
6  }
7}
8