SLOPSHOPPER

repo-radar

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

newpaneguardcommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · repo-radar
│ ┃ Radar ✕ › fix the failing auth test and add an audit log call │ ┃ 📡 app · 0 files │ ┃ ◉ src/ (3) r2 w3 ⏺ Read(src/auth.ts) │ ┃ ◉ cache.ts w1 ⎿ Read 6 lines │ ┃ ◉ audit.ts w1 ⏺ Update(src/auth.ts) │ ┃ ◉ auth.ts r1 w1 ⎿ Added 2 lines, removed 1 line │ ┃ read 2 · written 3 · failed 0 ⏺ Bash(bun test) │ ┃ 🔥 auth.ts · cache.ts · audit.ts ⎿ 3 pass, 1 fail │ ┃ ■ main │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /radar │ ⎿ repo-radar: Radar opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Radar
📡 app · 0 files ◉ src/ (3) r2 w3 ◉ cache.ts w1 ◉ audit.ts w1 ◉ auth.ts r1 w1 read 2 · written 3 · failed 0 🔥 auth.ts · cache.ts · audit.ts ■ main
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 249 lines
1import { 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};
249
hooks/radar.ts 200 lines
1import 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};
200
types/index.d.ts 26 lines
1export 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