SLOPSHOPPER

buddy

A tiny ASCII pet that lives above your prompt, reacts to everything Claude does, and levels up as you ship.

newbandguardcommandtoasttimer
★ 1v1.0.0MITupdated 2026-10-04Singh-AP/awesome-claude-mods/mods/fun/buddy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · buddy
› 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 › /buddy ⎿ buddy: Byte the cat · level 1 ⎿ buddy: /\_/\ ♪ ████░░░░░░░░░░░░ 14/50 XP to level 2 ⎿ buddy: ( ^w^ ) 9 tool calls · 1 turn · 0 commits ⎿ buddy: > ^ < 0 green test runs · 1 oopsie /\_/\ ! Byte Lv 1 cat ( OoO ) ██████░░░░░░░░░░░░░░ 14/50 XP > ^ < 😵 Bash failed ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
/\_/\ ! Byte Lv 1 cat ( OoO ) ██████░░░░░░░░░░░░░░ 14/50 XP > ^ < 😵 Bash failed ⟨Claude Code's own drawing⟩
README

🐱 buddy

A tiny ASCII pet that lives above your prompt, reacts to everything Claude does, and levels up as you ship.

<img src="../../../assets/screens/hero.svg" alt="buddy in a real Claude Code session" width="100%">

 /\_/\       Byte  Lv 4 cat
( o_o )      ███████████░░░░░░░░░ 412/500 XP
 > ^ <       ⚙️ running npm test

 /\_/\  ♪    Byte  Lv 4 cat
( ^w^ )      ███████████░░░░░░░░░ 423/500 XP
 > ^ <       ✅ tests are green!

 /\_/\  zZ   Byte  Lv 4 cat
( -.- )      ███████████░░░░░░░░░ 428/500 XP
 > ^ <       zZ… napping

Long agent runs are mostly you watching a spinner. buddy gives that wait a face: a little pet in the band above your prompt that reads files when Claude reads, types when Claude edits, panics when a command fails, throws a party when you commit, and dozes off when you walk away.

Features

  • Reacts to real events: 📖 reading register.ts · ✏️ editing · 📝 writing · ⚙️ running npm test · 🔍 searching · 🌐 browsing · 🤝 delegating to a subagent · 🙋 asking you a question
  • Moods: thinking while a turn runs, startled when a tool fails, wary when a guard blocks a command, happy on a green test run (jest, vitest, pytest, cargo, go, bun, …), party on a successful git commit, napping after a quiet spell
  • Six species: cat, dog, blob, robot, ghost and duck, each with a face for every mood
  • XP and levels: +1 per tool call, +5 per finished turn, +10 per green test run, +20 per commit. Levels get further apart, and a toast announces each one: 🎉 Byte reached level 4!
  • Persistent: XP, lifetime counts, name and species live in the mod's store, so your pet survives restarts and /clear and gains XP from every session
  • Cheap: the 2-frame animation runs only while Claude is working. An idle pet keeps one pending nap timer, and a sleeping pet runs nothing.
  • Fits anywhere: three rows on a wide band, one line (( o.o ) Byte 📖 reading…) under 44 columns. It steps aside for surveys.

Install

/plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install buddy@awesome-claude-mods

Requires Claude Code 2.1.287 or later. The band shows in the terminal and the Desktop app.

Commands

CommandWhat it does
/buddyStats card: level, XP bar, lifetime tool calls, turns, commits, green test runs, oopsies
/buddy name MochiRenames your pet (/buddy name alone goes back to the configured name)
/buddy species robotSwaps species: cat, dog, blob, robot, ghost, duck
/buddy hide · /buddy showTucks the pet away, or brings it back

Configuration

Set these in /config, or under pluginConfigs in settings.json.

OptionDefaultWhat it does
nameByteYour pet's name
speciescatcat, dog, blob, robot, ghost or duck
sleepAfterSeconds120Seconds without activity before your pet naps (10 to 3600)

How it works

Event / APIWhy
ui.render on AbovePromptDraws the pet, its XP bar and what it's up to
turn.start · turn.completeThinking while a turn runs; +5 XP and back to idle when it ends
tool.callA tool-specific line while it runs; after next(e), reads the result for errors, denies, green tests and commits
$.stateThe mood, the animation frame and the pet, so the band redraws only when they change
$.clock.every · $.clock.afterThe animation while working, the nap timer and fleeting moods
$.storeXP and lifetime counts, read fresh before each write so parallel sessions add up
classic.SessionStartReloads the pet after /clear resets $.state

The art (hooks/art.ts) and the rules (hooks/pet.ts) are pure TypeScript with no $. Add a species by adding three rows to ART.

Test it

claude plugin test mods/fun/buddy   # 33 tests

Limitations

  • Green tests and commits are recognized from the command and its output, so an unusual test runner may not count. A failing run never counts as green.
  • XP earned in the last moments before a crash or kill may be lost. It's written to the store at the end of each turn and at session end.
  • Emoji widths vary between terminals, which can shift the activity line by a cell.
Source 4 files
hooks/register.tsx 290 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Look, Mood, Pet, Species } from '../types'
5import { drawFace, drawPet, isSpecies, SPECIES } from './art'
6import { isFleeting, levelOf, moodWord, outcomeOf, toolLine, XP, xpBar, xpForLevel } from './pet'
7
8const look = atom({ plugin: 'buddy', key: 'look' } as const, { mood: 'idle', line: '' })
9const frame = atom({ plugin: 'buddy', key: 'frame' } as const, 0)
10const pet = atom({ plugin: 'buddy', key: 'pet' } as const, { name: 'Byte', species: 'cat', xp: 0 })
11const isHidden = atom({ plugin: 'buddy', key: 'isHidden' } as const, false)
12
13type Stats = { toolCalls: number; turns: number; commits: number; greenTests: number; errors: number }
14const NO_STATS: Stats = { toolCalls: 0, turns: 0, commits: 0, greenTests: 0, errors: 0 }
15
16const count = (n: number, noun: string) => `${n.toLocaleString('en-US')} ${noun}${n === 1 ? '' : 's'}`
17
18const COLORS: Record<Species, string> = {
19  cat: '#f5a623',
20  dog: '#c08552',
21  blob: '#7ed957',
22  robot: '#4fc3f7',
23  ghost: '#b0bec5',
24  duck: '#ffd54f',
25}
26
27// The module's own clockwork. A reload drops every timer with the old module.
28let defaults: { name: string; species: Species } = { name: 'Byte', species: 'cat' }
29let sleepAfterMs = 120_000
30let isTurnRunning = false
31let animation: Timer | undefined
32let napping: Timer | undefined
33let settling: Timer | undefined
34// XP and counts earned since the last write to the store.
35let pendingXp = 0
36let pending: Stats = { ...NO_STATS }
37
38function stopAnimation() {
39  animation?.cancel()
40  animation = undefined
41}
42
43/** Shows a mood. Only working and partying animate, so an idle pet runs no loop. */
44async function show($: EngineInterface, mood: Mood, line: string) {
45  await update($, look, () => ({ mood, line }))
46  if (mood === 'work' || mood === 'party') {
47    if (animation === undefined) animation = $.clock.every(500, () => void update($, frame, f => (f + 1) % 2))
48  } else {
49    stopAnimation()
50  }
51}
52
53/** After a quiet spell the pet falls asleep: one pending timer, no polling. */
54function napLater($: EngineInterface) {
55  napping?.cancel()
56  napping = $.clock.after(sleepAfterMs, () => {
57    napping = undefined
58    void show($, 'sleep', '')
59  })
60}
61
62function wake() {
63  napping?.cancel()
64  napping = undefined
65}
66
67/** What the pet goes back to once a fleeting mood has had its moment. */
68async function settle($: EngineInterface) {
69  settling = undefined
70  if (isTurnRunning) return show($, 'think', '')
71  await show($, 'idle', '')
72  napLater($)
73}
74
75/** A mood that lasts a few seconds, then gives way to what is going on. */
76async function flash($: EngineInterface, mood: Mood, line: string) {
77  settling?.cancel()
78  await show($, mood, line)
79  settling = $.clock.after(3500, () => void settle($))
80}
81
82async function award($: EngineInterface, xp: number, counts: Partial<Stats>) {
83  pendingXp += xp
84  for (const [key, value] of Object.entries(counts) as [keyof Stats, number][]) pending[key] += value
85  const before = (await read($, pet)).xp
86  const now = await update($, pet, p => ({ ...p, xp: p.xp + xp }))
87  const level = levelOf(now.xp)
88  if (level > levelOf(before)) {
89    $.ui.toast(`🎉 ${now.name} reached level ${level}!`)
90    await flash($, 'party', `🎉 level ${level}!`)
91  }
92}
93
94/** Adds what this session earned to the store, read fresh so other sessions' XP counts too. */
95async function flush($: EngineInterface) {
96  if (pendingXp === 0 && Object.values(pending).every(v => v === 0)) return
97  const xp = Number((await $.store.get('xp')) ?? 0) + pendingXp
98  const saved = { ...NO_STATS, ...((await $.store.get('stats')) as Partial<Stats> | undefined) }
99  const stats: Stats = { ...saved }
100  for (const key of Object.keys(pending) as (keyof Stats)[]) stats[key] = saved[key] + pending[key]
101  pendingXp = 0
102  pending = { ...NO_STATS }
103  await $.store.set('xp', xp)
104  await $.store.set('stats', stats)
105  await update($, pet, p => ({ ...p, xp }))
106}
107
108/** Loads who the pet is from the store: at start, and again after /clear wipes $.state. */
109async function loadPet($: EngineInterface) {
110  const name = (await $.store.get('name')) as string | undefined
111  const species = (await $.store.get('species')) as string | undefined
112  const xp = Number((await $.store.get('xp')) ?? 0) + pendingXp
113  const loaded: Pet = {
114    name: name ?? defaults.name,
115    species: species !== undefined && isSpecies(species) ? species : defaults.species,
116    xp,
117  }
118  const hidden = (await $.store.get('hidden')) === true
119  await update($, pet, () => loaded)
120  await update($, isHidden, () => hidden)
121}
122
123export const register: Register = (on, options) => {
124  const species = String(options.species ?? 'cat')
125  defaults = { name: String(options.name ?? 'Byte').trim() || 'Byte', species: isSpecies(species) ? species : 'cat' }
126  sleepAfterMs = Math.max(10, Number(options.sleepAfterSeconds ?? 120)) * 1000
127
128  on('session.start', async ($, e, next) => {
129    await $.command.register({
130      name: 'buddy',
131      description: 'Your pet: stats, or /buddy name <name> · species <kind> · hide · show',
132      argumentHint: '[name <name> | species <kind> | hide | show]',
133    })
134    await loadPet($)
135    napLater($)
136    return next(e)
137  })
138
139  on('classic.SessionStart', async ($, e, next) => {
140    if (e.source === 'clear') await loadPet($)
141    return next(e)
142  })
143
144  on('turn.start', async ($, e, next) => {
145    isTurnRunning = true
146    wake()
147    if (settling === undefined) await show($, 'think', '')
148    return next(e)
149  })
150
151  on('tool.call', async ($, e, next) => {
152    wake()
153    const input = e as unknown as Record<string, unknown>
154    if (settling === undefined) await show($, 'work', toolLine(e.tool, input))
155    const ran = await next(e)
156    const outcome = outcomeOf(e.tool, input, ran)
157    await award($, outcome.xp, {
158      toolCalls: 1,
159      commits: outcome.commits,
160      greenTests: outcome.greenTests,
161      errors: outcome.errors,
162    })
163    if (isFleeting(outcome.mood)) await flash($, outcome.mood, outcome.line)
164    else if (settling === undefined && isTurnRunning) await show($, 'think', '')
165    return ran
166  })
167
168  on('turn.complete', async ($, e, next) => {
169    if (e.agentId !== undefined) return next(e)
170    isTurnRunning = false
171    await award($, XP.turn, { turns: 1 })
172    await flush($)
173    if (settling === undefined) {
174      await show($, 'idle', '')
175      napLater($)
176    }
177    return next(e)
178  })
179
180  on('session.end', async ($, e, next) => {
181    await flush($)
182    return next(e)
183  })
184
185  on('command.run', { command: 'buddy' }, async ($, e) => {
186    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
187    const value = rest.join(' ').trim()
188    const current = await read($, pet)
189
190    if (verb === 'name') {
191      if (value === '') {
192        await $.store.delete('name')
193        await update($, pet, p => ({ ...p, name: defaults.name }))
194        return { text: `Your ${current.species} is called ${defaults.name} again.` }
195      }
196      const name = value.slice(0, 24)
197      await $.store.set('name', name)
198      await update($, pet, p => ({ ...p, name }))
199      await flash($, 'happy', `💖 I'm ${name} now!`)
200      return { text: `Your ${current.species} is now called ${name}.` }
201    }
202    if (verb === 'species') {
203      if (!isSpecies(value)) return { text: `Pick one of: ${SPECIES.join(', ')}.` }
204      await $.store.set('species', value)
205      await update($, pet, p => ({ ...p, species: value }))
206      await flash($, 'happy', `✨ ta-da!`)
207      return { text: `${current.name} is a ${value} now.` }
208    }
209    if (verb === 'hide' || verb === 'show') {
210      const hide = verb === 'hide'
211      await $.store.set('hidden', hide)
212      await update($, isHidden, () => hide)
213      return { text: hide ? `${current.name} is hiding. /buddy show brings them back.` : `${current.name} is back!` }
214    }
215    if (verb !== '') return { text: 'Usage: /buddy · /buddy name <name> · /buddy species <kind> · /buddy hide · /buddy show' }
216
217    await flush($)
218    const now = await read($, pet)
219    const stats = { ...NO_STATS, ...((await $.store.get('stats')) as Partial<Stats> | undefined) }
220    const level = levelOf(now.xp)
221    const need = xpForLevel(level + 1)
222    const art = drawPet(now.species, 'happy', 0)
223    const lines = [
224      `${xpBar(now.xp, 16)} ${now.xp}/${need} XP to level ${level + 1}`,
225      `${count(stats.toolCalls, 'tool call')} · ${count(stats.turns, 'turn')} · ${count(stats.commits, 'commit')}`,
226      `${count(stats.greenTests, 'green test run')} · ${count(stats.errors, 'oopsie')}`,
227    ]
228    const title = `${now.name} the ${now.species} · level ${level}`
229    return { text: [title, ...art.map((row, i) => `${row}  ${lines[i] ?? ''}`)].join('\n') }
230  })
231
232  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
233    if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
234    const { Box, Text } = $.ui.resolve(e)
235    const now = await read($, pet)
236    const seen = await read($, look)
237    const mood: Mood = seen.mood === 'idle' && e.props.isWorking ? 'think' : seen.mood
238    const line = seen.line || moodWord(mood)
239    const level = levelOf(now.xp)
240    const color = COLORS[now.species]
241    // The band is shared: draw the mods beneath this one under the pet.
242    const below = await next(e)
243
244    if (e.props.bodyColumns < 44) {
245      return (
246        <Box flexDirection="column">
247          <Box flexDirection="row" gap={1}>
248            <Text color={color}>{drawFace(now.species, mood)}</Text>
249            <Text bold>{now.name}</Text>
250            <Text dimColor wrap="truncate-end">
251              {line}
252            </Text>
253          </Box>
254          {below}
255        </Box>
256      )
257    }
258
259    const rows = drawPet(now.species, mood, await read($, frame))
260    const barWidth = Math.min(20, Math.max(8, e.props.bodyColumns - 48))
261    return (
262      <Box flexDirection="column">
263        <Box flexDirection="row" gap={1}>
264          <Box flexDirection="column">
265            {rows.map(row => (
266              <Text color={color}>{row}</Text>
267            ))}
268          </Box>
269          <Box flexDirection="column" flexShrink={1}>
270            <Box flexDirection="row" gap={1}>
271              <Text bold>{now.name}</Text>
272              <Text dimColor>
273                Lv {level} {now.species}
274              </Text>
275            </Box>
276            <Box flexDirection="row" gap={1}>
277              <Text color="#ffd54f">{xpBar(now.xp, barWidth)}</Text>
278              <Text dimColor>
279                {now.xp}/{xpForLevel(level + 1)} XP
280              </Text>
281            </Box>
282            <Text wrap="truncate-end">{line}</Text>
283          </Box>
284        </Box>
285        {below}
286      </Box>
287    )
288  })
289}
290
hooks/art.ts 74 lines
1// The pets, drawn in plain text: no `$`, so tests import it directly.
2
3import type { Mood, Species } from '../types'
4
5export const SPECIES: readonly Species[] = ['cat', 'dog', 'blob', 'robot', 'ghost', 'duck']
6
7/** Eyes and mouth for each mood; every species draws its face from these. */
8type Face = { left: string; right: string; mouth: string }
9
10const FACES: Record<Mood, Face> = {
11  idle: { left: 'o', right: 'o', mouth: '.' },
12  sleep: { left: '-', right: '-', mouth: '.' },
13  think: { left: 'o', right: 'O', mouth: '~' },
14  work: { left: 'o', right: 'o', mouth: '_' },
15  startled: { left: 'O', right: 'O', mouth: 'o' },
16  wary: { left: '¬', right: '¬', mouth: '_' },
17  happy: { left: '^', right: '^', mouth: 'w' },
18  party: { left: '^', right: '^', mouth: 'o' },
19}
20
21/**
22 * Three rows per species. `{L}`, `{M}` and `{R}` are the left eye, the mouth
23 * and the right eye; the last row has two frames, swapped while the pet works.
24 */
25const ART: Record<Species, { top: string; face: string; feet: readonly [string, string] }> = {
26  cat: { top: ' /\\_/\\ ', face: '( {L}{M}{R} )', feet: [' > ^ < ', ' < ^ > '] },
27  dog: { top: ' /\\_ _/\\ ', face: ' ( {L}{M}{R} ) ', feet: ['  \\_U_/  ', '  \\_u_/  '] },
28  blob: { top: '  .---.  ', face: ' ( {L}{M}{R} ) ', feet: ["  `---'  ", "  '---`  "] },
29  robot: { top: '   _T_   ', face: ' [ {L}{M}{R} ] ', feet: ['  /|_|\\  ', '  \\|_|/  '] },
30  ghost: { top: '  .-"-.  ', face: ' ( {L}{M}{R} ) ', feet: ['  ^v^v^  ', '  v^v^v  '] },
31  duck: { top: '   __    ', face: ' <({L} )__ ', feet: ['  ( ._> / ', '  ( ._> \\ '] },
32}
33
34/** Extra glyphs drawn to the right of the face for some moods. */
35const MARKS: Partial<Record<Mood, string>> = {
36  sleep: 'zZ',
37  think: '?',
38  startled: '!',
39  wary: '…',
40  happy: '♪',
41  party: '*',
42}
43
44export function isSpecies(value: string): value is Species {
45  return (SPECIES as readonly string[]).includes(value)
46}
47
48/** The pet as three rows of equal width, for a mood and an animation frame. */
49export function drawPet(species: Species, mood: Mood, frame: number): string[] {
50  const art = ART[species]
51  const face = FACES[mood]
52  const feet = mood === 'work' || mood === 'party' ? art.feet[frame % 2]! : art.feet[0]
53  const mark = MARKS[mood] ?? ''
54  const rows = [
55    art.top,
56    art.face.replace('{L}', face.left).replace('{M}', face.mouth).replace('{R}', face.right),
57    feet,
58  ]
59  const width = Math.max(...rows.map(r => r.length))
60  // The mark sits beside the top row so the face itself never shifts.
61  return rows.map((r, i) => (i === 0 ? `${r.padEnd(width)} ${mark.padEnd(2)}` : `${r.padEnd(width)}   `))
62}
63
64/** One-line form for narrow bands: just the face. */
65export function drawFace(species: Species, mood: Mood): string {
66  const face = FACES[mood]
67  const art = ART[species]
68  return art.face
69    .replace('{L}', face.left)
70    .replace('{M}', face.mouth)
71    .replace('{R}', face.right)
72    .trim()
73}
74
hooks/pet.ts 133 lines
1// What the pet makes of each event: no `$`, so tests import it directly.
2
3import type { Mood } from '../types'
4
5export const XP = { toolCall: 1, turn: 5, greenTests: 10, commit: 20 } as const
6
7/** Total XP needed to reach `level` (level 1 is free): 0, 50, 150, 300, 500... */
8export function xpForLevel(level: number): number {
9  return 25 * level * (level - 1)
10}
11
12export function levelOf(xp: number): number {
13  let level = 1
14  while (xpForLevel(level + 1) <= xp) level++
15  return level
16}
17
18/** How far into the current level, from 0 to 1. */
19export function progress(xp: number): number {
20  const level = levelOf(xp)
21  const from = xpForLevel(level)
22  const to = xpForLevel(level + 1)
23  return (xp - from) / (to - from)
24}
25
26export function xpBar(xp: number, width: number): string {
27  const filled = Math.round(progress(xp) * width)
28  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
29}
30
31function basename(path: unknown): string {
32  const text = typeof path === 'string' ? path : ''
33  return text.split(/[\\/]/).filter(Boolean).pop() ?? text
34}
35
36function clip(text: string, max: number): string {
37  const one = text.replace(/\s+/g, ' ').trim()
38  return one.length > max ? `${one.slice(0, max - 1)}…` : one
39}
40
41/** The line beside the pet while a tool runs. */
42export function toolLine(tool: string, input: Record<string, unknown>): string {
43  switch (tool) {
44    case 'Read':
45      return `📖 reading ${basename(input.file_path)}`
46    case 'Edit':
47    case 'MultiEdit':
48    case 'NotebookEdit':
49      return `✏️ editing ${basename(input.file_path ?? input.notebook_path)}`
50    case 'Write':
51      return `📝 writing ${basename(input.file_path)}`
52    case 'Bash':
53      return `⚙️ running ${clip(String(input.command ?? ''), 32)}`
54    case 'Grep':
55    case 'Glob':
56      return `🔍 searching ${clip(String(input.pattern ?? ''), 24)}`
57    case 'WebFetch':
58    case 'WebSearch':
59      return '🌐 browsing the web'
60    case 'Agent':
61    case 'Task':
62      return `🤝 delegating ${clip(String(input.description ?? ''), 28)}`.trimEnd()
63    case 'TodoWrite':
64      return '📋 making a plan'
65    case 'AskUserQuestion':
66      return '🙋 has a question for you'
67  }
68  if (tool.startsWith('mcp__')) return `🔌 using ${tool.split('__')[1] ?? 'a tool'}`
69  return `🛠 using ${tool}`
70}
71
72const TEST_COMMAND =
73  /\b(test|tests|jest|vitest|pytest|mocha|ava|tap|rspec|phpunit|ctest|tox|nox|playwright)\b|\b(go|cargo|bun|deno|swift|mix|dotnet|gradle|mvn)\s+test\b|\bnpm\s+(t|test)\b|\bmake\s+(test|check)\b/
74const TEST_PASSED = /\b(pass(ed|es|ing)?|ok)\b|✓|✔/i
75const TEST_FAILED = /\b[1-9]\d*\s+(failed|failing|failures?|errors?)\b|^\s*FAIL\b|\bFAILED\b/m
76
77/** A test command that exited 0 and says it passed. */
78export function isGreenTestRun(command: string, output: string, isError: boolean): boolean {
79  return !isError && TEST_COMMAND.test(command) && TEST_PASSED.test(output) && !TEST_FAILED.test(output)
80}
81
82/** A `git commit` that went through. */
83export function isCommit(command: string, output: string, isError: boolean): boolean {
84  if (isError) return false
85  if (!/\bgit\b(\s+-[Cc]\s+\S+)*\s+commit\b/.test(command) || /--dry-run/.test(command)) return false
86  return !/nothing to commit|no changes added to commit/i.test(output)
87}
88
89/** What happened when a tool finished, and the mood it puts the pet in. */
90export type Outcome = { mood: Mood; line: string; xp: number; commits: number; greenTests: number; errors: number }
91
92export function outcomeOf(
93  tool: string,
94  input: Record<string, unknown>,
95  result: { deny?: string; isError?: boolean; text?: string },
96): Outcome {
97  const base = { xp: XP.toolCall, commits: 0, greenTests: 0, errors: 0 }
98  if (result.deny !== undefined) return { ...base, mood: 'wary', line: `😬 ${tool} was blocked` }
99  const isError = result.isError === true
100  if (isError) return { ...base, errors: 1, mood: 'startled', line: `😵 ${tool} failed` }
101  if (tool === 'Bash') {
102    const command = String(input.command ?? '')
103    const output = result.text ?? ''
104    if (isCommit(command, output, isError)) {
105      return { ...base, xp: base.xp + XP.commit, commits: 1, mood: 'party', line: '🎉 committed!' }
106    }
107    if (isGreenTestRun(command, output, isError)) {
108      return { ...base, xp: base.xp + XP.greenTests, greenTests: 1, mood: 'happy', line: '✅ tests are green!' }
109    }
110  }
111  return { ...base, mood: 'work', line: '' }
112}
113
114/** Moods that show for a moment and then give way to what is going on. */
115export function isFleeting(mood: Mood): boolean {
116  return mood === 'startled' || mood === 'wary' || mood === 'happy' || mood === 'party'
117}
118
119export function moodWord(mood: Mood): string {
120  switch (mood) {
121    case 'sleep':
122      return 'zZ… napping'
123    case 'think':
124      return '💭 thinking'
125    case 'work':
126      return '💪 working'
127    case 'idle':
128      return '👀 waiting for you'
129    default:
130      return ''
131  }
132}
133
types/index.d.ts 21 lines
1export type Mood = 'idle' | 'sleep' | 'think' | 'work' | 'startled' | 'wary' | 'happy' | 'party'
2
3export type Species = 'cat' | 'dog' | 'blob' | 'robot' | 'ghost' | 'duck'
4
5/** What the band shows right now: the pet's mood and the line beside it. */
6export type Look = { mood: Mood; line: string }
7
8/** Who the pet is: the name and species the person picked, and its XP. */
9export type Pet = { name: string; species: Species; xp: number }
10
11declare module 'claude-code' {
12  interface PluginState {
13    buddy: {
14      look: Look
15      frame: number
16      pet: Pet
17      isHidden: boolean
18    }
19  }
20}
21