SLOPSHOPPER

swarm

Live map of the session's agent tree: who spawned whom and what each is doing

newpaneguardcommandtimeragents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · swarm
│ ┃ Swarm ✕ › fix the failing auth test and add an audit log call │ ┃ 0 running · 0 done · 0 total · peak 0 │ ┃ ◆ main opus-5-5 · 9 tools ⏺ Read(src/auth.ts) │ ┃ No subagents yet. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /swarm │ ⎿ swarm: Swarm map opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Swarm
0 running · 0 done · 0 total · peak 0 ◆ main opus-5-5 · 9 tools No subagents yet.
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 257 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register, Timer } from "claude-code";
3
4import type { AgentNode } from "../types";
5import { buildTree, prune } from "./tree";
6
7const PANE = "swarm";
8const SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
9
10const nodes = atom({ plugin: "swarm", key: "nodes" } as const, []);
11const main = atom({ plugin: "swarm", key: "main" } as const, {
12  model: "",
13  tools: 0,
14});
15const frame = atom({ plugin: "swarm", key: "frame" } as const, 0);
16const peak = atom({ plugin: "swarm", key: "peak" } as const, 0);
17
18const ICONS: Record<string, string> = {
19  Read: "📖",
20  Edit: "✏️",
21  Write: "📝",
22  Bash: "💻",
23  Grep: "🔍",
24  Glob: "🗂️",
25  Agent: "🐣",
26  WebFetch: "🌐",
27  WebSearch: "🔭",
28  Skill: "🎓",
29};
30
31let ticker: Timer | null = null;
32let isOpened = false;
33
34const short = (s: string, n: number) =>
35  s.length > n ? `${s.slice(0, n - 1)}…` : s;
36
37const shortModel = (model: string) => model.replace(/^claude-/, "");
38
39const ago = (ms: number) => {
40  const s = Math.max(0, Math.round(ms / 1000));
41  return s < 60
42    ? `${s}s`
43    : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
44};
45
46// What a call is doing, in a few words: icon, tool and its file, command or pattern.
47const activity = (tool: string, input: Record<string, unknown>) => {
48  const pick = (k: string) =>
49    typeof input[k] === "string" ? (input[k] as string) : undefined;
50  const path = pick("file_path") ?? pick("path");
51  const subject =
52    pick("command") ??
53    (path ? path.split("/").slice(-2).join("/") : undefined) ??
54    pick("pattern") ??
55    pick("description") ??
56    pick("url") ??
57    "";
58  const icon = ICONS[tool] ?? (tool.startsWith("mcp__") ? "🔌" : "🔧");
59  return short(`${icon} ${tool} ${subject}`.trim(), 40);
60};
61
62// Runs the spinner's timer while any agent runs, and stops it when none does.
63async function syncTicker($: EngineInterface): Promise<void> {
64  const running = (await read($, nodes)).filter(
65    (n) => n.status === "running",
66  ).length;
67  if (running > 0 && ticker === null) {
68    ticker = $.clock.every(
69      400,
70      () => void update($, frame, (n) => (n + 1) % SPIN.length),
71    );
72  } else if (running === 0 && ticker !== null) {
73    ticker.cancel();
74    ticker = null;
75  }
76}
77
78async function setCurrent(
79  $: EngineInterface,
80  agentId: string | undefined,
81  current: string | undefined,
82  toolDelta: number,
83): Promise<void> {
84  if (agentId === undefined) {
85    await update($, main, (m) => ({
86      ...m,
87      current,
88      tools: m.tools + toolDelta,
89    }));
90    return;
91  }
92  await update($, nodes, (list) =>
93    list.map((n) =>
94      n.id === agentId ? { ...n, current, tools: n.tools + toolDelta } : n,
95    ),
96  );
97}
98
99async function openPane($: EngineInterface): Promise<void> {
100  isOpened = true;
101  await $.ui.open({ id: PANE, title: "Swarm" });
102}
103
104export const register: Register = (on, options) => {
105  const isAutoOpen = options.autoOpen !== false;
106  on("session.start", async ($, e, next) => {
107    await $.command.register({
108      name: "swarm",
109      description: "Open the live map of this session’s agents",
110    });
111    const model = await $.session.model();
112    await update($, main, (m) => ({ ...m, model }));
113    await syncTicker($);
114    return next(e);
115  });
116
117  on("command.run", { command: "swarm" }, async ($) => {
118    await openPane($);
119    return { text: "Swarm map opened." };
120  });
121
122  on("agent.spawn", async ($, e, next) => {
123    const started = await next(e);
124    if (started.deny !== undefined || started.agentId === undefined)
125      return started;
126
127    const node: AgentNode = {
128      id: started.agentId,
129      parentId: e.parentAgentId,
130      type: e.subagentType,
131      label: e.name ?? e.description,
132      model: started.model,
133      startedAt: await $.clock.now(),
134      tools: 0,
135      status: "running",
136      runId: e.workflow?.runId,
137      agentIndex: e.workflow?.agentIndex,
138      teammate: started.teammateId,
139    };
140    const list = await update($, nodes, (all) => [...all, node].slice(-200));
141    const running = list.filter((n) => n.status === "running").length;
142    await update($, peak, (p) => Math.max(p, running));
143    await syncTicker($);
144    if (isAutoOpen && !isOpened) void openPane($).catch(() => undefined);
145
146    return started;
147  }).catch(($, e, next) => next(e));
148
149  on("tool.call", async ($, e, next) => {
150    await setCurrent(
151      $,
152      e.agentId,
153      activity(e.tool, e as unknown as Record<string, unknown>),
154      1,
155    );
156    const ran = await next(e);
157    await setCurrent($, e.agentId, undefined, 0);
158    return ran;
159  }).catch(($, e, next) => next(e));
160
161  on("turn.complete", async ($, e, next) => {
162    const id = e.agentId;
163    if (id !== undefined) {
164      const status = e.isAborted ? ("aborted" as const) : ("done" as const);
165      await update($, nodes, (list) =>
166        list.map((n) =>
167          n.id === id
168            ? { ...n, status, durationMs: e.durationMs, current: undefined }
169            : n,
170        ),
171      );
172      await syncTicker($);
173    } else {
174      await update($, main, (m) => ({ ...m, current: undefined }));
175    }
176    return next(e);
177  });
178
179  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
180    const { Box, Text } = $.ui.resolve(e);
181    const [all, m, f, p, now] = await Promise.all([
182      read($, nodes),
183      read($, main),
184      read($, frame),
185      read($, peak),
186      $.clock.now(),
187    ]);
188    const { kept, hidden } = prune(all);
189    const rows = buildTree(kept, hidden);
190    const running = all.filter((n) => n.status === "running").length;
191    const done = all.length - running;
192    const spin = SPIN[f % SPIN.length];
193
194    return (
195      <Box flexDirection="column">
196        <Text>
197          <Text color="yellow">{running} running</Text> ·{" "}
198          <Text color="green">{done} done</Text> · {all.length} total
199          <Text dimColor> · peak {p}</Text>
200        </Text>
201        <Text>
202          <Text bold color="cyan">
203            ◆ main
204          </Text>{" "}
205          <Text dimColor>
206            {shortModel(m.model)} · {m.tools} tools
207          </Text>
208          {m.current ? <Text> · {m.current}</Text> : null}
209        </Text>
210        {rows.map((row) => {
211          if (row.kind === "more")
212            return (
213              <Text dimColor>
214                {row.prefix}+{row.count} earlier
215              </Text>
216            );
217          if (row.kind === "group") {
218            return (
219              <Text>
220                <Text dimColor>{row.prefix}</Text>
221                <Text color="magenta">⚙ workflow {row.runId.slice(0, 12)}</Text>
222                <Text dimColor> · {row.count} agents</Text>
223              </Text>
224            );
225          }
226          const n = row.node;
227          const isRunning = n.status === "running";
228          const glyph = isRunning ? spin : n.status === "done" ? "✓" : "✗";
229          const color = isRunning
230            ? "yellow"
231            : n.status === "done"
232              ? "green"
233              : "red";
234          const elapsed = isRunning ? now - n.startedAt : (n.durationMs ?? 0);
235          return (
236            <Text dimColor={!isRunning}>
237              <Text dimColor>{row.prefix}</Text>
238              <Text color={color}>{glyph} </Text>
239              {n.runId !== undefined && n.agentIndex !== undefined
240                ? `#${n.agentIndex} `
241                : ""}
242              <Text bold={isRunning}>{n.type}</Text> "{short(n.label, 32)}"
243              {n.teammate ? <Text color="cyan"> @{n.teammate}</Text> : null}
244              <Text dimColor>
245                {" "}
246                {shortModel(n.model)} · {ago(elapsed)} · {n.tools} tools
247              </Text>
248              {isRunning && n.current ? <Text> · {n.current}</Text> : null}
249            </Text>
250          );
251        })}
252        {rows.length === 0 && <Text dimColor>No subagents yet.</Text>}
253      </Box>
254    );
255  });
256};
257
hooks/tree.ts 116 lines
1import type { AgentNode } from "../types";
2
3export type Row =
4  | { kind: "agent"; prefix: string; node: AgentNode }
5  | { kind: "group"; prefix: string; runId: string; count: number }
6  | { kind: "more"; prefix: string; count: number };
7
8export const KEEP_FINISHED = 20;
9
10type Item =
11  | { kind: "agent"; node: AgentNode }
12  | { kind: "group"; runId: string; nodes: AgentNode[] };
13
14const hasRunning = (
15  node: AgentNode,
16  byParent: Map<string, AgentNode[]>,
17): boolean =>
18  node.status === "running" ||
19  (byParent.get(node.id) ?? []).some((c) => hasRunning(c, byParent));
20
21// Drops the oldest finished agents past `keep`, never one with a running descendant.
22export const prune = (nodes: readonly AgentNode[], keep = KEEP_FINISHED) => {
23  const byParent = childrenOf(nodes);
24  const finished = nodes.filter((n) => !hasRunning(n, byParent));
25  const dropped = new Set(
26    finished.slice(0, Math.max(0, finished.length - keep)).map((n) => n.id),
27  );
28  const kept = nodes
29    .filter((n) => !dropped.has(n.id))
30    // A kept agent whose parent was dropped hangs off main.
31    .map((n) =>
32      n.parentId && dropped.has(n.parentId) ? { ...n, parentId: undefined } : n,
33    );
34  return { kept, hidden: dropped.size };
35};
36
37const childrenOf = (nodes: readonly AgentNode[]) => {
38  const ids = new Set(nodes.map((n) => n.id));
39  const byParent = new Map<string, AgentNode[]>();
40  for (const n of nodes) {
41    const parent = n.parentId && ids.has(n.parentId) ? n.parentId : "main";
42    byParent.set(parent, [...(byParent.get(parent) ?? []), n]);
43  }
44  return byParent;
45};
46
47// Workflow agents of one run sit together under a group, in agentIndex order.
48const itemsOf = (children: readonly AgentNode[]): Item[] => {
49  const items: Item[] = [];
50  const groups = new Map<string, AgentNode[]>();
51  for (const n of children) {
52    if (n.runId === undefined) {
53      items.push({ kind: "agent", node: n });
54      continue;
55    }
56    const group = groups.get(n.runId);
57    if (group) group.push(n);
58    else {
59      const fresh = [n];
60      groups.set(n.runId, fresh);
61      items.push({ kind: "group", runId: n.runId, nodes: fresh });
62    }
63  }
64  for (const g of groups.values())
65    g.sort((a, b) => (a.agentIndex ?? 0) - (b.agentIndex ?? 0));
66  return items;
67};
68
69// The rows under main, depth first, each with its tree connectors.
70export const buildTree = (nodes: readonly AgentNode[], hidden = 0): Row[] => {
71  const byParent = childrenOf(nodes);
72  const rows: Row[] = [];
73
74  const walk = (parentId: string, indent: string, extra: Row | null) => {
75    const items = itemsOf(byParent.get(parentId) ?? []);
76    const total = items.length + (extra ? 1 : 0);
77    if (extra)
78      rows.push({
79        ...extra,
80        prefix: `${indent}${total === 1 ? "└─ " : "├─ "}`,
81      });
82    items.forEach((item, i) => {
83      const isLast = i + (extra ? 1 : 0) === total - 1;
84      const prefix = `${indent}${isLast ? "└─ " : "├─ "}`;
85      const childIndent = `${indent}${isLast ? "   " : "│  "}`;
86      if (item.kind === "agent") {
87        rows.push({ kind: "agent", prefix, node: item.node });
88        walk(item.node.id, childIndent, null);
89      } else {
90        rows.push({
91          kind: "group",
92          prefix,
93          runId: item.runId,
94          count: item.nodes.length,
95        });
96        item.nodes.forEach((n, j) => {
97          const isLastInGroup = j === item.nodes.length - 1;
98          rows.push({
99            kind: "agent",
100            prefix: `${childIndent}${isLastInGroup ? "└─ " : "├─ "}`,
101            node: n,
102          });
103          walk(n.id, `${childIndent}${isLastInGroup ? "   " : "│  "}`, null);
104        });
105      }
106    });
107  };
108
109  walk(
110    "main",
111    "",
112    hidden > 0 ? { kind: "more", prefix: "", count: hidden } : null,
113  );
114  return rows;
115};
116
types/index.d.ts 31 lines
1export type AgentStatus = "running" | "done" | "aborted";
2
3export type AgentNode = {
4  id: string;
5  parentId?: string;
6  type: string;
7  label: string;
8  model: string;
9  startedAt: number;
10  durationMs?: number;
11  tools: number;
12  status: AgentStatus;
13  current?: string;
14  runId?: string;
15  agentIndex?: number;
16  teammate?: string;
17};
18
19export type MainNode = { model: string; current?: string; tools: number };
20
21declare module "claude-code" {
22  interface PluginState {
23    swarm: {
24      nodes: AgentNode[];
25      main: MainNode;
26      frame: number;
27      peak: number;
28    };
29  }
30}
31