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

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.
register.ts · ✏️ editing · 📝 writing · ⚙️ running npm test · 🔍 searching · 🌐 browsing · 🤝 delegating to a subagent · 🙋 asking you a questiongit commit, napping after a quiet spell🎉 Byte reached level 4!/clear and gains XP from every session( o.o ) Byte 📖 reading…) under 44 columns. It steps aside for surveys./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.
| Command | What it does |
|---|---|
/buddy | Stats card: level, XP bar, lifetime tool calls, turns, commits, green test runs, oopsies |
/buddy name Mochi | Renames your pet (/buddy name alone goes back to the configured name) |
/buddy species robot | Swaps species: cat, dog, blob, robot, ghost, duck |
/buddy hide · /buddy show | Tucks the pet away, or brings it back |
Set these in /config, or under pluginConfigs in settings.json.
| Option | Default | What it does |
|---|---|---|
name | Byte | Your pet's name |
species | cat | cat, dog, blob, robot, ghost or duck |
sleepAfterSeconds | 120 | Seconds without activity before your pet naps (10 to 3600) |
| Event / API | Why |
|---|---|
ui.render on AbovePrompt | Draws the pet, its XP bar and what it's up to |
turn.start · turn.complete | Thinking while a turn runs; +5 XP and back to idle when it ends |
tool.call | A tool-specific line while it runs; after next(e), reads the result for errors, denies, green tests and commits |
$.state | The mood, the animation frame and the pet, so the band redraws only when they change |
$.clock.every · $.clock.after | The animation while working, the nap timer and fleeting moods |
$.store | XP and lifetime counts, read fresh before each write so parallel sessions add up |
classic.SessionStart | Reloads 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.
claude plugin test mods/fun/buddy # 33 tests
hooks/register.tsx 290 lines1import { 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}
290hooks/art.ts 74 lines1// 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}
74hooks/pet.ts 133 lines1// 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}
133types/index.d.ts 21 lines1export 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