SLOPSHOPPER

bunny

A bunny above the prompt that reacts to what the session is doing.

bandguardcommandprompttimeragents
v0.1.0MITupdated 2026-10-17lillycham/claudepet/plugins/bunny
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bunny
› 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 › /bunny ⎿ bunny: Bunny is hiding. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

bunny

A small bunny above the Claude Code prompt. It reacts to the session:

EventMood
You send a promptthinking
A tool runsworking, with a caption per tool
A tool call failsoops
A turn completeshappy
60 seconds idleasleep

/bunny hides or shows it, and /bunny chibi, sit or loaf picks a style, which is remembered between sessions. While subagents run, a small helper for each shows what it is doing.

Install

At the prompt of a Claude Code session in a terminal:

/plugin install bunny --marketplace lillycham/claudepet

Or try it for one session from a clone of this repository:

claude --plugin-dir plugins/bunny
Source 2 files
hooks/register.tsx 479 lines
1import type { EngineInterface as Engine, Register } from 'claude-code'
2
3import type { Feeling, Helper, Mood, StyleName } from '../types'
4
5const TICK_MS = 400
6const HAPPY_MS = 6000
7const OOPS_MS = 5000
8const SLEEP_AFTER_MS = 60000
9const HELPER_DONE_MS = 4000
10const HELPER_STALE_MS = 180000
11const MAX_HELPERS = 4
12
13// Text bunny, used where there is no Raster (the desktop app). Two frames per mood.
14const FACES: Record<Mood, [string, string]> = {
15  idle: [`(='.'=)`, `(-'.'-)`],
16  sleep: [`(-.-)`, `(-_-)`],
17  think: [`(='.'=)`, `(=°.°=)`],
18  work: [`(>'.'>)`, `(<'.'<)`],
19  oops: [`(;'.';)`, `(T'.'T)`],
20  happy: [`(^'.'^)`, `(^o.o^)`],
21}
22const EARS: Record<Mood, [string, string]> = {
23  idle: [`(\\_/)`, `(\\_/)`],
24  sleep: [`(\\_/)`, `(\\_/)`],
25  think: [`(\\_/)`, `(/_/)`],
26  work: [`(\\_/)`, `(\\_/)`],
27  oops: [`(\\_/)`, `(\\_/)`],
28  happy: [`(\\_/)`, `(\\_/)`],
29}
30const COLORS: Record<Mood, string | undefined> = {
31  idle: undefined,
32  sleep: undefined,
33  think: 'yellow',
34  work: 'cyan',
35  oops: 'red',
36  happy: 'green',
37}
38
39// Raster bunny: pixel art, two pixels per terminal row (half blocks).
40// Legend: . empty, L outline, W fur, G shade, P inner ear, C cheek, N nose,
41// K eye, B tear, Y sparkle. Row counts are odd: the canvas adds one spare row for the hop.
42type Spot = [row: number, col: number]
43type Style = {
44  rows: string[]
45  eyes: Spot[]
46  smile: Spot[]
47  frown: Spot[]
48  sparks: [Spot[], Spot[]]
49}
50
51const STYLES: Record<StyleName, Style> = {
52  chibi: {
53    rows: [
54      '...LL...LL...',
55      '..LWWL.LWWL..',
56      '..LWPL.LPWL..',
57      '..LWPWLWPWL..',
58      '.LWWWWWWWWWL.',
59      'LWWWWWWWWWWWL',
60      'LWWKWWWWWKWWL',
61      'LWCWWWNWWWCWL',
62      '.LWWWWWWWWWL.',
63      '..LLLWWWLLL..',
64      '..LWWLWLWWL..',
65      '..LGWWWWWGL..',
66      '...LLL.LLL...',
67    ],
68    eyes: [[6, 3], [6, 9]],
69    smile: [[8, 5], [8, 7], [9, 6]],
70    frown: [[8, 6]],
71    sparks: [[[0, 0], [2, 12]], [[2, 0], [0, 12]]],
72  },
73  sit: {
74    rows: [
75      '........LL.LL...',
76      '.......LWWLWWL..',
77      '.......LWPLWPL..',
78      '.......LWWLWWWL.',
79      '.......LWWWWWWWL',
80      '....LLLLWWWWWWWL',
81      '..LLWWWLWWKWWKWL',
82      '.LWWWWWLWWWWWWWN',
83      '.LLWWWWWLWWWWWL.',
84      'LWWLWWWWWLLLLL..',
85      'LWWLWWWWWWWWL...',
86      '.LLGGWWWWWLWWL..',
87      '..LLLLLLLLLLLL..',
88    ],
89    eyes: [[6, 10], [6, 13]],
90    smile: [],
91    frown: [],
92    sparks: [[[0, 2], [1, 15]], [[3, 2], [0, 15]]],
93  },
94  loaf: {
95    rows: [
96      '..........LLLL..',
97      '....LLLLLLWWWWL.',
98      '..LLWPPPPLWWWWWL',
99      '.LWWLLLLLWWKWKWL',
100      'LWWWWWWWWWWWWWWN',
101      'LWWWWWWWWWWWWWWL',
102      'LWWWWWWWWWWWWWL.',
103      '.LGGWWWWWWWWGGL.',
104      '..LLLLLLLLLLLL..',
105    ],
106    eyes: [[3, 11], [3, 13]],
107    smile: [],
108    frown: [],
109    sparks: [[[0, 2], [1, 0]], [[1, 1], [0, 3]]],
110  },
111}
112
113const PALETTE: Record<string, number> = {
114  L: 0x8a8398,
115  W: 0xf4f0f2,
116  G: 0xc9c2cf,
117  P: 0xf4a6c0,
118  N: 0xe87ba0,
119  C: 0xf8c0d0,
120  K: 0x2b2433,
121  B: 0x6cb4ff,
122  Y: 0xffd84d,
123}
124const DEFAULT_COLOR = 0x01000000
125
126// Draws one mood and frame onto a copy of the style's pixels. Returns the grid
127// and whether the sprite lifts one pixel (the bounce of working and happy).
128const paint = (style: Style, mood: Mood, n: number): { grid: string[][]; isUp: boolean } => {
129  const grid = style.rows.map(row => row.split(''))
130  const put = (spots: Spot[], ch: string) => {
131    for (const [r, c] of spots) if (grid[r]?.[c] !== undefined) grid[r]![c] = ch
132  }
133  // Moves each eye by (dr, dc) onto fur or cheek, and gives it back where it was.
134  const lookAt = (dr: number, dc: number) => {
135    for (const [r, c] of style.eyes) {
136      const target = grid[r + dr]?.[c + dc]
137      if (target === 'W' || target === 'C') {
138        grid[r]![c] = 'W'
139        grid[r + dr]![c + dc] = 'K'
140      }
141    }
142  }
143  const odd = n % 2 === 1
144
145  if (mood === 'sleep' || (mood === 'idle' && n % 12 === 11)) put(style.eyes, 'L')
146  if (mood === 'think') lookAt(-1, odd ? -1 : 0)
147  if (mood === 'work') lookAt(0, odd ? 1 : -1)
148  if (mood === 'oops') {
149    for (const [r, c] of style.eyes) {
150      const dr = odd ? 2 : 1
151      if (grid[r + dr]?.[c] === 'W' || grid[r + dr]?.[c] === 'C') grid[r + dr]![c] = 'B'
152    }
153    put(style.frown, 'K')
154  }
155  if (mood === 'happy') {
156    put(style.smile, 'K')
157    put(style.sparks[odd ? 1 : 0], 'Y')
158  }
159
160  return { grid, isUp: odd && (mood === 'work' || mood === 'happy') }
161}
162
163// Packs the sprite as `Raster` cells: one `▀`/`▄` per two pixels, row-major.
164const spriteCells = (style: Style, mood: Mood, n: number): string => {
165  const { grid, isUp } = paint(style, mood, n)
166  const width = grid[0]!.length
167  // The sprite has an odd number of rows, so the canvas has one spare pixel row
168  // above it at rest. A hop moves the sprite into it.
169  const height = grid.length + 1
170  const shift = isUp ? 0 : 1
171  const pixel = (y: number, x: number): number | undefined => {
172    const ch = grid[y - shift]?.[x]
173    return ch === undefined || ch === '.' ? undefined : PALETTE[ch]
174  }
175  const words = new Uint32Array(width * (height / 2) * 3)
176  let i = 0
177  for (let r = 0; r < height / 2; r++) {
178    for (let c = 0; c < width; c++) {
179      const up = pixel(2 * r, c)
180      const down = pixel(2 * r + 1, c)
181      // Two equal pixels are a full block with the default background: a cell
182      // with a coloured background paints a grey bar to its left in the terminal.
183      const [glyph, fg, bg] =
184        up === undefined && down === undefined
185          ? [0x20, DEFAULT_COLOR, DEFAULT_COLOR]
186          : up === undefined
187            ? [0x2584, down!, DEFAULT_COLOR]
188            : up === down
189              ? [0x2588, up, DEFAULT_COLOR]
190              : [0x2580, up, down ?? DEFAULT_COLOR]
191      words[i++] = glyph!
192      words[i++] = fg!
193      words[i++] = bg!
194    }
195  }
196  return new Uint8Array(words.buffer).toBase64()
197}
198
199const decay = (f: Feeling, now: number): Feeling => {
200  const age = now - f.since
201  const isDone =
202    (f.mood === 'happy' && age > HAPPY_MS) || (f.mood === 'oops' && age > OOPS_MS)
203  if (isDone) return { mood: 'idle', label: '', since: now }
204  if (f.mood === 'idle' && age > SLEEP_AFTER_MS) return { mood: 'sleep', label: '', since: now }
205  return f
206}
207
208const shorten = (text: string, max = 48): string => {
209  const one = text.replace(/\s+/g, ' ').trim()
210  return one.length > max ? `${one.slice(0, max - 1)}…` : one
211}
212
213// A small text bunny for each running subagent: ears, then a face for its mood.
214const helperFace = (mood: Mood, odd: boolean): string => {
215  if (mood === 'happy') return odd ? '(^o^)' : '(^.^)'
216  if (mood === 'oops') return odd ? '(T.T)' : '(;.;)'
217  return odd ? '(>.>)' : '(<.<)'
218}
219const EARS_HELPER = '(\\_/)'
220const HELPER_COLORS: Partial<Record<Mood, string>> = { work: 'cyan', happy: 'green', oops: 'red' }
221
222const baseName = (path: string): string => path.split('/').pop() ?? path
223
224// What a tool call is doing, in a few words, from the tool's own arguments.
225const describeTool = (tool: string, input: Record<string, unknown>): string => {
226  const str = (key: string): string => (typeof input[key] === 'string' ? (input[key] as string) : '')
227  switch (tool) {
228    case 'Read':
229      return `reading ${baseName(str('file_path'))}`
230    case 'Edit':
231    case 'Write':
232    case 'NotebookEdit':
233      return `editing ${baseName(str('file_path'))}`
234    case 'Bash':
235      return shorten(`$ ${str('description') || str('command')}`)
236    case 'Grep':
237      return shorten(`searching for ${str('pattern')}`)
238    case 'Glob':
239      return shorten(`finding ${str('pattern')}`)
240    case 'Task':
241    case 'Agent':
242      return shorten(`delegating: ${str('description')}`)
243    case 'WebFetch':
244      return shorten(`fetching ${str('url')}`)
245    case 'WebSearch':
246      return shorten(`searching the web: ${str('query')}`)
247    default:
248      return `using ${tool}`
249  }
250}
251
252const upsert = (list: Helper[], helper: Helper): Helper[] =>
253  [...list.filter(one => one.id !== helper.id), helper].slice(-8)
254
255// Subagents run at the same time, so a change retries when another one wrote first.
256async function patchHelpers($: Engine, change: (list: Helper[]) => Helper[]): Promise<void> {
257  for (let attempt = 0; attempt < 5; attempt++) {
258    const held = await $.state.get({ plugin: 'bunny', key: 'helpers' } as const)
259    const done = await $.state.set(
260      { plugin: 'bunny', key: 'helpers' } as const,
261      change(held.value ?? []),
262      { ifVersion: held.version },
263    )
264    if (done.isSet) return
265  }
266}
267
268export const register: Register = on => {
269  let frameNo = 0
270
271  on('session.start', async ($, e, next) => {
272    await $.command.register({
273      name: 'bunny',
274      description: 'Show or hide the bunny, or pick its style: /bunny [chibi|sit|loaf]',
275    })
276
277    // The style is kept between sessions.
278    const saved = await $.store.get('style')
279    if (saved === 'chibi' || saved === 'sit' || saved === 'loaf') {
280      await $.state.set({ plugin: 'bunny', key: 'style' } as const, saved)
281    }
282
283    $.clock.every(TICK_MS, async () => {
284      const now = await $.clock.now()
285      const held = await $.state.get({ plugin: 'bunny', key: 'feeling' } as const)
286      if (held.value !== undefined) {
287        const decayed = decay(held.value, now)
288        if (decayed !== held.value) {
289          await $.state.set({ plugin: 'bunny', key: 'feeling' } as const, decayed)
290        }
291      }
292      const helpers = await $.state.get({ plugin: 'bunny', key: 'helpers' } as const)
293      const list = helpers.value ?? []
294      const fresh = list.filter(h => now - h.since < (h.mood === 'happy' ? HELPER_DONE_MS : HELPER_STALE_MS))
295      if (fresh.length !== list.length) {
296        await patchHelpers($, current => current.filter(h => fresh.some(one => one.id === h.id)))
297      }
298      frameNo = (frameNo + 1) % 1000
299      await $.state.set({ plugin: 'bunny', key: 'frame' } as const, frameNo)
300    })
301
302    return next(e)
303  })
304
305  on('command.run', { command: 'bunny' }, async ($, e) => {
306    const arg = e.args.trim()
307    const held = await $.state.get({ plugin: 'bunny', key: 'isHidden' } as const)
308    const hidden = held.value === true
309
310    if (arg === 'chibi' || arg === 'sit' || arg === 'loaf') {
311      await $.state.set({ plugin: 'bunny', key: 'style' } as const, arg)
312      await $.store.set('style', arg)
313      await $.state.set({ plugin: 'bunny', key: 'isHidden' } as const, false)
314
315      return { text: `Bunny style: ${arg} :3 (remembered)` }
316    }
317    if (arg !== '') {
318      return { text: 'Usage: /bunny [chibi|sit|loaf]. With no argument it shows or hides the bunny.' }
319    }
320
321    await $.state.set({ plugin: 'bunny', key: 'isHidden' } as const, !hidden)
322
323    return { text: hidden ? 'Bunny is back :3' : 'Bunny is hiding.' }
324  })
325
326  on('prompt.submit', async ($, e, next) => {
327    await $.state.set({ plugin: 'bunny', key: 'feeling' } as const, { mood: 'think', label: 'twitching ears', since: await $.clock.now() })
328
329    return next(e)
330  })
331
332  on('agent.spawn', async ($, e, next) => {
333    const started = await next(e)
334    const id = started.agentId
335
336    if (started.deny === undefined && id !== undefined) {
337      const now = await $.clock.now()
338      const name = e.name ?? e.description
339      await patchHelpers($, list =>
340        upsert(list, { id, name, mood: 'work', label: shorten(e.description), since: now }),
341      )
342    }
343
344    return started
345  })
346
347  on('tool.call', async ($, e, next) => {
348    const now = await $.clock.now()
349    const label = describeTool(e.tool, e as unknown as Record<string, unknown>)
350    const id = e.agentId
351
352    if (id !== undefined) {
353      // A subagent's call moves its own bunny, not the main one.
354      await patchHelpers($, list =>
355        upsert(list, { id, name: list.find(h => h.id === id)?.name ?? 'helper', mood: 'work', label, since: now }),
356      )
357      const ran = await next(e)
358      if (ran.deny === undefined && ran.isError === true) {
359        const failedAt = await $.clock.now()
360        await patchHelpers($, list =>
361          list.map(h => (h.id === id ? { ...h, mood: 'oops', label: `${e.tool} failed`, since: failedAt } : h)),
362        )
363      }
364
365      return ran
366    }
367
368    await $.state.set({ plugin: 'bunny', key: 'feeling' } as const, { mood: 'work', label, since: now })
369    const ran = await next(e)
370
371    if (ran.deny === undefined && ran.isError === true) {
372      await $.state.set({ plugin: 'bunny', key: 'feeling' } as const, { mood: 'oops', label: `${e.tool} failed`, since: await $.clock.now() })
373    }
374
375    return ran
376  })
377
378  on('turn.complete', async ($, e, next) => {
379    const now = await $.clock.now()
380    const id = e.agentId
381
382    if (id !== undefined) {
383      const mood: Mood = e.reason === 'answer' ? 'happy' : 'oops'
384      await patchHelpers($, list => list.map(h => (h.id === id ? { ...h, mood, label: 'done', since: now } : h)))
385
386      return next(e)
387    }
388
389    const seconds = Math.round(e.durationMs / 1000)
390    const feeling: Feeling =
391      e.reason === 'answer'
392        ? { mood: 'happy', label: `all done in ${seconds}s!`, since: now }
393        : e.reason === 'aborted'
394          ? { mood: 'idle', label: '', since: now }
395          : { mood: 'oops', label: 'something went wrong', since: now }
396    await $.state.set({ plugin: 'bunny', key: 'feeling' } as const, feeling)
397
398    return next(e)
399  })
400
401  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
402    const hid = await $.state.get({ plugin: 'bunny', key: 'isHidden' } as const)
403    if (e.props.hasSurvey || hid.value === true) {
404      return next(e)
405    }
406
407    const { Box, Text } = $.ui.resolve(e)
408    const feeling = await $.state.get({ plugin: 'bunny', key: 'feeling' } as const)
409    const frame = await $.state.get({ plugin: 'bunny', key: 'frame' } as const)
410    const p: Feeling = feeling.value ?? { mood: 'idle', label: '', since: 0 }
411    const f = (frame.value ?? 0) % 2
412    if (e.surface === 'terminal') {
413      const { Raster } = $.ui.resolve(e)
414      const styled = await $.state.get({ plugin: 'bunny', key: 'style' } as const)
415      const name: StyleName = styled.value ?? 'chibi'
416      const style = STYLES[name]
417      const n = frame.value ?? 0
418      const zzz = p.mood === 'sleep' ? (n % 2 ? 'z' : 'Z') : ''
419      const crew = await $.state.get({ plugin: 'bunny', key: 'helpers' } as const)
420      const helpers = crew.value ?? []
421      const shown = helpers.slice(-MAX_HELPERS)
422
423      return (
424        <Box flexDirection="row">
425          <Raster
426            key="bunny"
427            columns={style.rows[0]!.length}
428            rows={(style.rows.length + 1) / 2}
429            cells={spriteCells(style, p.mood, n)}
430          />
431          <Box flexDirection="column">
432            <Text dimColor>  {zzz}{p.label}</Text>
433            {shown.length > 0 && (
434              <Box flexDirection="row">
435                <Text>  </Text>
436                {shown.map((helper, i) => {
437                  const odd = (n + i) % 2 === 1
438                  const hop = odd && helper.mood !== 'oops' ? ' ' : ''
439                  const color = HELPER_COLORS[helper.mood]
440
441                  return (
442                    <Box key={`helper-${helper.id}`} flexDirection="row">
443                      <Text>   </Text>
444                      <Box flexDirection="column">
445                        <Text color={color}>{hop}{EARS_HELPER}</Text>
446                        <Text color={color}>{hop}{helperFace(helper.mood, odd)}</Text>
447                        <Text dimColor>{helper.name.length > 7 ? `${helper.name.slice(0, 6)}…` : helper.name}</Text>
448                      </Box>
449                    </Box>
450                  )
451                })}
452                {helpers.length > shown.length && <Text dimColor> +{helpers.length - shown.length}</Text>}
453              </Box>
454            )}
455          </Box>
456        </Box>
457      )
458    }
459
460    const crew = await $.state.get({ plugin: 'bunny', key: 'helpers' } as const)
461    const extra = (crew.value ?? []).length > 0 ? ` (+${(crew.value ?? []).length} helpers)` : ''
462    const hop = p.mood === 'work' || p.mood === 'happy' ? (f ? '  ' : '') : ''
463    const color = COLORS[p.mood]
464    const zzz = p.mood === 'sleep' ? (f ? ' z' : ' Z') : ''
465    const bottom = f ? ` (")_(")` : `(")_(")`
466
467    return (
468      <Box flexDirection="row">
469        <Box flexDirection="column">
470          <Text color={color}>{hop}{EARS[p.mood][f]}</Text>
471          <Text color={color}>{hop}{FACES[p.mood][f]}{zzz}</Text>
472          <Text color={color}>{hop}{bottom}</Text>
473        </Box>
474        <Text dimColor>  {p.label}{extra}</Text>
475      </Box>
476    )
477  })
478}
479
types/index.d.ts 11 lines
1export type Mood = 'idle' | 'sleep' | 'think' | 'work' | 'oops' | 'happy'
2export type StyleName = 'chibi' | 'sit' | 'loaf'
3export type Helper = { id: string; name: string; mood: Mood; label: string; since: number }
4export type Feeling = { mood: Mood; label: string; since: number }
5
6declare module 'claude-code' {
7  interface PluginState {
8    bunny: { feeling: Feeling; frame: number; isHidden: boolean; style: StyleName; helpers: Helper[] }
9  }
10}
11