Failing lint, tests or typechecks spawn a boss above the prompt; each fix damages it

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.
| Mod | What it does | Where | Command |
|---|---|---|---|
| cute-feed | A pet that hatches, grows and reacts to what Claude does, over a live emoji feed | pane | /feed, /pat |
| dashboard | Project, git, session cost, context, rate limits, subagents and recent activity | pane | /dashboard |
| swarm | Live tree of subagents: who spawned whom, what each is running right now | pane | /swarm |
| repo-radar | Map of the repo that lights up as files are read, edited or fail | pane | /radar |
| boss-fight | Failing lint, tests or typechecks spawn a boss; each fix damages it | above the prompt | /boss |
| chime | Sound + notification when a long turn finishes or Claude waits on you | sound | /chime |
| model-router | Sends routine prompts and read-only subagents to a cheaper model | status line | /route |
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.
Every setting is a row in /config, or under pluginConfigs.<mod> in your settings.
cute-feed
autoOpen (true) — open the pane when a session startspetName ("Mochi"), species ("cat") — for a brand-new pet: cat, chick, dog, slime, bunny, hamster, penguin, frogdangerPatterns ("") — 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 startsrepo-radar
autoOpen (false), depth (2) — folder levels always shownboss-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\bRecognised 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 routinglightAgents ("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.
/pat, /feed name <name>, /feed species <name>, /feed feed.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.
MIT
hooks/register.tsx 104 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import { applyRun, classify, failures, hpBar, isFailedRun, parseConfig } from "./boss";
5import type { FightConfig } from "./boss";
6
7const VICTORY_MS = 10_000;
8
9const bosses = atom({ plugin: "boss-fight", key: "bosses" } as const, []);
10const victory = atom({ plugin: "boss-fight", key: "victory" } as const, null);
11
12async function kills($: EngineInterface): Promise<number> {
13 const n = await $.store.get("kills");
14 return typeof n === "number" ? n : 0;
15}
16
17async function fight(
18 $: EngineInterface,
19 config: FightConfig,
20 command: string,
21 result: { isError?: boolean; text?: string },
22): Promise<void> {
23 const suite = classify(command, config);
24 if (suite === null) return;
25
26 const failed = isFailedRun(result);
27 const count = failed ? failures(suite, result.text ?? "") : 0;
28 const now = await $.clock.now();
29 const outcome = applyRun(await read($, bosses), suite, failed, count, now);
30 if (outcome.kind === "none") return;
31
32 await update($, bosses, () => outcome.bosses);
33
34 if (outcome.kind === "spawn") {
35 $.ui.toast(
36 `${outcome.boss.sprite} A wild ${outcome.boss.name} appears! (${outcome.boss.hp} HP)`,
37 );
38 } else if (outcome.kind === "ko") {
39 const total = (await kills($)) + 1;
40 await $.store.set("kills", total);
41 const text = `⚔ defeated ${outcome.boss.name} in ${outcome.minutes}m, ${outcome.hits} hit${outcome.hits === 1 ? "" : "s"} · ${total} slain`;
42 await update($, victory, () => ({ text, until: now + VICTORY_MS }));
43 $.ui.toast(`🏆 ${outcome.boss.name} defeated!`);
44 $.clock.after(VICTORY_MS, () => {
45 void update($, victory, (v) =>
46 v !== null && v.text === text ? null : v,
47 );
48 });
49 }
50}
51
52export const register: Register = (on, options) => {
53 const config = parseConfig(options);
54
55 on("session.start", async ($, e, next) => {
56 await $.command.register({
57 name: "boss",
58 description: "Boss fight: list active bosses and your kill count",
59 });
60 return next(e);
61 });
62
63 on("command.run", { command: "boss" }, async ($) => {
64 const list = await read($, bosses);
65 const total = await kills($);
66 const lines = list.map(
67 (b) =>
68 `${b.sprite} ${b.name}: ${b.hp}/${b.maxHp} HP, ${b.hits} hits taken`,
69 );
70 return {
71 text: `${lines.length ? lines.join("\n") : "No bosses. Peaceful lands."}\n🏆 ${total} slain all-time`,
72 };
73 });
74
75 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
76 const ran = await next(e);
77 if (ran.deny === undefined) await fight($, config, e.command, ran);
78 return ran;
79 }).catch(($, e, next) => next(e));
80
81 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
82 const [list, won] = await Promise.all([read($, bosses), read($, victory)]);
83 if (e.props.hasSurvey || (list.length === 0 && won === null))
84 return next(e);
85
86 const { Box, Text } = $.ui.resolve(e);
87 const isSolo = list.length === 1;
88
89 return (
90 <Box flexDirection="column">
91 {list.map((b) => (
92 <Text>
93 <Text color="red">{b.sprite}</Text> <Text bold>{b.name}</Text>{" "}
94 <Text color="red">{hpBar(b.hp, b.maxHp)}</Text> {b.hp}/{b.maxHp}
95 {!isSolo && <Text dimColor> · {b.last}</Text>}
96 </Text>
97 ))}
98 {isSolo && list[0] && <Text dimColor> {list[0].last}</Text>}
99 {won && <Text color="green">{won.text}</Text>}
100 </Box>
101 );
102 });
103};
104hooks/boss.ts 292 lines1import type { Boss, Suite } from "../types";
2
3export const SUITES: readonly Suite[] = [
4 "lint",
5 "unit",
6 "int",
7 "db",
8 "e2e",
9 "typecheck",
10];
11
12export type FightConfig = {
13 // User patterns, tried before the built-ins.
14 extra: [Suite, RegExp][];
15 enabled: ReadonlySet<Suite>;
16};
17
18const isSuite = (s: string): s is Suite =>
19 (SUITES as readonly string[]).includes(s);
20
21// `extraPatterns` is `suite=regex;suite=regex`; a bad suite or regex is skipped.
22export const parseConfig = (
23 options: Readonly<Record<string, unknown>> = {},
24): FightConfig => {
25 const extra: [Suite, RegExp][] = [];
26 const raw =
27 typeof options.extraPatterns === "string" ? options.extraPatterns : "";
28 for (const part of raw.split(";")) {
29 const eq = part.indexOf("=");
30 if (eq < 1) continue;
31 const suite = part.slice(0, eq).trim();
32 const source = part.slice(eq + 1).trim();
33 if (!isSuite(suite) || source === "") continue;
34 try {
35 extra.push([suite, new RegExp(source, "i")]);
36 } catch {
37 // An invalid regex is ignored rather than breaking the mod.
38 }
39 }
40
41 const listed =
42 typeof options.enabledSuites === "string"
43 ? options.enabledSuites
44 : SUITES.join(",");
45 const enabled = new Set(
46 listed
47 .split(",")
48 .map((s) => s.trim())
49 .filter(isSuite),
50 );
51
52 return { extra, enabled };
53};
54
55export const DEFAULT_CONFIG: FightConfig = parseConfig();
56
57const isWatch = (command: string) =>
58 /--watch\b|:watch\b|\b(?:vitest|jest)\s+(?:watch|dev)\b/.test(command) ||
59 (/(?:^|\s)-w(?:\s|$)/.test(command) &&
60 !/\b(?:npm|pnpm|yarn|bun)\b/.test(command));
61
62// A script run through a package runner or make: its name says the suite.
63const SCRIPT =
64 /\b(?:(?:npm|pnpm|yarn|bun)(?:\s+(?:--filter|-F|--workspace|-w|--cwd|-C)\s+\S+)*\s+(?:run\s+)?|make\s+(?:-\S+\s+)*)([\w:.\-/]+)/;
65
66const scriptSuite = (name: string): Suite | null => {
67 const n = name.toLowerCase();
68 if (/e2e|playwright|cypress/.test(n)) return "e2e";
69 if (/^(?:db|pgtap)[:_-]?test|test[:_-]?(?:db|pgtap|sql)$|^pgtap$/.test(n))
70 return "db";
71 if (
72 /^test[:_-]?int(?:egration)?$|^int(?:egration)?(?:[:_-]?tests?)?$/.test(n)
73 )
74 return "int";
75 if (/typecheck|type-check|check-types|^types$|^tsc$/.test(n))
76 return "typecheck";
77 if (/^lint(?:[:_-].*)?$/.test(n)) return "lint";
78 if (
79 /^(?:test|tests|unit|test[:_-]unit|unit[:_-]tests?|test[:_-]all)$/.test(n)
80 )
81 return "unit";
82 return null;
83};
84
85// Tools called directly (or through npx, pnpm exec, uv run, poetry run…).
86const TOOLS: [Suite, RegExp][] = [
87 ["db", /\bpg_prove\b|\bsupabase\s+test\s+db\b/],
88 ["e2e", /\bplaywright\s+test\b|\bcypress\s+run\b/],
89 [
90 "typecheck",
91 /(?:^|[\s;&|(/])(?:vue-)?tsc\b|\bmypy\b|\bpyright\b|\bflow\s+check\b/,
92 ],
93 [
94 "lint",
95 /\beslint\b|\boxlint\b|\bbiome\s+(?:check|lint|ci)\b|\bruff\s+check\b|\bflake8\b|\bpylint\b|\brubocop\b|\bgolangci-lint\b|\bclippy\b/,
96 ],
97 [
98 "unit",
99 /\bvitest\b|\bjest\b|\bmocha\b|(?:^|[\s;&|(/])ava\b|\bpytest\b|\bgo\s+test\b|\bcargo\s+(?:test|nextest)\b|\brspec\b|\bphpunit\b|\bdotnet\s+test\b|\bmix\s+test\b/,
100 ],
101];
102
103const INTEGRATION = /\bint(?:egration)?\b|integration|\.int\.|[/_-]int[/_.-]/i;
104
105export const classify = (
106 command: string,
107 config: FightConfig = DEFAULT_CONFIG,
108): Suite | null => {
109 const suite = rawClassify(command, config);
110 return suite !== null && config.enabled.has(suite) ? suite : null;
111};
112
113const rawClassify = (command: string, config: FightConfig): Suite | null => {
114 const custom = config.extra.find(([, re]) => re.test(command));
115 if (custom) return custom[0];
116 if (isWatch(command)) return null;
117
118 const script = SCRIPT.exec(command)?.[1];
119 const fromScript = script ? scriptSuite(script) : null;
120 if (fromScript) return fromScript;
121
122 const tool = TOOLS.find(([, re]) => re.test(command))?.[0] ?? null;
123 if (tool === "unit" && INTEGRATION.test(command)) return "int";
124 return tool;
125};
126
127export const BOSSES: Record<Suite, { name: string; sprite: string }> = {
128 lint: { name: "Lint Goblin", sprite: "(ง •̀_•́)ง" },
129 unit: { name: "Test Slime", sprite: "(ノ°益°)ノ" },
130 int: { name: "Integration Golem", sprite: "[◣_◢]" },
131 db: { name: "Schema Lich", sprite: "(☠ ‿ ☠)" },
132 e2e: { name: "Flaky E2E Hydra", sprite: "<°)))彡<°)))彡" },
133 typecheck: { name: "Type Wraith", sprite: "(╬ಠ益ಠ)" },
134};
135
136const sum = (text: string, re: RegExp) => {
137 let total = 0;
138 let found = false;
139 for (const m of text.matchAll(re)) {
140 total += Number(m[1]);
141 found = true;
142 }
143 return found ? total : undefined;
144};
145
146const count = (text: string, re: RegExp) => text.match(re)?.length || undefined;
147
148type Extract = (text: string) => number | undefined;
149
150// Tried in order; the first that finds a positive count wins.
151const LINT: Extract[] = [
152 (t) => sum(t, /✖\s*\d+ problems? \((\d+) errors?/g), // eslint
153 (t) => sum(t, /Found \d+ warnings? and (\d+) errors?/g), // oxlint
154 (t) => sum(t, /Found (\d+) errors?/g), // biome, ruff
155 (t) => sum(t, /(\d+) offenses? detected/g), // rubocop
156 (t) => sum(t, /due to (\d+) previous errors?/g), // clippy
157 (t) => sum(t, /^(\d+) issues?:/gm), // golangci-lint
158];
159
160const TYPES: Extract[] = [
161 (t) => count(t, /error TS\d+/g), // tsc, vue-tsc
162 (t) => sum(t, /Found (\d+) errors?/g), // mypy, flow
163 (t) => sum(t, /(\d+) errors?, \d+ warnings?/g), // pyright
164];
165
166const TESTS: Extract[] = [
167 (t) => sum(t, /Tests:?\s+(\d+) failed/g), // vitest, jest
168 (t) => sum(t, /\b(\d+) failing\b/g), // mocha
169 (t) => sum(t, /\b(\d+) failures?\b/g), // rspec, mix
170 (t) => count(t, /^--- FAIL\b/gm), // go
171 (t) =>
172 (sum(t, /Failures: (\d+)/g) ?? 0) + (sum(t, /Errors: (\d+)/g) ?? 0) ||
173 undefined, // phpunit
174 (t) => sum(t, /Failed:\s+(\d+)/g), // dotnet
175 (t) => sum(t, /\b(\d+) failed\b/g), // playwright, pytest, cargo, ava
176 (t) => count(t, /^\s*not ok\b/gm), // TAP
177];
178
179const DB: Extract[] = [
180 (t) => count(t, /^\s*not ok\b/gm), // pg_prove, pgTAP
181 (t) => sum(t, /Failed (\d+)\/\d+ subtests/g),
182 ...TESTS,
183];
184
185const first = (list: Extract[], text: string) => {
186 for (const f of list) {
187 const n = f(text);
188 if (n !== undefined && n > 0) return n;
189 }
190 return undefined;
191};
192
193// How many failures the run reported, when its output says; undefined when it does not.
194export const failures = (suite: Suite, text: string): number | undefined => {
195 switch (suite) {
196 case "lint":
197 return first(LINT, text);
198 case "typecheck":
199 return first(TYPES, text);
200 case "db":
201 return first(DB, text);
202 default:
203 return first(TESTS, text);
204 }
205};
206
207export const isFailedRun = (result: { isError?: boolean; text?: string }) =>
208 result.isError === true || /Exit code [1-9]\d*/.test(result.text ?? "");
209
210const TAUNTS = [
211 "ha! is that all?",
212 "I grow stronger!",
213 "your tests feed me",
214 "try again, mortal",
215];
216const HITS = [
217 "critical hit!",
218 "a clean strike!",
219 "it staggers!",
220 "it shrieks!",
221];
222
223export type Outcome =
224 | { kind: "none"; bosses: Boss[] }
225 | { kind: "spawn" | "hit" | "heal" | "block"; bosses: Boss[]; boss: Boss }
226 | { kind: "ko"; bosses: Boss[]; boss: Boss; minutes: number; hits: number };
227
228// One run of a suite against the bosses: a failure spawns, hits or heals its boss; a pass knocks it out.
229export const applyRun = (
230 bosses: Boss[],
231 suite: Suite,
232 failed: boolean,
233 count: number | undefined,
234 now: number,
235): Outcome => {
236 const current = bosses.find((b) => b.suite === suite);
237 const others = bosses.filter((b) => b.suite !== suite);
238
239 if (!failed) {
240 if (!current) return { kind: "none", bosses };
241 return {
242 kind: "ko",
243 bosses: others,
244 boss: current,
245 minutes: Math.max(1, Math.round((now - current.spawnedAt) / 60000)),
246 hits: current.hits + 1,
247 };
248 }
249
250 if (!current) {
251 const hp = count ?? 3;
252 const boss: Boss = {
253 suite,
254 ...BOSSES[suite],
255 hp,
256 maxHp: hp,
257 hits: 0,
258 spawnedAt: now,
259 last: `appears with ${hp} HP!`,
260 };
261 return { kind: "spawn", bosses: [...others, boss], boss };
262 }
263
264 const pick = (list: string[]) =>
265 list[(current.hits + current.hp) % list.length] ?? "";
266 if (count !== undefined && count < current.hp) {
267 const boss = {
268 ...current,
269 hp: count,
270 hits: current.hits + 1,
271 last: `-${current.hp - count} ${pick(HITS)}`,
272 };
273 return { kind: "hit", bosses: [...others, boss], boss };
274 }
275 if (count !== undefined && count > current.hp) {
276 const boss = {
277 ...current,
278 hp: count,
279 maxHp: Math.max(current.maxHp, count),
280 last: `+${count - current.hp} HP: "${pick(TAUNTS)}"`,
281 };
282 return { kind: "heal", bosses: [...others, boss], boss };
283 }
284 const boss = { ...current, last: "blocked! no damage" };
285 return { kind: "block", bosses: [...others, boss], boss };
286};
287
288export const hpBar = (hp: number, maxHp: number, width = 12) => {
289 const filled = maxHp <= 0 ? 0 : Math.round((Math.max(0, hp) / maxHp) * width);
290 return "█".repeat(filled) + "░".repeat(width - filled);
291};
292types/index.d.ts 21 lines1export type Suite = "lint" | "unit" | "int" | "db" | "e2e" | "typecheck";
2
3export type Boss = {
4 suite: Suite;
5 name: string;
6 sprite: string;
7 hp: number;
8 maxHp: number;
9 hits: number;
10 spawnedAt: number;
11 last: string;
12};
13
14export type Victory = { text: string; until: number };
15
16declare module "claude-code" {
17 interface PluginState {
18 "boss-fight": { bosses: Boss[]; victory: Victory | null };
19 }
20}
21