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

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.
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):
A notification when the whole crew is done.
| Type or description mentions | Costume | While working |
|---|---|---|
Explore, search, grep | 🏴☠️ Pirate | fences with its cutlass |
| reviewer, checker, audit, security | 🔍 Detective | sweeps the code with its magnifier |
Plan, architect | 🍳 Chef | flips the omelette |
prototyper, web-fetch | 🏁 Racer | runs twice as fast, flag fluttering |
| docs, research, writer | 🚀 Astronaut | floats in zero gravity, antenna blinking |
| engineer, developer, devops | 🔧 Engineer | tightens 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.
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.
The terminal has no SVG, so the crew panel draws the same Clawds pixel for pixel with half-block characters:
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.
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.
The pixel-art Clawd, its costumes and their animations are adapted from savvy-progress by johnnyvizz (MIT), itself traced from DockCrab.
MIT, see LICENSE.
Clawd is Anthropic's mascot. agent-crew is an unofficial community mod, not affiliated with or endorsed by Anthropic.
hooks/register.tsx 517 lines1import { 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}
517types/index.d.ts 23 lines1export 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