A virtual pet above the Claude Code prompt that grows as Claude works: passing tests, commits and edits grant XP and mood, failing tests cost mood, idling…

A virtual pet above the Claude Code prompt, fed by Claude's actual work. Passing tests, commits and edits grant XP and mood; failing tests cost mood; idling drains it. It advances egg → baby → kid → adult → legend (level = √(xp/10)). Click it to pet it. Zero tokens: the mod reads Claude's tool calls itself and never asks the model anything.
Split out of sezaakgun/cc-arcade (MIT, by Seza Akgün). Want the pet plus nine games and a picker in one plugin? Install
games/cc-arcadeinstead.
/pet shows it above the prompt, /pet stop hides it. It keeps eating while hidden.npx claude-code-templates@latest --mod games/pet
claude
It is written to .claude/skills/pet/, which Claude Code auto-loads as pet@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/pet.
claude -p, the desktop app or mobile.hooks/register.tsx 82 lines1/* @jsx h */
2import type { Register } from 'claude-code'
3import { feed, level, newPet, petEvent, stage, type Pet } from './games/pet.ts'
4
5// A virtual pet above the Claude Code prompt, split out of cc-arcade (https://github.com/sezaakgun/cc-arcade,
6// MIT, by Seza Akgün). It is fed by Claude's actual work: passing tests, commits and edits grant XP and
7// mood, failing tests cost mood, idling drains it; it grows egg → baby → kid → adult → legend. `/pet`
8// shows it, `/pet stop` hides it. The pet lives in $.store, so sessions side by side feed the same one.
9
10let open = false
11let pet: Pet | undefined
12
13const isPet = (value: unknown): value is Pet => typeof value === 'object' && value !== null && typeof (value as Pet).xp === 'number'
14
15export const register: Register = on => {
16 on('session.start', async ($, e, next) => {
17 const r = await next(e)
18 const saved = await $.store.get('pet').catch(err => { $.ui.log(`pet: store read failed: ${err}`); return undefined })
19 pet = isPet(saved) ? saved : newPet(await $.clock.now())
20 if (!isPet(saved)) await $.store.set('pet', pet).catch(err => $.ui.log(`pet: store write failed: ${err}`))
21 await $.command.register({
22 name: 'pet',
23 description: 'A pet above the prompt that grows as Claude tests, commits and edits (stop hides it)',
24 argumentHint: '[stop]',
25 immediate: true,
26 }).catch(err => $.ui.log(`pet: /pet not registered: ${err}`))
27 return r
28 })
29
30 on('command.run', { command: 'pet' }, async ($, e) => {
31 const arg = e.args.trim().toLowerCase()
32 open = !(arg === 'stop' || arg === 'close')
33 $.ui.invalidate('ui.render')
34 if (!open) return { text: 'pet hidden' }
35 const p = pet
36 return { text: p ? `pet: level ${level(p.xp)}, ${stage(p.xp)} · click it to pet it · /pet stop hides it` : 'pet: click it to pet it' }
37 })
38
39 // the pet feeds on work: a test run passing or failing, a commit, an edit. It is re-read from the
40 // store first, so sessions running side by side feed the same pet instead of overwriting it.
41 on('tool.call', async ($, e, next) => {
42 const r = await next(e)
43 const command = (e as { command?: unknown }).command
44 const event = petEvent(e.tool, typeof command === 'string' ? command : undefined, 'deny' in r ? undefined : !r.isError)
45 if (!event) return r
46 const saved = await $.store.get('pet').catch(() => undefined)
47 const now = await $.clock.now()
48 const current = isPet(saved) ? saved : pet ?? newPet(now)
49 const fed = feed(current, event, now)
50 pet = fed
51 await $.store.set('pet', fed).catch(err => $.ui.log(`pet: store write failed: ${err}`))
52 if (level(fed.xp) > level(current.xp)) {
53 const grew = stage(fed.xp) !== stage(current.xp) ? `, and is now ${stage(fed.xp) === 'adult' ? 'an adult' : `a ${stage(fed.xp)}`}` : ''
54 $.ui.toast(`pet: reached level ${level(fed.xp)}${grew}`)
55 }
56 if (open) $.ui.invalidate('ui.render')
57 return r
58 })
59
60 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
61 if (!open || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
62 const { Box, Button, Client } = $.ui.resolve(e)
63 // the band's own width: the transcript column's while a Pane is docked beside it
64 const cols = e.props.bodyColumns || (e.viewport?.columns ?? 80)
65 // $.clock.now() is a dispatch (a Promise): resolve it before it becomes a prop
66 const now = await $.clock.now()
67 const close = () => {
68 open = false
69 $.ui.invalidate('ui.render')
70 }
71 return (
72 <Box flexDirection="column">
73 <Box flexDirection="row" columnGap={1}>
74 <Button key="pet:close" label="close" onPress={close} />
75 </Box>
76 <Client key="board:pet" module="./boards/pet.tsx" width={cols} props={{ pet, now }} />
77 {await next(e)}
78 </Box>
79 )
80 })
81}
82hooks/games/pet.ts 65 lines1// The pet: pure logic. It grows with the work Claude does in any session: tests passing, commits
2// and edits give it xp and lift its mood; failing tests hurt its mood; idle time slowly bores it.
3// The hooks module feeds it from tool calls and keeps it in $.store; ../boards/pet.tsx draws it.
4
5export type PetEvent = 'test-pass' | 'test-fail' | 'commit' | 'edit'
6export type Pet = {
7 xp: number
8 mood: number
9 // when it was last fed, and when it hatched (ms since the epoch)
10 at: number
11 born: number
12 tests: number; fails: number; commits: number; edits: number
13 last?: PetEvent
14}
15
16const XP: Record<PetEvent, number> = { 'test-pass': 10, 'test-fail': 2, commit: 15, edit: 1 }
17const MOOD: Record<PetEvent, number> = { 'test-pass': 8, 'test-fail': -12, commit: 10, edit: 1 }
18const BORED = 30
19
20export const newPet = (now: number): Pet => ({ xp: 0, mood: 70, at: now, born: now, tests: 0, fails: 0, commits: 0, edits: 0 })
21
22// test runners across common stacks; a failing run is a tool call that ended in error
23const TEST_COMMAND = /\b(bun|npm|pnpm|yarn)\s+(run\s+)?test\b|\bnpx\s+(jest|vitest|mocha)\b|\b(pytest|jest|vitest|rspec|phpunit|mocha)\b|\bgo\s+test\b|\bcargo\s+test\b|\bmake\s+test\b|\b(mvn|gradle|gradlew|sbt)\b.*\btest\b/
24
25// what a finished tool call means for the pet; `ok` is undefined when the call was denied
26export function petEvent(tool: string, command: string | undefined, ok: boolean | undefined): PetEvent | undefined {
27 if (ok === undefined) return undefined
28 if (tool === 'Bash' && command) {
29 if (TEST_COMMAND.test(command)) return ok ? 'test-pass' : 'test-fail'
30 if (ok && /\bgit\s+commit\b/.test(command) && !/--dry-run\b/.test(command)) return 'commit'
31 return undefined
32 }
33 if (ok && (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit')) return 'edit'
34 return undefined
35}
36
37// idle, mood sinks a point every ten minutes, down to bored; below that only failures take it
38export function moodAt(p: Pet, now: number): number {
39 if (p.mood <= BORED) return p.mood
40 return Math.max(BORED, p.mood - Math.floor(Math.max(0, now - p.at) / 600_000))
41}
42
43export function feed(p: Pet, event: PetEvent, now: number): Pet {
44 return {
45 ...p,
46 xp: p.xp + XP[event],
47 mood: Math.max(0, Math.min(100, moodAt(p, now) + MOOD[event])),
48 at: now,
49 last: event,
50 tests: p.tests + (event === 'test-pass' ? 1 : 0),
51 fails: p.fails + (event === 'test-fail' ? 1 : 0),
52 commits: p.commits + (event === 'commit' ? 1 : 0),
53 edits: p.edits + (event === 'edit' ? 1 : 0),
54 }
55}
56
57// level n needs 10·n² xp: level 1 at 10, 2 at 40, 3 at 90, 6 at 360, 10 at 1000
58export const level = (xp: number) => Math.floor(Math.sqrt(xp / 10))
59export type Stage = 'egg' | 'baby' | 'kid' | 'adult' | 'legend'
60export const stage = (xp: number): Stage => {
61 const l = level(xp)
62 return l >= 10 ? 'legend' : l >= 6 ? 'adult' : l >= 3 ? 'kid' : l >= 1 ? 'baby' : 'egg'
63}
64export const feeling = (mood: number) => (mood >= 70 ? 'happy' : mood >= 35 ? 'okay' : 'sad')
65hooks/boards/pet.tsx 74 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { feeling, level, moodAt, stage, type Pet, type PetEvent, type Stage } from '../games/pet.ts'
4
5// The pet's window. The hooks module owns the pet (it feeds it from tool calls and keeps it in
6// $.store) and passes it in props; this board only draws it, blinks, and shows hearts on a click.
7
8type PetProps = { pet?: Pet; now?: number } | undefined
9type State = { frame: number; petted: number }
10
11const ART: Record<Stage, string[]> = {
12 egg: [' ____ ', ' / \\ ', ' | {e} | ', ' \\____/ '],
13 baby: [' (\\_/) ', ' ( {e} ) ', ' (")(") '],
14 kid: [' /\\_/\\ ', ' ( {e} ) ', ' > ^ < '],
15 adult: [' /\\_____/\\ ', ' ( {e} ) ', ' ( w ) ', ' \\_______/ '],
16 legend: [' \\^^^/ ', ' /\\_____/\\ ', ' ( {e} ) ', ' ( w ) ', ' \\_______/ '],
17}
18const EYES = { happy: '^.^', okay: 'o.o', sad: 'T.T' }
19const COLORS = { happy: 'greenBright', okay: 'yellow', sad: 'red' }
20const FED_BY: Record<PetEvent, string> = { 'test-pass': 'passing tests', 'test-fail': 'failing tests (ouch)', commit: 'a commit', edit: 'an edit' }
21
22const bar = (value: number) => {
23 const filled = Math.round(Math.max(0, Math.min(100, value)) / 10)
24 return '█'.repeat(filled) + '░'.repeat(10 - filled)
25}
26const ago = (ms: number) => {
27 const min = Math.floor(ms / 60_000)
28 return min < 1 ? 'just now' : min < 60 ? `${min} min ago` : min < 1440 ? `${Math.floor(min / 60)} h ago` : `${Math.floor(min / 1440)} days ago`
29}
30
31export default function PetBoard(props: PetProps, surface: ClientSurface<State>) {
32 const { Box, Text } = surface.elements
33
34 if (surface.state === undefined) {
35 surface.setState({ frame: 0, petted: 0 })
36 surface.every(200, () => {
37 const s = surface.state
38 if (s) surface.setState({ frame: s.frame + 1, petted: Math.max(0, s.petted - 1) })
39 })
40 surface.onPointer(ev => {
41 const s = surface.state
42 if (s && ev.type === 'down') surface.setState({ ...s, petted: 8 })
43 })
44 }
45
46 const s = surface.state
47 const pet = props?.pet
48 if (!pet) return <Text dimColor>pet · waiting for the plugin to load it</Text>
49
50 const now = props?.now ?? pet.at
51 const mood = moodAt(pet, now)
52 const feel = feeling(mood)
53 const lvl = level(pet.xp)
54 // a blink for one frame in every five seconds
55 const eyes = (s?.frame ?? 1) % 25 === 0 ? '-.-' : EYES[feel]
56 const art = ART[stage(pet.xp)].map(line => line.replace('{e}', eyes))
57 return (
58 <Box flexDirection="row" borderStyle="round" borderColor={COLORS[feel]} paddingX={1}>
59 <Box flexDirection="column" marginRight={3}>
60 <Text color="magentaBright">{s?.petted ? ' ♥ ♥ ' : ' '}</Text>
61 {art.map(line => <Text color={COLORS[feel]}>{line}</Text>)}
62 </Box>
63 <Box flexDirection="column">
64 <Text bold>{`${stage(pet.xp)} · level ${lvl}`}</Text>
65 <Text dimColor>{`${pet.xp} xp · next level at ${10 * (lvl + 1) ** 2}`}</Text>
66 <Text>{'mood '}<Text color={COLORS[feel]}>{bar(mood)}</Text>{` ${feel}`}</Text>
67 <Text dimColor>{`tests passed ${pet.tests} · failed ${pet.fails} · commits ${pet.commits} · edits ${pet.edits}`}</Text>
68 <Text dimColor>{pet.last ? `last fed by ${FED_BY[pet.last]}, ${ago(now - pet.at)}` : 'it eats passing tests, commits and edits'}</Text>
69 <Text dimColor>{`hatched ${ago(now - pet.born)} · click to pet · /arcade stop closes`}</Text>
70 </Box>
71 </Box>
72 )
73}
74