Pixel-art pets above the prompt that react to what Claude and each subagent are doing

English · Português
A pixel-art pet that lives above your Claude Code prompt and reacts to what Claude is doing: it thinks, types while Claude runs commands, reads while Claude reads files, cheers when a turn ends and gets sad when a tool fails. Every subagent Claude starts gets a small pet of its own, in its own color, showing what that agent is doing right now.
Pets are plain JSON files, so you can draw your own or use one someone else made.
![]()
![]()
![]()
![]()
![]()
| Mood | When | Borrows from |
|---|---|---|
sleeping | no turn is running | (required) |
deepSleep | nothing has happened for 10 minutes | sleeping |
idle | awake with nothing to do, for a while after Claude finishes (see awakeMinutes) | thinking |
sleepy | idle at night, from 22h to 6h | idle |
tired | idle after 3 hours of work with no break of an hour | sleepy |
walking | strolling around while idle and alone; a pack without it never walks | idle |
watching | you are typing in the prompt while Claude is idle | reading |
waking | for a moment when a prompt wakes it from a deep sleep | thinking |
thinking | a turn started | sleeping |
typing | any other tool, MCP tools included | thinking |
running | Bash | typing |
writing | Edit, Write | typing |
reading | Read | thinking |
searching | Grep, Glob, WebFetch, WebSearch, LSP | reading |
supervising | Claude is waiting for its agents, or only thinking while they run, or they still run in the background after the turn | thinking |
compacting | the conversation is being compacted | thinking |
sweating | a turn has run for over 2 minutes and Claude is thinking or running a command | thinking |
worried | idle after a few failures in a row | sweating |
grumpy | idle after many failures in a row | sad |
proud | idle after a streak of turns that went well | happy |
happy | for two seconds after the last agent is done, now and then after a turn of work, or after you allow a permission prompt ("thanks!") | sleeping |
celebrating | for four seconds after a turn of 5 minutes or more goes well, and on an anniversary | happy |
sad | for two seconds after a tool fails, a turn errors, or you deny a permission prompt ("okay, I won't") | sleeping |
A pack only has to draw sleeping. Any mood it leaves out borrows the frames of its nearest drawn parent, so running falls back to typing, then thinking, then sleeping.
The pet walks a stage under Claude's line: it strolls while idle, and when subagents start it stays put and they gather around it, each on the side with more free room: on both sides when it stands in the middle, on one when it is in a corner. Each keeps its side until it leaves. When they do not fit where it stands, it walks aside to make room and they join as it gets there. Only a pack that draws walking walks. One that draws main.teleport instead gets about by vanishing and turning up elsewhere, as the slime and the ghost do; a pack with neither stays put at the left. Packs draw their pet facing right; pixel-pets mirrors it to face left.
It keeps track of how the session goes. A failed tool or turn worries it and a turn that goes well makes it proud; a failed turn ends a streak of pride. Both fade a point every five minutes, and when it is idle the strongest feeling shows: grumpy, then worried, then tired, then proud, then sleepy at night. Worry and pride last for the session only; tiredness counts your work in every session, as a break is an hour with no prompt in any of them.
It has wants of its own too, for the session. Energy: sleep fills it and work drains it, and a pet low on energy dozes off sooner and rests longer between strolls. Boredom: it builds while nothing happens, and a bored pet gets up from a nap on its own for a while, though never out of a deep sleep. Longing: it builds while you are away, and a pet that misses you strolls near the prompt and greets your first key after half an hour away ("missed you!"), unless a turn is running.
The time of day is your local time. At night it is sleepy and dozes off in half of awakeMinutes. It remembers you across sessions. The day's first sight of you opens with one welcome: an anniversary (a week, a month, 100 days, each year together), "missed you!" after two days or more away, or good morning. /pet also says how long you have been together. Late at night it says so once, whichever session sees you first. A claude -p run does not count as seeing you.
Now and then it says something of its own, in quotes on Claude's line: "hmm…" after 30 seconds of thinking with no tool, a remark on the 20th file read in a turn, on three agents at work at once, on a prompt at night, a sigh when boredom gets it up from a nap, and a word in its sleep now and then. Each at most once a turn, and never two within three minutes.
Each subagent gets a mini pet with its type (Explore, Plan, ...) and its current action. When the agent ends, its pet is happy (or sad, if it failed) for a moment and then leaves.
claude -p draw nothing.In a Claude Code session:
/plugin marketplace add HenriqueSchroeder/pixel-pets
/plugin install pixel-pets@pixel-pets
Or from your shell, then /reload-plugins in any open session:
claude plugin marketplace add HenriqueSchroeder/pixel-pets
claude plugin install pixel-pets@pixel-pets
/pet hides the pets and brings them back. /pet <name> gives the current project a pet of its own, so each of your Claude Code windows can tell which project it is in; it is kept for the project's next sessions too, and /pet default goes back to the one in your settings.
Run /plugin configure pixel-pets@pixel-pets, or find pixel-pets in /config:
| Option | Default | What it does |
|---|---|---|
pet | cat | Which pet to show, picked from the shipped ones (see below), or custom for your own |
customPet | Your own pack when pet is custom: a file in ~/.claude/pets/, without .json | |
language | auto | auto follows Claude Code's language setting, then $LANG. Or pick en, pt-BR |
awakeMinutes | 1 | How long the pet stays awake, strolling around, after Claude finishes, before it falls asleep (half as long at night). 0 sends it straight to sleep |
~/.claude/pets/<name>.json. The file name must match the pack's name.custom in the pet option and set customPet to <name>.A pack in ~/.claude/pets/ wins over a shipped one with the same name. If a pack can't be read, pixel-pets says why in a toast and shows the cat.
Shipped pets:
cat, the default, which turns side on to walk, and slime, which bounces in place, melts into a puddle in a deep sleep and never walks: it sinks into the floor and wells up somewhere else.dog, which wags its tail, pants, tilts its head when it thinks and turns side on to walk.ghost, which floats instead of walking, says boo now and then, fades as it sleeps and turns up elsewhere with a boo.owl, which blinks slowly and turns its head all the way round.clawd, shown at the top with the dragon, the ghost, the fox and the capybara: the critter on Claude Code's welcome screen, waving its little arms, cheering with both when a long job is done and cracking a whip at its agents to keep them at it. Fan art: not made or endorsed by Anthropic.fox, which sways its brush, pounces on mice under the snow and turns side on to walk.dragon, a big red dragon that breathes fire, counts its hoard and flies off to land elsewhere. It draws no mini pets.capybara, the calmest of them all: it chews grass, lets a bird sit on its head and balances an orange. It draws no mini pets.![]()
A pack is a palette and some frames. Each frame is rows of palette letters, and . is see-through:
{
"$schema": "https://raw.githubusercontent.com/HenriqueSchroeder/pixel-pets/main/schema/pet.schema.json",
"name": "blob",
"palette": { "o": "#1a1a1a", "b": "#7cc4f2", "e": "#ffffff" },
"main": {
"moods": {
"sleeping": [
["..oooo..", ".obbbbo.", "obbbbbbo", "obobbobo", "obbbbbbo", ".oooooo."]
]
}
},
"mini": { "moods": { "working": [[".oo.", "obbo", ".oo."]] } }
}
sleeping is required. A mood you leave out borrows from its parent, as the table in What it shows lists.fps frames per second (1–12, default 4). Repeat a frame to hold a pose: at 4 fps, the same frame four times in a row holds it for a second.working. Its happy and sad, shown when an agent ends, and startled, shown while an action that startles plays, fall back to working.mini.tint (default b) is the letter recolored for each subagent."mini": false draws no mini pets: while agents work, the pet shows them only by its own supervising. Handy for a wide pet that leaves little room beside it.npx tsx scripts/preview-pet.ts <name or path>.A loop alone looks like a machine. Three optional keys under main make the pet unpredictable:
"variants": {
"thinking": [[["...frame..."], ["...frame..."]]]
},
"transitions": {
"*>sleeping": [["...yawn..."], ["...eyes droop..."]],
"sleeping>*": [["...stretch..."]]
},
"actions": {
"blink": { "frames": [["...eyes shut..."]], "moods": ["thinking", "reading"], "every": [2, 6] }
}
variants: more loops for a mood you draw in moods. One of them, or the mood's own loop, is picked at random each time the mood starts.transitions: frames played once when the mood changes, keyed from>to. Either side may be *. An exact key wins over from>*, which wins over *>to.actions: frames played once at a random moment while the pet is in one of moods, every [min, max] seconds after the last time. Blinks, ear twitches and yawns live here, so the loops can stay calm. An action with startles, a frame counted from 0, makes the agents' mini pets on the side the pet faces play startled from that frame until it ends, always from its first frame: clawd's whip crack.Give it a voice with speech, at the top level: lines by language code and situation, one picked at random. A language or situation you leave out says the default line.
"speech": {
"en": { "longThink": ["mrrp… hmm"], "manyReads": ["so many files… mrow!"] },
"pt-BR": { "longThink": ["mrrp… hmm"] }
}
The situations are longThink, manyReads, manyAgents, lateNight, bored and dreaming. Up to 8 lines each, one line of up to 40 characters.
A pet that does not walk can teleport: give main a teleport with vanish, played where it stands, and appear, played where it lands, frames like any other. It is how it strolls and how it makes room for agents.
Give it a nature with personality, at the root of the pack: three traits from 0 to 1, each the usual 0.5 when left out. energetic tires slower, curious gets bored and up from a nap sooner (0 never does), affectionate misses you sooner (0 never does). The dog is { "energetic": 0.8, "curious": 0.6, "affectionate": 0.9 }; the slime, lazy, is { "energetic": 0.2, "curious": 0.3, "affectionate": 0.6 }.
The schema gives your editor completion and checks. To share a pet, see CONTRIBUTING.md.
A mod runs with your permissions. Clone this repository and list every event it hooks and every call it makes:
claude plugin validate ./pixel-pets
pixel-pets reads your pet packs, Claude Code's language setting and the HOME and LANG variables, and draws above the prompt. It never writes files, runs commands, or uses the network.
/reload-plugins.claude --plugin-dir . # try it in a session
claude plugin validate . # manifest, hooks and calls
claude plugin test . # tests/
npx tsx scripts/check-pets.ts # every pack in pets/
npx tsc -p . # types (after one --plugin-dir run lays them)
hooks/register.tsx 813 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer, ToolCallInput } from 'claude-code'
3
4import type { Activity, AgentPet, Feelings, Label, MiniMood, Mood, Reaction, Situation, Traits } from '../types'
5import { alertness, drift, engaged, misses, paceOf, rested, seen, stirred, stirs, USUAL } from './drives'
6import type { Drives, State } from './drives'
7import { calm, cheer, feel, idleMood, isNight, nightOf } from './feelings'
8import { pickLocale, say } from './i18n'
9import type { Locale, Text } from './i18n'
10import { asVisit, daysBetween, welcome } from './memory'
11import type { Visit } from './memory'
12import { step } from './motion'
13import type { Motion } from './motion'
14import { DEFAULT_PET, packPaths, parsePack } from './pack'
15import type { Pack } from './pack'
16import { encode, mirror, sizeOf } from './render'
17import { LONG_THINK_MS, MANY_AGENTS, MANY_READS, lineFor, maySpeak, quiet, spoke } from './speech'
18import type { Speaker } from './speech'
19import { EMPTY_STAGE, faceFor, holdFacing, moveOnStage, sideIn } from './walk'
20import type { Held, Side, Stage } from './walk'
21
22const asleep: Activity = { mood: 'sleeping', label: { text: 'sleeping', detail: '' } }
23
24const activity = atom({ plugin: 'pixel-pets', key: 'activity' } as const, asleep)
25const reaction = atom({ plugin: 'pixel-pets', key: 'reaction' } as const, null as Reaction | null)
26const override = atom({ plugin: 'pixel-pets', key: 'override' } as const, null as Activity | null)
27const turnStartedAt = atom({ plugin: 'pixel-pets', key: 'turnStartedAt' } as const, null as number | null)
28const lastActiveAt = atom({ plugin: 'pixel-pets', key: 'lastActiveAt' } as const, null as number | null)
29const typingAt = atom({ plugin: 'pixel-pets', key: 'typingAt' } as const, null as number | null)
30const agents = atom({ plugin: 'pixel-pets', key: 'agents' } as const, [] as AgentPet[])
31const isHidden = atom({ plugin: 'pixel-pets', key: 'isHidden' } as const, false)
32const feelings = atom({ plugin: 'pixel-pets', key: 'feelings' } as const, calm as Feelings)
33const restedAt = atom({ plugin: 'pixel-pets', key: 'restedAt' } as const, null as number | null)
34
35const REACTION_MS = 2000
36const WAKING_MS = 1200
37const LEAVING_MS = 1500
38const LONG_TURN_MS = 2 * 60_000
39const DEEP_SLEEP_MS = 10 * 60_000
40// A break this long rests it, as far as getting tired goes.
41const BREAK_MS = 60 * 60_000
42const GREETING_MS = 2500
43// A turn this long that goes well earns a bigger, longer celebration.
44const LONG_WIN_MS = 5 * 60_000
45const CELEBRATION_MS = 4000
46// How long a line it says stays on Claude's line.
47const SPEECH_MS = 4000
48// How long the pet keeps watching the prompt after the last key.
49const TYPING_MS = 2000
50// How long it stays up when boredom gets it out of a nap.
51const STIR_MS = 45_000
52// How long into a nap it may talk in its sleep, in minutes.
53const DREAM_MINUTES: [number, number] = [10, 20]
54const AGENT_SLOT = 20
55// Room kept for the " +N" that stands for agents with no slot.
56const OVERFLOW_COLUMNS = 4
57const MAX_AGENTS = 6
58// While only its own frame moves, the clock repaints that frame and redraws the
59// band in full only this often, in seconds, for what changes with time alone
60// (a reaction running out, dozing off): once a second awake, every 5 asleep.
61const REDRAW_AWAKE_S = 1
62const REDRAW_ASLEEP_S = 5
63
64// One body color per subagent, picked in spawn order.
65const AGENT_COLORS = [0x7cc4f2, 0x9bd57a, 0xc69af2, 0xf2d16b, 0xf28fb0, 0x6fd8c8]
66
67const SEARCHERS = new Set(['Grep', 'Glob', 'WebFetch', 'WebSearch', 'LSP'])
68
69// Waiting on something slow: these turn into sweating when a turn runs long.
70const PATIENT: readonly Mood[] = ['thinking', 'running', 'supervising']
71
72const short = (text: string, max = 32) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
73const basename = (path: string) => path.split('/').pop() ?? path
74const label = (text: Text, detail = ''): Label => ({ text, detail })
75const stillHere = (list: AgentPet[], now: number) => list.filter(pet => pet.leaving === undefined || pet.leaving.until > now)
76
77const describe = (e: ToolCallInput): Activity => {
78 switch (e.tool) {
79 case 'Bash':
80 return { mood: 'running', label: label('running', short(e.command)) }
81 case 'Edit':
82 case 'Write':
83 return { mood: 'writing', label: label('writing', basename(e.file_path)) }
84 case 'Read':
85 return { mood: 'reading', label: label('reading', basename(e.file_path)) }
86 case 'Agent':
87 return { mood: 'supervising', label: label('waitingForAgents') }
88 }
89 const tool = String(e.tool)
90 return SEARCHERS.has(tool)
91 ? { mood: 'searching', label: label('searching', tool) }
92 : { mood: 'typing', label: label('using', short(tool, 24)) }
93}
94
95const callKey = (agentId: string | undefined, tool: string) => `${agentId ?? 'main'}:${tool}`
96
97// The engine's words for a call the person said no to: there is no flag for it.
98const isDenial = (ran: { isError?: boolean; result?: unknown }) =>
99 ran.isError === true && typeof ran.result === 'string' && ran.result.includes("doesn't want to proceed")
100
101const reason = (error: unknown) => (error instanceof Error ? error.message : String(error))
102
103// The language and the pack are read once per module load: a settings change
104// reloads the module, and with it these.
105let choosing: Promise<Locale> | undefined
106// Kept with the name it is for: a draw can ask before `session.start` has read the
107// project's own pet, and the settings' pet must not stand in for it after.
108let loading: { name: string; pack: Promise<Pack> } | undefined
109
110function localeOf($: EngineInterface, choice: string) {
111 choosing ??= (async () => {
112 const settings = await $.settings.read()
113 return pickLocale(choice, settings.language, await $.env.get('LANG'))
114 })()
115 return choosing
116}
117
118// The first pack found wins; a broken or missing one is why not, in words.
119async function findPack($: EngineInterface, name: string, locale: Locale): Promise<Pack | string> {
120 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
121 const paths = packPaths(name, home, $.plugin.root)
122 let problem = say(locale, paths === undefined ? 'invalidName' : 'notFound', name)
123
124 for (const path of paths ?? []) {
125 let text: string
126 try {
127 text = await $.fs.read(path)
128 } catch {
129 continue
130 }
131 try {
132 return parsePack(JSON.parse(text))
133 } catch (error) {
134 problem = `${path}: ${reason(error)}`
135 break
136 }
137 }
138 return problem
139}
140
141// A broken or missing pack falls back to the shipped default, and the toast says why.
142async function readPack($: EngineInterface, name: string, locale: Locale): Promise<Pack> {
143 const found = await findPack($, name, locale)
144 if (typeof found !== 'string') return found
145 const fallback = parsePack(JSON.parse(await $.fs.read(`${$.plugin.root}/pets/${DEFAULT_PET}.json`)))
146 $.ui.toast(say(locale, 'fallback', found))
147 return fallback
148}
149
150function packOf($: EngineInterface, name: string, locale: Locale) {
151 if (loading?.name !== name) loading = { name, pack: readPack($, name, locale) }
152 return loading.pack
153}
154
155// `/pet <name>` picks a project's own pet, kept by project root; `/pet default`
156// goes back to the one in the settings.
157const DEFAULT_ARG = 'default'
158
159async function projectPets($: EngineInterface): Promise<Record<string, string>> {
160 const kept = await $.store.get('projectPets')
161 return typeof kept === 'object' && kept !== null && !Array.isArray(kept) ? (kept as Record<string, string>) : {}
162}
163
164// This session's project's pet, if one was picked.
165async function projectPet($: EngineInterface) {
166 try {
167 const name = (await projectPets($))[await $.session.root()]
168 return typeof name === 'string' ? name : undefined
169 } catch {
170 return undefined
171 }
172}
173
174async function keepProjectPet($: EngineInterface, root: string, name: string | undefined) {
175 const { [root]: _, ...others } = await projectPets($)
176 await $.store.set('projectPets', name === undefined ? others : { ...others, [root]: name })
177}
178
179const hourOf = (now: number) => new Date(now).getHours()
180const anniversary = (days: number): Label =>
181 days === 7
182 ? label('aWeekTogether')
183 : days === 30
184 ? label('aMonthTogether')
185 : days === 365
186 ? label('aYearTogether')
187 : days % 365 === 0
188 ? label('yearsTogether', String(days / 365))
189 : label('daysTogether', String(days))
190
191// Notes that the person is here now, and when they first met. Returns the visit
192// before this one. With no store it remembers nothing, and holds nothing up.
193async function remember($: EngineInterface, now: number) {
194 try {
195 const visit = asVisit(await $.store.get('visit'))
196 await $.store.set('visit', { metAt: visit?.metAt ?? now, lastSeenAt: now })
197 return visit
198 } catch {
199 return undefined
200 }
201}
202
203// How the day's first sight of the person goes: an anniversary, missing them, or
204// good morning. Checked when a session opens and on each prompt, so a session left
205// open overnight still greets the morning. `before` is the visit before this one.
206async function greet($: EngineInterface, now: number, before: Visit | undefined) {
207 const hello = welcome(before, now, hourOf(now))
208 if (hello === undefined) return
209 const until = now + GREETING_MS
210 const shown: Reaction =
211 hello.kind === 'anniversary'
212 ? { mood: 'celebrating', label: anniversary(hello.days), until: now + CELEBRATION_MS }
213 : hello.kind === 'missedYou'
214 ? { mood: 'happy', label: label('missedYou'), until }
215 : { mood: 'waking', label: label('goodMorning'), until }
216 await update($, reaction, () => shown)
217}
218
219// When the person came back from their last break, across every session: an hour
220// with no prompt in any of them rests the pet. `before` is the visit before this one.
221async function restedSince($: EngineInterface, now: number, before: Visit | undefined) {
222 try {
223 const kept = await $.store.get('restedAt')
224 if (typeof kept === 'number' && before !== undefined && now - before.lastSeenAt < BREAK_MS) return kept
225 await $.store.set('restedAt', now)
226 } catch {
227 // No store: rested as of now, as a session that remembers nothing.
228 }
229 return now
230}
231
232// Whether no session has said the late-night line yet tonight.
233async function quietTonight($: EngineInterface, now: number) {
234 try {
235 return (await $.store.get('lateNight')) !== nightOf(now)
236 } catch {
237 return true
238 }
239}
240
241async function saidTonight($: EngineInterface, now: number) {
242 try {
243 await $.store.set('lateNight', nightOf(now))
244 } catch {
245 // No store: this session alone keeps it.
246 }
247}
248
249// The person is at the prompt: the day's welcome, and their rest, from the visit before.
250async function atThePrompt($: EngineInterface, now: number) {
251 const before = await remember($, now)
252 await greet($, now, before)
253 const since = await restedSince($, now, before)
254 await update($, restedAt, () => since)
255}
256
257// "Together for 12 days", for /pet; nothing when it has no memory of the person.
258async function together($: EngineInterface, locale: Locale) {
259 try {
260 const visit = asVisit(await $.store.get('visit'))
261 if (visit === undefined) return ''
262 const days = daysBetween(visit.metAt, await $.clock.now())
263 const said = days === 0 ? say(locale, 'justMet') : days === 1 ? say(locale, 'togetherADay') : say(locale, 'togetherDays', String(days))
264 return ` ${said.charAt(0).toUpperCase()}${said.slice(1)}.`
265 } catch {
266 return ''
267 }
268}
269
270// Clears the override only if it is still the one this hook set.
271async function release($: EngineInterface, mood: Mood) {
272 await update($, override, current => (current?.mood === mood ? null : current))
273}
274
275export const register: Register = (on, options) => {
276 // The picker names a shipped pet, or `custom` for the person's own pack named in `customPet`.
277 const picked = options.pet === 'custom' ? options.customPet : options.pet
278 const configured = typeof picked === 'string' && picked !== '' ? picked : DEFAULT_PET
279 // The project's own pet, if `/pet <name>` picked one, else the settings'.
280 let petName = configured
281 // The pack the frame clock plays, the clock, and a way to start it again at another pack's pace.
282 let playing: Pack | undefined
283 let timer: Timer | undefined
284 let restartClock = () => {}
285 const language = typeof options.language === 'string' ? options.language : 'auto'
286 const awakeMs = (typeof options.awakeMinutes === 'number' && options.awakeMinutes >= 0 ? options.awakeMinutes : 1) * 60_000
287 let frame = 0
288 // What the main pet is playing and where it stands, between draws; a reload starts them fresh.
289 let motion: Motion | undefined
290 // Where it stands and each agent's side, kept so none hops over when another leaves.
291 let stage: Stage = EMPTY_STAGE
292 // A full redraw costs Claude Code far more than repainting the pet, and every
293 // open session pays it. While the band shows nothing but the pet's own frame
294 // moving, `still` holds what the clock needs to repaint that frame alone; while
295 // the pet is hidden the clock redraws nothing, as showing it again redraws.
296 let still: { requestId: string; mood: Mood; facing: Side; cells: string } | null = null
297 let isOff = false
298 // The tool call running for each agent and tool, so a permission prompt (which
299 // names only those) finds the call it is for; and the calls that asked.
300 // ponytail: two same-named calls of one agent at once share a key; the later wins.
301 const openCalls = new Map<string, string>()
302 const asked = new Set<string>()
303 // Only a session with a person at the prompt counts as seeing them: a `claude -p`
304 // run must not spend the day's welcome.
305 let isInteractive = false
306 // What it said lately and what it is saying now; a reload starts them fresh.
307 let speaker: Speaker = quiet
308 let saying: { text: string; until: number } | null = null
309 // Since when the main loop has run no tool (null while one runs), and its tools and reads this turn.
310 let thinkingSince: number | null = null
311 let toolsThisTurn = 0
312 let readsThisTurn = 0
313 // An agent finished since the pet last cheered: the turn that reports it is worth a cheer.
314 let agentsDone = false
315 // The night it last said it was getting late, by its evening's date.
316 let saidNight: string | null = null
317 // What it wants, from the first draw on, and what the band showed last, which is
318 // what it has been up to since; plus until when it is up on its own.
319 let drives: Drives | undefined
320 // How the pet is made, from its pack once the band has drawn it.
321 let traits: Traits = USUAL
322 let lastState: State = 'awake'
323 let stirredUntil = 0
324 // The way it faced as the action it plays began, kept until it ends.
325 let heldFacing: Held
326 // When it last woke up glad at the person's first key after a long while away,
327 // and whether that was since it went idle at `idleSince`.
328 let gladAt: number | null = null
329 // When it talks in its sleep next; null while awake.
330 let dreamAt: number | null = null
331 const wokeForThem = (idleSince: number | null) => gladAt !== null && idleSince !== null && gladAt >= idleSince
332 // Brings the drives up to `now` before `change` touches them.
333 const touch = (now: number, change: (settled: Drives) => Drives) => {
334 if (drives !== undefined) drives = change(drift(drives, now, lastState, traits))
335 }
336
337 // Says the line for `situation`, unless it spoke too lately or already did this turn.
338 const speak = (situation: Situation, now: number, pack: Pack, locale: Locale) => {
339 if (!maySpeak(speaker, situation, now)) return false
340 speaker = spoke(speaker, situation, now)
341 saying = { text: lineFor(pack.speech, locale.code, situation, say(locale, situation), Math.random), until: now + SPEECH_MS }
342 return true
343 }
344
345 on('session.start', async ($, e, next) => {
346 const locale = await localeOf($, language)
347 await $.command.register({ name: 'pet', description: say(locale, 'commandDescription'), argumentHint: '[name | default]' })
348 petName = (await projectPet($)) ?? configured
349 playing = await packOf($, petName, locale)
350 const now = await $.clock.now()
351 await update($, lastActiveAt, () => now)
352 const tick = () => {
353 const pack = playing
354 frame += 1
355 if (isOff || pack === undefined) return
356 const scene = still
357 const isAsleep = scene?.mood === 'sleeping' || scene?.mood === 'deepSleep'
358 const every = pack.fps * (isAsleep ? REDRAW_ASLEEP_S : REDRAW_AWAKE_S)
359 if (scene === null || frame % every === 0) {
360 $.ui.invalidate('ui.render')
361 return
362 }
363 const moved = step(pack, motion, scene.mood, frame, Math.random)
364 motion = moved.motion
365 const cells = encode(scene.facing === 1 ? moved.frame : mirror(moved.frame), pack.colors)
366 if (cells === scene.cells) return
367 still = { ...scene, cells }
368 // Refused when the band is no longer drawn as it was: redraw it in full.
369 void $.ui
370 .blit({ requestId: scene.requestId, key: 'main', cells })
371 .then(done => done.deny !== undefined)
372 .catch(() => true)
373 .then(refused => {
374 if (refused) $.ui.invalidate('ui.render')
375 })
376 }
377 restartClock = () => {
378 timer?.cancel()
379 timer = $.clock.every(Math.round(1000 / (playing?.fps ?? 4)), tick)
380 }
381 restartClock()
382 isInteractive = e.isInteractive
383 if (isInteractive) await atThePrompt($, now)
384 else await update($, restedAt, at => at ?? now)
385 return next(e)
386 })
387
388 on('command.run', { command: 'pet' }, async ($, e) => {
389 const locale = await localeOf($, language)
390 const asked = (e.args ?? '').trim()
391 if (asked === '') {
392 const hidden = await update($, isHidden, value => !value)
393 return { text: `${say(locale, hidden ? 'hidden' : 'shown')}${await together($, locale)}` }
394 }
395
396 // A pet asked by name is tried before it is kept: one that does not load leaves
397 // things as they were. `default` always lets go of the project's own, even when
398 // the settings' pet is broken and falls back to the shipped one.
399 const isDefault = asked === DEFAULT_ARG
400 const name = isDefault ? configured : asked
401 const found = isDefault ? await readPack($, name, locale) : await findPack($, name, locale)
402 if (typeof found === 'string') return { text: say(locale, 'projectPetFailed', found) }
403 try {
404 await keepProjectPet($, await $.session.root(), isDefault ? undefined : name)
405 } catch {
406 // No store: the pet changes for this session alone.
407 }
408 petName = name
409 loading = { name, pack: Promise.resolve(found) }
410 playing = found
411 motion = undefined
412 still = null
413 restartClock()
414 $.ui.invalidate('ui.render')
415 return { text: say(locale, isDefault ? 'projectPetDefault' : 'projectPet', name) }
416 })
417
418 // The pet looks at the prompt while the person types in it, glad at the first key
419 // after a long while away, unless a turn or something else is showing.
420 on('prompt.edit', async ($, e, next) => {
421 const now = await $.clock.now()
422 await update($, typingAt, () => now)
423 let missed = false
424 touch(now, settled => {
425 missed = misses(settled)
426 return seen(settled)
427 })
428 if (missed && (await read($, turnStartedAt)) === null) {
429 gladAt = now
430 const glad: Reaction = { mood: 'happy', label: label('missedYou'), until: now + REACTION_MS }
431 await update($, reaction, live => (live !== null && live.until > now ? live : glad))
432 }
433 return next(e)
434 }).catch(($, e, next) => next(e))
435
436 // Only a pet in deep sleep is startled awake; a dozing one just starts thinking,
437 // one keeping an eye on background agents was awake all along, and one glad to
438 // see the person woke up already.
439 on('prompt.submit', async ($, e, next) => {
440 const now = await $.clock.now()
441 const idleSince = await read($, lastActiveAt)
442 const busySince = await read($, turnStartedAt)
443 const isSupervising = stillHere(await read($, agents), now).some(one => one.leaving === undefined)
444 const isFastAsleep = idleSince !== null && now - idleSince > DEEP_SLEEP_MS && !wokeForThem(idleSince)
445 if (busySince === null && !isSupervising && isFastAsleep) {
446 await update($, reaction, (): Reaction => ({ mood: 'waking', label: label('wakingUp'), until: now + WAKING_MS }))
447 }
448 touch(now, seen)
449 if (isInteractive) await atThePrompt($, now)
450 // Once a night across every session: the first to see the person late says it.
451 const tonight = nightOf(now)
452 if (isNight(hourOf(now)) && saidNight !== tonight) {
453 if (await quietTonight($, now)) {
454 const locale = await localeOf($, language)
455 if (speak('lateNight', now, await packOf($, petName, locale), locale)) {
456 saidNight = tonight
457 await saidTonight($, now)
458 }
459 } else saidNight = tonight
460 }
461 return next(e)
462 }).catch(($, e, next) => next(e))
463
464 on('turn.start', async ($, e, next) => {
465 const now = await $.clock.now()
466 await update($, activity, (): Activity => ({ mood: 'thinking', label: label('thinking') }))
467 await update($, turnStartedAt, () => now)
468 speaker = { ...speaker, saidThisTurn: [] }
469 touch(now, engaged)
470 thinkingSince = now
471 toolsThisTurn = 0
472 readsThisTurn = 0
473 return next(e)
474 })
475
476 on('turn.complete', async ($, e, next) => {
477 const now = await $.clock.now()
478 const isBad = e.reason === 'error' || e.reason === 'refusal'
479
480 if (e.agentId !== undefined) {
481 const id = e.agentId
482 const leaving = { mood: (isBad || e.isAborted ? 'sad' : 'happy') as MiniMood, until: now + LEAVING_MS }
483 await update($, agents, list => stillHere(list, now).map(pet => (pet.id === id ? { ...pet, leaving } : pet)))
484 await update($, lastActiveAt, () => now)
485 agentsDone = true
486 return next(e)
487 }
488
489 if (!e.isAborted) await update($, feelings, felt => feel(felt, isBad ? 'turnFailed' : 'turnOk', now))
490 const startedAt = await read($, turnStartedAt)
491 const tookLong = startedAt !== null && now - startedAt >= LONG_WIN_MS
492 const agentsLeft = stillHere(await read($, agents), now).filter(one => one.leaving === undefined).length
493 const cheered = cheer({ tookLong, tools: toolsThisTurn, agentsDone, agentsLeft }, Math.random)
494 if (agentsLeft === 0) agentsDone = false
495 const until = now + REACTION_MS
496 // An interrupted turn earns no reaction, and one still showing (a thanks, a
497 // "fine, I won't" after a denied permission, a failure) is left to finish.
498 // A failure always shows; a turn that went well only sometimes cheers.
499 const live = await read($, reaction)
500 if (!e.isAborted && (live === null || live.until <= now) && (isBad || cheered !== null)) {
501 await update($, reaction, (): Reaction =>
502 isBad
503 ? { mood: 'sad', label: label('wentWrong'), until }
504 : cheered === 'celebrating'
505 ? { mood: 'celebrating', label: label('phew'), until: now + CELEBRATION_MS }
506 : { mood: 'happy', label: label('done'), until },
507 )
508 }
509 await update($, override, () => null)
510 await update($, turnStartedAt, () => null)
511 await update($, lastActiveAt, () => now)
512 return next(e)
513 })
514
515 on('agent.spawn', async ($, e, next) => {
516 const started = await next(e)
517 const id = 'agentId' in started ? started.agentId : undefined
518 if (id !== undefined) {
519 const now = await $.clock.now()
520 await update($, agents, list => {
521 const color = AGENT_COLORS[list.length % AGENT_COLORS.length] ?? 0xffffff
522 const pet: AgentPet = { id, toolUseId: e.tool_use_id, type: e.subagentType, label: label('thinking'), color }
523 return [...stillHere(list, now), pet]
524 })
525 const working = stillHere(await read($, agents), now).filter(one => one.leaving === undefined)
526 if (working.length >= MANY_AGENTS) {
527 const locale = await localeOf($, language)
528 speak('manyAgents', now, await packOf($, petName, locale), locale)
529 }
530 }
531 return started
532 }).catch(($, e, next) => next(e))
533
534 on('tool.call', async ($, e, next) => {
535 const now = describe(e)
536 const agentId = e.agentId
537
538 if (agentId === undefined) {
539 await update($, activity, () => now)
540 } else {
541 await update($, agents, list => list.map(pet => (pet.id === agentId ? { ...pet, label: now.label } : pet)))
542 }
543
544 if (agentId === undefined) {
545 thinkingSince = null
546 toolsThisTurn += 1
547 }
548 const key = callKey(agentId, String(e.tool))
549 openCalls.set(key, e.tool_use_id)
550 let ran: Awaited<ReturnType<typeof next>>
551 let wasAsked = false
552 try {
553 ran = await next(e)
554 } finally {
555 if (openCalls.get(key) === e.tool_use_id) openCalls.delete(key)
556 wasAsked = asked.delete(e.tool_use_id)
557 }
558
559 // A background agent's tool call returns at once; its pet leaves on its own
560 // turn.complete. Only a spawn that failed is dropped here.
561 if (e.tool === 'Agent' && ran.isError === true) {
562 await update($, agents, list => list.filter(pet => pet.toolUseId !== e.tool_use_id))
563 }
564 const at = await $.clock.now()
565 const until = at + REACTION_MS
566 if (agentId === undefined) {
567 thinkingSince = at
568 if (e.tool === 'Read' && (readsThisTurn += 1) === MANY_READS) {
569 const locale = await localeOf($, language)
570 speak('manyReads', at, await packOf($, petName, locale), locale)
571 }
572 }
573 if (wasAsked) {
574 // The person answered a permission prompt: thanks for a yes, fine for a no.
575 const thanked: Reaction = isDenial(ran)
576 ? { mood: 'sad', label: label('denied'), until }
577 : { mood: 'happy', label: label('thanks'), until }
578 await update($, reaction, () => thanked)
579 } else if (agentId === undefined && ran.isError === true) {
580 // "failed: npm test", or the tool's name when the action has no detail.
581 const failed = label('failed', short(now.label.detail || String(e.tool), 24))
582 await update($, reaction, (): Reaction => ({ mood: 'sad', label: failed, until }))
583 await update($, feelings, felt => feel(felt, 'toolFailed', at))
584 }
585 return ran
586 }).catch(($, e, next) => next(e))
587
588 // The band is hidden while a permission dialog is open (and `$.ui.notice` does
589 // not show under the terminal's), so the pet only marks the call that asked;
590 // tool.call reacts once the person has answered.
591 on('classic.PermissionRequest', async ($, e, next) => {
592 const id = openCalls.get(callKey(e.agent_id, e.tool_name))
593 if (id !== undefined) asked.add(id)
594 return next(e)
595 }).catch(($, e, next) => next(e))
596
597 on('session.compact', async ($, e, next) => {
598 await update($, override, (): Activity => ({ mood: 'compacting', label: label('compacting') }))
599 try {
600 return await next(e)
601 } finally {
602 await release($, 'compacting')
603 }
604 }).catch(($, e, next) => next(e))
605
606 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
607 still = null
608 isOff = await read($, isHidden)
609 if (e.props.hasSurvey || isOff) {
610 return next(e)
611 }
612
613 const locale = await localeOf($, language)
614 const pack = await packOf($, petName, locale)
615 const now = await $.clock.now()
616 const busy = await read($, override)
617 const flash = await read($, reaction)
618 const doing = await read($, activity)
619 const startedAt = await read($, turnStartedAt)
620 const idleSince = await read($, lastActiveAt)
621 const typedAt = await read($, typingAt)
622 const list = stillHere(await read($, agents), now)
623 const words = (one: Label) => say(locale, one.text, one.detail)
624 // ponytail: the drives move only when the band is drawn, so a band hidden by /pet
625 // counts all that time as what it last showed; give them a clock of their own if that skews them.
626 traits = pack.personality
627 drives = drives === undefined ? rested(now) : drift(drives, now, lastState, traits)
628
629 let shown: Activity
630 let extra = ''
631 if (busy !== null) {
632 shown = busy
633 } else if (flash !== null && flash.until > now) {
634 shown = flash
635 } else if (e.props.isWorking) {
636 const elapsed = startedAt === null ? 0 : now - startedAt
637 const isLong = elapsed > LONG_TURN_MS && PATIENT.includes(doing.mood)
638 if (thinkingSince !== null && now - thinkingSince >= LONG_THINK_MS) speak('longThink', now, pack, locale)
639 // A turn that only thinks while agents still run is waiting on them.
640 const current: Activity =
641 doing.mood === 'thinking' && list.some(one => one.leaving === undefined)
642 ? { mood: 'supervising', label: label('waitingForAgents') }
643 : doing
644 shown = isLong ? { ...current, mood: 'sweating' } : current
645 if (isLong) extra = ` · ${Math.floor(elapsed / 60_000)}m`
646 } else {
647 // It dozes off sooner at night, and when it runs low on energy. Unknown idle time
648 // (nothing has run yet) counts as a plain nap.
649 const hour = hourOf(now)
650 const awakeFor = (isNight(hour) ? awakeMs / 2 : awakeMs) * alertness(drives)
651 const idleFor = idleSince === null ? awakeFor + 1 : now - idleSince
652 const isTyping = typedAt !== null && now - typedAt < TYPING_MS
653 // Fast asleep it does not look up, unless it woke up glad to see them since.
654 if (isTyping && (idleFor <= DEEP_SLEEP_MS || wokeForThem(idleSince))) shown = { mood: 'watching', label: label('watching') }
655 // Background agents still at work: it keeps an eye on them rather than dozing off.
656 else if (list.some(one => one.leaving === undefined)) shown = { mood: 'supervising', label: label('waitingForAgents') }
657 // Once glad to see them it only dozes, so the prompt it sends does not startle it.
658 else if (idleSince !== null && idleFor > DEEP_SLEEP_MS && !wokeForThem(idleSince)) shown = { mood: 'deepSleep', label: label('deepSleep') }
659 else if (idleFor > awakeFor && now >= stirredUntil && !stirs(drives)) shown = asleep
660 else {
661 // Bored in a nap, it gets up for a while on its own.
662 if (idleFor > awakeFor && now >= stirredUntil) {
663 stirredUntil = now + STIR_MS
664 drives = stirred(drives)
665 speak('bored', now, pack, locale)
666 }
667 const felt = idleMood(await read($, feelings), now, await read($, restedAt), hour)
668 shown = felt === undefined ? { mood: 'idle', label: label('idle') } : { mood: felt, label: label(felt) }
669 }
670 }
671
672 lastState = shown.mood === 'sleeping' || shown.mood === 'deepSleep' ? 'asleep' : e.props.isWorking ? 'working' : 'awake'
673 // Asleep, it talks in its sleep now and then, as rarely as it speaks at all.
674 const dreamGap = () => (DREAM_MINUTES[0] + (DREAM_MINUTES[1] - DREAM_MINUTES[0]) * Math.random()) * 60_000
675 if (lastState !== 'asleep') dreamAt = null
676 else if (dreamAt === null) dreamAt = now + dreamGap()
677 else if (now >= dreamAt) {
678 speak('dreaming', now, pack, locale)
679 dreamAt = now + dreamGap()
680 }
681
682 const miniSize = sizeOf(pack.mini?.working[0] ?? [])
683 const slot = Math.max(miniSize.columns, AGENT_SLOT)
684 const petColumns = pack.moods.sleeping[0]?.[0]?.length ?? 0
685 // As many agents as fit beside the pet; the rest are a "+N" that needs room too.
686 // A pack with no mini pets shows its agents only by its own mood.
687 const room = e.props.bodyColumns - petColumns - 2
688 const fits = (count: number) => count * (slot + 1) + (count < list.length ? OVERFLOW_COLUMNS : 0) <= room
689 let shownAgents = pack.mini === null ? 0 : Math.min(list.length, MAX_AGENTS)
690 while (shownAgents > 0 && !fits(shownAgents)) shownAgents -= 1
691 const visible = list.slice(0, shownAgents)
692 const hidden = pack.mini === null ? 0 : list.length - visible.length
693 const agentWords = (one: AgentPet) =>
694 one.leaving === undefined ? words(one.label) : say(locale, one.leaving.mood === 'sad' ? 'wentWrong' : 'done')
695
696 // Every frame is one size, so the sleeping one tells whether the stage fits, tall and wide.
697 // A line of text has no stage, and must not move the pet on the one that has.
698 const mainSize = sizeOf(pack.moods.sleeping[0] ?? [])
699 if (e.surface !== 'terminal' || e.props.maxRows < mainSize.rows + 1 || e.props.bodyColumns < mainSize.columns) {
700 const { Text } = $.ui.resolve(e)
701 const others = list.map(one => ` · ${one.type}: ${agentWords(one)}`).join('')
702 return <Text dimColor>🐾 Claude: {words(shown.label)}{extra}{others}</Text>
703 }
704
705 // It only strolls alone; with agents around they gather on both sides, and when
706 // they do not fit where it stands it hurries off to make room.
707 // A proud pet struts about too; a sleepy, tired, worried or grumpy one stays put.
708 const isRestless = shown.mood === 'idle' || shown.mood === 'proud'
709 const width = slot + 1
710 const extraColumns = hidden > 0 ? OVERFLOW_COLUMNS : 0
711 // A pet that draws no walking but a teleport gets about by vanishing and appearing.
712 const teleport = pack.walks ? null : pack.teleport
713 const onStage = moveOnStage(stage, {
714 tick: frame,
715 room,
716 fps: pack.fps,
717 random: Math.random,
718 pace: paceOf(drives),
719 walks: pack.walks,
720 teleport: teleport === null ? null : { vanish: teleport.vanish.length, appear: teleport.appear.length },
721 agents: visible.map(one => one.id),
722 width,
723 extra: extraColumns,
724 wants: placed => (visible.length > 0 ? { go: placed.x } : list.length === 0 && isRestless ? 'wander' : 'stay'),
725 })
726 stage = onStage.stage
727 const { drawn, drawnCount, standX, blinking } = onStage
728 const blinkFrame = teleport === null || blinking === undefined ? undefined : teleport[blinking.phase][blinking.at]
729 const showsMore = standX === onStage.placed.x && hidden > 0
730 const sideOf = (index: number) => sideIn(drawn, index)
731 const isOnTheMove = onStage.moving || blinkFrame !== undefined
732 const isStrolling = isOnTheMove && onStage.wants === 'wander'
733 const mood: Mood = pack.walks && onStage.moving ? 'walking' : shown.mood
734 const labelShown = isStrolling ? label('strolling') : shown.label
735 // A line it says takes Claude's line for a moment, unless a reaction or a mood that
736 // must show is on it.
737 const isQuoting = saying !== null && saying.until > now && busy === null && !(flash !== null && flash.until > now)
738 const said = isQuoting ? `“${saying?.text}”` : words(labelShown)
739
740 const moved = step(pack, motion, mood, frame, Math.random)
741 motion = moved.motion
742 // It faces the way it walks, else the agent finishing as it says goodbye, else the
743 // first; through an action, the way it faced as the action began.
744 const finishing = visible.slice(0, drawnCount).findLastIndex(one => one.leaving !== undefined)
745 const facesNow = faceFor(isOnTheMove, stage.walk?.facing ?? 1, drawn, Math.max(finishing, 0))
746 const once = moved.motion.once
747 const held = holdFacing(heldFacing, once?.isAction ? once : undefined, facesNow, isOnTheMove)
748 heldFacing = held.held
749 const facing = held.side
750 const shape = blinkFrame ?? moved.frame
751 const body = facing === 1 ? shape : mirror(shape)
752 const cells = encode(body, pack.colors)
753 // Nothing but the pet's own frame will move until something is written or
754 // time passes: no turn, agents, stroll or blink, reaction or line it says.
755 const isStill =
756 !e.props.isWorking &&
757 list.length === 0 &&
758 !isOnTheMove &&
759 stage.blink === undefined &&
760 (!(pack.walks || teleport !== null) || onStage.wants === 'stay') &&
761 busy === null &&
762 !(flash !== null && flash.until > now) &&
763 !isQuoting
764 still = isStill ? { requestId: e.requestId, mood, facing, cells } : null
765
766 const { Box, Raster, Text } = $.ui.resolve(e)
767 const agentPet = (index: number, side: Side) => {
768 const one = visible[index]
769 if (one === undefined) return null
770 // The ones still at work on the side it faces jump while it startles them, their
771 // jump played from its first frame as it does, not with the clock.
772 const startledAt = side === facing && one.leaving === undefined ? moved.startled : undefined
773 const miniFrames = pack.mini?.[one.leaving?.mood ?? (startledAt === undefined ? 'working' : 'startled')] ?? []
774 const mini = miniFrames[(startledAt ?? frame) % miniFrames.length] ?? []
775 return (
776 // On the left the slot hugs the pet too: its pet and words lean right.
777 <Box
778 key={one.id}
779 flexDirection="column"
780 width={slot}
781 alignItems={side === 1 ? 'flex-start' : 'flex-end'}
782 marginLeft={side === 1 ? 1 : 0}
783 marginRight={side === 1 ? 0 : 1}
784 >
785 <Raster key={`mini-${one.id}`} {...miniSize} cells={encode(mini, { ...pack.colors, [pack.tint]: one.color })} />
786 <Text bold>{short(one.type, slot)}</Text>
787 <Text dimColor>{short(agentWords(one), slot)}</Text>
788 </Box>
789 )
790 }
791 const leftColumns = drawn.left.length * width + (showsMore && drawn.more === -1 ? extraColumns : 0)
792
793 return (
794 <Box flexDirection="column">
795 <Text>
796 <Text bold>Claude</Text> <Text dimColor>· {said}{extra}</Text>
797 </Text>
798 <Box flexDirection="row" marginLeft={standX - leftColumns}>
799 {showsMore && drawn.more === -1 && (
800 <Box width={OVERFLOW_COLUMNS}>
801 <Text dimColor>+{hidden}</Text>
802 </Box>
803 )}
804 {[...drawn.left].reverse().map(i => agentPet(i, -1))}
805 <Raster key="main" {...mainSize} cells={cells} />
806 {drawn.right.map(i => agentPet(i, 1))}
807 {showsMore && drawn.more === 1 && <Text dimColor> +{hidden}</Text>}
808 </Box>
809 </Box>
810 )
811 })
812}
813hooks/drives.ts 76 lines1import type { Traits } from '../types'
2import type { Pace } from './walk'
3
4// What the pet wants, apart from what Claude is doing: each in [0, 1], drifting
5// with time by what it was up to. Session only, like the feelings.
6export type Drives = { energy: number; boredom: number; longing: number; at: number }
7
8// What it was up to since `at`: what the band last showed.
9export type State = 'asleep' | 'awake' | 'working'
10
11const HOUR_MS = 60 * 60_000
12
13// Change per hour. Sleep fills energy in two hours; boredom fills in a quarter of
14// an hour with nothing to do; longing fills in an hour without the person, even
15// while Claude works, since it is about them.
16const RATES: Record<State, Omit<Drives, 'at'>> = {
17 asleep: { energy: 0.5, boredom: 4, longing: 1 },
18 awake: { energy: -0.1, boredom: 4, longing: 1 },
19 working: { energy: -0.25, boredom: 0, longing: 1 },
20}
21
22// Bored enough, and not too tired, to get up on its own.
23const STIR_BOREDOM = 1 / 3
24const STIR_ENERGY = 0.4
25// Below this it starts to flag.
26const LOW_ENERGY = 0.5
27// Missing the person this much, it greets their first key.
28const MISSING = 0.5
29
30export const USUAL: Traits = { energetic: 0.5, curious: 0.5, affectionate: 0.5 }
31
32export const rested = (now: number): Drives => ({ energy: 1, boredom: 0, longing: 0, at: now })
33
34const unit = (value: number) => Math.min(1, Math.max(0, value))
35
36// The drives at `now`, after spending the time since `at` in `state`, as a pet
37// made with `traits`: an energetic one tires slower, a curious one gets bored
38// sooner, an affectionate one misses the person sooner. The usual traits change nothing.
39export const drift = (drives: Drives, now: number, state: State, traits: Traits = USUAL): Drives => {
40 const hours = (now - drives.at) / HOUR_MS
41 if (hours <= 0) return drives
42 const rate = RATES[state]
43 const tiring = rate.energy < 0 ? 1.5 - traits.energetic : 1
44 return {
45 energy: unit(drives.energy + rate.energy * tiring * hours),
46 boredom: unit(drives.boredom + rate.boredom * traits.curious * 2 * hours),
47 longing: unit(drives.longing + rate.longing * traits.affectionate * 2 * hours),
48 at: now,
49 }
50}
51
52// A turn started: something to watch at last.
53export const engaged = (drives: Drives): Drives => ({ ...drives, boredom: 0 })
54
55// Up from a nap on its own: that cured its boredom.
56export const stirred = (drives: Drives): Drives => ({ ...drives, boredom: 0 })
57
58// The person typed or sent a prompt.
59export const seen = (drives: Drives): Drives => ({ ...drives, boredom: 0, longing: 0 })
60
61// How much of its usual awake time it lasts: all of it until energy runs low.
62export const alertness = (drives: Drives) => (drives.energy >= LOW_ENERGY ? 1 : Math.max(0.25, drives.energy * 2))
63
64// How it strolls: a tired pet rests longer between strolls, up to twice as long,
65// and one that misses the person keeps near the prompt.
66export const paceOf = (drives: Drives): Pace => ({
67 rest: drives.energy >= LOW_ENERGY ? 1 : 2 - drives.energy * 2,
68 lean: drives.longing,
69})
70
71// Whether it has missed the person enough to greet them.
72export const misses = (drives: Drives) => drives.longing >= MISSING
73
74// Whether a dozing pet gets up on its own.
75export const stirs = (drives: Drives) => drives.boredom >= STIR_BOREDOM && drives.energy >= STIR_ENERGY
76hooks/feelings.ts 72 lines1import type { Feelings, Mood } from '../types'
2
3export type Feeling = 'toolFailed' | 'turnFailed' | 'turnOk'
4
5const FADE_MS = 5 * 60_000
6const WORRIED = 3
7const GRUMPY = 6
8const PROUD = 3
9// Hours of work since its last long break that make it tired.
10const TIRED_MS = 3 * 60 * 60_000
11
12export const calm: Feelings = { worry: 0, pride: 0, at: 0 }
13
14// The feelings as they stand at `now`, the points faded since `at` taken off.
15export const settle = (feelings: Feelings, now: number): Feelings => {
16 const faded = Math.max(0, Math.floor((now - feelings.at) / FADE_MS))
17 if (faded === 0) return feelings
18 return {
19 worry: Math.max(0, feelings.worry - faded),
20 pride: Math.max(0, feelings.pride - faded),
21 at: feelings.at + faded * FADE_MS,
22 }
23}
24
25export const feel = (feelings: Feelings, what: Feeling, now: number): Feelings => {
26 const { worry, pride } = settle(feelings, now)
27 switch (what) {
28 case 'toolFailed':
29 return { worry: worry + 1, pride, at: now }
30 // Pride is for a streak: a turn that fails ends it.
31 case 'turnFailed':
32 return { worry: worry + 2, pride: 0, at: now }
33 case 'turnOk':
34 return { worry: Math.max(0, worry - 1), pride: pride + 1, at: now }
35 }
36}
37
38// A turn of work that went well cheers it only now and then, or it would cheer at every step.
39const CHEER_CHANCE = 0.6
40
41type Turn = { tookLong: boolean; tools: number; agentsDone: boolean; agentsLeft: number }
42
43// How a turn that went well ends. It waits for the last agent before cheering;
44// a long turn is celebrated, the last agent done is cheered, other work only now
45// and then, and a turn that only talked earns nothing.
46export const cheer = (turn: Turn, random: () => number): 'celebrating' | 'happy' | null => {
47 if (turn.agentsLeft > 0) return null
48 if (turn.tookLong) return 'celebrating'
49 if (turn.agentsDone) return 'happy'
50 if (turn.tools === 0) return null
51 return random() < CHEER_CHANCE ? 'happy' : null
52}
53
54export const isNight = (hour: number) => hour >= 22 || hour < 6
55export const isMorning = (hour: number) => hour >= 5 && hour < 11
56// The night `now` falls in, named by its evening's date: 2 a.m. belongs to the night before.
57export const nightOf = (now: number) => new Date(now - 6 * 60 * 60_000).toDateString()
58
59// The moods that stand in for plain `idle`; each is also the key of its words.
60export type Felt = Extract<Mood, 'grumpy' | 'worried' | 'tired' | 'proud' | 'sleepy'>
61
62// What it shows instead of plain `idle`, if anything: the strongest feeling wins.
63export const idleMood = (feelings: Feelings, now: number, restedAt: number | null, hour: number): Felt | undefined => {
64 const { worry, pride } = settle(feelings, now)
65 if (worry >= GRUMPY) return 'grumpy'
66 if (worry >= WORRIED) return 'worried'
67 if (restedAt !== null && now - restedAt >= TIRED_MS) return 'tired'
68 if (pride >= PROUD && worry === 0) return 'proud'
69 if (isNight(hour)) return 'sleepy'
70 return undefined
71}
72hooks/i18n.ts 28 lines1import type { Text } from '../types'
2import { en } from '../locales/en'
3import type { Locale } from '../locales/en'
4import { ptBR } from '../locales/pt-BR'
5
6export type { Locale, Text }
7
8export const LOCALES: readonly Locale[] = [en, ptBR]
9
10const byName = (wanted: string | undefined) => {
11 if (!wanted) return undefined
12 // "pt_BR.UTF-8" → "pt-br"
13 const name = wanted.trim().toLowerCase().split('.')[0]?.replace('_', '-') ?? ''
14 return (
15 LOCALES.find(locale => locale.names.includes(name)) ??
16 LOCALES.find(locale => locale.names.includes(name.split('-')[0] ?? ''))
17 )
18}
19
20// `auto` follows Claude Code's own `language` setting, then $LANG, then English.
21export const pickLocale = (choice: string, setting: unknown, lang: string | undefined): Locale =>
22 (choice !== 'auto' ? byName(choice) : undefined) ??
23 byName(typeof setting === 'string' ? setting : undefined) ??
24 byName(lang) ??
25 en
26
27export const say = (locale: Locale, text: Text, detail = '') => locale.strings[text].replace('{detail}', detail)
28hooks/memory.ts 38 lines1import { isMorning } from './feelings'
2
3// What the pet remembers of the person across sessions, kept in `$.store`.
4export type Visit = { metAt: number; lastSeenAt: number }
5
6// How a session opens, at most one: the first that applies wins.
7export type Welcome = { kind: 'anniversary'; days: number } | { kind: 'missedYou' } | { kind: 'goodMorning' }
8
9const DAY_MS = 24 * 60 * 60_000
10const MILESTONES = [7, 30, 100]
11const MISSED_DAYS = 2
12
13const midnight = (time: number) => new Date(time).setHours(0, 0, 0, 0)
14
15// Calendar days from `from` to `to`, in local time.
16export const daysBetween = (from: number, to: number) => Math.round((midnight(to) - midnight(from)) / DAY_MS)
17
18export const isAnniversary = (days: number) => MILESTONES.includes(days) || (days > 0 && days % 365 === 0)
19
20export const welcome = (visit: Visit | undefined, now: number, hour: number): Welcome | undefined => {
21 if (visit !== undefined) {
22 const away = daysBetween(visit.lastSeenAt, now)
23 // Seen already today: the day's welcome is spent.
24 if (away === 0) return undefined
25 const age = daysBetween(visit.metAt, now)
26 if (isAnniversary(age)) return { kind: 'anniversary', days: age }
27 if (away >= MISSED_DAYS) return { kind: 'missedYou' }
28 }
29 return isMorning(hour) ? { kind: 'goodMorning' } : undefined
30}
31
32// The store holds what anyone could have written: only a well-formed visit counts.
33export const asVisit = (value: unknown): Visit | undefined => {
34 if (typeof value !== 'object' || value === null) return undefined
35 const { metAt, lastSeenAt } = value as Record<string, unknown>
36 return typeof metAt === 'number' && typeof lastSeenAt === 'number' ? { metAt, lastSeenAt } : undefined
37}
38hooks/motion.ts 95 lines1import type { Frame, Mood } from '../types'
2import type { Pack } from './pack'
3
4// What the main pet is playing. Kept between draws so a loop runs from its own
5// start and a one-off finishes before the loop picks up again.
6export type Motion = {
7 mood: Mood
8 loop: Frame[]
9 // The tick the mood started on, before its transition.
10 began: number
11 // The tick the loop's first frame plays on.
12 since: number
13 // A transition or an action, played once from `start`; an action that startles
14 // the agents does so from its frame `startles` on.
15 once?: { frames: Frame[]; start: number; isAction: boolean; startles?: number }
16 // The tick from which each action may play again.
17 next: Record<string, number>
18}
19
20// A number in [0, 1): Math.random in the plugin, a fixed sequence in tests.
21export type Random = () => number
22
23// Moods of a turn at work: a burst of tools would flick between them every call.
24const WORK: ReadonlySet<Mood> = new Set(['thinking', 'typing', 'running', 'writing', 'reading', 'searching', 'supervising'])
25// How long a work mood shows before another work mood may take over.
26const DWELL_SECONDS = 1
27// An action this close to its end finishes before the mood changes.
28const FOLLOW_THROUGH_TICKS = 2
29
30const ticks = (seconds: number, fps: number) => Math.max(1, Math.round(seconds * fps))
31
32const someTime = ([min, max]: [number, number], fps: number, random: Random) => ticks(min + (max - min) * random(), fps)
33
34export const transitionFor = (pack: Pack, from: Mood, to: Mood) =>
35 pack.transitions[`${from}>${to}`] ?? pack.transitions[`${from}>*`] ?? pack.transitions[`*>${to}`]
36
37const start = (pack: Pack, previous: Motion | undefined, mood: Mood, tick: number, random: Random): Motion => {
38 const loops = [pack.moods[mood], ...pack.variants[mood]]
39 const loop = loops[Math.floor(random() * loops.length)] ?? pack.moods[mood]
40 const transition = previous === undefined ? undefined : transitionFor(pack, previous.mood, mood)
41 const next: Record<string, number> = {}
42 for (const action of pack.actions) {
43 if (action.moods.includes(mood)) next[action.name] = tick + someTime(action.every, pack.fps, random)
44 }
45 return {
46 mood,
47 loop,
48 began: tick,
49 since: tick + (transition?.length ?? 0),
50 once: transition === undefined ? undefined : { frames: transition, start: tick, isAction: false },
51 next,
52 }
53}
54
55// Whether it keeps playing `motion` on `tick` though the mood is now `mood`: a work
56// mood shows a moment before the next one, and an action about to end finishes.
57// Anything else, a reaction or sleep, takes over at once.
58const holds = (pack: Pack, motion: Motion, mood: Mood, tick: number) => {
59 if (WORK.has(motion.mood) && WORK.has(mood) && tick - motion.began < ticks(DWELL_SECONDS, pack.fps)) return true
60 const left = motion.once === undefined ? 0 : motion.once.start + motion.once.frames.length - tick
61 return motion.once?.isAction === true && left > 0 && left <= FOLLOW_THROUGH_TICKS
62}
63
64// The frame to draw on `tick`, and how many ticks ago it began to startle the
65// agents, if it does. Safe to call more than once per tick: the same tick gives
66// the same frame.
67export const step = (pack: Pack, previous: Motion | undefined, mood: Mood, tick: number, random: Random) => {
68 let motion =
69 previous !== undefined && (previous.mood === mood || holds(pack, previous, mood, tick))
70 ? previous
71 : start(pack, previous, mood, tick, random)
72
73 if (motion.once !== undefined) {
74 const at = tick - motion.once.start
75 const frame = motion.once.frames[at]
76 const { startles } = motion.once
77 const startled = startles !== undefined && at >= startles ? at - startles : undefined
78 if (frame !== undefined) return { motion, frame, startled }
79 motion = { ...motion, once: undefined }
80 }
81
82 // A mood on its way out starts no new action.
83 const isLeaving = motion.mood !== mood
84 const due = isLeaving ? undefined : pack.actions.find(action => (motion.next[action.name] ?? Infinity) <= tick)
85 if (due !== undefined) {
86 const again = tick + due.frames.length + someTime(due.every, pack.fps, random)
87 const once = { frames: due.frames, start: tick, isAction: true, startles: due.startles }
88 motion = { ...motion, once, next: { ...motion.next, [due.name]: again } }
89 return { motion, frame: due.frames[0] ?? [], startled: due.startles === 0 ? 0 : undefined }
90 }
91
92 const at = (((tick - motion.since) % motion.loop.length) + motion.loop.length) % motion.loop.length
93 return { motion, frame: motion.loop[at] ?? [], startled: undefined }
94}
95hooks/pack.ts 328 lines1import type { Frame, MiniMood, Mood, PackFile, Situation, Traits } from '../types'
2import { USUAL } from './drives'
3import { hexColor } from './render'
4import { SITUATIONS } from './speech'
5import type { Colors } from './render'
6
7// Each mood a pack leaves out borrows from its parent, so an old or small pack
8// still shows something close. `sleeping` is the root every pack must draw.
9export const PARENT: Record<Mood, Mood | null> = {
10 sleeping: null,
11 deepSleep: 'sleeping',
12 idle: 'thinking',
13 sleepy: 'idle',
14 tired: 'sleepy',
15 walking: 'idle',
16 watching: 'reading',
17 thinking: 'sleeping',
18 waking: 'thinking',
19 typing: 'thinking',
20 running: 'typing',
21 writing: 'typing',
22 reading: 'thinking',
23 searching: 'reading',
24 supervising: 'thinking',
25 compacting: 'thinking',
26 sweating: 'thinking',
27 worried: 'sweating',
28 grumpy: 'sad',
29 proud: 'happy',
30 happy: 'sleeping',
31 celebrating: 'happy',
32 sad: 'sleeping',
33}
34
35export const MOODS = Object.keys(PARENT) as Mood[]
36
37export const MINI_MOODS: readonly MiniMood[] = ['working', 'happy', 'sad', 'startled']
38
39export const DEFAULT_PET = 'cat'
40
41const LIMITS = {
42 main: { columns: 48, pixelRows: 32 },
43 mini: { columns: 12, pixelRows: 12 },
44 framesPerMood: 16,
45 variantsPerMood: 4,
46 transitions: 32,
47 actions: 16,
48 everySeconds: { min: 1, max: 600 },
49 linesPerSituation: 8,
50 lineLength: 40,
51 paletteSize: 16,
52 fps: { min: 1, max: 12 },
53}
54
55export type Action = { name: string; frames: Frame[]; moods: Mood[]; every: [number, number]; startles?: number }
56
57export type Pack = {
58 name: string
59 colors: Colors
60 fps: number
61 // The loop each mood plays, borrowed from a parent when the pack leaves it out.
62 moods: Record<Mood, Frame[]>
63 // More loops for a mood, following the same borrowing as `moods`.
64 variants: Record<Mood, Frame[][]>
65 transitions: Record<string, Frame[]>
66 actions: Action[]
67 // null when the pack draws no mini pets: its agents show only as its own `supervising`.
68 mini: Record<MiniMood, Frame[]> | null
69 tint: string
70 // Only a pack that draws `walking` leaves its spot by walking; one that draws
71 // `teleport` and no `walking` gets about by vanishing and appearing.
72 walks: boolean
73 teleport: { vanish: Frame[]; appear: Frame[] } | null
74 speech: Record<string, Partial<Record<Situation, string[]>>>
75 personality: Traits
76}
77
78const isRecord = (value: unknown): value is Record<string, unknown> =>
79 typeof value === 'object' && value !== null && !Array.isArray(value)
80
81const checkFrames = (frames: unknown, where: string, limit: { columns: number; pixelRows: number }): Frame[] => {
82 if (!Array.isArray(frames) || frames.length === 0) throw new Error(`${where}: needs at least one frame`)
83 if (frames.length > LIMITS.framesPerMood) throw new Error(`${where}: at most ${LIMITS.framesPerMood} frames`)
84 return frames.map((frame, i) => {
85 if (!Array.isArray(frame) || frame.length === 0 || !frame.every(row => typeof row === 'string')) {
86 throw new Error(`${where}[${i}]: a frame is a list of strings`)
87 }
88 const rows = frame as string[]
89 const width = rows[0]?.length ?? 0
90 if (width === 0 || rows.some(row => row.length !== width)) throw new Error(`${where}[${i}]: every row needs the same length`)
91 if (width > limit.columns || rows.length > limit.pixelRows) {
92 throw new Error(`${where}[${i}]: at most ${limit.columns}x${limit.pixelRows} pixels`)
93 }
94 return rows
95 })
96}
97
98const sameSize = (frames: Frame[], where: string) => {
99 const [first] = frames
100 if (frames.some(f => f.length !== first?.length || f[0]?.length !== first?.[0]?.length)) {
101 throw new Error(`${where}: every frame needs the same size`)
102 }
103}
104
105const isMood = (value: unknown): value is Mood => typeof value === 'string' && MOODS.includes(value as Mood)
106
107// Variants hang off a mood the pack draws: one on a borrowed mood would never show.
108const parseVariants = (raw: unknown, drawn: Map<Mood, Frame[]>) => {
109 const out = new Map<Mood, Frame[][]>()
110 if (raw === undefined) return out
111 if (!isRecord(raw)) throw new Error('main.variants: an object of mood to loops')
112 for (const [mood, loops] of Object.entries(raw)) {
113 if (!isMood(mood)) throw new Error(`main.variants: unknown mood ${mood}`)
114 if (!drawn.has(mood)) throw new Error(`main.variants.${mood}: draw main.moods.${mood} first`)
115 if (!Array.isArray(loops) || loops.length === 0 || loops.length > LIMITS.variantsPerMood) {
116 throw new Error(`main.variants.${mood}: 1 to ${LIMITS.variantsPerMood} loops`)
117 }
118 out.set(mood, loops.map((loop, i) => checkFrames(loop, `main.variants.${mood}[${i}]`, LIMITS.main)))
119 }
120 return out
121}
122
123const TRANSITION = /^(\*|[a-zA-Z]+)>(\*|[a-zA-Z]+)$/
124
125const parseTransitions = (raw: unknown) => {
126 const out: Record<string, Frame[]> = {}
127 if (raw === undefined) return out
128 if (!isRecord(raw)) throw new Error('main.transitions: an object of "from>to" to frames')
129 const entries = Object.entries(raw)
130 if (entries.length > LIMITS.transitions) throw new Error(`main.transitions: at most ${LIMITS.transitions}`)
131 for (const [key, frames] of entries) {
132 const [, from, to] = TRANSITION.exec(key) ?? []
133 const known = (side: string | undefined) => side === '*' || isMood(side)
134 if (!known(from) || !known(to) || key === '*>*') {
135 throw new Error(`main.transitions: "${key}" must be "from>to" with moods or one "*"`)
136 }
137 out[key] = checkFrames(frames, `main.transitions.${key}`, LIMITS.main)
138 }
139 return out
140}
141
142const ACTION_NAME = /^[a-zA-Z][a-zA-Z0-9_-]{0,31}$/
143
144const parseActions = (raw: unknown): Action[] => {
145 if (raw === undefined) return []
146 if (!isRecord(raw)) throw new Error('main.actions: an object of name to action')
147 const entries = Object.entries(raw)
148 if (entries.length > LIMITS.actions) throw new Error(`main.actions: at most ${LIMITS.actions}`)
149 return entries.map(([name, action]) => {
150 const where = `main.actions.${name}`
151 if (!ACTION_NAME.test(name)) throw new Error(`${where}: name with letters, digits, - or _ (up to 32)`)
152 if (!isRecord(action)) throw new Error(`${where}: needs frames, moods and every`)
153 const { moods, every } = action
154 if (!Array.isArray(moods) || moods.length === 0 || !moods.every(isMood)) throw new Error(`${where}.moods: a list of moods`)
155 const { min, max } = LIMITS.everySeconds
156 if (
157 !Array.isArray(every) ||
158 every.length !== 2 ||
159 !every.every(n => typeof n === 'number' && n >= min && n <= max) ||
160 every[0] > every[1]
161 ) {
162 throw new Error(`${where}.every: [min, max] seconds, ${min} to ${max}`)
163 }
164 const frames = checkFrames(action.frames, `${where}.frames`, LIMITS.main)
165 const { startles } = action
166 if (startles === undefined) return { name, frames, moods, every: [every[0], every[1]] }
167 if (typeof startles !== 'number' || !Number.isInteger(startles) || startles < 0 || startles >= frames.length) {
168 throw new Error(`${where}.startles: one of its frames, 0 to ${frames.length - 1}`)
169 }
170 return { name, frames, moods, every: [every[0], every[1]], startles }
171 })
172}
173
174const parseTeleport = (raw: unknown) => {
175 if (raw === undefined) return null
176 if (!isRecord(raw)) throw new Error('main.teleport: an object with vanish and appear frames')
177 return {
178 vanish: checkFrames(raw.vanish, 'main.teleport.vanish', LIMITS.main),
179 appear: checkFrames(raw.appear, 'main.teleport.appear', LIMITS.main),
180 }
181}
182
183// Each trait a number from 0 to 1; one left out is the usual.
184const parsePersonality = (raw: unknown): Traits => {
185 if (raw === undefined) return USUAL
186 if (!isRecord(raw)) throw new Error('personality: an object of trait to a number from 0 to 1')
187 const traits = { ...USUAL }
188 for (const [trait, value] of Object.entries(raw)) {
189 if (!Object.hasOwn(USUAL, trait)) throw new Error(`personality.${trait}: unknown trait, use ${Object.keys(USUAL).join(', ')}`)
190 if (typeof value !== 'number' || !(value >= 0 && value <= 1)) throw new Error(`personality.${trait}: a number from 0 to 1`)
191 traits[trait as keyof Traits] = value
192 }
193 return traits
194}
195
196const LANGUAGE = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})?$/
197
198// Lines are shown as they are: plain one-line text, short enough for the band.
199const parseSpeech = (raw: unknown) => {
200 const out: Record<string, Partial<Record<Situation, string[]>>> = {}
201 if (raw === undefined) return out
202 if (!isRecord(raw)) throw new Error('speech: an object of language to situations')
203 for (const [code, situations] of Object.entries(raw)) {
204 if (!LANGUAGE.test(code)) throw new Error(`speech: "${code}" is not a language code like "en" or "pt-BR"`)
205 if (!isRecord(situations)) throw new Error(`speech.${code}: an object of situation to lines`)
206 const lines: Partial<Record<Situation, string[]>> = {}
207 for (const [situation, list] of Object.entries(situations)) {
208 const where = `speech.${code}.${situation}`
209 if (!SITUATIONS.includes(situation as Situation)) throw new Error(`${where}: unknown situation, use ${SITUATIONS.join(', ')}`)
210 const { linesPerSituation: most, lineLength: longest } = LIMITS
211 if (
212 !Array.isArray(list) ||
213 list.length === 0 ||
214 list.length > most ||
215 !list.every(line => typeof line === 'string' && line.trim() !== '' && line.length <= longest && !/[\u0000-\u001f\u007f]/.test(line))
216 ) {
217 throw new Error(`${where}: 1 to ${most} lines of one line each, up to ${longest} characters`)
218 }
219 lines[situation as Situation] = list
220 }
221 out[code] = lines
222 }
223 return out
224}
225
226// Packs come from anyone, so everything is checked before it reaches the renderer.
227export const parsePack = (raw: unknown): Pack => {
228 if (!isRecord(raw)) throw new Error('a pack is a JSON object')
229 const file = raw as Partial<PackFile>
230
231 if (typeof file.name !== 'string' || file.name === '') throw new Error('name: required')
232
233 if (!isRecord(file.palette)) throw new Error('palette: required')
234 const entries = Object.entries(file.palette)
235 if (entries.length > LIMITS.paletteSize) throw new Error(`palette: at most ${LIMITS.paletteSize} colors`)
236 const colors: Colors = {}
237 for (const [letter, hex] of entries) {
238 if (letter.length !== 1 || letter === '.') throw new Error(`palette: "${letter}" must be one character other than "."`)
239 if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(hex)) throw new Error(`palette.${letter}: use #rrggbb`)
240 colors[letter] = hexColor(hex)
241 }
242
243 const fps = file.fps ?? 4
244 if (typeof fps !== 'number' || fps < LIMITS.fps.min || fps > LIMITS.fps.max) {
245 throw new Error(`fps: between ${LIMITS.fps.min} and ${LIMITS.fps.max}`)
246 }
247
248 if (!isRecord(file.main) || !isRecord(file.main.moods)) throw new Error('main.moods: required')
249 const given = file.main.moods as Record<string, unknown>
250 if (given.sleeping === undefined) throw new Error('main.moods.sleeping: required, other moods fall back to it')
251 const unknown = Object.keys(given).filter(key => !MOODS.includes(key as Mood))
252 if (unknown.length > 0) throw new Error(`main.moods: unknown mood ${unknown.join(', ')}`)
253
254 const drawn = new Map<Mood, Frame[]>()
255 for (const mood of MOODS) {
256 if (given[mood] !== undefined) drawn.set(mood, checkFrames(given[mood], `main.moods.${mood}`, LIMITS.main))
257 }
258 const extra = parseVariants(file.main.variants, drawn)
259 const moods = {} as Record<Mood, Frame[]>
260 const variants = {} as Record<Mood, Frame[][]>
261 for (const mood of MOODS) {
262 let source: Mood | null = mood
263 while (source !== null && !drawn.has(source)) source = PARENT[source]
264 moods[mood] = drawn.get(source ?? 'sleeping') ?? []
265 variants[mood] = extra.get(source ?? 'sleeping') ?? []
266 }
267 const transitions = parseTransitions(file.main.transitions)
268 const actions = parseActions(file.main.actions)
269 const teleport = parseTeleport(file.main.teleport)
270 sameSize(
271 [
272 ...drawn.values(),
273 ...[...extra.values()].flat(),
274 ...Object.values(transitions),
275 ...actions.map(a => a.frames),
276 ...(teleport === null ? [] : [teleport.vanish, teleport.appear]),
277 ].flat(),
278 'main',
279 )
280
281 const { mini, tint } = file.mini === false ? { mini: null, tint: 'b' } : parseMini(file.mini, colors)
282
283 const speech = parseSpeech(file.speech)
284 const personality = parsePersonality(file.personality)
285
286 return {
287 name: file.name,
288 colors,
289 fps,
290 moods,
291 variants,
292 transitions,
293 actions,
294 mini,
295 tint,
296 walks: drawn.has('walking'),
297 teleport,
298 speech,
299 personality,
300 }
301}
302
303const parseMini = (raw: unknown, colors: Colors) => {
304 if (!isRecord(raw) || !isRecord(raw.moods)) throw new Error('mini.moods: required, or mini: false for no mini pets')
305 const miniGiven = raw.moods as Record<string, unknown>
306 if (miniGiven.working === undefined) throw new Error('mini.moods.working: required, happy, sad and startled fall back to it')
307 const miniUnknown = Object.keys(miniGiven).filter(key => !MINI_MOODS.includes(key as MiniMood))
308 if (miniUnknown.length > 0) throw new Error(`mini.moods: unknown mood ${miniUnknown.join(', ')}`)
309 const working = checkFrames(miniGiven.working, 'mini.moods.working', LIMITS.mini)
310 const mini = { working } as Record<MiniMood, Frame[]>
311 for (const mood of MINI_MOODS) {
312 mini[mood] = miniGiven[mood] === undefined ? working : checkFrames(miniGiven[mood], `mini.moods.${mood}`, LIMITS.mini)
313 }
314 sameSize(MINI_MOODS.flatMap(mood => mini[mood]), 'mini')
315 const tint = raw.tint ?? 'b'
316 if (typeof tint !== 'string' || colors[tint] === undefined) throw new Error('mini.tint: must be a palette letter')
317 return { mini, tint }
318}
319
320const SAFE_NAME = /^[a-z0-9][a-z0-9_-]{0,40}$/
321
322// Where a pet named `name` may live, the person's own pets folder first.
323// Undefined for a name that could step outside those folders.
324export const packPaths = (name: string, home: string | undefined, root: string) =>
325 SAFE_NAME.test(name)
326 ? [...(home ? [`${home}/.claude/pets/${name}.json`] : []), `${root}/pets/${name}.json`]
327 : undefined
328hooks/render.ts 35 lines1import type { Frame } from '../types'
2
3const NONE = 0x01000000
4const HALF_TOP = 0x2580
5const HALF_BOTTOM = 0x2584
6
7export type Colors = Record<string, number>
8
9export const hexColor = (hex: string) => Number.parseInt(hex.slice(1), 16)
10
11export const sizeOf = (frame: Frame) => ({ columns: frame[0]?.length ?? 0, rows: Math.ceil(frame.length / 2) })
12
13// Two pixel rows share one terminal cell: '▀' takes the top as foreground
14// and the bottom as background. Letters missing from the palette are see-through.
15export const encode = (frame: Frame, colors: Colors) => {
16 const words: number[] = []
17 for (let r = 0; r < frame.length; r += 2) {
18 const top = frame[r] ?? ''
19 const bottom = frame[r + 1] ?? ''
20 for (let c = 0; c < top.length; c++) {
21 const up = colors[top[c] ?? '.']
22 const down = colors[bottom[c] ?? '.']
23 if (up === undefined && down === undefined) words.push(0x20, NONE, NONE)
24 else if (up === undefined) words.push(HALF_BOTTOM, down ?? NONE, NONE)
25 else words.push(HALF_TOP, up, down ?? NONE)
26 }
27 }
28 // The engine's runtime has TC39 base64; TypeScript's es2023 lib does not type it.
29 const bytes = new Uint8Array(Uint32Array.from(words).buffer) as Uint8Array & { toBase64(): string }
30 return bytes.toBase64()
31}
32
33// The same frame facing the other way. Packs draw their pet facing right.
34export const mirror = (frame: Frame): Frame => frame.map(row => [...row].reverse().join(''))
35hooks/speech.ts 37 lines1import type { Situation } from '../types'
2import type { Random } from './motion'
3
4export const SITUATIONS: readonly Situation[] = ['longThink', 'manyReads', 'manyAgents', 'lateNight', 'bored', 'dreaming']
5
6// What the pet said lately: speech stays rare, at most once a turn for each
7// situation and never twice within a few minutes.
8export type Speaker = { spokeAt: number | null; saidThisTurn: readonly Situation[] }
9
10export const quiet: Speaker = { spokeAt: null, saidThisTurn: [] }
11
12const COOLDOWN_MS = 3 * 60_000
13// Thinking with no tool this long is a long think.
14export const LONG_THINK_MS = 30_000
15export const MANY_READS = 20
16export const MANY_AGENTS = 3
17
18export const maySpeak = (speaker: Speaker, situation: Situation, now: number) =>
19 (speaker.spokeAt === null || now - speaker.spokeAt >= COOLDOWN_MS) && !speaker.saidThisTurn.includes(situation)
20
21export const spoke = (speaker: Speaker, situation: Situation, now: number): Speaker => ({
22 spokeAt: now,
23 saidThisTurn: [...speaker.saidThisTurn, situation],
24})
25
26// The pack's own line for this language, picked at random, or the locale's.
27export const lineFor = (
28 lines: Record<string, Partial<Record<Situation, string[]>>>,
29 code: string,
30 situation: Situation,
31 fallback: string,
32 random: Random,
33) => {
34 const own = lines[code]?.[situation] ?? []
35 return own[Math.floor(random() * own.length)] ?? fallback
36}
37hooks/walk.ts 244 lines1import type { Random } from './motion'
2
3// Where the main pet stands on the stage, in columns from its left edge, and
4// where it is heading. Kept between draws like Motion.
5export type Walk = {
6 x: number
7 target: number
8 facing: 1 | -1
9 // While wandering, the tick before which it stays put after arriving.
10 restUntil: number
11 // The tick this walk was last stepped on: a second draw on it changes nothing.
12 tick: number
13 // Whether it took a step on that tick, so a second draw shows it walking too.
14 moving: boolean
15 // Where a stroll it broke halfway goes on to, after a short look around.
16 onward?: number
17}
18
19// wander: stroll to a random spot now and then. stay: hold still. go: hurry to a spot.
20export type Plan = 'wander' | 'stay' | { go: number }
21
22const COLUMNS_PER_TICK = 1
23const HURRY_COLUMNS_PER_TICK = 2
24const REST_SECONDS: [number, number] = [3, 10]
25// A stroll this long breaks halfway now and then, for a look around this long.
26const BREAK_FROM_COLUMNS = 12
27const BREAK_CHANCE = 0.3
28const LOOK_SECONDS: [number, number] = [1, 2]
29
30// How it strolls: `rest` stretches the pauses, `lean` (0 to 1) draws its spots
31// toward the left, where the prompt starts, and `stride` sets the columns a tick
32// in place of the usual walk: Infinity lands it there at once, as a teleport.
33export type Pace = { rest: number; lean: number; stride?: number }
34
35const EVEN: Pace = { rest: 1, lean: 0 }
36
37export const clamp = (value: number, max: number) => Math.min(Math.max(value, 0), Math.max(max, 0))
38
39// Moves the pet one tick along `plan`; `moving` is true when it took a step.
40export const walkStep = (
41 walk: Walk | undefined,
42 plan: Plan,
43 tick: number,
44 maxX: number,
45 fps: number,
46 random: Random,
47 pace: Pace = EVEN,
48): { walk: Walk; moving: boolean } => {
49 if (walk === undefined) return { walk: { x: 0, target: 0, facing: 1, restUntil: tick, tick, moving: false }, moving: false }
50 const x = clamp(walk.x, maxX)
51 // A second draw on this tick only keeps it inside a stage that shrank since.
52 if (walk.tick === tick) return { walk: { ...walk, x }, moving: walk.moving }
53
54 const pause = ([min, max]: [number, number], scale: number) => tick + Math.round((min + (max - min) * random()) * scale * fps)
55 let { target, facing, restUntil, onward } = walk
56 target = clamp(target, maxX)
57
58 if (plan !== 'wander') onward = undefined
59 if (plan === 'stay') target = x
60 else if (typeof plan === 'object') target = clamp(plan.go, maxX)
61 else if (x === target && tick >= restUntil) {
62 if (onward !== undefined) {
63 target = clamp(onward, maxX)
64 onward = undefined
65 } else {
66 target = Math.round(random() ** (1 + 2 * pace.lean) * maxX)
67 const breaks = pace.stride === undefined && Math.abs(target - x) >= BREAK_FROM_COLUMNS
68 if (breaks && random() < BREAK_CHANCE) {
69 onward = target
70 target = Math.round((x + target) / 2)
71 }
72 }
73 if (target === x) restUntil = pause(REST_SECONDS, pace.rest)
74 }
75
76 if (x === target) return { walk: { x, target, facing, restUntil, tick, moving: false, onward }, moving: false }
77
78 const direction = target > x ? 1 : -1
79 const speed = pace.stride ?? (typeof plan === 'object' ? HURRY_COLUMNS_PER_TICK : COLUMNS_PER_TICK)
80 const next = x + direction * Math.min(speed, Math.abs(target - x))
81 if (next === target && plan === 'wander') restUntil = onward === undefined ? pause(REST_SECONDS, pace.rest) : pause(LOOK_SECONDS, 1)
82 return { walk: { x: next, target, facing: direction, restUntil, tick, moving: true, onward }, moving: true }
83}
84
85export type Side = 1 | -1
86
87// Where the subagents' pets stand around the main one, by their index: each
88// side's list runs from nearest to farthest. `more` is the side of the "+N".
89export type Gathering = { x: number; left: number[]; right: number[]; more: Side }
90
91// Gathers `count` agents, `width` columns each, plus `extra` columns for a "+N",
92// in the free room on both sides of a pet standing at `at`: each on the side it
93// stood on before (`sides[i]`) while it fits there, else on the side with more
94// room left. undefined when they do not all fit there.
95export const layoutAt = (
96 at: number,
97 room: number,
98 count: number,
99 width: number,
100 extra: number,
101 sides: readonly (Side | undefined)[] = [],
102): Gathering | undefined => {
103 const free = { [-1]: at, [1]: room - at }
104 const take = (need: number, wanted?: Side): Side | undefined => {
105 const first: Side = wanted ?? (free[1] >= free[-1] ? 1 : -1)
106 const side = ([first, -first] as Side[]).find(one => free[one] >= need)
107 if (side !== undefined) free[side] -= need
108 return side
109 }
110 const out: Gathering = { x: at, left: [], right: [], more: 1 }
111 for (let i = 0; i < count; i++) {
112 const side = take(width, sides[i])
113 if (side === undefined) return undefined
114 ;(side === 1 ? out.right : out.left).push(i)
115 }
116 if (extra > 0) {
117 const side = take(extra)
118 if (side === undefined) return undefined
119 out.more = side
120 }
121 return out
122}
123
124// Gathers them as `layoutAt` does, where the pet stands; when they do not fit
125// there, at the nearest spot where they do.
126export const gather = (
127 x: number,
128 room: number,
129 count: number,
130 width: number,
131 extra: number,
132 sides: readonly (Side | undefined)[] = [],
133): Gathering => {
134 for (let step = 0; step <= Math.max(room, 0); step++) {
135 for (const at of [x - step, x + step]) {
136 const found = at >= 0 && at <= room ? layoutAt(at, room, count, width, extra, sides) : undefined
137 if (found !== undefined) return found
138 }
139 }
140 return { x, left: [], right: [], more: 1 }
141}
142
143// The side agent `index` stands on.
144export const sideIn = (drawn: Gathering, index: number): Side => (drawn.right.includes(index) ? 1 : -1)
145
146// Which way it faces: the way it walks, else toward the drawn agent `at`.
147export const faceFor = (moving: boolean, facing: Side, drawn: Gathering, at: number): Side =>
148 moving || drawn.left.length + drawn.right.length === 0 ? facing : sideIn(drawn, at)
149
150// The way it faced as the action playing now began.
151export type Held = { start: number; side: Side } | undefined
152
153// While an action plays it keeps the way it faced as the action began, so a whip
154// raised at one side cracks there though an agent comes or goes meanwhile; with
155// no action, or on the move, it faces `side`, so it never walks backwards.
156export const holdFacing = (held: Held, action: { start: number } | undefined, side: Side, moving: boolean) => {
157 if (action === undefined || moving) return { held: undefined, side }
158 if (held?.start === action.start) return { held, side: held.side }
159 return { held: { start: action.start, side }, side }
160}
161
162// Who shows while the pet is on its way to `placed`: all of them once it stands
163// there, else as many as already fit around `x`, with no room kept for a "+N".
164export const onTheWay = (
165 x: number,
166 placed: Gathering,
167 room: number,
168 count: number,
169 width: number,
170 sides: readonly (Side | undefined)[] = [],
171): Gathering => {
172 if (x === placed.x) return placed
173 // All of them may fit already when only the "+N" sent it off.
174 for (let shown = count; shown > 0; shown--) {
175 const found = layoutAt(x, room, shown, width, 0, sides)
176 if (found !== undefined) return found
177 }
178 return { x, left: [], right: [], more: 1 }
179}
180
181// What the band keeps between draws about the main pet's spot: where it stands,
182// the teleport it is in the middle of, and the side each agent stood on.
183export type Stage = { walk?: Walk; blink?: { from: number; start: number }; sides: ReadonlyMap<string, Side> }
184
185export const EMPTY_STAGE: Stage = { sides: new Map() }
186
187type Scene = {
188 tick: number
189 room: number
190 fps: number
191 random: Random
192 pace: Pace
193 walks: boolean
194 // The frames of its teleport, as counts; null for a pet that walks or draws none.
195 teleport: { vanish: number; appear: number } | null
196 // The agents to stand around it, by id, `width` columns each, plus `extra` for a "+N".
197 agents: readonly string[]
198 width: number
199 extra: number
200 // What it wants this tick, given where the agents would gather.
201 wants: (placed: Gathering) => Plan
202}
203
204// Moves the main pet one tick and lays its agents out around it, as the band
205// draws them: it walks, or teleports, or is set down where they fit, and the
206// agents that do not fit yet join once it gets there. One teleport plays out
207// before it goes anywhere else; `blinking` is how far into it this tick is.
208export const moveOnStage = (stage: Stage, scene: Scene) => {
209 const { tick, room, teleport, agents } = scene
210 const sides = agents.map(id => stage.sides.get(id))
211 const from = clamp(stage.walk?.x ?? 0, room)
212 const placed = gather(from, room, agents.length, scene.width, scene.extra, sides)
213 const wants = scene.wants(placed)
214 const moves = scene.walks || teleport !== null
215 const pace = teleport === null ? scene.pace : { ...scene.pace, stride: Infinity }
216 const walked = walkStep(stage.walk, moves && stage.blink === undefined ? wants : 'stay', tick, room, scene.fps, scene.random, pace)
217 // A pet that does neither is set down where they fit.
218 const walk = moves || placed.x === walked.walk.x ? walked.walk : { ...walked.walk, x: placed.x, target: placed.x }
219
220 let blink = stage.blink ?? (teleport !== null && walked.moving ? { from, start: tick } : undefined)
221 // Where it shows: where it vanishes from until it is gone, then where it lands.
222 let standX = walk.x
223 let blinking: { phase: 'vanish' | 'appear'; at: number } | undefined
224 if (teleport !== null && blink !== undefined) {
225 const at = tick - blink.start
226 if (at < teleport.vanish) {
227 blinking = { phase: 'vanish', at }
228 standX = clamp(blink.from, room)
229 } else if (at < teleport.vanish + teleport.appear) blinking = { phase: 'appear', at: at - teleport.vanish }
230 else blink = undefined
231 }
232
233 const drawn = onTheWay(standX, placed, room, agents.length, scene.width, sides)
234 const drawnCount = drawn.left.length + drawn.right.length
235 // One not drawn yet keeps the side it had, so it does not hop over once it shows.
236 const kept = new Map(
237 agents.flatMap((id, i): [string, Side][] => {
238 const side = i < drawnCount ? sideIn(drawn, i) : stage.sides.get(id)
239 return side === undefined ? [] : [[id, side]]
240 }),
241 )
242 return { stage: { walk, blink, sides: kept }, wants, placed, drawn, drawnCount, standX, moving: walked.moving, blinking }
243}
244locales/en.ts 64 lines1import type { Text } from '../types'
2
3export type Locale = { code: string; names: string[]; strings: Record<Text, string> }
4
5// The reference locale: every other locale must give each of these strings.
6// `{detail}` is filled in at run time and must be kept as written.
7export const en: Locale = {
8 code: 'en',
9 // How Claude Code's `language` setting or $LANG may spell this locale.
10 names: ['en', 'english'],
11 strings: {
12 sleeping: 'sleeping',
13 thinking: 'thinking',
14 running: 'running {detail}',
15 writing: 'writing {detail}',
16 reading: 'reading {detail}',
17 searching: 'searching ({detail})',
18 using: 'using {detail}',
19 waitingForAgents: 'waiting for agents',
20 done: 'done!',
21 wentWrong: 'something went wrong',
22 failed: 'failed: {detail}',
23 wakingUp: 'waking up',
24 deepSleep: 'fast asleep',
25 thanks: 'thanks!',
26 sleepy: 'sleepy',
27 tired: 'tired',
28 worried: 'a bit worried',
29 grumpy: 'grumpy',
30 proud: 'proud of us',
31 goodMorning: 'good morning!',
32 phew: 'phew, done!',
33 missedYou: 'missed you!',
34 aWeekTogether: 'a week together!',
35 aMonthTogether: 'a month together!',
36 daysTogether: '{detail} days together!',
37 aYearTogether: 'a year together!',
38 yearsTogether: '{detail} years together!',
39 justMet: 'we just met',
40 togetherADay: 'together for a day',
41 togetherDays: 'together for {detail} days',
42 longThink: 'hmm…',
43 manyReads: 'so many files!',
44 manyAgents: 'full team today!',
45 lateNight: "it's getting late…",
46 bored: 'nothing to do…',
47 dreaming: 'zzz… mmh…',
48 denied: "okay, I won't",
49 compacting: 'tidying up its memory',
50 idle: 'hanging around',
51 strolling: 'strolling around',
52 watching: 'watching you type',
53 commandDescription: "Show or hide the pets above the prompt, or pick this project's pet",
54 hidden: 'Pets hidden.',
55 shown: 'Pets are back.',
56 projectPet: "This project's pet: {detail}.",
57 projectPetDefault: "This project is back to your settings' pet: {detail}.",
58 projectPetFailed: "Couldn't change the pet: {detail}.",
59 invalidName: '"{detail}" is not a valid pet name',
60 notFound: 'pet "{detail}" not found',
61 fallback: 'pixel-pets: {detail}. Using the default pet.',
62 },
63}
64locales/pt-BR.ts 59 lines1import type { Locale } from './en'
2
3export const ptBR: Locale = {
4 code: 'pt-BR',
5 names: ['pt-br', 'pt', 'portuguese', 'português', 'portugues', 'brazilian portuguese'],
6 strings: {
7 sleeping: 'dormindo',
8 thinking: 'pensando',
9 running: 'rodando {detail}',
10 writing: 'escrevendo {detail}',
11 reading: 'lendo {detail}',
12 searching: 'procurando ({detail})',
13 using: 'usando {detail}',
14 waitingForAgents: 'esperando os agents',
15 done: 'pronto!',
16 wentWrong: 'algo deu errado',
17 failed: 'falhou: {detail}',
18 wakingUp: 'acordando',
19 deepSleep: 'dormindo pesado',
20 thanks: 'valeu!',
21 sleepy: 'com sono',
22 tired: 'cansado',
23 worried: 'meio preocupado',
24 grumpy: 'emburrado',
25 proud: 'orgulhoso da gente',
26 goodMorning: 'bom dia!',
27 phew: 'ufa, pronto!',
28 missedYou: 'senti sua falta!',
29 aWeekTogether: 'uma semana juntos!',
30 aMonthTogether: 'um mês juntos!',
31 daysTogether: '{detail} dias juntos!',
32 aYearTogether: 'um ano juntos!',
33 yearsTogether: '{detail} anos juntos!',
34 justMet: 'a gente acabou de se conhecer',
35 togetherADay: 'juntos há um dia',
36 togetherDays: 'juntos há {detail} dias',
37 longThink: 'hmm…',
38 manyReads: 'quantos arquivos!',
39 manyAgents: 'time completo hoje!',
40 lateNight: 'já tá tarde…',
41 bored: 'que tédio…',
42 dreaming: 'zzz… hmm…',
43 denied: 'tá bom, não vou',
44 compacting: 'arrumando a memória',
45 idle: 'de bobeira',
46 strolling: 'passeando',
47 watching: 'olhando você digitar',
48 commandDescription: 'Mostra ou esconde os pets acima do prompt, ou escolhe o pet deste projeto',
49 hidden: 'Pets escondidos.',
50 shown: 'Pets de volta.',
51 projectPet: 'Pet deste projeto: {detail}.',
52 projectPetDefault: 'Este projeto voltou ao pet da config: {detail}.',
53 projectPetFailed: 'Não deu para trocar o pet: {detail}.',
54 invalidName: '"{detail}" não é um nome de pet válido',
55 notFound: 'pet "{detail}" não encontrado',
56 fallback: 'pixel-pets: {detail}. Usando o pet padrão.',
57 },
58}
59