SLOPSHOPPER

Agent Party

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-party
│ ┃ Quest Log ✕ › fix the failing auth test and add an audit log call │ ┃ ⚔ 0 out · 0 back │ ┃ No quests yet. Spawn an agent and it shows ⏺ Read(src/auth.ts) │ ┃ up here. ⎿ 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 │ │ › /agent-voice │ ⎿ agent-party: agent-party voice off │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Quest Log
⚔ 0 out · 0 back No quests yet. Spawn an agent and it shows up here.
README

Agent Party

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
  • A hero appears with a poof when its agent is spawned, bobs while it works, and hops with sparkles when it finishes. If the agent is stopped, the hero is knocked out (grey). It leaves the stage 20 seconds after it finishes.
  • When it finishes, the bubble says what it brought back: Done ✓ · 4 files · 2 test runs · 1 error (files it edited or wrote, test suites it ran, tool calls that failed).
  • An agent spawned by another agent stands under its parent as a small familiar (↳ Explore the Ranger), and under it any it spawned in turn. It joins the main row once its parent has left the stage.
  • The speech bubble shows the agent's task, and below it what the agent is doing right now, from its latest tool call.
  • The level goes up by one with each tool call. The timer and token count update as the agent works.
  • Each tool call that comes back as an error costs the hero a heart (♥♥♥♥♡) and makes it flash red.
  • A hero with no tool running and no model step for 2 minutes shows 💦 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.
  • The status line under the prompt sums up the party (⚔ 2 running · ✓ 1 done · 💦 1 stuck?). It shows on every surface, also when the band is hidden.
  • A voice tells you who started and who finished. While an agent works, it gives at most one status line per agent every 45 seconds.

Tested with Claude Code v2.1.289. The mods API can change between releases.

Commands

CommandWhat it does
/agent-themeSwitches between the two casts. /agent-theme party or /agent-theme wizarding picks one. Your choice is saved across sessions.
/agent-voiceTurns the voice on or off
/agent-sizeauto (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-outlinethin (default: ink only on the shadow side, below and right), dark (ink all round) or none
/agent-logOpens 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-castPicks 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.

Casts

Agent typeparty (default)wizarding
plannerWizardHeadmaster
backend-developerDwarf SmithGroundskeeper
frontend-developerBardCharms Student
fullstack-developerPaladinBroom Rider
testerAlchemistPotions Master
code-reviewerKnightProfessor
security-auditorRogueDark Arts Defender
git-managerCourierPost Owl
researcherSageLibrary Prefect
ExploreRangerNight Explorer
scoutScoutCastle Ghost
docs-managerScribeTalking Portrait
any other typethe role its name hints at, else Adventurerthe 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.

The wizarding cast in every pose

Each row in the image shows one hero, with these frames from left to right:

  • working, with its idle bob and blink
  • victory hop
  • knocked out
  • the two spawn frames

How it works

PixelsOne 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.
SurfacesThe 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 sprites12×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.
Animation4 fps. The work loop steps every 250 ms and the idle bob every 500 ms. A blink comes every 4 s.
Hooksagent.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.
VoiceUses 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.
ArtSprites 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:

  • chibi proportions, with a big head on a short body
  • a full dark outline, so heroes read on dark and light terminals
  • 5–7 colours per sprite, lit from the top left
  • class colours from the Endesga 32 and Sweetie 16 palettes

Develop

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.

Source 7 files
hooks/register.tsx 486 lines
1import { 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}
486
hooks/heroes.ts 175 lines
1// 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}
175
hooks/activity.ts 62 lines
1const 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(' · ')
62
hooks/art.ts 196 lines
1// 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}
196
hooks/sprites.ts 50 lines
1import 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}
50
hooks/heroes-wizarding.ts 141 lines
1// 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!
141
types/index.d.ts 39 lines
1export 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