SLOPSHOPPER

clawd-gotchi

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

newspinnerguardtoasttimer
★ 1v0.5.1no licenseupdated 2026-10-04Lunik/gmz-claude-marketplace/clawd-gotchi
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-gotchi
› fix the failing auth test and add an audit log call ╭─────────────────────────────╮ │ clawd-gotchi │ ⏺ Read(src/auth.ts) │ Un boss BUN surgit ! (1 PV) │ ⎿ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ CLAWD ┃ Nv.1 (°o°)! ┃ PV ▰▰▰▰▰▰▰▰▱▱ 80 ┃ XP ▱▱▱▱▱▱▱▱▱▱ 0c 0ko BUN ┃ boss (╬ ಠ_ಠ) ┃ PV ▰▰▰▰▰▰▰▰▰▰ ┃ Échecs 1/1

Draws

Prompt hint
⟨Claude Code's own drawing⟩ CLAWD ┃ Nv.1 (°o°)! ┃ PV ▰▰▰▰▰▰▰▰▱▱ 80 ┃ XP ▱▱▱▱▱▱▱▱▱▱ 0c 0ko BUN ┃ boss (╬ ಠ_ಠ) ┃ PV ▰▰▰▰▰▰▰▰▰▰ ┃ Échecs 1/1
README

clawd-gotchi

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.

Life

EventEffect
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 awayThe pet leaves and a new egg hatches (next generation).

Animation

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:

EventReaction
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.

Boss fights

A Bash command that runs a test runner is a fight. The boss is named after the runner:

EcosystemRunners (boss name)
JavaScript / TypeScriptvitest, jest, mocha, jasmine, karma start, playwright test, cypress run, ava, node --test, bun test, deno test, nx test, turbo test, npm/pnpm/yarn test
Pythonpytest, unittest, nose2, tox, nox, hatch test, manage.py test (DJANGO)
Go, Rust, Ziggo test, gotestsum, cargo test, cargo nextest, zig build test
JVMmvn/mvnw test/verify/install (MAVEN), gradle/gradlew test/check/build, sbt test, lein test, clojure -X:test
.NETdotnet test
Rubyrspec, rails test, rake test
PHPphpunit, pest, artisan test
Elixir, Erlangmix test (EXUNIT), rebar3 eunit/ct
Swift, Dartswift test, xcodebuild … test, flutter test, dart test
C / C++, build toolsctest, meson test, bazel test, make test/check
Haskell, R, Juliastack test, cabal test, testthat/devtools::test, Pkg.test
Claude Code pluginsclaude plugin test

The runner is matched anywhere in the command, so grep pytest notes.md also summons a boss.

Fight rules:

  • Failures with no boss out: a boss named after the runner appears, with one HP per failing test.
  • Fewer failures: the boss loses HP, CLAWD gains 5 XP per fixed test.
  • More failures: the boss heals.
  • A clean run (no failures, exit 0): the boss is K.O., CLAWD gains 50 XP plus 10 per HP the boss had at most, and +20 HP.
  • A run that crashes without a failure count changes nothing.

Behavior

  • The pet is kept across sessions in the plugin store. The boss lasts for the session.
  • Failure counts are read from the runner's summary: 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.

Options

OptionDefaultMeaning
nameCLAWDCompanion name, shown upper case, 10 characters at most. Used in the rows and the toasts.
Source 2 files
hooks/register.tsx 352 lines
1import { 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}
352
types/index.d.ts 18 lines
1export 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