Bottom-left retro RPG companion: a pet fed by cache hits and commits, failing test suites as bosses.

A retro RPG companion in two rows at the bottom left, under the prompt hint line: a pet that feeds on your session and fights failing test suites as bosses.
CLAWD ┃ Nv.7 (◕ᴗ◕) ┃ PV ▰▰▰▰▰▰▰▰▱▱ 80 ┃ XP ▰▰▰▱▱▱▱▱▱▱ 42c 3ko
PYTEST ┃ boss (╬ ಠ_ಠ) ┃ PV ▰▰▰▰▰▱▱▱▱▱ ┃ Échecs 3/6
With no boss out, the second row stays drawn with dim - cells. Both rows have fixed cell widths and sit under the hint line at the bottom left, so the footer never moves.
| Event | Effect |
|---|---|
| Request with a cache hit of 80% or more | +3 HP |
| Cache miss that re-writes 20k tokens or more | -15 HP and a toast |
| Request while a usage window is above 90% | -5 HP |
| Each Bash call | +1 XP |
Successful git commit | +20 XP. Every 100 XP is a level, announced by a toast. |
| A day away | -10 HP |
| A week away | The pet leaves and a new egg hatches (next generation). |
The face moves all the time, one frame every 400 ms, inside a fixed-width cell. Each mood has its own loop: it blinks, hops and hums when fed, throws punches during a fight, drools when hungry, shivers when sick, snores zZz asleep.
Events play a short reaction (3 seconds, highlighted) over the loop:
| Event | Reaction |
|---|---|
| Cache hit | (◕ᴗ◕)♥ |
| Cache miss | (>﹏<)! |
| Level up | \(★▽★)/ |
| Boss appears | (°o°)! |
| Boss loses HP | (ง •_•)ง✦ |
| Boss K.O. | ヽ(^▽^)ノ |
| Commit | (•ᴗ•)✓ |
| Failed command | (・_・;) |
The boss has its own loop too.
Mood follows HP and activity and shows in the face only: hungry below 50 HP, sick below 25 HP, asleep after 5 minutes idle, K.O. at 0 HP, fighting while a boss is out.
A Bash command that runs a test runner is a fight. The boss is named after the runner:
| Ecosystem | Runners (boss name) |
|---|---|
| JavaScript / TypeScript | vitest, jest, mocha, jasmine, karma start, playwright test, cypress run, ava, node --test, bun test, deno test, nx test, turbo test, npm/pnpm/yarn test |
| Python | pytest, unittest, nose2, tox, nox, hatch test, manage.py test (DJANGO) |
| Go, Rust, Zig | go test, gotestsum, cargo test, cargo nextest, zig build test |
| JVM | mvn/mvnw test/verify/install (MAVEN), gradle/gradlew test/check/build, sbt test, lein test, clojure -X:test |
| .NET | dotnet test |
| Ruby | rspec, rails test, rake test |
| PHP | phpunit, pest, artisan test |
| Elixir, Erlang | mix test (EXUNIT), rebar3 eunit/ct |
| Swift, Dart | swift test, xcodebuild … test, flutter test, dart test |
| C / C++, build tools | ctest, meson test, bazel test, make test/check |
| Haskell, R, Julia | stack test, cabal test, testthat/devtools::test, Pkg.test |
| Claude Code plugins | claude plugin test |
The runner is matched anywhere in the command, so grep pytest notes.md also summons a boss.
Fight rules:
N failed, N failing, N failures, N fail, Failures: N, Errors: M (summed), failures=N, N of M failed, +P -N: Some tests failed, --- FAIL lines, or ✗ lines as a last resort. The largest per-line total wins, so the final summary beats per-file lines.| Option | Default | Meaning |
|---|---|---|
name | CLAWD | Companion name, shown upper case, 10 characters at most. Used in the rows and the toasts. |
hooks/register.tsx 352 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Boss, Pet } from '../types'
5
6const DAY = 86_400_000
7const IDLE = 300_000 // ms without activity before the pet falls asleep
8const FRAME = 400 // ms per animation frame
9const REACT = 3_000 // ms a reaction stays on the face
10const WIDTH = 10
11const CELL = 22 // last cell
12
13const egg = (generation = 1, now = 0): Pet => ({ name: 'CLAWD', generation, hp: 80, xp: 0, commits: 0, kos: 0, lastSeen: now })
14
15const petRef = atom({ plugin: 'clawd-gotchi', key: 'pet' } as const, egg())
16const bossRef = atom({ plugin: 'clawd-gotchi', key: 'boss' } as const, null as Boss | null)
17const activeRef = atom({ plugin: 'clawd-gotchi', key: 'activeAt' } as const, 0)
18
19export const clamp = (n: number) => Math.max(0, Math.min(100, Math.round(n)))
20export const level = (xp: number) => 1 + Math.floor(xp / 100)
21
22// test runner a Bash command invokes, as the boss's name; undefined when it runs no tests
23// test runners a Bash command can invoke, first match wins; the name is the boss's
24// ponytail: matched anywhere in the command, so `grep pytest notes.md` also summons PYTEST
25const RUNNERS: [RegExp, string][] = [
26 // JavaScript / TypeScript
27 [/\bvitest\b/, 'VITEST'],
28 [/\bjest\b/, 'JEST'],
29 [/\bmocha\b/, 'MOCHA'],
30 [/\bjasmine\b/, 'JASMINE'],
31 [/\bkarma\s+start\b/, 'KARMA'],
32 [/\bplaywright\s+test\b/, 'PLAYWRIGHT'],
33 [/\bcypress\s+run\b/, 'CYPRESS'],
34 [/\bava\b/, 'AVA'],
35 [/\bnode\s+(?:\S+\s+)*--test\b/, 'NODE'],
36 [/\bbun\s+test\b/, 'BUN'],
37 [/\bdeno\s+test\b/, 'DENO'],
38 [/\bnx\s+(?:run-many\s+(?:-t|--target[= ])\s*)?test\b|\bnx\s+affected\s+(?:-t|--target[= ])\s*test\b/, 'NX'],
39 [/\bturbo\s+(?:run\s+)?test\b/, 'TURBO'],
40 [/\bpnpm\s+(?:run\s+)?test\b/, 'PNPM'],
41 [/\byarn\s+(?:run\s+)?test\b/, 'YARN'],
42 [/\bnpm\s+(?:run\s+|run-script\s+)?(?:test|t)\b/, 'NPM'],
43 // Python
44 [/\bpy(?:\.)?test\b/, 'PYTEST'],
45 [/\bunittest\b/, 'UNITTEST'],
46 [/\bnose2\b/, 'NOSE'],
47 [/\btox\b/, 'TOX'],
48 [/\bnox\b/, 'NOX'],
49 [/\bhatch\s+(?:run\s+\S*)?test\b/, 'HATCH'],
50 [/\bmanage\.py\s+test\b/, 'DJANGO'],
51 // Go, Rust, Zig
52 [/\bgo\s+test\b|\bgotestsum\b/, 'GO'],
53 [/\bcargo\s+(?:test|nextest)\b/, 'CARGO'],
54 [/\bzig\s+(?:build\s+)?test\b/, 'ZIG'],
55 // JVM
56 [/\bmvnw?\b.*\b(?:test|verify|install)\b/, 'MAVEN'],
57 [/\bgradlew?\b.*\b(?:test|check|build)\b/, 'GRADLE'],
58 [/\bsbt\b.*\btest\b/, 'SBT'],
59 [/\blein\s+test\b/, 'LEIN'],
60 [/\bclojure\s+-[XM]:test\b/, 'CLOJURE'],
61 // .NET
62 [/\bdotnet\s+test\b/, 'DOTNET'],
63 // Ruby
64 [/\brspec\b/, 'RSPEC'],
65 [/\brails\s+test\b/, 'RAILS'],
66 [/\brake\s+(?:test|spec)\b/, 'RAKE'],
67 // PHP
68 [/\bphpunit\b/, 'PHPUNIT'],
69 [/\bpest\b/, 'PEST'],
70 [/\bartisan\s+test\b/, 'ARTISAN'],
71 // Elixir, Erlang
72 [/\bmix\s+test\b/, 'EXUNIT'],
73 [/\brebar3\s+(?:eunit|ct)\b/, 'REBAR3'],
74 // Swift, Dart
75 [/\bswift\s+test\b/, 'SWIFT'],
76 [/\bxcodebuild\b.*\btest\b/, 'XCODE'],
77 [/\bflutter\s+test\b/, 'FLUTTER'],
78 [/\bdart\s+test\b/, 'DART'],
79 // C / C++ / build systems
80 [/\bctest\b/, 'CTEST'],
81 [/\bmeson\s+test\b/, 'MESON'],
82 [/\bbazel\s+test\b/, 'BAZEL'],
83 [/\bmake\s+(?:\S+\s+)*(?:test|check)\b/, 'MAKE'],
84 // Haskell, R, Julia
85 [/\bstack\s+test\b/, 'STACK'],
86 [/\bcabal\s+test\b/, 'CABAL'],
87 [/\btestthat\b|\bdevtools::test\b/, 'TESTTHAT'],
88 [/\bPkg\.test\b/, 'JULIA'],
89 // Claude Code plugins
90 [/\bclaude\s+plugin\s+test\b/, 'CLAUDE'],
91]
92
93export const runner = (command: string) => RUNNERS.find(([re]) => re.test(command))?.[1]
94
95// count before the word: "2 failed" (pytest, jest, vitest, cargo, gradle, playwright, deno, zig),
96// "3 failing" (mocha), "2 failures" (rspec, mix, jasmine, swift, hspec), "2 fail" (bun, bazel),
97// "1 error", "2 tests failed" (ctest, ava), "(2 FAILED)" (karma)
98const BEFORE = /(\d+)\s+(?:(?:tests?|examples?|specs?)\s+)?(?:failed|failing|failures?|fail|errors?)\b/gi
99// count after the word: "Failures: 2, Errors: 1" (maven, phpunit), "Failed: 2" (dotnet, rebar3),
100// "failures=2, errors=1" (unittest), "failed 2" / "Failed 2" (sbt), "# fail 2" (node --test, tap)
101const AFTER = /\b(?:failures|failed|fail|errors)\s*[:=]?\s*(\d+)/gi
102// "2 of 10 failed" (cypress), "2 out of 10 tests failed" (tasty): the first number counts
103const OF = /(\d+)\s+(?:out\s+)?of\s+\d+\s+(?:tests?\s+)?failed\b/gi
104// "+8 -2: Some tests failed" (dart, flutter)
105const DART = /\s-(\d+):\s+Some tests failed/g
106
107const sum = (line: string, re: RegExp) => [...line.matchAll(re)].reduce((n, m) => n + Number(m[1]), 0)
108
109// failing tests a runner reported, 0 when it reported none: the largest per-line total, so the
110// final summary wins over per-file lines and "Failures: 2, Errors: 1" counts 3
111export const failures = (text: string) => {
112 const perLine = text.split('\n').map(line => {
113 const of = sum(line, OF)
114 const rest = line.replace(OF, '')
115 return of + sum(rest, BEFORE) + sum(rest, AFTER) + sum(line, DART)
116 })
117 const goFails = (text.match(/^\s*--- FAIL\b/gm) ?? []).length
118 const best = Math.max(0, goFails, ...perLine)
119 return best || (text.match(/^\s*(?:✗|✕|✘|×)\s/gm) ?? []).length
120}
121
122export type Fight = { boss: Boss | null; xp: number; say?: string }
123
124// one test run against the current boss
125export const fight = (boss: Boss | null, name: string, failed: number, isError: boolean, hero = 'CLAWD'): Fight => {
126 if (!boss) {
127 if (failed > 0) return { boss: { name, hp: failed, maxHp: failed }, xp: 0, say: `Un boss ${name} surgit ! (${failed} PV)` }
128 return { boss: null, xp: 0 }
129 }
130 if (failed === 0 && !isError) {
131 const xp = 50 + 10 * boss.maxHp
132 return { boss: null, xp, say: `${boss.name} est K.O. ! ${hero} gagne ${xp} XP !` }
133 }
134 // ponytail: a crashed run that reports no count leaves the boss as is
135 if (failed === 0) return { boss, xp: 0 }
136 if (failed < boss.hp) return { boss: { ...boss, hp: failed }, xp: 5 * (boss.hp - failed), say: `Coup critique ! ${boss.name} perd ${boss.hp - failed} PV !` }
137 if (failed > boss.hp) return { boss: { ...boss, hp: failed, maxHp: Math.max(boss.maxHp, failed) }, xp: 0, say: `${boss.name} se soigne ! (${failed} PV)` }
138 return { boss, xp: 0, say: `${boss.name} encaisse sans broncher...` }
139}
140
141export type Mood = 'ko' | 'sleep' | 'sick' | 'hungry' | 'fight' | 'happy'
142
143export const mood = (pet: Pet, boss: Boss | null, idleMs: number): Mood =>
144 pet.hp <= 0 ? 'ko' : idleMs > IDLE ? 'sleep' : pet.hp < 25 ? 'sick' : pet.hp < 50 ? 'hungry' : boss ? 'fight' : 'happy'
145
146
147// idle loops, one frame per FRAME ms; leading spaces make it hop inside its fixed cell
148const LOOPS: Record<Mood, string[]> = {
149 happy: ['(◕ᴗ◕)', '(◕ᴗ◕)', '(◕ᴗ◕)', '(-ᴗ-)', '(◕ᴗ◕)', ' (◕ᴗ◕)', ' (◕ᴗ◕)', ' (◕ᴗ◕)', '(◕ᴗ◕)', '(◕‿◕)♪', '(◕‿◕) ♫', '(◕ᴗ◕)'],
150 fight: ['(ง •_•)ง', '(ง •_•)ง', 'ᕦ(•_•)ᕤ', '(ง •_•)ง', ' (ง •_•)ง', '(ง -_-)ง', '(ง ˋ_ˊ)ง'],
151 hungry: ['(◕︿◕)', '(◕︿◕)', '(-︿-)', '(◕﹃◕)', '(◕﹃◕).', '(◕﹃◕)..', '(◕︿◕)'],
152 sick: ['(×﹏×)', '(+﹏+)', ' (×﹏×)', '(×﹏×)~', '~(+﹏+)'],
153 sleep: ['(-.-)', '(-.-) z', '(-.-) zZ', '(-.-) zZz', '(-o-) zZ', '(-.-) z'],
154 ko: ['(x_x)', '(x_x)', '(x_x)✝', '(x_x)'],
155}
156
157export type Reaction = 'hit' | 'miss' | 'level' | 'victory' | 'commit' | 'strike' | 'spawn' | 'error'
158
159// short animations played over the loop for REACT ms after an event
160const REACTIONS: Record<Reaction, string[]> = {
161 hit: ['(◕ᴗ◕)♥', '(◕ᴗ◕) ♥', '(◕ᴗ◕) ♥'],
162 miss: ['(>﹏<)!', '(>﹏<) !', ' (>﹏<)!'],
163 level: ['\\(★▽★)/', '/(★▽★)\\', '\\(★▽★)/', '✧(★▽★)✧'],
164 victory: ['ヽ(^▽^)ノ', '\\(^▽^)/', 'ヽ(^▽^)ノ', '└(^▽^)┘'],
165 commit: ['(•ᴗ•)✓', '(•ᴗ•) ✓', '(•ᴗ•)✓'],
166 strike: ['(ง •_•)ง✦', '(ง •_•)ง ✦', '(ง •_•)ง ✸'],
167 spawn: ['(°o°)!', '(°o°)!!', '(°o°)!'],
168 error: ['(・_・;)', '(・_・;) ', '(・_・;)'],
169}
170
171export const face = (m: Mood, frame: number, reaction?: Reaction) => {
172 const frames = reaction ? REACTIONS[reaction] : LOOPS[m]
173 return frames[frame % frames.length] ?? ''
174}
175
176const BOSS = ['(╬ ಠ_ಠ)', '(╬ಠ_ಠ )', '(╬ ಠ_ಠ)', '(╬ ಠoಠ)', '(╬ ಠ_ಠ)', '(╬ಠ_ಠ )']
177
178// hunger between sessions: 10 HP per day away; a week away is a K.O. and a new egg
179export const wake = (pet: Pet, now: number): { pet: Pet; say?: string } => {
180 const away = pet.lastSeen ? now - pet.lastSeen : 0
181 if (away > 7 * DAY) return { pet: egg(pet.generation + 1, now), say: `${pet.name} est parti... Un nouvel œuf éclot (génération ${pet.generation + 1}).` }
182 return { pet: { ...pet, hp: clamp(pet.hp - Math.floor(away / DAY) * 10), lastSeen: now } }
183}
184
185const save = async ($: EngineInterface, fn: (p: Pet) => Pet) => {
186 const now = await $.clock.now()
187 const before = await read($, petRef)
188 const after = { ...fn(before), lastSeen: now }
189 await update($, petRef, () => after)
190 await update($, activeRef, () => now)
191 await $.store.set('pet', after)
192 if (level(after.xp) > level(before.xp)) {
193 $.ui.toast(`${after.name} monte au niveau ${level(after.xp)} !`)
194 await react($, 'level')
195 }
196}
197
198const bar = (pct: number, color: string, Text: any) =>
199 Array.from({ length: WIDTH }, (_, i) => (
200 <Text key={i} color={i < Math.round(pct / WIDTH) ? color : '#3b4261'}>{i < Math.round(pct / WIDTH) ? '▰' : '▱'}</Text>
201 ))
202
203const hpColor = (pct: number) => (pct < 25 ? 'error' : pct < 50 ? '#e0af68' : '#9ece6a')
204
205// ponytail: module var; the first render after a hot reload starts it again
206let ticking = false
207let frame = 0
208// ponytail: module var; a hot reload drops a reaction in progress
209let reaction: { kind: Reaction; until: number } | undefined
210const react = async ($: EngineInterface, kind: Reaction) => {
211 reaction = { kind, until: (await $.clock.now()) + REACT }
212 $.ui.invalidate('ui.render')
213}
214const startTick = ($: EngineInterface) => {
215 if (ticking) return
216 ticking = true
217 $.clock.every(FRAME, () => {
218 frame += 1
219 $.ui.invalidate('ui.render')
220 })
221}
222
223// the companion's name from the `name` option, fit to the 11-column label
224export const petName = (name: unknown) => String(name ?? '').trim().toUpperCase().slice(0, 10) || 'CLAWD'
225
226// ponytail: module var, set again by every register (a reload or a new `name`)
227let hero = 'CLAWD'
228
229export const register: Register = (on, options) => {
230 hero = petName(options.name)
231
232 on('session.start', async ($, e, next) => {
233 const stored = (await $.store.get('pet')) as Pet | undefined
234 const { pet, say } = wake(stored ?? egg(1, await $.clock.now()), await $.clock.now())
235 const named = { ...pet, name: hero }
236 await update($, petRef, () => named)
237 await update($, activeRef, () => named.lastSeen)
238 await $.store.set('pet', named)
239 if (say) $.ui.toast(say)
240 startTick($)
241 return next(e)
242 })
243
244 // cache hits feed, misses poison; quota above 90% drains
245 on('turn.step', async function* ($, e, next) {
246 const res = yield* next(e)
247 const u = res.usage
248 if (!u || e.agentId) return res
249 const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
250 const hit = total ? u.cache_read_input_tokens / total : 1
251 const quota = Math.max(0, ...(await $.session.usage()).rateLimits.map(r => r.percentUsed))
252 const delta = (hit >= 0.8 ? 3 : 0) - (hit < 0.2 && u.cache_creation_input_tokens >= 20_000 ? 15 : 0) - (quota >= 90 ? 5 : 0)
253 if (delta <= -15) {
254 $.ui.toast(`Cache miss ! ${hero} encaisse un coup critique...`)
255 await react($, 'miss')
256 } else if (delta > 0) await react($, 'hit')
257 await save($, p => ({ ...p, hp: clamp(p.hp + delta) }))
258 return res
259 })
260
261 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
262 const ran = await next(e)
263 if (ran.deny !== undefined) return ran
264 const text = ran.text ?? ''
265 const name = runner(e.command)
266 let xp = 1
267 if (name) {
268 const before = await read($, bossRef)
269 const f = fight(before, name, failures(text), ran.isError === true, hero)
270 await update($, bossRef, () => f.boss)
271 if (f.say) $.ui.toast(f.say)
272 await react($, !before && f.boss ? 'spawn' : before && !f.boss ? 'victory' : f.xp ? 'strike' : 'error')
273 xp += f.xp
274 if (f.xp && !f.boss) await save($, p => ({ ...p, kos: p.kos + 1, hp: clamp(p.hp + 20) }))
275 }
276 const isCommit = /\bgit\s+commit\b/.test(e.command) && !ran.isError
277 if (isCommit) await react($, 'commit')
278 else if (!name && ran.isError) await react($, 'error')
279 await save($, p => ({ ...p, xp: p.xp + xp + (isCommit ? 20 : 0), commits: p.commits + (isCommit ? 1 : 0) }))
280 return ran
281 })
282
283 on('tool.call', async ($, e, next) => {
284 const now = await $.clock.now()
285 await update($, activeRef, () => now)
286 return next(e)
287 })
288
289 // under the hint line at the bottom left (PromptHint), the engine's line kept above
290 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
291 startTick($)
292 const pet = await read($, petRef)
293 const boss = await read($, bossRef)
294 const idle = (await $.clock.now()) - (await read($, activeRef))
295 const now = await $.clock.now()
296 const m = mood(pet, boss, idle)
297 const r = reaction && reaction.until > now && m !== 'ko' ? reaction.kind : undefined
298 const other = await next(e)
299 const { Box, Text } = $.ui.resolve(e)
300 const sep = <Text color="#565f89"> ┃ </Text>
301 const dash = <Text color="#565f89">-</Text>
302 // fixed cell widths: the rows never change size, so the footer never jumps
303 const rows = (
304 <Box flexDirection="column" alignItems="flex-start">
305 <Box>
306 <Box width={11}><Text bold color="#bb9af7">{hero}</Text></Box>
307 {sep}
308 <Box width={24}>
309 <Text dimColor>Nv.{level(pet.xp)} </Text>
310 <Text bold color={r ? '#e0af68' : '#c0caf5'}>{face(m, frame, r)}</Text>
311 </Box>
312 {sep}
313 <Box width={24}>
314 <Text dimColor>PV </Text>
315 {bar(pet.hp, hpColor(pet.hp), Text)}
316 <Text bold color={hpColor(pet.hp)}> {pet.hp}</Text>
317 </Box>
318 {sep}
319 <Box width={CELL}>
320 <Text dimColor>XP </Text>
321 {bar(pet.xp % 100, '#7dcfff', Text)}
322 <Text dimColor> {pet.commits}c {pet.kos}ko</Text>
323 </Box>
324 </Box>
325 <Box>
326 <Box width={11}><Text bold color={boss ? '#f7768e' : '#565f89'}>{boss?.name ?? 'Boss'}</Text></Box>
327 {sep}
328 <Box width={24}>
329 {boss ? <Box><Text dimColor>boss </Text><Text bold color="#f7768e">{BOSS[frame % BOSS.length]}</Text></Box> : dash}
330 </Box>
331 {sep}
332 <Box width={24}>
333 <Text dimColor>PV </Text>
334 {boss ? bar((boss.hp / boss.maxHp) * 100, '#f7768e', Text) : dash}
335 </Box>
336 {sep}
337 <Box width={CELL}>
338 <Text dimColor>Échecs </Text>
339 {boss ? <Text bold color="#f7768e">{boss.hp}/{boss.maxHp}</Text> : dash}
340 </Box>
341 </Box>
342 </Box>
343 )
344 return (
345 <Box flexDirection="column" alignItems="flex-start">
346 {other}
347 {rows}
348 </Box>
349 )
350 })
351}
352types/index.d.ts 18 lines1export type Pet = {
2 name: string
3 generation: number
4 hp: number // 0-100: fed by cache hits, drained by misses, hunger and quota
5 xp: number
6 commits: number
7 kos: number
8 lastSeen: number // ms epoch, for hunger and death between sessions
9}
10
11export type Boss = { name: string; hp: number; maxHp: number }
12
13declare module 'claude-code' {
14 interface PluginState {
15 'clawd-gotchi': { pet: Pet; boss: Boss | null; activeAt: number }
16 }
17}
18