SLOPSHOPPER

agent-crabs

Above the prompt, one animated pixel Clawd per running subagent, acting out what it is doing now.

newbandguardcommandtimeragents
v0.5.5MITupdated 2026-10-21dante32683/claude-code-mods/agent-crabs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-crabs
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /crabs-demo ⎿ agent-crabs: Two pretend crabs play each animation for 3 s. ▐▛█▜▌ Demo crab 1 demo: think · 0s ▐▛█▜▌ Demo crab 2 demo: think · 2s ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▐▛█▜▌ Demo crab 1 demo: think · 0s ▐▛█▜▌ Demo crab 2 demo: think · 2s ⟨Claude Code's own drawing⟩
README

claude-code-mods

A few mods for Claude Code that I use every day. They work in the terminal and in the desktop app.

agent-crabs

Install

From a shell:

claude plugin marketplace add dante32683/claude-code-mods
claude plugin install agent-crabs@dante32683-mods

Swap agent-crabs for any mod below. In the desktop app, click + next to the prompt box, then Plugins, add the marketplace dante32683/claude-code-mods, and pick the mods you want.

Mods load when a chat starts, so open a new chat after installing.

Mods

agent-crabs

A little Clawd above the prompt for each subagent that's running, with what it's doing right now. The crab acts it out: reading a book, walking around with a magnifying glass while it searches, writing on paper while it edits, jogging while a command runs, holding a globe while it's on the web, and thinking in between. It throws confetti when the subagent finishes and sits under a rain cloud if it fails. /crabs-demo plays every animation.

The animated crabs show in the desktop app; the terminal gets a small text crab.

usage-context

A pane with your plan's 5-hour and weekly usage, how fast you're using it, how much per hour or day you can spend and still make the reset, and what's filling the context window. While the pane is closed, the usage bars sit above the prompt. /meter shows or hides the pane. It saves the last reading to statusline-limits.json in your Claude folder.

compact-plus

Better compaction. Before each compaction it saves the whole conversation to a searchable file in ~/.claude/compact-history/ and tells Claude where it is, so details the summary drops can still be found. Your own messages are kept word for word, the summary is written as a handoff, instruction files are re-read afterward, and when a turn ends close to the auto-compact limit it compacts right away instead of mid-task. History older than 30 days is deleted. Options: turnEndPercent and verbatimUserTokens.

repo-peers

Tells Claude when other Claude chats are working in the same repo (same checkout or another worktree) and which files they've edited, so it can coordinate before work collides. Windows only for now.

aside

/aside <task> hands a side task to a background worker while your chat keeps going. Unlike /btw, the worker has tools. It sees the last few messages for context, and its answer opens in a pane where you can send it to the chat or dismiss it. Haiku by default; /aside opus: <task> uses Opus.

bash-fixes

Windows Git Bash only. It fixes a few ways Claude's Bash tool breaks there: very long commands get cut off or mangled, rg patterns starting with / get turned into Windows paths, sed -i doesn't say when nothing changed, and rm "${DIR:?}"/... keeps asking for approval.

Credits

agent-crabs takes its idea from hamzafer's agent-radar (MIT), and Clawd's shape from Codrops' article on reverse-engineering Claude's mascot animations. Clawd is Anthropic's mascot; this is a fan project, not affiliated with Anthropic. compact-plus's summary style is modeled on OpenAI's Codex CLI.

License

MIT

Source 3 files
hooks/register.tsx 244 lines
1// agent-crabs: above the prompt, one animated Clawd per running subagent and what it
2//   is doing now (reading, searching, editing, running a command, on the web). The crab
3//   throws confetti when its subagent finishes and goes gray under a rain cloud if it
4//   fails; finished rows leave after 20 s. Activity comes from the subagents' own tool
5//   calls; whether one is still running comes from $.agent.list(), so a missed end event
6//   never leaves a row stuck. /crabs-demo plays every animation with two pretend crabs.
7//
8//   Each pose is an interactive Svg, which draws in its own frame. Changing a frame's
9//   source flashes it blank while it reloads, so a new pose loads hidden beside the one
10//   showing and takes over after WARM_MS. Only those two are mounted, because the app
11//   refuses a drawing over 100,000 characters.
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register, Timer } from 'claude-code'
14
15import type { Activity, Crab } from '../types'
16import { crabGlyph, crabSvg } from './sprite'
17
18const SHOW_DONE_MS = 20_000
19const WARM_MS = 900 // a new pose loads hidden this long before it shows
20const DEMO_STEP_MS = 3_000
21const REST_MS = 2_500 // with no tool running this long, the subagent is thinking again
22const ROWS = 4
23const KEEP = 20
24const LIVE = new Set(['pending', 'running', 'waiting'])
25export const ACTIVITIES: Activity[] = ['think', 'read', 'search', 'edit', 'run', 'web', 'done', 'failed']
26const SVGS = Object.fromEntries(ACTIVITIES.map(a => [a, crabSvg(a)])) as Record<Activity, string>
27
28// A new activity; its pose shows once it has loaded (see tick).
29export function setActivity(c: Crab, activity: Activity, at: number): Crab {
30  return activity === c.activity ? c : { ...c, activity, changedAt: at }
31}
32
33const crabs = atom({ plugin: 'agent-crabs', key: 'crabs' } as const, [] as Crab[])
34const now = atom({ plugin: 'agent-crabs', key: 'now' } as const, 0)
35
36export const register: Register = on => {
37  let timers: Timer[] = []
38
39  on('session.start', async ($, e, next) => {
40    const r = await next(e)
41    for (const t of timers) t.cancel()
42    // One tick a second while a row shows: elapsed times, the glyph crab's steps, and a
43    // check against the engine's own agent list.
44    timers = [$.clock.every(1000, () => void tick($).catch(() => {}))]
45    await $.command.register({ name: 'crabs-demo', description: 'Play every agent-crabs animation with two pretend subagents' }).catch(() => {})
46    return r
47  })
48
49  on('command.run', { command: 'crabs-demo' }, async $ => {
50    const t = Date.now()
51    const demo = (n: number, offset: number): Crab => ({ id: `demo-${n}-${t}`, label: `Demo crab ${n}`, activity: 'think', shown: 'think', changedAt: t, detail: 'demo: think', tools: 0, startedAt: t - offset })
52    await update($, crabs, list => [demo(1, 0), demo(2, DEMO_STEP_MS / 2), ...list].slice(0, KEEP))
53    await update($, now, () => t)
54    return { text: `Two pretend crabs play each animation for ${DEMO_STEP_MS / 1000} s.` }
55  })
56
57  on('agent.spawn', async ($, e, next) => {
58    const r = await next(e)
59    if (r.agentId) {
60      const crab: Crab = {
61        id: r.agentId,
62        label: e.description || e.subagentType || 'subagent',
63        activity: 'think',
64        shown: 'think',
65        changedAt: Date.now(),
66        detail: 'starting',
67        tools: 0,
68        startedAt: Date.now(),
69      }
70      await update($, crabs, list => [crab, ...list.filter(c => c.id !== crab.id)].slice(0, KEEP)).catch(() => {})
71    }
72    return r
73  })
74
75  // A subagent's tool call sets what its crab is doing; the main loop's calls pass by.
76  on('tool.call', async ($, e, next) => {
77    const id = e.agentId
78    if (!id) return next(e)
79    const { activity, detail } = describe(e as unknown as Record<string, unknown>)
80    // Drawing must never fail a tool call, so these updates swallow their errors.
81    await update($, crabs, list =>
82      list.map(c =>
83        c.id === id && !c.endedAt
84          ? { ...setActivity(c, activity, Date.now()), detail, tools: c.tools + 1, busy: (c.busy ?? 0) + 1, lastToolAt: Date.now() }
85          : c,
86      ),
87    ).catch(() => {})
88    try {
89      return await next(e)
90    } finally {
91      await update($, crabs, list =>
92        list.map(c => (c.id === id ? { ...c, busy: Math.max(0, (c.busy ?? 1) - 1), lastToolAt: Date.now() } : c)),
93      ).catch(() => {})
94    }
95  }).catch(($, e, next) => next(e))
96
97  on('turn.complete', async ($, e, next) => {
98    const r = await next(e)
99    if (e.agentId) await finish($, e.agentId, e.reason === 'error' || e.reason === 'aborted')
100    return r
101  })
102
103  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
104    const rest = await next(e)
105    const at = await read($, now) // subscribes the band to the tick
106    const shown = (await read($, crabs)).filter(c => isShown(c, at || Date.now()))
107    if (e.props.hasSurvey || shown.length === 0) return rest
108    const isDesktop = e.surface !== 'terminal'
109    const els = $.ui.resolve(e)
110    const { Box, Text } = els
111    const Svg = 'Svg' in els ? els.Svg : undefined
112    const frame = Math.floor((at || Date.now()) / 1000)
113    const rows = shown.slice(0, ROWS).map(c => (
114      <Box key={`crab-${c.id}`} flexDirection="row" alignItems="center" gap={1}>
115        {isDesktop && Svg ? (
116          <Box key={`art-${c.id}`} width={6} height={2}>
117            {[...new Set([c.shown, c.activity])].map(a => (
118              <Box key={`pose-${a}`} display={a === c.shown && (at || Date.now()) - c.startedAt >= WARM_MS ? 'flex' : 'none'}>
119                <Svg key={`sprite-${a}`} source={SVGS[a]} alt={`crab ${a}`} width={48} height={28} isInteractive />
120              </Box>
121            ))}
122          </Box>
123        ) : (
124          <Text color={c.activity === 'failed' ? 'gray' : '#D77757'}>{crabGlyph(c.activity, frame)}</Text>
125        )}
126        <Text wrap="truncate-end">
127          <Text bold dimColor={c.activity === 'failed'}>{c.label}</Text>
128          <Text dimColor>{`  ${c.detail} · ${elapsed(c, at)}${c.tools ? ` · ${c.tools} tool${c.tools === 1 ? '' : 's'}` : ''}`}</Text>
129        </Text>
130      </Box>
131    ))
132    const mine = (
133      <Box key="agent-crabs" flexDirection="column" paddingX={1}>
134        {rows}
135        {shown.length > ROWS && <Text dimColor>{`  +${shown.length - ROWS} more`}</Text>}
136      </Box>
137    )
138    return rest ? (
139      <Box flexDirection="column">
140        {mine}
141        {rest}
142      </Box>
143    ) : (
144      mine
145    )
146  })
147}
148
149async function tick($: EngineInterface) {
150  const list = await read($, crabs)
151  const t = Date.now()
152  if (!list.some(c => isShown(c, t - 2_000))) {
153    if (list.length) await update($, crabs, () => []) // all gone: stop redrawing
154    return
155  }
156  // A running crab whose agent the engine no longer lists as live has ended.
157  if (list.some(c => !c.endedAt)) {
158    const agents = await $.agent.list().catch(() => null)
159    if (agents) {
160      const status = new Map(agents.map(a => [a.id, a.status]))
161      for (const c of list) {
162        const s = status.get(c.id)
163        if (!c.endedAt && s !== undefined && !LIVE.has(s)) await finish($, c.id, s !== 'completed' && s !== 'idle')
164      }
165    }
166  }
167  // Pretend crabs step through every animation, then the first finishes and the second fails.
168  if (list.some(c => c.id.startsWith('demo-') && !c.endedAt)) {
169    await update($, crabs, all => all.map(c => (c.id.startsWith('demo-') && !c.endedAt ? demoStep(c, t) : c)))
170  }
171  // A subagent with no tool running for a while is thinking about its next step.
172  if (list.some(c => settle(c, t) !== c)) await update($, crabs, all => all.map(c => settle(c, t)))
173  // A pose that has had time to load takes over from the one showing.
174  if (list.some(c => c.activity !== c.shown)) {
175    await update($, crabs, all => all.map(c => (c.activity !== c.shown && t - c.changedAt >= WARM_MS ? { ...c, shown: c.activity } : c)))
176  }
177  await update($, now, () => t)
178}
179
180export function demoStep(c: Crab, at: number): Crab {
181  const i = Math.floor((at - c.startedAt) / DEMO_STEP_MS)
182  const steps = ACTIVITIES.slice(0, 6)
183  if (i < steps.length) {
184    const activity = steps[i]!
185    return activity === c.activity ? c : { ...setActivity(c, activity, at), detail: `demo: ${activity}`, tools: i }
186  }
187  const failed = c.label.endsWith('2')
188  return { ...setActivity(c, failed ? 'failed' : 'done', at), detail: failed ? 'stopped' : 'done', endedAt: at }
189}
190
191export function settle(c: Crab, t: number): Crab {
192  if (c.endedAt || c.busy || c.activity === 'think' || c.id.startsWith('demo-')) return c
193  return t - (c.lastToolAt ?? c.changedAt) >= REST_MS ? setActivity(c, 'think', t) : c
194}
195
196async function finish($: EngineInterface, id: string, failed: boolean) {
197  await update($, crabs, list =>
198    list.map(c =>
199      c.id === id && !c.endedAt
200        ? { ...setActivity(c, failed ? 'failed' : 'done', Date.now()), detail: failed ? 'stopped' : 'done', endedAt: Date.now() }
201        : c,
202    ),
203  )
204}
205
206export function isShown(c: Crab, at: number) {
207  return !c.endedAt || at - c.endedAt < SHOW_DONE_MS
208}
209
210export function elapsed(c: Pick<Crab, 'startedAt' | 'endedAt'>, at: number) {
211  const s = Math.max(0, Math.round(((c.endedAt ?? (at || Date.now())) - c.startedAt) / 1000))
212  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
213}
214
215// What a person would say a tool call is doing, and which animation fits it.
216export function describe(e: Record<string, unknown>): { activity: Activity; detail: string } {
217  const tool = String(e.tool)
218  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
219  const file = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() ?? p
220  const short = (t: string, n = 48) => (t.length > n ? `${t.slice(0, n - 1)}…` : t)
221  switch (tool) {
222    case 'Read':
223      return { activity: 'read', detail: `reading ${file(s('file_path'))}` }
224    case 'Grep':
225    case 'Glob':
226      return { activity: 'search', detail: `searching ${short(s('pattern'), 32)}` }
227    case 'Edit':
228    case 'Write':
229    case 'NotebookEdit':
230      return { activity: 'edit', detail: `editing ${file(s('file_path') || s('notebook_path'))}` }
231    case 'Bash':
232    case 'PowerShell':
233      return { activity: 'run', detail: short(s('description') || s('command')) }
234    case 'WebSearch':
235      return { activity: 'web', detail: `searching the web: ${short(s('query'), 40)}` }
236    case 'WebFetch': {
237      const host = /^https?:\/\/([^/]+)/.exec(s('url'))?.[1] ?? 'a page'
238      return { activity: 'web', detail: `reading ${host}` }
239    }
240  }
241  if (tool.startsWith('mcp__')) return { activity: 'think', detail: `using ${tool.split('__').slice(1).join(' ').replace(/_/g, ' ')}` }
242  return { activity: 'think', detail: `using ${tool}` }
243}
244
hooks/sprite.ts 368 lines
1// Clawd, the Claude Code crab, as frame-by-frame pixel sprites, one loop per activity.
2//   Proportions are the official mascot's (Codrops' rect-by-rect rebuild, 2026-05-05):
3//   on a 2-pixel unit, a 16x12 body, 4x4 hands, 2x2 eyes and four 2x4 legs. Like the
4//   official clips, each activity has its own prop and pose: a book, a magnifier, a
5//   pencil and paper, a headband for a run, a spinning globe, thought dots, confetti, a
6//   rain cloud. Each SVG holds every frame of its loop and shows one at a time with
7//   discrete SMIL, which the Desktop app runs in an interactive (sandboxed) Svg.
8import type { Activity } from '../types'
9
10export const W = 38
11export const H = 22
12const OX = 4 // Clawd's left edge on the canvas
13const OY = 6 // Clawd's top edge; the ground is at H
14
15const PALETTE: Record<string, string> = {
16  O: '#DD775B', // body
17  g: '#8A8A8A', // body, failed
18  K: '#000000', // eyes
19  W: '#F3EEE6', // paper
20  G: '#A39C94', // ink lines
21  B: '#3F6FC4', // book cover
22  Y: '#F2C14E', // pencil
23  P: '#E88FA3', // eraser
24  D: '#3A3A3A', // pencil tip, magnifier handle
25  M: '#D3D9DF', // magnifier ring
26  H: '#9A6A3C', // magnifier handle
27  L: '#8FCBEA', // lens
28  N: '#2F4C86', // headband
29  F: '#8A8F96', // speed lines, dust
30  U: '#4A90D9', // globe sea
31  E: '#5DBB63', // globe land
32  C: '#B7BDC4', // cloud
33  R: '#6FB3E8', // rain
34  T: '#BDBDBD', // thought dots
35  S: '#7FC8F0', // sweat
36  '1': '#E07A5F',
37  '2': '#F2C14E',
38  '3': '#6FA8DC',
39  '4': '#C77DFF',
40  '5': '#7BD389',
41}
42
43type Eyes = 'open' | 'closed' | 'happy' | 'x' | 'squint'
44
45// One frame: Clawd's pose plus props, held for `hold` seconds.
46type Frame = {
47  hold: number
48  dx?: number
49  dy?: number
50  eyes?: Eyes
51  look?: [number, number]
52  armL?: number
53  armR?: number
54  lift?: [number, number, number, number]
55  air?: boolean
56  headband?: boolean
57  body?: string
58  props?: Sprite[]
59}
60
61type Sprite = { x: number; y: number; rows: string[] }
62
63// A pixel canvas: later paints cover earlier ones.
64class Canvas {
65  px = new Map<string, string>()
66  set(x: number, y: number, c: string) {
67    if (x >= 0 && y >= 0 && x < W && y < H) this.px.set(`${x},${y}`, c)
68  }
69  rect(x: number, y: number, w: number, h: number, c: string) {
70    for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) this.set(x + i, y + j, c)
71  }
72  sprite(s: Sprite) {
73    s.rows.forEach((row, j) => [...row].forEach((ch, i) => ch !== '.' && this.set(s.x + i, s.y + j, ch)))
74  }
75  // One path per color: runs of a color on a row, merged down the rows while they
76  // line up, become rectangles. Keeps a frame to a few hundred characters.
77  toPaths(): string {
78    const runs = new Map<string, { x: number; y: number; n: number; h: number }[]>()
79    const open = new Map<string, { x: number; y: number; n: number; h: number }>()
80    for (let y = 0; y < H; y++) {
81      let x = 0
82      while (x < W) {
83        const c = this.px.get(`${x},${y}`)
84        if (!c) {
85          x++
86          continue
87        }
88        let n = 1
89        while (this.px.get(`${x + n},${y}`) === c) n++
90        const key = `${c}|${x}|${n}`
91        const above = open.get(key)
92        if (above && above.y + above.h === y) above.h++
93        else {
94          const r = { x, y, n, h: 1 }
95          open.set(key, r)
96          runs.set(c, [...(runs.get(c) ?? []), r])
97        }
98        x += n
99      }
100    }
101    let out = ''
102    for (const [c, rs] of runs) out += `<path fill="${PALETTE[c] ?? c}" d="${rs.map(r => `M${r.x} ${r.y}h${r.n}v${r.h}h-${r.n}z`).join('')}"/>`
103    return out
104  }
105}
106
107const EYES: Record<Eyes, string[]> = {
108  open: ['KK', 'KK'],
109  closed: ['..', 'KK'],
110  squint: ['K.', '.K'],
111  happy: ['.K.', 'K.K'],
112  x: ['K.K', '.K.', 'K.K'],
113}
114
115function drawFrame(f: Frame): string {
116  const c = new Canvas()
117  const ox = OX + (f.dx ?? 0)
118  const oy = OY + (f.dy ?? 0)
119  const body = f.body ?? 'O'
120  const lift = f.lift ?? [0, 0, 0, 0]
121  ;[4, 8, 14, 18].forEach((lx, i) => {
122    const top = oy + 12
123    const h = (f.air ? 4 : H - top) - lift[i]!
124    if (h > 0) c.rect(ox + lx, top, 2, h, body)
125  })
126  c.rect(ox + 4, oy, 16, 12, body)
127  c.rect(ox, oy + 4 + (f.armL ?? 0), 4, 4, body)
128  c.rect(ox + 20, oy + 4 + (f.armR ?? 0), 4, 4, body)
129  if (f.headband) {
130    c.rect(ox + 4, oy + 1, 16, 1, 'N')
131    // The knot's tails stream back behind a runner.
132    c.set(ox + 3, oy + 1, 'N')
133    c.rect(ox + 1, oy + 2 + ((f.dy ?? 0) < 0 ? -1 : 0), 2, 1, 'N')
134  }
135  const eyes = EYES[f.eyes ?? 'open']
136  const [lx, ly] = f.look ?? [0, 0]
137  const shift = eyes[0]!.length === 3 ? -1 : 0
138  for (const ex of [6, 16]) c.sprite({ x: ox + ex + lx + (eyes[0]!.length === 3 && ex === 6 ? 0 : shift), y: oy + 2 + ly + (eyes.length === 3 ? -1 : 0), rows: eyes })
139  for (const p of f.props ?? []) c.sprite(p)
140  return c.toPaths()
141}
142
143// ---------------------------------------------------------------- props
144
145const dots = (n: number): Sprite[] =>
146  [
147    { x: 29, y: 5, rows: ['T'] },
148    { x: 31, y: 2, rows: ['TT', 'TT'] },
149    { x: 34, y: 0, rows: ['TTT', 'TTT'] },
150  ].slice(0, n)
151
152// An open book like the emoji: blue cover at the sides and bottom, pages dipping to the
153// spine, lines of text that change with the page.
154function book(page: number, flip: 0 | 1 | 2): Sprite[] {
155  const text = page % 2 ? ['BGGGGWWWW', 'WGGGGGWWW', 'WGGGWWWWW'] : ['BGGGGGWWW', 'WGGGWWWWW', 'WGGGGGWWW']
156  const left = [
157    '..WWWW...',
158    'WWWWWWWW.',
159    text[0]!,
160    'BWWWWWWWW',
161    text[1]!.replace(/^W/, 'B'),
162    'BWWWWWWWW',
163    text[2]!.replace(/^W/, 'B'),
164    'BBBBWWWWW',
165    '....BBBBB',
166  ]
167  const spine = ['.', '.', 'G', 'G', 'G', 'G', 'G', 'W', 'B']
168  const rows = left.map((r, j) => r + spine[j] + [...left[j]!].reverse().join(''))
169  const out: Sprite[] = [{ x: OX + 3, y: OY + 5, rows }]
170  // A page lifting off the right half and laying over the left.
171  if (flip === 1) out.push({ x: OX + 13, y: OY + 1, rows: ['WW', 'WW', 'WW', 'WW'] })
172  if (flip === 2) out.push({ x: OX + 6, y: OY + 2, rows: ['.WWWWW', 'WWWWWW', '..WWWW'] })
173  return out
174}
175
176// A magnifying glass like the emoji, held up in front: a silver ring, a blue lens with
177// a shine, and a wooden handle running down into Clawd's raised hand.
178const GLASS = [
179  '.....MMMMM..',
180  '....MLLLLLM.',
181  '...MLWWLLLLM',
182  '...MLWLLLLLM',
183  '...MLLLLLLLM',
184  '...MLLLLLLLM',
185  '...MLLLLLLLM',
186  '....MLLLLLM.',
187  '...HHMMMMM..',
188  '..HHH.......',
189  '.HHH........',
190  'HH..........',
191]
192
193function glass(dx: number, dy: number, glint: boolean): Sprite[] {
194  const lens: Sprite = { x: OX + dx + 19, y: OY - 6 + dy, rows: GLASS }
195  // The sparkle sits off the lens's upper left, where the canvas has room for it.
196  return glint ? [lens, { x: OX + dx + 19, y: OY - 6 + dy, rows: ['.W.', 'WWW', '.W.'] }] : [lens]
197}
198
199function pencilAndPaper(len: number[]): Sprite[] {
200  const paper = Array.from({ length: 9 }, () => 'WWWWWWW')
201  len.forEach((n, k) => {
202    const r = 2 + k * 2
203    paper[r] = 'W' + 'G'.repeat(n) + 'W'.repeat(6 - n)
204  })
205  const k = len.length ? len.length - 1 : 0
206  const tx = 30 + (len[k] ?? 0)
207  const ty = 15 + k * 2
208  const pencil: Sprite[] = [
209    { x: tx, y: ty, rows: ['D'] },
210    { x: tx - 1, y: ty - 1, rows: ['Y'] },
211    { x: tx - 2, y: ty - 2, rows: ['Y'] },
212    { x: tx - 3, y: ty - 3, rows: ['Y'] },
213    { x: tx - 4, y: ty - 4, rows: ['P'] },
214  ]
215  return [{ x: 29, y: 13, rows: paper }, ...pencil]
216}
217
218// Speed lines streaming back past a running Clawd (who runs in place, 5 right of usual).
219function speedLines(k: number): Sprite[] {
220  return [
221    [OY + 3, 4, 0],
222    [OY + 9, 4, 5],
223  ].map(([y, n, phase]) => ({ x: ((phase! - 2 * k) % 9 + 9) % 9 - 2, y: y!, rows: ['F'.repeat(n!)] }))
224}
225
226const LAND = ['......EE......', '..EE..EEE...E.', '.EEEE..EE..EEE', '..EEE...E..EE.', '...E.......E..', '..........EE..', '..............']
227const DISC = ['..UUU..', '.UUUUU.', 'UUUUUUU', 'UUUUUUU', 'UUUUUUU', '.UUUUU.', '..UUU..']
228
229function globe(turn: number, dy: number): Sprite {
230  const rows = DISC.map((r, j) =>
231    [...r].map((ch, i) => (ch === 'U' && LAND[j]![(i + turn) % 14] === 'E' ? 'E' : ch)).join(''),
232  )
233  return { x: 30, y: 0 + dy, rows }
234}
235
236// Confetti bursting from above Clawd, frame by frame.
237function confetti(t: number, fromX: number): Sprite[] {
238  const bits = [
239    [-6, -5, '1'], [-3, -7, '2'], [0, -8, '3'], [3, -7, '4'], [6, -5, '5'], [-5, -3, '3'], [5, -3, '1'], [1, -6, '5'],
240  ] as const
241  if (t === 0) return []
242  return bits.map(([vx, vy, col]) => {
243    const x = Math.round(fromX + (vx * t) / 3)
244    const y = Math.round(6 + (vy * t) / 2 + (t * t) / 2.2)
245    return { x, y, rows: [col] }
246  })
247}
248
249const cloud = (shift: number): Sprite[] => [
250  { x: OX + 6, y: 0, rows: ['..CCCC..', '.CCCCCCC', 'CCCCCCCC'] },
251  { x: OX + 7, y: 3 + shift, rows: ['R...R...'] },
252  { x: OX + 9, y: 4 - shift, rows: ['R...R'] },
253]
254
255// ---------------------------------------------------------------- loops
256
257const A: [number, number, number, number] = [1, 0, 1, 0]
258const B: [number, number, number, number] = [0, 1, 0, 1]
259
260export const LOOPS: Record<Activity, Frame[]> = {
261  think: [
262    { hold: 0.42, look: [1, -1], props: dots(1) },
263    { hold: 0.42, look: [1, -1], props: dots(2) },
264    { hold: 0.68, look: [1, -1], props: dots(3) },
265    { hold: 0.1, look: [1, -1], eyes: 'closed', props: dots(3) },
266    { hold: 0.5, look: [0, 0] },
267  ],
268  read: [
269    { hold: 0.55, look: [-1, 1], armL: 3, armR: 3, props: book(0, 0) },
270    { hold: 0.55, look: [1, 1], armL: 3, armR: 3, props: book(0, 0) },
271    { hold: 0.12, look: [1, 1], armL: 3, armR: 1, props: book(0, 1) },
272    { hold: 0.12, look: [0, 1], armL: 3, armR: 2, props: book(1, 2) },
273    { hold: 0.55, look: [-1, 1], armL: 3, armR: 3, props: book(1, 0) },
274    { hold: 0.12, look: [-1, 1], eyes: 'closed', armL: 3, armR: 3, props: book(1, 0) },
275    { hold: 0.55, look: [1, 1], armL: 3, armR: 3, props: book(1, 0) },
276  ],
277  // Peering through the glass, it steps forward, pauses (the lens glints), backs up.
278  search: [
279    { hold: 0.2, dx: 0, lift: A, look: [1, -1], armR: -2, props: glass(0, 0, false) },
280    { hold: 0.2, dx: 1, lift: B, look: [1, -1], armR: -1, props: glass(1, 1, false) },
281    { hold: 0.2, dx: 2, lift: A, look: [1, -1], armR: -2, props: glass(2, 0, false) },
282    { hold: 0.5, dx: 2, look: [1, -1], armR: -2, props: glass(2, 0, true) },
283    { hold: 0.2, dx: 2, look: [1, 0], armR: -1, props: glass(2, 1, false) },
284    { hold: 0.2, dx: 1, lift: B, look: [1, 0], armR: -2, props: glass(1, 0, false) },
285    { hold: 0.2, dx: 0, lift: A, look: [1, -1], armR: -1, props: glass(0, 1, false) },
286    { hold: 0.2, dx: -1, lift: B, look: [1, -1], armR: -2, props: glass(-1, 0, false) },
287    { hold: 0.5, dx: -1, look: [1, -1], armR: -2, props: glass(-1, 0, true) },
288  ],
289  edit: [
290    { hold: 0.16, look: [1, 1], armR: 1, props: pencilAndPaper([1]) },
291    { hold: 0.16, look: [1, 1], armR: 0, props: pencilAndPaper([3]) },
292    { hold: 0.16, look: [1, 1], armR: 1, props: pencilAndPaper([5]) },
293    { hold: 0.16, look: [1, 1], armR: 2, props: pencilAndPaper([5, 2]) },
294    { hold: 0.16, look: [1, 1], armR: 3, props: pencilAndPaper([5, 5]) },
295    { hold: 0.16, look: [1, 1], armR: 4, props: pencilAndPaper([5, 5, 3]) },
296    { hold: 0.5, look: [1, 1], eyes: 'happy', armR: 4, props: pencilAndPaper([5, 5, 5]) },
297    { hold: 0.25, look: [0, 0], armR: 0, props: pencilAndPaper([]) },
298  ],
299  // A steady run in place: legs step in pairs, a small bob between strides, arms swing
300  // against the legs, and two speed lines drift back.
301  run: [0, 1, 2, 3, 4, 5, 6, 7].map((k): Frame => {
302    const stride = k % 4 // 0 and 2: one pair of legs is up; 1 and 3: passing
303    return {
304      hold: 0.14,
305      dx: 5,
306      dy: stride % 2 ? -1 : 0,
307      headband: true,
308      look: [1, 0],
309      lift: stride === 0 ? [2, 0, 2, 0] : stride === 2 ? [0, 2, 0, 2] : [0, 0, 0, 0],
310      armL: stride === 0 ? -1 : stride === 2 ? 1 : 0,
311      armR: stride === 0 ? 1 : stride === 2 ? -1 : 0,
312      props: speedLines(k),
313    }
314  }),
315  web: [0, 1, 2, 3, 4, 5, 6].map((k): Frame => ({
316    hold: 0.2,
317    look: [1, -1],
318    lift: k % 2 ? [0, 0, 0, 1] : [0, 0, 0, 0],
319    armR: k % 4 < 2 ? -2 : -3,
320    props: [globe(k * 2, k % 4 < 2 ? 0 : 1)],
321  })),
322  done: [0, 1, 2, 3, 4, 5, 6, 7].map((t): Frame => {
323    const side = t % 4
324    return {
325      hold: 0.145,
326      eyes: 'happy',
327      dx: side === 1 ? -1 : side === 3 ? 1 : 0,
328      armL: side === 1 ? -5 : 0,
329      armR: side === 3 ? -5 : 0,
330      lift: side === 1 ? [0, 0, 1, 1] : side === 3 ? [1, 1, 0, 0] : [0, 0, 0, 0],
331      props: confetti(t, t < 4 ? 9 : 27),
332    }
333  }),
334  failed: [
335    { hold: 0.3, dy: 1, body: 'g', eyes: 'x', props: cloud(0) },
336    { hold: 0.3, dy: 1, body: 'g', eyes: 'x', props: cloud(1) },
337  ],
338}
339
340// Every frame in one SVG, each visible for its slot of the loop.
341export function crabSvg(activity: Activity): string {
342  const frames = LOOPS[activity]
343  const total = frames.reduce((s, f) => s + f.hold, 0)
344  let t = 0
345  const groups = frames.map((f, i) => {
346    const a = t / total
347    t += f.hold
348    const b = t / total
349    if (frames.length === 1) return `<g>${drawFrame(f)}</g>`
350    const r = (n: number) => Number(n.toFixed(4))
351    const anim =
352      i === 0
353        ? `values="visible;hidden" keyTimes="0;${r(b)}"`
354        : i === frames.length - 1
355          ? `values="hidden;visible" keyTimes="0;${r(a)}"`
356          : `values="hidden;visible;hidden" keyTimes="0;${r(a)};${r(b)}"`
357    return `<g visibility="hidden">${drawFrame(f)}<animate attributeName="visibility" ${anim} calcMode="discrete" dur="${r(total)}s" repeatCount="indefinite"/></g>`
358  })
359  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" shape-rendering="crispEdges">${groups.join('')}</svg>`
360}
361
362// The terminal has no Svg: a small glyph crab instead, its legs stepping by frame.
363export function crabGlyph(activity: Activity, frame: number): string {
364  if (activity === 'failed') return '▐▀█▀▌'
365  if (activity === 'done') return frame % 2 ? '▐▛█▜▌' : '▝▜█▛▘'
366  return frame % 2 ? '▐▛█▜▌' : '▗▛█▜▖'
367}
368
types/index.d.ts 28 lines
1// What a subagent is doing, which picks its crab's animation.
2export type Activity = 'think' | 'read' | 'search' | 'edit' | 'run' | 'web' | 'done' | 'failed'
3
4// One subagent this session started. `id` is its agentId.
5export type Crab = {
6  id: string
7  label: string
8  activity: Activity
9  detail: string
10  tools: number
11  startedAt: number
12  endedAt?: number
13  // The pose on screen. A new activity's pose loads hidden beside it and takes over
14  // once it has had WARM_MS to load (changedAt), so a swap never shows a blank frame.
15  shown: Activity
16  changedAt: number
17  // Its tool calls in flight, and when one last started or ended: a crab with none
18  // running for a while goes back to thinking.
19  busy?: number
20  lastToolAt?: number
21}
22
23declare module 'claude-code' {
24  interface PluginState {
25    'agent-crabs': { crabs: Crab[]; now: number }
26  }
27}
28