Live map of the repo that lights up as Claude and its subagents read, edit and fail on files

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 249 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register, Timer } from "claude-code";
3
4import type { TouchKind } from "../types";
5import {
6 MAIN,
7 MAIN_COLOR,
8 PALETTE,
9 buildRows,
10 glyph,
11 hottest,
12 isAnyWarm,
13 relativize,
14 totals,
15 touch,
16} from "./radar";
17
18const PANE = "repo-radar";
19const TICK_MS = 2000;
20
21const READS = new Set(["Read", "Grep", "Glob"]);
22const WRITES = new Set(["Edit", "Write", "NotebookEdit", "MultiEdit"]);
23const KIND_COLOR: Record<TouchKind, string> = {
24 read: "blue",
25 write: "green",
26 fail: "red",
27};
28
29const rootAtom = atom({ plugin: "repo-radar", key: "root" } as const, "");
30const filesAtom = atom({ plugin: "repo-radar", key: "files" } as const, []);
31const touchesAtom = atom({ plugin: "repo-radar", key: "touches" } as const, {});
32const agentsAtom = atom({ plugin: "repo-radar", key: "agents" } as const, {});
33const nowAtom = atom({ plugin: "repo-radar", key: "now" } as const, 0);
34const isRepoAtom = atom({ plugin: "repo-radar", key: "isRepo" } as const, true);
35
36let ticker: Timer | null = null;
37
38async function loadRepo($: EngineInterface): Promise<number> {
39 let root = "";
40 try {
41 root = (await $.session.repo())?.root ?? (await $.session.cwd());
42 } catch {
43 root = "";
44 }
45 let files: string[] = [];
46 let isRepo = false;
47 try {
48 const ran = await $.process.run(["git", "ls-files"], {
49 cwd: root || undefined,
50 });
51 isRepo = ran.exitCode === 0;
52 if (isRepo) files = ran.stdout.split("\n").filter(Boolean);
53 } catch {
54 files = [];
55 }
56 await update($, rootAtom, () => root);
57 await update($, filesAtom, () => files);
58 await update($, isRepoAtom, () => isRepo);
59 return files.length;
60}
61
62async function tick($: EngineInterface): Promise<void> {
63 const now = await $.clock.now();
64 await update($, nowAtom, () => now);
65 if (!isAnyWarm(await read($, touchesAtom), now) && ticker) {
66 ticker.cancel();
67 ticker = null;
68 }
69}
70
71function ensureTicker($: EngineInterface): void {
72 if (ticker) return;
73 ticker = $.clock.every(TICK_MS, () => void tick($));
74}
75
76async function tagAgent(
77 $: EngineInterface,
78 agentId: string,
79 type: string,
80): Promise<void> {
81 await update($, agentsAtom, (agents) => {
82 if (agents[agentId]) return agents;
83 const n =
84 Object.values(agents).filter((a) => a.name.startsWith(`${type}#`))
85 .length + 1;
86 const color =
87 PALETTE[Object.keys(agents).length % PALETTE.length] ?? "white";
88 return { ...agents, [agentId]: { name: `${type}#${n}`, color } };
89 });
90}
91
92async function record(
93 $: EngineInterface,
94 paths: string[],
95 kind: TouchKind,
96 agent: string,
97): Promise<void> {
98 if (paths.length === 0) return;
99 const now = await $.clock.now();
100 await update($, touchesAtom, (touches) => {
101 const next = { ...touches };
102 for (const path of paths) next[path] = touch(next[path], kind, now, agent);
103 return next;
104 });
105 await update($, nowAtom, () => now);
106 ensureTicker($);
107}
108
109async function pathsOf(
110 $: EngineInterface,
111 input: Record<string, unknown>,
112): Promise<string[]> {
113 const root = await read($, rootAtom);
114 return ["file_path", "notebook_path", "path"]
115 .map((k) => input[k])
116 .filter((v): v is string => typeof v === "string" && v !== "")
117 .map((p) => relativize(root, p))
118 .filter((p): p is string => p !== null);
119}
120
121export const register: Register = (on, options) => {
122 const isAutoOpen = options.autoOpen === true;
123 const depth = Math.max(1, Math.floor(Number(options.depth) || 2));
124 on("session.start", async ($, e, next) => {
125 await $.command.register({
126 name: "radar",
127 description:
128 "Open the repo radar pane (/radar refresh re-reads the file list)",
129 });
130 void loadRepo($).catch(() => 0);
131 if (isAutoOpen) void $.ui.open({ id: PANE, title: "Radar" }).catch(() => {});
132 return next(e);
133 });
134
135 on("command.run", { command: "radar" }, async ($, e) => {
136 if (e.args.trim() === "refresh") {
137 const count = await loadRepo($);
138 return { text: `Radar re-read ${count} files.` };
139 }
140 await $.ui.open({ id: PANE, title: "Radar" });
141 return { text: "Radar opened." };
142 });
143
144 on("agent.spawn", async ($, e, next) => {
145 const started = await next(e);
146 if (started.agentId !== undefined)
147 await tagAgent($, started.agentId, e.subagentType);
148 return started;
149 }).catch(($, e, next) => next(e));
150
151 on("tool.call", async ($, e, next) => {
152 const kind: TouchKind | null = READS.has(e.tool)
153 ? "read"
154 : WRITES.has(e.tool)
155 ? "write"
156 : null;
157 if (kind === null) return next(e);
158
159 const agent = e.agentId ?? MAIN;
160 if (e.agentId !== undefined) await tagAgent($, e.agentId, "agent");
161 const paths = await pathsOf($, e as unknown as Record<string, unknown>);
162 await record($, paths, kind, agent);
163
164 const ran = await next(e);
165 if (ran.deny !== undefined || ran.isError === true)
166 await record($, paths, "fail", agent);
167 return ran;
168 }).catch(($, e, next) => next(e));
169
170 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
171 const { Box, Text } = $.ui.resolve(e);
172 const [root, files, touches, agents, storedNow, isRepo] = await Promise.all([
173 read($, rootAtom),
174 read($, filesAtom),
175 read($, touchesAtom),
176 read($, agentsAtom),
177 read($, nowAtom),
178 read($, isRepoAtom),
179 ]);
180 const now = storedNow || (await $.clock.now());
181 const rows = buildRows(files, touches, depth);
182 const room = Math.max(4, (e.viewport?.rows ?? 30) - 9);
183 const shown = rows.slice(0, room);
184 const sum = totals(touches);
185 const top = hottest(touches);
186 const colorOf = (agent: string) =>
187 agent === MAIN ? MAIN_COLOR : (agents[agent]?.color ?? "white");
188
189 return (
190 <Box flexDirection="column">
191 <Text bold>
192 📡 {root.split("/").pop() || "repo"}{" "}
193 <Text dimColor>· {files.length} files</Text>
194 </Text>
195 {!isRepo && (
196 <Text color="yellow">
197 not a git repo — /radar refresh after git init
198 </Text>
199 )}
200 {isRepo && shown.length === 0 && (
201 <Text dimColor>No files yet. Try /radar refresh.</Text>
202 )}
203 {shown.map((row) => {
204 const heat = row.heat;
205 const isWarm = heat !== null && now - heat.lastAt < 60_000;
206 return (
207 <Text>
208 {" ".repeat(row.depth)}
209 <Text color={heat ? colorOf(heat.agent) : undefined}>
210 {glyph(heat?.lastAt, now)}
211 </Text>{" "}
212 <Text
213 color={isWarm && heat ? KIND_COLOR[heat.lastKind] : undefined}
214 dimColor={heat === null}
215 >
216 {row.name}
217 </Text>
218 {row.isDir && <Text dimColor> ({row.fileCount})</Text>}
219 {heat && heat.reads > 0 && <Text color="blue"> r{heat.reads}</Text>}
220 {heat && heat.writes > 0 && <Text color="green"> w{heat.writes}</Text>}
221 {heat && heat.fails > 0 && <Text color="red"> !{heat.fails}</Text>}
222 </Text>
223 );
224 })}
225 {rows.length > shown.length && (
226 <Text dimColor>… {rows.length - shown.length} more</Text>
227 )}
228 <Text dimColor>
229 read {sum.read} · written {sum.written} · failed {sum.failed}
230 </Text>
231 {top.length > 0 && (
232 <Text dimColor>
233 🔥 {top.map((t) => t.path.split("/").pop()).join(" · ")}
234 </Text>
235 )}
236 <Text>
237 <Text color={MAIN_COLOR}>■</Text> <Text dimColor>main</Text>
238 {Object.values(agents).map((a) => (
239 <Text>
240 {" "}
241 <Text color={a.color}>■</Text> <Text dimColor>{a.name}</Text>
242 </Text>
243 ))}
244 </Text>
245 </Box>
246 );
247 });
248};
249hooks/radar.ts 200 lines1import type { Touch, TouchKind } from "../types";
2
3export const HOT_MS = 10_000;
4export const WARM_MS = 60_000;
5
6export const MAIN = "main";
7export const MAIN_COLOR = "cyan";
8export const PALETTE = ["magenta", "yellow", "green", "blue", "red", "white"];
9
10export type Heat = {
11 reads: number;
12 writes: number;
13 fails: number;
14 lastAt: number;
15 lastKind: TouchKind;
16 agent: string;
17};
18
19export type Row = {
20 path: string;
21 name: string;
22 depth: number;
23 isDir: boolean;
24 fileCount: number;
25 heat: Heat | null;
26};
27
28type Node = {
29 name: string;
30 path: string;
31 isDir: boolean;
32 children: Map<string, Node>;
33 fileCount: number;
34 heat: Heat | null;
35};
36
37// The glyph a node shows for how recently it was touched.
38export const glyph = (lastAt: number | undefined, now: number) => {
39 if (lastAt === undefined) return " ";
40 const age = now - lastAt;
41 if (age < HOT_MS) return "◉";
42 if (age < WARM_MS) return "●";
43 return "○";
44};
45
46export const isAnyWarm = (touches: Record<string, Touch>, now: number) =>
47 Object.values(touches).some((t) => now - t.lastAt < WARM_MS);
48
49// Records one touch on a path, counting it by kind.
50export const touch = (
51 prev: Touch | undefined,
52 kind: TouchKind,
53 at: number,
54 agent: string,
55): Touch => ({
56 reads: (prev?.reads ?? 0) + (kind === "read" ? 1 : 0),
57 writes: (prev?.writes ?? 0) + (kind === "write" ? 1 : 0),
58 fails: (prev?.fails ?? 0) + (kind === "fail" ? 1 : 0),
59 lastAt: at,
60 lastKind: kind,
61 agent,
62});
63
64// A path relative to the repo root, or null when it lies outside it.
65export const relativize = (root: string, path: string) => {
66 const clean = path.replace(/\/+$/, "");
67 if (root === "") return clean.replace(/^\/+/, "") || null;
68 if (clean === root) return null;
69 if (clean.startsWith(`${root}/`)) return clean.slice(root.length + 1);
70 return clean.startsWith("/") ? null : clean.replace(/^\.\//, "") || null;
71};
72
73const merge = (a: Heat | null, b: Heat | null): Heat | null => {
74 if (a === null) return b;
75 if (b === null) return a;
76 const latest = a.lastAt >= b.lastAt ? a : b;
77 return {
78 reads: a.reads + b.reads,
79 writes: a.writes + b.writes,
80 fails: a.fails + b.fails,
81 lastAt: latest.lastAt,
82 lastKind: latest.lastKind,
83 agent: latest.agent,
84 };
85};
86
87const newNode = (name: string, path: string, isDir: boolean): Node => ({
88 name,
89 path,
90 isDir,
91 children: new Map(),
92 fileCount: 0,
93 heat: null,
94});
95
96const insert = (root: Node, path: string, isFile: boolean) => {
97 const parts = path.split("/").filter(Boolean);
98 let node = root;
99 parts.forEach((part, i) => {
100 const isLast = i === parts.length - 1;
101 let child = node.children.get(part);
102 if (!child) {
103 child = newNode(
104 part,
105 parts.slice(0, i + 1).join("/"),
106 !(isLast && isFile),
107 );
108 node.children.set(part, child);
109 } else if (!isLast && !child.isDir) {
110 child.isDir = true;
111 }
112 node = child;
113 });
114 return node;
115};
116
117const aggregate = (node: Node, touches: Record<string, Touch>): void => {
118 let heat: Heat | null = touches[node.path] ?? null;
119 let count = node.isDir ? 0 : 1;
120 for (const child of node.children.values()) {
121 aggregate(child, touches);
122 heat = merge(heat, child.heat);
123 count += child.fileCount;
124 }
125 node.heat = heat;
126 node.fileCount = count;
127};
128
129const order = (a: Node, b: Node) => {
130 const at = a.heat?.lastAt ?? -1;
131 const bt = b.heat?.lastAt ?? -1;
132 if (at !== bt) return bt - at;
133 if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
134 return a.name.localeCompare(b.name);
135};
136
137/**
138 * The map's rows: the top `depth` levels of directories always, files only at
139 * the top or when touched, and every touched path expanded down to its file.
140 * Touched nodes sort first, most recent on top.
141 */
142export const buildRows = (
143 files: readonly string[],
144 touches: Record<string, Touch>,
145 depth = 2,
146): Row[] => {
147 const root = newNode("", "", true);
148 for (const file of files) insert(root, file, true);
149 for (const path of Object.keys(touches)) {
150 if (!files.includes(path)) insert(root, path, !hasChildrenIn(files, path));
151 }
152 aggregate(root, touches);
153
154 const rows: Row[] = [];
155 const walk = (node: Node, level: number) => {
156 for (const child of [...node.children.values()].sort(order)) {
157 const isTouched = child.heat !== null;
158 const isShown = isTouched || (child.isDir ? level < depth : level === 0);
159 if (!isShown) continue;
160 rows.push({
161 path: child.path,
162 name: child.isDir ? `${child.name}/` : child.name,
163 depth: level,
164 isDir: child.isDir,
165 fileCount: child.fileCount,
166 heat: child.heat,
167 });
168 if (child.isDir && (isTouched || level + 1 < depth))
169 walk(child, level + 1);
170 }
171 };
172 walk(root, 0);
173 return rows;
174};
175
176const hasChildrenIn = (files: readonly string[], path: string) =>
177 files.some((f) => f.startsWith(`${path}/`));
178
179export const hottest = (touches: Record<string, Touch>, n = 3) =>
180 Object.entries(touches)
181 .filter(([path]) => !path.endsWith("/"))
182 .sort(
183 ([, a], [, b]) =>
184 b.reads +
185 b.writes * 2 +
186 b.fails * 3 -
187 (a.reads + a.writes * 2 + a.fails * 3) || b.lastAt - a.lastAt,
188 )
189 .slice(0, n)
190 .map(([path, t]) => ({ path, ...t }));
191
192export const totals = (touches: Record<string, Touch>) => {
193 const all = Object.values(touches);
194 return {
195 read: all.filter((t) => t.reads > 0).length,
196 written: all.filter((t) => t.writes > 0).length,
197 failed: all.filter((t) => t.fails > 0).length,
198 };
199};
200types/index.d.ts 26 lines1export type TouchKind = "read" | "write" | "fail";
2
3export type Touch = {
4 reads: number;
5 writes: number;
6 fails: number;
7 lastAt: number;
8 lastKind: TouchKind;
9 agent: string;
10};
11
12export type AgentTag = { name: string; color: string };
13
14declare module "claude-code" {
15 interface PluginState {
16 "repo-radar": {
17 root: string;
18 isRepo: boolean;
19 files: string[];
20 touches: Record<string, Touch>;
21 agents: Record<string, AgentTag>;
22 now: number;
23 };
24 }
25}
26