SLOPSHOPPER

boss-fight

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

newbandguardcommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · boss-fight
› fix the failing auth test and add an audit log call ╭───────────────────────────────────────────╮ │ boss-fight │ ⏺ Read(src/auth.ts) │ (ノ°益°)ノ A wild Test Slime appears! (3 HP) │ ⎿ 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 › /boss ⎿ boss-fight: (ノ°益°)ノ Test Slime: 3/3 HP, 0 hits taken ⎿ boss-fight: 🏆 0 slain all-time (ノ°益°)ノ Test Slime ████████████ 3/3 appears with 3 HP! ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
(ノ°益°)ノ Test Slime ████████████ 3/3 appears with 3 HP!
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 104 lines
1import { 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};
104
hooks/boss.ts 292 lines
1import 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};
292
types/index.d.ts 21 lines
1export 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