SLOPSHOPPER

pixel-pet

A pixel slime above the prompt that grows as you work with Claude

newbandguardcommandtoasttimer
v0.1.0no licenseupdated 2026-10-02pierregoutheraud/claude-mods/plugins/pixel-pet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixel-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 ⎿ pixel-pet: Byte is an egg with 1 xp, 0 days old. It grows up at 3 xp. ▄▄▄▄ Byte an egg ▄▀▀▀▀▀▀▄ Byte winces: Bash failed ▄▀▀▀▀▀▀▀▀▄ ███░░░░░░░ 1/3 xp ▀▀▀▀▀▀▀▀▀▀ [ pat ] ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▄▄▄▄ Byte an egg ▄▀▀▀▀▀▀▄ Byte winces: Bash failed ▄▀▀▀▀▀▀▀▀▄ ███░░░░░░░ 1/3 xp ▀▀▀▀▀▀▀▀▀▀ [ pat ] ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀
README

claude-mods

Mods for Claude Code, in the terminal and in the desktop app's Code tab: little games and toys that live above the prompt, written as plugins of function hooks.

The server farm strip

The mods

server-farm

An idle game in the band above the prompt. Racks of servers fill up with data, daemon bots walk over to collect it, and you spend it on more racks, more daemons, overclocking and better hardware (Raspberry Pi, Tower, Blade, GPU rig).

Your real work is the weather:

  • a turn Claude finishes starts a traffic spike: production x2 for 60 seconds, stacking up to 5 minutes
  • passing tests (npm test, vitest, pytest and the like) pay 30 seconds of production
  • a failing tool takes a rack down until a daemon fixes it

The farm keeps earning while you are away, at half rate for up to 8 hours. With several sessions open, one runs the farm and the others mirror it and send it their events.

Commands: /farm opens the shop (keys 1 to 4 buy), /farm stats, /farm hide, /farm show.

pixel-pet

The pixel pet's stages and moods

A pixel slime that hatches from an egg and grows into a teen and then a slime king as you work. It glances around while Claude works, cheers when tests pass, frowns when a tool fails and falls asleep when you leave.

Commands: /pet, /pet pat, /pet name <name>, /pet hide, /pet show.

Both mods draw the band above the prompt, so install one at a time.

Install

/plugin marketplace add pierregoutheraud/claude-mods
/plugin install server-farm@pierre-mods

What the mods touch

They watch turns and tool calls (to see whether a call failed or ran tests, never to change it), add their slash command, draw the band above the prompt and keep their save in their own plugin store. No network, no file access, no processes. claude plugin validate plugins/<name> lists every hook and call a mod makes.

Developing

Load a mod straight from this folder; it hot-reloads when you save:

claude --plugin-dir plugins/server-farm

To load it in every session, the desktop app included, add its absolute path to CLAUDE_CODE_PLUGIN_DIRS in the env block of your Claude Code settings.json. Do not run a dev copy and an installed copy of the same mod at once.

Run the checks before committing (CI runs the same script):

./scripts/check.sh

It validates the marketplace and each plugin, runs each plugin's tests with claude plugin test, and type-checks a plugin once Claude Code has loaded it, which is when its types land in .claude-plugin/types/ (gitignored).

The function hooks API is early access and can change between Claude Code releases. These mods were built and tested on Claude Code 2.1.287, and CI runs against the latest release on every push.

Releasing

Bump version in the plugin's .claude-plugin/plugin.json, commit, tag and push. Users get it with /plugin marketplace update pierre-mods.

Layout

.claude-plugin/marketplace.json   the marketplace: its name and the plugins it lists
plugins/<name>/
  .claude-plugin/plugin.json      the plugin's manifest
  hooks/hooks.json                names the hooks module
  hooks/register.tsx              the hooks: events in, drawing out
  hooks/*.ts                      pure logic and drawing, tested directly
  types/index.d.ts                the plugin's $.state contract
  tests/                          claude plugin test runs these
scripts/check.sh                  validate, test, type-check
Source 3 files
hooks/register.tsx 264 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pet, PetMood } from '../types'
5import { COLUMNS, ROWS, drawPet, kaomoji, nextStageAt, PALETTES, stageLabel, stageOf, stageStart } from './sprites'
6
7const TICK_MS = 500
8const FLASH_MS = 5000
9const SLEEP_AFTER_MS = 10 * 60 * 1000
10const TESTS_XP = 3
11const TEST_COMMAND = /\b(vitest|jest|pytest|mocha|playwright test|go test|cargo test|(npm|pnpm|yarn|bun) (run )?test)\b/
12
13const pet = atom({ plugin: 'pixel-pet', key: 'pet' } as const, null)
14const frame = atom({ plugin: 'pixel-pet', key: 'frame' } as const, 0)
15const flash = atom({ plugin: 'pixel-pet', key: 'flash' } as const, null)
16const isAsleep = atom({ plugin: 'pixel-pet', key: 'isAsleep' } as const, false)
17const isHidden = atom({ plugin: 'pixel-pet', key: 'isHidden' } as const, false)
18
19const isPet = (value: unknown): value is Pet =>
20  typeof value === 'object' &&
21  value !== null &&
22  typeof (value as Pet).name === 'string' &&
23  typeof (value as Pet).xp === 'number' &&
24  typeof (value as Pet).bornAt === 'number'
25
26const xpBar = (xp: number): string => {
27  const stage = stageOf(xp)
28  const next = nextStageAt(stage)
29  if (next === undefined) return `${xp} xp, fully grown`
30
31  const from = stageStart(stage)
32  const filled = Math.round(((xp - from) / (next - from)) * 10)
33
34  return `${'█'.repeat(filled)}${'░'.repeat(10 - filled)} ${xp}/${next} xp`
35}
36
37const moodLine = (name: string, mood: PetMood, frame: number, isEgg: boolean): string => {
38  const dots = '.'.repeat(1 + (frame % 3))
39  switch (mood) {
40    case 'working':
41      return isEgg ? `${name} wobbles${dots}` : `${name} is reading over Claude's shoulder${dots}`
42    case 'sleeping':
43      return `${name} is asleep ${'z'.repeat(1 + (frame % 3))}`
44    case 'idle':
45      return isEgg ? `${name} is an egg. It hatches at 3 xp.` : `${name} is waiting for your next prompt`
46    default:
47      return name
48  }
49}
50
51// When the person or Claude last did something; a hot reload starts it over.
52let lastActiveAt = 0
53
54async function flashMood($: EngineInterface, mood: 'happy' | 'sad', text: string) {
55  const until = (await $.clock.now()) + FLASH_MS
56  await update($, flash, () => ({ mood, text, until }))
57}
58
59async function wake($: EngineInterface) {
60  lastActiveAt = await $.clock.now()
61  if (await read($, isAsleep)) await update($, isAsleep, () => false)
62}
63
64async function pat($: EngineInterface) {
65  await wake($)
66  await flashMood($, 'happy', 'purrs happily')
67}
68
69async function savePet($: EngineInterface, next: Pet) {
70  await update($, pet, () => next)
71  await $.store.set('pet', next)
72}
73
74async function gainXp($: EngineInterface, amount: number) {
75  const before = await read($, pet)
76  if (before === null) return
77
78  const after = { ...before, xp: before.xp + amount }
79  await savePet($, after)
80
81  const grown = stageOf(after.xp)
82  if (grown !== stageOf(before.xp)) {
83    $.ui.toast(`${after.name} evolved into ${stageLabel(grown)}!`)
84    await flashMood($, 'happy', `evolved into ${stageLabel(grown)}!`)
85  }
86}
87
88async function setHidden($: EngineInterface, hidden: boolean) {
89  await update($, isHidden, () => hidden)
90  await $.store.set('isHidden', hidden)
91}
92
93async function tick($: EngineInterface) {
94  const now = await $.clock.now()
95  await update($, frame, n => n + 1)
96
97  const shown = await read($, flash)
98  if (shown !== null && now >= shown.until) await update($, flash, () => null)
99
100  if (now - lastActiveAt >= SLEEP_AFTER_MS && !(await read($, isAsleep))) {
101    await update($, isAsleep, () => true)
102  }
103}
104
105export const register: Register = on => {
106  on('session.start', async ($, e, next) => {
107    const now = await $.clock.now()
108    lastActiveAt = now
109
110    const stored = await $.store.get('pet')
111    const loaded = isPet(stored) ? stored : { name: 'Byte', xp: 0, bornAt: now }
112    await savePet($, loaded)
113
114    const hidden = (await $.store.get('isHidden')) === true
115    await update($, isHidden, () => hidden)
116
117    await $.command.register({
118      name: 'pet',
119      description: 'Check on your pixel pet (/pet, /pet pat, /pet name <name>, /pet hide, /pet show)',
120    })
121
122    // Headless runs draw nothing; a timer there would only keep the process busy.
123    if (e.isInteractive) {
124      $.clock.every(TICK_MS, () => {
125        void tick($).catch(() => undefined)
126      })
127    }
128
129    return next(e)
130  })
131
132  on('turn.start', async ($, e, next) => {
133    await wake($)
134
135    return next(e)
136  })
137
138  on('tool.call', async ($, e, next) => {
139    const ran = await next(e)
140    if (ran.deny !== undefined) return ran
141
142    if (ran.isError === true) {
143      await flashMood($, 'sad', `winces: ${String(e.tool)} failed`)
144    } else if (e.tool === 'Bash' && TEST_COMMAND.test(e.command)) {
145      await flashMood($, 'happy', `loved that: tests passed! (+${TESTS_XP} xp)`)
146      await gainXp($, TESTS_XP)
147    }
148
149    return ran
150  })
151
152  on('turn.complete', async ($, e, next) => {
153    if (e.agentId === undefined) {
154      await wake($)
155      if (e.reason === 'answer') await gainXp($, 1)
156      if (e.reason === 'error') await flashMood($, 'sad', 'got scared by an API error')
157    }
158
159    return next(e)
160  })
161
162  on('command.run', { command: 'pet' }, async ($, e) => {
163    const current = await read($, pet)
164    if (current === null) return { text: 'Your pet has not arrived yet.' }
165
166    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
167    switch (verb) {
168      case '': {
169        await setHidden($, false)
170        const stage = stageOf(current.xp)
171        const next = nextStageAt(stage)
172        const days = Math.floor(((await $.clock.now()) - current.bornAt) / 86_400_000)
173        const growth = next === undefined ? 'It is fully grown.' : `It grows up at ${next} xp.`
174
175        return { text: `${current.name} is ${stageLabel(stage)} with ${current.xp} xp, ${days} days old. ${growth}` }
176      }
177      case 'pat':
178        await pat($)
179
180        return { text: `You pat ${current.name}.` }
181      case 'name': {
182        const name = rest.join(' ').slice(0, 20)
183        if (name === '') return { text: 'Usage: /pet name <name>' }
184        await savePet($, { ...current, name })
185
186        return { text: `Your pet is now called ${name}.` }
187      }
188      case 'hide':
189        await setHidden($, true)
190
191        return { text: `${current.name} is napping out of sight. /pet show brings it back.` }
192      case 'show':
193        await setHidden($, false)
194
195        return { text: `${current.name} is back above the prompt.` }
196      default:
197        return { text: 'Usage: /pet [pat | name <name> | hide | show]' }
198    }
199  })
200
201  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
202    const current = await read($, pet)
203    if (e.props.hasSurvey || current === null || (await read($, isHidden))) return next(e)
204
205    const beat = await read($, frame)
206    const shown = await read($, flash)
207    const asleep = await read($, isAsleep)
208    const mood: PetMood = shown?.mood ?? (e.props.isWorking ? 'working' : asleep ? 'sleeping' : 'idle')
209    const stage = stageOf(current.xp)
210    const line = shown === null ? moodLine(current.name, mood, beat, stage === 'egg') : `${current.name} ${shown.text}`
211    const color = PALETTES[stage].b
212
213    const { Box, Button, Text } = $.ui.resolve(e)
214
215    if (e.props.bodyColumns < 44) {
216      return (
217        <Text wrap="truncate">
218          {kaomoji(stage, mood, beat)} {line}
219        </Text>
220      )
221    }
222
223    const info = (
224      <Box flexDirection="column" flexShrink={1}>
225        <Box flexDirection="row" gap={1}>
226          <Text bold color={color}>
227            {current.name}
228          </Text>
229          <Text dimColor>{stageLabel(stage)}</Text>
230        </Box>
231        <Text italic wrap="truncate">
232          {line}
233        </Text>
234        <Text dimColor>
235          {xpBar(current.xp)}
236        </Text>
237        <Box flexDirection="row">
238          <Button key="pat" label="pat" onPress={() => pat($)} />
239        </Box>
240      </Box>
241    )
242
243    if (e.surface === 'terminal') {
244      const { Raster } = $.ui.resolve(e)
245
246      return (
247        <Box flexDirection="row" gap={2}>
248          <Raster key="sprite" columns={COLUMNS} rows={ROWS} cells={drawPet(stage, mood, beat)} />
249          {info}
250        </Box>
251      )
252    }
253
254    return (
255      <Box flexDirection="row" gap={2}>
256        <Text bold color={color}>
257          {kaomoji(stage, mood, beat)}
258        </Text>
259        {info}
260      </Box>
261    )
262  })
263}
264
hooks/sprites.ts 202 lines
1import type { PetMood, PetStage } from '../types'
2
3// The sprite is 14x12 pixels, drawn two pixels per terminal cell with half blocks.
4export const COLUMNS = 14
5export const ROWS = 6
6
7export const STAGES: readonly { stage: PetStage; from: number; label: string }[] = [
8  { stage: 'egg', from: 0, label: 'an egg' },
9  { stage: 'baby', from: 3, label: 'a baby slime' },
10  { stage: 'teen', from: 25, label: 'a teen slime' },
11  { stage: 'adult', from: 100, label: 'a slime king' },
12]
13
14export const stageOf = (xp: number): PetStage =>
15  STAGES.reduce<PetStage>((found, step) => (xp >= step.from ? step.stage : found), 'egg')
16
17export const stageLabel = (stage: PetStage): string =>
18  STAGES.find(step => step.stage === stage)?.label ?? stage
19
20export const stageStart = (stage: PetStage): number =>
21  STAGES.find(step => step.stage === stage)?.from ?? 0
22
23// The xp the stage after `stage` starts at, or undefined once fully grown.
24export const nextStageAt = (stage: PetStage): number | undefined => {
25  const index = STAGES.findIndex(step => step.stage === stage)
26
27  return STAGES[index + 1]?.from
28}
29
30// '.' is transparent; every other letter is a key of the stage's palette.
31const EGG = [
32  '..............',
33  '.....oooo.....',
34  '....obbbbo....',
35  '...obblbbbo...',
36  '...obbbbbbo...',
37  '..obbbbbllbo..',
38  '..obllbbbbbo..',
39  '..obbbbbbbbo..',
40  '..obbbblbbbo..',
41  '...obbbbbbo...',
42  '....oooooo....',
43  '..............',
44]
45
46const BODY = [
47  '..obllbbbbbo..',
48  '.obllbbbbbbbo.',
49  '.obbbbbbbbbbo.',
50  '.obbbbbbbbbbo.',
51  'obbbbbbbbbbbbo',
52  'obbbbbbbbbbbbo',
53  'obbbbbbbbbbbbo',
54  '.obbbbbbbbbbo.',
55  '..oooooooooo..',
56]
57
58const BODIES: Record<Exclude<PetStage, 'egg'>, string[]> = {
59  baby: ['..............', '.....oooo.....', '...oobbbboo...', ...BODY],
60  teen: ['..o........o..', '..obo....obo..', '..obboooobbo..', ...BODY],
61  adult: ['....g.gg.g....', '....gggggg....', '...oobbbboo...', ...BODY],
62}
63
64type Face = 'open' | 'left' | 'right' | 'closed' | 'happy'
65
66// Each eye is 2x2 pixels: top-left, top-right, bottom-left, bottom-right.
67const EYES: Record<Face, readonly [string, string, string, string]> = {
68  open: ['e', 'w', 'e', 'e'],
69  left: ['e', 'w', 'e', 'w'],
70  right: ['w', 'e', 'w', 'e'],
71  closed: ['b', 'b', 'e', 'e'],
72  happy: ['e', 'e', 'b', 'b'],
73}
74
75const MOUTHS = {
76  neutral: [[6, 9], [7, 9]],
77  smile: [[4, 8], [5, 9], [6, 9], [7, 9], [8, 9], [9, 8]],
78  frown: [[5, 9], [6, 9], [7, 9], [8, 9], [4, 10], [9, 10]],
79} as const
80
81const hex = (color: string): number => parseInt(color.slice(1), 16)
82
83const FACE_COLORS = { e: '#14201a', w: '#ffffff', m: '#14201a', p: '#ff8fab', t: '#6ec6ff' }
84
85export const PALETTES: Record<PetStage, Record<string, string>> = {
86  egg: { o: '#3b2f2f', b: '#f2e6c9', l: '#c9a36b' },
87  baby: { ...FACE_COLORS, o: '#1f5130', b: '#6fd08c', l: '#b6f2c8' },
88  teen: { ...FACE_COLORS, o: '#1d3a6b', b: '#6fa8ff', l: '#bcd6ff' },
89  adult: { ...FACE_COLORS, o: '#43205e', b: '#b07cff', l: '#dcc4ff', g: '#ffd166' },
90}
91
92const faceFor = (mood: PetMood, frame: number): Face => {
93  switch (mood) {
94    case 'happy':
95      return 'happy'
96    case 'sleeping':
97      return 'closed'
98    case 'working':
99      // Glances left and right, as if reading the code going by.
100      return Math.floor(frame / 2) % 2 === 0 ? 'left' : 'right'
101    case 'sad':
102      return 'open'
103    case 'idle':
104      return frame % 8 === 7 ? 'closed' : 'open'
105  }
106}
107
108const shift = (row: string, by: number): string =>
109  by > 0 ? '.'.repeat(by) + row.slice(0, -by) : by < 0 ? row.slice(-by) + '.'.repeat(-by) : row
110
111export const petPixels = (stage: PetStage, mood: PetMood, frame: number): string[] => {
112  if (stage === 'egg') {
113    const wobble = mood === 'working' || mood === 'happy' ? ([0, 1, 0, -1][frame % 4] ?? 0) : 0
114
115    return EGG.map(row => shift(row, wobble))
116  }
117
118  const grid = BODIES[stage].map(row => row.split(''))
119  const paint = (x: number, y: number, key: string) => {
120    const row = grid[y]
121    if (row !== undefined && x < row.length) row[x] = key
122  }
123
124  const [topLeft, topRight, bottomLeft, bottomRight] = EYES[faceFor(mood, frame)]
125  for (const x of [4, 8]) {
126    paint(x, 6, topLeft)
127    paint(x + 1, 6, topRight)
128    paint(x, 7, bottomLeft)
129    paint(x + 1, 7, bottomRight)
130  }
131
132  const mouth = mood === 'happy' ? MOUTHS.smile : mood === 'sad' ? MOUTHS.frown : MOUTHS.neutral
133  for (const [x, y] of mouth) paint(x, y, 'm')
134
135  if (mood === 'happy') {
136    paint(3, 8, 'p')
137    paint(10, 8, 'p')
138  }
139  if (mood === 'sad') paint(4, 8, 't')
140
141  return grid.map(row => row.join(''))
142}
143
144const DEFAULT_COLOR = 0x01000000
145const UPPER_HALF = 0x2580
146const LOWER_HALF = 0x2584
147const SPACE = 0x20
148
149// Packs pixel rows into RasterProps cells: [codePoint, fg, bg] u32 triplets, base64.
150export const encodeCells = (pixels: string[], palette: Record<string, string>): string => {
151  const width = pixels[0]?.length ?? 0
152  const rows = Math.floor(pixels.length / 2)
153  const words = new Uint32Array(width * rows * 3)
154  const colorAt = (x: number, y: number): number | undefined => {
155    const key = pixels[y]?.[x]
156    const color = key === undefined ? undefined : palette[key]
157
158    return color === undefined ? undefined : hex(color)
159  }
160
161  for (let y = 0; y < rows; y++) {
162    for (let x = 0; x < width; x++) {
163      const top = colorAt(x, 2 * y)
164      const bottom = colorAt(x, 2 * y + 1)
165      const cell =
166        top !== undefined
167          ? [UPPER_HALF, top, bottom ?? DEFAULT_COLOR]
168          : bottom !== undefined
169            ? [LOWER_HALF, bottom, DEFAULT_COLOR]
170            : [SPACE, DEFAULT_COLOR, DEFAULT_COLOR]
171      words.set(cell, (y * width + x) * 3)
172    }
173  }
174
175  const bytes = new Uint8Array(words.buffer)
176  let binary = ''
177  for (const byte of bytes) binary += String.fromCharCode(byte)
178
179  return btoa(binary)
180}
181
182export const drawPet = (stage: PetStage, mood: PetMood, frame: number): string =>
183  encodeCells(petPixels(stage, mood, frame), PALETTES[stage])
184
185// A text face for surfaces without Raster (desktop, VS Code, mobile).
186export const kaomoji = (stage: PetStage, mood: PetMood, frame: number): string => {
187  if (stage === 'egg') return mood === 'working' && frame % 2 === 1 ? '(0)~' : '(0)'
188
189  switch (mood) {
190    case 'happy':
191      return '(^‿^)'
192    case 'sad':
193      return '(;︵;)'
194    case 'sleeping':
195      return '(-.-)'
196    case 'working':
197      return Math.floor(frame / 2) % 2 === 0 ? '(•_• )' : '( •_•)'
198    case 'idle':
199      return frame % 8 === 7 ? '(-‿-)' : '(•‿•)'
200  }
201}
202
types/index.d.ts 20 lines
1export type PetStage = 'egg' | 'baby' | 'teen' | 'adult'
2
3export type PetMood = 'idle' | 'working' | 'happy' | 'sad' | 'sleeping'
4
5export type Pet = { name: string; xp: number; bornAt: number }
6
7export type PetFlash = { mood: 'happy' | 'sad'; text: string; until: number }
8
9declare module 'claude-code' {
10  interface PluginState {
11    'pixel-pet': {
12      pet: Pet | null
13      frame: number
14      flash: PetFlash | null
15      isAsleep: boolean
16      isHidden: boolean
17    }
18  }
19}
20