SLOPSHOPPER

pet

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…

newbandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pet
› 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 › /pet ⎿ pet: pet: level 0, egg · click it to pet it · /pet stop hides it [ close ] ▣ client module ./boards/pet.tsx ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ close ] ▣ client module ./boards/pet.tsx ⟨Claude Code's own drawing⟩
README

pet

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-arcade instead.

Use

  • /pet shows it above the prompt, /pet stop hides it. It keeps eating while hidden.
  • The pet lives in the plugin's store, so sessions running side by side feed the same one.

Install

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.

Requirements

  • Claude Code 2.1.287 or later (mods are on by default from that version).
  • An interactive terminal. Nothing draws in claude -p, the desktop app or mobile.
Source 3 files
hooks/register.tsx 82 lines
1/* @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}
82
hooks/games/pet.ts 65 lines
1// 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')
65
hooks/boards/pet.tsx 74 lines
1/* @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