Your Codex pet, living above the Claude Code prompt: it works while Claude works, waits when you are needed, and celebrates when a turn is done.

English | 简体中文

<sub>Codex, the Codex app's own pet, with the frames this plugin draws from the copy on your machine. The pet's artwork is OpenAI's: it appears here to show what the plugin does, is not part of the plugin, and is not covered by this repository's license.</sub>
Your Codex pet, living above the Claude Code prompt. It works while Claude works, waits when you are needed, and celebrates when a turn is done.
It reads the pets already on your machine, in the Codex pet format, and draws them in the Claude Code desktop app and in terminals that show images. In any other terminal the pet is a small face of characters that follows the same moods. No pet artwork is bundled except Blob, a small original pet shown when no Codex pet is found.
An unofficial community plugin. Not affiliated with, endorsed by, or sponsored by OpenAI or Anthropic. "Codex" is a trademark of OpenAI; "Claude" is a trademark of Anthropic.
| The session | The pet |
|---|---|
| A turn is running | running |
| Claude asks you a question, wants a plan approved, or a tool needs permission | waiting |
| A tool call fails | failed, briefly (not when you refused it, or interrupted the turn) |
| A turn finishes | jumping, then review for 20 seconds, then idle |
| A turn ends in an error | failed for 6 seconds |
| You interrupt a turn | idle |
| The turn is over but background agents are still going | stays running until they finish (background shell commands do not count) |
| The session starts | waving |
PATH, to convert a pet's spritesheet. Without it the plugin still runs and shows Blob.sips is used), dwebp from libwebp, ImageMagick, ffmpeg, or Python with Pillow. PNG spritesheets need nothing.claude plugin marketplace add steven-panxd/codex-pet-in-claude
claude plugin install codex-pet@codex-pet
Or try it from a checkout without installing:
claude --plugin-dir /path/to/codex-pet-in-claude
With the default setting, auto, the plugin picks in this order:
~/.codex/pets (with /pet install <name>, with npx petdex install <slug>, or one you hatched in Codex),/pet list names every pet found, and /pet use <id> switches. The choice is remembered across sessions.
Petdex is the community gallery of pets in the Codex format. Petdex compatibility: this plugin reads pets installed to ~/.codex/pets, and /pet install <name> downloads any Petdex pet by the name in its page's address.
/pet install boba
The pets are their creators' work, shared through Petdex; this plugin only fetches and draws them. Thanks to the Petdex team and its contributors for the gallery and the open pet format.
| Command | What it does |
|---|---|
/pet | Says which pet is showing and lists the commands |
/pet list | Lists the pets found on this machine |
/pet use <id> | Switches to a pet and remembers it |
/pet use auto | Goes back to following the pet setting |
/pet install <name> | Downloads a pet from petdex.dev into ~/.codex/pets, shows it and remembers it. <name> is the last part of the pet's page address, or the whole address |
/pet refresh | Converts the current pet again, whatever is cached |
/pet hide, /pet show | Hides the pet for this session, or brings it back |
/pet <mood> | Previews a mood for 6 seconds: idle, running, waiting, review, failed, jumping, waving, running-left, running-right |
Set these in /config, under the plugin's name.
| Setting | Values | Default | |
|---|---|---|---|
pet | auto, or a pet's id | auto | The pet shown unless /pet use chose another |
size | small, medium, large | medium | Desktop: 72, 104 or 156 pixels tall. A terminal picture: 4, 6 or 9 rows. Terminal blocks: 7 rows for small, 13 otherwise |
animation | lively, calm, still | calm | calm lets a pet that is idle, waiting or up for review rest between movements; still draws one frame a mood |
label | on, off | on | The pet's name and what it is doing, beside it |
align | left, center, right | center | Where the pet stands in the band above the prompt |
terminalStyle | auto, picture, face, blocks | auto | How the pet is drawn in a terminal. See below |
terminalCells | standard, tall | standard | For the terminal picture and blocks. Set to tall if the pet looks stretched upward in your terminal (its line spacing is roomy): it is then drawn wider to keep its shape |
size. Experimental: it is covered by tests but has not been tried in a real kitty or Ghostty yet. It is not used through tmux or over ssh, and if the terminal turns out not to draw the picture the plugin falls back to the face by itself.(•‿•) Codex idle. It blinks, spins while a turn runs, and changes with each mood. A character cell cannot hold enough pixels to do the pet's artwork justice, so the plugin does not try by default.terminalStyle to blocks for quadrant block characters, 48 by 26 pixels in 13 rows, or 24 by 14 in 7 rows when the terminal is short or size is small. Recognizable, coarse.~/.codex/pets and, on macOS, the pet spritesheets inside the Codex app's bundle. It reads nothing else of Codex's: no settings, no sessions, no credentials.~/.cache/codex-pet-claude (or $XDG_CACHE_HOME/codex-pet-claude). You may delete that folder; it is rebuilt at the next session's start, or by /pet refresh./pet install, and then only petdex.dev: it reads that pet's install script without running it, and downloads the two files it names (a manifest and a spritesheet), checking that they are what they claim. Nothing is uploaded, and nothing else is ever fetched.The plugin installs on any account, but its pet only appears where Claude Code runs plugin function hooks, which is behind a gradual rollout (seen in Claude Code 2.1.292).
true when it has: grep -o '"tengu_plugin_hooks_modules": *[a-z]*' ~/.claude.json
false or nothing, the plugin cannot draw yet on your account, and nothing in this repository can change that. Star or watch the repository to try again later.true and there is still no pet, run /pet. A reply means the plugin is loaded: try /pet show, then /pet list. No reply means it is not installed or not enabled: check claude plugin list.waiting until that tool call finishes. Claude Code raises no event for the approval itself. In a terminal, a long-running command's "run in background" hint ends the wait early; the desktop app has no such sign.CODEX_APP_ASAR to the app's app.asar, or install a pet into ~/.codex/pets.running-left / running-right, are not tied to anything: the pet does not move across the screen.calm rests a pet that is idle, waiting or up for review most of the time, so only a running turn animates throughout; still stops animation altogether.codex@codex-app.node --test scripts/pet.test.mjs
claude plugin validate .
claude plugin test .
scripts/pet.mjs finds pets and converts one into the frames the plugin draws. It has no dependencies.hooks/register.tsx is the plugin: the session events it follows and what it draws.hooks/draw.ts turns converted frames into terminal cells and SVG.pets/blob is the bundled pet: its spritesheet, and its frames already converted (cache/) so it draws without Node. After changing the spritesheet, rebuild them with node scripts/pet.mjs build blob --out pets/blob/cache.MIT, for the code and for Blob. See LICENSE. The animation at the top of this page shows OpenAI's Codex pet and is not covered by it.
hooks/register.tsx 884 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Mood } from '../types'
5import { FACES, LABEL, MOODS, cellsOf, faceOf, frameCount, imageOf, isMood, petOf, stateOf, svgOf } from './draw'
6import type { Pet, TerminalSize } from './draw'
7
8const FRAME_MS = 160
9// the timer's period while nothing moves: a hidden band, a resting pet
10const REST_MS = 1_000
11// calm: a pet that is idle, waiting or up for review plays its frames
12// through, then holds the first this long, so a quiet session draws little
13const CALM_REST_MS = 8_000
14const CALM_MOODS: ReadonlySet<Mood> = new Set<Mood>(['idle', 'waiting', 'review'])
15const REVIEW_MS = 20_000
16const FLASH_MS = 2_500
17const FAILED_MS = 6_000
18const JUMP_MS = 5 * FRAME_MS // the jump's five frames, once through
19// how long background agents a turn left behind may keep the pet at work
20// with no word of them: one that ends unseen must not pin it there
21const AGENTS_CAP_MS = 15 * 60_000
22const DESKTOP_HEIGHT = { small: 72, medium: 104, large: 156 } // CSS pixels
23// a terminal that shows images: rows the picture takes, and the fewest worth drawing
24const IMAGE_ROWS = { small: 4, medium: 6, large: 9 }
25const IMAGE_MIN_ROWS = 3
26const JUSTIFY = { left: 'flex-start', center: 'center', right: 'flex-end' } as const
27// what may run scripts/pet.mjs, in the order tried: an app started from the
28// dock has a short PATH, so the usual homes of node are named too
29const RUNNERS = [['node'], ['bun'], ['/opt/homebrew/bin/node'], ['/usr/local/bin/node']]
30const SCRIPT_TIMEOUT_MS = 60_000
31const SHELL_TIMEOUT_MS = 20_000
32const STORE_PET = 'pet' // the id /pet use chose, across sessions
33const STORE_WARNED = 'warned' // the last problem toasted, so it is said once
34const STORE_RUNNER = 'runner' // the command that ran the script last time
35
36const mood = atom({ plugin: 'codex-pet', key: 'mood' } as const, 'idle')
37const isHidden = atom({ plugin: 'codex-pet', key: 'isHidden' } as const, false)
38const step = atom({ plugin: 'codex-pet', key: 'step' } as const, 0)
39const loads = atom({ plugin: 'codex-pet', key: 'loads' } as const, 0)
40
41type Settings = {
42 pet: string
43 size: keyof typeof DESKTOP_HEIGHT
44 animation: 'lively' | 'calm' | 'still'
45 hasLabel: boolean
46 /** In a terminal: the pet's picture where it can be shown and a face elsewhere, or one of them always, or blocks of color. */
47 style: 'auto' | 'picture' | 'face' | 'blocks'
48 align: keyof typeof JUSTIFY
49 hasTallCells: boolean
50}
51
52// What scripts/pet.mjs prints: one line of JSON, an `error` when it failed.
53type ScriptAnswer = {
54 error?: string
55 dir?: string
56 id?: string
57 name?: string
58 /** A pet `auto` passed over because it would not convert, and why. */
59 skipped?: string
60 pets?: { id: string; name: string; source: string }[]
61 auto?: string
62}
63
64const SOURCE: Record<string, string> = {
65 installed: 'installed in ~/.codex/pets',
66 'codex-app': 'from the Codex app',
67 bundled: 'bundled',
68}
69
70// The module's own state: lost on a reload, which loads the pet again.
71let settings: Settings = { pet: 'auto', size: 'medium', animation: 'calm', hasLabel: true, style: 'auto', align: 'center', hasTallCells: false }
72let pet: Pet | undefined
73let runner: string[] | undefined
74// the session's pet being loaded: a command typed at once waits on it
75let booting: Promise<void> | undefined
76
77// What the session's events say. The main loop's turn is running; agents its
78// last stop left in flight; and what waits on the person: the calls that are
79// a question to them, the tools a permission dialog is open for, and the
80// inputs an MCP server asked for.
81let isTurnRunning = false
82let agentsInFlight = 0
83let agentsCap: Timer | undefined
84const questions = new Set<string>()
85const permissions: string[] = []
86let elicitations = 0
87// tool calls in flight, by id: which tool each is
88const calls = new Map<string, string>()
89// tools the person just refused at the dialog: their error is no failure
90const refused: string[] = []
91// calls whose run-in-background pill the terminal drew, a sign the call is
92// past its dialog; the timer settles them, since a render hook writes nothing
93const pilled = new Set<string>()
94const started: string[] = []
95
96// The mood the events have asked for; the band draws the `mood` atom, which
97// follows it. Only `show` writes it.
98let target: Mood = 'idle'
99let generation = 0
100let revert: Timer | undefined
101
102// What the band last drew, for the frame timer.
103let bandId: string | undefined
104// undefined until the band is first drawn: until then the desktop's frames
105// are read too, so its first drawing has them
106let isOnDesktop: boolean | undefined
107let terminalSize: TerminalSize = 'lo'
108// whether this terminal shows images (the kitty graphics protocol), as far as
109// its environment says; and what the band drew last, a picture or half blocks
110let hasImages = false
111let isImageDrawn = false
112// the band drew the pet as a face of characters, redrawn a frame at a time
113let isFaceDrawn = false
114// the picture drawn has not been repainted yet: the first repaint says
115// whether the terminal took it or drew its text in its place
116let isImageUnproven = false
117let ticks = 0
118let frame = 0
119let restedMs = 0
120let timer: Timer | undefined
121let isTicking = false
122const cells = new Map<string, string>()
123
124function removeOne(list: string[], item: string): boolean {
125 const at = list.indexOf(item)
126
127 if (at >= 0) {
128 list.splice(at, 1)
129 }
130
131 return at >= 0
132}
133
134// The mood with nothing passing over it: waiting on the person comes first.
135function baseMood(): Mood {
136 if (questions.size > 0 || permissions.length > 0 || elicitations > 0) {
137 return 'waiting'
138 }
139
140 return isTurnRunning || agentsInFlight > 0 ? 'running' : 'idle'
141}
142
143function isBase(one: Mood): boolean {
144 return one === 'idle' || one === 'running' || one === 'waiting'
145}
146
147// The frame timer, one period from now: after a change, and after each tick.
148function wake($: EngineInterface, delay = FRAME_MS): void {
149 timer?.cancel()
150 timer = $.clock.after(delay, () => void tick($))
151}
152
153// Shows `to`. Given `after`, it then moves on to `then`, or to the base mood
154// as it stands by then; a `review` goes back to the base in its own time.
155async function show($: EngineInterface, to: Mood, after?: number, then?: Mood): Promise<void> {
156 generation += 1
157 const mine = generation
158 revert?.cancel()
159 // armed before anything is awaited, so a later `show` cancels it
160 const wait = after ?? (to === 'review' ? REVIEW_MS : undefined)
161 revert = wait === undefined ? undefined : $.clock.after(wait, () => void show($, then ?? baseMood()))
162 target = to
163 ticks = 0
164 frame = 0
165 restedMs = 0
166
167 if (isOnDesktop !== false) {
168 await ensurePaths($, to)
169 }
170
171 // a later `show` came in meanwhile: this one is not what is wanted now
172 if (mine !== generation) {
173 return
174 }
175
176 await update($, mood, () => to)
177 wake($)
178}
179
180// The facts changed: shows the base mood, unless something passing (a jump,
181// a failure, the review) is on, which ends in the base mood by itself. A wait
182// on the person is never held back.
183async function settle($: EngineInterface): Promise<void> {
184 const base = baseMood()
185
186 if (base !== target && (base === 'waiting' || isBase(target))) {
187 await show($, base)
188 }
189}
190
191async function runScript($: EngineInterface, args: readonly string[]): Promise<ScriptAnswer> {
192 const script = `${$.plugin.root}/scripts/pet.mjs`
193
194 if (runner === undefined) {
195 const stored = await $.store.get(STORE_RUNNER).catch(() => undefined)
196 runner = Array.isArray(stored) && stored.every(part => typeof part === 'string') ? stored : undefined
197 }
198
199 // last, the person's own shell as they log in: a node a version manager
200 // put on the PATH is found there and nowhere else (fish takes no "$@")
201 const shell = await $.env.get('SHELL').catch(() => undefined)
202 const viaShell =
203 shell === undefined || shell === '' || shell.endsWith('fish') ? [] : [[shell, '-lic', 'exec node "$0" "$@"']]
204 const known = runner === undefined ? [] : [runner]
205
206 for (const command of [...known, ...RUNNERS, ...viaShell]) {
207 try {
208 const ran = await $.process.run([...command, script, ...args], {
209 timeoutMs: command.length > 1 ? SHELL_TIMEOUT_MS : SCRIPT_TIMEOUT_MS,
210 })
211 const answer: ScriptAnswer = JSON.parse(ran.stdout.trim().split('\n').pop() ?? '')
212
213 if (runner?.join(' ') !== command.join(' ')) {
214 runner = command
215 await $.store.set(STORE_RUNNER, command).catch(() => undefined)
216 }
217
218 return answer
219 } catch {
220 // not installed here, or it printed no answer: try the next
221 }
222 }
223
224 runner = undefined
225
226 return { error: 'Node.js was not found, and reading a Codex pet needs it' }
227}
228
229async function readPet($: EngineInterface, dir: string): Promise<Pet | undefined> {
230 try {
231 return petOf(await $.fs.read(`${dir}/meta.json`), dir)
232 } catch {
233 return undefined
234 }
235}
236
237// Reads the mood's desktop frames once; a state that cannot be read is left
238// empty, so the band draws the label alone rather than asking again.
239async function ensurePaths($: EngineInterface, of: Mood): Promise<void> {
240 const current = pet
241
242 if (current === undefined) {
243 return
244 }
245
246 const state = stateOf(current, of)
247
248 if (current.paths.has(state)) {
249 return
250 }
251
252 let frames: string[] = []
253
254 try {
255 const parsed: unknown = JSON.parse(await $.fs.read(`${current.dir}/svg-${state}.json`))
256 frames = Array.isArray(parsed) ? parsed.map(String) : []
257 } catch {
258 // gone from the cache: the label alone is drawn until /pet refresh
259 }
260
261 current.paths.set(state, frames)
262}
263
264// Loads the pet `wanted` names, converting it first when it is not cached.
265// Answers what went wrong, if anything; with `orBundled`, a pet that cannot
266// be loaded is replaced by the bundled one, and otherwise the current stays.
267async function loadPet(
268 $: EngineInterface,
269 wanted: string,
270 { orBundled, isForced = false }: { orBundled: boolean; isForced?: boolean },
271): Promise<string | undefined> {
272 const built = await runScript($, ['build', wanted, ...(isForced ? ['--force'] : [])])
273 const converted = built.dir === undefined ? undefined : await readPet($, built.dir)
274 const problem =
275 converted === undefined
276 ? (built.error ?? 'the converted pet could not be read')
277 : built.skipped === undefined
278 ? undefined
279 : `skipped ${built.skipped}`
280 const loaded = converted ?? (orBundled ? await readPet($, `${$.plugin.root}/pets/blob/cache`) : undefined)
281
282 if (loaded !== undefined) {
283 pet = loaded
284 cells.clear()
285 ticks = 0
286 frame = 0
287
288 if (isOnDesktop !== false) {
289 await ensurePaths($, target)
290 }
291
292 await update($, loads, n => n + 1)
293 wake($)
294 }
295
296 return problem
297}
298
299// Whether the terminal the session runs in shows images: kitty and Ghostty
300// do, by their own word in the environment. Not through tmux, which passes
301// none on, nor over ssh, where the terminal cannot read this machine's files.
302async function detectImages($: EngineInterface): Promise<boolean> {
303 if (settings.style !== 'auto') {
304 return settings.style === 'picture'
305 }
306
307 const [term, program, kitty, ghostty, tmux, ssh] = await Promise.all([
308 $.env.get('TERM').catch(() => undefined),
309 $.env.get('TERM_PROGRAM').catch(() => undefined),
310 $.env.get('KITTY_WINDOW_ID').catch(() => undefined),
311 $.env.get('GHOSTTY_RESOURCES_DIR').catch(() => undefined),
312 $.env.get('TMUX').catch(() => undefined),
313 $.env.get('SSH_CONNECTION').catch(() => undefined),
314 ])
315 const isCapable = term === 'xterm-kitty' || term === 'xterm-ghostty' || program === 'ghostty' || !!kitty || !!ghostty
316
317 return isCapable && !tmux && !ssh
318}
319
320async function chosenPet($: EngineInterface): Promise<string> {
321 const stored = await $.store.get(STORE_PET).catch(() => undefined)
322
323 return typeof stored === 'string' && stored !== '' ? stored : settings.pet
324}
325
326// Says a problem once, not at every session's start.
327async function report($: EngineInterface, problem: string | undefined): Promise<void> {
328 try {
329 const last = await $.store.get(STORE_WARNED)
330
331 if (problem === undefined) {
332 if (last !== undefined) {
333 await $.store.delete(STORE_WARNED)
334 }
335
336 return
337 }
338
339 if (last === problem) {
340 return
341 }
342
343 await $.store.set(STORE_WARNED, problem)
344 } catch {
345 // no store: say it anyway
346 }
347
348 if (problem !== undefined) {
349 $.ui.toast(`Codex Pet: ${problem}. Showing ${pet?.name ?? 'no pet'}; /pet list names the pets found.`, {
350 timeoutMs: 10_000,
351 })
352 }
353}
354
355// The session's pet, loaded behind the session's start rather than in it:
356// a first conversion, or a slow shell, holds nothing up.
357async function boot($: EngineInterface): Promise<void> {
358 try {
359 hasImages = await detectImages($)
360 await report($, await loadPet($, await chosenPet($), { orBundled: true }))
361 } catch {
362 // no pet this session: the band draws nothing
363 }
364}
365
366async function paint($: EngineInterface, current: Pet): Promise<void> {
367 if (isOnDesktop === true) {
368 await update($, step, n => (n + 1) % 1_000_000)
369 } else if (bandId !== undefined && isImageDrawn) {
370 const answer = await $.ui.blit({ requestId: bandId, key: 'pet', source: imageOf(current, target, frame) })
371 isImageUnproven = false
372
373 // the terminal drew the picture's text in its place: no pictures from here on
374 if (answer.deny !== undefined) {
375 hasImages = false
376 await update($, loads, n => n + 1)
377 }
378 } else if (bandId !== undefined && isFaceDrawn) {
379 await update($, step, n => (n + 1) % 1_000_000)
380 } else if (bandId !== undefined) {
381 const key = `${terminalSize}:${target}:${frame}`
382 const packed = cells.get(key) ?? cellsOf(current, terminalSize, target, frame)
383 cells.set(key, packed)
384 await $.ui.blit({ requestId: bandId, key: 'pet', cells: packed })
385 }
386}
387
388// One period of the frame timer. Answers how long until the next.
389async function advance($: EngineInterface): Promise<number> {
390 // a call whose pill appeared is running: its permission dialog is behind it
391 while (started.length > 0) {
392 const tool = calls.get(started.shift() ?? '')
393
394 if (tool !== undefined && removeOne(permissions, tool)) {
395 await settle($)
396 }
397 }
398
399 const current = pet
400
401 if (current === undefined || (isOnDesktop !== true && bandId === undefined)) {
402 return REST_MS
403 }
404
405 // the desktop drew before this mood's frames were read: read, then redraw
406 if (isOnDesktop === true && !current.paths.has(stateOf(current, target))) {
407 await ensurePaths($, target)
408 await paint($, current)
409
410 return FRAME_MS
411 }
412
413 // a picture just drawn is repainted once, moving or not, to hear whether
414 // the terminal took it
415 if (isImageDrawn && isImageUnproven) {
416 await paint($, current)
417 }
418
419 const count = isFaceDrawn ? FACES[target].length : frameCount(current, target)
420
421 if (settings.animation === 'still' || count <= 1) {
422 return REST_MS
423 }
424
425 // calm: once through, then the first frame held a while
426 if (settings.animation === 'calm' && CALM_MOODS.has(target) && ticks >= count) {
427 restedMs += REST_MS
428
429 if (restedMs >= CALM_REST_MS) {
430 ticks = 0
431 restedMs = 0
432 }
433
434 return REST_MS
435 }
436
437 ticks += 1
438 const due = ticks % count
439
440 if (due !== frame) {
441 frame = due
442 await paint($, current)
443 }
444
445 return FRAME_MS
446}
447
448async function tick($: EngineInterface): Promise<void> {
449 // this period's timer has fired: whoever finishes arms the next
450 timer = undefined
451
452 if (isTicking) {
453 return
454 }
455
456 isTicking = true
457 let delay = REST_MS
458
459 try {
460 delay = await advance($)
461 } catch {
462 // a band gone between the tick and the paint is no fault
463 } finally {
464 isTicking = false
465 }
466
467 // unless a change woke the timer meanwhile
468 if (timer === undefined) {
469 wake($, delay)
470 }
471}
472
473async function listPets($: EngineInterface): Promise<string> {
474 const answer = await runScript($, ['list'])
475
476 if (answer.pets === undefined) {
477 return `Could not list pets: ${answer.error ?? 'the script printed nothing'}.`
478 }
479
480 const rows = answer.pets.map(one => {
481 const mark = one.id === pet?.id ? '>' : ' '
482
483 return `${mark} ${one.id.padEnd(16)} ${one.name.padEnd(24)} ${SOURCE[one.source] ?? one.source}`
484 })
485
486 // fenced: a command's text is drawn as markdown, which would reflow the columns
487 return [
488 'Pets found (`>` is showing). `/pet use <id>` switches; `/pet use auto` follows the setting.',
489 '```',
490 ...rows,
491 '```',
492 `auto would pick: ${answer.auto ?? 'none'}`,
493 ].join('\n')
494}
495
496function showing(problem: string | undefined, isKept = false): string {
497 const name = pet?.name ?? 'no pet'
498
499 return problem === undefined ? `Showing ${name}.` : `${problem}. ${isKept ? 'Still showing' : 'Showing'} ${name}.`
500}
501
502async function usePet($: EngineInterface, wanted: string): Promise<string> {
503 if (wanted.startsWith('-')) {
504 return usage()
505 }
506
507 if (wanted === 'auto') {
508 await $.store.delete(STORE_PET).catch(() => undefined)
509
510 return showing(await loadPet($, settings.pet, { orBundled: true }))
511 }
512
513 const problem = await loadPet($, wanted, { orBundled: false })
514
515 if (problem !== undefined) {
516 return showing(problem, true)
517 }
518
519 // the id as the script knows it, whatever case it was typed in
520 await $.store.set(STORE_PET, pet?.id ?? wanted).catch(() => undefined)
521
522 return showing(undefined)
523}
524
525// Downloads a pet from petdex.dev by its name there, or its page's address,
526// then shows it. The script does the fetching and checks what arrives.
527async function installPet($: EngineInterface, wanted: string): Promise<string> {
528 const slug = wanted
529 .replace(/^https?:\/\/(www\.)?petdex\.dev\/([a-z-]+\/)?pets\//, '')
530 .replace(/[/?#].*$/, '')
531 .toLowerCase()
532 const installed = await runScript($, ['install', slug])
533
534 if (installed.id === undefined) {
535 return showing(installed.error ?? 'the pet could not be installed', true)
536 }
537
538 const shown = await usePet($, installed.id)
539
540 return `Installed ${installed.name ?? installed.id} from petdex.dev into ~/.codex/pets. ${shown}`
541}
542
543function usage(): string {
544 const now = pet === undefined ? 'No pet is loaded.' : `Showing ${pet.name} (${SOURCE[pet.source] ?? pet.source}): ${LABEL[target]}`
545
546 return [
547 now,
548 '',
549 'Usage: `/pet list` | `use <id|auto>` | `install <name on petdex.dev>` | `refresh` | `hide` | `show` | `<mood>`',
550 '',
551 `Moods to preview: ${MOODS.join(', ')}`,
552 ].join('\n')
553}
554
555export const register: Register = (on, options) => {
556 settings = {
557 pet: typeof options.pet === 'string' && options.pet !== '' ? options.pet : 'auto',
558 size: options.size === 'small' || options.size === 'large' ? options.size : 'medium',
559 animation: options.animation === 'lively' || options.animation === 'still' ? options.animation : 'calm',
560 hasLabel: options.label !== false,
561 style:
562 options.terminalStyle === 'picture' || options.terminalStyle === 'face' || options.terminalStyle === 'blocks'
563 ? options.terminalStyle
564 : 'auto',
565 align: options.align === 'left' || options.align === 'right' ? options.align : 'center',
566 hasTallCells: options.terminalCells === 'tall',
567 }
568
569 on('session.start', async ($, e, next) => {
570 await $.command.register({
571 name: 'pet',
572 description: 'Your Codex pet: /pet list | use <id|auto> | install <name> | refresh | hide | show | <mood>',
573 })
574
575 booting = boot($)
576 void show($, 'waving', FLASH_MS)
577
578 return next(e)
579 })
580
581 on('turn.start', async ($, e, next) => {
582 isTurnRunning = true
583 // nothing of the last turn is still asked of the person
584 questions.clear()
585 permissions.length = 0
586 refused.length = 0
587 elicitations = 0
588 await show($, 'running')
589
590 return next(e)
591 })
592
593 on('turn.complete', async ($, e, next) => {
594 // a subagent's run ends in a turn.complete too: one fewer in flight
595 if (e.agentId !== undefined) {
596 if (agentsInFlight > 0) {
597 agentsInFlight -= 1
598 await settle($)
599 }
600
601 return next(e)
602 }
603
604 isTurnRunning = false
605 questions.clear()
606 permissions.length = 0
607 elicitations = 0
608
609 if (e.reason === 'aborted') {
610 await show($, baseMood())
611 } else if (e.reason !== 'answer') {
612 await show($, 'failed', FAILED_MS)
613 } else if (agentsInFlight > 0) {
614 await show($, 'running')
615 } else {
616 await show($, 'jumping', JUMP_MS, 'review')
617 }
618
619 return next(e)
620 })
621
622 // The main loop's stop says what is still in flight behind it; it may come
623 // before or after turn.complete, so both settle on the same count. Agents
624 // count, shells do not: a dev server left running is not the pet at work.
625 on('classic.Stop', async ($, e, next) => {
626 agentsInFlight = (e.background_tasks ?? []).filter(
627 task => task.agent_type !== undefined || /agent/i.test(task.type),
628 ).length
629 agentsCap?.cancel()
630 agentsCap = undefined
631
632 if (agentsInFlight > 0) {
633 agentsCap = $.clock.after(AGENTS_CAP_MS, () => {
634 agentsInFlight = 0
635 void settle($)
636 })
637
638 if (!isTurnRunning && target !== 'failed') {
639 await show($, 'running')
640 }
641 }
642
643 return next(e)
644 }).catch(($, e, next) => next(e))
645
646 on('classic.PermissionRequest', async ($, e, next) => {
647 permissions.push(e.tool_name)
648 await settle($)
649
650 return next(e)
651 }).catch(($, e, next) => next(e))
652
653 on('classic.PermissionDenied', async ($, e, next) => {
654 removeOne(permissions, e.tool_name)
655 refused.push(e.tool_name)
656 await settle($)
657
658 return next(e)
659 }).catch(($, e, next) => next(e))
660
661 on('classic.Elicitation', async ($, e, next) => {
662 elicitations += 1
663 await settle($)
664
665 return next(e)
666 }).catch(($, e, next) => next(e))
667
668 on('classic.ElicitationResult', async ($, e, next) => {
669 elicitations = Math.max(0, elicitations - 1)
670 await settle($)
671
672 return next(e)
673 }).catch(($, e, next) => next(e))
674
675 on('tool.call', async ($, e, next) => {
676 const tool = String(e.tool)
677 const id = e.tool_use_id
678 // these two tools are the model waiting on the person
679 const isQuestion = tool === 'AskUserQuestion' || tool === 'ExitPlanMode'
680 calls.set(id, tool)
681
682 if (isQuestion) {
683 questions.add(id)
684 await settle($)
685 }
686
687 const ran = await next(e)
688 // this call is over, and with it whatever of it waited on the person
689 calls.delete(id)
690 pilled.delete(id)
691 questions.delete(id)
692 removeOne(permissions, tool)
693 const wasRefused = removeOne(refused, tool)
694 const hasFailed = ran.deny === undefined && ran.isError === true
695 // not a failure worth showing: the person said no, or the turn it ran
696 // in was interrupted, or the pet is waiting on the person for another
697 const isWorthShowing = !wasRefused && (isTurnRunning || e.agentId !== undefined) && baseMood() !== 'waiting'
698
699 if (hasFailed && isWorthShowing) {
700 await show($, 'failed', FLASH_MS)
701 } else {
702 await settle($)
703 }
704
705 return ran
706 }).catch(($, e, next) => next(e))
707
708 on('command.run', { command: 'pet' }, async ($, e) => {
709 await booting
710 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
711 const arg = rest.join(' ')
712
713 if (verb === 'list') {
714 return { text: await listPets($) }
715 }
716
717 if (verb === 'use' && arg !== '') {
718 return { text: await usePet($, arg) }
719 }
720
721 if (verb === 'install' && arg !== '') {
722 return { text: await installPet($, arg) }
723 }
724
725 if (verb === 'refresh') {
726 return { text: showing(await loadPet($, await chosenPet($), { orBundled: true, isForced: true })) }
727 }
728
729 if (verb === 'hide' || verb === 'show') {
730 await update($, isHidden, () => verb === 'hide')
731 wake($)
732
733 return { text: `${pet?.name ?? 'The pet'} is ${verb === 'hide' ? 'hidden; /pet show brings it back' : 'back'}.` }
734 }
735
736 if (isMood(verb)) {
737 await update($, isHidden, () => false)
738 await show($, verb, 6_000)
739
740 return { text: `${pet?.name ?? 'The pet'}: ${verb}` }
741 }
742
743 return { text: usage() }
744 })
745
746 // Approving a permission dialog raises no event of its own. On the terminal
747 // a long call then draws its pill, which says that call is running: the one
748 // sign there is, and only there (the desktop raises no ToolProgress).
749 on('ui.render', { component: 'ToolProgress' }, ($, e, next) => {
750 const id = e.props.tool_use_id
751
752 if (calls.has(id) && !pilled.has(id)) {
753 pilled.add(id)
754 started.push(id)
755 }
756
757 return next(e)
758 })
759
760 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
761 // each read subscribes the band: a write to any of them draws it again
762 const [now, hidden] = await Promise.all([read($, mood), read($, isHidden), read($, step), read($, loads)])
763 const current = pet
764
765 if (hidden || e.props.hasSurvey || current === undefined) {
766 bandId = undefined
767 isOnDesktop = undefined
768 isFaceDrawn = false
769
770 return next(e)
771 }
772
773 isOnDesktop = e.surface === 'desktop'
774 const label = `${current.name}: ${LABEL[now]}`
775 // the band is as wide as the prompt: where in it the pet stands
776 const justify = JUSTIFY[settings.align]
777
778 if (e.surface === 'terminal') {
779 const { Box, Image, Raster, Text } = $.ui.resolve(e)
780 const beside = settings.hasLabel && (
781 <Box flexDirection="column" justifyContent="flex-end" marginLeft={1}>
782 <Text bold>{current.name}</Text>
783 <Text dimColor>{LABEL[now]}</Text>
784 </Box>
785 )
786 // a terminal that shows images gets the pet's own pixels, in fewer rows
787 const imageRows = Math.min(IMAGE_ROWS[settings.size], e.props.maxRows)
788
789 if (hasImages && imageRows >= IMAGE_MIN_ROWS) {
790 // a cell is about twice as tall as it is wide
791 const columns = Math.max(1, Math.round((imageRows * (settings.hasTallCells ? 2.4 : 2) * current.png.width) / current.png.height))
792 isImageUnproven ||= !isImageDrawn || bandId !== e.requestId
793 bandId = e.requestId
794 isImageDrawn = true
795 isFaceDrawn = false
796
797 return (
798 <Box width="100%" justifyContent={justify}>
799 <Image key="pet" source={imageOf(current, now, frame)} columns={columns} rows={imageRows} alt={label} />
800 {beside}
801 </Box>
802 )
803 }
804
805 isImageDrawn = false
806 isFaceDrawn = settings.style !== 'blocks'
807
808 // no picture here: a face of characters, one row and sharp, rather
809 // than the pet in blocks of color too coarse to do it justice
810 if (isFaceDrawn) {
811 bandId = e.requestId
812
813 return (
814 <Box width="100%" justifyContent={justify}>
815 <Text color={current.tint} bold>
816 {faceOf(now, frame)}
817 </Text>
818 {settings.hasLabel && <Text bold> {current.name}</Text>}
819 {settings.hasLabel && <Text dimColor> {LABEL[now]}</Text>}
820 </Box>
821 )
822 }
823
824 // the small size, or a terminal too short for the full one, draws the
825 // half-size pet; one too short for that, the label alone
826 const full: TerminalSize = settings.hasTallCells ? 'loTall' : 'lo'
827 const half: TerminalSize = settings.hasTallCells ? 'tinyTall' : 'tiny'
828 const fits = (one: TerminalSize) => e.props.maxRows >= current.terminal[one].rows
829 const size = settings.size !== 'small' && fits(full) ? full : fits(half) ? half : undefined
830
831 if (size === undefined) {
832 bandId = undefined
833
834 return settings.hasLabel ? <Text dimColor>{label}</Text> : next(e)
835 }
836
837 bandId = e.requestId
838 terminalSize = size
839
840 return (
841 <Box width="100%" justifyContent={justify}>
842 <Raster
843 key="pet"
844 columns={current.terminal[size].columns}
845 rows={current.terminal[size].rows}
846 cells={cellsOf(current, size, now, frame)}
847 />
848 {beside}
849 </Box>
850 )
851 }
852
853 if (e.surface === 'desktop') {
854 const { Box, Svg, Text } = $.ui.resolve(e)
855 const source = svgOf(current, now, frame)
856 const height = DESKTOP_HEIGHT[settings.size]
857
858 // this mood's frames are not read yet: the timer reads them and redraws
859 if (source === undefined) {
860 return settings.hasLabel ? <Text dimColor>{label}</Text> : next(e)
861 }
862
863 return (
864 <Box width="100%" justifyContent={justify}>
865 <Svg
866 source={source}
867 alt={label}
868 width={Math.round((height * current.svg.width) / current.svg.height)}
869 height={height}
870 />
871 {settings.hasLabel && (
872 <Box flexDirection="column" justifyContent="flex-end" marginLeft={1}>
873 <Text bold>{current.name}</Text>
874 <Text dimColor>{LABEL[now]}</Text>
875 </Box>
876 )}
877 </Box>
878 )
879 }
880
881 return next(e)
882 })
883}
884hooks/draw.ts 261 lines1import type { Mood } from '../types'
2
3// A converted pet, as scripts/pet.mjs writes it and the hooks module reads it.
4export type PetMeta = {
5 version: number
6 id: string
7 name: string
8 source: string
9 // terminal: the pet at each size and cell shape, on one palette. A frame is
10 // 2 * columns by 2 * rows pixels, a character a pixel: '.' transparent,
11 // else the palette index as the character 48 + index
12 terminal: { palette: string[] } & Record<TerminalSize, Frames>
13 // a terminal that shows images: each frame a PNG file beside the manifest
14 png: { width: number; height: number }
15 // desktop: how many frames each state has; their markup is a file a state
16 svg: { width: number; height: number; colors: number; frames: Partial<Record<Mood, number>> }
17}
18
19type Frames = { columns: number; rows: number; states: Partial<Record<Mood, string[]>> }
20
21export type TerminalSize = 'lo' | 'tiny' | 'loTall' | 'tinyTall'
22
23export type Pet = PetMeta & {
24 dir: string
25 rgb: number[]
26 /** The pet's main color, for the face a plain terminal draws. */
27 tint: string
28 /** A state's frames as SVG path markup, read when the desktop first needs them. */
29 paths: Map<Mood, string[]>
30}
31
32export const META_VERSION = 7
33
34export const MOODS: readonly Mood[] = [
35 'idle',
36 'running-right',
37 'running-left',
38 'waving',
39 'jumping',
40 'failed',
41 'waiting',
42 'running',
43 'review',
44]
45
46export const LABEL: Record<Mood, string> = {
47 idle: 'idle',
48 'running-right': 'on the move',
49 'running-left': 'on the move',
50 waving: 'hi!',
51 jumping: 'done!',
52 failed: 'that failed',
53 waiting: 'needs you',
54 running: 'working…',
55 review: 'ready for review',
56}
57
58const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
59
60// The pet as a face of plain characters, a few frames a mood: what a terminal
61// that shows no images draws, sharp at any size where blocks of color are not.
62// A mood's frames are all one width, so nothing beside the face shifts.
63export const FACES: Record<Mood, readonly string[]> = {
64 idle: ['(•‿•)', '(•‿•)', '(•‿•)', '(-‿-)'],
65 'running-right': ['(•_•)>', '(•_•)»'],
66 'running-left': ['<(•_•)', '«(•_•)'],
67 waving: ['(^‿^)/', '(^‿^)-'],
68 jumping: ['\\(^o^)/', ' (^o^) '],
69 failed: ['(x_x)', '(>_<)'],
70 waiting: ['(•_•)?', '(•_•) '],
71 running: SPINNER.map(mark => `${mark} (•_•)`),
72 review: ['(^‿^)*', '(^‿^) '],
73}
74
75export function faceOf(mood: Mood, at: number): string {
76 const frames = FACES[mood]
77
78 return frames[at % frames.length] ?? ''
79}
80
81const TRANSPARENT = 46 // '.'
82const FIRST = 48 // '0': palette index n is the char FIRST + n
83const DEFAULT_COLOR = 0x01000000
84
85export function isMood(text: string): text is Mood {
86 return MOODS.some(one => one === text)
87}
88
89// What the converter wrote, or undefined when it is not that.
90export function petOf(text: string, dir: string): Pet | undefined {
91 let meta: Partial<PetMeta>
92
93 try {
94 meta = JSON.parse(text)
95 } catch {
96 return undefined
97 }
98
99 const isWhole =
100 meta.version === META_VERSION &&
101 typeof meta.id === 'string' &&
102 typeof meta.name === 'string' &&
103 Array.isArray(meta.terminal?.palette) &&
104 (['lo', 'tiny', 'loTall', 'tinyTall'] as const).every(size => (meta.terminal?.[size]?.states?.idle?.length ?? 0) > 0) &&
105 (meta.png?.width ?? 0) > 0 &&
106 (meta.png?.height ?? 0) > 0 &&
107 (meta.svg?.frames?.idle ?? 0) > 0
108
109 if (!isWhole) {
110 return undefined
111 }
112
113 const whole = meta as PetMeta
114 // the color most of its first idle frame is
115 const counts = new Map<number, number>()
116
117 for (const char of whole.terminal.lo.states.idle?.[0] ?? '') {
118 const code = char.charCodeAt(0)
119
120 if (code !== TRANSPARENT) {
121 counts.set(code, (counts.get(code) ?? 0) + 1)
122 }
123 }
124
125 const [most] = [...counts].sort((a, b) => b[1] - a[1])
126
127 return {
128 ...whole,
129 dir,
130 tint: whole.terminal.palette[(most?.[0] ?? FIRST) - FIRST] ?? '#888888',
131 rgb: whole.terminal.palette.map(hex => parseInt(hex.slice(1), 16)),
132 paths: new Map(),
133 }
134}
135
136// A state with no frames of its own is drawn as idle.
137export function stateOf(pet: Pet, mood: Mood): Mood {
138 return (pet.terminal.lo.states[mood]?.length ?? 0) > 0 ? mood : 'idle'
139}
140
141export function frameCount(pet: Pet, mood: Mood): number {
142 return pet.terminal.lo.states[stateOf(pet, mood)]?.length ?? 1
143}
144
145function colorAt(pet: Pet, frame: string, index: number): number {
146 const code = index < frame.length ? frame.charCodeAt(index) : TRANSPARENT
147
148 return code === TRANSPARENT ? -1 : (pet.rgb[code - FIRST] ?? -1)
149}
150
151// The block that fills the quadrants a mask names (1 top left, 2 top right,
152// 4 bottom left, 8 bottom right), by mask.
153const QUADRANTS = [
154 0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b, 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f,
155 0x2588,
156]
157// every way to split a cell's four pixels in two groups, as the mask of one
158const SPLITS = [0b0011, 0b0101, 0b0110, 0b0001, 0b0010, 0b0100, 0b1000]
159
160function mean(colors: number[], mask: number): number {
161 let r = 0
162 let g = 0
163 let b = 0
164 let n = 0
165
166 colors.forEach((color, at) => {
167 if (mask & (1 << at)) {
168 r += color >> 16
169 g += (color >> 8) & 0xff
170 b += color & 0xff
171 n += 1
172 }
173 })
174
175 return n === 0 ? 0 : (Math.round(r / n) << 16) | (Math.round(g / n) << 8) | Math.round(b / n)
176}
177
178function spread(colors: number[], mask: number, to: number): number {
179 let sum = 0
180
181 colors.forEach((color, at) => {
182 if (mask & (1 << at)) {
183 sum += ((color >> 16) - (to >> 16)) ** 2 + (((color >> 8) & 0xff) - ((to >> 8) & 0xff)) ** 2 + ((color & 0xff) - (to & 0xff)) ** 2
184 }
185 })
186
187 return sum
188}
189
190// One cell's four pixels (top left, top right, bottom left, bottom right; -1
191// transparent) as the block and two colors that come closest to them.
192function cellOf(pixels: number[]): [number, number, number] {
193 const opaque = pixels.reduce((mask, color, at) => (color < 0 ? mask : mask | (1 << at)), 0)
194
195 // part of the cell shows the terminal through: the rest is one color
196 if (opaque !== 0b1111) {
197 return [QUADRANTS[opaque] ?? 0x20, opaque === 0 ? DEFAULT_COLOR : mean(pixels, opaque), DEFAULT_COLOR]
198 }
199
200 let best: [number, number, number] = [0x2588, mean(pixels, 0b1111), DEFAULT_COLOR]
201 let least = spread(pixels, 0b1111, best[1])
202
203 for (const mask of SPLITS) {
204 const fore = mean(pixels, mask)
205 const back = mean(pixels, ~mask & 0b1111)
206 const error = spread(pixels, mask, fore) + spread(pixels, ~mask & 0b1111, back)
207
208 if (error < least) {
209 least = error
210 best = [QUADRANTS[mask] ?? 0x2588, fore, back]
211 }
212 }
213
214 return best
215}
216
217// A terminal frame as a Raster's cells, four pixels a cell: the quadrant
218// block and the two colors that draw them best.
219export function cellsOf(pet: Pet, size: TerminalSize, mood: Mood, at: number): string {
220 const { columns, rows, states } = pet.terminal[size]
221 const frames = states[stateOf(pet, mood)] ?? []
222 const frame = frames[at % Math.max(1, frames.length)] ?? ''
223 const width = columns * 2
224 const words = new Uint32Array(columns * rows * 3)
225
226 for (let row = 0; row < rows; row += 1) {
227 for (let column = 0; column < columns; column += 1) {
228 const top = row * 2 * width + column * 2
229 const pixels = [top, top + 1, top + width, top + width + 1].map(index => colorAt(pet, frame, index))
230 words.set(cellOf(pixels), (row * columns + column) * 3)
231 }
232 }
233
234 return new Uint8Array(words.buffer).toBase64()
235}
236
237// One frame as the PNG file the converter left in the cache, for a terminal
238// that shows images: it reads the file itself, so no pixel crosses the plugin.
239export function imageOf(pet: Pet, mood: Mood, at: number): { file: string; format: 'png' } {
240 const state = stateOf(pet, mood)
241
242 return { file: `${pet.dir}/png-${state}-${at % frameCount(pet, mood)}.png`, format: 'png' }
243}
244
245// One desktop frame as an SVG of its own, or undefined until the state's
246// markup is read. An image, so the band shows through it; and one frame an
247// element, since a whole animation at the atlas's size fits none.
248export function svgOf(pet: Pet, mood: Mood, at: number): string | undefined {
249 const frames = pet.paths.get(stateOf(pet, mood))
250 const paths = frames?.[at % Math.max(1, frames.length)]
251
252 if (paths === undefined) {
253 return undefined
254 }
255
256 const { width, height } = pet.svg
257
258 // paths are written on whole rows; the half pixel centers each stroke on its row
259 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" shape-rendering="crispEdges" fill="none" stroke-width="1"><g transform="translate(0 .5)">${paths}</g></svg>`
260}
261types/index.d.ts 24 lines1export type Mood =
2 | 'idle'
3 | 'running-right'
4 | 'running-left'
5 | 'waving'
6 | 'jumping'
7 | 'failed'
8 | 'waiting'
9 | 'running'
10 | 'review'
11
12declare module 'claude-code' {
13 interface PluginState {
14 'codex-pet': {
15 mood: Mood
16 isHidden: boolean
17 /** Counts the desktop's frame changes: each write redraws the band. */
18 step: number
19 /** Counts the pets loaded, 0 before the first: a write redraws the band. */
20 loads: number
21 }
22 }
23}
24