Your running subagents as 16×16 pixel heroes above the prompt: their task, what they are doing, time, tokens, and a voice that tells their status

A Claude Code mod that turns every subagent you spawn into a 16×16 pixel hero in a band above the prompt.
▶ Try the live demo. It draws the same sprites and speech bubbles the mod draws in your terminal. The browser copy lives in docs/agent-party/; rebuild it with demo/build.sh ../../docs/agent-party/index.html.
▄██▄ ╭────────────────────────────────╮
█▀▀█ │ Plan the product search page │
▀██▀ │ Reading product.service.ts │
▀ ▀ ╰────────────────────────────────╯
planner the Wizard · Lv 12
⏱ 10m 7s · ↓ 231.3k tokens
Done ✓ · 4 files · 2 test runs · 1 error (files it edited or wrote, test suites it ran, tool calls that failed).↳ Explore the Ranger), and under it any it spawned in turn. It joins the main row once its parent has left the stage.💦 Quiet for 2m, stuck?. The same tool call with the same input 3 times in a row shows 🔁 … ×3. The voice says when an agent seems stuck.⚔ 2 running · ✓ 1 done · 💦 1 stuck?). It shows on every surface, also when the band is hidden.Tested with Claude Code v2.1.289. The mods API can change between releases.
| Command | What it does |
|---|---|
/agent-theme | Switches between the two casts. /agent-theme party or /agent-theme wizarding picks one. Your choice is saved across sessions. |
/agent-voice | Turns the voice on or off |
/agent-size | auto (default), large (16×16), medium (12×12), small (8×8) or line (one row per agent, no sprite). auto shrinks the heroes as the party grows or the window narrows |
/agent-outline | thin (default: ink only on the shadow side, below and right), dark (ink all round) or none |
/agent-log | Opens the Quest Log pane: every agent of this session, the running ones first, then the finished ones newest first, with time, tokens, what each brought back and who summoned it. It keeps the last 100 quests |
/agent-cast | Picks the hero for an agent type: /agent-cast my-agent=code-reviewer. /agent-cast my-agent= undoes one, /agent-cast reset clears all, /agent-cast lists the roles and your picks |
With no argument, /agent-theme, /agent-size and /agent-outline step to the next option. Every choice is saved across sessions.
| Agent type | party (default) | wizarding |
|---|---|---|
| planner | Wizard | Headmaster |
| backend-developer | Dwarf Smith | Groundskeeper |
| frontend-developer | Bard | Charms Student |
| fullstack-developer | Paladin | Broom Rider |
| tester | Alchemist | Potions Master |
| code-reviewer | Knight | Professor |
| security-auditor | Rogue | Dark Arts Defender |
| git-manager | Courier | Post Owl |
| researcher | Sage | Library Prefect |
| Explore | Ranger | Night Explorer |
| scout | Scout | Castle Ghost |
| docs-manager | Scribe | Talking Portrait |
| any other type | the role its name hints at, else Adventurer | the role its name hints at, else First-Year |
An agent type the table doesn't name plays the role its name hints at: superpowers:code-reviewer is a Knight, api-dev a Dwarf Smith, e2e-runner an Alchemist, ui-designer a Bard. /agent-cast overrides the guess.
The wizarding cast is made of original archetypes, not characters from any book or film.

Each row in the image shows one hero, with these frames from left to right:
| Pixels | One terminal cell holds two pixels: an upper half block ▀ coloured with the top pixel and backed by the bottom one. A hero is 16 columns × 8 rows, 12 × 6 at medium and 8 × 4 at small. |
| Surfaces | The terminal draws the sprite as a Raster of half blocks. The desktop app, VS Code and the mobile app draw the same pixels as an Svg. line size and the status line are plain text, so they work everywhere. |
| Smaller sprites | 12×12 drops rows and columns 0, 5, 10 and 15 of the 16×16 art, which loses no feature. 8×8 takes each 2×2 block: an eye wins its block, a block less than half covered stays empty, otherwise the commonest colour. The outline goes on last, at the drawn size. |
| Animation | 4 fps. The work loop steps every 250 ms and the idle bob every 500 ms. A blink comes every 4 s. |
| Hooks | agent.spawn puts a hero on stage. tool.call fills the speech bubble and, from its result, takes a heart on an error. turn.step counts tokens, and turn.complete ends the quest. |
| Voice | Uses Claude Code's system speech (say on macOS). If that fails, it falls back to spd-say on Linux. If neither works, it turns the voice off and shows a notice. |
| Art | Sprites are data in hooks/heroes.ts and hooks/heroes-wizarding.ts. hooks/art.ts combines a shared body with each class's hat, outfit and prop, then adds the animation and a 1px outline. |
The design follows common pixel-art practice for 16×16 characters:
claude --plugin-dir ./plugins/agent-party # edits reload as you save
claude plugin validate ./plugins/agent-party
claude plugin test ./plugins/agent-party
The browser demo is built from the same art files. Running demo/build.sh <out.html> writes a single-file HTML page with a scripted quest and the full roster. It needs Node.js, and it fetches esbuild through npx.
hooks/register.tsx 486 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderNode } from 'claude-code'
3
4import type { Agent, Outline, Size } from '../types'
5import { HEROES, heroOf, roleOf, THEMES } from './heroes'
6import type { Theme } from './heroes'
7import { describe, elapsed, isTestRun, loot, spoken, tokens } from './activity'
8import { frame, TICK_MS } from './art'
9import type { Mode } from './art'
10import { cellsOf, classOf, colorOf, spriteCells, spriteSvg } from './sprites'
11
12const agents = atom({ plugin: 'agent-party', key: 'agents' } as const, [] as Agent[])
13const isVoiceOn = atom({ plugin: 'agent-party', key: 'isVoiceOn' } as const, true)
14const theme = atom({ plugin: 'agent-party', key: 'theme' } as const, 'party' as Theme)
15const size = atom({ plugin: 'agent-party', key: 'size' } as const, 'auto' as Size)
16const outline = atom({ plugin: 'agent-party', key: 'outline' } as const, 'thin' as Outline)
17const cast = atom({ plugin: 'agent-party', key: 'cast' } as const, {} as Record<string, string>)
18const log = atom({ plugin: 'agent-party', key: 'log' } as const, [] as Agent[])
19
20const LOG_PANE = 'agent-log'
21const LOG_KEEP = 100 // ponytail: the session's last 100 quests; page the pane if anyone needs more
22
23const SIZES: Size[] = ['auto', 'large', 'medium', 'small', 'line']
24const OUTLINES: Outline[] = ['thin', 'dark', 'none']
25const PIXELS = { large: 16, medium: 12, small: 8 } as const
26
27const LINGER_MS = 20_000 // a finished agent stays on stage this long
28const CHATTER_MS = 45_000 // at most one spoken activity update per agent this often
29const STUCK_MS = 120_000 // no tool running and no model step for this long reads as stuck
30const LOOP_REPEATS = 3 // the same tool call with the same input this many times in a row reads as a loop
31const HURT_MS = 750 // the red flash after a failed tool call
32const MAX_HP = 5
33const BUBBLE = 30
34
35// WHY: hooks only queue lines; the session.start timer speaks them, so no tool call waits on a voice.
36const speech: string[] = []
37const spokenAt = new Map<string, number>()
38const warned = new Set<string>() // agents already announced as stuck
39let isSpeaking = false
40let useSpdSay = false
41let shownStatus: string | undefined
42
43const say = (text: string, isImportant = false) => {
44 if (!isImportant && speech.length >= 2) return
45 speech.push(text)
46 if (speech.length > 4) speech.shift()
47}
48
49const speakNext = async ($: EngineInterface) => {
50 const text = speech.shift()
51 if (!text || !(await read($, isVoiceOn))) return
52 isSpeaking = true
53 try {
54 if (!useSpdSay) await $.audio.speak(text)
55 else await $.process.run(['spd-say', '-w', text], { timeoutMs: 30_000 })
56 } catch (err) {
57 if (useSpdSay) {
58 await update($, isVoiceOn, () => false)
59 $.ui.toast(`agent-party: no voice available (${String(err).slice(0, 80)}), voice off`)
60 } else {
61 useSpdSay = true // ponytail: Linux fallback when the engine has no synthesizer; one retry, then voice off
62 speech.unshift(text)
63 }
64 } finally {
65 isSpeaking = false
66 }
67}
68
69const patch = ($: EngineInterface, id: string, fn: (agent: Agent) => Agent) =>
70 update($, agents, list => list.map(agent => (agent.id === id ? fn(agent) : agent)))
71
72const isStuck = (agent: Agent, now: number) =>
73 agent.status === 'running' && agent.inFlight === 0 && now - agent.seenAt > STUCK_MS
74
75// The status line: the party at a glance on every surface, also when the band is hidden or too narrow.
76const summary = (list: Agent[], now: number): string | undefined => {
77 if (!list.length) return undefined
78 const count = (status: Agent['status']) => list.filter(agent => agent.status === status).length
79 const stuck = list.filter(agent => isStuck(agent, now)).length
80 return [
81 `⚔ ${count('running')} running`,
82 count('done') && `✓ ${count('done')} done`,
83 count('failed') && `✗ ${count('failed')} stopped`,
84 stuck && `💦 ${stuck} stuck?`,
85 ].filter(Boolean).join(' · ')
86}
87
88// auto: the hero shrinks as the party grows or the window narrows, then goes to one line each.
89const sizeFor = (setting: Size, count: number, columns: number): Exclude<Size, 'auto'> => {
90 if (setting !== 'auto') return setting
91 if (columns < 45 || count > 6) return 'line'
92 if (count <= 2 && columns >= 60) return 'large'
93 return count <= 4 ? 'medium' : 'small'
94}
95
96// /agent-theme, /agent-size, /agent-outline: a name picks, no name steps to the next one.
97const pick = <T extends string>(args: string, current: T, options: readonly T[], key: string): { value?: T; text: string } => {
98 const asked = args.trim()
99 const value = asked ? (asked as T) : options[(options.indexOf(current) + 1) % options.length]!
100 return options.includes(value)
101 ? { value, text: `agent-party ${key}: ${value}` }
102 : { text: `Unknown ${key} "${asked}". Use ${options.join(' | ')}.` }
103}
104
105const isOneOf = <T extends string>(options: readonly T[], value: unknown): value is T => options.includes(value as T)
106
107const fresh = (now: number) => ({ errors: 0, seenAt: now, inFlight: 0, lastCall: '', repeats: 0, files: [] as string[], tests: 0 })
108
109const hearts = (agent: Agent) => {
110 const hp = Math.max(0, MAX_HP - agent.errors)
111 return '♥'.repeat(hp) + '♡'.repeat(MAX_HP - hp)
112}
113
114export const register: Register = on => {
115 on('session.start', async ($, e, next) => {
116 await $.command.register({ name: 'agent-voice', description: 'Turn the agent-party voice on or off' })
117 await $.command.register({ name: 'agent-theme', description: 'Switch the cast: party (fantasy RPG) or wizarding (wizarding school)' })
118 await $.command.register({ name: 'agent-size', description: 'Hero size: auto, large (16×16), medium (12×12), small (8×8) or line (one row each)' })
119 await $.command.register({ name: 'agent-outline', description: 'Hero outline: thin (shadow side), dark (all round) or none' })
120 await $.command.register({ name: 'agent-cast', description: 'Pick the hero for an agent type: /agent-cast my-agent=code-reviewer' })
121 await $.command.register({ name: 'agent-log', description: 'Open the Quest Log: every agent of this session, running and finished' })
122 // WHY: the look is a preference, so it lives in $.store and outlives the session
123 const [savedTheme, savedSize, savedOutline, savedCast] = await Promise.all(['theme', 'size', 'outline', 'cast'].map(key => $.store.get(key)))
124 if (isOneOf(THEMES, savedTheme)) await update($, theme, () => savedTheme)
125 if (isOneOf(SIZES, savedSize)) await update($, size, () => savedSize)
126 if (isOneOf(OUTLINES, savedOutline)) await update($, outline, () => savedOutline)
127 if (savedCast && typeof savedCast === 'object') await update($, cast, () => savedCast as Record<string, string>)
128 // WHY: agents kept in $.state across a reload may predate the newer fields
129 await update($, agents, list => list.map(agent => ({ ...fresh(agent.startedAt), ...agent })))
130
131 $.clock.every(TICK_MS, () => {
132 void (async () => {
133 const now = await $.clock.now()
134 const list = await read($, agents)
135 const kept = list.filter(agent => !agent.endedAt || now - agent.endedAt < LINGER_MS)
136 if (kept.length !== list.length) await update($, agents, () => kept)
137 for (const agent of kept) {
138 if (!isStuck(agent, now)) warned.delete(agent.id)
139 else if (!warned.has(agent.id)) {
140 warned.add(agent.id)
141 say(`${spoken(agent.type)} seems stuck`, true)
142 }
143 }
144 const status = summary(kept, now)
145 if (status !== shownStatus) $.ui.status((shownStatus = status))
146 if (kept.length) $.ui.invalidate('ui.render')
147 if (!isSpeaking && speech.length) await speakNext($)
148 })()
149 })
150 return next(e)
151 })
152
153 on('command.run', { command: 'agent-theme' }, async ($, e) => {
154 const { value, text } = pick(e.args, await read($, theme), THEMES, 'theme')
155 if (value) await Promise.all([update($, theme, () => value), $.store.set('theme', value)])
156 return { text }
157 })
158
159 on('command.run', { command: 'agent-size' }, async ($, e) => {
160 const { value, text } = pick(e.args, await read($, size), SIZES, 'size')
161 if (value) await Promise.all([update($, size, () => value), $.store.set('size', value)])
162 return { text }
163 })
164
165 on('command.run', { command: 'agent-outline' }, async ($, e) => {
166 const { value, text } = pick(e.args, await read($, outline), OUTLINES, 'outline')
167 if (value) await Promise.all([update($, outline, () => value), $.store.set('outline', value)])
168 return { text }
169 })
170
171 on('command.run', { command: 'agent-voice' }, async $ => {
172 const isOn = await update($, isVoiceOn, value => !value)
173 if (!isOn) speech.length = 0
174 return { text: `agent-party voice ${isOn ? 'on' : 'off'}` }
175 })
176
177 on('command.run', { command: 'agent-log' }, async $ => {
178 await $.ui.open({ id: LOG_PANE, title: 'Quest Log' })
179 return { text: 'agent-party: Quest Log opened' }
180 })
181
182 on('command.run', { command: 'agent-cast' }, async ($, e) => {
183 const asked = e.args.trim()
184 const roles = Object.keys(HEROES)
185 const save = async (map: Record<string, string>) => {
186 await update($, cast, () => map)
187 await $.store.set('cast', map)
188 }
189 if (asked === 'reset') {
190 await save({})
191 return { text: 'agent-party cast: every agent type plays the role its name hints at again' }
192 }
193 const pair = /^(\S+?)\s*=\s*(\S*)$/.exec(asked)
194 if (!pair) {
195 const mine = Object.entries(await read($, cast)).map(([type, role]) => ` ${type} = ${role}`)
196 return {
197 text: [
198 `Roles: ${roles.join(', ')}`,
199 mine.length ? `Your picks:\n${mine.join('\n')}` : 'No picks yet: an unknown type plays the role its name hints at.',
200 'Pick: /agent-cast my-agent=code-reviewer · undo: /agent-cast my-agent= · clear all: /agent-cast reset',
201 ].join('\n'),
202 }
203 }
204 const [, type, role] = pair as unknown as [string, string, string]
205 if (role && !roles.includes(role)) return { text: `Unknown role "${role}". Use one of: ${roles.join(', ')}` }
206 const map = { ...(await read($, cast)) }
207 if (role) map[type] = role
208 else delete map[type]
209 await save(map)
210 const shown = heroOf(role || type, await read($, theme)).class
211 return { text: `agent-party cast: ${type} plays the ${shown}${role ? '' : ` (guessed: ${roleOf(type)})`}` }
212 })
213
214 on('agent.spawn', async ($, e, next) => {
215 const started = await next(e)
216 if (!started.agentId) return started
217 const type = e.subagentType || 'general-purpose'
218 const now = await $.clock.now()
219 const agent: Agent = {
220 id: started.agentId,
221 type,
222 title: e.description,
223 activity: 'Getting started…',
224 startedAt: now,
225 context: 0,
226 calls: 0,
227 status: 'running',
228 ...fresh(now),
229 parentId: e.parentAgentId,
230 }
231 await update($, agents, list => [...list.filter(one => one.id !== agent.id), agent])
232 say(`${spoken(type)} started: ${e.description}`, true)
233 return started
234 })
235
236 on('tool.call', async ($, e, next) => {
237 const id = e.agentId
238 const agent = id ? (await read($, agents)).find(one => one.id === id && one.status === 'running') : undefined
239 if (!agent || !id) return next(e)
240
241 const input = e as unknown as Record<string, unknown>
242 const activity = describe(e.tool, input)
243 const call = `${e.tool} ${JSON.stringify({ ...input, agentId: undefined })}`.slice(0, 500)
244 const isWrite = e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit'
245 const file = isWrite ? String(input.file_path ?? input.notebook_path ?? '') : ''
246 const started = await $.clock.now()
247 await patch($, id, one => ({
248 ...one,
249 activity,
250 calls: one.calls + 1,
251 inFlight: one.inFlight + 1,
252 seenAt: started,
253 lastCall: call,
254 repeats: one.lastCall === call ? one.repeats + 1 : 1,
255 }))
256 if (started - (spokenAt.get(id) ?? 0) > CHATTER_MS) {
257 spokenAt.set(id, started)
258 say(`${spoken(agent.type)}: ${activity}`)
259 }
260
261 let isError = false // an abort (Esc) throws and is not the agent's fault, so it costs no heart
262 try {
263 const result = await next(e)
264 isError = 'deny' in result || result.isError === true
265 return result
266 } finally {
267 const ended = await $.clock.now()
268 await patch($, id, one => ({
269 ...one,
270 inFlight: Math.max(0, one.inFlight - 1),
271 seenAt: ended,
272 ...(isError ? { errors: one.errors + 1, hurtAt: ended } : {}),
273 ...(!isError && file && !one.files.includes(file) ? { files: [...one.files, file] } : {}),
274 ...(e.tool === 'Bash' && isTestRun(input.command) ? { tests: one.tests + 1 } : {}),
275 }))
276 }
277 })
278
279 on('turn.step', async function* ($, e, next) {
280 const step = yield* next(e)
281 const usage = step.usage
282 if (e.agentId && usage) {
283 const context = usage.input_tokens + usage.cache_read_input_tokens + (usage.cache_creation_input_tokens ?? 0)
284 const now = await $.clock.now()
285 await patch($, e.agentId, one => ({ ...one, context: context + usage.output_tokens, seenAt: now }))
286 }
287 return step
288 })
289
290 on('turn.complete', async ($, e, next) => {
291 const id = e.agentId
292 const agent = id ? (await read($, agents)).find(one => one.id === id && !one.endedAt) : undefined
293 if (agent && id) {
294 const isFailed = e.isAborted || e.reason === 'refusal'
295 const now = await $.clock.now()
296 const latest = (await read($, agents)).find(one => one.id === id) ?? agent
297 const brought = loot(latest)
298 const ended: Agent = {
299 ...latest,
300 status: isFailed ? 'failed' : 'done',
301 activity: `${isFailed ? 'Stopped ✗' : 'Done ✓'}${brought ? ` · ${brought}` : ''}`,
302 endedAt: now,
303 inFlight: 0,
304 }
305 await patch($, id, () => ended)
306 await update($, log, list => [...list, ended].slice(-LOG_KEEP))
307 say(`${spoken(agent.type)} ${isFailed ? 'stopped' : 'finished'}: ${agent.title}`, true)
308 }
309 return next(e)
310 })
311
312 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
313 const list = await read($, agents)
314 if (!list.length || e.props.hasSurvey) return next(e)
315
316 const table = $.ui.resolve(e)
317 const { Box, Text } = table
318 // Terminal: half-block Raster. Desktop, VS Code, mobile: the same pixels as an Svg.
319 // WHY e.surface: the table names every element on every surface (a missing one draws nothing),
320 // so `'Raster' in table` is true everywhere.
321 const isTerminal = e.surface === 'terminal'
322 const Raster = isTerminal && 'Raster' in table ? table.Raster : undefined
323 const Svg = !isTerminal && 'Svg' in table ? table.Svg : undefined
324 const now = await $.clock.now()
325 const tick = Math.floor(now / TICK_MS)
326 const theTheme = await read($, theme)
327 const picks = await read($, cast)
328 const look = { outline: await read($, outline) }
329
330 // An agent another agent spawned stands under it as a small familiar while its parent is on stage.
331 const onStage = new Set(list.map(agent => agent.id))
332 const leaders = list.filter(agent => !agent.parentId || !onStage.has(agent.parentId))
333 const shown = sizeFor(await read($, size), leaders.length, e.props.bodyColumns ?? 100)
334
335 const card = (agent: Agent, size: Exclude<Size, 'auto'>, isFamiliar: boolean) => {
336 const role = picks[agent.type] ?? agent.type
337 const isRunning = agent.status === 'running'
338 const tone = agent.status === 'failed' ? 'red' : agent.status === 'done' ? 'green' : colorOf(role, theTheme)
339 const heroName = (
340 <Text wrap="truncate">
341 {isFamiliar ? <Text dimColor>↳ </Text> : ''}
342 <Text bold color={colorOf(role, theTheme)}>{agent.type}</Text>
343 <Text dimColor> the {classOf(role, theTheme)} · Lv {agent.calls + 1}</Text>
344 </Text>
345 )
346 const warning =
347 isStuck(agent, now) ? `💦 Quiet for ${elapsed(now - agent.seenAt)}, stuck?`
348 : isRunning && agent.repeats >= LOOP_REPEATS ? `🔁 ${agent.activity} ×${agent.repeats}`
349 : undefined
350 const activity = (
351 <Text italic color={warning ? 'yellow' : isRunning ? undefined : tone} wrap="truncate">
352 {warning ?? agent.activity}
353 </Text>
354 )
355 const stats = (
356 <Text dimColor wrap="truncate">
357 {' '}⏱ {elapsed((agent.endedAt ?? now) - agent.startedAt)}
358 {agent.context ? ` · ↓ ${tokens(agent.context)} tokens` : ''}
359 {agent.errors ? <Text color="red"> {hearts(agent)}</Text> : ''}
360 </Text>
361 )
362
363 if (size === 'line') {
364 const mark = agent.status === 'done' ? '✓' : agent.status === 'failed' ? '✗' : '●'
365 return (
366 <Text wrap="truncate">
367 {isFamiliar ? <Text dimColor> ↳ </Text> : ''}
368 <Text color={tone}>{mark} </Text>
369 <Text bold color={colorOf(role, theTheme)}>{agent.type}</Text>
370 <Text> {agent.title} · </Text>
371 <Text italic color={warning ? 'yellow' : undefined}>{warning ?? agent.activity}</Text>
372 <Text dimColor> · {elapsed((agent.endedAt ?? now) - agent.startedAt)}</Text>
373 {agent.errors ? <Text color="red"> {hearts(agent)}</Text> : ''}
374 </Text>
375 )
376 }
377
378 const isSmall = size === 'small' // 4 rows: no room for the bubble's border
379 const mode: Mode =
380 agent.status === 'done' ? 'done'
381 : agent.status === 'failed' ? 'failed'
382 : now - (agent.hurtAt ?? 0) < HURT_MS ? 'hurt'
383 : 'work'
384 const age = Math.floor((now - agent.startedAt) / TICK_MS)
385 const sinceEnd = agent.endedAt ? Math.floor((now - agent.endedAt) / TICK_MS) : undefined
386 const grid = frame(role, mode, tick, age, sinceEnd, theTheme, { ...look, pixels: PIXELS[size] })
387 const sprite =
388 Raster ? <Raster key={`sprite-${agent.id}`} {...cellsOf(grid)} cells={spriteCells(grid)} />
389 : Svg ? <Svg source={spriteSvg(grid)} alt={`${agent.type} the ${classOf(role, theTheme)}`} width={grid.length * 4} height={grid.length * 4} />
390 : ''
391
392 return (
393 <Box flexDirection="row" gap={1}>
394 {sprite}
395 <Box flexDirection="column" width={BUBBLE + 4}>
396 {heroName}
397 {isSmall ? (
398 <Text bold wrap="truncate">{agent.title}</Text>
399 ) : (
400 <Box flexDirection="column" borderStyle="round" borderColor={tone} paddingX={1}>
401 <Text bold wrap="truncate">{agent.title}</Text>
402 {activity}
403 </Box>
404 )}
405 {isSmall ? activity : ''}
406 {stats}
407 </Box>
408 </Box>
409 )
410 }
411
412 // A leader with its familiars beneath it, and theirs beneath them.
413 const party = (agent: Agent, depth: number): RenderNode => {
414 const familiars = depth < 3 ? list.filter(one => one.parentId === agent.id) : []
415 const size = depth === 0 ? shown : shown === 'line' ? 'line' : 'small'
416 if (!familiars.length) return card(agent, size, depth > 0)
417 return (
418 <Box flexDirection="column">
419 {card(agent, size, depth > 0)}
420 <Box flexDirection="column" paddingLeft={shown === 'line' ? 2 : 4}>
421 {familiars.map(one => party(one, depth + 1))}
422 </Box>
423 </Box>
424 )
425 }
426
427 const cards = leaders.map(agent => party(agent, 0))
428 return shown === 'line' ? (
429 <Box flexDirection="column">{cards}</Box>
430 ) : (
431 <Box flexDirection="row" flexWrap="wrap" columnGap={3}>{cards}</Box>
432 )
433 })
434
435 // The Quest Log: who is out now, then every quest that came back, newest first.
436 on('ui.render', { component: 'Pane', requestId: LOG_PANE }, async ($, e) => {
437 const { Box, Text } = $.ui.resolve(e)
438 const now = await $.clock.now()
439 const theTheme = await read($, theme)
440 const picks = await read($, cast)
441 const running = (await read($, agents)).filter(agent => agent.status === 'running')
442 const finished = await read($, log)
443 const everyone = [...running, ...finished]
444 const typeOf = (id?: string) => everyone.find(agent => agent.id === id)?.type
445 const spent = everyone.reduce((sum, agent) => sum + agent.context, 0)
446 const room = Math.max(1, Math.floor(((e.viewport?.rows ?? 24) - 3) / 2))
447
448 const row = (agent: Agent) => {
449 const role = picks[agent.type] ?? agent.type
450 const mark = agent.status === 'done' ? '✓' : agent.status === 'failed' ? '✗' : '●'
451 const tone = agent.status === 'failed' ? 'red' : agent.status === 'done' ? 'green' : colorOf(role, theTheme)
452 const parent = typeOf(agent.parentId)
453 const facts = [
454 `⏱ ${elapsed((agent.endedAt ?? now) - agent.startedAt)}`,
455 agent.context && `↓ ${tokens(agent.context)}`,
456 agent.status === 'running' ? agent.activity : loot(agent),
457 parent && `summoned by ${parent}`,
458 ].filter(Boolean).join(' · ')
459 return (
460 <Box flexDirection="column">
461 <Text wrap="truncate">
462 <Text color={tone}>{mark} </Text>
463 <Text bold color={colorOf(role, theTheme)}>{agent.type}</Text>
464 <Text dimColor> the {classOf(role, theTheme)} · </Text>
465 <Text>{agent.title}</Text>
466 </Text>
467 <Text dimColor wrap="truncate">
468 {' '}{facts}
469 {agent.errors ? <Text color="red"> {hearts(agent)}</Text> : ''}
470 </Text>
471 </Box>
472 )
473 }
474
475 return (
476 <Box flexDirection="column">
477 <Text bold wrap="truncate">
478 ⚔ {running.length} out · {finished.length} back{spent ? ` · ↓ ${tokens(spent)} tokens` : ''}
479 </Text>
480 {everyone.length ? '' : <Text dimColor>No quests yet. Spawn an agent and it shows up here.</Text>}
481 {[...running, ...[...finished].reverse()].slice(0, room).map(row)}
482 </Box>
483 )
484 })
485}
486hooks/heroes.ts 175 lines1// The party: one RPG class per agent type. Letters are palette slots (shared ones in art.ts), '.' keeps
2// what is beneath. Class colours: main C/T, shade D/U, accent A, after the Endesga 32 / Sweetie 16 ramps.
3import type { Theme } from '../types'
4import { WIZARDING } from './heroes-wizarding'
5
6export type { Theme }
7
8export type Layer = { at: [row: number, col: number]; rows: string[] }
9export type Hero = {
10 class: string
11 body?: string[] // replaces the shared chibi body (an owl, a ghost…)
12 colors: Record<string, string>
13 hat: string[] // overlay from row 0; '' leaves a row alone
14 outfit?: Layer[]
15 props: Layer[][] // the held prop, one layer list per work frame
16}
17
18const at = (row: number, col: number, ...rows: string[]): Layer => ({ at: [row, col], rows })
19const staff = at(5, 13, 'P', 'P', 'P', 'P', 'P', 'P', 'P', 'P', 'Q')
20const blade = (top: number) => at(top, 13, 'M', 'M', 'M', 'M', 'N')
21
22export const HEROES: Record<string, Hero> = {
23 planner: {
24 class: 'Wizard',
25 colors: { H: '#d9d4e8', C: '#8f5fd0', D: '#5b3f9e', L: '#5b3f9e', F: '#3e2731', T: '#8f5fd0', U: '#5b3f9e', A: '#feae34', G: '#2ce8f5' },
26 hat: ['', '.........T......', '........TTA.....', '......TTTTTU....', '...UUUUUUUUUU...'],
27 props: [
28 [at(2, 12, '.G.', 'GWG', '.G.'), staff],
29 [at(1, 12, 'A.A', '.W.', 'WGW', '.W.'), staff],
30 ],
31 },
32 'backend-developer': {
33 class: 'Dwarf Smith',
34 colors: { H: '#733e39', B: '#be4a2f', C: '#e07a30', D: '#a24a2a', L: '#3e2731', F: '#181425', T: '#5a6988', U: '#3a4466', A: '#c0cbdc', G: '#feae34' },
35 hat: ['', '', '', '.....TTTTTT.....', '....TTTTTTTU....', '', '', '....BBssssBB....', '.....BBBBBB.....', '......BBBB......'],
36 outfit: [at(10, 5, 'QQQQQ'), at(11, 5, 'QQQQQ')],
37 props: [
38 [at(4, 12, 'AAA', 'NNN'), at(6, 13, 'P', 'P', 'P', 'P', 'Q')],
39 [at(8, 12, 'AAA', 'NNN'), at(10, 13, 'P', 'Q'), at(6, 14, 'G'), at(7, 11, 'G')],
40 ],
41 },
42 'frontend-developer': {
43 class: 'Bard',
44 colors: { H: '#feae34', C: '#e8639f', D: '#a83a82', L: '#3a4466', F: '#262b44', T: '#e8639f', U: '#a83a82', A: '#fee761', G: '#fee761' },
45 hat: ['', '..........A.....', '.........AA.....', '.....TTTTTA.....', '...UTTTTTTTTU...'],
46 props: [
47 [at(6, 14, 'Q'), at(7, 13, 'Q'), at(8, 13, 'Q'), at(9, 11, 'PPP', 'PWP', '.P.'), at(4, 12, 'G')],
48 [at(6, 14, 'Q'), at(7, 13, 'Q'), at(8, 13, 'Q'), at(9, 11, 'PPP', 'PWP', '.P.'), at(2, 13, 'G'), at(3, 11, 'G')],
49 ],
50 },
51 'fullstack-developer': {
52 class: 'Paladin',
53 colors: { H: '#733e39', C: '#f2c230', D: '#c4862a', L: '#3a4466', F: '#262b44', T: '#f2c230', U: '#c4862a', A: '#3b5dc9', G: '#f4f4f4' },
54 hat: ['', '........A.......', '......TTTT......', '.....TTTTTT.....', '....TTTTTTTU....', '....TssssssU....'],
55 outfit: [at(9, 1, '.AAA', '.AWA', '.AAA', '..A.')],
56 props: [
57 [blade(4), at(9, 12, 'UUU'), at(10, 13, 'Q')],
58 [at(3, 13, 'G'), blade(4), at(9, 12, 'UUU'), at(10, 13, 'Q'), at(2, 14, 'G')],
59 ],
60 },
61 tester: {
62 class: 'Alchemist',
63 colors: { H: '#f77622', C: '#1fb5e0', D: '#1a7ab8', L: '#3a4466', F: '#262b44', T: '#f77622', U: '#be4a2f', A: '#a7f070', G: '#a7f070' },
64 hat: ['', '', '.....H.H.H......', '', '....MWWMMWWM....'],
65 props: [
66 [at(8, 13, 'W'), at(9, 12, 'GGG', 'GWG', 'GGG'), at(6, 13, 'G')],
67 [at(8, 13, 'W'), at(9, 12, 'GGG', 'GGG', 'GWG'), at(4, 14, 'G'), at(6, 12, 'W')],
68 ],
69 },
70 'code-reviewer': {
71 class: 'Knight',
72 colors: { H: '#6a7a9a', C: '#aab6cc', D: '#6a7a9a', L: '#3a4466', F: '#262b44', T: '#aab6cc', U: '#6a7a9a', A: '#e43b44', G: '#f4f4f4' },
73 hat: ['', '........AA......', '......TTTT......', '.....TTTTTT.....', '....TTTTTTTU....', '....TUUUUUUU....', '....TUeUUeUU....', '....TTTTTTTU....', '.....UUUUUU.....'],
74 outfit: [at(9, 1, 'TAT', 'AAA', 'TAT', '.T.')],
75 props: [
76 [blade(4), at(9, 12, 'AAA'), at(10, 13, 'Q')],
77 [at(2, 13, 'G'), blade(3), at(8, 12, 'AAA'), at(9, 13, 'Q'), at(1, 14, 'G')],
78 ],
79 },
80 'security-auditor': {
81 class: 'Rogue',
82 colors: { H: '#7a2048', C: '#c8323c', D: '#7a2048', L: '#262b44', F: '#181425', T: '#c8323c', U: '#7a2048', A: '#262b44', G: '#f4f4f4', e: '#f4f4f4' },
83 hat: ['', '', '......TTT.......', '.....TTTTTT.....', '....TTTTTTTU....', '....TAAAAAAU....', '....TAeAAeAU....', '....TAAAAAAU....', '.....UUUUUU.....'],
84 props: [
85 [at(8, 14, 'M'), at(9, 13, 'M'), at(10, 12, 'Q')],
86 [at(6, 14, 'G'), at(7, 14, 'M'), at(8, 13, 'M'), at(9, 12, 'Q')],
87 ],
88 },
89 'git-manager': {
90 class: 'Courier',
91 colors: { H: '#b86f50', C: '#3b5dc9', D: '#29366f', L: '#29366f', F: '#3e2731', T: '#3b5dc9', U: '#29366f', A: '#f4f4f4', G: '#e43b44' },
92 hat: ['', '', '', '.....TTTTTT.....', '..UUTTTTTTTU....'],
93 outfit: [at(9, 5, 'P'), at(10, 6, 'P'), at(11, 7, 'P'), at(12, 8, 'PPP')],
94 props: [
95 [at(9, 12, 'AAA', 'AGA')],
96 [at(8, 12, 'AAA', 'AGA'), at(6, 14, 'A')],
97 ],
98 },
99 researcher: {
100 class: 'Sage',
101 colors: { H: '#c0cbdc', C: '#ead4aa', D: '#b89a78', L: '#b89a78', F: '#733e39', T: '#c0cbdc', U: '#8b9bb4', A: '#5a6988', G: '#feae34' },
102 hat: ['', '', '', '.....ssssss.....', '....HssssssH....', '', '....sMeMMeMS....', '....sHHHHHHS....', '.....HHHHHH.....', '......HHHH......', '.......HH.......'],
103 props: [
104 [at(10, 2, 'AWWA', 'AWWA')],
105 [at(9, 3, '.W'), at(10, 2, 'AWGA', 'AWWA')],
106 ],
107 },
108 Explore: {
109 class: 'Ranger',
110 colors: { H: '#b86f50', C: '#3e8948', D: '#265c42', L: '#733e39', F: '#3e2731', T: '#3e8948', U: '#265c42', A: '#b86f50', G: '#f4f4f4' },
111 hat: ['', '', '......TTTT......', '.....TTTTTT.....', '..UTTTTTTTTU....', '...UT......U....', '....T......U....', '....T......U....'],
112 props: [
113 [at(5, 13, 'A.', '.A', '.A', '.A', '.A', '.A', '.A', 'A.'), at(6, 13, 'G', 'G', 'G', 'G', 'G', 'G')],
114 [at(5, 13, 'A.', '.A', '.A', '.A', '.A', '.A', '.A', 'A.'), at(6, 12, 'G', 'G', 'G'), at(9, 10, 'WAAAA'), at(9, 12, 'G')],
115 ],
116 },
117 scout: {
118 class: 'Scout',
119 colors: { H: '#3e2731', C: '#2fb0a0', D: '#257179', L: '#733e39', F: '#3e2731', T: '#2fb0a0', U: '#257179', A: '#fee761', G: '#fee761' },
120 hat: ['', '', '', '', '....AAAAAAAAA...', '............A...', '.............A..'],
121 props: [
122 [at(6, 10, 'NMMMW'), at(7, 12, 'Q')],
123 [at(6, 10, 'NMMMG'), at(5, 14, 'G'), at(7, 12, 'Q')],
124 ],
125 },
126 'docs-manager': {
127 class: 'Scribe',
128 colors: { H: '#3e2731', C: '#9a6a45', D: '#6a4033', L: '#3a4466', F: '#262b44', T: '#9a6a45', U: '#6a4033', A: '#ead4aa', G: '#181425' },
129 hat: ['', '', '...........W....', '.....HHHHHW.....'],
130 outfit: [at(9, 1, 'AAA', 'AQA', 'AQA', 'AAA')],
131 props: [
132 [at(6, 14, 'W'), at(7, 13, 'W'), at(8, 13, 'W'), at(9, 12, 'G')],
133 [at(5, 14, 'W'), at(6, 13, 'W'), at(7, 13, 'W'), at(8, 12, 'G')],
134 ],
135 },
136 'general-purpose': {
137 class: 'Adventurer',
138 colors: { H: '#733e39', C: '#9bd13c', D: '#5a9a3a', L: '#733e39', F: '#3e2731', T: '#e43b44', U: '#a22633', A: '#b86f50', G: '#f4f4f4' },
139 hat: ['', '', '', '', '....TTTTTTTTU...', '............U...'],
140 outfit: [at(9, 2, 'AA', 'AA', 'AA'), at(9, 4, 'Q'), at(10, 5, 'Q')],
141 props: [
142 [blade(5), at(10, 12, 'AAA'), at(11, 13, 'Q')],
143 [at(4, 13, 'G'), blade(5), at(10, 12, 'AAA'), at(11, 13, 'Q')],
144 ],
145 },
146}
147
148export const THEMES: Theme[] = ['party', 'wizarding']
149const CASTS: Record<Theme, Record<string, Hero>> = { party: HEROES, wizarding: WIZARDING }
150
151// An agent type no cast names ("ck:api-dev", "superpowers:code-reviewer") gets the role its name hints at.
152// First match wins, so the narrow words come before the broad ones.
153const HINTS: [RegExp, string][] = [
154 [/secur|audit|pentest|vuln/i, 'security-auditor'],
155 [/review/i, 'code-reviewer'],
156 [/test|qa\b|e2e/i, 'tester'],
157 [/full.?stack/i, 'fullstack-developer'],
158 [/front|\bui\b|ux|css|react|vue|design/i, 'frontend-developer'],
159 [/back|api|server|database|\bdb\b|sql/i, 'backend-developer'],
160 [/plan|architect|brainstorm/i, 'planner'],
161 [/git|commit|release|deploy|ship/i, 'git-manager'],
162 [/research|analy/i, 'researcher'],
163 [/explor|search|find/i, 'Explore'],
164 [/scout|recon/i, 'scout'],
165 [/doc|writ/i, 'docs-manager'],
166]
167
168export const roleOf = (type: string): string =>
169 type in HEROES ? type : (HINTS.find(([hint]) => hint.test(type))?.[1] ?? 'general-purpose')
170
171export const heroOf = (type: string, theme: Theme = 'party'): Hero => {
172 const cast = CASTS[theme] ?? HEROES
173 return cast[type] ?? cast[roleOf(type)] ?? cast['general-purpose']!
174}
175hooks/activity.ts 62 lines1const base = (path: unknown): string => String(path ?? '').split('/').pop() || 'a file'
2const clip = (text: unknown, n: number): string => {
3 const s = String(text ?? '').replace(/\s+/g, ' ').trim()
4 return s.length > n ? `${s.slice(0, n - 1)}…` : s
5}
6
7// What a tool call reads as in a speech bubble: "Reading product.service.ts".
8export const describe = (tool: string, input: Record<string, unknown>): string => {
9 switch (tool) {
10 case 'Read':
11 return `Reading ${base(input.file_path)}`
12 case 'Edit':
13 case 'NotebookEdit':
14 return `Editing ${base(input.file_path ?? input.notebook_path)}`
15 case 'Write':
16 return `Writing ${base(input.file_path)}`
17 case 'Bash':
18 return input.description ? clip(input.description, 60) : `Running ${clip(input.command, 40)}`
19 case 'Grep':
20 return `Searching "${clip(input.pattern, 30)}"`
21 case 'Glob':
22 return `Finding ${clip(input.pattern, 30)}`
23 case 'WebFetch':
24 return `Reading ${clip(String(input.url ?? '').replace(/^https?:\/\//, '').split('/')[0], 30)}`
25 case 'WebSearch':
26 return `Googling "${clip(input.query, 30)}"`
27 case 'Agent':
28 return `Calling in a ${input.subagent_type ?? 'helper'}`
29 case 'TodoWrite':
30 return 'Updating the todo list'
31 default: {
32 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
33 return mcp ? `Asking ${mcp[1]}: ${mcp[2]!.replace(/_/g, ' ')}` : `Using ${tool}`
34 }
35 }
36}
37
38export const elapsed = (ms: number): string => {
39 const s = Math.max(0, Math.floor(ms / 1000))
40 return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`
41}
42
43export const tokens = (n: number): string => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
44
45// Spoken name: "code-reviewer" → "code reviewer".
46export const spoken = (type: string): string => type.replace(/[-_]/g, ' ')
47
48// A Bash command that runs a test suite counts as a test run, pass or fail (a failure costs a heart).
49// ponytail: a word match, so `git commit -m "add test"` counts too; parse the command if that misleads.
50const TEST_RUN = /\b(tests?|jest|vitest|pytest|mocha|playwright|rspec|phpunit)\b/i
51export const isTestRun = (command: unknown): boolean => TEST_RUN.test(String(command ?? ''))
52
53const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
54
55// What an agent brought back: "4 files · 2 test runs · 1 error", or '' for a quest with nothing to show.
56export const loot = (agent: { files: string[]; tests: number; errors: number }): string =>
57 [
58 agent.files.length && plural(agent.files.length, 'file'),
59 agent.tests && plural(agent.tests, 'test run'),
60 agent.errors && plural(agent.errors, 'error'),
61 ].filter(Boolean).join(' · ')
62hooks/art.ts 196 lines1// 16×16 sprite compositing: body + class hat/outfit/prop, then animation, then a computed outline.
2// Pure data in, pixels out, no engine imports, so the HTML demo draws the exact same frames.
3import { heroOf } from './heroes'
4import type { Outline } from '../types'
5import type { Layer, Theme } from './heroes'
6
7export const SIZE = 16
8export const TICK_MS = 250 // 4 fps: work loops step every tick, the idle bob every second tick
9
10export type Mode = 'work' | 'hurt' | 'done' | 'failed'
11export type Grid = (number | null)[][] // 0xRRGGBB per pixel, null = transparent
12export type { Outline } // thin: ink on the shadow side only (below, right) · dark: ink all round · none
13export type Pixels = 16 | 12 | 8
14export type Look = { pixels?: Pixels; outline?: Outline }
15
16// Chibi anatomy, light from the top left: hair 3–4 · face 5–7 (eyes row 6, cols 6 and 9) · chin 8
17// · torso 9–12 with 1 px hands · legs 13 · boots 14 · row 15 holds the outline.
18const BODY = [
19 '................',
20 '................',
21 '................',
22 '.....HHHHHH.....',
23 '....HHHHHHHH....',
24 '....HssssssH....',
25 '....ssessesS....',
26 '....sssssssS....',
27 '.....SSSSSS.....',
28 '....CCCCCCCD....',
29 '...sCCCCCCDDS...',
30 '...sCCCCCCDDS...',
31 '....DDDDDDDD....',
32 '.....LL..LL.....',
33 '.....FF..FF.....',
34 '................',
35]
36
37const SHARED: Record<string, string> = {
38 s: '#e8b796', S: '#c28569', e: '#181425', W: '#f4f4f4',
39 M: '#c0cbdc', N: '#8b9bb4', P: '#b86f50', Q: '#733e39', R: '#e43b44',
40}
41export const OUTLINE = 0x181425
42
43const num = (hex: string): number => parseInt(hex.slice(1), 16)
44const blank = (): (number | null)[] => Array<number | null>(SIZE).fill(null)
45
46const paint = (letters: string[][], layer: Layer) =>
47 layer.rows.forEach((line, dr) =>
48 [...line].forEach((ch, dc) => {
49 const row = letters[layer.at[0] + dr]
50 const col = layer.at[1] + dc
51 if (ch !== '.' && row && col >= 0 && col < SIZE) row[col] = ch
52 }),
53 )
54
55const scale = (rgb: number, k: number): number =>
56 (Math.round((rgb >> 16) * k) << 16) | (Math.round(((rgb >> 8) & 255) * k) << 8) | Math.round((rgb & 255) * k)
57
58const isInk = (px: number | null | undefined): boolean => px !== undefined && px !== null && px !== OUTLINE
59
60// The light comes from the top left, so `thin` inks only the edges in shadow: half the stroke of
61// `dark`, and the silhouette still reads on a light terminal.
62const outline = (grid: Grid, style: Outline): Grid =>
63 style === 'none'
64 ? grid
65 : grid.map((row, r) =>
66 row.map((px, c) => {
67 if (px !== null) return px
68 const up = isInk(grid[r - 1]?.[c])
69 const left = isInk(row[c - 1])
70 const isEdge = style === 'thin' ? up || left : up || left || isInk(grid[r + 1]?.[c]) || isInk(row[c + 1])
71 return isEdge ? OUTLINE : null
72 }),
73 )
74
75
76// 16 → 12: drop rows and columns 0, 5, 10 and 15 (the empty border, the forehead, the belt line);
77// every feature of the chibi body survives. An eye on a dropped line first moves a pixel toward the
78// middle, so a face drawn wider than the chibi one (the owl's eyes sit on cols 5 and 10) keeps both.
79const DROP = [0, 5, 10, 15]
80const shrink12 = (grid: Grid, eye: number): Grid => {
81 const moved = grid.map(row => [...row])
82 for (const [from, to] of [[5, 6], [10, 9]] as const) {
83 for (let i = 0; i < SIZE; i++) {
84 if (grid[from]![i] === eye) moved[to]![i] = eye
85 if (grid[i]![from] === eye) moved[i]![to] = eye
86 }
87 }
88 return moved.filter((_, r) => !DROP.includes(r)).map(row => row.filter((_, c) => !DROP.includes(c)))
89}
90
91// 16 → 8: each pixel stands for a 2×2 block, taken one column left so the eyes (cols 6, 9) stay a
92// pixel apart. An eye wins its block; a block less than half covered stays empty (no fringe);
93// otherwise the commonest colour, ties to the top left (the light).
94const shrink8 = (grid: Grid, eye: number): Grid =>
95 Array.from({ length: SIZE / 2 }, (_, r) =>
96 Array.from({ length: SIZE / 2 }, (_, c) => {
97 const block = [grid[2 * r]?.[2 * c - 1], grid[2 * r]?.[2 * c], grid[2 * r + 1]?.[2 * c - 1], grid[2 * r + 1]?.[2 * c]]
98 .filter((px): px is number => px !== undefined && px !== null)
99 if (block.includes(eye)) return eye
100 if (block.length < 2) return null
101 let best: number | null = null
102 let most = 0
103 for (const px of block) {
104 const n = block.filter(one => one === px).length
105 if (n > most) [best, most] = [px, n]
106 }
107 return best
108 }),
109 )
110
111// `eye` is the hero's own eye colour: the ghost's are slate blue, not ink.
112export const shrink = (grid: Grid, pixels: Pixels, eye: number): Grid =>
113 pixels === 12 ? shrink12(grid, eye) : pixels === 8 ? shrink8(grid, eye) : grid
114
115export const eyeOf = (type: string, theme: Theme = 'party'): number => num(heroOf(type, theme).colors.e ?? SHARED.e!)
116
117const hurt = (rgb: number): number => (Math.min(255, (rgb >> 16) + 90) << 16) | scale(rgb & 0xffff, 0.55)
118
119const shift = (grid: Grid, by: number): Grid =>
120 grid.map((_, r) => (grid[r - by] ? [...grid[r - by]!] : blank()))
121
122const grey = (rgb: number): number => {
123 const y = Math.round(0.3 * (rgb >> 16) + 0.59 * ((rgb >> 8) & 255) + 0.11 * (rgb & 255))
124 const v = Math.round(y * 0.75)
125 return (v << 16) | (v << 8) | v
126}
127
128const sparkle = (grid: Grid, color: number, spots: number[][]) => {
129 for (const [r, c] of spots) if (grid[r!]?.[c!] === null) grid[r!]![c!] = color
130}
131
132const compose = (type: string, theme: Theme, propFrame: number, isBlink: boolean, isKo: boolean): Grid => {
133 const hero = heroOf(type, theme)
134 const colors = { ...SHARED, ...hero.colors }
135 const letters = (hero.body ?? BODY).map(line => [...line])
136 hero.hat.forEach((line, r) => line && paint(letters, { at: [r, 0], rows: [line] }))
137 hero.outfit?.forEach(layer => paint(letters, layer))
138 hero.props[propFrame % hero.props.length]!.forEach(layer => paint(letters, layer))
139 return letters.map((row, r) =>
140 row.map((ch, c) => {
141 // closed or knocked-out eyes: the eye takes the colour of the pixel on its left
142 const shown = ch === 'e' && (isBlink || isKo) ? (row[c - 1] === 's' ? 'S' : (row[c - 1] ?? 'S')) : ch
143 return shown === '.' ? null : num(colors[shown] ?? '#ff00ff')
144 }),
145 )
146}
147
148const pose = (type: string, mode: Exclude<Mode, 'hurt'>, tick: number, age: number, sinceEnd: number, theme: Theme): Grid => {
149 const accent = num(heroOf(type, theme).colors.A ?? '#feae34')
150 const white = num(SHARED.W!)
151
152 if (age === 0) {
153 const poof = Array.from({ length: SIZE }, blank)
154 sparkle(poof, white, [[6, 7], [6, 8], [7, 6], [7, 9], [8, 6], [8, 9], [9, 7], [9, 8], [7, 7], [8, 8]])
155 return poof
156 }
157 if (age === 1) {
158 const ghost = compose(type, theme, 0, false, false).map(row => row.map(px => (px === null ? null : white)))
159 sparkle(ghost, white, [[2, 2], [3, 13], [12, 1], [13, 14]])
160 return ghost
161 }
162
163 if (mode === 'failed') {
164 return shift(compose(type, theme, 0, false, true), 2).map(row => row.map(px => (px === null ? null : grey(px))))
165 }
166
167 if (mode === 'done') {
168 let grid = compose(type, theme, 1, false, false)
169 if (sinceEnd === 0) grid = shift(grid, -1)
170 sparkle(grid, accent, sinceEnd === 0 ? [[1, 2], [0, 8], [2, 13]] : [[0, 7], [1, 3], [1, 12]])
171 return grid
172 }
173
174 const isBlink = tick % 16 === 15
175 let grid = compose(type, theme, tick, isBlink, false)
176 if (age === 2) sparkle(grid, accent, [[1, 3], [2, 12]])
177 if (Math.floor(tick / 2) % 2 === 1) {
178 // idle bob: everything above the legs sinks a pixel; the belt row covers the top of the legs
179 const sunk = shift(grid, 1)
180 grid = grid.map((row, r) => (r >= 13 ? row.map((px, c) => (r === 13 ? (sunk[r]![c] ?? px) : px)) : sunk[r]!))
181 }
182 return grid
183}
184
185/**
186 * The pixels of one frame. `tick` is the global animation clock (TICK_MS steps); `age` counts ticks
187 * since the agent spawned, `sinceEnd` ticks since it finished: they drive the one-shot effects.
188 * `look` picks the sprite size (16, 12 or 8 pixels) and the outline; the outline goes on last, at the drawn size.
189 */
190export const frame = (type: string, mode: Mode, tick: number, age = 99, sinceEnd = 99, theme: Theme = 'party', look: Look = {}): Grid => {
191 let grid = pose(type, mode === 'hurt' ? 'work' : mode, tick, age, sinceEnd, theme)
192 grid = shrink(grid, look.pixels ?? 16, eyeOf(type, theme))
193 if (mode === 'hurt') grid = grid.map(row => row.map(px => (px === null ? null : hurt(px))))
194 return outline(grid, look.outline ?? 'thin')
195}
196hooks/sprites.ts 50 lines1import type { Grid } from './art'
2import { heroOf } from './heroes'
3import type { Theme } from './heroes'
4
5const DEFAULT = 0x01000000 // the terminal's own colour
6
7export const colorOf = (type: string, theme?: Theme): string => heroOf(type, theme).colors.C ?? '#94a3b8'
8export const classOf = (type: string, theme?: Theme): string => heroOf(type, theme).class
9
10// One terminal cell carries two pixels with the upper half block: top pixel as the glyph's colour,
11// bottom pixel as its background. 16×16 pixels → 16 columns × 8 rows; 8×8 → 8 × 4.
12export const cellsOf = (grid: Grid) => ({ columns: grid[0]!.length, rows: Math.ceil(grid.length / 2) })
13
14// Raster cells: row-major little-endian u32 triplets [codePoint, foreground, background], base64.
15export const spriteCells = (grid: Grid): string => {
16 const { columns, rows } = cellsOf(grid)
17 const words = new Uint32Array(columns * rows * 3)
18 for (let row = 0; row < rows; row++) {
19 for (let col = 0; col < columns; col++) {
20 const top = grid[row * 2]![col] ?? null
21 const bottom = grid[row * 2 + 1]?.[col] ?? null
22 const cell =
23 top !== null ? [0x2580, top, bottom ?? DEFAULT] // ▀
24 : bottom !== null ? [0x2584, bottom, DEFAULT] // ▄
25 : [0x20, DEFAULT, DEFAULT]
26 words.set(cell, (row * columns + col) * 3)
27 }
28 }
29 let binary = ''
30 for (const byte of new Uint8Array(words.buffer)) binary += String.fromCharCode(byte)
31 return btoa(binary)
32}
33
34const hex = (rgb: number) => `#${rgb.toString(16).padStart(6, '0')}`
35
36// The same pixels for the surfaces without a terminal (desktop, VS Code, mobile): one rect per colour run.
37export const spriteSvg = (grid: Grid): string => {
38 let rects = ''
39 grid.forEach((row, y) => {
40 for (let x = 0; x < row.length; ) {
41 const px = row[x] ?? null
42 let end = x + 1
43 while (end < row.length && (row[end] ?? null) === px) end++
44 if (px !== null) rects += `<rect x="${x}" y="${y}" width="${end - x}" height="1" fill="${hex(px)}"/>`
45 x = end
46 }
47 })
48 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${grid[0]!.length} ${grid.length}" shape-rendering="crispEdges">${rects}</svg>`
49}
50hooks/heroes-wizarding.ts 141 lines1// The wizarding-school cast: original archetypes (no named characters). Same slots as heroes.ts;
2// three of them bring their own body (an owl, a ghost, a portrait). Scarves use four house-style pairs.
3import type { Hero, Layer } from './heroes'
4
5const at = (row: number, col: number, ...rows: string[]): Layer => ({ at: [row, col], rows })
6const wand = [at(8, 13, 'G'), at(9, 13, 'Q'), at(10, 13, 'Q')]
7const wandSpell = [at(6, 14, 'A'), at(7, 13, 'A'), at(8, 13, 'W'), at(9, 13, 'Q'), at(10, 13, 'Q'), at(5, 12, 'A')]
8const scarf = at(9, 4, 'XYXYXYXY')
9const robe = { C: '#3a4466', D: '#262b44', L: '#262b44', F: '#181425' }
10
11const OWL = [
12 '................', '................', '................', '....U......U....',
13 '....UCCCCCCU....', '...CCCCCCCCCC...', '...CWWWCCWWWC...', '...CWeWCCWeWC...',
14 '...CWWWAAWWWC...', '..DCCCCAACCCCD..', '..DCBBBBBBBBCD..', '..DCBBBBBBBBCD..',
15 '...DCBBBBBBCD...', '....DDDDDDDD....', '.....AA..AA.....', '................',
16]
17const GHOST = [
18 '................', '................', '................', '.....CCCCCC.....',
19 '....CCCCCCCC....', '....CCCCCCCC....', '....CCeCCeCC....', '....CCCCCCCC....',
20 '...CCCCDDCCCC...', '...CCCCCCCCCC...', '..CCCCCCCCCCCC..', '...CCCCCCCCCC...',
21 '...CCCCCCCCCC...', '...CC.CCCC.CC...', '................', '................',
22]
23const PORTRAIT = [
24 '................', '..AAAAAAAAAAAA..', '..AXXXXXXXXXXA..', '..AXXXHHHHXXXA..',
25 '..AXXHHHHHHXXA..', '..AXXHssssHXXA..', '..AXXHesseHXXA..', '..AXXHssssHXXA..',
26 '..AXXXSSSSXXXA..', '..AXXCCCCCCXXA..', '..AXCCCCCCCCXA..', '..AXCCCCCCCCXA..',
27 '..AAAAAAAAAAAA..', '......AAAA......', '................', '................',
28]
29
30export const WIZARDING: Record<string, Hero> = {
31 planner: {
32 class: 'Headmaster',
33 colors: { H: '#c0cbdc', B: '#e6e9ef', C: '#8f5fd0', D: '#5b3f9e', L: '#5b3f9e', F: '#3e2731', T: '#8f5fd0', U: '#5b3f9e', A: '#fee761', G: '#2ce8f5' },
34 hat: ['', '.........T......', '........TAT.....', '......TATTTU....', '...UUUUUUUUUU...', '', '....sMeMMeMS....', '....BBBBBBBB....', '.....BBBBBB.....', '......BBBB......', '.......BB.......'],
35 props: [wand, wandSpell],
36 },
37 'backend-developer': {
38 class: 'Groundskeeper',
39 colors: { H: '#3e2731', B: '#5d3a2e', C: '#9a6a45', D: '#6a4033', L: '#3e2731', F: '#181425', A: '#feae34', G: '#fee761' },
40 hat: ['', '', '....H.HH.H......', '....HHHHHHHH....', '...HHHHHHHHHH...', '...HHssssssHH...', '...HHsessesHH...', '....BBBBBBBB....', '....BBBBBBBB....', '.....BBBBBB.....'],
41 props: [
42 [at(8, 13, 'Q'), at(9, 12, 'QAQ', 'QGQ', '.Q.')],
43 [at(8, 13, 'Q'), at(9, 12, 'QGQ', 'QWQ', '.Q.'), at(7, 14, 'A'), at(8, 11, 'A')],
44 ],
45 },
46 'frontend-developer': {
47 class: 'Charms Student',
48 colors: { ...robe, H: '#b86f50', X: '#c42430', Y: '#feae34', A: '#fee761', G: '#f4f4f4' },
49 hat: [],
50 outfit: [scarf, at(10, 9, 'X', 'Y')],
51 props: [wand, wandSpell],
52 },
53 'fullstack-developer': {
54 class: 'Broom Rider',
55 colors: { ...robe, H: '#feae34', X: '#124e89', Y: '#c28569', A: '#e4a672', G: '#2ce8f5' },
56 hat: ['', '', '', '', '....MGGMMGGM....'],
57 outfit: [scarf, at(10, 9, 'X', 'Y')],
58 props: [
59 [at(3, 13, 'P', 'P', 'P', 'P', 'P', 'P', 'P', 'P', 'P'), at(12, 12, 'AAA'), at(13, 12, 'A.A')],
60 [at(3, 13, 'P', 'P', 'P', 'P', 'P', 'P', 'P', 'P', 'P'), at(12, 12, 'AAA'), at(13, 12, '.AA'), at(14, 14, 'A')],
61 ],
62 },
63 tester: {
64 class: 'Potions Master',
65 colors: { H: '#262b44', C: '#4a3f63', D: '#2c2840', L: '#2c2840', F: '#181425', K: '#5a6988', G: '#63c74d', A: '#a7f070' },
66 hat: ['', '', '', '.....HHHHHH.....', '....HHHHHHHH....', '....HssssssH....', '....HsessesH....', '....HssssssH....', '....HSSSSSSH....'],
67 props: [
68 [at(10, 11, 'GGGG', 'KKKK', 'KKKK', '.KK.'), at(8, 12, 'W'), at(7, 13, 'W')],
69 [at(10, 11, 'GAGG', 'KKKK', 'KKKK', '.KK.'), at(8, 13, 'W'), at(6, 12, 'W'), at(9, 14, 'A')],
70 ],
71 },
72 'code-reviewer': {
73 class: 'Professor',
74 colors: { H: '#8b9bb4', C: '#3e8948', D: '#265c42', L: '#265c42', F: '#181425', T: '#265c42', U: '#193c3e', A: '#e43b44', G: '#f4f4f4' },
75 hat: ['', '........TT......', '.......TTT......', '......TTTTU.....', '..UUUUUUUUUUUU..', '', '....sMeMMeMS....'],
76 outfit: [at(10, 1, 'WW', 'WW')],
77 props: [wand, [...wandSpell.slice(1), at(6, 14, 'A'), at(5, 13, 'A')]],
78 },
79 'security-auditor': {
80 class: 'Dark Arts Defender',
81 colors: { H: '#3e2731', C: '#a22633', D: '#5d1a2a', L: '#262b44', F: '#181425', A: '#41a6f6', G: '#73eff7' },
82 hat: ['', '', '', '.....HHHHHH.....', '....HHHHHHHH....', '....HssssssH....'],
83 props: [
84 [at(7, 13, 'G'), at(8, 13, 'Q'), at(9, 13, 'Q')],
85 [at(4, 14, 'A', 'A', 'A', 'A', 'A', 'A'), at(3, 13, 'A'), at(10, 13, 'A'), at(7, 13, 'G'), at(8, 13, 'Q'), at(9, 13, 'Q')],
86 ],
87 },
88 'git-manager': {
89 class: 'Post Owl',
90 body: OWL,
91 colors: { C: '#b86f50', D: '#733e39', U: '#733e39', B: '#ead4aa', A: '#feae34', G: '#e43b44' },
92 hat: [],
93 props: [
94 [at(13, 5, 'WWGWWW')],
95 [at(13, 5, 'WWGWWW'), at(7, 1, 'D'), at(8, 1, 'DD'), at(7, 14, 'D'), at(8, 13, 'DD')],
96 ],
97 },
98 researcher: {
99 class: 'Library Prefect',
100 colors: { ...robe, H: '#9a6a45', X: '#c42430', Y: '#feae34', A: '#a22633', K: '#124e89', G: '#ead4aa' },
101 hat: ['', '', '....H.HHH.H.....', '...HHHHHHHHH....', '...HHHHHHHHHH...', '...HHssssssHH...', '...HssessesSH...', '...HsssssssSH...', '...HHSSSSSSHH...'],
102 outfit: [scarf],
103 props: [
104 [at(10, 2, 'AAAA', 'GGGG', 'KKKK')],
105 [at(9, 3, 'GG'), at(10, 2, 'AGGA', 'GGGG', 'KKKK')],
106 ],
107 },
108 Explore: {
109 class: 'Night Explorer',
110 colors: { ...robe, H: '#3e2731', X: '#fee761', Y: '#262b44', A: '#ead4aa', G: '#fee761' },
111 hat: [],
112 outfit: [scarf],
113 props: [
114 [at(10, 4, 'AAAAAAA', 'AQAAAAA', 'AAAAQAA'), at(7, 13, 'G'), at(8, 13, 'Q'), at(9, 13, 'Q')],
115 [at(10, 4, 'AAAAAAA', 'AAQAAAA', 'AAAAAQA'), at(6, 13, 'G'), at(7, 12, 'G'), at(7, 14, 'G'), at(8, 13, 'Q'), at(9, 13, 'Q')],
116 ],
117 },
118 scout: {
119 class: 'Castle Ghost',
120 body: GHOST,
121 colors: { C: '#cfe9f5', D: '#8bb5cc', A: '#cfe9f5', e: '#3a4466' },
122 hat: [],
123 props: [[at(14, 4, 'C..CC..C')], [at(14, 4, '.C.CC.C.')]],
124 },
125 'docs-manager': {
126 class: 'Talking Portrait',
127 body: PORTRAIT,
128 colors: { A: '#c4862a', X: '#262b44', H: '#9a6a45', C: '#8f5fd0' },
129 hat: [],
130 props: [[], [at(8, 7, 'QQ')]],
131 },
132 'general-purpose': {
133 class: 'First-Year',
134 colors: { ...robe, H: '#733e39', X: '#fee761', Y: '#262b44', A: '#fee761', G: '#f4f4f4' },
135 hat: ['', '', '.....H.H.H......'],
136 outfit: [scarf],
137 props: [wand, wandSpell],
138 },
139}
140WIZARDING.Plan = WIZARDING.planner!
141types/index.d.ts 39 lines1export type Theme = 'party' | 'wizarding'
2export type Size = 'auto' | 'large' | 'medium' | 'small' | 'line'
3export type Outline = 'thin' | 'dark' | 'none'
4
5export type Agent = {
6 id: string
7 type: string
8 title: string
9 activity: string
10 startedAt: number
11 endedAt?: number
12 context: number
13 calls: number
14 status: 'running' | 'done' | 'failed'
15 errors: number // tool calls that came back as errors: each one costs a heart
16 hurtAt?: number
17 seenAt: number // last sign of life: a tool call started or ended, or a model step
18 inFlight: number // tool calls still running: a long test run is busy, not stuck
19 lastCall: string // the latest tool call with its input, to spot the same call over and over
20 repeats: number
21 parentId?: string // the agent that spawned this one, when an agent did
22 files: string[] // files it edited or wrote
23 tests: number // test suites it ran
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'agent-party': {
29 agents: Agent[]
30 isVoiceOn: boolean
31 theme: Theme
32 size: Size
33 outline: Outline
34 cast: Record<string, string> // agent type → role, set with /agent-cast
35 log: Agent[] // finished quests, newest last: what /agent-log shows after the heroes leave
36 }
37 }
38}
39