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

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 418 lines1import { 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};
418hooks/cat.ts 35 lines1// 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};
35hooks/pet.ts 395 lines1import 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};
395types/index.d.ts 97 lines1export 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