SLOPSHOPPER

agent-crew

Shows your parallel Claude Code agents as costumed pixel-art Clawds: a band above the prompt and a crew panel

newpanebandguardcommandtoast
★ 4v0.7.0MITupdated 2026-10-16LudovicLaloux/agent-crew
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-crew
│ ┃ Running agents ✕ › fix the failing auth test and add an audit log call │ ┃ No agents yet. They will show up here as │ ┃ soon as they start. ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ › /agents │ ⎿ agent-crew: Agents panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Running agents
No agents yet. They will show up here as soon as they start.
README

agent-crew

Your Claude Code agents work in parallel. Now you can see them.

<img src="assets/crew.svg" alt="Six pixel-art Clawds, each dressed for its job: pirate, detective, chef, racer, astronaut and engineer" width="100%">

When Claude Code spawns several subagents, they work silently: you can't tell how many are running, what they're doing, or which one is lagging behind. agent-crew makes them visible, with a little pixel-art Clawd per agent, dressed for its job.

What you get

A band above the prompt, only while agents are running: the headcount ("3 agents in parallel · 1 done ›"), then one chip per agent with its Clawd, its name, elapsed time and current tool, all on a single line (+N when they no longer fit). Clicking the headcount opens the crew panel.

A crew panel, which opens by itself when the first agent starts and closes by itself once they are all done (/agents to reopen it):

  • one Clawd per agent, walking and playing with its prop while it works;
  • it closes its eyes once its task is done, and gets crossed-out eyes if it fails;
  • next to it: its description, type, elapsed time, number of tool calls and current tool.

A notification when the whole crew is done.

The costumes

Type or description mentionsCostumeWhile working
Explore, search, grep🏴‍☠️ Piratefences with its cutlass
reviewer, checker, audit, security🔍 Detectivesweeps the code with its magnifier
Plan, architect🍳 Chefflips the omelette
prototyper, web-fetch🏁 Racerruns twice as fast, flag fluttering
docs, research, writer🚀 Astronautfloats in zero gravity, antenna blinking
engineer, developer, devops🔧 Engineertightens a bolt

The agent's type is checked first, then its description: a general-purpose agent described as "Review the PR" becomes a detective. When neither names a job, the agent gets the next costume in turn, so every agent is dressed up.

Each agent keeps its own colour, carried by its costume. Animations are pure CSS and stop when your system asks for reduced motion.

Installation

In a Claude Code session:

/plugin install agent-crew --marketplace LudovicLaloux/agent-crew

Or from your shell, on any recent version:

claude plugin marketplace add LudovicLaloux/agent-crew
claude plugin install agent-crew@agent-crew

Then open a new session and launch a few agents in parallel, for example: "Launch 3 Explore agents in parallel to map this repo". The Clawds show up in the desktop app (Code tab) and in the terminal (see below).

Update:

claude plugin marketplace update agent-crew
claude plugin update agent-crew@agent-crew

Uninstall: claude plugin uninstall agent-crew@agent-crew

What it can access: mods run with your permissions, outside any sandbox, so check before you install. agent-crew only watches agent and tool events, keeps its state in the session and draws its band and panel: no file access, no shell commands, no network. Run claude plugin validate . on a clone to see every event it hooks and every call it makes.

In the terminal

The terminal has no SVG, so the crew panel draws the same Clawds pixel for pixel with half-block characters:

  • each agent is a card: its Clawd, then its description, elapsed time and tool calls underneath;
  • cards sit side by side and wrap onto new rows when the width runs out;
  • a full-size Clawd is 30 columns × 14 rows; when the panel is too short for a full card, it is halved to 15 × 7 (one-pixel details such as the magnifier's ring or the racing flag get coarser);
  • only the legs move, one step per second: the CSS animations of the desktop don't carry over;
  • translucent pixels and finished agents are blended over a dark background, so they look darker than intended on a light terminal theme.

The band above the prompt has a single line, so it keeps text faces: ◖•ᴗ•◗ working, ◖ᵔᴗᵔ◗ done, ◖×_×◗ failed.

By default the panel opens above the prompt. With the fullscreen renderer ("tui": "fullscreen" in ~/.claude/settings.json, or CLAUDE_CODE_NO_FLICKER=1), it docks as a sidebar beside the conversation once the terminal is at least 110 columns wide.

The Claude Code extension for VS Code doesn't draw mods yet: nothing shows there. Set "claudeCode.useTerminal": true in VS Code to run Claude in its integrated terminal instead.

Contributing

The whole mod lives in one file, hooks/register.tsx. Costumes are drawn pixel by pixel from small character grids, easy to tweak or extend.

claude --plugin-dir .      # try it without installing
claude plugin validate .
claude plugin test .

Remember to bump version in .claude-plugin/plugin.json with every change: an installation keeps a cached copy, which claude plugin update only refreshes on a new version.

Credits

The pixel-art Clawd, its costumes and their animations are adapted from savvy-progress by johnnyvizz (MIT), itself traced from DockCrab.

License

MIT, see LICENSE.

Clawd is Anthropic's mascot. agent-crew is an unofficial community mod, not affiliated with or endorsed by Anthropic.

Source 2 files
hooks/register.tsx 517 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Costume, CrewAgent, CrewStatus } from '../types'
5
6const PANE = 'agent-crew'
7const TITLE = 'Running agents'
8const agents = atom({ plugin: 'agent-crew', key: 'agents' } as const, [])
9const tick = atom({ plugin: 'agent-crew', key: 'tick' } as const, 0)
10
11// One colour per agent, handed out in turn.
12const PALETTE = ['#E8875B', '#5BA8E8', '#7BC47F', '#C58BE8', '#E8C35B', '#E86B8F', '#5BD1C9', '#A0A0F0']
13
14const isLive = (a: CrewAgent) => a.status === 'running'
15
16const fmt = (ms: number) => {
17  const s = Math.max(0, Math.round(ms / 1000))
18  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}`
19}
20
21// Pixel Clawd and its costumes: adapted from savvy-progress (github.com/johnnyvizz/claude-kit, MIT),
22// itself traced from DockCrab. A 24×18 crab on a 30×28 grid, one pixel = one <rect>.
23const CLAY = '#D97757'
24const INK = '#1F1E1D'
25
26// `cls` puts the pixel in a group: `bd` the body and its costume, `la`/`lb` the leg pairs,
27// any other name a prop with its own motion.
28type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
29
30const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string) =>
31  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
32
33const crabBody = (f: Fill, status: CrewStatus, armFront = 0, armCls?: string) => {
34  f(7, 10, 16, 12, CLAY)
35  f(3, 14, 4, 4, CLAY)
36  f(23, 14 + armFront, 4, 4, CLAY, armCls)
37  if (status === 'running') {
38    f(9, 12, 2, 2, INK)
39    f(19, 12, 2, 2, INK)
40  } else if (status === 'failed') {
41    stamp(f, 8, 11, ['k.k', '.k.', 'k.k'], { k: INK })
42    stamp(f, 18, 11, ['k.k', '.k.', 'k.k'], { k: INK })
43  } else {
44    f(9, 13, 2, 1, INK)
45    f(19, 13, 2, 1, INK)
46  }
47  f(7, 22, 2, 4, CLAY, 'la')
48  f(17, 22, 2, 4, CLAY, 'la')
49  f(11, 22, 2, 4, CLAY, 'lb')
50  f(21, 22, 2, 4, CLAY, 'lb')
51}
52
53// CSS only: periods divide one second, so the ticker's redraw restarts them in phase.
54const CRAB_CSS = `<style>
55.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
56.run .bd{animation:bob .5s steps(1) infinite -.125s}
57.run g{transform-box:fill-box}
58@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
59.c-astronaut.run{animation:float 1s ease-in-out infinite}
60.c-astronaut.run .la,.c-astronaut.run .lb,.c-astronaut.run .bd{animation:none}
61.c-astronaut.run .ant{animation:blink 1s steps(1) infinite}
62.c-astronaut.run .star{animation:blink .5s steps(1) infinite -.25s}
63@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
64.c-detective.run .it{animation:scan 1s steps(1) infinite}
65.c-detective.run .gl{animation:blink 1s steps(1) infinite -.5s}
66@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
67.c-engineer.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
68@keyframes twist{50%{transform:rotate(-35deg)}}
69.c-chef.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
70.c-chef.run .egg{animation:flip 1s ease-in-out infinite}
71@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
72.c-racer.run .la{animation-duration:.25s}.c-racer.run .lb{animation-duration:.25s;animation-delay:-.125s}
73.c-racer.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
74@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
75.c-pirate.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
76@keyframes fence{50%{transform:rotate(25deg)}}
77@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
78</style>`
79
80// `t` is the agent's colour, carried by the costume's accent; the body keeps Claude's clay.
81const COSTUMES: Record<Costume, (f: Fill, s: CrewStatus, t: string) => void> = {
82  astronaut: (f, s, t) => {
83    crabBody(f, s)
84    f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
85    f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
86    f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
87    f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
88  },
89  detective: (f, s, t) => {
90    crabBody(f, s, -4, 'it')
91    stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
92    f(6, 9, 18, 1, t)
93    stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
94    f(24, 2, 3, 3, 'rgba(169,214,245,.7)', 'it'); f(25, 6, 1, 4, '#7A4A26', 'it'); f(24, 2, 1, 1, '#fff', 'gl')
95  },
96  engineer: (f, s, t) => {
97    crabBody(f, s)
98    stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
99    f(13, 5, 4, 2, t)
100    stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
101  },
102  chef: (f, s, t) => {
103    crabBody(f, s, -4, 'pan')
104    stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
105    f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
106  },
107  racer: (f, s, t) => {
108    crabBody(f, s, -4)
109    stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
110    f(25, 1, 1, 9, '#8E929A')
111    stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
112  },
113  pirate: (f, s) => {
114    crabBody(f, s)
115    stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
116    f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
117    f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
118  },
119  plain: (f, s) => crabBody(f, s),
120}
121
122// The costume follows the agent's job; order matters (a "Security Engineer" investigates before tinkering).
123const costumeOf = (text: string): Costume | null => {
124  if (/explore|search|grep/i.test(text)) return 'pirate'
125  if (/review|checker|audit|security/i.test(text)) return 'detective'
126  if (/plan|architect/i.test(text)) return 'chef'
127  if (/prototyp|fetch/i.test(text)) return 'racer'
128  if (/docs|research|guide|writer/i.test(text)) return 'astronaut'
129  if (/engineer|developer|devops|builder|minimal/i.test(text)) return 'engineer'
130  return null
131}
132
133// Handed out in turn to agents whose type and description name no job.
134const ROTATION: Costume[] = ['pirate', 'detective', 'chef', 'racer', 'astronaut', 'engineer']
135
136// Rows saved before costumes were stored have none.
137const drawCostume = (costume: Costume | undefined) => COSTUMES[costume ?? 'plain'] ?? COSTUMES.plain
138
139const crabMarkup = (costume: Costume, accent: string, status: CrewStatus) => {
140  const groups = new Map<string, string[]>([['bd', []]])
141  const f: Fill = (x, y, w, h, c, cls = 'bd') => {
142    if (!groups.has(cls)) groups.set(cls, [])
143    groups.get(cls)?.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${c}"/>`)
144  }
145  drawCostume(costume)(f, status, accent)
146  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
147  // Props sit inside `bd` to ride its bob; the legs step on their own.
148  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
149  const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
150  const run = status === 'running' ? ' run' : ''
151  const opacity = status === 'running' ? 1 : 0.55
152  return `<g opacity="${opacity}" shape-rendering="crispEdges"><g class="c-${costume ?? 'plain'}${run}">${body}${group('la')}${group('lb')}</g></g>`
153}
154
155const avatarSvg = (costume: Costume, accent: string, status: CrewStatus) =>
156  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 28" width="33" height="31">${CRAB_CSS}${crabMarkup(costume, accent, status)}</svg>`
157
158// ── Desktop band: a single image, since the desktop wraps sibling elements onto new lines ──
159const BAND_H = 28
160const BAND_CSS = `<style>
161.bt{font:600 12px system-ui,-apple-system,"Segoe UI",sans-serif;fill:#1f1e1d}
162.bs{font:11px system-ui,-apple-system,"Segoe UI",sans-serif;fill:#6b6b68}
163@media (prefers-color-scheme: dark){.bt{fill:#ececec}.bs{fill:#a8a8a4}}
164</style>`
165
166const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
167// An image cannot measure its text: estimate generously so the ellipsis lands before the edge.
168const textW = (s: string, px: number) => s.length * px * 0.56
169const clip = (s: string, max: number) => (s.length <= max ? s : `${s.slice(0, Math.max(1, max - 1))}…`)
170
171const bandSvg = (live: CrewAgent[], now: number, W: number) => {
172  const CRAB = 24
173  const MORE = 36
174  let x = 0
175  let chips = ''
176  for (const [i, a] of live.entries()) {
177    const color = PALETTE[a.color] ?? CLAY
178    const meta = `${fmt(now - a.startedAt)}${a.lastTool ? ` · ${a.lastTool}` : ''}`
179    const fixed = CRAB + 6 + 6 + textW(meta, 11) + 12
180    const reserve = i < live.length - 1 ? MORE : 0
181    const chars = Math.min(28, Math.floor((W - x - reserve - fixed) / (12 * 0.56)))
182    if (chars < 6) {
183      chips += `<text x="${x + 4}" y="18" class="bs">+${live.length - i}</text>`
184      break
185    }
186    const label = clip(a.description, chars)
187    const w = fixed + textW(label, 12)
188    chips += `<g transform="translate(${x},0)">
189<rect x=".5" y="1.5" width="${w - 1}" height="${BAND_H - 3}" rx="12.5" fill="${color}" fill-opacity=".14" stroke="${color}" stroke-opacity=".5"/>
190<g transform="translate(5,3) scale(.8)">${crabMarkup(a.costume, color, a.status)}</g>
191<text x="${CRAB + 8}" y="18"><tspan class="bt">${esc(label)}</tspan><tspan class="bs" dx="6">${esc(meta)}</tspan></text></g>`
192    x += w + 6
193  }
194  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${BAND_H}" viewBox="0 0 ${W} ${BAND_H}">${CRAB_CSS}${BAND_CSS}${chips}</svg>`
195}
196
197// ── Terminal avatar: the same pixel grid in a Raster, two pixels per cell (▀ = top in fg, bottom in bg) ──
198const GRID_W = 30
199const GRID_H = 28
200const TERM_DEFAULT = 0x01000000
201// shortcut: the terminal's background is unknown, so translucency blends over a dark one; read it if the API ever exposes it.
202const BACKDROP = 0x1f1e1d
203
204const parseColor = (c: string): [rgb: number, alpha: number] => {
205  const m = c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/)
206  if (m) return [(Number(m[1]) << 16) | (Number(m[2]) << 8) | Number(m[3]), m[4] === undefined ? 1 : Number(m[4])]
207  const hex = c.slice(1)
208  return [parseInt(hex.length === 3 ? [...hex].map(d => d + d).join('') : hex, 16), 1]
209}
210
211const mix = (over: number, under: number, a: number) =>
212  [16, 8, 0].reduce((acc, s) => acc | (Math.round(((over >> s) & 255) * a + ((under >> s) & 255) * (1 - a)) << s), 0)
213
214// Header and its margin, a full-size crab, two lines of text and the card's margin.
215const FULL_CARD_ROWS = 2 + GRID_H / 2 + 2 + 1
216
217// Each 2×2 block keeps its most frequent colour; on a tie, a painted pixel beats the background.
218const halve = (px: number[]) => {
219  const out: number[] = []
220  for (let y = 0; y < GRID_H; y += 2)
221    for (let x = 0; x < GRID_W; x += 2) {
222      const block = [px[y * GRID_W + x], px[y * GRID_W + x + 1], px[(y + 1) * GRID_W + x], px[(y + 1) * GRID_W + x + 1]].map(p => p ?? TERM_DEFAULT)
223      const count = (c: number) => block.filter(p => p === c).length
224      out.push(block.reduce((best, c) => (count(c) > count(best) || (count(c) === count(best) && best === TERM_DEFAULT) ? c : best)))
225    }
226  return out
227}
228
229const crabCells = (costume: Costume, accent: string, status: CrewStatus, frame: number, scale: 1 | 2) => {
230  const px = new Array<number>(GRID_W * GRID_H).fill(TERM_DEFAULT)
231  // The CSS gait, one tick per step: each leg pair lifts in turn, by a whole cell once halved.
232  const lifted = status === 'running' ? (frame % 2 ? 'lb' : 'la') : null
233  const f: Fill = (x, y, w, h, c, cls) => {
234    const [rgb, a] = parseColor(c)
235    const top = cls === lifted ? y - scale : y
236    for (let j = Math.max(0, top); j < Math.min(GRID_H, top + h); j++)
237      for (let i = Math.max(0, x); i < Math.min(GRID_W, x + w); i++) {
238        const under = px[j * GRID_W + i] ?? TERM_DEFAULT
239        px[j * GRID_W + i] = a >= 1 ? rgb : mix(rgb, under === TERM_DEFAULT ? BACKDROP : under, a)
240      }
241  }
242  drawCostume(costume)(f, status, accent)
243  const faded = status === 'running' ? px : px.map(p => (p === TERM_DEFAULT ? p : mix(p, BACKDROP, 0.55)))
244  const shown = scale === 2 ? halve(faded) : faded
245  const W = GRID_W / scale
246  const rows = GRID_H / scale / 2
247  const words = new Uint32Array(W * rows * 3)
248  for (let r = 0; r < rows; r++)
249    for (let i = 0; i < W; i++) {
250      const top = shown[2 * r * W + i] ?? TERM_DEFAULT
251      const bottom = shown[(2 * r + 1) * W + i] ?? TERM_DEFAULT
252      const cell = top === TERM_DEFAULT ? [bottom === TERM_DEFAULT ? 0x20 : 0x2584, bottom, TERM_DEFAULT] : [0x2580, top, bottom]
253      words.set(cell, (r * W + i) * 3)
254    }
255  return new Uint8Array(words.buffer).toBase64()
256}
257
258// One-line text version for the terminal band.
259const avatarText = (status: CrewStatus, frame: number) => {
260  if (status === 'done') return '◖ᵔᴗᵔ◗'
261  if (status === 'failed') return '◖×_×◗'
262  if (status === 'stopped') return '◖-_-◗'
263  return ['◖•ᴗ•◗', '◖•ᴗ•◗', '◖-ᴗ-◗', '◖•ᴗ•◗ノ'][frame % 4]
264}
265
266const statusLabel: Record<CrewStatus, string> = {
267  running: 'running',
268  done: 'done',
269  failed: 'failed',
270  stopped: 'stopped',
271}
272
273let timer: { cancel: () => void } | null = null
274
275function ensureTicker($: EngineInterface) {
276  if (timer) return
277  timer = $.clock.every(1000, () => {
278    void (async () => {
279      const list = await read($, agents)
280      if (!list.some(isLive)) {
281        timer?.cancel()
282        timer = null
283        // The panel takes room from the conversation: it leaves with the crew.
284        await $.ui.close({ id: PANE }).catch(() => {})
285        return
286      }
287      // Reconcile with the engine's list (killed, failed agents…).
288      try {
289        const known = await $.agent.list()
290        const byId = new Map(known.map(k => [k.id, k.status]))
291        const now = await $.clock.now()
292        await update($, agents, l =>
293          l.map(a => {
294            if (!isLive(a)) return a
295            const s = byId.get(a.id)
296            if (s === 'completed') return { ...a, status: 'done' as const, endedAt: now }
297            if (s === 'failed') return { ...a, status: 'failed' as const, endedAt: now }
298            if (s === 'killed') return { ...a, status: 'stopped' as const, endedAt: now }
299            return a
300          }),
301        )
302      } catch {
303        // the list is only a safety net
304      }
305      await update($, tick, n => (n + 1) % 1_000_000)
306    })()
307  })
308}
309
310export const register: Register = on => {
311  let colorCursor = 0
312  let costumeCursor = 0
313
314  on('session.start', async ($, e, next) => {
315    await $.command.register({
316      name: 'agents',
317      description: 'Open the panel of running parallel agents',
318    })
319    const list = await read($, agents)
320    if (list.some(isLive)) ensureTicker($)
321    return next(e)
322  })
323
324  on('command.run', { command: 'agents' }, async $ => {
325    await $.ui.open({ id: PANE, title: TITLE })
326    return { text: 'Agents panel opened.' }
327  })
328
329  on('agent.spawn', async ($, e, next) => {
330    const result = await next(e)
331    if (!('agentId' in result) || !result.agentId) return result
332    const id = result.agentId
333    const now = await $.clock.now()
334    const before = await read($, agents)
335    const wasIdle = !before.some(isLive)
336    const row: CrewAgent = {
337      id,
338      description: e.description || e.subagentType,
339      type: e.subagentType,
340      color: colorCursor++ % PALETTE.length,
341      costume: costumeOf(e.subagentType) ?? costumeOf(e.description) ?? ROTATION[costumeCursor++ % ROTATION.length] ?? 'plain',
342      startedAt: now,
343      endedAt: null,
344      status: 'running',
345      lastTool: null,
346      tools: 0,
347    }
348    await update($, agents, l => {
349      // keep the live agents + the last 12 finished ones
350      const rest = l.filter(a => a.id !== id)
351      const live = rest.filter(isLive)
352      const ended = rest.filter(a => !isLive(a)).slice(-12)
353      return [...ended, ...live, row]
354    })
355    ensureTicker($)
356    if (wasIdle) void $.ui.open({ id: PANE, title: TITLE })
357    return result
358  })
359
360  on('tool.call', async ($, e, next) => {
361    if (e.agentId) {
362      const id = e.agentId
363      const tool = e.tool
364      await update($, agents, l =>
365        l.map(a => (a.id === id && isLive(a) ? { ...a, lastTool: tool, tools: a.tools + 1 } : a)),
366      )
367    }
368    return next(e)
369  })
370
371  on('turn.complete', async ($, e, next) => {
372    if (e.agentId) {
373      const id = e.agentId
374      const now = await $.clock.now()
375      const status: CrewStatus = e.isAborted ? 'stopped' : e.reason === 'answer' ? 'done' : 'failed'
376      let finished: CrewAgent | undefined
377      await update($, agents, l =>
378        l.map(a => {
379          if (a.id !== id || !isLive(a)) return a
380          finished = { ...a, status, endedAt: now }
381          return finished
382        }),
383      )
384      const list = await read($, agents)
385      if (finished && list.length > 1 && !list.some(isLive)) {
386        $.ui.toast(`All agents finished (${list.filter(a => a.endedAt !== null).length})`)
387      }
388    }
389    return next(e)
390  })
391
392  // ── Band above the prompt ────────────────────────────────────────────
393  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
394    if (e.props.hasSurvey) return next(e)
395    const list = await read($, agents)
396    const live = list.filter(isLive)
397    if (live.length === 0) return next(e)
398    const frame = await read($, tick)
399    const now = await $.clock.now()
400    const ui = $.ui.resolve(e)
401    const { Box, Text, Button } = ui
402    const done = list.filter(a => !isLive(a) && a.endedAt !== null && a.endedAt >= Math.min(...live.map(l => l.startedAt))).length
403    const summary = `${live.length} agent${live.length > 1 ? 's' : ''} in parallel${done > 0 ? ` · ${done} done` : ''}`
404    // Only a Button takes clicks: it carries the summary and opens the panel (never closes it).
405    const open = <Button key="agent-crew-open" label={`${summary} ›`} plain onPress={() => void $.ui.open({ id: PANE, title: TITLE })} />
406
407    // The terminal gets an Svg that draws nothing: test the surface too.
408    if (e.surface !== 'terminal' && 'Svg' in ui) {
409      const { Svg } = ui
410      const W = Math.max(160, Math.min(1600, (e.props.bodyColumns || 100) * 8 - textW(summary, 13) - 64))
411      return (
412        <Box flexDirection="row" alignItems="center" gap={1} overflow="hidden">
413          {open}
414          <Svg
415            source={bandSvg(live, now, W)}
416            alt={live.map(a => `${a.description}, ${fmt(now - a.startedAt)}`).join(' ; ')}
417            width={W}
418            height={BAND_H}
419          />
420        </Box>
421      )
422    }
423
424    const spin = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'][frame % 10]
425    return (
426      <Box flexDirection="row" gap={1}>
427        <Text color="claude" bold>{spin}</Text>
428        {open}
429        <Box flexShrink={1}>
430          <Text wrap="truncate-end">
431            {live.map(a => (
432              <Text color={PALETTE[a.color]}>
433                {'  '}{avatarText(a.status, frame)} {a.description} <Text dimColor>{fmt(now - a.startedAt)}</Text>
434              </Text>
435            ))}
436          </Text>
437        </Box>
438      </Box>
439    )
440  })
441
442  // ── Side panel with avatars ─────────────────────────────────────────
443  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
444    const { Box, Text } = $.ui.resolve(e)
445    const list = await read($, agents)
446    const frame = await read($, tick)
447    const now = await $.clock.now()
448    const live = list.filter(isLive)
449    const ended = list.filter(a => !isLive(a)).reverse()
450    const ordered = [...live, ...ended]
451
452    if (ordered.length === 0) {
453      return (
454        <Box flexDirection="column">
455          <Text dimColor>No agents yet. They will show up here as soon as they start.</Text>
456        </Box>
457      )
458    }
459
460    const row = (a: CrewAgent) => {
461      const color = PALETTE[a.color] ?? "#E8875B"
462      const elapsed = fmt((a.endedAt ?? now) - a.startedAt)
463      const detail = isLive(a)
464        ? `${elapsed} · ${a.tools} tool${a.tools > 1 ? 's' : ''}${a.lastTool ? ` · ${a.lastTool}` : ''}`
465        : `${statusLabel[a.status]} in ${elapsed} · ${a.tools} tool${a.tools > 1 ? 's' : ''}`
466      // The terminal lays cards side by side, and halves the crab when the pane is too short for a full card.
467      if (e.surface === 'terminal') {
468        const { Raster } = $.ui.resolve(e)
469        const scale = e.props.scroll.bodyRows >= FULL_CARD_ROWS ? 1 : 2
470        return (
471          <Box flexDirection="column" width={Math.max(GRID_W / scale, 24)} marginBottom={1}>
472            <Raster key={`crab-${a.id}`} columns={GRID_W / scale} rows={GRID_H / scale / 2} cells={crabCells(a.costume, color, a.status, frame, scale)} />
473            <Text bold={isLive(a)} dimColor={!isLive(a)} wrap="truncate-end">
474              {a.description}
475            </Text>
476            <Text dimColor wrap="truncate-end">
477              {detail}
478            </Text>
479          </Box>
480        )
481      }
482      const { Svg } = $.ui.resolve(e)
483      return (
484        <Box flexDirection="row" alignItems="center" columnGap={1} marginBottom={1}>
485          <Svg
486            source={avatarSvg(a.costume, color, a.status)}
487            alt={`${a.description}: ${statusLabel[a.status]}`}
488            width={33}
489            height={31}
490            isInteractive
491          />
492          <Box flexDirection="column" flexShrink={1}>
493            <Text bold={isLive(a)} dimColor={!isLive(a)} wrap="truncate-end">
494              {a.description}
495            </Text>
496            <Text dimColor wrap="truncate-end">
497              {a.type} · {detail}
498            </Text>
499          </Box>
500        </Box>
501      )
502    }
503
504    return (
505      <Box flexDirection="column">
506        <Text color="claude" bold>
507          {live.length > 0 ? `${live.length} running` : 'All done'}
508          {ended.length > 0 ? ` · ${ended.length} done` : ''}
509        </Text>
510        <Box marginTop={1} {...(e.surface === 'terminal' ? { flexDirection: 'row', flexWrap: 'wrap', columnGap: 2 } as const : { flexDirection: 'column' } as const)}>
511          {ordered.map(row)}
512        </Box>
513      </Box>
514    )
515  })
516}
517
types/index.d.ts 23 lines
1export type CrewStatus = 'running' | 'done' | 'failed' | 'stopped'
2
3export type Costume = 'astronaut' | 'detective' | 'engineer' | 'chef' | 'racer' | 'pirate' | 'plain'
4
5export type CrewAgent = {
6  id: string
7  description: string
8  type: string
9  color: number
10  costume: Costume
11  startedAt: number
12  endedAt: number | null
13  status: CrewStatus
14  lastTool: string | null
15  tools: number
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    'agent-crew': { agents: CrewAgent[]; tick: number }
21  }
22}
23