Agent Pets: live limits for the Agent Pets widget, /pets, nudges about other agents and a pixel pet above the prompt.

<img src="docs/images/banner.png" alt="Agent Pets banner: the Agent Pets wordmark with all nine pets perched on its letters: the Grok robot waves hi, the ZCode panda wears headphones, the Cursor block claps, Kodek, a blob and Clawd sit on the letters, the Copilot pilot sends hearts, the Android robot cheers and the opencode cyclops throws a paper plane; under it the tagline "Your coding agents, alive on the Windows 11 taskbar" and a Windows 11 taskbar" width="100%">
<a href="https://github.com/Marczelloo/agent-pets/releases/latest"><img alt="Latest release" src="https://img.shields.io/github/v/release/Marczelloo/agent-pets?include_prereleases&color=D97757"></a> <img alt="Windows 11" src="https://img.shields.io/badge/Windows-11-5DCAA5"> <a href="LICENSE"><img alt="License: GPL-3.0-or-later" src="https://img.shields.io/badge/license-GPL--3.0-8C887E"></a>
Animated pets in the Windows 11 taskbar that show what your coding agents are doing. Every session of Claude Code, Codex, opencode, GitHub Copilot, Antigravity, Cursor, Grok Build or ZCode gets its own pet: it codes at a desk, types commands, reads files, catches web pages with a butterfly net, waves when it needs you and naps when idle. Progress, context and rate limits sit right next to it.
<img src="docs/images/pets.gif" alt="Animated gallery of all pets: Clawd editing, Kodek browsing, the opencode cyclops running a command, the Copilot pilot reading, the Android robot thinking, the Cursor block searching, the Grok robot delegating, the ZCode panda done and a blob pet that needs you" width="900">
<img src="docs/images/taskbar.png" alt="Five pets in the taskbar: a knocked-out Codex robot after an error, a Codex robot presenting a web page, Clawd waving because it needs you, a Codex robot with the Agent Router badge, Clawd typing at a desk; a +2 badge and rate-limit bars" width="900">
/pets pane with every agent's sessions, nudges when another agent waits or fails, and a pixel Clawd above the prompt. On by default, and installable from the marketplace without the widget (/plugin marketplace add Marczelloo/agent-pets). See Agents.More in docs/features.md.
<img src="docs/images/states.gif" alt="Clawd in twelve states: thinking, editing, running a command, reading, searching, browsing, delegating, needs you, done, error, compacting and asleep" width="900">
<img src="docs/images/styles.gif" alt="Kodek thinking and Clawd editing in the seven looks: Sticker, Sketch, Clean, Pixel art, Neon, Ink and Pastel" width="900">
<img src="docs/images/dynamic.gif" alt="Dynamic motion scenes: a punch barrage, ninja hand seals, a detective with a magnifier, shadow thinking, a thunder dash, a summoning seal and Hollow Purple while compacting" width="900">
Agent.Pets_<version>_x64-setup.exe from Releases and run it. It installs for your user, without administrator rights. Code signing is being set up (see Code signing policy); until then SmartScreen may ask: More info → Run anyway.Change anything later in Settings (right-click the tray icon, or ⚙ in the panel). Updates are signed and install from GitHub; uninstall from Windows Settings → Apps, which also removes the hooks.
| Agent | Pet | Status | Limits |
|---|---|---|---|
| Claude Code | Clawd | ✅ core | 5h and weekly |
| Codex, incl. Agent Router tasks | Kodek | ✅ core | 5h and weekly |
| opencode | a near-black cyclops | ✅ core | tokens, cost, account bars |
| GitHub Copilot | a pilot in a leather helmet | 🧪 experimental | – |
| Antigravity | the Android robot | 🧪 experimental | Gemini 5h and weekly |
| Cursor | a dark faceted block | 🧪 experimental | – |
| Grok Build | a white robot with a visor | 🧪 experimental | – |
| ZCode | a panda with a “Z” headband | 🧪 experimental | – |
| Anything else | a blob with its first letter | ✅ the door | – |
Claude Code, Codex and opencode get the most care and testing; the experimental ones work through their hooks and were not checked in every detail against a live session. Setup details, what each integration writes and how to undo it: docs/agents.md.
| Panel | First-run wizard | Settings |
|---|---|---|
| <img src="docs/images/panel.png" alt="Panel with the session list: a main session with its subagents and two more sessions" width="260"> | <img src="docs/images/wizard.png" alt="Wizard step: pet appearance, motion and a gallery of seven styles" width="400"> | <img src="docs/images/settings.png" alt="Settings window, Look tab with the live preview and all scenes" width="400"> |
Session data never leaves your computer: states come from local hooks and files, and only titles, progress and counters are kept, never message content. The only network calls are the update check on GitHub (can be turned off) and, if you allow it, fetching your Claude plan limits from api.anthropic.com. Details in docs/privacy.md.
From 0.17 on, release installers are built from this repository by the release workflow on GitHub Actions, not on a personal computer. Code signing of Agent.Pets_<version>_x64-setup.exe, agent-pets.exe and hook.exe is being requested from SignPath Foundation; once it is granted, this section will say: free code signing provided by SignPath.io, certificate by SignPath Foundation.
Every release is also signed for the in-app updater, which installs only an update with a valid signature. Privacy lists every network call the app makes; it sends no other data anywhere.
pets-cli, architecture, releasesAgent Pets © 2026 Marczelloo, licensed under the GNU General Public License v3.0 or later. You may use, study, change and share it, also commercially; if you share a changed version, it must stay open source under the same license. Versions up to 0.12.2 were released under the MIT License; see NOTICE for earlier terms, contributors, trademarks and third-party artwork.
The Antigravity pet is based on the Android robot, which is reproduced or modified from work created and shared by Google and used according to terms described in the Creative Commons 3.0 Attribution License.
Agent Pets is not affiliated with Anthropic, OpenAI, GitHub, Google, Cursor, xAI, Z.ai or opencode.
hooks/register.tsx 13 lines1import type { Register } from 'claude-code'
2import { registerNudges } from './nudge'
3import { registerPane } from './pane'
4import { registerPet } from './pet'
5import { registerReport } from './report'
6
7export const register: Register = on => {
8 registerReport(on)
9 registerPane(on)
10 registerNudges(on)
11 registerPet(on)
12}
13hooks/nudge.ts 140 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Api, On } from 'claude-code'
3import { sharedBridge } from './bridge'
4import type { Bridge, BridgeIo, Board } from './bridge'
5import { tidy } from './pane'
6
7// Mirrors the app's switch from the board, like the pet's in pet.tsx.
8const nudgesAtom = atom({ plugin: 'agent-pets', key: 'nudges' } as const, true)
9
10const POLL_MS = 3000
11const QUESTION_MAX = 80
12
13type Nudges = { toasts: string[]; seen: Set<string>; waiting: number }
14
15const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
16
17// `codex` -> `Codex`; by code point, an agent name is whatever another program called itself.
18const capitalised = (agent: string): string => {
19 const [first = 'A', ...rest] = Array.from(agent || 'agent')
20 return first.toUpperCase() + rest.join('')
21}
22
23/**
24 * What changed on the widget's board since `prev`: a toast for each session of another agent that now waits for the user
25 * or has failed, unless `prev` already holds its key (id, state and question). `seen` is every such key on this board,
26 * so a session that moves on forgets its key and toasts again when it asks anew. `self` and its children (`self/...`) are left out.
27 * Board strings come from other programs: they are tidied before they reach a toast.
28 */
29export function diff(prev: Set<string>, board: Board, self: string): Nudges {
30 const out: Nudges = { toasts: [], seen: new Set(), waiting: 0 }
31 for (const raw of isObject(board) && Array.isArray(board.sessions) ? board.sessions : []) {
32 if (!isObject(raw)) continue
33 const id = String(raw.id ?? '')
34 if (id === self || id.startsWith(`${self}/`)) continue
35 if (raw.state !== 'needs_input' && raw.state !== 'error') continue
36 const key = `${id}|${raw.state}|${raw.question ?? ''}`
37 if (raw.state === 'needs_input') out.waiting++
38 out.seen.add(key)
39 if (prev.has(key)) continue
40 const agent = capitalised(tidy(raw.agent, 40))
41 if (raw.state === 'needs_input') {
42 // A question can be missing; the title still says what is waiting.
43 out.toasts.push(`${agent} waits: ${tidy(raw.question, QUESTION_MAX) || tidy(raw.title, QUESTION_MAX)}`)
44 } else {
45 out.toasts.push(`${agent} hit an error: ${tidy(raw.title, QUESTION_MAX)}`)
46 }
47 }
48 return out
49}
50
51// Mirrors report.ts's `ioOf`: the two must stay in step. The engine's `$` as the bridge's io. The validator follows `$` only into functions declared in this file,
52// and `$.env.get` takes a literal name: both are why this lives here and not in bridge.ts (report.ts and pane.tsx have their own).
53function ioOf($: Api): BridgeIo {
54 return {
55 pluginRoot: $.plugin.root,
56 home: async () => (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')),
57 read: path => $.fs.read(path),
58 exists: path => $.fs.exists(path),
59 now: () => $.clock.now(),
60 fetch: (url, init) => $.http.fetch(url, init),
61 }
62}
63
64/**
65 * Toasts when another agent's session needs the user or fails, and pins `⏳ N waiting` under the prompt, while a person is at the prompt.
66 * Polls the widget's board every 3 s, through `bridge` or the shared one. The first look only learns what already waits, so Claude
67 * starting beside a waiting session does not burst. Nothing here throws into the session.
68 */
69export function registerNudges(on: On, bridge?: Bridge): void {
70 let timer: { cancel: () => void } | undefined
71
72 // A matcher: the engine takes one unmatched hook per event and plugin, and report.ts has that one.
73 on('session.start', { surface: 'terminal', isInteractive: true }, async ($, e, next) => {
74 const r = await next(e)
75 try {
76 timer?.cancel()
77 const door = bridge ?? sharedBridge(ioOf($))
78 // null until the first look: that look seeds, it does not toast.
79 let seen: Set<string> | null = null
80 let shown: string | undefined
81 let busy = false
82
83 const pin = async (text: string | undefined) => {
84 if (text === shown) return
85 shown = text
86 await $.ui.status(text)
87 }
88
89 const tick = async () => {
90 if (busy) return
91 busy = true
92 try {
93 if (await door.isDuplicateCopy()) {
94 // The app's own copy nudges: this one says nothing, and seeds again if it ever takes over.
95 seen = null
96 await pin(undefined)
97 return
98 }
99 const { nudges } = await door.prefs()
100 if (nudges !== (await read($, nudgesAtom))) await update($, nudgesAtom, () => nudges)
101 if (!nudges) {
102 // Off: forget what was seen, so turning it back on seeds again instead of bursting.
103 seen = null
104 await pin(undefined)
105 return
106 }
107 const board = await door.state()
108 if (!board) {
109 await pin(undefined)
110 return
111 }
112 // The board just read carries the latest switch: a nudge turned off in the app is not toasted once more.
113 if (!(await door.prefs()).nudges) {
114 seen = null
115 await pin(undefined)
116 return
117 }
118 const out = diff(seen ?? new Set(), board, await $.session.id())
119 const first = seen === null
120 seen = out.seen
121 await pin(out.waiting > 0 ? `⏳ ${out.waiting} waiting` : undefined)
122 if (first) return
123 for (const text of out.toasts) {
124 try {
125 await $.ui.toast(text)
126 } catch {}
127 }
128 } catch {
129 // a missed look is made up for by the next
130 } finally {
131 busy = false
132 }
133 }
134
135 timer = $.clock.every(POLL_MS, tick)
136 } catch {}
137 return r
138 })
139}
140hooks/pane.tsx 217 lines1import type { EngineInterface as Api, On } from 'claude-code'
2import { sharedBridge } from './bridge'
3import type { Bridge, BridgeIo, Prefs } from './bridge'
4
5const PANE = 'agent-pets'
6const BAR_CELLS = 10
7const DAY_MS = 24 * 60 * 60 * 1000
8
9/** `Pet: off · Nudges: on`; the switches live in the app's settings, the pane only tells them. */
10export const prefsLine = (p: Prefs): string =>
11 `Pet: ${p.pet ? 'on' : 'off'} · Nudges: ${p.nudges ? 'on' : 'off'} — change in Agent Pets → Settings → Apps`
12
13type LimitRow = { label: string; percent: number; resetsAt?: number | null; stale: boolean }
14
15const WINDOW_LABEL: Record<string, string> = { five_hour: '5h', weekly: 'week', seven_day: 'week', spend: 'spend', spend_limit: 'spend' }
16
17// Everything the widget sends ends up in a Text, and ultimately comes from the user's sessions: one tidy line each.
18// Control characters and line breaks become a space; bidi marks and zero-width characters (which could reorder
19// or hide what is drawn) go. Cut by code point so a surrogate pair is never split.
20export const tidy = (text: unknown, max = 160): string => {
21 const flat = String(text ?? '')
22 .replace(/[\u200b-\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff]/g, '')
23 .replace(/[\u0000-\u001f\u007f\u0080-\u009f\s]+/g, ' ')
24 .trim()
25 const points = Array.from(flat)
26 return points.length > max ? `${points.slice(0, max - 1).join('')}\u2026` : flat
27}
28
29const clampPercent = (v: number) => Math.min(100, Math.max(0, Number.isFinite(v) ? v : 0))
30
31/** `██████░░░░`: one cell per ten percent, rounded. */
32export function bar(percent: number): string {
33 const filled = Math.round(clampPercent(percent) / (100 / BAR_CELLS))
34 return '█'.repeat(filled) + '░'.repeat(BAR_CELLS - filled)
35}
36
37/** Local clock time of a reset (the weekday too when it is a day or more away); the user's own zone and locale, none hard-coded. */
38export function resetText(resetsAt: number, now: number): string {
39 try {
40 const at = new Date(resetsAt)
41 const time = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(at)
42 if (resetsAt - now < DAY_MS) return time
43 return `${new Intl.DateTimeFormat(undefined, { weekday: 'short' }).format(at)} ${time}`
44 } catch {
45 return ''
46 }
47}
48
49/** `5h ██████░░░░ 23% · resets 18:00` */
50export function limitLine(row: LimitRow, now: number): string {
51 const parts = [`${String(row.label ?? '').padEnd(5)} ${bar(row.percent)} ${Math.round(clampPercent(row.percent))}%`]
52 const reset = typeof row.resetsAt === 'number' ? resetText(row.resetsAt, now) : ''
53 if (reset) parts.push(`resets ${reset}`)
54 if (row.stale) parts.push('stale')
55 return parts.join(' · ')
56}
57
58type SessionRow = { agent: string; state: string; title: string; question: string }
59type BoardView = { sessions: SessionRow[]; limits: (LimitRow & { agent: string })[] }
60
61const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
62
63/**
64 * The widget's board as rows that are safe to draw: arrays that are not arrays become empty, entries that are not
65 * objects are skipped, every string is tidied (agents are grouped by their tidied name) and a percentage that is not a number is 0.
66 */
67export function viewOf(board: unknown): BoardView | null {
68 if (!isObject(board)) return null
69 const view: BoardView = { sessions: [], limits: [] }
70 for (const raw of Array.isArray(board.sessions) ? board.sessions : []) {
71 if (!isObject(raw)) continue
72 view.sessions.push({ agent: tidy(raw.agent, 40), state: tidy(raw.state, 24), title: tidy(raw.title, 80), question: tidy(raw.question) })
73 }
74 for (const raw of Array.isArray(board.limits) ? board.limits : []) {
75 if (!isObject(raw)) continue
76 const window = tidy(raw.window, 12)
77 const percent = typeof raw.used_pct === 'number' && Number.isFinite(raw.used_pct) ? raw.used_pct : 0
78 view.limits.push({
79 agent: tidy(raw.agent, 40),
80 label: WINDOW_LABEL[window] ?? window,
81 percent,
82 resetsAt: typeof raw.resets_at === 'number' ? raw.resets_at : null,
83 stale: typeof raw.stale_since === 'number',
84 })
85 }
86 return view
87}
88
89// The agents in the order the board names them: sessions first, then agents that only have limits.
90const agentsOf = (view: BoardView): string[] => [...new Set([...view.sessions.map(s => s.agent), ...view.limits.map(l => l.agent)])]
91
92// This session's own windows, from the engine; what the pane can still show without the widget.
93async function sessionLimits($: Api): Promise<LimitRow[]> {
94 try {
95 const { rateLimits } = await $.session.usage()
96 return rateLimits.map(l => {
97 const at = l.resetsAt ? Date.parse(l.resetsAt) : NaN
98 return { label: WINDOW_LABEL[l.kind] ?? tidy(l.kind, 12), percent: l.percentUsed, resetsAt: Number.isFinite(at) ? at : null, stale: false }
99 })
100 } catch {
101 return []
102 }
103}
104
105// Mirrors report.ts's `ioOf`: the two must stay in step. The engine's `$` as the bridge's io. The validator follows `$` only into functions declared in this file,
106// and `$.env.get` takes a literal name: both are why this lives here and not in bridge.ts (report.ts has its own).
107function ioOf($: Api): BridgeIo {
108 return {
109 pluginRoot: $.plugin.root,
110 home: async () => (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')),
111 read: path => $.fs.read(path),
112 exists: path => $.fs.exists(path),
113 now: () => $.clock.now(),
114 fetch: (url, init) => $.http.fetch(url, init),
115 }
116}
117
118/**
119 * `/pets` (terminal, interactive sessions): a pane with the widget's sessions and limits, and where the pet and nudges are switched.
120 * Without the widget it shows this session's own limits and says so. Reads through `bridge`, or the shared one.
121 */
122export function registerPane(on: On, bridge?: Bridge): void {
123 // A matcher: the engine takes one unmatched hook per event and plugin, and report.ts has that one.
124 on('session.start', { surface: 'terminal', isInteractive: true }, async ($, e, next) => {
125 const r = await next(e)
126 try {
127 // The app's own copy has the command; a second /pets would only ever say "widget not running".
128 if (await (bridge ?? sharedBridge(ioOf($))).isDuplicateCopy()) return r
129 await $.command.register({ name: 'pets', description: 'Agent Pets: sessions and limits' })
130 } catch {}
131 return r
132 })
133
134 on('command.run', { command: 'pets' }, async ($, e, next) => {
135 if (await (bridge ?? sharedBridge(ioOf($))).isDuplicateCopy()) return next(e)
136 try {
137 await $.ui.open({ id: PANE, title: 'Agent Pets', focus: true, closeOnEscape: true })
138 } catch {}
139 return {}
140 })
141
142 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
143 if (await (bridge ?? sharedBridge(ioOf($))).isDuplicateCopy()) return next(e)
144 const { Box, Text } = $.ui.resolve(e)
145 // A pane that cannot gather its data still draws: no board means the widget-not-running view.
146 let now = Date.now()
147 let view: BoardView | null = null
148 try {
149 now = await $.clock.now()
150 view = viewOf(await (bridge ?? sharedBridge(ioOf($))).state())
151 } catch {
152 view = null
153 }
154 const own = view ? [] : await sessionLimits($)
155 // The board that was just read refreshed the bridge's switches; without a board there is nothing to tell.
156 const settings = view ? await (bridge ?? sharedBridge(ioOf($))).prefs() : undefined
157
158 if (!view) {
159 return (
160 <Box flexDirection="column">
161 <Text dimColor>Agent Pets widget not running</Text>
162 {own.length > 0 && <Text bold>This session</Text>}
163 {own.map(row => (
164 <Text>{limitLine(row, now)}</Text>
165 ))}
166 </Box>
167 )
168 }
169
170 const board = view
171 const agents = agentsOf(board)
172 return (
173 <Box flexDirection="column">
174 <Text bold>Sessions</Text>
175 {board.sessions.length === 0 && <Text dimColor>No sessions</Text>}
176 {agents.map(agent => {
177 const sessions = board.sessions.filter(s => s.agent === agent)
178 if (sessions.length === 0) return null
179 return (
180 <Box flexDirection="column">
181 <Text bold>{agent}</Text>
182 {sessions.map(s => (
183 <Box flexDirection="column">
184 <Text>
185 {' '}
186 {s.state.replace(/_/g, ' ')} {s.title}
187 </Text>
188 {s.state === 'needs_input' && s.question !== '' && <Text dimColor>{` ? ${s.question}`}</Text>}
189 </Box>
190 ))}
191 </Box>
192 )
193 })}
194 <Text bold>Limits</Text>
195 {board.limits.length === 0 && <Text dimColor>No limits reported yet</Text>}
196 {agents.map(agent => {
197 const rows = board.limits.filter(l => l.agent === agent)
198 if (rows.length === 0) return null
199 return (
200 <Box flexDirection="column">
201 <Text bold>{agent}</Text>
202 {rows.map(row => (
203 <Text>{` ${limitLine(row, now)}`}</Text>
204 ))}
205 </Box>
206 )
207 })}
208 {settings && (
209 <Text key="prefs" dimColor>
210 {prefsLine(settings)}
211 </Text>
212 )}
213 </Box>
214 )
215 })
216}
217hooks/pet.tsx 206 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Api, On } from 'claude-code'
3import { sharedBridge } from './bridge'
4import type { Bridge, BridgeIo } from './bridge'
5import { COLS, FRAMES, ROWS, encode } from './sprites'
6import type { PetState } from './sprites'
7
8export type PetEvent = 'prompt' | 'tool' | 'tool_done' | 'ask' | 'answer' | 'aborted' | 'error' | 'tick'
9
10const TICK_MS = 200
11const DONE_MS = 3000
12const SLEEP_MS = 5 * 60 * 1000
13const BAND_MIN_COLUMNS = 60
14
15// What the pet is doing, and when the last event (anything but a tick) happened. It lives in `$.state`, not in a variable:
16// a hot reload loses variables. `pet` mirrors the app's switch from the board; the band reads it, so a change made in the app redraws it.
17const moodAtom = atom({ plugin: 'agent-pets', key: 'mood' } as const, { state: 'idle', at: 0 })
18const petAtom = atom({ plugin: 'agent-pets', key: 'pet' } as const, false)
19
20const STATUS: Record<PetState, string> = {
21 idle: '',
22 thinking: 'thinking…',
23 working: 'working…',
24 waiting: 'waiting for you',
25 done: 'done',
26 error: 'something went wrong',
27 sleeping: 'zzz',
28}
29
30/**
31 * The next state. `quietMs` is how long it has been since the last event that was not a tick, and matters to a tick alone:
32 * a finished turn waves for 3 s and goes idle; an idle pet falls asleep after 5 minutes. A turn that is thinking, working or
33 * waiting never sleeps however quiet it is, and an error stays until the next prompt.
34 */
35export function step(state: PetState, ev: PetEvent, quietMs: number): PetState {
36 switch (ev) {
37 case 'prompt':
38 return 'thinking'
39 case 'tool':
40 return 'working'
41 case 'ask':
42 return 'waiting'
43 case 'answer':
44 return 'done'
45 case 'aborted':
46 return 'idle'
47 case 'error':
48 return 'error'
49 case 'tool_done':
50 // A tool that ends (or a question that is answered) sends the turn back to the model; late news after the turn ended changes nothing.
51 return state === 'working' || state === 'waiting' ? 'thinking' : state === 'sleeping' ? 'idle' : state
52 case 'tick':
53 if (state === 'done' && quietMs >= DONE_MS) return 'idle'
54 if (state === 'idle' && quietMs >= SLEEP_MS) return 'sleeping'
55 return state
56 }
57}
58
59// Mirrors report.ts's `ioOf`: the two must stay in step. The engine's `$` as the bridge's io. The validator follows `$` only into functions declared in this file,
60// and `$.env.get` takes a literal name: both are why this lives here and not in bridge.ts (report.ts, pane.tsx and nudge.ts have their own).
61function ioOf($: Api): BridgeIo {
62 return {
63 pluginRoot: $.plugin.root,
64 home: async () => (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')),
65 read: path => $.fs.read(path),
66 exists: path => $.fs.exists(path),
67 now: () => $.clock.now(),
68 fetch: (url, init) => $.http.fetch(url, init),
69 }
70}
71
72// The app's own copy has the pet: a second copy of the mod draws nothing (cached for a minute, never throws).
73function isDuplicate($: Api, bridge?: Bridge): Promise<boolean> {
74 return (bridge ?? sharedBridge(ioOf($))).isDuplicateCopy()
75}
76
77// The engine's `$` stays in this file (the validator follows it only into functions declared here).
78// `ev` may be a function, asked when the write happens: what a call's end means depends on the calls still running then.
79async function feed($: Api, event: PetEvent | (() => PetEvent)): Promise<void> {
80 try {
81 const ev = typeof event === 'function' ? event() : event
82 const now = await $.clock.now()
83 const { state, at } = await read($, moodAtom)
84 const quiet = at === 0 ? 0 : now - at
85 if (ev === 'tick' && step(state, ev, quiet) === state) return
86 // The step is computed from the value the write finds, so two events at once cannot undo each other.
87 await update($, moodAtom, cur => {
88 const at = typeof event === 'function' ? event() : ev
89 const quietNow = cur.at === 0 ? 0 : now - cur.at
90 return { state: step(cur.state, at, quietNow), at: at === 'tick' ? cur.at : now }
91 })
92 } catch {}
93}
94
95/**
96 * A pixel Clawd above the prompt that mirrors what this session is doing, on interactive terminal sessions wide enough
97 * for it and while the app's Settings → Apps has the terminal pet on. It watches the prompt, tool calls, permission asks and the end of each turn.
98 */
99export function registerPet(on: On, bridge?: Bridge): void {
100 let timer: { cancel: () => void } | undefined
101 // The band's request id and the animation's frame: the band draws frame `frame`, the timer blits the next. Losing them on a reload costs one frame.
102 let band: string | undefined
103 let frame = 0
104 // This session's tool calls under way: with tools in parallel the turn goes back to the model only when the last one ends.
105 let running = 0
106
107 // A matcher: the engine takes one unmatched hook per event and plugin, and report.ts has that one.
108 on('session.start', { surface: 'terminal', isInteractive: true }, async ($, e, next) => {
109 const r = await next(e)
110 try {
111 timer?.cancel()
112 const now = await $.clock.now()
113 await update($, moodAtom, () => ({ state: 'idle', at: now }))
114 let busy = false
115 timer = $.clock.every(TICK_MS, async () => {
116 if (busy) return
117 busy = true
118 try {
119 if (await isDuplicate($, bridge)) return
120 // The switch is the app's: a change made while this session runs lands here (cached 30 s) and redraws the band.
121 // Before the band check: a band that is not drawn yet is how a pet that was off gets drawn.
122 const { pet } = await (bridge ?? sharedBridge(ioOf($))).prefs()
123 if (pet !== (await read($, petAtom))) await update($, petAtom, () => pet)
124 await feed($, 'tick')
125 if (band === undefined) return
126 const { state } = await read($, moodAtom)
127 frame++
128 const frames = FRAMES[state]
129 const { deny } = await $.ui.blit({ requestId: band, key: 'pet', cells: encode(frames[frame % frames.length]!) })
130 // Not mounted (the band is off, narrow or yielded to a survey): wait for the next draw to name it again.
131 if (deny !== undefined) band = undefined
132 } catch {
133 // a missed frame is made up for by the next
134 } finally {
135 busy = false
136 }
137 })
138 } catch {}
139 return r
140 })
141
142 on('prompt.submit', async ($, e, next) => {
143 const r = await next(e)
144 if (r.drop === undefined) {
145 running = 0
146 await feed($, 'prompt')
147 }
148 return r
149 })
150
151 // A tool that ends, or a question that is answered, takes the turn back to the model. Subagents' tools are the main turn's business.
152 on('tool.call', async ($, e, next) => {
153 const own = e.agentId === undefined
154 if (!own) return next(e)
155 running++
156 // The pet never holds a tool call up: the write starts now and is not waited for. `feed` swallows its own errors.
157 const started = feed($, e.tool === 'AskUserQuestion' ? 'ask' : 'tool').catch(() => {})
158 try {
159 return await next(e)
160 } finally {
161 running = Math.max(0, running - 1)
162 // After the start's write, so a call that ends at once cannot be undone by its own beginning; not waited for either.
163 // Another call still running keeps the pet at work (and takes it off `waiting` once this call's question is settled).
164 void started.then(() => feed($, () => (running > 0 ? 'tool' : 'tool_done'))).catch(() => {})
165 }
166 })
167
168 // The engine's verdict `ask` on a real call is a permission prompt for the user; a call without `tool_use_id` is a plugin's query.
169 on('tool.check', async ($, e, next) => {
170 const r = await next(e)
171 if (r.decision === 'ask' && e.tool_use_id !== undefined) await feed($, 'ask')
172 return r
173 })
174
175 // A matcher naming every reason: see session.start above.
176 on('turn.complete', { reason: ['answer', 'aborted', 'refusal', 'error'] }, async ($, e, next) => {
177 const r = await next(e)
178 if (e.agentId === undefined) running = 0
179 if (e.agentId === undefined) await feed($, e.reason === 'answer' ? 'answer' : e.reason === 'aborted' ? 'aborted' : 'error')
180 return r
181 })
182
183 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
184 if (await isDuplicate($, bridge)) return next(e)
185 if (e.surface !== 'terminal' || e.props.bodyColumns < BAND_MIN_COLUMNS || e.props.maxRows < ROWS || e.props.hasSurvey) return next(e)
186 try {
187 // The mirror is read for the redraw its change brings; the board's switch is what decides.
188 await read($, petAtom)
189 if (!(await (bridge ?? sharedBridge(ioOf($))).prefs()).pet) return next(e)
190 const { state } = await read($, moodAtom)
191 const frames = FRAMES[state]
192 const cells = encode(frames[frame % frames.length]!)
193 const { Box, Raster, Text } = $.ui.resolve(e)
194 band = e.requestId
195 return (
196 <Box justifyContent="flex-end" alignItems="flex-end" gap={1}>
197 {STATUS[state] !== '' && <Text dimColor>{STATUS[state]}</Text>}
198 <Raster key="pet" columns={COLS} rows={ROWS} cells={cells} />
199 </Box>
200 )
201 } catch {
202 return next(e)
203 }
204 })
205}
206hooks/report.ts 133 lines1import type { EngineInterface as Api, On } from 'claude-code'
2import { sharedBridge } from './bridge'
3import type { Bridge, BridgeIo } from './bridge'
4
5export type ModPayload = {
6 v: 1
7 kind: 'start' | 'measure' | 'turn_end' | 'end'
8 session_id: string
9 ts: number
10 cwd?: string
11 model?: string
12 context?: { tokens?: number; window?: number; percent?: number }
13 rate_limits?: { kind: string; percent_used: number; resets_at?: string }[]
14 cost_usd?: number
15 reason?: string
16}
17
18type Rec = Record<string, unknown>
19const rec = (v: unknown): Rec => (typeof v === 'object' && v !== null ? (v as Rec) : {})
20const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
21// Token counts are u64 on the Rust side; a fraction or a negative would get the whole payload refused.
22const count = (v: unknown): number | undefined => (Number.isInteger(v) && (v as number) >= 0 ? (v as number) : undefined)
23const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
24
25/** A hook's input as the widget's payload. Fields the input lacks stay absent (never 0); `model` is added by the hook. */
26export function toPayload(kind: ModPayload['kind'], sessionId: string, ts: number, e: unknown): ModPayload {
27 const input = rec(e)
28 const p: ModPayload = { v: 1, kind, session_id: sessionId, ts }
29 if (kind === 'start') {
30 const cwd = str(input.cwd)
31 if (cwd) p.cwd = cwd
32 } else if (kind === 'turn_end') {
33 const reason = str(input.reason)
34 if (reason) p.reason = reason
35 } else if (kind === 'measure') {
36 const c = rec(input.context)
37 const context = { tokens: count(c.tokens), window: count(c.window), percent: num(c.percent) }
38 if (Object.values(context).some(v => v !== undefined)) {
39 p.context = {}
40 if (context.tokens !== undefined) p.context.tokens = context.tokens
41 if (context.window !== undefined) p.context.window = context.window
42 if (context.percent !== undefined) p.context.percent = context.percent
43 }
44 const limits: NonNullable<ModPayload['rate_limits']> = []
45 for (const raw of Array.isArray(input.rateLimits) ? input.rateLimits : []) {
46 const l = rec(raw)
47 const limitKind = str(l.kind)
48 const percentUsed = num(l.percentUsed)
49 if (!limitKind || percentUsed === undefined) continue
50 const entry: NonNullable<ModPayload['rate_limits']>[number] = { kind: limitKind, percent_used: percentUsed }
51 const resetsAt = str(l.resetsAt)
52 if (resetsAt) entry.resets_at = resetsAt
53 limits.push(entry)
54 }
55 if (limits.length > 0) p.rate_limits = limits
56 const usd = num(rec(input.cost).usd)
57 if (usd !== undefined) p.cost_usd = usd
58 }
59 return p
60}
61
62// The session's model, when the engine can say; a payload goes without it rather than not at all.
63async function modelOf($: Api): Promise<string | undefined> {
64 try {
65 return str(await $.session.model())
66 } catch {
67 return undefined
68 }
69}
70
71// The engine's `$` as the bridge's io. The validator follows `$` only into functions declared in this file,
72// and `$.env.get` takes a literal name: both are why this lives here and not in bridge.ts.
73function ioOf($: Api): BridgeIo {
74 return {
75 pluginRoot: $.plugin.root,
76 home: async () => (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')),
77 read: path => $.fs.read(path),
78 exists: path => $.fs.exists(path),
79 now: () => $.clock.now(),
80 fetch: (url, init) => $.http.fetch(url, init),
81 }
82}
83
84async function report($: Api, bridge: Bridge | undefined, kind: ModPayload['kind'], e: unknown, withModel: boolean, sessionId?: string) {
85 const payload = toPayload(kind, sessionId ?? (await $.session.id()), await $.clock.now(), e)
86 if (withModel) {
87 const model = await modelOf($)
88 if (model) payload.model = model
89 }
90 ;(bridge ?? sharedBridge(ioOf($))).send(payload)
91}
92
93/**
94 * Reports the session's start, every measure (limits, context, cost), each turn's end and the session's end.
95 * Each hook lets the engine finish first and never fails or delays the session on the widget's account.
96 * Reports through `bridge`, or through the shared one made from the first hook's `$` (`register` gets no `$`).
97 * `model` rides on `start` and on every `measure`: a start can reach the app before it knows the session.
98 */
99export function registerReport(on: On, bridge?: Bridge): void {
100 on('session.start', async ($, e, next) => {
101 const r = await next(e)
102 try {
103 await report($, bridge, 'start', e, true)
104 } catch {}
105 return r
106 })
107
108 on('session.measure', async ($, e, next) => {
109 const r = await next(e)
110 try {
111 await report($, bridge, 'measure', e, true)
112 } catch {}
113 return r
114 })
115
116 on('turn.complete', async ($, e, next) => {
117 const r = await next(e)
118 try {
119 await report($, bridge, 'turn_end', e, false)
120 } catch {}
121 return r
122 })
123
124 // After a /clear the process goes on under a new id: the event names the session that ended.
125 on('session.end', async ($, e, next) => {
126 const r = await next(e)
127 try {
128 await report($, bridge, 'end', e, false, e.sessionId)
129 } catch {}
130 return r
131 })
132}
133hooks/bridge.ts 178 lines1import type { ModPayload } from './report'
2
3export type Endpoint = { port: number; token: string }
4/** The mod's two switches, set in the app's Settings → Apps and carried on the board. */
5export type Prefs = { pet: boolean; nudges: boolean }
6export type Board = { v: 1; app_version: string; prefs?: Prefs; sessions: BoardSession[]; limits: BoardLimit[] }
7export type BoardSession = { id: string; agent: string; state: string; title: string; question?: string | null; cwd: string; since: number }
8export type BoardLimit = { agent: string; window: 'five_hour' | 'weekly' | 'spend'; used_pct: number; resets_at?: number | null; stale_since?: number | null }
9
10/**
11 * What the bridge needs from the engine. The plugin validator follows `$` only inside the file that
12 * hooks, never across an import, so the hook file builds this from its `$` (see `ioOf` in report.ts).
13 */
14export type BridgeIo = {
15 /** This copy's folder (`$.plugin.root`). */
16 pluginRoot: string
17 /** The user's home directory (USERPROFILE, else HOME); undefined when neither is set. */
18 home(): Promise<string | undefined>
19 read(path: string): Promise<string>
20 exists(path: string): Promise<boolean>
21 /** Milliseconds since the epoch. */
22 now(): Promise<number>
23 fetch(url: string, init: { method: string; headers: Record<string, string>; body?: string }): Promise<BridgeResponse>
24}
25
26const BACKOFF_MS = 30_000
27const DUPLICATE_TTL_MS = 60_000
28const PREFS_TTL_MS = 30_000
29const EVENTS_PATH = '/v1/events/claude-mod'
30const STATE_PATH = '/v1/state'
31const SKILLS_COPY = '/.claude/skills/agent-pets'
32
33/** Forward slashes, no trailing slash. Compare with `.toLowerCase()` where case does not matter. */
34const normalise = (path: string) => path.replace(/\\/g, '/').replace(/\/+$/, '')
35
36type BridgeResponse = { status: number; ok: boolean; text: string }
37
38/** What counts when the board has no say: no widget, an older app without `prefs`, or a board that cannot be read. */
39export const DEFAULT_PREFS: Prefs = { pet: false, nudges: true }
40
41// A switch that is not a boolean falls back to its default, so one bad value cannot flip the other.
42const prefsOf = (board: Board): Prefs => {
43 const p: unknown = board.prefs
44 const raw = typeof p === 'object' && p !== null ? (p as Partial<Record<keyof Prefs, unknown>>) : {}
45 return {
46 pet: typeof raw.pet === 'boolean' ? raw.pet : DEFAULT_PREFS.pet,
47 nudges: typeof raw.nudges === 'boolean' ? raw.nudges : DEFAULT_PREFS.nudges,
48 }
49}
50
51/**
52 * The only door to the widget: its endpoint file, a bearer token and three failure rules.
53 * Nothing here throws to a caller; the token never leaves the Authorization header.
54 */
55export function createBridge(io: BridgeIo) {
56 let endpoint: Endpoint | null = null
57 let backoffUntil = 0
58 const stoppedSessions = new Set<string>()
59 let duplicate: { value: boolean; at: number } | undefined
60 let cached: { value: Prefs; at: number } | undefined
61
62 const home = async () => normalise((await io.home()) ?? '')
63
64 // Another copy of this plugin (the app's own, in the skills folder) already reports and draws: this one stays silent.
65 // The answer is kept for a minute, so a running session follows the app's Settings toggle without a restart. Never throws.
66 const isDuplicateCopy = async (): Promise<boolean> => {
67 try {
68 const now = await io.now()
69 if (duplicate && now - duplicate.at >= 0 && now - duplicate.at < DUPLICATE_TTL_MS) return duplicate.value
70 const value = await (async () => {
71 const base = await home()
72 if (!base) return false
73 const own = `${base}${SKILLS_COPY}`
74 if (normalise(io.pluginRoot).toLowerCase() === own.toLowerCase()) return false
75 return await io.exists(`${own}/.claude-plugin/plugin.json`)
76 })()
77 duplicate = { value, at: now }
78 return value
79 } catch {
80 return false
81 }
82 }
83
84 const readEndpoint = async (): Promise<Endpoint> => {
85 const text = await io.read(`${await home()}/.agent-pets/endpoint.json`)
86 const parsed: unknown = JSON.parse(text)
87 const { port, token } = (parsed ?? {}) as Partial<Endpoint>
88 if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535 || typeof token !== 'string' || token === '') {
89 throw new Error('bad endpoint file')
90 }
91 return { port, token }
92 }
93
94 // A 2xx or a 400 is an answer; anything else (or no answer) drops the endpoint and pauses every call.
95 const request = async (path: string, init: { method: string; body?: string }): Promise<BridgeResponse | null> => {
96 if (await isDuplicateCopy()) return null
97 const now = await io.now()
98 if (now < backoffUntil) return null
99 try {
100 endpoint ??= await readEndpoint()
101 const headers: Record<string, string> = { authorization: `Bearer ${endpoint.token}` }
102 if (init.body !== undefined) headers['content-type'] = 'application/json'
103 const res = await io.fetch(`http://127.0.0.1:${endpoint.port}${path}`, { method: init.method, headers, body: init.body })
104 if (res.ok || res.status === 400) return res
105 } catch {
106 // fall through to the backoff
107 }
108 endpoint = null
109 backoffUntil = now + BACKOFF_MS
110 return null
111 }
112
113 // The widget's board; null when it is unreachable or the bridge is backing off. A board refreshes the cached switches.
114 const state = async (): Promise<Board | null> => {
115 try {
116 const res = await request(STATE_PATH, { method: 'GET' })
117 if (!res?.ok) return null
118 const board = JSON.parse(res.text) as Board
119 if (!board || board.v !== 1) return null
120 cached = { value: prefsOf(board), at: await io.now() }
121 return board
122 } catch {
123 return null
124 }
125 }
126
127 return {
128 /** True when this is not the app's copy and the app's copy is installed: draw and report nothing. Cached for 60 s; never throws. */
129 isDuplicateCopy,
130
131 /** Fire-and-forget; never throws. */
132 send(payload: ModPayload): void {
133 if (stoppedSessions.has(payload.session_id)) return
134 request(EVENTS_PATH, { method: 'POST', body: JSON.stringify(payload) })
135 .then(res => {
136 if (res?.status === 400) stoppedSessions.add(payload.session_id)
137 })
138 .catch(() => {})
139 },
140
141 /** The widget's board; null when it is unreachable or the bridge is backing off. */
142 state,
143
144 /**
145 * The mod's switches from the latest board, looked up again when the last one is older than 30 s (a clock that went
146 * backwards counts as older). The defaults when there is no board to read. Never throws.
147 */
148 async prefs(): Promise<Prefs> {
149 try {
150 const now = await io.now()
151 if (cached && now - cached.at >= 0 && now - cached.at < PREFS_TTL_MS) return cached.value
152 // `state` refreshes the cache itself; with no board the switches are the defaults, and nothing is cached.
153 if (!(await state())) return DEFAULT_PREFS
154 return cached?.value ?? DEFAULT_PREFS
155 } catch {
156 return DEFAULT_PREFS
157 }
158 },
159
160 /** True after the widget answered 400 for that session: stop sending it anything. */
161 stopped(sessionId: string): boolean {
162 return stoppedSessions.has(sessionId)
163 },
164 }
165}
166
167export type Bridge = ReturnType<typeof createBridge>
168
169let shared: Bridge | undefined
170
171/**
172 * The one bridge of this module instance, so the report hooks and the pane share one backoff and one stopped list.
173 * The first caller's `io` wins; later calls get the same bridge. Tests inject their own instead.
174 */
175export function sharedBridge(io: BridgeIo): Bridge {
176 return (shared ??= createBridge(io))
177}
178hooks/sprites.ts 167 lines1// Clawd for the terminal: 16 x 16 pixels, drawn in the palette of the widget's pixel model (`PIXEL_PAL.clawd`
2// in app/src/renderer/models/pixel.ts) and packed two pixel rows to a terminal row.
3export type PetState = 'idle' | 'thinking' | 'working' | 'waiting' | 'done' | 'error' | 'sleeping'
4
5export const COLS = 16
6export const ROWS = 8
7const PIXEL_ROWS = ROWS * 2
8
9const PALETTE: Record<string, number> = {
10 k: 0x2b1d16,
11 m: 0xd97757,
12 s: 0xb25d3d,
13 h: 0xf2ae92,
14 e: 0x1e1410,
15 w: 0xffffff,
16 x: 0xf0997b,
17}
18// The terminal's own colour, for a pixel that is not drawn.
19const DEFAULT = 0x01000000
20
21// The body: 12 wide, 8 tall, corners cut, a highlight row on top and a shade row below.
22const SHELL = [
23 '...kkkkkkkkkk...',
24 '..kmhhhhhhhhmk..',
25 '..kmmmmmmmmmmk..',
26 '..kmmmmmmmmmmk..',
27 '..kmmmmmmmmmmk..',
28 '..kmxmmmmmmxmk..',
29 '..kssssssssssk..',
30 '...kkkkkkkkkk...',
31]
32const TOP = 6
33const EYE_COLS = [5, 10]
34const LEG_COLS = [4, 6, 9, 11]
35// A glyph is rows of palette letters, '.' being nothing.
36const QUESTION = ['mmm', '..m', '.m.', '...', '.m.']
37const BANG = ['s', 's', 's', '.', 's']
38const ZED = ['mmm', '.m.', 'mmm']
39
40type Eyes = 'open' | 'up' | 'closed' | 'cross' | 'happy'
41type Pose = {
42 dy?: number
43 eyes?: Eyes
44 mouth?: boolean
45 // Where an arm hangs: -2 raised high, -1 raised, 0 level with the eyes, 1 low.
46 armL?: number
47 armR?: number
48 glyphs?: [row: number, col: number, rows: string[]][]
49}
50
51const stamp = (grid: string[][], row: number, col: number, rows: string[]) =>
52 rows.forEach((line, i) =>
53 Array.from(line).forEach((ch, j) => {
54 if (ch !== '.' && grid[row + i]?.[col + j] !== undefined) grid[row + i]![col + j] = ch
55 }),
56 )
57
58const eyePixels = (eyes: Eyes, col: number): [row: number, col: number][] => {
59 switch (eyes) {
60 case 'open':
61 return [[3, col], [4, col]]
62 case 'up':
63 return [[2, col], [3, col]]
64 case 'closed':
65 return col > 7 ? [[4, col], [4, col + 1]] : [[4, col - 1], [4, col]]
66 case 'cross':
67 return [[2, col - 1], [2, col + 1], [3, col], [4, col - 1], [4, col + 1]]
68 case 'happy':
69 return [[3, col], [4, col - 1], [4, col + 1]]
70 }
71}
72
73function pose({ dy = 0, eyes = 'open', mouth = true, armL = 0, armR = 0, glyphs = [] }: Pose): string[] {
74 const grid = Array.from({ length: PIXEL_ROWS }, () => Array<string>(COLS).fill('.'))
75 const top = TOP + dy
76 // legs first: the body hides their tops
77 for (const col of LEG_COLS) for (let row = top + SHELL.length; row < PIXEL_ROWS; row++) grid[row]![col] = 's'
78 stamp(grid, top, 0, SHELL)
79 const arm = (outer: number, inner: number, cap: number, hang: number) => {
80 const at = top + 3 + hang
81 stamp(grid, at, outer, ['k'])
82 stamp(grid, at + 1, outer, ['k'])
83 stamp(grid, at, inner, ['m'])
84 stamp(grid, at + 1, inner, ['m'])
85 stamp(grid, at - 1, cap, ['k'])
86 stamp(grid, at + 2, cap, ['k'])
87 }
88 arm(0, 1, 1, armL)
89 arm(15, 14, 14, armR)
90 for (const col of EYE_COLS) for (const [row, c] of eyePixels(eyes, col)) stamp(grid, top + row, c, ['e'])
91 if (mouth) stamp(grid, top + 5, 7, ['ee'])
92 for (const [row, col, rows] of glyphs) stamp(grid, row, col, rows)
93 return grid.map(row => row.join(''))
94}
95
96// One to three dots, a pixel apart, above the head.
97const dots = (n: number): [number, number, string[]][] => [[4, 9, ['m.m.m'.slice(0, 2 * n - 1)]]]
98
99export const FRAMES: Record<PetState, string[][]> = {
100 idle: [pose({}), pose({ dy: 1 }), pose({}), pose({ eyes: 'closed', mouth: false })],
101 thinking: [1, 2, 3, 2].map(n => pose({ eyes: 'up', mouth: false, glyphs: dots(n) })),
102 working: [
103 pose({ armL: -1, armR: 1 }),
104 pose({ armL: 0, armR: 0 }),
105 pose({ armL: 1, armR: -1 }),
106 pose({ armL: 0, armR: 0 }),
107 ],
108 waiting: [
109 pose({ glyphs: [[0, 7, QUESTION]] }),
110 pose({ dy: 1, glyphs: [[1, 7, QUESTION]] }),
111 ],
112 done: [-1, -2, -1, -2].map(armR => pose({ eyes: 'happy', mouth: false, armR })),
113 error: [
114 pose({ eyes: 'cross', mouth: false, glyphs: [[0, 7, BANG]] }),
115 pose({ eyes: 'cross', mouth: false, dy: 1 }),
116 ],
117 sleeping: [
118 pose({ dy: 1, eyes: 'closed', mouth: false, glyphs: [[2, 11, ZED]] }),
119 pose({ dy: 1, eyes: 'closed', mouth: false, glyphs: [[1, 12, ZED]] }),
120 pose({ dy: 0, eyes: 'closed', mouth: false, glyphs: [[0, 13, ZED]] }),
121 pose({ dy: 0, eyes: 'closed', mouth: false, glyphs: [[0, 13, ZED]] }),
122 ],
123}
124
125const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
126
127// Standard padded base64; written out so nothing depends on the runtime having `btoa` or `Buffer`.
128function base64(bytes: Uint8Array): string {
129 let out = ''
130 for (let i = 0; i < bytes.length; i += 3) {
131 const n = (bytes[i]! << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
132 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
133 out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
134 out += i + 2 < bytes.length ? B64[n & 63]! : '='
135 }
136 return out
137}
138
139const colourOf = (letter: string | undefined): number | undefined => (letter === undefined || letter === '.' ? undefined : PALETTE[letter])
140
141/**
142 * A frame as `RasterProps.cells`: per terminal cell `[code point, foreground, background]` as little-endian u32,
143 * two pixel rows to a cell. A cell shows its top pixel as the foreground of `▀` and its bottom pixel as the background;
144 * a half that is not drawn is the terminal's default colour, which is why a lone bottom pixel is a `▄` (the foreground of
145 * `▀` is the text colour, not transparent) and a cell with neither is a space.
146 */
147export function encode(frame: string[]): string {
148 const view = new DataView(new ArrayBuffer(COLS * ROWS * 12))
149 let at = 0
150 const put = (code: number, fg: number, bg: number) => {
151 view.setUint32(at, code, true)
152 view.setUint32(at + 4, fg, true)
153 view.setUint32(at + 8, bg, true)
154 at += 12
155 }
156 for (let row = 0; row < ROWS; row++) {
157 for (let col = 0; col < COLS; col++) {
158 const top = colourOf(frame[row * 2]?.[col])
159 const bottom = colourOf(frame[row * 2 + 1]?.[col])
160 if (top !== undefined) put(0x2580, top, bottom ?? DEFAULT)
161 else if (bottom !== undefined) put(0x2584, bottom, DEFAULT)
162 else put(0x20, DEFAULT, DEFAULT)
163 }
164 }
165 return base64(new Uint8Array(view.buffer))
166}
167types/index.d.ts 12 lines1// Agent Pets plugin contract. `pet` and `nudges` mirror the app's Settings → Apps switches, as the board
2// carries them (the app is the truth, these redraw whatever reads them); `mood` is what the pixel pet is doing and when its last event was.
3declare module 'claude-code' {
4 interface PluginState {
5 'agent-pets': {
6 pet: boolean
7 nudges: boolean
8 mood: { state: 'idle' | 'thinking' | 'working' | 'waiting' | 'done' | 'error' | 'sleeping'; at: number }
9 }
10 }
11}
12