A Tamagotchi that lives in Claude Code: it hatches, eats the work Claude does, leaves bugs behind, gets sick, throws tantrums and grows into one of eight…

<img src="docs/device.gif" width="260" alt="An egg-shaped coral Tamagotchi named Mochi: an octopus-like pixel pet eats a rice ball, its bugs get washed away, and it throws a party">
A Tamagotchi that lives inside Claude Code.<br> It hatches when Claude gets to work, eats the code Claude writes, leaves bugs behind,<br> gets sick, throws tantrums, and grows into one of eight adults depending on how you code.
claude plugin marketplace add davidurco/cc-tamagotchi
claude plugin install cc-tamagotchi@cc-tamagotchi
Then start claude. An egg appears. Type /tama.
The first Claude Code mod that is a real virtual pet, not a status line. It lives in a pane beside your transcript, on a pixel LCD that animates on its own: it strolls about, blinks, naps, eats, and reacts to what Claude is doing right now.
| Stage | When it grows |
|---|---|
| Egg | Hatches a minute after it appears, at Claude's first tool call (or after three minutes) |
| Bit | After 1 hour and 40 tool calls |
| Nibble | After 1 day and 250 tool calls: few care mistakes make a Bytey, many a Glitchy |
| Teen | After 3 days and 800 tool calls, one of eight adults |
Which adult? Care comes first: a pet raised with at most one care mistake and firm discipline becomes the rare Unicorn, one raised with eight or more becomes Spaghetto. If you mostly code past 11 pm, it becomes an Owlgorithm. Otherwise it becomes what it watched Claude do most:
| It watched Claude… | It becomes |
|---|---|
| run tests | Assertoise, who carries the checkmark on its back |
| edit code | Refactopus, eight arms, eight files open |
| run shell commands | Shellby, who lives in the terminal |
| read and search | Grepossum, who finds what you lost |
| commit and push | Commitmunk, cheeks full of commits |
Open the device with /tama. Its buttons have hotkeys while the pane has the keyboard. Click the screen to pat your pet; after that click, the same keys work on the screen.
| Key | Command | What it does |
|---|---|---|
f | /tama feed | A meal: one heart of food, one gram |
s | /tama snack | Candy: one heart of fun, two grams |
p | /tama play | The left-or-right game. Guess which way it looks; win 3 of 5 |
c | /tama clean | Squash the bugs it left behind |
m | /tama medicine | Cure it when it is sick. Sometimes it takes two doses |
l | /tama lights | Tuck it in (it sleeps through Claude's work), or wake it up |
d | /tama scold | Discipline a tantrum. Never scold a pet that did nothing wrong |
i | /tama stats | Its life in numbers, and what it is growing into |
What to know:
! on the device, a line under the prompt) when it is hungry, bored, sick, buried in bugs, or throwing a tantrum. A call left unanswered for 15 awake minutes is a care mistake./tama history), and a new egg arrives (/tama new). Prefer it never leave? /tama mode gentle./tama vacation on stops its clock until you are back: no hunger, no aging, no growing.Above the prompt, your pet strolls along a strip of braille dots while Claude works, and its face rides beside the spinner. When Claude is idle the strip folds into one line: face, name, meters, and whatever it is saying.
| Claude… | Your pet… |
|---|---|
| edits a file | nibbles the diff (+food) |
| runs tests that pass | cheers, and the tests squash a bug |
| runs tests that fail | a bug crawls out (−fun) |
| commits | throws a party (+fun, +food) |
| pushes | wheee |
| hits an error | flinches |
/tama band auto is the default. band big draws the strip in solid pixels (8 rows), band on keeps the strip up even when Claude is idle, band line keeps it to one line, and band off hides it.
/tama shell coral (the default), mint, grape, sky, banana or ink. In the terminal the shell is the device's frame; in the desktop app it is the whole egg.
| Command | |||||
|---|---|---|---|---|---|
/tama | Open or close the device (/tamagotchi works too) | ||||
/tama feed · snack · play · clean · medicine · lights · scold · pat | Take care of it | ||||
/tama stats · history · help | Its numbers, the pets before it, this list | ||||
/tama name <name> | Rename it | ||||
/tama shell <color> | coral, mint, grape, sky, banana, ink | ||||
| `/tama band auto\ | big\ | on\ | line\ | off` | How it shows above the prompt |
| `/tama vacation on\ | off` | Stop its clock while you are away | |||
| `/tama mode classic\ | gentle` | Gentle: it never leaves, however neglected | |||
| `/tama spinner on\ | off` | Its face beside the spinner | |||
| `/tama sound on\ | off` | Beeps when it calls (macOS, played on the machine Claude Code runs on) | |||
/tama new · reset | A new egg once it has left; start over (it asks first) |
Every command runs at once, even while Claude is working, and prints nothing into the conversation.
| Where | What you get |
|---|---|
claude in a terminal | Everything: the pixel LCD, the band, the spinner face, the hint line |
| The Code tab of the Claude desktop app | The device as an animated SVG, the band, buttons |
The VS Code extension, claude -p, the Agent SDK | No drawing, but its life goes on: Claude's tool calls still feed it |
One pet per machine. Every session shares it through Claude Code's plugin store (~/.claude/plugins/store/). The simulation is deterministic, so all your sessions see the same pet, and work done in any of them feeds it.
A mod runs with your permissions, so check what one does before you install it. Claude Code lists what this one asks for without running it:
git clone https://github.com/davidurco/cc-tamagotchi
claude plugin validate ./cc-tamagotchi/.claude-plugin/plugin.json
calls: $.audio.play, $.clock.after, $.clock.every, $.clock.now, $.command.register, $.state.get,
$.state.set, $.store.get, $.store.set, $.ui.ask, $.ui.close, $.ui.invalidate, $.ui.log,
$.ui.open, $.ui.panes, $.ui.resolve, $.ui.toast
No $.fs, $.process, $.http or $.model: it reads no files, runs nothing, sends nothing anywhere, and calls no model. It watches tool calls only to see what kind they were (an edit, a test run, a commit), and it never changes or blocks one.
Claude Code v2.1.287 or later, where mods are on by default. Check with claude --version.
To try it without installing anything, load it from a clone for one session:
git clone https://github.com/davidurco/cc-tamagotchi
claude --plugin-dir ./cc-tamagotchi
claude --plugin-dir . # loads the mod and reloads it on every save
claude plugin test . # the tests: no session, sign-in or network needed
claude plugin validate ./.claude-plugin/plugin.json
hooks/
register.tsx the hooks module: events, /tama, the store, every drawing
lcd.tsx the terminal's screen: a Client that animates on the drawing thread
pet/life.ts the pet's life as pure functions: time, care, Claude's work, growth
pet/scene.ts what the screen shows at a moment, as a display list
pet/sprites.ts the pixel art: 1-bit sprites drawn as text
pet/svg.ts the desktop's animated device and strip
pet/paint.ts half blocks and braille for the terminal
types/index.d.ts the $.state contract
tests/ claude plugin test
scripts/ the README's pictures, drawn from the mod's own code
Ideas, sprites and new adults are welcome: see CONTRIBUTING.md for how a creature is drawn and added, or open an issue.
<sub>A fan project, not affiliated with or endorsed by Bandai or Anthropic. Tamagotchi is a trademark of Bandai. Made with Claude Code.</sub>
hooks/register.tsx 1005 lines1/* @jsx h */
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register } from 'claude-code'
4import type { BandMode, Game, Memorial, Pet, Reaction, ReactionKind, Settings, ShellColor, Speech, View } from '../types'
5import {
6 ageOf,
7 ageText,
8 canPlay,
9 care,
10 classify,
11 endVacation,
12 hearts,
13 isPet,
14 memorialOf,
15 newPet,
16 nextEgg,
17 nextGrowth,
18 played,
19 rename,
20 revive,
21 startVacation,
22 tick,
23 turnDone,
24 work,
25 type Care,
26 type LifeEvent,
27 type Outcome,
28} from './pet/life.ts'
29import { REACTION_MS, type SceneInput } from './pet/scene.ts'
30import { species } from './pet/species.ts'
31import { deviceSvg, LCD, laneSvg, SHELLS } from './pet/svg.ts'
32import { bars, CALL_SAYS, compact, face, HELP, meter, NEED_TEXT, statusLine, title } from './pet/words.ts'
33
34// cc-tamagotchi: a virtual pet that lives in Claude Code. It hatches from an egg, eats the work
35// Claude does, leaves bugs behind, gets sick, throws tantrums and grows into one of eight adults
36// depending on how it is cared for and what kind of work it watches. One pet per machine, kept in
37// $.store and shared by every session; nothing it does reaches the model, so it costs no tokens.
38//
39// This module owns the pet: it runs its life forward (./pet/life.ts), applies Claude's tool calls
40// and the person's care, and draws it: the device in a pane (/tama), the band above the prompt,
41// a face beside the spinner. In the terminal the screen is ./lcd.tsx, a Client that animates on the
42// drawing thread; the desktop and mobile apps get one animated SVG (./pet/svg.ts).
43
44type Engine = EngineInterface
45
46const PANE = 'tama'
47const SPEECH_MS = 9_000
48/** In `auto`, how long the band stays big after something happened. */
49const BAND_AFTER_MS = 12_000
50const TICK_MS = 30_000
51
52const DEFAULT_SETTINGS: Settings = { band: 'auto', spinner: true, sound: false, mode: 'classic', shell: 'coral' }
53
54const petAtom = atom({ plugin: 'cc-tamagotchi', key: 'pet' } as const, null)
55const settingsAtom = atom({ plugin: 'cc-tamagotchi', key: 'settings' } as const, DEFAULT_SETTINGS)
56const viewAtom = atom({ plugin: 'cc-tamagotchi', key: 'view' } as const, 'main')
57const gameAtom = atom({ plugin: 'cc-tamagotchi', key: 'game' } as const, null)
58const reactionAtom = atom({ plugin: 'cc-tamagotchi', key: 'reaction' } as const, null)
59const speechAtom = atom({ plugin: 'cc-tamagotchi', key: 'speech' } as const, null)
60const workingAtom = atom({ plugin: 'cc-tamagotchi', key: 'working' } as const, false)
61const graveAtom = atom({ plugin: 'cc-tamagotchi', key: 'graveyard' } as const, [])
62
63const BANDS: readonly BandMode[] = ['auto', 'big', 'on', 'line', 'off']
64const COLORS = Object.keys(SHELLS) as ShellColor[]
65
66// one change to the pet at a time in this session; sessions side by side meet in the store
67let queue: Promise<unknown> = Promise.resolve()
68
69const isSettings = (v: unknown): v is Partial<Settings> => typeof v === 'object' && v !== null
70
71/** Whether the device is on screen: the engine's record, which outlives a reload of this module. */
72async function isPaneUp($: Engine): Promise<boolean> {
73 const panes = await $.ui.panes().catch(() => [])
74 return panes.some(pane => pane.id === PANE && pane.isShown && pane.isPlaced)
75}
76
77// ── the pet in the store ────────────────────────────────────────────────────────────────────
78
79/** The settings as the store has them now (another session may have changed them), shown as well. */
80async function loadSettings($: Engine): Promise<Settings> {
81 const saved = await $.store.get('settings').catch(() => undefined)
82 const settings = { ...DEFAULT_SETTINGS, ...(isSettings(saved) ? saved : {}) } as Settings
83 const shown = await read($, settingsAtom)
84 if (JSON.stringify(shown) !== JSON.stringify(settings)) await update($, settingsAtom, () => settings)
85 return settings
86}
87
88async function saveSettings($: Engine, patch: Partial<Settings>): Promise<Settings> {
89 const settings = { ...(await loadSettings($)), ...patch }
90 await update($, settingsAtom, () => settings)
91 await $.store.set('settings', settings).catch(err => $.ui.log(`cc-tamagotchi: settings not saved: ${err}`, { to: 'debug' }))
92 return settings
93}
94
95async function loadGraveyard($: Engine): Promise<Memorial[]> {
96 const saved = await $.store.get('graveyard').catch(() => undefined)
97 const list = Array.isArray(saved) ? (saved as Memorial[]) : []
98 await update($, graveAtom, () => list)
99 return list
100}
101
102/**
103 * The one way the pet changes: read the newest copy from the store (another session may have
104 * written it), run it forward to now, apply `fn`, write it back, show it, and play what happened.
105 */
106function change($: Engine, fn: ((pet: Pet, now: number) => Outcome | null) | null): Promise<Outcome | null> {
107 const run = async (): Promise<Outcome | null> => {
108 const settings = await loadSettings($)
109 for (let attempt = 0; ; attempt++) {
110 const now = await $.clock.now()
111 const saved = await $.store.get('pet').catch(() => undefined)
112 const stored = isPet(saved) ? revive(saved) : newPet(now)
113 const { pet: ticked, events } = tick(stored, now, settings.mode)
114 const out = fn ? fn(ticked, now) : null
115 // what the change brings about at once: the tool call that hatches the egg, the one that grows it
116 const after = out ? tick(out.pet, now, settings.mode) : { pet: ticked, events: [] }
117 events.push(...after.events)
118 const pet = after.pet
119 const isNew = !isPet(saved) || JSON.stringify(pet) !== JSON.stringify(stored)
120 const next = isNew ? { ...pet, rev: stored.rev + 1 } : stored
121 if (isNew) {
122 // another session wrote since the read: do it again from theirs (the store has no compare-and-set)
123 const again = await $.store.get('pet').catch(() => undefined)
124 if (attempt < 2 && isPet(again) && isPet(saved) && again.rev !== saved.rev) continue
125 await $.store.set('pet', next).catch(err => $.ui.log(`cc-tamagotchi: pet not saved: ${err}`, { to: 'debug' }))
126 }
127 await update($, petAtom, () => next)
128 await announce($, events, next, settings)
129 // hatching and growing up are the moment: the tool call that caused them waits its turn
130 const grew = events.some(ev => ev.kind === 'hatched' || ev.kind === 'evolved')
131 if (out?.reaction && !grew) await react($, out.reaction)
132 if (out?.say && !grew) await say($, out.say)
133 return out
134 }
135 }
136 const p = queue.then(run, run)
137 queue = p.catch(() => undefined)
138 return p
139}
140
141async function react($: Engine, kind: ReactionKind, from?: Reaction['from']): Promise<void> {
142 const now = await $.clock.now()
143 await update($, reactionAtom, (r: Reaction | null) => ({ kind, id: (r?.id ?? 0) + 1, at: now, ...(from ? { from } : {}) }))
144 // the SVG surfaces play a reaction once, then need the idle loop drawn back
145 $.clock.after(REACTION_MS[kind] + 80, () => $.ui.invalidate('ui.render'))
146}
147
148async function say($: Engine, text: string): Promise<void> {
149 const now = await $.clock.now()
150 await update($, speechAtom, () => ({ text, at: now }))
151 $.clock.after(SPEECH_MS + 80, () => $.ui.invalidate('ui.render'))
152 $.clock.after(BAND_AFTER_MS + 80, () => $.ui.invalidate('ui.render'))
153}
154
155function beep($: Engine, settings: Settings): void {
156 if (settings.sound) $.audio.play({ asset: 'fx/beep.wav' }).catch(() => undefined)
157}
158
159/** What the simulation reported: toasts for the big moments, words for the small ones. */
160async function announce($: Engine, events: LifeEvent[], pet: Pet, settings: Settings): Promise<void> {
161 for (const ev of events) {
162 switch (ev.kind) {
163 case 'hatched':
164 await react($, 'hatch')
165 await say($, 'hello, world!')
166 $.ui.toast(`${pet.name} hatched! Say hi with /tama`)
167 beep($, settings)
168 break
169 case 'evolved':
170 await react($, 'evolve', ev.from)
171 await say($, `i'm a ${species(ev.to).name} now!`)
172 $.ui.toast(`${pet.name} grew into a ${species(ev.to).name}!`, { timeoutMs: 8000 })
173 beep($, settings)
174 break
175 case 'sick':
176 $.ui.toast(`${pet.name} is sick. /tama medicine`)
177 break
178 case 'calling':
179 await say($, CALL_SAYS[ev.need])
180 beep($, settings)
181 break
182 case 'departed': {
183 const saved = await $.store.get('graveyard').catch(() => undefined)
184 const list = [...(Array.isArray(saved) ? (saved as Memorial[]) : []), memorialOf(pet)].slice(-50)
185 await update($, graveAtom, () => list)
186 await $.store.set('graveyard', list).catch(() => undefined)
187 $.ui.toast(`${pet.name} has left for the cloud. /tama to see`, { timeoutMs: 10000 })
188 break
189 }
190 case 'bug':
191 if (!pet.asleep) await say($, 'oops… a bug')
192 break
193 default:
194 break
195 }
196 }
197}
198
199// ── what the person does ────────────────────────────────────────────────────────────────────
200
201async function doCare($: Engine, what: Care): Promise<void> {
202 const out = await change($, (pet, now) => care(pet, what, now))
203 if (out && !out.ok && out.say && !out.reaction) $.ui.toast(out.say)
204}
205
206async function startGame($: Engine): Promise<void> {
207 const pet = await read($, petAtom)
208 const why = pet ? canPlay(pet) : 'no pet yet'
209 if (why) {
210 $.ui.toast(why)
211 return
212 }
213 await update($, gameAtom, () => ({ round: 0, wins: 0, answer: null, guess: null, isOver: false }))
214 await update($, viewAtom, () => 'game')
215}
216
217// one guess at a time, so two quick presses play two rounds and a game ends once
218let guesses: Promise<unknown> = Promise.resolve()
219
220function guess($: Engine, dir: 'left' | 'right'): Promise<void> {
221 const p = guesses.then(() => guessNow($, dir))
222 guesses = p.catch(() => undefined)
223 return p
224}
225
226async function guessNow($: Engine, dir: 'left' | 'right'): Promise<void> {
227 const game = await read($, gameAtom)
228 if (!game || game.isOver) return
229 const answer: 'left' | 'right' = Math.random() < 0.5 ? 'left' : 'right'
230 const next: Game = { round: game.round + 1, wins: game.wins + (answer === dir ? 1 : 0), answer, guess: dir, isOver: game.round + 1 >= 5 }
231 await update($, gameAtom, () => next)
232 if (next.isOver) await change($, (pet, now) => played(pet, next.wins, now))
233 else await react($, answer === dir ? 'pat' : 'lose')
234}
235
236async function setView($: Engine, view: View): Promise<void> {
237 if (view !== 'game') await update($, gameAtom, () => null)
238 await update($, viewAtom, () => view)
239}
240
241async function newEgg($: Engine): Promise<void> {
242 await change($, (pet, now) => (pet.gone ? { pet: nextEgg(pet, now), ok: true, say: 'a new egg!' } : null))
243 await setView($, 'main')
244}
245
246async function openPane($: Engine, view?: View): Promise<void> {
247 if (view) await setView($, view)
248 const pet = await read($, petAtom)
249 // inline it asks for room for the screen and its buttons; docked, for the doubled screen
250 await $.ui.open({ id: PANE, title: pet ? pet.name : 'tamagotchi', focus: true, rows: 22, columns: 72 })
251 $.ui.invalidate('ui.render')
252}
253
254/** A key: a button's hotkey, or a key pressed on the screen once clicked. */
255async function press($: Engine, key: string): Promise<void> {
256 const view = await read($, viewAtom)
257 const pet = await read($, petAtom)
258 if (view === 'game') {
259 const game = await read($, gameAtom)
260 if (key === 'left' || key === 'a') return guess($, 'left')
261 if (key === 'right' || key === 'd') return guess($, 'right')
262 if (key === 'p' && game?.isOver) return startGame($)
263 if (key === 'x' || key === 'b' || key === 'return') return setView($, 'main')
264 return
265 }
266 if (view !== 'main' && (key === 'b' || key === 'return')) return setView($, 'main')
267 if (pet?.gone && key === 'n') return newEgg($)
268 switch (key) {
269 case 'f':
270 return doCare($, 'meal')
271 case 's':
272 return doCare($, 'snack')
273 case 'p':
274 return startGame($)
275 case 'c':
276 return doCare($, 'clean')
277 case 'm':
278 return doCare($, 'medicine')
279 case 'l':
280 return doCare($, 'lights')
281 case 'd':
282 return doCare($, 'scold')
283 case 'i':
284 return setView($, view === 'stats' ? 'main' : 'stats')
285 case 'r':
286 return setView($, view === 'history' ? 'main' : 'history')
287 case 'h':
288 case '?':
289 return setView($, view === 'help' ? 'main' : 'help')
290 default:
291 return
292 }
293}
294
295// ── /tama ───────────────────────────────────────────────────────────────────────────────────
296
297async function runCommand($: Engine, args: string): Promise<void> {
298 const [verb = '', ...rest] = args.trim().split(/\s+/)
299 const arg = rest.join(' ').trim()
300 const on = (v: string) => (v === 'on' || v === 'true' ? true : v === 'off' || v === 'false' ? false : null)
301 const v = verb.toLowerCase()
302 switch (v) {
303 case '':
304 if (await isPaneUp($)) {
305 await $.ui.close({ id: PANE })
306 $.ui.invalidate('ui.render')
307 return
308 }
309 return openPane($, 'main')
310 case 'open':
311 case 'show':
312 return openPane($)
313 case 'close':
314 case 'hide':
315 await $.ui.close({ id: PANE })
316 $.ui.invalidate('ui.render')
317 return
318 case 'feed':
319 case 'meal':
320 case 'eat':
321 return doCare($, 'meal')
322 case 'snack':
323 case 'candy':
324 return doCare($, 'snack')
325 case 'clean':
326 case 'flush':
327 return doCare($, 'clean')
328 case 'medicine':
329 case 'med':
330 case 'heal':
331 return doCare($, 'medicine')
332 case 'lights':
333 case 'sleep':
334 case 'wake':
335 return doCare($, 'lights')
336 case 'scold':
337 case 'discipline':
338 return doCare($, 'scold')
339 case 'pat':
340 case 'pet':
341 return doCare($, 'pat')
342 case 'play':
343 case 'game':
344 await startGame($)
345 return (await read($, viewAtom)) === 'game' ? openPane($) : undefined
346 case 'stats':
347 case 'info':
348 return openPane($, 'stats')
349 case 'help':
350 case '?':
351 return openPane($, 'help')
352 case 'history':
353 case 'hall':
354 await loadGraveyard($)
355 return openPane($, 'history')
356 case 'name':
357 case 'rename':
358 if (!arg) return openPane($, 'name')
359 await change($, pet => ({ pet: rename(pet, arg), ok: true, say: `my name is ${rename(pet, arg).name}!` }))
360 return
361 case 'band':
362 if (!BANDS.includes(arg as BandMode)) {
363 $.ui.toast(`band: ${BANDS.join(', ')}`)
364 return
365 }
366 await saveSettings($, { band: arg as BandMode })
367 $.ui.toast(`band: ${arg}`)
368 return
369 case 'shell':
370 case 'color':
371 if (!COLORS.includes(arg as ShellColor)) {
372 $.ui.toast(`shell: ${COLORS.join(', ')}`)
373 return
374 }
375 await saveSettings($, { shell: arg as ShellColor })
376 return
377 case 'spinner':
378 case 'sound': {
379 const value = on(arg)
380 const current = await loadSettings($)
381 const next = value ?? !current[v === 'sound' ? 'sound' : 'spinner']
382 await saveSettings($, v === 'sound' ? { sound: next } : { spinner: next })
383 $.ui.toast(`${v}: ${next ? 'on' : 'off'}`)
384 return
385 }
386 case 'mode':
387 if (arg !== 'classic' && arg !== 'gentle') {
388 $.ui.toast('mode: classic (it can leave if badly neglected) or gentle (it never does)')
389 return
390 }
391 await saveSettings($, { mode: arg })
392 $.ui.toast(`mode: ${arg}`)
393 return
394 case 'vacation': {
395 const value = on(arg)
396 const pet = await read($, petAtom)
397 const going = value ?? pet?.vacation === null
398 await change($, (p, now) => ({ pet: going ? startVacation(p, now) : endVacation(p, now), ok: true, say: going ? 'have a nice trip!' : 'welcome back!' }))
399 $.ui.toast(going ? 'vacation: its clock is stopped until /tama vacation off' : 'vacation over: its clock runs again')
400 return
401 }
402 case 'new':
403 return newEgg($)
404 case 'reset': {
405 const pet = await read($, petAtom)
406 let answer = 'Keep it'
407 try {
408 answer = await $.ui.ask(`Say goodbye to ${pet?.name ?? 'your pet'} and start a new egg?`, ['Keep it', 'Start over'])
409 } catch {
410 return
411 }
412 if (answer !== 'Start over') return
413 await change($, (p, now) => ({ pet: { ...nextEgg(p, now), generation: p.generation }, ok: true, say: 'a new egg!' }))
414 return
415 }
416 default:
417 $.ui.toast(`no /tama ${verb}. /tama help lists them`)
418 }
419}
420
421// ── drawing ─────────────────────────────────────────────────────────────────────────────────
422
423type Ctx = {
424 pet: Pet
425 settings: Settings
426 view: View
427 game: Game | null
428 reaction: Reaction | null
429 speech: Speech | null
430 working: boolean
431 now: number
432 graveyard: Memorial[]
433}
434
435async function context($: Engine, working?: boolean): Promise<Ctx | null> {
436 const pet = await read($, petAtom)
437 if (!pet) return null
438 return {
439 pet,
440 settings: await read($, settingsAtom),
441 view: await read($, viewAtom),
442 game: await read($, gameAtom),
443 reaction: await read($, reactionAtom),
444 speech: await read($, speechAtom),
445 working: working ?? (await read($, workingAtom)),
446 now: await $.clock.now(),
447 graveyard: await read($, graveAtom),
448 }
449}
450
451function sceneOf(c: Ctx): SceneInput {
452 const { pet, reaction } = c
453 return {
454 species: pet.species,
455 seed: pet.id,
456 asleep: pet.asleep,
457 tucked: pet.tucked,
458 sick: pet.sick > 0,
459 bugs: pet.bugs,
460 calling: pet.calling !== null && pet.calling !== 'tantrum',
461 tantrum: pet.tantrum,
462 sad: pet.happy < 25 && pet.species !== 'egg',
463 working: c.working,
464 gone: pet.gone !== null,
465 reaction: reaction ? { kind: reaction.kind, id: reaction.id, ...(reaction.from ? { from: reaction.from } : {}) } : null,
466 game: c.view === 'game' && c.game ? { answer: c.game.answer, guess: c.game.guess, round: c.game.round } : null,
467 }
468}
469
470const reactionAge = (c: Ctx) => (c.reaction ? c.now - c.reaction.at : null)
471const isReacting = (c: Ctx) => c.reaction !== null && c.now - c.reaction.at < REACTION_MS[c.reaction.kind]
472const spoken = (c: Ctx) => (c.speech && c.now - c.speech.at < SPEECH_MS ? c.speech.text : null)
473
474function icons(p: Pet) {
475 return {
476 food: p.hunger <= 25,
477 light: p.asleep,
478 game: p.happy <= 25,
479 medicine: p.sick > 0,
480 bath: p.bugs > 0,
481 meter: false,
482 discipline: p.tantrum,
483 attention: p.calling !== null && !p.gone,
484 }
485}
486
487type Action = { key: string; label: string; hotkey: string }
488
489/** The buttons for a view: each one also answers its key on the clicked screen. */
490function actionsOf(c: Ctx): Action[] {
491 const { pet, view, game } = c
492 if (view === 'game') {
493 return game?.isOver
494 ? [
495 { key: 'p', label: 'again', hotkey: 'p' },
496 { key: 'x', label: 'done', hotkey: 'x' },
497 ]
498 : [
499 { key: 'left', label: '← left', hotkey: 'a' },
500 { key: 'right', label: 'right →', hotkey: 'd' },
501 { key: 'x', label: 'quit', hotkey: 'x' },
502 ]
503 }
504 if (view !== 'main') return [{ key: 'b', label: 'back', hotkey: 'b' }]
505 if (pet.gone) {
506 return [
507 { key: 'n', label: 'new egg', hotkey: 'n' },
508 { key: 'r', label: 'history', hotkey: 'r' },
509 ]
510 }
511 if (pet.species === 'egg') {
512 return [
513 { key: 'i', label: 'stats', hotkey: 'i' },
514 { key: 'h', label: 'help', hotkey: 'h' },
515 ]
516 }
517 return [
518 { key: 'f', label: 'feed', hotkey: 'f' },
519 { key: 's', label: 'snack', hotkey: 's' },
520 { key: 'p', label: 'play', hotkey: 'p' },
521 { key: 'c', label: 'clean', hotkey: 'c' },
522 { key: 'm', label: 'medicine', hotkey: 'm' },
523 { key: 'l', label: pet.asleep ? 'wake' : 'lights', hotkey: 'l' },
524 { key: 'd', label: 'scold', hotkey: 'd' },
525 { key: 'i', label: 'stats', hotkey: 'i' },
526 ]
527}
528
529/** The lines beside the screen: meters while it lives, a memorial once it has gone. */
530function infoLines(c: Ctx): { label: string; value: string; color?: string }[] {
531 const { pet, now } = c
532 if (pet.gone) {
533 return [
534 { label: 'lived', value: ageText(ageOf(pet, now)) },
535 { label: 'watched', value: `${compact(pet.stats.toolCalls)} tool calls` },
536 { label: 'saw', value: `${pet.stats.commits} commits` },
537 ]
538 }
539 if (pet.species === 'egg') {
540 return [
541 { label: 'gen', value: `${pet.generation}` },
542 { label: 'laid', value: `${ageText(now - pet.born)} ago` },
543 ]
544 }
545 const lines: { label: string; value: string; color?: string }[] = [
546 { label: 'food', value: meter(pet.hunger), color: hearts(pet.hunger) <= 1 ? 'red' : 'redBright' },
547 { label: 'fun', value: meter(pet.happy), color: hearts(pet.happy) <= 1 ? 'red' : 'yellow' },
548 { label: 'disc', value: bars(pet.discipline), color: 'cyan' },
549 { label: 'age', value: `${ageText(ageOf(pet, now))} · gen ${pet.generation}` },
550 { label: 'weight', value: `${pet.weight}g` },
551 ]
552 if (pet.bugs > 0) lines.push({ label: 'bugs', value: 'ж '.repeat(pet.bugs).trim(), color: 'green' })
553 if (pet.sick > 0) lines.push({ label: 'sick', value: `needs ${pet.sick === 1 ? 'a dose' : '2 doses'}`, color: 'magenta' })
554 if (pet.vacation !== null) lines.push({ label: 'away', value: 'on vacation', color: 'blue' })
555 return lines
556}
557
558function statsLines(c: Ctx): [string, string][] {
559 const { pet, now } = c
560 const s = pet.stats
561 const sp = species(pet.species)
562 const lines: [string, string][] = [
563 ['form', `${sp.name}: ${sp.blurb}`],
564 ['next', nextGrowth(pet, now) ?? 'fully grown'],
565 ['age', `${ageText(ageOf(pet, now))} · generation ${pet.generation}`],
566 ['care', `${pet.mistakes} care mistake${pet.mistakes === 1 ? '' : 's'} · discipline ${Math.round(pet.discipline)}`],
567 ['watched', `${compact(s.toolCalls)} tool calls · ${compact(s.turns)} turns · ${compact(s.tokens)} tokens`],
568 ['work', `${s.edits} edits · ${s.searches} reads · ${s.shell} commands · ${s.agents} agents`],
569 ['tests', `${s.tests} passed · ${s.testFails} failed`],
570 ['git', `${s.commits} commits · ${s.pushes} pushes`],
571 ['care', `${s.meals} meals · ${s.snacks} snacks · ${s.games} games (${s.wins} won) · ${s.cleans} cleans · ${s.meds} doses · ${s.pats} pats`],
572 ]
573 return lines
574}
575
576function historyLines(c: Ctx): string[] {
577 if (c.graveyard.length === 0) return ['No pets have left yet. Long may it last.']
578 return [...c.graveyard]
579 .reverse()
580 .slice(0, 12)
581 .map(m => `gen ${m.generation} · ${m.name} the ${species(m.species).name} · lived ${ageText(m.gone - m.born)} · ${compact(m.toolCalls)} tool calls`)
582}
583
584// ── /tama's pane ────────────────────────────────────────────────────────────────────────────
585
586function paneTerminal($: Engine, el: any, c: Ctx, cols: number, rows: number, docked: boolean) {
587 const { Box, Text, Button, Client, Input } = el
588 const shell = SHELLS[c.settings.shell] ?? SHELLS.coral
589 const { pet } = c
590 const sp = species(pet.species)
591 // the screen doubles where there is room for it: 64×16 cells, else 32×8
592 const scale: 1 | 2 = cols >= 72 && rows >= 26 ? 2 : 1
593 const wide = cols >= (scale === 2 ? 68 : 36) + 30
594 const lcd = (
595 <Client
596 key={`lcd:${scale}`}
597 module="./lcd.tsx"
598 width={32 * scale}
599 height={8 * scale}
600 props={{ input: sceneOf(c), mode: 'device', scale, age: reactionAge(c) ?? 999999, ink: LCD.ink, bg: LCD.bg }}
601 />
602 )
603 const attention = pet.calling !== null && !pet.gone
604 const device = (
605 <Box flexDirection="column" borderStyle="round" borderColor={shell.base} paddingX={1} width={32 * scale + 4}>
606 <Box flexDirection="row" justifyContent="space-between">
607 <Text bold color={shell.base}>
608 {pet.name.toUpperCase()}
609 </Text>
610 <Text color={attention ? 'redBright' : shell.base} bold={attention}>
611 {attention ? '! ' : ''}
612 {pet.gone ? 'gone' : sp.name.toLowerCase()}
613 </Text>
614 </Box>
615 {lcd}
616 </Box>
617 )
618
619 let side: unknown
620 if (c.view === 'stats') {
621 side = (
622 <Box flexDirection="column">
623 <Text bold>{title(pet)}</Text>
624 {statsLines(c).map(([k, v]) => (
625 <Text wrap="wrap">
626 <Text dimColor>{k.padEnd(8)}</Text>
627 {v}
628 </Text>
629 ))}
630 </Box>
631 )
632 } else if (c.view === 'help') {
633 side = (
634 <Box flexDirection="column">
635 <Text bold>/tama</Text>
636 {HELP.map(([k, v]) => (
637 <Text wrap="wrap">
638 <Text color={shell.base}>{k}</Text>
639 <Text dimColor>{` ${v}`}</Text>
640 </Text>
641 ))}
642 <Text dimColor wrap="wrap">
643 Click the screen to pat it; then its keys work there too. Esc gives the prompt back.
644 </Text>
645 </Box>
646 )
647 } else if (c.view === 'history') {
648 side = (
649 <Box flexDirection="column">
650 <Text bold>Hall of fame</Text>
651 {historyLines(c).map(line => (
652 <Text dimColor wrap="wrap">
653 {line}
654 </Text>
655 ))}
656 </Box>
657 )
658 } else if (c.view === 'game' && c.game) {
659 const g = c.game
660 side = (
661 <Box flexDirection="column">
662 <Text bold>Left or right?</Text>
663 <Text dimColor wrap="wrap">
664 Guess which way {pet.name} will look. Win 3 of 5.
665 </Text>
666 <Text>
667 {'round '}
668 {Math.min(5, g.round + (g.isOver ? 0 : 1))}
669 {'/5 · won '}
670 {g.wins}
671 </Text>
672 {g.answer ? (
673 <Text color={g.answer === g.guess ? 'greenBright' : 'red'}>{g.answer === g.guess ? `yes! it looked ${g.answer}` : `no, it looked ${g.answer}`}</Text>
674 ) : null}
675 {g.isOver ? <Text bold>{g.wins >= 3 ? `${pet.name} had a blast!` : `${pet.name} wants a rematch`}</Text> : null}
676 </Box>
677 )
678 } else if (c.view === 'name' && Input) {
679 side = (
680 <Box flexDirection="column">
681 <Text bold>A new name</Text>
682 <Input
683 key="name"
684 label="Name"
685 placeholder={pet.name}
686 value=""
687 submitLabel="rename"
688 autoFocus
689 onSubmit={async (value: string) => {
690 if (value.trim()) await change($, p => ({ pet: rename(p, value), ok: true, say: `my name is ${rename(p, value).name}!` }))
691 await setView($, 'main')
692 }}
693 />
694 </Box>
695 )
696 } else {
697 side = (
698 <Box flexDirection="column">
699 {infoLines(c).map(line => (
700 <Text>
701 <Text dimColor>{line.label.padEnd(7)}</Text>
702 {line.color ? <Text color={line.color}>{line.value}</Text> : line.value}
703 </Text>
704 ))}
705 </Box>
706 )
707 }
708
709 const words = spoken(c)
710 return (
711 <Box flexDirection="column">
712 <Box flexDirection={wide ? 'row' : 'column'} columnGap={2}>
713 {device}
714 {side}
715 </Box>
716 <Text wrap="truncate-end" color={words ? shell.base : undefined} dimColor={!words}>
717 {words ? `“${words}”` : statusLine(pet)}
718 </Text>
719 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
720 {actionsOf(c).map(a => (
721 <Button key={`act:${a.key}`} label={a.label} hotkey={a.hotkey} plain onPress={() => press($, a.key)} />
722 ))}
723 </Box>
724 </Box>
725 )
726}
727
728function paneRemote($: Engine, el: any, c: Ctx, cols: number) {
729 const { Box, Text, Button, Svg, Input } = el
730 const { pet } = c
731 const svg = deviceSvg({ input: sceneOf(c), rt: isReacting(c) ? reactionAge(c) : null, name: pet.name, shell: c.settings.shell, icons: icons(pet) })
732 const words = spoken(c)
733 const lines =
734 c.view === 'stats'
735 ? statsLines(c).map(([k, v]) => `${k}: ${v}`)
736 : c.view === 'help'
737 ? HELP.map(([k, v]) => `${k}: ${v}`)
738 : c.view === 'history'
739 ? historyLines(c)
740 : c.view === 'game' && c.game
741 ? [
742 `Left or right? Round ${Math.min(5, c.game.round + (c.game.isOver ? 0 : 1))} of 5, won ${c.game.wins}.`,
743 ...(c.game.answer ? [c.game.answer === c.game.guess ? `Yes! It looked ${c.game.answer}.` : `No, it looked ${c.game.answer}.`] : []),
744 ]
745 : infoLines(c).map(l => `${l.label} ${l.value}`)
746 return (
747 <Box flexDirection="column" gap={1}>
748 <Box flexDirection={cols >= 60 ? 'row' : 'column'} columnGap={3} alignItems="flex-start">
749 <Svg key="device" source={svg} alt={`${title(pet)}: ${statusLine(pet)}`} width={220} height={250} isInteractive />
750 <Box flexDirection="column">
751 <Text bold>{title(pet)}</Text>
752 {lines.map(line => (
753 <Text wrap="wrap">{line}</Text>
754 ))}
755 {c.view === 'name' && Input ? (
756 <Input
757 key="name"
758 label="Name"
759 placeholder={pet.name}
760 value=""
761 submitLabel="rename"
762 autoFocus
763 onSubmit={async (value: string) => {
764 if (value.trim()) await change($, p => ({ pet: rename(p, value), ok: true }))
765 await setView($, 'main')
766 }}
767 />
768 ) : null}
769 </Box>
770 </Box>
771 <Text dimColor={!words}>{words ? `“${words}”` : statusLine(pet)}</Text>
772 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
773 {actionsOf(c).map(a => (
774 <Button key={`act:${a.key}`} label={a.label} hotkey={a.hotkey} onPress={() => press($, a.key)} />
775 ))}
776 </Box>
777 </Box>
778 )
779}
780
781// ── the band above the prompt ───────────────────────────────────────────────────────────────
782
783function bandLine(el: any, c: Ctx) {
784 const { Box, Text } = el
785 const { pet } = c
786 const shell = SHELLS[c.settings.shell] ?? SHELLS.coral
787 const words = spoken(c)
788 const alive = !pet.gone && pet.species !== 'egg'
789 return (
790 <Box flexDirection="row" columnGap={1}>
791 <Text color={shell.base} bold>
792 {face(pet)}
793 </Text>
794 <Text bold>{pet.name}</Text>
795 {alive ? <Text color="redBright">{meter(pet.hunger)}</Text> : null}
796 {alive ? <Text color="yellow">{meter(pet.happy)}</Text> : null}
797 {alive && pet.bugs > 0 ? <Text color="green">{'ж'.repeat(pet.bugs)}</Text> : null}
798 <Text dimColor={!words && pet.calling === null} color={pet.calling ? 'redBright' : undefined} wrap="truncate-end">
799 {words ? `“${words}”` : statusLine(pet)}
800 </Text>
801 </Box>
802 )
803}
804
805function bandLaneSide(el: any, c: Ctx) {
806 const { Box, Text } = el
807 const { pet } = c
808 const shell = SHELLS[c.settings.shell] ?? SHELLS.coral
809 const words = spoken(c)
810 const alive = !pet.gone && pet.species !== 'egg'
811 return (
812 <Box flexDirection="column" width={30}>
813 <Text wrap="truncate-end">
814 <Text bold color={shell.base}>
815 {pet.name}
816 </Text>
817 <Text dimColor>{` ${species(pet.species).name.toLowerCase()}`}</Text>
818 </Text>
819 {alive ? (
820 <Text>
821 <Text dimColor>{'food '}</Text>
822 <Text color="redBright">{meter(pet.hunger)}</Text>
823 <Text dimColor>{' fun '}</Text>
824 <Text color="yellow">{meter(pet.happy)}</Text>
825 </Text>
826 ) : (
827 <Text dimColor>{pet.gone ? 'gone to the cloud' : 'wobbling…'}</Text>
828 )}
829 <Text wrap="truncate-end" color={words ? shell.base : pet.calling ? 'redBright' : undefined} dimColor={!words && !pet.calling}>
830 {words ? `“${words}”` : statusLine(pet)}
831 </Text>
832 <Text dimColor>{'/tama'}</Text>
833 </Box>
834 )
835}
836
837// ── the hooks ───────────────────────────────────────────────────────────────────────────────
838
839export const register: Register = on => {
840 on('session.start', async ($, e, next) => {
841 const r = await next(e)
842 await loadSettings($)
843 await loadGraveyard($)
844 const saved = await $.store.get('pet').catch(() => undefined)
845 const isFirst = !isPet(saved)
846 await change($, null)
847 // a headless run (claude -p, the SDK) still feeds it from tool calls, but draws nothing
848 if (e.isInteractive) {
849 if (isFirst) $.ui.toast('A Tamagotchi egg appeared! Type /tama to see it', { timeoutMs: 8000 })
850 // the pet's clock: every half minute, run its life forward (and see what other sessions did)
851 $.clock.every(TICK_MS, () => {
852 void change($, null)
853 })
854 }
855 for (const name of ['tama', 'tamagotchi']) {
856 await $.command
857 .register({
858 name,
859 description: name === 'tama' ? 'Your Tamagotchi: open it, or feed, play, clean, medicine, lights, stats, help…' : 'Your Tamagotchi (same as /tama)',
860 argumentHint: '[feed|snack|play|clean|medicine|lights|scold|stats|help|name|band|shell]',
861 immediate: true,
862 })
863 .catch(err => $.ui.log(`cc-tamagotchi: /${name} not registered: ${err}`, { to: 'debug' }))
864 }
865 return r
866 })
867
868 // /clear, /resume and /branch reset $.state: put the pet back from the store
869 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
870 await loadSettings($)
871 await loadGraveyard($)
872 await change($, null)
873 return next(e)
874 })
875
876 on('command.run', { command: ['tama', 'tamagotchi'] }, async ($, e) => {
877 await runCommand($, e.args)
878 return {}
879 })
880
881 // Claude's work feeds it: every finished tool call, told apart by kind
882 on('tool.call', async ($, e, next) => {
883 const r = await next(e)
884 const shellCommand = (e as { command?: unknown }).command
885 const kind = classify(e.tool, typeof shellCommand === 'string' ? shellCommand : undefined, 'deny' in r && r.deny !== undefined ? undefined : r.isError !== true)
886 // the pet follows the tool's result; it never holds it up
887 if (kind) void change($, (pet, now) => (pet.vacation !== null ? null : work(pet, kind.work, kind.isError, now))).catch(() => undefined)
888 return r
889 })
890
891 on('turn.start', async ($, e, next) => {
892 await update($, workingAtom, () => true)
893 return next(e)
894 })
895
896 on('turn.complete', async ($, e, next) => {
897 const r = await next(e)
898 if (e.agentId === undefined) {
899 await update($, workingAtom, () => false)
900 const tokens = e.usage?.output_tokens ?? 0
901 void change($, (pet, now) => (pet.vacation !== null ? null : { pet: turnDone(pet, tokens, new Date(now).getHours(), now), ok: true })).catch(() => undefined)
902 }
903 return r
904 })
905
906 // a prompt wakes a napping pet
907 on('prompt.submit', async ($, e, next) => {
908 void change($, (pet, now) => (pet.asleep && !pet.tucked ? { pet: { ...pet, asleep: false, lastActive: now }, ok: true, reaction: 'wake' } : null)).catch(() => undefined)
909 return next(e)
910 })
911
912 // the screen in the terminal: a click pats, keys press the device's buttons
913 on('ui.message', async ($, e, next) => {
914 const data = e.data as { act?: unknown; key?: unknown } | null
915 if (data?.act === 'pat') await doCare($, 'pat')
916 else if (data?.act === 'key' && typeof data.key === 'string') await press($, data.key)
917 return next(e)
918 })
919
920 on('ui.close', async ($, e, next) => {
921 const r = await next(e)
922 if (e.id === PANE) $.ui.invalidate('ui.render')
923 return r
924 })
925
926 on('ui.render', { component: 'Pane', requestId: 'tama' }, async ($, e) => {
927 const c = await context($)
928 const el = $.ui.resolve(e)
929 if (!c) return el.Text({ children: ['waking up…'] })
930 const cols = e.props.bodyColumns
931 if (e.surface === 'terminal') {
932 // docked, the pane has its body's rows; inline above the prompt, about what it asked for
933 const rows = e.props.placement === 'dock' ? e.props.scroll.bodyRows : (e.viewport?.rows ?? 24) >= 60 ? 26 : 20
934 return paneTerminal($, el, c, cols, rows, e.props.placement === 'dock')
935 }
936 return paneRemote($, el, c, cols)
937 })
938
939 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
940 const c = await context($, e.props.isWorking)
941 // the device is up: it says everything the band would
942 if (!c || e.props.hasSurvey || c.settings.band === 'off' || (await isPaneUp($))) return next(e)
943 const el: any = $.ui.resolve(e)
944 const { Box, Client, Svg } = el
945 const { pet } = c
946 const lively = e.props.isWorking || pet.species === 'egg' || isReacting(c) || (c.speech !== null && c.now - c.speech.at < BAND_AFTER_MS)
947 // auto and big: the strip while something is going on, else one line; on: the strip always
948 const blocks = c.settings.band === 'big'
949 // below 60 columns the strip and its side text do not fit: one line it is
950 const big = e.props.bodyColumns >= 60 && e.props.maxRows >= (blocks ? 10 : 6) && (c.settings.band === 'on' || ((c.settings.band === 'auto' || blocks) && lively))
951 const shell = SHELLS[c.settings.shell] ?? SHELLS.coral
952 let mine: unknown
953 if (!big) {
954 mine = bandLine(el, c)
955 } else if (e.surface === 'terminal') {
956 // dots: 16 pixels in 4 rows; blocks: 16 pixels in 8 rows, each pixel a whole cell wide
957 const laneCols = Math.max(16, Math.min(blocks ? 96 : 90, e.props.bodyColumns - 34))
958 mine = (
959 <Box flexDirection="row" columnGap={2}>
960 <Client
961 key={blocks ? 'lane:blocks' : 'lane'}
962 module="./lcd.tsx"
963 width={laneCols}
964 height={blocks ? 8 : 4}
965 props={{ input: sceneOf(c), mode: blocks ? 'strip' : 'lane', age: reactionAge(c) ?? 999999, ink: shell.base }}
966 />
967 {bandLaneSide(el, c)}
968 </Box>
969 )
970 } else if (Svg) {
971 const svg = laneSvg(sceneOf(c), isReacting(c) ? reactionAge(c) : null, shell.base, 80)
972 mine = (
973 <Box flexDirection="row" columnGap={2} alignItems="center">
974 <Svg key="lane" source={svg} alt={statusLine(pet)} width={240} height={48} isInteractive />
975 {bandLaneSide(el, c)}
976 </Box>
977 )
978 } else {
979 mine = bandLine(el, c)
980 }
981 return (
982 <Box flexDirection="column">
983 {mine}
984 {await next(e)}
985 </Box>
986 )
987 })
988
989 // its face rides beside the spinner while Claude works
990 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
991 const settings = await read($, settingsAtom)
992 const pet = await read($, petAtom)
993 if (!settings.spinner || !pet || pet.species === 'egg' || pet.gone) return next(e)
994 return next({ ...e, props: { ...e.props, suffix: `${e.props.suffix} ${face(pet)}` } })
995 })
996
997 // when it needs something and Claude is idle, the hint line under the prompt says so
998 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
999 if (e.props.isWorking || e.props.isDraft) return next(e)
1000 const pet = await read($, petAtom)
1001 if (!pet?.calling || pet.gone) return next(e)
1002 return next({ ...e, props: { ...e.props, tail: `${pet.name} ${NEED_TEXT[pet.calling]} · /tama` } })
1003 })
1004}
1005hooks/pet/life.ts 594 lines1import type { Memorial, Need, Pet, PetStats, ReactionKind, Settings, SpeciesId } from '../../types'
2import { newId, pick, roll } from './rng.ts'
3import { species } from './species.ts'
4
5// The pet's life, as pure functions of the pet and the time. Nothing here touches the engine:
6// the hooks module reads the pet from $.store, runs it forward with `tick`, applies what the person
7// did (`care`) and what Claude did (`work`), and writes it back.
8
9export const MINUTE = 60_000
10export const HOUR = 60 * MINUTE
11export const DAY = 24 * HOUR
12
13/** No activity from Claude or the person for this long, and it naps. */
14export const NAP_AFTER = 20 * MINUTE
15/** Tucked in, it sleeps at most this long before it is just napping. */
16const TUCK_MAX = 10 * HOUR
17/** Full to empty in four awake hours; content to miserable in five. Asleep, a tenth as fast. */
18const HUNGER_PER_MIN = 100 / (4 * 60)
19const HAPPY_PER_MIN = 100 / (5 * 60)
20const ASLEEP = 0.1
21/** Awake minutes a call may go unanswered before it counts as a care mistake. */
22export const CALL_GRACE = 15
23/** Minutes of hard neglect before a pet leaves, in classic mode: half a day awake, days asleep. */
24export const DEPART_AFTER = 12 * 60
25/** A pet left alone longer than this is simulated from this far back only. */
26const MAX_SIM = 30 * DAY
27export const MAX_BUGS = 4
28
29export const HATCH_AFTER = MINUTE
30export const GROWTH: Record<'baby' | 'child' | 'teen', { age: number; toolCalls: number }> = {
31 baby: { age: HOUR, toolCalls: 40 },
32 child: { age: DAY, toolCalls: 250 },
33 teen: { age: 3 * DAY, toolCalls: 800 },
34}
35
36const NAMES = ['Mochi', 'Pixel', 'Tofu', 'Bitsy', 'Dumpling', 'Biscuit', 'Momo', 'Kiwi', 'Taro', 'Pudding', 'Noodle', 'Sprout', 'Bean', 'Pico', 'Waffle', 'Nori', 'Gizmo', 'Peanut']
37
38export const emptyStats = (): PetStats => ({
39 toolCalls: 0, edits: 0, tests: 0, testFails: 0, commits: 0, pushes: 0, shell: 0, searches: 0, agents: 0, errors: 0,
40 turns: 0, nightTurns: 0, tokens: 0, meals: 0, snacks: 0, games: 0, wins: 0, cleans: 0, meds: 0, pats: 0, scolds: 0,
41})
42
43export function newPet(now: number, opts: { name?: string; generation?: number; id?: string } = {}): Pet {
44 const id = opts.id ?? newId(now)
45 return {
46 v: 1,
47 id,
48 name: opts.name ?? pick(NAMES, roll(id, 'name')),
49 species: 'egg',
50 generation: opts.generation ?? 1,
51 born: now,
52 hatchedAt: null,
53 evolvedAt: now,
54 at: now,
55 hunger: 100,
56 happy: 100,
57 discipline: 0,
58 weight: species('egg').weight,
59 bugs: 0,
60 sick: 0,
61 asleep: false,
62 tucked: false,
63 lastActive: now,
64 lastMeal: null,
65 lastPat: 0,
66 tantrum: false,
67 calling: null,
68 callWait: 0,
69 callingCounted: false,
70 mistakes: 0,
71 neglect: 0,
72 vacation: null,
73 gone: null,
74 rev: 0,
75 stats: emptyStats(),
76 }
77}
78
79/** Whether a value read back from the store is a pet this version can run. */
80export function isPet(value: unknown): value is Pet {
81 if (typeof value !== 'object' || value === null) return false
82 const p = value as Partial<Pet>
83 return p.v === 1 && typeof p.id === 'string' && typeof p.at === 'number' && typeof p.species === 'string' && typeof p.stats === 'object'
84}
85
86/** A pet from the store, with any field an older version did not write filled in. */
87export function revive(value: Pet): Pet {
88 const fresh = newPet(value.born ?? value.at, { id: value.id, name: value.name })
89 return { ...fresh, ...value, stats: { ...emptyStats(), ...value.stats } }
90}
91
92// ── reading the pet ─────────────────────────────────────────────────────────────────────────
93
94/** 0 to 4 hearts. Anything above an empty meter shows at least one. */
95export const hearts = (value: number): number => Math.max(0, Math.min(4, Math.ceil(value / 25 - 1e-9)))
96
97export const stageOf = (p: Pet) => species(p.species).stage
98
99/** What it is calling about right now, most urgent first. */
100export function needOf(p: Pet): Need | null {
101 if (p.gone || p.species === 'egg') return null
102 if (p.sick > 0) return 'sick'
103 if (p.hunger <= 0) return 'hungry'
104 if (p.happy <= 0) return 'sad'
105 if (p.bugs >= 3) return 'bugs'
106 if (p.tantrum) return 'tantrum'
107 return null
108}
109
110export function ageText(ms: number): string {
111 const m = Math.floor(ms / MINUTE)
112 if (m < 60) return `${m}m`
113 const h = Math.floor(m / 60)
114 if (h < 24) return `${h}h ${m % 60}m`
115 const d = Math.floor(h / 24)
116 return `${d}d ${h % 24}h`
117}
118
119export const ageOf = (p: Pet, now: number): number => (p.hatchedAt === null ? 0 : (p.gone?.at ?? now) - p.hatchedAt)
120
121/** The next form it is growing towards and what is still missing, for the stats page. */
122export function nextGrowth(p: Pet, now: number): string | null {
123 const stage = stageOf(p)
124 if (stage === 'egg') return 'hatches soon'
125 if (stage === 'adult') return null
126 const need = GROWTH[stage]
127 const age = ageOf(p, now)
128 const parts: string[] = []
129 if (age < need.age) parts.push(`${ageText(need.age - age)} older`)
130 if (p.stats.toolCalls < need.toolCalls) parts.push(`${need.toolCalls - p.stats.toolCalls} more tool calls`)
131 return parts.length ? `grows after ${parts.join(' and ')}` : 'about to grow'
132}
133
134// ── time ────────────────────────────────────────────────────────────────────────────────────
135
136export type LifeEvent =
137 | { kind: 'hatched' }
138 | { kind: 'evolved'; from: SpeciesId; to: SpeciesId }
139 | { kind: 'bug' }
140 | { kind: 'sick' }
141 | { kind: 'tantrum' }
142 | { kind: 'calling'; need: Need }
143 | { kind: 'mistake'; need: Need }
144 | { kind: 'napped' }
145 | { kind: 'departed' }
146
147const clone = (p: Pet): Pet => ({ ...p, stats: { ...p.stats } })
148
149/**
150 * Runs the pet's life forward to `now`, a whole minute at a time, and says what happened.
151 * Pure and deterministic: every roll is seeded by the pet's id and the minute.
152 */
153export function tick(p: Pet, now: number, mode: Settings['mode'] = 'classic'): { pet: Pet; events: LifeEvent[] } {
154 const events: LifeEvent[] = []
155 if (p.gone || p.vacation !== null) return { pet: p, events }
156 const pet = clone(p)
157
158 if (pet.species === 'egg') {
159 pet.at = Math.max(pet.at, now)
160 hatch(pet, now, events)
161 return events.length ? { pet, events } : { pet: now > p.at ? pet : p, events }
162 }
163
164 if (now - pet.at > MAX_SIM) pet.at = now - MAX_SIM
165 const steps = Math.max(0, Math.floor((now - pet.at) / MINUTE))
166 for (let i = 0; i < steps && !pet.gone; i++) {
167 minute(pet, pet.at + MINUTE, mode, events)
168 pet.at += MINUTE
169 }
170 if (!pet.gone) grow(pet, now, events)
171 return steps > 0 || events.length > 0 ? { pet, events } : { pet: p, events }
172}
173
174function hatch(pet: Pet, now: number, events: LifeEvent[]): void {
175 const waited = now - pet.born
176 if (waited >= HATCH_AFTER && (pet.stats.toolCalls >= 1 || waited >= 3 * MINUTE)) {
177 pet.species = 'bit'
178 pet.hatchedAt = now
179 pet.evolvedAt = now
180 pet.lastActive = Math.max(pet.lastActive, now)
181 pet.weight = species('bit').weight
182 events.push({ kind: 'hatched' })
183 }
184}
185
186function minute(pet: Pet, t: number, mode: Settings['mode'], events: LifeEvent[]): void {
187 const m = Math.floor(t / MINUTE)
188
189 // sleep: a nap after a while with nothing going on; a tucked-in pet sleeps through Claude's work
190 if (pet.tucked && t - pet.lastActive >= TUCK_MAX) pet.tucked = false
191 if (!pet.asleep && t - pet.lastActive >= NAP_AFTER) {
192 pet.asleep = true
193 events.push({ kind: 'napped' })
194 }
195 const f = pet.asleep ? ASLEEP : 1
196
197 pet.hunger = Math.max(0, pet.hunger - HUNGER_PER_MIN * f * (pet.sick ? 1.25 : 1))
198 pet.happy = Math.max(0, pet.happy - HAPPY_PER_MIN * f * (1 + 0.25 * pet.bugs) * (pet.sick ? 2 : 1))
199
200 // bugs: one about half an hour after each meal, and now and then while awake
201 if (pet.lastMeal !== null) {
202 const due = pet.lastMeal + (28 + Math.floor(roll(pet.id, pet.lastMeal, 'digest') * 17)) * MINUTE
203 if (t >= due && t - MINUTE < due) addBug(pet, events)
204 }
205 if (!pet.asleep && roll(pet.id, m, 'bug') < 1 / 150) addBug(pet, events)
206
207 // sickness: bugs left lying around, starving, or too many snacks
208 if (pet.sick === 0) {
209 let risk = 0
210 if (pet.bugs >= 2) risk += 0.002 * pet.bugs
211 if (pet.hunger <= 0) risk += 0.004
212 if (pet.weight > species(pet.species).weight * 1.6) risk += 0.001
213 if (roll(pet.id, m, 'sick') < risk * f) {
214 pet.sick = roll(pet.id, m, 'doses') < 0.35 ? 2 : 1
215 pet.tantrum = false
216 events.push({ kind: 'sick' })
217 }
218 }
219
220 // a tantrum: calling for nothing, which is what discipline is for
221 const isLittle = pet.species === 'bit'
222 if (!pet.asleep && !pet.tantrum && !isLittle && pet.sick === 0 && pet.hunger > 50 && pet.happy > 50 && pet.discipline < 100) {
223 if (roll(pet.id, m, 'tantrum') < 1 / 300) {
224 pet.tantrum = true
225 events.push({ kind: 'tantrum' })
226 }
227 }
228
229 // calls: one left unanswered for a quarter of an awake hour is a care mistake
230 const need = needOf(pet)
231 if (need !== pet.calling) {
232 pet.calling = need
233 pet.callWait = 0
234 pet.callingCounted = false
235 if (need) events.push({ kind: 'calling', need })
236 } else if (need) {
237 pet.callWait += f
238 if (!pet.callingCounted && pet.callWait >= CALL_GRACE) {
239 pet.mistakes += 1
240 pet.callingCounted = true
241 events.push({ kind: 'mistake', need })
242 // an ignored tantrum blows over
243 if (need === 'tantrum') pet.tantrum = false
244 }
245 }
246
247 // hard neglect: starving and miserable, or sick. In classic mode, half an awake day of it and it leaves.
248 // Gentle mode counts none of it, so switching to classic starts the count from nothing.
249 const isHard = (pet.hunger <= 0 && pet.happy <= 0) || pet.sick > 0
250 if (mode === 'gentle') pet.neglect = 0
251 else pet.neglect = isHard ? Math.min(DEPART_AFTER, pet.neglect + f) : Math.max(0, pet.neglect - f / 2)
252 if (mode === 'classic' && isHard && pet.neglect >= DEPART_AFTER) {
253 pet.gone = { at: t, cause: 'neglect' }
254 pet.asleep = false
255 pet.calling = null
256 events.push({ kind: 'departed' })
257 }
258}
259
260function addBug(pet: Pet, events: LifeEvent[]): void {
261 if (pet.bugs >= MAX_BUGS) return
262 pet.bugs += 1
263 events.push({ kind: 'bug' })
264}
265
266/** Grows into the next form once it is old enough and has watched enough of Claude's work. */
267function grow(pet: Pet, now: number, events: LifeEvent[]): void {
268 const stage = stageOf(pet)
269 if (stage === 'egg' || stage === 'adult') return
270 const need = GROWTH[stage]
271 if (ageOf(pet, now) < need.age || pet.stats.toolCalls < need.toolCalls) return
272 const from = pet.species
273 const to: SpeciesId = stage === 'baby' ? 'nibble' : stage === 'child' ? (pet.mistakes <= 3 ? 'bytey' : 'glitchy') : adultFor(pet)
274 pet.weight = Math.max(1, pet.weight + species(to).weight - species(from).weight)
275 pet.species = to
276 pet.evolvedAt = now
277 events.push({ kind: 'evolved', from, to })
278}
279
280/** Which adult a teen becomes: care first, then the work it watched Claude do the most of. */
281export function adultFor(pet: Pet): SpeciesId {
282 const s = pet.stats
283 if (pet.mistakes >= 8 || (pet.species === 'glitchy' && pet.mistakes >= 6)) return 'spaghetto'
284 if (pet.mistakes <= 1 && pet.discipline >= 75) return 'unicorn'
285 if (s.turns >= 20 && s.nightTurns / s.turns >= 0.4) return 'owlgorithm'
286 // weighted so a habit counts for what it is, not for how often a tool happens to be called
287 const scores: [SpeciesId, number][] = [
288 ['grepossum', s.searches],
289 ['refactopus', s.edits * 1.5],
290 ['shellby', s.shell * 1.3],
291 ['assertoise', (s.tests + s.testFails) * 6],
292 ['commitmunk', s.commits * 25 + s.pushes * 15],
293 ]
294 return scores.reduce((best, one) => (one[1] > best[1] ? one : best))[0]
295}
296
297/** After care or work, what it calls about is what it needs now: a fed pet stops calling hungry at once. */
298function settle(out: Outcome): Outcome {
299 const pet = out.pet
300 const need = needOf(pet)
301 if (need === pet.calling) return out
302 return { ...out, pet: { ...pet, calling: need, callWait: 0, callingCounted: false } }
303}
304
305// ── the person's care ───────────────────────────────────────────────────────────────────────
306
307export type Care = 'meal' | 'snack' | 'clean' | 'medicine' | 'lights' | 'scold' | 'pat'
308
309export type Outcome = { pet: Pet; reaction?: ReactionKind; say?: string; ok: boolean }
310
311/** What the person did. The pet is assumed ticked to `now` already. */
312export function care(p: Pet, what: Care, now: number): Outcome {
313 return settle(careFor(p, what, now))
314}
315
316function careFor(p: Pet, what: Care, now: number): Outcome {
317 if (p.gone) return { pet: p, ok: false, say: `${p.name} is not here any more` }
318 if (p.species === 'egg') return { pet: p, ok: false, say: 'it is still an egg: give it a minute' }
319 const pet = clone(p)
320 const name = pet.name
321
322 // anything but the lights and a pat wakes it
323 if (what !== 'lights' && what !== 'pat' && pet.asleep) {
324 pet.asleep = false
325 pet.tucked = false
326 }
327 if (what !== 'lights') pet.lastActive = Math.max(pet.lastActive, now)
328
329 switch (what) {
330 case 'meal': {
331 if (pet.tantrum) return { pet, ok: false, reaction: 'refuse', say: `${name} won't eat. A tantrum: try discipline` }
332 if (pet.hunger >= 100) return { pet, ok: false, reaction: 'refuse', say: `${name} is full` }
333 pet.hunger = Math.min(100, pet.hunger + 25)
334 pet.weight += 1
335 pet.lastMeal = now
336 pet.stats.meals += 1
337 return { pet, ok: true, reaction: 'eat', say: pick(['*munch munch*', 'nom nom nom', 'tasty!', 'rice ball!'], roll(pet.id, now, 'say')) }
338 }
339 case 'snack': {
340 if (pet.tantrum) return { pet, ok: false, reaction: 'refuse', say: `${name} throws the candy away. A tantrum: try discipline` }
341 pet.happy = Math.min(100, pet.happy + 25)
342 pet.hunger = Math.min(100, pet.hunger + 5)
343 pet.weight += 2
344 pet.stats.snacks += 1
345 return { pet, ok: true, reaction: 'snack', say: pick(['sweet!!', 'yum, sugar', '*crunch*', 'more?'], roll(pet.id, now, 'say')) }
346 }
347 case 'clean': {
348 if (pet.bugs === 0) return { pet, ok: false, reaction: 'clean', say: 'already squeaky clean' }
349 const n = pet.bugs
350 pet.bugs = 0
351 pet.happy = Math.min(100, pet.happy + 4)
352 pet.stats.cleans += 1
353 return { pet, ok: true, reaction: 'clean', say: n === 1 ? 'squashed the bug' : `squashed ${n} bugs` }
354 }
355 case 'medicine': {
356 if (pet.sick === 0) {
357 pet.happy = Math.max(0, pet.happy - 5)
358 return { pet, ok: false, reaction: 'refuse', say: `${name} is not sick, and medicine tastes awful` }
359 }
360 pet.sick -= 1
361 pet.stats.meds += 1
362 return { pet, ok: true, reaction: 'medicine', say: pet.sick === 0 ? `${name} feels better` : 'one more dose to go' }
363 }
364 case 'lights': {
365 if (pet.asleep) {
366 pet.asleep = false
367 pet.tucked = false
368 pet.lastActive = Math.max(pet.lastActive, now)
369 return { pet, ok: true, reaction: 'wake', say: 'good morning!' }
370 }
371 pet.asleep = true
372 pet.tucked = true
373 pet.lastActive = now
374 return { pet, ok: true, reaction: 'sleep', say: 'lights out. zzz' }
375 }
376 case 'scold': {
377 if (pet.tantrum) {
378 pet.tantrum = false
379 pet.discipline = Math.min(100, pet.discipline + 25)
380 pet.stats.scolds += 1
381 return { pet, ok: true, reaction: 'scold', say: `${name} behaves now` }
382 }
383 pet.happy = Math.max(0, pet.happy - 20)
384 return { pet, ok: false, reaction: 'scold', say: `${name} did nothing wrong. :(` }
385 }
386 case 'pat': {
387 if (pet.asleep) return { pet, ok: false, say: 'shh, it is sleeping' }
388 if (now - pet.lastPat < 5_000) return { pet, ok: false, reaction: 'pat' }
389 pet.happy = Math.min(100, pet.happy + 2)
390 pet.lastPat = now
391 pet.lastActive = Math.max(pet.lastActive, now)
392 pet.stats.pats += 1
393 return { pet, ok: true, reaction: 'pat', say: pick(['♥', 'hehe', 'purr…', '*wiggles*'], roll(pet.id, now, 'say')) }
394 }
395 }
396}
397
398/** Whether a game can start, and why not. */
399export function canPlay(p: Pet): string | null {
400 if (p.gone) return `${p.name} is not here any more`
401 if (p.species === 'egg') return 'it is still an egg'
402 if (p.sick > 0) return `${p.name} is too sick to play`
403 if (p.hunger <= 0) return `${p.name} is too hungry to play`
404 return null
405}
406
407/** A finished game of five rounds. Three wins lift its mood; any game burns a gram. */
408export function played(p: Pet, wins: number, now: number): Outcome {
409 return settle(playedFor(p, wins, now))
410}
411
412function playedFor(p: Pet, wins: number, now: number): Outcome {
413 const pet = clone(p)
414 pet.asleep = false
415 pet.tucked = false
416 pet.lastActive = Math.max(pet.lastActive, now)
417 pet.stats.games += 1
418 pet.weight = Math.max(species(pet.species).weight - 5, pet.weight - 1)
419 pet.hunger = Math.max(0, pet.hunger - 4)
420 if (wins >= 3) {
421 pet.happy = Math.min(100, pet.happy + 25)
422 pet.stats.wins += 1
423 return { pet, ok: true, reaction: 'win', say: `won ${wins} of 5!` }
424 }
425 pet.happy = Math.min(100, pet.happy + 5)
426 return { pet, ok: true, reaction: 'lose', say: `${wins} of 5. again?` }
427}
428
429export function rename(p: Pet, name: string): Pet {
430 const clean = name.replace(/[^\p{L}\p{N} _.'-]/gu, '').trim().slice(0, 16)
431 return clean ? { ...p, name: clean } : p
432}
433
434export function startVacation(p: Pet, now: number): Pet {
435 return p.vacation !== null || p.gone ? p : { ...p, vacation: now }
436}
437
438/** Ends a vacation: the time away never happened, so its age and its growth skip it too. */
439export function endVacation(p: Pet, now: number): Pet {
440 if (p.vacation === null) return p
441 const away = Math.max(0, now - p.vacation)
442 return {
443 ...p,
444 vacation: null,
445 born: p.born + away,
446 hatchedAt: p.hatchedAt === null ? null : p.hatchedAt + away,
447 evolvedAt: p.evolvedAt + away,
448 at: now,
449 lastActive: now,
450 asleep: false,
451 tucked: false,
452 lastMeal: null,
453 }
454}
455
456export function memorialOf(p: Pet): Memorial {
457 return {
458 name: p.name,
459 species: p.species,
460 generation: p.generation,
461 born: p.born,
462 gone: p.gone?.at ?? p.at,
463 toolCalls: p.stats.toolCalls,
464 commits: p.stats.commits,
465 }
466}
467
468/** The next generation: a new egg, the old pet's name kept unless the person gives another. */
469export const nextEgg = (p: Pet, now: number): Pet => newPet(now, { generation: p.generation + 1 })
470
471// ── Claude's work ───────────────────────────────────────────────────────────────────────────
472
473export type Work = 'edit' | 'test-pass' | 'test-fail' | 'commit' | 'push' | 'shell' | 'search' | 'agent' | 'other'
474
475const EDITS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
476const SEARCHES = new Set(['Read', 'Grep', 'Glob', 'LS', 'WebSearch', 'WebFetch', 'NotebookRead', 'ToolSearch'])
477const AGENTS = new Set(['Task', 'Agent'])
478const SHELLS = new Set(['Bash', 'PowerShell'])
479
480// Test runners across the usual stacks, matched where a command starts (after any `cd x &&`, an
481// env assignment or `npx`), so `cat jest.config.js` or a commit message that names pytest is not a run.
482const TEST_START =
483 /^(?:(?:npx|bunx|pnpx|pnpm\s+(?:exec|dlx)|yarn\s+(?:exec|dlx)|uv\s+run|poetry\s+run|pipenv\s+run|hatch\s+run)\s+)?(?:jest|vitest|mocha|ava|tap|pytest|rspec|phpunit|tox|nox|ctest|playwright\s+test)\b|^(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b|^python3?\s+-m\s+(?:pytest|unittest)\b|^go\s+test\b|^cargo\s+(?:test|nextest)\b|^make\s+(?:test|check)\b|^(?:\.\/)?(?:mvn|mvnw|gradle|gradlew|sbt)\b.*\btest\b|^dotnet\s+test\b|^mix\s+test\b|^deno\s+test\b|^swift\s+test\b|^(?:claude\s+)?plugin\s+test\b/
484
485/** The commands of a shell line, each without leading env assignments or wrappers. */
486export function commandsOf(line: string): string[] {
487 return line
488 .split(/&&|\|\||;|\||\n/)
489 .map(part =>
490 part
491 .trim()
492 .replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, '')
493 .replace(/^(?:time|sudo|exec|env|command|nice)\s+/, ''),
494 )
495 .filter(Boolean)
496}
497
498const isGit = (part: string, verb: string) => new RegExp(`^git\\s+(?:-C\\s+\\S+\\s+|-c\\s+\\S+\\s+)*${verb}\\b`).test(part) && !/--dry-run\b/.test(part)
499
500/**
501 * What a finished tool call was, for the pet: `ok` is false when the tool failed and undefined
502 * when the call was refused (which feeds nothing). Shell lines are read command by command.
503 */
504export function classify(tool: string, command: string | undefined, ok: boolean | undefined): { work: Work; isError: boolean } | null {
505 if (ok === undefined) return null
506 if (SHELLS.has(tool) && command) {
507 const parts = commandsOf(command)
508 if (ok && parts.some(part => isGit(part, 'commit'))) return { work: 'commit', isError: false }
509 if (ok && parts.some(part => isGit(part, 'push'))) return { work: 'push', isError: false }
510 if (parts.some(part => TEST_START.test(part))) return { work: ok ? 'test-pass' : 'test-fail', isError: false }
511 return { work: 'shell', isError: !ok }
512 }
513 if (EDITS.has(tool)) return { work: ok ? 'edit' : 'other', isError: !ok }
514 if (SEARCHES.has(tool)) return { work: 'search', isError: !ok }
515 if (AGENTS.has(tool)) return { work: 'agent', isError: !ok }
516 return { work: 'other', isError: !ok }
517}
518
519/** Claude or the person was active: a napping pet wakes (a tucked-in one sleeps on). */
520export function touch(p: Pet, now: number): { pet: Pet; woke: boolean } {
521 if (p.gone) return { pet: p, woke: false }
522 const woke = p.asleep && !p.tucked
523 return { pet: { ...p, lastActive: Math.max(p.lastActive, now), asleep: woke ? false : p.asleep }, woke }
524}
525
526/** One tool call Claude finished. The pet is assumed ticked to `now` already. */
527export function work(p: Pet, w: Work, isError: boolean, now: number): Outcome {
528 return settle(workFor(p, w, isError, now))
529}
530
531function workFor(p: Pet, w: Work, isError: boolean, now: number): Outcome {
532 if (p.gone) return { pet: p, ok: false }
533 const pet = touch(clone(p), now).pet
534 const s = pet.stats
535 s.toolCalls += 1
536 if (isError) s.errors += 1
537 const isEgg = pet.species === 'egg'
538 const awake = !pet.asleep
539
540 switch (w) {
541 case 'edit':
542 s.edits += 1
543 if (!isEgg) pet.hunger = Math.min(100, pet.hunger + 1)
544 return { pet, ok: true, reaction: awake ? 'nibble' : undefined }
545 case 'test-pass':
546 s.tests += 1
547 if (isEgg) return { pet, ok: true }
548 pet.happy = Math.min(100, pet.happy + 6)
549 if (pet.bugs > 0) {
550 pet.bugs -= 1
551 return { pet, ok: true, reaction: 'cheer', say: 'the tests squashed a bug!' }
552 }
553 return { pet, ok: true, reaction: awake ? 'cheer' : undefined, say: awake ? pick(['tests pass! ♪', 'all green ♪', 'yay, green!'], roll(pet.id, now, 'say')) : undefined }
554 case 'test-fail':
555 s.testFails += 1
556 if (isEgg) return { pet, ok: true }
557 pet.happy = Math.max(0, pet.happy - 4)
558 pet.bugs = Math.min(MAX_BUGS, pet.bugs + 1)
559 return { pet, ok: true, reaction: 'flinch', say: pick(['a bug crawled out of the tests!', 'red… a bug!', 'eek, failing tests'], roll(pet.id, now, 'say')) }
560 case 'commit':
561 s.commits += 1
562 if (isEgg) return { pet, ok: true }
563 pet.happy = Math.min(100, pet.happy + 12)
564 pet.hunger = Math.min(100, pet.hunger + 3)
565 return { pet, ok: true, reaction: 'party', say: pick(['committed! party!', 'into the reflog it goes ♪', 'ship it!'], roll(pet.id, now, 'say')) }
566 case 'push':
567 s.pushes += 1
568 if (isEgg) return { pet, ok: true }
569 pet.happy = Math.min(100, pet.happy + 8)
570 return { pet, ok: true, reaction: 'party', say: 'pushed! wheee' }
571 case 'shell':
572 s.shell += 1
573 return { pet, ok: true, reaction: isError && awake && !isEgg ? 'flinch' : undefined }
574 case 'search':
575 s.searches += 1
576 return { pet, ok: true }
577 case 'agent':
578 s.agents += 1
579 return { pet, ok: true }
580 case 'other':
581 return { pet, ok: true, reaction: isError && awake && !isEgg ? 'flinch' : undefined }
582 }
583}
584
585/** A turn of Claude's ended: count it, the tokens it wrote, and whether it was late at night. */
586export function turnDone(p: Pet, tokens: number, hour: number, now: number): Pet {
587 if (p.gone) return p
588 const pet = touch(clone(p), now).pet
589 pet.stats.turns += 1
590 pet.stats.tokens += Math.max(0, Math.round(tokens))
591 if (hour >= 23 || hour < 5) pet.stats.nightTurns += 1
592 return pet
593}
594hooks/pet/scene.ts 333 lines1import type { ReactionKind, SpeciesId } from '../../types'
2import { blank, draw, invert, spriteWidth, type Bitmap } from './bitmap.ts'
3import { roll } from './rng.ts'
4import * as S from './sprites.ts'
5import type { Sprite } from './sprites.ts'
6import { SPECIES } from './species.ts'
7
8// What the LCD shows at a moment: the pet walking about, its bugs, the icons around it, and the
9// animation of whatever just happened. `scene` is a pure function of what the hooks module knows
10// (SceneInput) and the screen's clock, and returns a display list: the terminal rasterizes it into
11// a Bitmap, the desktop turns a run of them into an animated SVG.
12
13/** Every picture a scene can place, by name: the SVG defines each once and reuses it. */
14export const ART: Record<string, Sprite> = {
15 ...Object.fromEntries(Object.values(SPECIES).map(s => [s.id, s.sprite])),
16 angel: S.ANGEL,
17 crack: S.CRACK,
18 bug0: S.BUG[0]!,
19 bug1: S.BUG[1]!,
20 rice0: S.RICE[0]!,
21 rice1: S.RICE[1]!,
22 rice2: S.RICE[2]!,
23 candy0: S.CANDY[0]!,
24 candy1: S.CANDY[1]!,
25 candy2: S.CANDY[2]!,
26 pill: S.PILL,
27 heart: S.SMALL_HEART,
28 note: S.NOTE,
29 skull: S.SKULL,
30 bang: S.BANG,
31 sweat: S.SWEAT,
32 zz: S.ZZ,
33 z: S.SMALL_Z,
34 spark0: S.SPARK[0]!,
35 spark1: S.SPARK[1]!,
36 left: S.ARROW_LEFT,
37 right: S.ARROW_RIGHT,
38 check: S.CHECK,
39 cross: S.CROSS,
40 star: S.STAR,
41 wave: S.WAVE,
42}
43
44export type ArtName = keyof typeof ART & string
45
46export type Draw = { art: ArtName; x: number; y: number; flip?: boolean; eyesClosed?: boolean; mouthOpen?: boolean }
47export type Frame = { draws: Draw[]; dark: boolean }
48
49/** Everything about the pet a screen needs, as plain data: what the hooks module hands a Client. */
50export type SceneInput = {
51 species: SpeciesId
52 seed: string
53 asleep: boolean
54 tucked: boolean
55 sick: boolean
56 bugs: number
57 calling: boolean
58 tantrum: boolean
59 sad: boolean
60 working: boolean
61 gone: boolean
62 reaction: { kind: ReactionKind; id: number; from?: SpeciesId } | null
63 game: { answer: 'left' | 'right' | null; guess: 'left' | 'right' | null; round: number } | null
64}
65
66/** How long each animation plays, in milliseconds. */
67export const REACTION_MS: Record<ReactionKind, number> = {
68 eat: 2400,
69 snack: 2400,
70 refuse: 1500,
71 happy: 1400,
72 pat: 1400,
73 clean: 1800,
74 medicine: 1600,
75 scold: 1400,
76 cheer: 1200,
77 flinch: 900,
78 party: 2000,
79 nibble: 500,
80 hatch: 2600,
81 evolve: 3200,
82 sleep: 1200,
83 wake: 1000,
84 win: 2000,
85 lose: 1500,
86}
87
88const SEGMENT = 2400
89const STEP = 130
90/** The walk repeats after this many segments, so the desktop can loop it seamlessly. */
91export const WALK_SEGMENTS = 4
92export const WALK_LOOP = SEGMENT * WALK_SEGMENTS
93
94const size = (art: ArtName) => {
95 const s = ART[art]!
96 return { w: spriteWidth(s), h: s.length }
97}
98
99/** Where a walking pet is at time t: it strolls to a new spot each segment, then waits. */
100export function walk(seed: string, t: number, maxX: number): { x: number; flip: boolean; moving: boolean; hop: boolean } {
101 if (maxX <= 0) return { x: 0, flip: false, moving: false, hop: false }
102 const k = Math.floor(t / SEGMENT)
103 const spot = (n: number) => Math.round(roll(seed, 'walk', ((n % WALK_SEGMENTS) + WALK_SEGMENTS) % WALK_SEGMENTS) * maxX)
104 const from = spot(k)
105 const to = spot(k + 1)
106 const local = t - k * SEGMENT
107 const dx = to - from
108 const moved = Math.min(Math.abs(dx), Math.floor(local / STEP))
109 const moving = moved < Math.abs(dx)
110 return { x: from + Math.sign(dx) * moved, flip: dx < 0 || (dx === 0 && spot(k - 1) > from), moving, hop: moving && Math.floor(local / (STEP * 2)) % 2 === 1 }
111}
112
113/**
114 * The display list at clock `t` (ms since the screen began) on a w×h screen. `rt` is how long the
115 * current reaction has been playing, or null when none is.
116 */
117export function scene(input: SceneInput, t: number, rt: number | null, w = 32, h = 16): Frame {
118 const draws: Draw[] = []
119 const reaction = input.reaction && rt !== null && rt < REACTION_MS[input.reaction.kind] ? input.reaction.kind : null
120 const r = rt ?? 0
121 const blink = (period: number, on: number) => t % period < on
122
123 if (input.gone) {
124 const { w: sw, h: sh } = size('angel')
125 const bob = Math.floor(t / 600) % 2
126 draws.push({ art: 'angel', x: Math.floor((w - sw) / 2), y: h - sh - 1 - bob })
127 return { draws, dark: false }
128 }
129
130 if (input.species === 'egg' || (reaction === 'hatch' && r < 2100)) {
131 return egg(input, t, reaction === 'hatch' ? r : null, w, h)
132 }
133
134 // the pet's sprite this frame: during an evolution it flickers between the old form and the new
135 let art: ArtName = input.species
136 if (reaction === 'evolve' && input.reaction?.from && Math.floor(r / 250) % 2 === 0 && r < REACTION_MS.evolve - 600) art = input.reaction.from
137 const { w: sw, h: sh } = size(art)
138
139 // bugs stand at the right edge, in one column or two; the pet keeps out of their way
140 const bugCols = input.bugs === 0 ? 0 : input.bugs <= 2 ? 1 : 2
141 // the right edge also holds the corner icon (a spark while Claude works, a skull while sick)
142 const corner = !input.asleep && (input.working || input.sick) ? 6 : 0
143 const room = w - Math.max(bugCols * 6, corner)
144 const maxX = Math.max(0, room - sw)
145 const center = Math.floor(maxX / 2)
146
147 // lights out: the screen goes dark and only the Z's show
148 if (input.tucked && reaction !== 'wake') {
149 const z = Math.floor(t / 700) % 2 === 0 ? 'zz' : 'z'
150 const { w: zw } = size(z)
151 draws.push({ art: z, x: Math.floor((w - zw) / 2) + (z === 'z' ? 3 : 0), y: Math.floor(h / 2) - 2 - (z === 'z' ? 2 : 0) })
152 return { draws, dark: true }
153 }
154
155 let x = center
156 let y = h - sh
157 let flip = false
158 let eyesClosed = false
159 let mouthOpen = false
160 const front: Draw[] = []
161
162 const isStill = input.asleep || input.sick || input.game !== null || (reaction !== null && reaction !== 'nibble' && reaction !== 'cheer' && reaction !== 'flinch')
163 if (!isStill) {
164 const step = walk(input.seed, t, maxX)
165 x = step.x
166 flip = step.flip
167 if (step.hop) y -= 1
168 } else if (input.sick && !reaction) {
169 // too sick to stroll: shuffles a little
170 x = Math.max(0, Math.min(maxX, center + (Math.floor(t / 900) % 2)))
171 }
172
173 // a blink every few seconds
174 if (blink(3700, 160)) eyesClosed = true
175
176 if (input.asleep) {
177 eyesClosed = true
178 const z = Math.floor(t / 800) % 2 === 0 ? 'zz' : 'z'
179 const zx = x + sw + 4 <= room ? x + sw : Math.max(0, x - 4)
180 front.push({ art: z, x: zx, y: z === 'zz' ? 0 : 1 })
181 }
182
183 if (input.game) {
184 // it stands in the middle; the way it looked, and whether the guess was right, at that side
185 const g = input.game
186 x = center
187 if (g.answer) {
188 flip = g.answer === 'left'
189 const side = g.answer === 'left' ? 0 : room - 7
190 front.push({ art: g.answer === 'left' ? 'left' : 'right', x: g.answer === 'left' ? 0 : room - 4, y: 1 })
191 front.push({ art: g.guess === g.answer ? 'check' : 'cross', x: side, y: 9 })
192 } else if (blink(800, 400)) {
193 front.push({ art: 'left', x: 0, y: 4 }, { art: 'right', x: room - 4, y: 4 })
194 }
195 }
196
197 switch (reaction) {
198 case 'eat':
199 case 'snack': {
200 x = Math.min(maxX, 2)
201 flip = false
202 const bite = Math.floor(r / 600)
203 mouthOpen = bite % 2 === 1
204 const food = `${reaction === 'eat' ? 'rice' : 'candy'}${Math.min(2, Math.floor(r / 800))}` as ArtName
205 front.push({ art: food, x: x + sw + 1, y: h - 8 })
206 break
207 }
208 case 'refuse':
209 flip = Math.floor(r / 250) % 2 === 1
210 eyesClosed = true
211 front.push({ art: 'cross', x: Math.min(w - 6, x + sw + 1), y: 2 })
212 break
213 case 'happy':
214 case 'pat':
215 case 'win': {
216 eyesClosed = true
217 y -= Math.floor(r / 200) % 2 === 1 ? 2 : 0
218 const rise = Math.floor(r / 350)
219 front.push({ art: 'heart', x: Math.min(w - 5, x + sw), y: Math.max(0, 5 - rise) })
220 if (reaction === 'win') front.push({ art: 'heart', x: Math.max(0, x - 5), y: Math.max(0, 7 - rise) })
221 break
222 }
223 case 'cheer': {
224 y -= Math.floor(r / 200) % 2 === 1 ? 2 : 0
225 mouthOpen = true
226 front.push({ art: 'note', x: Math.min(w - 5, x + sw), y: Math.max(0, 4 - Math.floor(r / 300)) })
227 break
228 }
229 case 'party': {
230 y -= Math.floor(r / 250) % 2 === 1 ? 2 : 0
231 mouthOpen = true
232 // confetti around it, never over its face
233 const burst = Math.floor(r / 250)
234 const spots = [...Array(Math.max(0, x - 5)).keys(), ...Array.from({ length: Math.max(0, room - 5 - (x + sw)) }, (_, i) => x + sw + 1 + i)]
235 for (let i = 0; i < 3 && spots.length; i++) {
236 const sx = spots[Math.floor(roll(input.reaction!.id, burst, i, 'x') * spots.length)]!
237 front.push({ art: 'star', x: sx, y: Math.floor(roll(input.reaction!.id, burst, i, 'y') * 11) })
238 }
239 break
240 }
241 case 'flinch':
242 eyesClosed = r > 300
243 if (r < 300) y -= 2
244 front.push({ art: 'bang', x: Math.min(w - 2, x + sw + 1), y: 1 })
245 break
246 case 'nibble':
247 mouthOpen = r < 250
248 break
249 case 'medicine':
250 eyesClosed = r > 500
251 front.push({ art: 'pill', x: Math.min(w - 8, x + sw + 1), y: r < 800 ? 6 : 8 })
252 if (r > 1100) x = Math.max(0, x + (Math.floor(r / 100) % 2))
253 break
254 case 'scold':
255 eyesClosed = true
256 y += 1
257 front.push({ art: 'bang', x: Math.min(w - 2, x + sw + 1), y: 0 })
258 break
259 case 'lose':
260 eyesClosed = true
261 front.push({ art: 'sweat', x: Math.max(0, x - 4), y: 3 })
262 break
263 case 'wake':
264 y -= r < 300 ? 2 : 0
265 front.push({ art: 'bang', x: Math.min(w - 2, x + sw + 1), y: 1 })
266 break
267 case 'sleep':
268 eyesClosed = true
269 break
270 case 'hatch':
271 y -= Math.floor(r / 150) % 2 === 1 ? 1 : 0
272 break
273 default:
274 break
275 }
276
277 // the state around it, when no animation says otherwise
278 if (!reaction && !input.asleep) {
279 // the corner above the bugs: a skull while sick, else a spark while Claude works
280 if (input.sick) {
281 if (blink(1000, 600)) front.push({ art: 'skull', x: w - 6, y: 0 })
282 } else if (input.working) front.push({ art: Math.floor(t / 350) % 2 ? 'spark1' : 'spark0', x: w - 5, y: 0 })
283 if (input.tantrum) {
284 x = Math.max(0, Math.min(maxX, x + (Math.floor(t / 150) % 2 ? 1 : -1)))
285 if (blink(500, 300)) front.push({ art: 'bang', x: Math.min(room - 2, x + sw + 1), y: 1 })
286 } else if (input.calling && blink(1000, 500)) {
287 front.push({ art: 'bang', x: Math.min(room - 2, x + sw + 1), y: 1 })
288 }
289 if (input.sad && !input.calling) front.push({ art: 'sweat', x: Math.max(0, x - 4), y: 3 })
290 }
291
292 // bugs, wiggling; a clean washes them away right to left
293 const wave = reaction === 'clean' ? w - Math.floor((r / REACTION_MS.clean) * (w + 6)) : null
294 for (let i = 0; i < input.bugs; i++) {
295 const bx = w - 5 - (i >= 2 ? 6 : 0)
296 if (wave !== null && bx >= wave) continue
297 draws.push({ art: (Math.floor(t / 400) + i) % 2 ? 'bug1' : 'bug0', x: bx, y: h - 5 - (i % 2) * 6 })
298 }
299 if (wave !== null) draws.push({ art: 'wave', x: wave, y: 0 })
300
301 draws.push({ art, x, y, flip, eyesClosed, mouthOpen })
302 draws.push(...front)
303 return { draws, dark: false }
304}
305
306function egg(input: SceneInput, t: number, r: number | null, w: number, h: number): Frame {
307 const { w: ew, h: eh } = size('egg')
308 const x = Math.floor((w - ew) / 2)
309 const draws: Draw[] = []
310 if (r !== null) {
311 // hatching: a fast wobble, the crack, a flash
312 const tilt = r < 1200 ? (Math.floor(r / 120) % 2 ? 1 : -1) : 0
313 draws.push({ art: 'egg', x: x + tilt, y: h - eh })
314 if (r >= 900) draws.push({ art: 'crack', x: x + tilt, y: h - eh })
315 return { draws, dark: r >= 1700 && Math.floor(r / 100) % 2 === 0 }
316 }
317 const period = input.working ? 900 : 2600
318 const phase = t % period
319 const tilt = phase < 240 ? -1 : phase < 480 ? 1 : 0
320 draws.push({ art: 'egg', x: x + tilt, y: h - eh })
321 return { draws, dark: false }
322}
323
324/** Rasterizes a frame onto a w×h bitmap. */
325export function rasterize(frame: Frame, w = 32, h = 16): Bitmap {
326 const b = blank(w, h)
327 for (const d of frame.draws) {
328 const s = ART[d.art]
329 if (s) draw(b, s, d.x, d.y, { flip: d.flip, eyesClosed: d.eyesClosed, mouthOpen: d.mouthOpen })
330 }
331 return frame.dark ? invert(b) : b
332}
333hooks/pet/species.ts 44 lines1import type { SpeciesId, Stage } from '../../types'
2import * as S from './sprites.ts'
3import type { Sprite } from './sprites.ts'
4
5// Who the pet can be. It hatches as a Bit, grows into a Nibble, then a teen picked by how well it
6// was cared for, then one of eight adults picked by care and by the work it watched Claude do.
7
8export type Species = {
9 id: SpeciesId
10 name: string
11 stage: Stage
12 sprite: Sprite
13 /** One line about it, for the stats page and the README. */
14 blurb: string
15 /** How it is reached, for the evolution chart. */
16 how: string
17 /** Grams it weighs when it reaches this form. */
18 weight: number
19 /** Its face beside the spinner and in the one-line band: content, then sleepy. */
20 face: string
21}
22
23export const SPECIES: Record<SpeciesId, Species> = {
24 egg: { id: 'egg', name: 'Egg', stage: 'egg', sprite: S.EGG, blurb: 'Something is moving inside.', how: 'Where every pet starts.', weight: 5, face: '( )' },
25 bit: { id: 'bit', name: 'Bit', stage: 'baby', sprite: S.BIT, blurb: 'The smallest unit of joy.', how: 'Hatches after a minute and a tool call.', weight: 5, face: '(•ω•)' },
26 nibble: { id: 'nibble', name: 'Nibble', stage: 'child', sprite: S.NIBBLE, blurb: 'Four bits of mischief.', how: '1 hour old and 40 tool calls watched.', weight: 10, face: '(=^ω^=)' },
27 bytey: { id: 'bytey', name: 'Bytey', stage: 'teen', sprite: S.BYTEY, blurb: 'A well-raised teenager. Eight bits, all in order.', how: '1 day old, 250 tool calls, few care mistakes.', weight: 18, face: 'ʕ•ω•ʔ' },
28 glitchy: { id: 'glitchy', name: 'Glitchy', stage: 'teen', sprite: S.GLITCHY, blurb: 'Some of its bits got flipped along the way.', how: '1 day old, 250 tool calls, many care mistakes.', weight: 16, face: '(⊙_⊙)' },
29 assertoise: { id: 'assertoise', name: 'Assertoise', stage: 'adult', sprite: S.ASSERTOISE, blurb: 'Slow, steady, and every test green. Carries the checkmark on its back.', how: 'Grows up watching Claude run tests.', weight: 30, face: '(✓_✓)' },
30 refactopus: { id: 'refactopus', name: 'Refactopus', stage: 'adult', sprite: S.REFACTOPUS, blurb: 'Eight arms, eight files open, zero fear of a big diff.', how: 'Grows up watching Claude edit code.', weight: 26, face: '(◕ω◕)' },
31 shellby: { id: 'shellby', name: 'Shellby', stage: 'adult', sprite: S.SHELLBY, blurb: 'Lives in the terminal. Pipes things into other things.', how: 'Grows up watching Claude run shell commands.', weight: 24, face: '(°▽°)' },
32 grepossum: { id: 'grepossum', name: 'Grepossum', stage: 'adult', sprite: S.GREPOSSUM, blurb: 'Reads every file before it says a word. Finds what you lost.', how: 'Grows up watching Claude read and search.', weight: 22, face: '(ºoº)' },
33 commitmunk: { id: 'commitmunk', name: 'Commitmunk', stage: 'adult', sprite: S.COMMITMUNK, blurb: 'Stuffs its cheeks with commits and buries them in the reflog.', how: 'Grows up watching Claude commit and push.', weight: 28, face: '(^•^)' },
34 owlgorithm: { id: 'owlgorithm', name: 'Owlgorithm', stage: 'adult', sprite: S.OWLGORITHM, blurb: 'Does its best work after midnight. Who? You.', how: 'Grows up coding late at night.', weight: 25, face: '(⊙v⊙)' },
35 spaghetto: { id: 'spaghetto', name: 'Spaghetto', stage: 'adult', sprite: S.SPAGHETTO, blurb: 'Tangled, saucy, and somehow still running in production.', how: 'Grows up with many care mistakes.', weight: 34, face: '(@_@)' },
36 unicorn: { id: 'unicorn', name: 'Unicorn', stage: 'adult', sprite: S.UNICORN, blurb: 'The mythical 10x pet. Raised with perfect care and firm discipline.', how: 'At most one care mistake and discipline of 75 or more.', weight: 22, face: '(✧ω✧)' },
37}
38
39export const ADULTS: readonly SpeciesId[] = ['assertoise', 'refactopus', 'shellby', 'grepossum', 'commitmunk', 'owlgorithm', 'spaghetto', 'unicorn']
40
41export const species = (id: SpeciesId): Species => SPECIES[id] ?? SPECIES.bit
42
43export const isSpecies = (value: unknown): value is SpeciesId => typeof value === 'string' && Object.hasOwn(SPECIES, value)
44hooks/pet/svg.ts 191 lines1import type { ShellColor } from '../../types'
2import { blank, draw, spriteWidth } from './bitmap.ts'
3import { ART, REACTION_MS, scene, WALK_LOOP, type ArtName, type Draw, type SceneInput } from './scene.ts'
4
5// The pet for surfaces that draw SVG (the desktop app, the mobile app, the editor): a whole
6// animation in one document. Each picture is defined once and placed by <use>; the frames are
7// groups shown in turn by SMIL, so the drawing moves without the hooks module redrawing it.
8
9export const LCD = { bg: '#b8c4a0', ink: '#2d3226', ghost: '#a7b38f', dark: '#3a4032', glow: '#c9d6ad' } as const
10
11export const SHELLS: Record<ShellColor, { base: string; dark: string; light: string }> = {
12 coral: { base: '#e8846b', dark: '#b4553f', light: '#f6b9a6' },
13 mint: { base: '#76c9a6', dark: '#3f8f6d', light: '#c3eedb' },
14 grape: { base: '#a58ad8', dark: '#6c55a3', light: '#d9cdf3' },
15 sky: { base: '#77b4ef', dark: '#457fba', light: '#c7e2fb' },
16 banana: { base: '#f0cd62', dark: '#b9922c', light: '#fbecb6' },
17 ink: { base: '#474c59', dark: '#24272e', light: '#7b8191' },
18}
19
20const esc = (s: string) => s.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`)
21
22/** One path for a picture: a 1×1 rectangle per run of lit pixels in a row. */
23function pathOf(art: ArtName, variant: Pick<Draw, 'flip' | 'eyesClosed' | 'mouthOpen'>): string {
24 const s = ART[art]!
25 const w = spriteWidth(s)
26 const b = blank(w, s.length)
27 draw(b, s, 0, 0, variant)
28 let d = ''
29 for (let y = 0; y < b.h; y++) {
30 let x = 0
31 while (x < w) {
32 if (!b.px[y * w + x]) {
33 x++
34 continue
35 }
36 let n = 1
37 while (x + n < w && b.px[y * w + x + n]) n++
38 d += `M${x} ${y}h${n}v1h-${n}z`
39 x += n
40 }
41 }
42 return d
43}
44
45type Clip = { frames: { draws: Draw[]; dark: boolean; ms: number }[]; loop: boolean }
46
47/**
48 * Samples the scene into frames: the reaction once if one is playing (`rt` ms into it), else the
49 * walk's whole loop. Equal neighbours merge into one longer frame.
50 */
51export function clip(input: SceneInput, rt: number | null, w: number, h: number, every = 130): Clip {
52 const playing = input.reaction && rt !== null && rt < REACTION_MS[input.reaction.kind]
53 const from = playing ? rt! : 0
54 const until = playing ? REACTION_MS[input.reaction!.kind] : WALK_LOOP
55 const frames: Clip['frames'] = []
56 let last = ''
57 for (let t = from; t < until; t += every) {
58 const f = scene(input, playing ? t : t, playing ? t : null, w, h)
59 const k = JSON.stringify(f)
60 if (k === last) frames[frames.length - 1]!.ms += every
61 else frames.push({ ...f, ms: every })
62 last = k
63 }
64 return { frames, loop: !playing }
65}
66
67/**
68 * The frames as SVG: <defs> holding each picture once, then one group per frame. `scale` is the
69 * size of a pixel, (ox, oy) where the screen's corner sits, `ink` the lit color.
70 */
71function animate(c: Clip, ox: number, oy: number, scale: number, w: number, h: number, ink: string, dark: { bg: string; ink: string } | null, idPrefix: string): string {
72 const ids = new Map<string, string>()
73 let defs = ''
74 const ref = (d: Draw) => {
75 const variant = { flip: d.flip, eyesClosed: d.eyesClosed, mouthOpen: d.mouthOpen }
76 const key = `${d.art}|${d.flip ? 1 : 0}${d.eyesClosed ? 1 : 0}${d.mouthOpen ? 1 : 0}`
77 let id = ids.get(key)
78 if (!id) {
79 id = `${idPrefix}${ids.size}`
80 ids.set(key, id)
81 defs += `<path id="${id}" d="${pathOf(d.art, variant)}"/>`
82 }
83 return id
84 }
85 const total = c.frames.reduce((n, f) => n + f.ms, 0)
86 const dur = `${(total / 1000).toFixed(2)}s`
87 let at = 0
88 let body = ''
89 c.frames.forEach((f, i) => {
90 const uses = f.draws.map(d => `<use href="#${ref(d)}" x="${d.x}" y="${d.y}"/>`).join('')
91 const fill = f.dark && dark ? dark.ink : ink
92 const bg = f.dark && dark ? `<rect width="${w}" height="${h}" fill="${dark.bg}"/>` : ''
93 const a = at / total
94 const b = (at + f.ms) / total
95 at += f.ms
96 let anim = ''
97 if (c.frames.length > 1) {
98 // shown from a to b of each cycle; a clip that plays once holds its last frame
99 const isLast = i === c.frames.length - 1
100 const repeat = c.loop ? ' repeatCount="indefinite"' : ' fill="freeze"'
101 const values = i === 0 ? '1;0' : isLast && !c.loop ? '0;1' : '0;1;0'
102 const times = i === 0 ? `0;${b.toFixed(4)}` : isLast && !c.loop ? `0;${a.toFixed(4)}` : `0;${a.toFixed(4)};${b.toFixed(4)}`
103 anim = `<animate attributeName="opacity" values="${values}" keyTimes="${times}" dur="${dur}" calcMode="discrete"${repeat}/>`
104 }
105 body += `<g fill="${fill}"${i === 0 || c.frames.length === 1 ? '' : ' opacity="0"'}>${anim}${bg}${uses}</g>`
106 })
107 return `<defs>${defs}</defs><g transform="translate(${ox} ${oy}) scale(${scale})">${body}</g>`
108}
109
110export type DeviceView = {
111 input: SceneInput
112 rt: number | null
113 name: string
114 shell: ShellColor
115 /** Makes the document's ids its own, for a page that inlines several. */
116 uid?: string
117 /** One still frame at this moment instead of the animation (the README's pictures). */
118 at?: { t: number; rt: number | null }
119 /** Which of the eight icons around the screen are lit. */
120 icons: { food: boolean; light: boolean; game: boolean; medicine: boolean; bath: boolean; meter: boolean; discipline: boolean; attention: boolean }
121}
122
123// the eight icons around the screen, 7×7 each
124const ICONS: Record<keyof DeviceView['icons'], string[]> = {
125 food: ['..###..', '.#####.', '##...##', '#.....#', '#.###.#', '#.###.#', '.#####.'],
126 light: ['..###..', '.#...#.', '#.....#', '#.....#', '.#...#.', '..###..', '..###..'],
127 game: ['..###..', '.#.#.#.', '#..#..#', '#######', '#..#..#', '.#.#.#.', '..###..'],
128 medicine: ['...#...', '..###..', '...#...', '.#####.', '.#...#.', '.#...#.', '.#####.'],
129 bath: ['.......', '.#..#..', '#.##.##', '.......', '.#..#..', '#.##.##', '.......'],
130 meter: ['......#', '.....##', '....###', '...####', '..#####', '.######', '#######'],
131 discipline: ['..###..', '..###..', '..###..', '..###..', '.......', '..###..', '..###..'],
132 attention: ['.#####.', '#.....#', '#.#.#.#', '#.....#', '#.###.#', '#.....#', '.#####.'],
133}
134
135function icon(name: keyof DeviceView['icons'], x: number, y: number, on: boolean, blinks: boolean): string {
136 let d = ''
137 ICONS[name].forEach((row, iy) => {
138 for (let ix = 0; ix < row.length; ix++) if (row[ix] === '#') d += `M${ix} ${iy}h1v1h-1z`
139 })
140 const anim = on && blinks ? `<animate attributeName="opacity" values="1;0.15;1" keyTimes="0;0.5;1" dur="1s" calcMode="discrete" repeatCount="indefinite"/>` : ''
141 return `<path transform="translate(${x} ${y}) scale(1.4)" d="${d}" fill="${on ? LCD.ink : LCD.ghost}">${anim}</path>`
142}
143
144/** The whole device: an egg-shaped shell, the screen with its icons, three buttons, the name. */
145export function deviceSvg(v: DeviceView): string {
146 const c = SHELLS[v.shell] ?? SHELLS.coral
147 const u = v.uid ?? 't'
148 const W = 220
149 const H = 250
150 const px = 4
151 const sx = 46
152 const sy = 84
153 const frames: Clip = v.at ? { frames: [{ ...scene(v.input, v.at.t, v.at.rt, 32, 16), ms: 1 }], loop: false } : clip(v.input, v.rt, 32, 16)
154 const screen = animate(frames, sx, sy, px, 32, 16, LCD.ink, { bg: LCD.dark, ink: LCD.glow }, `${u}p`)
155 const top = (['food', 'light', 'game', 'medicine'] as const).map((n, i) => icon(n, 52 + i * 32, 68, v.icons[n], false)).join('')
156 const bottom = (['bath', 'meter', 'discipline', 'attention'] as const).map((n, i) => icon(n, 52 + i * 32, 155, v.icons[n], n === 'attention')).join('')
157 const name = esc(v.name.toUpperCase().slice(0, 12))
158 return [
159 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">`,
160 `<defs><radialGradient id="${u}shell" cx="38%" cy="30%" r="75%"><stop offset="0" stop-color="${c.light}"/><stop offset="0.55" stop-color="${c.base}"/><stop offset="1" stop-color="${c.dark}"/></radialGradient>`,
161 `<linearGradient id="${u}lcd" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c3cfab"/><stop offset="1" stop-color="${LCD.bg}"/></linearGradient></defs>`,
162 // the key ring and the shell
163 `<circle cx="110" cy="11" r="8" fill="none" stroke="${c.dark}" stroke-width="3"/>`,
164 `<path d="M110 16 C176 16 210 92 210 152 C210 214 168 246 110 246 C52 246 10 214 10 152 C10 92 44 16 110 16 Z" fill="url(#${u}shell)" stroke="${c.dark}" stroke-width="2"/>`,
165 `<path d="M70 40 C84 30 100 27 112 27" fill="none" stroke="#fff" stroke-opacity="0.45" stroke-width="5" stroke-linecap="round"/>`,
166 `<text x="110" y="53" text-anchor="middle" font-family="ui-monospace, Menlo, monospace" font-size="10" font-weight="700" letter-spacing="3" fill="${c.dark}">${name}</text>`,
167 // the screen's bezel and glass
168 `<rect x="36" y="60" width="148" height="112" rx="14" fill="${c.dark}"/>`,
169 `<rect x="40" y="64" width="140" height="104" rx="10" fill="url(#${u}lcd)"/>`,
170 top,
171 bottom,
172 `<g>${screen}</g>`,
173 // the three buttons
174 ...[
175 [76, 200, 'A'],
176 [110, 212, 'B'],
177 [144, 200, 'C'],
178 ].map(([x, y, l]) => `<circle cx="${x}" cy="${y}" r="10" fill="${c.dark}"/><circle cx="${x}" cy="${(y as number) - 1.5}" r="8.5" fill="${c.light}"/><text x="${x}" y="${(y as number) + 18}" text-anchor="middle" font-family="ui-monospace, Menlo, monospace" font-size="7" fill="${c.dark}">${l}</text>`),
179 `</svg>`,
180 ].join('')
181}
182
183/** The pet strolling along a strip: the band above the prompt, where SVG is drawn. */
184export function laneSvg(input: SceneInput, rt: number | null, color: string, columns = 96, uid = 'l'): string {
185 const px = 3
186 const w = columns
187 const h = 16
188 const body = animate(clip(input, rt, w, h), 0, 0, px, w, h, color, { bg: color, ink: '#ffffff' }, uid)
189 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w * px} ${h * px}" width="${w * px}" height="${h * px}">${body}</svg>`
190}
191hooks/pet/words.ts 79 lines1import type { Need, Pet } from '../../types'
2import { hearts, stageOf } from './life.ts'
3import { species } from './species.ts'
4
5// What the pet looks like and says in text: its face, its meters, the line about how it is doing.
6
7export const meter = (value: number, full = '♥', empty = '♡'): string => full.repeat(hearts(value)) + empty.repeat(4 - hearts(value))
8
9/** Discipline as four bars. */
10export const bars = (value: number): string => '■'.repeat(hearts(value)) + '□'.repeat(4 - hearts(value))
11
12/** Its face as text: for the spinner and the one-line band. */
13export function face(p: Pet): string {
14 if (p.gone) return '( )'
15 if (p.species === 'egg') return '( )'
16 if (p.asleep) return '(-_-)zZ'
17 if (p.sick > 0) return '(×_×)'
18 if (p.tantrum) return '(ò_ó)'
19 if (p.hunger <= 0 || p.happy <= 0) return '(;_;)'
20 if (p.happy < 40) return '(._.)'
21 return species(p.species).face
22}
23
24export const NEED_TEXT: Record<Need, string> = {
25 hungry: 'is hungry',
26 sad: 'is bored and sad',
27 sick: 'is sick: medicine!',
28 bugs: 'is surrounded by bugs',
29 tantrum: 'is throwing a tantrum',
30}
31
32/** One line about how it is doing, most pressing first. */
33export function statusLine(p: Pet): string {
34 if (p.gone) return `${p.name} has left for the cloud. /tama new for a new egg`
35 if (p.vacation !== null) return `${p.name} is on vacation. /tama vacation off when you are back`
36 if (p.species === 'egg') return 'an egg. it hatches when Claude gets to work'
37 if (p.calling) return `${p.name} ${NEED_TEXT[p.calling]}`
38 if (p.tucked) return `${p.name} is tucked in`
39 if (p.asleep) return `${p.name} is napping`
40 if (p.bugs > 0) return `${p.name} left ${p.bugs === 1 ? 'a bug' : `${p.bugs} bugs`} behind`
41 if (p.hunger < 30) return `${p.name} is getting peckish`
42 if (p.happy < 30) return `${p.name} wants to play`
43 return `${p.name} is ${p.happy >= 75 ? 'happy' : 'doing fine'}`
44}
45
46export function title(p: Pet): string {
47 const s = species(p.species)
48 return stageOf(p) === 'egg' ? 'an egg' : `${p.name} the ${s.name}`
49}
50
51export const compact = (n: number): string =>
52 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
53
54/** What the pet says when a need starts calling. */
55export const CALL_SAYS: Record<Need, string> = {
56 hungry: 'my tummy is empty…',
57 sad: 'play with me?',
58 sick: 'i feel funny… (×_×)',
59 bugs: 'so… many… bugs',
60 tantrum: 'NO NO NO NO',
61}
62
63export const HELP: readonly [string, string][] = [
64 ['/tama', 'open or close the device'],
65 ['/tama feed · snack', 'a meal fills a heart of hunger, a snack a heart of fun'],
66 ['/tama play', 'the left-or-right game: win 3 of 5'],
67 ['/tama clean', 'squash the bugs it left behind'],
68 ['/tama medicine', 'cure it when it is sick (sometimes twice)'],
69 ['/tama lights', 'tuck it in, or wake it up'],
70 ['/tama scold', 'discipline a tantrum (never a well-behaved pet)'],
71 ['/tama stats · history', 'its life in numbers, and the pets before it'],
72 ['/tama name <name>', 'rename it'],
73 ['/tama shell <color>', 'coral, mint, grape, sky, banana or ink'],
74 ['/tama band auto|big|on|line|off', 'how it shows above the prompt'],
75 ['/tama vacation on|off', 'stop its clock while you are away'],
76 ['/tama mode classic|gentle', 'gentle: it never leaves, however neglected'],
77 ['/tama spinner · sound on|off', 'its face beside the spinner; beeps'],
78]
79hooks/lcd.tsx 93 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { key } from './pet/bitmap.ts'
4import { bigBlocks, braille, halfBlocks } from './pet/paint.ts'
5import { rasterize, REACTION_MS, scene, type SceneInput } from './pet/scene.ts'
6
7// The pet's screen in the terminal: a surface module the hooks module mounts with <Client>. It runs
8// on the drawing thread with a frame clock of its own, so the pet walks, blinks and plays its
9// animations without the hooks module redrawing anything. A click pats the pet; once clicked, the
10// keys of the device's buttons reach the hooks module through `post`.
11//
12// Never name a local `h` here: every JSX tag compiles to a call of `h`.
13
14export type LcdProps = {
15 input: SceneInput
16 /**
17 * `device`: the 32×16 screen, pixels as half blocks (or doubled). `lane`: a strip as wide as the
18 * region, in braille dots, 4 rows. `strip`: the same strip in half blocks, 8 rows.
19 */
20 mode: 'device' | 'lane' | 'strip'
21 scale?: 1 | 2
22 /** How long ago the current reaction began, as the hooks module drew it. */
23 age?: number
24 ink: string
25 bg?: string
26}
27
28type State = { rows: string[]; frame: string }
29
30const TICK = 110
31
32// per instance: the props it was last drawn with, its clock and the reaction it is playing
33type Live = { props: LcdProps; t: number; seen: number; startedAt: number }
34const lives = new WeakMap<object, Live>()
35
36function render(live: Live, columns: number): { rows: string[]; frame: string } {
37 const { input, mode } = live.props
38 const playing = input.reaction && input.reaction.id === live.seen ? live.t - live.startedAt : null
39 const rt = playing !== null && input.reaction && playing < REACTION_MS[input.reaction.kind] ? playing : null
40 const w = mode === 'lane' ? Math.max(24, columns * 2) : mode === 'strip' ? Math.max(24, columns) : 32
41 const bitmap = rasterize(scene(input, live.t, rt, w, 16), w, 16)
42 const rows = mode === 'lane' ? braille(bitmap) : mode === 'device' && live.props.scale === 2 ? bigBlocks(bitmap) : halfBlocks(bitmap)
43 return { rows, frame: key(bitmap) }
44}
45
46export default function Lcd(props: LcdProps, surface: ClientSurface<State>) {
47 const { Box, Text } = surface.elements
48 let live = lives.get(surface)
49
50 if (!live) {
51 // a reaction already playing when the screen mounts plays on from where it is
52 const age = props.age ?? Infinity
53 live = { props, t: 0, seen: props.input.reaction?.id ?? -1, startedAt: -age }
54 lives.set(surface, live)
55 const first = live
56 surface.every(TICK, () => {
57 first.t += TICK
58 const next = render(first, surface.columns)
59 if (next.frame !== surface.state?.frame) surface.setState(next)
60 })
61 surface.onPointer(ev => {
62 if (ev.type === 'down') surface.post({ act: 'pat' })
63 })
64 surface.onKey(ev => {
65 if (!ev.ctrl && !ev.meta) surface.post({ act: 'key', key: ev.key })
66 })
67 }
68
69 // new props: a new reaction starts now, on this screen's clock
70 live.props = props
71 const id = props.input.reaction?.id ?? -1
72 if (id !== live.seen) {
73 live.seen = id
74 live.startedAt = live.t - Math.min(props.age ?? 0, 400)
75 }
76
77 // drawn from the live props every call; the clock's setState only says a new frame is due
78 const drawn = render(live, surface.columns || 48)
79 return (
80 <Box flexDirection="column">
81 {drawn.rows.map(row =>
82 props.bg ? (
83 <Text color={props.ink} backgroundColor={props.bg}>
84 {row}
85 </Text>
86 ) : (
87 <Text color={props.ink}>{row}</Text>
88 ),
89 )}
90 </Box>
91 )
92}
93hooks/pet/rng.ts 39 lines1// Deterministic randomness. The pet's life is simulated minute by minute in every session that
2// loads the mod; seeding each roll by the pet's id, the minute and what the roll is for makes them
3// all reach the same pet, whichever session simulates it first.
4
5/** FNV-1a over the string: a 32-bit seed. */
6export function hash(text: string): number {
7 let h = 0x811c9dc5
8 for (let i = 0; i < text.length; i++) {
9 h ^= text.charCodeAt(i)
10 h = Math.imul(h, 0x01000193)
11 }
12 return h >>> 0
13}
14
15/** One number in [0, 1) for this seed: mulberry32's first draw. */
16export function unit(seed: number): number {
17 let t = (seed + 0x6d2b79f5) >>> 0
18 t = Math.imul(t ^ (t >>> 15), t | 1)
19 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
20 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
21}
22
23/** A roll in [0, 1) named by its parts: `roll(pet.id, minute, 'bug')`. */
24export const roll = (...parts: readonly (string | number)[]): number => unit(hash(parts.join(':')))
25
26/** One item of `list`, picked by the roll. */
27export const pick = <T>(list: readonly T[], r: number): T => list[Math.min(list.length - 1, Math.floor(r * list.length))]!
28
29/** A fresh id: random where the environment offers it. */
30export function newId(now: number): string {
31 const bytes = new Uint8Array(6)
32 try {
33 crypto.getRandomValues(bytes)
34 } catch {
35 for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(unit(hash(`${now}:${i}`)) * 256)
36 }
37 return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
38}
39hooks/pet/bitmap.ts 61 lines1import type { Sprite } from './sprites.ts'
2
3// A 1-bit picture: `w` by `h` pixels, one byte each (1 on, 0 off), row-major.
4// Everything the LCD shows is composed into one of these, then painted for the surface at hand.
5
6export type Bitmap = { w: number; h: number; px: Uint8Array }
7
8export type Face = { eyesClosed?: boolean; mouthOpen?: boolean }
9
10export const blank = (w: number, h: number): Bitmap => ({ w, h, px: new Uint8Array(w * h) })
11
12export const get = (b: Bitmap, x: number, y: number): number =>
13 x < 0 || y < 0 || x >= b.w || y >= b.h ? 0 : b.px[y * b.w + x]!
14
15export const set = (b: Bitmap, x: number, y: number, on = 1): void => {
16 if (x >= 0 && y >= 0 && x < b.w && y < b.h) b.px[y * b.w + x] = on
17}
18
19export const spriteWidth = (s: Sprite): number => s.reduce((w, row) => Math.max(w, row.length), 0)
20
21// whether the sprite's pixel at (x, y) is lit for this face
22function lit(s: Sprite, x: number, y: number, face: Face): boolean {
23 const c = s[y]?.[x]
24 if (c === '#') return true
25 if (c === 'M') return face.mouthOpen === true
26 if (c === 'E') return !face.eyesClosed || s[y + 1]?.[x] !== 'E'
27 return false
28}
29
30/**
31 * Draws `s` with its top-left corner at (x, y); `flip` mirrors it (a creature walking left),
32 * `invert` draws its lit pixels dark-on-light as off (used to cut a sprite out of a lit area).
33 */
34export function draw(b: Bitmap, s: Sprite, x: number, y: number, opts: Face & { flip?: boolean; erase?: boolean } = {}): void {
35 const w = spriteWidth(s)
36 for (let sy = 0; sy < s.length; sy++) {
37 for (let sx = 0; sx < w; sx++) {
38 if (!lit(s, sx, sy, opts)) continue
39 set(b, x + (opts.flip ? w - 1 - sx : sx), y + sy, opts.erase ? 0 : 1)
40 }
41 }
42}
43
44/** Every lit pixel becomes off and every off one lit: the screen with the lights out. */
45export function invert(b: Bitmap): Bitmap {
46 const px = new Uint8Array(b.px.length)
47 for (let i = 0; i < px.length; i++) px[i] = b.px[i] ? 0 : 1
48 return { w: b.w, h: b.h, px }
49}
50
51/** A cheap fingerprint, so a surface redraws only when a frame actually changed. */
52export function key(b: Bitmap): string {
53 let out = ''
54 for (let i = 0; i < b.px.length; i += 6) {
55 let n = 0
56 for (let j = 0; j < 6 && i + j < b.px.length; j++) n = (n << 1) | b.px[i + j]!
57 out += String.fromCharCode(48 + n)
58 }
59 return out
60}
61hooks/pet/sprites.ts 323 lines1// The pixel art. Every sprite is 1-bit, drawn as rows of characters:
2//
3// # ink, always on
4// . off (transparent)
5// E eye: on while the eyes are open; a closed eye keeps only its lowest row
6// M mouth: on only while the mouth is open (eating, cheering)
7//
8// Creatures face right as drawn; a sprite walking left is mirrored. Sprites stand on their last row.
9
10export type Sprite = readonly string[]
11
12// ── the egg ─────────────────────────────────────────────────────────────────────────────────
13
14export const EGG: Sprite = [
15 '......####......',
16 '.....#....#.....',
17 '....#......#....',
18 '...#..##....#...',
19 '...#.####...#...',
20 '..#...##.....#..',
21 '..#..........#..',
22 '.#.......##...#.',
23 '.#......####..#.',
24 '.#.##....##...#.',
25 '.#####........#.',
26 '.#.##.........#.',
27 '.#............#.',
28 '..#..........#..',
29 '...##......##...',
30 '.....######.....',
31]
32
33// drawn over the egg as it is about to hatch
34export const CRACK: Sprite = [
35 '................',
36 '................',
37 '................',
38 '................',
39 '................',
40 '................',
41 '..#.#...#.#..#..',
42 '...#.#.#...#.#..',
43 '......#.........',
44 '................',
45]
46
47// ── babies, children, teens ─────────────────────────────────────────────────────────────────
48
49export const BIT: Sprite = [
50 '...####...',
51 '.##....##.',
52 '#........#',
53 '#..E..E..#',
54 '#..E..E..#',
55 '#...##...#',
56 '.#..MM..#.',
57 '..######..',
58]
59
60export const NIBBLE: Sprite = [
61 '.#........#.',
62 '#.#......#.#',
63 '#..######..#',
64 '#..........#',
65 '#..E....E..#',
66 '#..E....E..#',
67 '#..........#',
68 '#....##....#',
69 '.#...MM...#.',
70 '..########..',
71 '..##....##..',
72 '.###....###.',
73]
74
75export const BYTEY: Sprite = [
76 '....######....',
77 '..##......##..',
78 '.#..........#.',
79 '#............#',
80 '#...E....E...#',
81 '#...E....E...#',
82 '#............#',
83 '#.....##.....#',
84 '##....MM....##',
85 '#.#........#.#',
86 '#..##....##..#',
87 '.....####.....',
88 '....#....#....',
89 '...##....##...',
90]
91
92export const GLITCHY: Sprite = [
93 '.....#####....',
94 '...##.....#...',
95 '..#.......#.#.',
96 '.#.........#..',
97 '.#..E...E...#.',
98 '#...E...E...#.',
99 '#............#',
100 '#..##.##.##..#',
101 '.#.MM.MM.MM.#.',
102 '.#..........#.',
103 '..#...#.....#.',
104 '..#..#.#...#..',
105 '.#..#...###...',
106 '.##.##...##...',
107]
108
109// ── adults ──────────────────────────────────────────────────────────────────────────────────
110
111export const ASSERTOISE: Sprite = [
112 '................',
113 '.....######.....',
114 '....#......#....',
115 '....#.E..E.#....',
116 '....#.E..E.#....',
117 '....#..##..#....',
118 '.....#.MM.#.....',
119 '...##########...',
120 '..#..........#..',
121 '.#.........#..#.',
122 '.#..#.....#...#.',
123 '.#...#...#....#.',
124 '.#....#.#.....#.',
125 '..#....#.....#..',
126 '..############..',
127 '..##........##..',
128]
129
130export const REFACTOPUS: Sprite = [
131 '.....######.....',
132 '...##......##...',
133 '..#..........#..',
134 '.#............#.',
135 '.#...E....E...#.',
136 '.#...E....E...#.',
137 '.#............#.',
138 '..#....##....#..',
139 '..#....MM....#..',
140 '..############..',
141 '.#..#..##..#..#.',
142 '#..#..#..#..#..#',
143 '#.#..#....#..#.#',
144 '.#..#......#..#.',
145 '.#.#........#.#.',
146 '..#..........#..',
147]
148
149export const SHELLBY: Sprite = [
150 '##............##',
151 '#.#..........#.#',
152 '##.#.EE..EE.#.##',
153 '.#.#.EE..EE.#.#.',
154 '...#..#..#..#...',
155 '...#..#..#..#...',
156 '..############..',
157 '.#............#.',
158 '#......##......#',
159 '#......MM......#',
160 '#..............#',
161 '.#............#.',
162 '..############..',
163 '.#..#..#..#..#..',
164 '#..#..#....#..#.',
165 '................',
166]
167
168export const GREPOSSUM: Sprite = [
169 '.##........##...',
170 '#..#......#..#..',
171 '#.#.######.#.#..',
172 '.#..........#...',
173 '.#..E....E..#...',
174 '.#..E....E..#...',
175 '..#...##...#....',
176 '...#..MM..#.....',
177 '....######......',
178 '...#......#.###.',
179 '..#........#...#',
180 '..#........#...#',
181 '..#.........###.',
182 '...#......#....#',
183 '....######......',
184 '...##....##.....',
185]
186
187export const COMMITMUNK: Sprite = [
188 '..##........##..',
189 '.#..#......#..#.',
190 '.#.#.######.#.#.',
191 '..#....##....#..',
192 '.#.....##.....#.',
193 '#..E...##...E..#',
194 '#..E........E..#',
195 '#.##........##.#',
196 '####...##...####',
197 '.###...MM...###.',
198 '..#..........#..',
199 '...##########...',
200 '...#.##..##.#...',
201 '...#........#...',
202 '...#........#...',
203 '..###......###..',
204]
205
206export const OWLGORITHM: Sprite = [
207 '.#............#.',
208 '.##..........##.',
209 '.#.##########.#.',
210 '.#..........#.#.',
211 '#..####..####..#',
212 '#.#.E..##.E..#.#',
213 '#.#.E..##.E..#.#',
214 '#..####..####..#',
215 '#.......##.....#',
216 '.#......MM....#.',
217 '.#..#......#..#.',
218 '.#.#.#....#.#.#.',
219 '.#..#......#..#.',
220 '..#..........#..',
221 '...##########...',
222 '...#.#....#.#...',
223]
224
225export const SPAGHETTO: Sprite = [
226 '....#.#..#.#....',
227 '..#..#.##.#..#..',
228 '.#.##......##.#.',
229 '#.#..........#.#',
230 '#..............#',
231 '.#..E......E..#.',
232 '#...E......E...#',
233 '#......##......#',
234 '.#.....MM.....#.',
235 '#..............#',
236 '.##.########.##.',
237 '#..#.#..#.#.#..#',
238 '#.#..#..#..#.#.#',
239 '.#..#..#...#..#.',
240 '..#.#...#..#.#..',
241 '.#...#..#...#...',
242]
243
244export const UNICORN: Sprite = [
245 '.......#........',
246 '.......##.......',
247 '......#.##......',
248 '..#...##.#...#..',
249 '.#.#..#..#..#.#.',
250 '.#..##....##..#.',
251 '#..............#',
252 '#..E........E..#',
253 '#..E........E..#',
254 '#..............#',
255 '.#.....##.....#.',
256 '..#....MM....#..',
257 '...##########...',
258 '...#.#....#.#...',
259 '...#.#....#.#...',
260 '..##.##..##.##..',
261]
262
263export const ANGEL: Sprite = [
264 '....########....',
265 '...#........#...',
266 '....########....',
267 '.....######.....',
268 '...##......##...',
269 '..#..........#..',
270 '..#..#....#..#..',
271 '..#..#....#..#..',
272 '..#....##....#..',
273 '..#..........#..',
274 '..#..........#..',
275 '..#..........#..',
276 '..#.##....##.#..',
277 '..##..#..#..##..',
278 '..#....##....#..',
279 '................',
280]
281
282// ── things on the screen ────────────────────────────────────────────────────────────────────
283
284// a bug, the pet's droppings: two frames, legs in and out
285export const BUG: readonly Sprite[] = [
286 ['.#.#.', '..#..', '#####', '.###.', '#.#.#'],
287 ['#...#', '.#.#.', '.###.', '#####', '.#.#.'],
288]
289
290export const RICE: readonly Sprite[] = [
291 ['...##...', '..####..', '.##..##.', '.#....#.', '#......#', '#.####.#', '#.####.#', '.######.'],
292 ['...##...', '..####..', '.##..##.', '.#....#.', '#.......', '#.####..', '#.####..', '.#####..'],
293 ['........', '........', '........', '........', '#.......', '#.##....', '#.##....', '.###....'],
294]
295
296export const CANDY: readonly Sprite[] = [
297 ['..####..', '.#.##.#.', '#.#..#.#', '#.#.##.#', '.#....#.', '..####..', '...#....', '...#....'],
298 ['..##....', '.#.#....', '#.#.....', '#.#.#...', '.#..#...', '..##....', '...#....', '...#....'],
299 ['........', '........', '........', '........', '........', '........', '...#....', '...#....'],
300]
301
302export const PILL: Sprite = ['..####..', '.#..###.', '#...####', '#...####', '#...####', '#...####', '.#..###.', '..####..']
303
304export const HEART: Sprite = ['.##.##.', '#######', '#######', '.#####.', '..###..', '...#...']
305export const SMALL_HEART: Sprite = ['##.##', '#####', '.###.', '..#..']
306export const NOTE: Sprite = ['..###', '..#.#', '..#..', '###..', '###..']
307export const SKULL: Sprite = ['.####.', '#.##.#', '######', '.#..#.', '.####.']
308export const BANG: Sprite = ['##', '##', '##', '##', '..', '##']
309export const SWEAT: Sprite = ['.#.', '###', '###', '.#.']
310export const ZZ: Sprite = ['####', '..#.', '.#..', '####']
311export const SMALL_Z: Sprite = ['###', '.#.', '###']
312export const SPARK: readonly Sprite[] = [
313 ['..#..', '..#..', '#####', '..#..', '..#..'],
314 ['#...#', '.#.#.', '..#..', '.#.#.', '#...#'],
315]
316export const ARROW_LEFT: Sprite = ['...#', '..##', '.###', '####', '.###', '..##', '...#']
317export const ARROW_RIGHT: Sprite = ['#...', '##..', '###.', '####', '###.', '##..', '#...']
318export const CHECK: Sprite = ['......#', '.....##', '#...##.', '##.##..', '.###...', '..#....']
319export const CROSS: Sprite = ['#....#', '##..##', '.####.', '..##..', '.####.', '##..##', '#....#']
320export const STAR: Sprite = ['..#..', '.###.', '#####', '.###.', '.#.#.']
321export const WAVE: Sprite = ['..#.', '.#..', '#...', '#...', '.#..', '..#.', '.#..', '#...', '#...', '.#..', '..#.', '.#..', '#...', '#...', '.#..', '..#.']
322export const UFO: Sprite = ['....####....', '..##....##..', '############', '#.#.#..#.#.#', '.##########.', '...#....#...']
323hooks/pet/paint.ts 53 lines1import { get, type Bitmap } from './bitmap.ts'
2
3// Turning a bitmap into terminal characters. Two pixels per cell, one above the other, with the
4// half blocks (crisp, square pixels: the device's LCD), or eight per cell with braille dots (a
5// dot-matrix look a quarter the height: the band above the prompt).
6
7const HALF = [' ', '▄', '▀', '█'] as const
8
9/** One string per row of cells, h/2 rows: top pixel ▀, bottom ▄, both █. */
10export function halfBlocks(b: Bitmap): string[] {
11 const rows: string[] = []
12 for (let y = 0; y < b.h; y += 2) {
13 let row = ''
14 for (let x = 0; x < b.w; x++) row += HALF[(get(b, x, y) << 1) | get(b, x, y + 1)]
15 rows.push(row)
16 }
17 return rows
18}
19
20/** Doubled: each pixel two cells wide and one tall, so a 32×16 screen fills 64×16 cells. */
21export function bigBlocks(b: Bitmap): string[] {
22 const rows: string[] = []
23 for (let y = 0; y < b.h; y++) {
24 let row = ''
25 for (let x = 0; x < b.w; x++) row += get(b, x, y) ? '██' : ' '
26 rows.push(row)
27 }
28 return rows
29}
30
31// the braille dot for each pixel of a 2×4 cell, as Unicode numbers them
32const DOT = [
33 [0x01, 0x08],
34 [0x02, 0x10],
35 [0x04, 0x20],
36 [0x40, 0x80],
37] as const
38
39/** One string per row of cells, h/4 rows, each cell a braille character (blank cells a space). */
40export function braille(b: Bitmap): string[] {
41 const rows: string[] = []
42 for (let y = 0; y < b.h; y += 4) {
43 let row = ''
44 for (let x = 0; x < b.w; x += 2) {
45 let bits = 0
46 for (let dy = 0; dy < 4; dy++) for (let dx = 0; dx < 2; dx++) if (get(b, x + dx, y + dy)) bits |= DOT[dy]![dx]!
47 row += bits ? String.fromCharCode(0x2800 + bits) : ' '
48 }
49 rows.push(row)
50 }
51 return rows
52}
53types/index.d.ts 179 lines1// cc-tamagotchi's type contract: the values it keeps in $.state, and the shapes they are made of.
2
3export type SpeciesId =
4 | 'egg'
5 | 'bit'
6 | 'nibble'
7 | 'bytey'
8 | 'glitchy'
9 | 'assertoise'
10 | 'refactopus'
11 | 'shellby'
12 | 'grepossum'
13 | 'commitmunk'
14 | 'owlgorithm'
15 | 'spaghetto'
16 | 'unicorn'
17
18export type Stage = 'egg' | 'baby' | 'child' | 'teen' | 'adult'
19
20/** What Claude's work and the person's care added up to over the pet's life. */
21export type PetStats = {
22 toolCalls: number
23 edits: number
24 tests: number
25 testFails: number
26 commits: number
27 pushes: number
28 shell: number
29 searches: number
30 agents: number
31 errors: number
32 turns: number
33 nightTurns: number
34 tokens: number
35 meals: number
36 snacks: number
37 games: number
38 wins: number
39 cleans: number
40 meds: number
41 pats: number
42 scolds: number
43}
44
45/** The pet, as kept in $.store and shared by every session on the machine. */
46export type Pet = {
47 v: 1
48 /** Seeds every random choice its life makes, so all sessions simulate the same pet. */
49 id: string
50 name: string
51 species: SpeciesId
52 generation: number
53 /** When the egg appeared, when it hatched, and when its current form began (ms since the epoch). */
54 born: number
55 hatchedAt: number | null
56 evolvedAt: number
57 /** How far its life has been simulated, a whole minute at a time. */
58 at: number
59 /** 0 to 100: four hearts of 25. */
60 hunger: number
61 happy: number
62 discipline: number
63 /** Grams. */
64 weight: number
65 /** Bugs on the screen: what this pet leaves behind. At most four. */
66 bugs: number
67 /** Doses of medicine still needed; 0 is healthy. */
68 sick: number
69 asleep: boolean
70 /** Put to bed by the person: Claude's work does not wake it. */
71 tucked: boolean
72 /** The last time Claude or the person did anything. After a while without, it naps. */
73 lastActive: number
74 lastMeal: number | null
75 lastPat: number
76 tantrum: boolean
77 /** The need it is calling about, the awake minutes it has waited, and whether that was counted. */
78 calling: Need | null
79 callWait: number
80 callingCounted: boolean
81 /** Care mistakes: a call left unanswered for too long. They shape what it grows into. */
82 mistakes: number
83 /** Minutes of hard neglect (starving and miserable, or sick); in classic mode too many and it leaves. */
84 neglect: number
85 /** When a vacation began: its time stands still until the person ends it. */
86 vacation: number | null
87 gone: { at: number; cause: 'neglect' } | null
88 rev: number
89 stats: PetStats
90}
91
92export type Need = 'hungry' | 'sad' | 'sick' | 'bugs' | 'tantrum'
93
94/** One line of the hall of fame: a pet that has gone. */
95export type Memorial = {
96 name: string
97 species: SpeciesId
98 generation: number
99 born: number
100 gone: number
101 toolCalls: number
102 commits: number
103}
104
105export type BandMode = 'auto' | 'big' | 'on' | 'line' | 'off'
106export type ShellColor = 'coral' | 'mint' | 'grape' | 'sky' | 'banana' | 'ink'
107
108export type Settings = {
109 band: BandMode
110 /** Show the pet's face beside the spinner while Claude works. */
111 spinner: boolean
112 sound: boolean
113 /** classic: a badly neglected pet leaves. gentle: it never does. */
114 mode: 'classic' | 'gentle'
115 shell: ShellColor
116}
117
118export type ReactionKind =
119 | 'eat'
120 | 'snack'
121 | 'refuse'
122 | 'happy'
123 | 'pat'
124 | 'clean'
125 | 'medicine'
126 | 'scold'
127 | 'cheer'
128 | 'flinch'
129 | 'party'
130 | 'nibble'
131 | 'hatch'
132 | 'evolve'
133 | 'sleep'
134 | 'wake'
135 | 'win'
136 | 'lose'
137
138/** An animation the screens play once. `id` grows with each, so a screen knows a new one began. */
139export type Reaction = { kind: ReactionKind; id: number; at: number; from?: SpeciesId }
140
141export type View = 'main' | 'stats' | 'help' | 'history' | 'game' | 'name'
142
143/** The left-or-right game: five rounds, three wins make it happy. */
144export type Game = {
145 round: number
146 wins: number
147 /** Which way it looked last round, and which way the person guessed. */
148 answer: 'left' | 'right' | null
149 guess: 'left' | 'right' | null
150 isOver: boolean
151}
152
153export type Speech = { text: string; at: number }
154
155// inside the augmentation a name resolves in claude-code first (it has a Settings of its own),
156// so the contract names its values through aliases no module there shares
157export type TamagotchiPet = Pet
158export type TamagotchiSettings = Settings
159export type TamagotchiView = View
160export type TamagotchiGame = Game
161export type TamagotchiReaction = Reaction
162export type TamagotchiSpeech = Speech
163export type TamagotchiMemorial = Memorial
164
165declare module 'claude-code' {
166 interface PluginState {
167 'cc-tamagotchi': {
168 pet: TamagotchiPet | null
169 settings: TamagotchiSettings
170 view: TamagotchiView
171 game: TamagotchiGame | null
172 reaction: TamagotchiReaction | null
173 speech: TamagotchiSpeech | null
174 working: boolean
175 graveyard: TamagotchiMemorial[]
176 }
177 }
178}
179