SLOPSHOPPER

cute-feed

A pet that hatches, grows and reacts to what Claude is doing, over a live emoji feed

newpaneguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cute-feed
│ ┃ Pet ✕ › fix the failing auth test and add an audit log call │ ┃ .--. │ ┃ / \ ⏺ Read(src/auth.ts) │ ┃ \____/ ⎿ Read 6 lines │ ┃ Mochi · egg · hatches in 2 turn(s) ⏺ Update(src/auth.ts) │ ┃ 🍙 ███████░ 85 🧠 ████░░░░ 49% ⏱ 31% 🐛×1 ⎿ Added 2 lines, removed 1 line │ ┃ ✏️ 16 lines · 📖 2 · 💻 4 · 🐣 0 · 💥 1 · 🧹 ⏺ Bash(bun test) │ ┃ 0 · 🌙 0% ⎿ 3 pass, 1 fail │ ┃ [ Pat ♥ ] [ Feed 🍙 ] [ Play 🎾 ] Species: c │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 🔧 9 · 🐣 0 · 🎉 1 · 💥 1 │ ┃ 08:53 🐾 an egg! keep working to hatch it ✻ Worked for 42s · done 4:20 PM │ ┃ 08:53 💬 you: fix the failing auth test and │ ┃ add an audit l › /feed │ ┃ 08:53 📖 Read src/auth.ts ⎿ cute-feed: ฅ^•ﻌ•^ฅ pet opened │ ┃ 08:53 🔍 Grep app/src │ ┃ 08:53 ✏️ Edit src/auth.ts │ ┃ 08:53 📝 Write src/audit.ts │ ┃ 08:53 📝 Write src/cache.ts │ ┃ 08:53 💥 Bash bun test │ ┃ 08:53 💻 Bash git status --porcelain │ ┃ 08:53 💻 Bash rm -rf build && git push │ ┃ --force origin ma… │ ┃ 08:53 🚨 careful!! rm -rf build && git push │ ┃ --force origin │ ┃ 08:53 💻 Bash cat .env ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pet
.--. / \ \____/ Mochi · egg · hatches in 2 turn(s) 🍙 ███████░ 85 🧠 ████░░░░ 49% ⏱ 31% 🐛×1 ✏️ 16 lines · 📖 2 · 💻 4 · 🐣 0 · 💥 1 · 🧹 0 · 🌙 0% [ Pat ♥ ] [ Feed 🍙 ] [ Play 🎾 ] Species: cat ▾ 🔧 9 · 🐣 0 · 🎉 1 · 💥 1 08:53 🐾 an egg! keep working to hatch it 08:53 💬 you: fix the failing auth test and add an audit l 08:53 📖 Read src/auth.ts 08:53 🔍 Grep app/src 08:53 ✏️ Edit src/auth.ts 08:53 📝 Write src/audit.ts 08:53 📝 Write src/cache.ts 08:53 💥 Bash bun test 08:53 💻 Bash git status --porcelain 08:53 💻 Bash rm -rf build && git push --force origin ma… 08:53 🚨 careful!! rm -rf build && git push --force origin 08:53 💻 Bash cat .env 08:53 🎉 done in 42s 08:53 💕 you patted me
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 4 files
hooks/register.tsx 418 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { Effects, FeedItem, Mood, Pet, Species } from "../types";
5import { describe, iconFor } from "./cat";
6import {
7  FEED_COOLDOWN_MS,
8  LOOK_COLOR,
9  SPECIES,
10  adultColor,
11  adultOf,
12  art,
13  bugKey,
14  foodNow,
15  isDangerous,
16  parsePatterns,
17  isNight,
18  isSick,
19  isSpecies,
20  levelOf,
21  linesOf,
22  lookOf,
23  meter,
24  progressOf,
25  revive,
26  shouldHatch,
27  stageLabel,
28  stageOf,
29  withFood,
30} from "./pet";
31
32const PANE = "cute-feed";
33const TICK_MS = 600;
34const VITALS_MS = 10_000;
35const NAP_AFTER_MS = 20_000;
36const CHEER_FOR_MS = 6_000;
37
38const feed = atom({ plugin: "cute-feed", key: "feed" } as const, []);
39const mood = atom({ plugin: "cute-feed", key: "mood" } as const, "sleepy");
40const frame = atom({ plugin: "cute-feed", key: "frame" } as const, 0);
41const stats = atom({ plugin: "cute-feed", key: "stats" } as const, { tools: 0, agents: 0, turns: 0, oops: 0 });
42const petAtom = atom({ plugin: "cute-feed", key: "pet" } as const, null);
43const vitals = atom({ plugin: "cute-feed", key: "vitals" } as const, {});
44const effects = atom({ plugin: "cute-feed", key: "effects" } as const, {
45  pattedUntil: 0,
46  playUntil: 0,
47  eatUntil: 0,
48  sickUntil: 0,
49  panicUntil: 0,
50});
51
52let lastActivity = 0;
53let moodSince = 0;
54let seq = 0;
55let failTimes: number[] = [];
56let hasWarnedContext = false;
57
58const clockTime = (at: number) => {
59  const d = new Date(at);
60  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
61};
62
63async function setMood($: EngineInterface, next: Mood): Promise<void> {
64  moodSince = await $.clock.now();
65  lastActivity = moodSince;
66  await update($, mood, () => next);
67}
68
69async function post($: EngineInterface, icon: string, text: string, id = `f${++seq}`): Promise<string> {
70  const item: FeedItem = { id, at: await $.clock.now(), icon, text, isDim: false };
71  await update($, feed, (list) => [...list, item].slice(-60));
72  return id;
73}
74
75async function effect($: EngineInterface, key: keyof Effects, forMs: number): Promise<void> {
76  const until = (await $.clock.now()) + forMs;
77  await update($, effects, (fx) => ({ ...fx, [key]: until }));
78}
79
80// What the person configured: the name and species a brand-new pet starts with, and extra danger patterns.
81let config: { name: string; species: Species; danger: RegExp[] } = { name: "Mochi", species: "cat", danger: [] };
82
83// The pet, from this session's state or else the store (it outlives sessions).
84async function loadPet($: EngineInterface): Promise<Pet> {
85  const held = await read($, petAtom);
86  if (held) return held;
87  const pet = revive(await $.store.get("pet"), await $.clock.now(), config.name, config.species);
88  await update($, petAtom, () => pet);
89  return pet;
90}
91
92// The pet for drawing: the same lookup without writing, as a render hook must not.
93async function peekPet($: EngineInterface): Promise<Pet> {
94  return (await read($, petAtom)) ?? revive(await $.store.get("pet"), await $.clock.now(), config.name, config.species);
95}
96
97// Changes the pet, saves it and announces hatching, growing up and new forms.
98async function changePet($: EngineInterface, change: (pet: Pet, now: number) => Pet): Promise<Pet> {
99  const now = await $.clock.now();
100  const before = await loadPet($);
101  let after = change(before, now);
102
103  if (shouldHatch(after)) after = { ...after, isHatched: true };
104  if (stageOf(after) === "adult" && after.adult === null) after = { ...after, adult: adultOf(after.care) };
105
106  await update($, petAtom, () => after);
107  await $.store.set("pet", after);
108
109  const [was, is] = [stageOf(before), stageOf(after)];
110  if (was !== is) {
111    const text =
112      is === "baby"
113        ? `🐣 ${after.name} hatched into a baby ${after.species}!`
114        : is === "adult"
115          ? `✨ ${after.name} grew up into a ${after.adult} ${after.species}!`
116          : `✨ ${after.name} evolved into a ${is} ${after.species}!`;
117    $.ui.toast(text);
118    await post($, "✨", text.replace(/^\S+ /, ""));
119    await setMood($, "happy");
120  } else if (levelOf(after.xp) > levelOf(before.xp) && is !== "egg") {
121    await post($, "⭐", `${after.name} reached Lv ${levelOf(after.xp)}`);
122  }
123  return after;
124}
125
126async function addXp($: EngineInterface, xp: number): Promise<void> {
127  await changePet($, (p) => ({ ...p, xp: p.xp + xp }));
128}
129
130async function patPet($: EngineInterface): Promise<void> {
131  await effect($, "pattedUntil", 3000);
132  await changePet($, (p) => ({ ...p, xp: p.xp + 2, care: { ...p.care, pats: p.care.pats + 1 } }));
133  await post($, "💕", "you patted me");
134}
135
136async function feedPet($: EngineInterface): Promise<string> {
137  const pet = await loadPet($);
138  const now = await $.clock.now();
139  const wait = pet.lastFedAt + FEED_COOLDOWN_MS - now;
140  if (wait > 0) {
141    const text = `${pet.name} is not hungry yet (${Math.ceil(wait / 1000)}s)`;
142    $.ui.toast(text);
143    return text;
144  }
145  await effect($, "eatUntil", 2000);
146  await changePet($, (p, t) => ({
147    ...withFood(p, t, 25),
148    lastFedAt: t,
149    care: { ...p.care, feeds: p.care.feeds + 1 },
150  }));
151  await post($, "🍙", `you fed ${pet.name}`);
152  return `${pet.name} munches happily`;
153}
154
155async function playWith($: EngineInterface): Promise<void> {
156  await effect($, "playUntil", 3000);
157  await changePet($, (p) => ({ ...p, xp: p.xp + 1, care: { ...p.care, plays: p.care.plays + 1 } }));
158  await post($, "🎾", "we played fetch!");
159}
160
161async function setSpecies($: EngineInterface, name: string): Promise<string> {
162  if (!isSpecies(name)) return `Pick one of: ${SPECIES.join(", ")}`;
163  const pet = await changePet($, (p) => ({ ...p, species: name }));
164  await post($, "🪄", `${pet.name} is now a ${name}`);
165  return `${pet.name} is now a ${name}.`;
166}
167
168async function pollVitals($: EngineInterface): Promise<void> {
169  const usage = await $.session.usage();
170  const context = usage.context.percent;
171  const rate = usage.rateLimits.reduce((m, r) => Math.max(m, r.percentUsed), 0);
172  await update($, vitals, () => ({ context, rate }));
173  if ((context ?? 0) > 90 && !hasWarnedContext) {
174    hasWarnedContext = true;
175    $.ui.toast("😵 context almost full!");
176    await post($, "😵", "context almost full!");
177  }
178  if ((context ?? 0) < 80) hasWarnedContext = false;
179}
180
181async function tick($: EngineInterface): Promise<void> {
182  const now = await $.clock.now();
183  const current = await read($, mood);
184  const isCheer = current === "happy" || current === "sad";
185  if ((isCheer && now - moodSince > CHEER_FOR_MS) || (!isCheer && current !== "sleepy" && now - lastActivity > NAP_AFTER_MS)) {
186    await update($, mood, () => "sleepy");
187  }
188  await update($, frame, (n) => (n + 1) % 1000);
189}
190
191async function onCommand($: EngineInterface, args: string): Promise<string> {
192  const [verb = "", ...rest] = args.trim().split(/\s+/);
193  const value = rest.join(" ").trim();
194  if (verb === "name" && value) {
195    await changePet($, (p) => ({ ...p, name: value.slice(0, 20) }));
196    return `Your pet is now called ${value.slice(0, 20)}.`;
197  }
198  if (verb === "species") {
199    return value ? setSpecies($, value.toLowerCase()) : `Species: ${SPECIES.join(", ")}. Use /feed species <name>.`;
200  }
201  if (verb === "feed") return feedPet($);
202  await $.ui.open({ id: PANE, title: "Pet" });
203  return "ฅ^•ﻌ•^ฅ pet opened";
204}
205
206export const register: Register = (on, options) => {
207  const isAutoOpen = options.autoOpen !== false;
208  const species = String(options.species ?? "cat").toLowerCase();
209  config = {
210    name: String(options.petName ?? "").trim().slice(0, 20) || "Mochi",
211    species: isSpecies(species) ? species : "cat",
212    danger: parsePatterns(String(options.dangerPatterns ?? "")),
213  };
214
215  on("session.start", async ($, e, next) => {
216    await $.command.register({
217      name: "feed",
218      description: "Pet pane: /feed · /feed name <name> · /feed species [name] · /feed feed",
219    });
220    await $.command.register({ name: "pat", description: "Pat your pet ♥" });
221    const pet = await loadPet($);
222    await post($, "🐾", stageOf(pet) === "egg" ? "an egg! keep working to hatch it" : `${pet.name} is happy to see you`);
223
224    $.clock.every(TICK_MS, () => void tick($).catch(() => {}));
225    $.clock.every(VITALS_MS, () => void pollVitals($).catch(() => {}));
226    void pollVitals($).catch(() => {});
227    if (isAutoOpen) void $.ui.open({ id: PANE, title: "Pet" }).catch(() => {});
228
229    return next(e);
230  });
231
232  on("command.run", { command: "feed" }, async ($, e) => ({ text: await onCommand($, e.args) }));
233
234  on("command.run", { command: "pat" }, async ($) => {
235    await patPet($);
236    return { text: "purr~ ♥" };
237  });
238
239  on("prompt.submit", async ($, e, next) => {
240    if (!e.text.trimStart().startsWith("/")) {
241      await setMood($, "thinking");
242      await post($, "💬", `you: ${e.text.replace(/\s+/g, " ").slice(0, 44)}`);
243    }
244    return next(e);
245  }).catch(($, e, next) => next(e));
246
247  on("tool.call", async ($, e, next) => {
248    const input = e as unknown as Record<string, unknown>;
249    const now = await $.clock.now();
250    const id = await post($, iconFor(e.tool), `${e.agentId === undefined ? "" : "🐣 "}${describe(e.tool, input)}`);
251    await setMood($, "working");
252    await update($, stats, (s) => ({ ...s, tools: s.tools + 1 }));
253
254    const command = typeof input.command === "string" ? input.command : "";
255    if (e.tool === "Bash" && isDangerous(command, config.danger)) {
256      await effect($, "panicUntil", 8000);
257      await post($, "🚨", `careful!! ${command.slice(0, 40)}`);
258    }
259
260    await changePet($, (p) => ({
261      ...p,
262      xp: p.xp + 1,
263      care: {
264        ...p.care,
265        acts: p.care.acts + 1,
266        night: p.care.night + (isNight(new Date(now).getHours()) ? 1 : 0),
267        reads: p.care.reads + (e.tool === "Read" ? 1 : 0),
268        searches: p.care.searches + (["Grep", "Glob", "WebSearch", "WebFetch"].includes(e.tool) ? 1 : 0),
269        bash: p.care.bash + (e.tool === "Bash" ? 1 : 0),
270        edits: p.care.edits + (["Edit", "Write", "NotebookEdit"].includes(e.tool) ? 1 : 0),
271      },
272    }));
273
274    const ran = await next(e);
275    const isOops = ran.deny !== undefined || ran.isError === true;
276    const key = bugKey(e.tool, input);
277    await update($, feed, (list) => list.map((f) => (f.id === id ? { ...f, isDim: true, icon: isOops ? "💥" : f.icon } : f)));
278
279    if (isOops) {
280      const at = await $.clock.now();
281      failTimes = [...failTimes.filter((t) => at - t < 60_000), at];
282      await update($, stats, (s) => ({ ...s, oops: s.oops + 1 }));
283      await changePet($, (p) => ({
284        ...p,
285        bugs: [...p.bugs, { key, at }].slice(-30),
286        care: { ...p.care, fails: p.care.fails + 1 },
287      }));
288      if (isSick(failTimes, at)) {
289        await effect($, "sickUntil", 30_000);
290        await post($, "🤢", "too many bugs… feeling sick");
291      } else {
292        await setMood($, "sad");
293      }
294      return ran;
295    }
296
297    const pet = await loadPet($);
298    const squashed = pet.bugs.filter((b) => b.key === key).length;
299    const lines = linesOf(e.tool, input);
300    if (squashed > 0 || lines > 0) {
301      if (lines > 0) await effect($, "eatUntil", 1500);
302      await changePet($, (p, t) => ({
303        ...(lines > 0 ? withFood(p, t, Math.min(30, Math.ceil(lines / 4))) : p),
304        bugs: p.bugs.filter((b) => b.key !== key),
305        care: { ...p.care, bugsCleaned: p.care.bugsCleaned + squashed, linesEaten: p.care.linesEaten + lines },
306      }));
307      if (squashed > 0) await post($, "🧹", `cleaned up ${squashed} bug${squashed === 1 ? "" : "s"}`);
308    }
309
310    return ran;
311  }).catch(($, e, next) => next(e));
312
313  on("agent.spawn", async ($, e, next) => {
314    const started = await next(e);
315    if (started.agentId !== undefined) {
316      await post($, "🥚", `hatched ${e.subagentType}: ${e.description.slice(0, 32)}`);
317      await update($, stats, (s) => ({ ...s, agents: s.agents + 1 }));
318      await changePet($, (p) => ({ ...p, care: { ...p.care, agents: p.care.agents + 1 } }));
319    }
320    return started;
321  }).catch(($, e, next) => next(e));
322
323  on("turn.complete", async ($, e, next) => {
324    const secs = Math.round(e.durationMs / 1000);
325    if (e.agentId !== undefined) {
326      await post($, "🐥", `a helper finished in ${secs}s`);
327      await addXp($, 25);
328      return next(e);
329    }
330    await update($, stats, (s) => ({ ...s, turns: s.turns + 1 }));
331    if (e.isAborted) {
332      await post($, "✋", "stopped");
333      await setMood($, "sad");
334    } else {
335      await post($, "🎉", `done in ${secs}s`);
336      await setMood($, "happy");
337    }
338    await changePet($, (p) => ({ ...p, xp: p.xp + (e.isAborted ? 0 : 10), turns: p.turns + 1 }));
339    return next(e);
340  });
341
342  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
343    const els = $.ui.resolve(e);
344    const { Box, Text, Button } = els;
345    const Select = "Select" in els ? els.Select : undefined;
346    const [items, m, f, s, fx, v, pet, now] = await Promise.all([
347      read($, feed),
348      read($, mood),
349      read($, frame),
350      read($, stats),
351      read($, effects),
352      read($, vitals),
353      peekPet($),
354      $.clock.now(),
355    ]);
356    const food = foodNow(pet, now);
357    const look = lookOf(m, fx, v, food, now);
358    const lines = art(pet, look, f, now);
359    const color = (look === m && adultColor(pet.adult)) || LOOK_COLOR[look];
360    const isEgg = stageOf(pet) === "egg";
361    const c = pet.care;
362    const nightShare = c.acts === 0 ? 0 : Math.round((c.night / c.acts) * 100);
363    const room = Math.max(3, (e.viewport?.rows ?? 30) - lines.length - 11);
364
365    return (
366      <Box flexDirection="column">
367        <Box flexDirection="column">
368          {lines.map((line) => (
369            <Text color={color}>{line}</Text>
370          ))}
371        </Box>
372        <Text bold>
373          {pet.name} <Text dimColor>· {stageLabel(pet)}</Text>
374        </Text>
375        {!isEgg && (
376          <Text dimColor>
377            xp {meter(progressOf(pet.xp) * 100)} {pet.xp}
378            {stageOf(pet) !== "adult" && stageOf(pet) !== "baby" ? ` · growing into ${adultOf(c)}` : ""}
379          </Text>
380        )}
381        <Text>
382          🍙 <Text color={food < 15 ? "red" : "green"}>{meter(food, 8)}</Text> {Math.round(food)}
383          {"  "}🧠 <Text color={(v.context ?? 0) > 70 ? "yellow" : "cyan"}>{meter(v.context ?? 0, 8)}</Text>{" "}
384          {Math.round(v.context ?? 0)}%{(v.rate ?? 0) > 0 ? `  ⏱ ${Math.round(v.rate ?? 0)}%` : ""}
385          {pet.bugs.length > 0 ? `  🐛×${pet.bugs.length}` : ""}
386        </Text>
387        <Text dimColor>
388          ✏️ {c.linesEaten} lines · 📖 {c.reads + c.searches} · 💻 {c.bash} · 🐣 {c.agents} · 💥 {c.fails} · 🧹{" "}
389          {c.bugsCleaned} · 🌙 {nightShare}%
390        </Text>
391        <Box flexDirection="row" gap={1} marginBottom={1}>
392          <Button key="pat" label="Pat ♥" onPress={() => void patPet($)} />
393          <Button key="feed" label="Feed 🍙" onPress={() => void feedPet($)} />
394          <Button key="play" label="Play 🎾" onPress={() => void playWith($)} />
395          {Select && (
396            <Select
397              key="species"
398              label="Species"
399              value={pet.species}
400              options={SPECIES.map((sp) => ({ value: sp, label: sp }))}
401              onSelect={(value) => void setSpecies($, value)}
402            />
403          )}
404        </Box>
405        <Text dimColor>
406          🔧 {s.tools} · 🐣 {s.agents} · 🎉 {s.turns} · 💥 {s.oops}
407        </Text>
408        {items.slice(-room).map((item, i, shown) => (
409          <Text dimColor={item.isDim && i < shown.length - 1}>
410            <Text dimColor>{clockTime(item.at)} </Text>
411            {item.icon} {item.text}
412          </Text>
413        ))}
414      </Box>
415    );
416  });
417};
418
hooks/cat.ts 35 lines
1// How a tool call reads in the feed: an emoji and a few words.
2
3const ICONS: Record<string, string> = {
4  Read: "📖",
5  Edit: "✏️ ",
6  Write: "📝",
7  Bash: "💻",
8  Grep: "🔍",
9  Glob: "🗂️ ",
10  Agent: "🐣",
11  WebFetch: "🌐",
12  WebSearch: "🔭",
13  Skill: "🎓",
14  TodoWrite: "📋",
15};
16
17export const iconFor = (tool: string) => ICONS[tool] ?? (tool.startsWith("mcp__") ? "🔌" : "🔧");
18
19const short = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
20
21// What a tool call is about, in a few words: the file, the command or the pattern.
22export const describe = (tool: string, input: Record<string, unknown>) => {
23  const pick = (k: string) => (typeof input[k] === "string" ? (input[k] as string) : undefined);
24  const path = pick("file_path") ?? pick("path");
25  const subject =
26    pick("command") ??
27    (path ? path.split("/").slice(-2).join("/") : undefined) ??
28    pick("pattern") ??
29    pick("description") ??
30    pick("url") ??
31    pick("skill") ??
32    "";
33  return short(`${tool} ${subject}`.trim(), 48);
34};
35
hooks/pet.ts 395 lines
1import type {
2  Adult,
3  Care,
4  Effects,
5  Mood,
6  Pet,
7  Species,
8  Stage,
9  Vitals,
10} from "../types";
11
12// Everything about the pet that needs no `$`: growth, care, looks and art.
13
14export const HATCH_TURNS = 3;
15export const FOOD_DECAY_MS = 6 * 60_000; // one point of food every 6 minutes
16export const FEED_COOLDOWN_MS = 2 * 60_000;
17export const SICK_WINDOW_MS = 60_000;
18export const SICK_FAILS = 3;
19
20export const SPECIES: Species[] = [
21  "cat",
22  "chick",
23  "dog",
24  "slime",
25  "bunny",
26  "hamster",
27  "penguin",
28  "frog",
29];
30
31export const isSpecies = (s: string): s is Species =>
32  (SPECIES as string[]).includes(s);
33
34const emptyCare = (): Care => ({
35  acts: 0,
36  night: 0,
37  edits: 0,
38  linesEaten: 0,
39  reads: 0,
40  searches: 0,
41  bash: 0,
42  agents: 0,
43  fails: 0,
44  bugsCleaned: 0,
45  feeds: 0,
46  pats: 0,
47  plays: 0,
48});
49
50export const newPet = (
51  now: number,
52  name = "Mochi",
53  species: Species = "cat",
54): Pet => ({
55  name,
56  species,
57  bornAt: now,
58  xp: 0,
59  turns: 0,
60  isHatched: false,
61  food: 80,
62  foodAt: now,
63  lastFedAt: 0,
64  care: emptyCare(),
65  bugs: [],
66  adult: null,
67});
68
69// A pet read back from the store, filled in with any field an older version did not keep.
70export const revive = (
71  raw: unknown,
72  now: number,
73  name?: string,
74  species?: Species,
75): Pet => {
76  const base = newPet(now, name, species);
77  if (raw === null || typeof raw !== "object") return base;
78  const p = raw as Partial<Pet>;
79  return {
80    ...base,
81    ...p,
82    care: { ...base.care, ...(p.care ?? {}) },
83    bugs: p.bugs ?? [],
84  };
85};
86
87// ---- growth ----
88
89export const levelOf = (xp: number) =>
90  Math.floor(Math.sqrt(Math.max(0, xp) / 20)) + 1;
91
92export const xpForLevel = (level: number) => 20 * (level - 1) ** 2;
93
94export const progressOf = (xp: number) => {
95  const level = levelOf(xp);
96  const from = xpForLevel(level);
97  return (xp - from) / (xpForLevel(level + 1) - from);
98};
99
100export const shouldHatch = (pet: Pet) =>
101  !pet.isHatched && pet.turns >= HATCH_TURNS;
102
103export const stageOf = (pet: Pet): Stage => {
104  if (!pet.isHatched) return "egg";
105  const level = levelOf(pet.xp);
106  if (level < 3) return "baby";
107  if (level < 5) return "kid";
108  if (level < 7) return "teen";
109  return "adult";
110};
111
112// The adult form its upbringing points to; checked in order, the first match wins.
113export const adultOf = (care: Care): Adult => {
114  const acts = Math.max(1, care.acts);
115  const share = (n: number) => n / acts;
116  if (care.acts >= 20 && share(care.night) > 0.5) return "Owl";
117  if (care.acts >= 20 && share(care.fails) > 0.15) return "Gremlin";
118  if (care.feeds >= 8 && share(care.fails) < 0.05) return "Golden";
119  if (care.agents >= 10 && share(care.agents) > 0.05) return "Shepherd";
120  if (share(care.edits) > 0.35) return "Chonk";
121  if (share(care.reads + care.searches) > 0.5) return "Scholar";
122  if (share(care.bash) > 0.4) return "Hacker";
123  return "Classic";
124};
125
126// ---- food ----
127
128export const foodNow = (pet: Pet, now: number) =>
129  Math.max(0, Math.min(100, pet.food - (now - pet.foodAt) / FOOD_DECAY_MS));
130
131export const withFood = (pet: Pet, now: number, add: number): Pet => ({
132  ...pet,
133  food: Math.min(100, foodNow(pet, now) + add),
134  foodAt: now,
135});
136
137// Lines of code a tool call wrote: what the pet eats.
138export const linesOf = (tool: string, input: Record<string, unknown>) => {
139  const text =
140    tool === "Edit"
141      ? input.new_string
142      : tool === "Write"
143        ? input.content
144        : tool === "NotebookEdit"
145          ? input.new_source
146          : undefined;
147  return typeof text === "string" && text.length > 0
148    ? text.split("\n").length
149    : 0;
150};
151
152// ---- trouble ----
153
154export const isSick = (failTimes: readonly number[], now: number) =>
155  failTimes.filter((t) => now - t < SICK_WINDOW_MS).length >= SICK_FAILS;
156
157const DANGER = [
158  /\brm\s+(-\w*r\w*f|-\w*f\w*r|-r\s+-f|-f\s+-r)\b/,
159  /\bgit\s+push\b.*(--force\b|-f\b|--force-with-lease)/,
160  /\bgit\s+reset\s+--hard\b/,
161  /\bgit\s+clean\s+-\w*f/,
162  /\bgit\s+branch\s+-D\b/,
163  /\bdrop\s+(table|database|schema)\b/i,
164  /\btruncate\s+table\b/i,
165  /\bsupabase\s+db\s+(reset|push)\b/,
166  /\bvercel\b.*--prod\b/,
167  /\b(mkfs|dd\s+if=)/,
168  /:\(\)\s*\{\s*:\|:&\s*\};:/,
169  /\bkubectl\s+delete\b/,
170  /\bterraform\s+(destroy|apply\b.*-auto-approve)/,
171  /\bdocker\s+system\s+prune\b.*(-a\b|--all\b)/,
172  /\bheroku\s+\S*destroy\b/,
173  /\bnpm\s+unpublish\b/,
174  /\bchmod\s+-R\s+777\s+\/(\s|$)/,
175  />\s*\/dev\/sd[a-z]/,
176];
177
178// Extra patterns from the `dangerPatterns` option: `;`-separated regexes, case-insensitive; a bad one is skipped.
179export const parsePatterns = (text: string): RegExp[] =>
180  text
181    .split(";")
182    .map((part) => part.trim())
183    .filter(Boolean)
184    .flatMap((part) => {
185      try {
186        return [new RegExp(part, "i")];
187      } catch {
188        return [];
189      }
190    });
191
192export const isDangerous = (command: string, extra: readonly RegExp[] = []) =>
193  DANGER.some((re) => re.test(command)) || extra.some((re) => re.test(command));
194
195// Which "bug" a failed call leaves: a later passing call with the same key cleans it.
196export const bugKey = (tool: string, input: Record<string, unknown>) => {
197  if (tool === "Bash" && typeof input.command === "string") {
198    return `Bash:${input.command.trim().split(/\s+/).slice(0, 2).join(" ")}`;
199  }
200  const path = typeof input.file_path === "string" ? input.file_path : "";
201  return `${tool}:${path}`;
202};
203
204export const isNight = (hour: number) => hour >= 22 || hour < 5;
205
206// ---- looks ----
207
208export type Look =
209  | Mood
210  | "eating"
211  | "sick"
212  | "panic"
213  | "stuffed"
214  | "anxious"
215  | "patted"
216  | "playing"
217  | "hungry";
218
219export const lookOf = (
220  mood: Mood,
221  fx: Effects,
222  vitals: Vitals,
223  food: number,
224  now: number,
225): Look => {
226  if (now < fx.panicUntil) return "panic";
227  if (now < fx.sickUntil) return "sick";
228  if (now < fx.pattedUntil) return "patted";
229  if (now < fx.playUntil) return "playing";
230  if (now < fx.eatUntil) return "eating";
231  if ((vitals.context ?? 0) > 90) return "panic";
232  if ((vitals.context ?? 0) > 70) return "stuffed";
233  if ((vitals.rate ?? 0) > 80) return "anxious";
234  if (food < 15 && (mood === "sleepy" || mood === "happy")) return "hungry";
235  return mood;
236};
237
238// Eyes, mouth and speech bubble, two frames per look.
239const FACE: Record<Look, [[string, string, string], [string, string, string]]> =
240  {
241    sleepy: [
242      ["-", ".", "z"],
243      ["-", ".", "zZ"],
244    ],
245    thinking: [
246      ["o", ".", "hmm?"],
247      ["o", "-", "hmm…"],
248    ],
249    working: [
250      ["O", ".", "⚡ on it"],
251      ["O", "_", "⚡ on it!"],
252    ],
253    happy: [
254      ["^", ".", "♥ yay!"],
255      ["^", "ω", "♥ done!"],
256    ],
257    sad: [
258      ["T", ".", "oops…"],
259      [";", ".", "oh no"],
260    ],
261    eating: [
262      ["^", "o", "nom"],
263      ["^", "~", "nom nom"],
264    ],
265    sick: [
266      ["x", "~", "🤢 ugh…"],
267      ["@", "~", "🤢 bugs…"],
268    ],
269    panic: [
270      ["◉", "□", "!!"],
271      ["⊙", "□", "!!!"],
272    ],
273    stuffed: [
274      ["@", "з", "😵 so full"],
275      ["@", "ε", "😵 stuffed"],
276    ],
277    anxious: [
278      ["°", "︿", "limits…"],
279      ["º", "︿", "eep"],
280    ],
281    patted: [
282      ["^", "///", "purr~"],
283      ["^", "///", "purr~ ♥"],
284    ],
285    playing: [
286      ["^", "▽", "🎾 wheee"],
287      [">", "▽", "🎾 again!"],
288    ],
289    hungry: [
290      ["・", "﹏", "hungry…"],
291      ["・", "o", "🍙?"],
292    ],
293  };
294
295export const LOOK_COLOR: Record<Look, string> = {
296  sleepy: "gray",
297  thinking: "cyan",
298  working: "yellow",
299  happy: "magenta",
300  sad: "blue",
301  eating: "green",
302  sick: "green",
303  panic: "red",
304  stuffed: "yellow",
305  anxious: "yellow",
306  patted: "magenta",
307  playing: "cyan",
308  hungry: "gray",
309};
310
311type Body = { top: string; left: string; right: string; paws: string };
312
313const BODIES: Record<Species, Body> = {
314  cat: { top: " /\\_/\\", left: "( ", right: " )", paws: " > ^ <" },
315  chick: { top: "  ,_,", left: " (", right: ")>", paws: "  ^^" },
316  dog: { top: " ∪‾‾∪", left: "U(", right: ")U", paws: "  ⊔⊔" },
317  slime: { top: "  .--.", left: " (", right: ") ", paws: " ~~~~~" },
318  bunny: { top: " (\\(\\", left: " (", right: ") ", paws: ' o(")(")' },
319  hamster: { top: " ()_()", left: "(•", right: "•)", paws: ' (")(")' },
320  penguin: { top: "  .-.", left: " (", right: ") ", paws: " /(_)\\" },
321  frog: { top: " @..@", left: "(", right: ")", paws: " (>  <)" },
322};
323
324const ADULT_LOOK: Record<
325  Adult,
326  { hat: string[]; tail: string; color?: string }
327> = {
328  Golden: { hat: ["   ♛"], tail: " ✨", color: "yellow" },
329  Chonk: { hat: [], tail: " ▓▓", color: "magenta" },
330  Scholar: { hat: ["  _🎓_"], tail: " 📚" },
331  Gremlin: { hat: [" ^v^v^"], tail: " 🐛", color: "green" },
332  Shepherd: { hat: [], tail: " ʃ🐣🐣" },
333  Owl: { hat: ["  ☾ ."], tail: " 🦉", color: "blue" },
334  Hacker: { hat: [], tail: " >_", color: "green" },
335  Classic: { hat: [], tail: " ♡" },
336};
337
338export const adultColor = (adult: Adult | null) =>
339  adult ? ADULT_LOOK[adult].color : undefined;
340
341// The pet as text lines for one frame.
342export const art = (
343  pet: Pet,
344  look: Look,
345  frame: number,
346  now: number,
347): string[] => {
348  const f = frame % 2;
349  const stage = stageOf(pet);
350
351  if (stage === "egg") {
352    const isCracking = pet.turns >= HATCH_TURNS - 1;
353    const wobble = f === 0 ? "" : " ";
354    return [
355      `${wobble}  .--.`,
356      `${wobble} /${isCracking ? " ⚡ " : "    "}\\`,
357      `${wobble} \\____/`,
358    ];
359  }
360
361  const [eyes, mouth, bubble] = FACE[look][f] as [string, string, string];
362  const body = BODIES[pet.species];
363  const extra = stage === "teen" ? " ʚɞ" : "";
364  const adult = stage === "adult" && pet.adult ? ADULT_LOOK[pet.adult] : null;
365  const small = stage === "baby" ? "·" : "";
366
367  const lines = [
368    ...(adult?.hat ?? []),
369    `${body.top}${extra}`,
370    `${body.left}${eyes}${mouth}${eyes}${body.right}${small}  ${bubble}`,
371    `${look === "eating" ? " 🍙" : ""}${body.paws}${adult?.tail ?? ""}`,
372  ];
373
374  // Playing: the pet bounces side to side between frames.
375  if (look === "playing") return lines.map((l) => (f === 0 ? l : `  ${l}`));
376  // Panic: it shakes.
377  if (look === "panic" && Math.floor(now / 150) % 2 === 0)
378    return lines.map((l) => ` ${l}`);
379  return lines;
380};
381
382export const stageLabel = (pet: Pet) => {
383  const stage = stageOf(pet);
384  if (stage === "egg")
385    return `egg · hatches in ${Math.max(0, HATCH_TURNS - pet.turns)} turn(s)`;
386  if (stage === "adult" && pet.adult)
387    return `Lv ${levelOf(pet.xp)} ${pet.adult} ${pet.species}`;
388  return `Lv ${levelOf(pet.xp)} ${stage} ${pet.species}`;
389};
390
391export const meter = (value: number, width = 10) => {
392  const filled = Math.round((Math.max(0, Math.min(100, value)) / 100) * width);
393  return "█".repeat(filled) + "░".repeat(width - filled);
394};
395
types/index.d.ts 97 lines
1export type Mood = "sleepy" | "thinking" | "working" | "happy" | "sad";
2
3export type Species =
4  | "cat"
5  | "chick"
6  | "dog"
7  | "slime"
8  | "bunny"
9  | "hamster"
10  | "penguin"
11  | "frog";
12
13export type Adult =
14  | "Golden"
15  | "Chonk"
16  | "Scholar"
17  | "Gremlin"
18  | "Shepherd"
19  | "Owl"
20  | "Hacker"
21  | "Classic";
22
23export type Stage = "egg" | "baby" | "kid" | "teen" | "adult";
24
25// How the pet was raised, across sessions: what decides its adult form.
26export type Care = {
27  acts: number;
28  night: number;
29  edits: number;
30  linesEaten: number;
31  reads: number;
32  searches: number;
33  bash: number;
34  agents: number;
35  fails: number;
36  bugsCleaned: number;
37  feeds: number;
38  pats: number;
39  plays: number;
40};
41
42export type Bug = { key: string; at: number };
43
44export type Pet = {
45  name: string;
46  species: Species;
47  bornAt: number;
48  xp: number;
49  turns: number;
50  isHatched: boolean;
51  food: number;
52  foodAt: number;
53  lastFedAt: number;
54  care: Care;
55  bugs: Bug[];
56  adult: Adult | null;
57};
58
59export type Vitals = { context?: number; rate?: number };
60
61export type Effects = {
62  pattedUntil: number;
63  playUntil: number;
64  eatUntil: number;
65  sickUntil: number;
66  panicUntil: number;
67};
68
69export type FeedItem = {
70  id: string;
71  at: number;
72  icon: string;
73  text: string;
74  isDim: boolean;
75};
76
77export type Stats = {
78  tools: number;
79  agents: number;
80  turns: number;
81  oops: number;
82};
83
84declare module "claude-code" {
85  interface PluginState {
86    "cute-feed": {
87      feed: FeedItem[];
88      mood: Mood;
89      frame: number;
90      stats: Stats;
91      pet: Pet | null;
92      vitals: Vitals;
93      effects: Effects;
94    };
95  }
96}
97