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"> <img alt="Linux: experimental" src="https://img.shields.io/badge/Linux-experimental-E8B04B"> <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. There is also an experimental Linux version.
<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, /pets jump, mute, name, pin and clear to control the widget from the prompt, 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.
Linux support is experimental. Agent Pets is developed and tested day to day on Windows 11, and Windows is its main platform. The Linux version was tested before the release, in virtual machines with Ubuntu (GNOME), Fedora (KDE) and Linux Mint (Cinnamon), but not every change gets checked there. Expect rough edges, and please report what breaks.
.deb (Ubuntu, Mint, Debian), .rpm (Fedora, openSUSE) and .AppImage packages are on the same Releases page. The pets sit in a strip along the bottom of the screen (above the bottom panel when there is one, rather than inside it), at the bottom right or bottom left (Settings → Screen), or in a floating window, on X11 and on Wayland (through XWayland). For now: no auto-update (install the new package), the tray icon opens a menu on any click (its first item shows the panel) and on stock GNOME needs the AppIndicator extension (otherwise launch the app again to open Settings), and on GNOME/KDE Wayland the floating window catches clicks over its empty space. On Wayland the global keyboard shortcuts go through the desktop's GlobalShortcuts portal (KDE Plasma, GNOME 48 and newer), which asks you to confirm them once; on a desktop without it they may only fire while an Agent Pets window is active. If the pets show up blank or leave trails, start the app with WEBKIT_DISABLE_DMABUF_RENDERER=1 (or =0: by default the DMABUF renderer is turned off only on the NVIDIA driver).
| 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. Releases after 0.19.0 list the SHA-256 of every file in SHA256SUMS.txt and carry a build provenance attestation: gh attestation verify <file> --repo Marczelloo/agent-pets confirms the file was built by the release workflow from this repository. 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 244 lines1import type { EngineInterface as Api, On } from 'claude-code'
2import { sharedBridge } from './bridge'
3import type { Bridge, BridgeIo, Prefs } from './bridge'
4import { ARGUMENT_HINT, HELP, describe, parse } from './commands'
5
6const PANE = 'agent-pets'
7const BAR_CELLS = 10
8const DAY_MS = 24 * 60 * 60 * 1000
9
10/** `Pet: off · Nudges: on`; the switches live in the app's settings, the pane only tells them. */
11export const prefsLine = (p: Prefs): string =>
12 `Pet: ${p.pet ? 'on' : 'off'} · Nudges: ${p.nudges ? 'on' : 'off'} — change in Agent Pets → Settings → Apps`
13
14type LimitRow = { label: string; percent: number; resetsAt?: number | null; stale: boolean }
15
16const WINDOW_LABEL: Record<string, string> = { five_hour: '5h', weekly: 'week', seven_day: 'week', monthly: 'month', spend: 'spend', spend_limit: 'spend' }
17
18// Everything the widget sends ends up in a Text, and ultimately comes from the user's sessions: one tidy line each.
19// Control characters and line breaks become a space; bidi marks and zero-width characters (which could reorder
20// or hide what is drawn) go. Cut by code point so a surrogate pair is never split.
21export const tidy = (text: unknown, max = 160): string => {
22 const flat = String(text ?? '')
23 .replace(/[\u200b-\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff]/g, '')
24 .replace(/[\u0000-\u001f\u007f\u0080-\u009f\s]+/g, ' ')
25 .trim()
26 const points = Array.from(flat)
27 return points.length > max ? `${points.slice(0, max - 1).join('')}\u2026` : flat
28}
29
30const clampPercent = (v: number) => Math.min(100, Math.max(0, Number.isFinite(v) ? v : 0))
31
32/** `██████░░░░`: one cell per ten percent, rounded. */
33export function bar(percent: number): string {
34 const filled = Math.round(clampPercent(percent) / (100 / BAR_CELLS))
35 return '█'.repeat(filled) + '░'.repeat(BAR_CELLS - filled)
36}
37
38/** 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. */
39export function resetText(resetsAt: number, now: number): string {
40 try {
41 const at = new Date(resetsAt)
42 const time = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(at)
43 if (resetsAt - now < DAY_MS) return time
44 return `${new Intl.DateTimeFormat(undefined, { weekday: 'short' }).format(at)} ${time}`
45 } catch {
46 return ''
47 }
48}
49
50/** `5h ██████░░░░ 23% · resets 18:00` */
51export function limitLine(row: LimitRow, now: number): string {
52 const parts = [`${String(row.label ?? '').padEnd(5)} ${bar(row.percent)} ${Math.round(clampPercent(row.percent))}%`]
53 const reset = typeof row.resetsAt === 'number' ? resetText(row.resetsAt, now) : ''
54 if (reset) parts.push(`resets ${reset}`)
55 if (row.stale) parts.push('stale')
56 return parts.join(' · ')
57}
58
59type SessionRow = { agent: string; state: string; title: string; question: string }
60type BoardView = { sessions: SessionRow[]; limits: (LimitRow & { agent: string })[] }
61
62const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
63
64/**
65 * The widget's board as rows that are safe to draw: arrays that are not arrays become empty, entries that are not
66 * objects are skipped, every string is tidied (agents are grouped by their tidied name) and a percentage that is not a number is 0.
67 */
68export function viewOf(board: unknown): BoardView | null {
69 if (!isObject(board)) return null
70 const view: BoardView = { sessions: [], limits: [] }
71 for (const raw of Array.isArray(board.sessions) ? board.sessions : []) {
72 if (!isObject(raw)) continue
73 view.sessions.push({ agent: tidy(raw.agent, 40), state: tidy(raw.state, 24), title: tidy(raw.title, 80), question: tidy(raw.question) })
74 }
75 for (const raw of Array.isArray(board.limits) ? board.limits : []) {
76 if (!isObject(raw)) continue
77 const window = tidy(raw.window, 12)
78 const percent = typeof raw.used_pct === 'number' && Number.isFinite(raw.used_pct) ? raw.used_pct : 0
79 view.limits.push({
80 agent: tidy(raw.agent, 40),
81 label: WINDOW_LABEL[window] ?? window,
82 percent,
83 resetsAt: typeof raw.resets_at === 'number' ? raw.resets_at : null,
84 stale: typeof raw.stale_since === 'number',
85 })
86 }
87 return view
88}
89
90// The agents in the order the board names them: sessions first, then agents that only have limits.
91const agentsOf = (view: BoardView): string[] => [...new Set([...view.sessions.map(s => s.agent), ...view.limits.map(l => l.agent)])]
92
93// This session's own windows, from the engine; what the pane can still show without the widget.
94async function sessionLimits($: Api): Promise<LimitRow[]> {
95 try {
96 const { rateLimits } = await $.session.usage()
97 return rateLimits.map(l => {
98 const at = l.resetsAt ? Date.parse(l.resetsAt) : NaN
99 return { label: WINDOW_LABEL[l.kind] ?? tidy(l.kind, 12), percent: l.percentUsed, resetsAt: Number.isFinite(at) ? at : null, stale: false }
100 })
101 } catch {
102 return []
103 }
104}
105
106// 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,
107// and `$.env.get` takes a literal name: both are why this lives here and not in bridge.ts (report.ts has its own).
108function ioOf($: Api): BridgeIo {
109 return {
110 pluginRoot: $.plugin.root,
111 home: async () => (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')),
112 read: path => $.fs.read(path),
113 exists: path => $.fs.exists(path),
114 now: () => $.clock.now(),
115 fetch: (url, init) => $.http.fetch(url, init),
116 }
117}
118
119async function registerCommand($: Api, bridge?: Bridge): Promise<void> {
120 // The app's own copy has the command; a second /pets would only ever say "widget not running".
121 if (await (bridge ?? sharedBridge(ioOf($))).isDuplicateCopy()) return
122 // immediate: `/pets jump` or `/pets mute` is wanted while a turn is still running, not after it
123 await $.command.register({ name: 'pets', description: 'Agent Pets: sessions and limits, jump, mute and more', argumentHint: ARGUMENT_HINT, immediate: true })
124}
125
126/**
127 * `/pets` (the terminal, the desktop app, VS Code, the phone): a pane with the widget's sessions and limits, and where the pet and nudges are switched.
128 * `/pets <command>` controls the widget instead (`commands.ts`).
129 * Without the widget it shows this session's own limits and says so. Reads through `bridge`, or the shared one.
130 */
131export function registerPane(on: On, bridge?: Bridge): void {
132 // A matcher: the engine takes one unmatched hook per event and plugin, and report.ts has that one.
133 on('session.start', { surface: 'terminal', isInteractive: true }, async ($, e, next) => {
134 const r = await next(e)
135 try { await registerCommand($, bridge) } catch {}
136 return r
137 })
138 // The desktop app, VS Code and the phone start the session without a surface and join it later;
139 // registering again only replaces the command.
140 on('session.attach', { surface: ['desktop', 'vscode', 'mobile'] }, async ($, e, next) => {
141 const r = await next(e)
142 try { await registerCommand($, bridge) } catch {}
143 return r
144 })
145
146 on('command.run', { command: 'pets' }, async ($, e, next) => {
147 const b = bridge ?? sharedBridge(ioOf($))
148 if (await b.isDuplicateCopy()) return next(e)
149 const cmd = parse(e.args)
150 if (cmd.kind === 'help') return { text: HELP }
151 if (cmd.kind === 'usage') return { text: cmd.text }
152 if (cmd.kind === 'action') {
153 try {
154 const sent = cmd.action(await $.session.id())
155 return { text: describe(sent, await b.action(sent), await $.clock.now(), t => tidy(t, 80)) }
156 } catch {
157 return { text: 'Agent Pets widget not running' }
158 }
159 }
160 try {
161 const { isPlaced } = await $.ui.open({ id: PANE, title: 'Agent Pets', focus: true, closeOnEscape: true })
162 // a surface that draws no panes still takes the commands
163 if (!isPlaced) return { text: `The Agent Pets pane does not show here; the commands do:
164${HELP}` }
165 } catch {}
166 return {}
167 })
168
169 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
170 if (await (bridge ?? sharedBridge(ioOf($))).isDuplicateCopy()) return next(e)
171 const { Box, Text } = $.ui.resolve(e)
172 // A pane that cannot gather its data still draws: no board means the widget-not-running view.
173 let now = Date.now()
174 let view: BoardView | null = null
175 try {
176 now = await $.clock.now()
177 view = viewOf(await (bridge ?? sharedBridge(ioOf($))).state())
178 } catch {
179 view = null
180 }
181 const own = view ? [] : await sessionLimits($)
182 // The board that was just read refreshed the bridge's switches; without a board there is nothing to tell.
183 const settings = view ? await (bridge ?? sharedBridge(ioOf($))).prefs() : undefined
184
185 if (!view) {
186 return (
187 <Box flexDirection="column">
188 <Text dimColor>Agent Pets widget not running</Text>
189 {own.length > 0 && <Text bold>This session</Text>}
190 {own.map(row => (
191 <Text>{limitLine(row, now)}</Text>
192 ))}
193 </Box>
194 )
195 }
196
197 const board = view
198 const agents = agentsOf(board)
199 return (
200 <Box flexDirection="column">
201 <Text bold>Sessions</Text>
202 {board.sessions.length === 0 && <Text dimColor>No sessions</Text>}
203 {agents.map(agent => {
204 const sessions = board.sessions.filter(s => s.agent === agent)
205 if (sessions.length === 0) return null
206 return (
207 <Box flexDirection="column">
208 <Text bold>{agent}</Text>
209 {sessions.map(s => (
210 <Box flexDirection="column">
211 <Text>
212 {' '}
213 {s.state.replace(/_/g, ' ')} {s.title}
214 </Text>
215 {s.state === 'needs_input' && s.question !== '' && <Text dimColor>{` ? ${s.question}`}</Text>}
216 </Box>
217 ))}
218 </Box>
219 )
220 })}
221 <Text bold>Limits</Text>
222 {board.limits.length === 0 && <Text dimColor>No limits reported yet</Text>}
223 {agents.map(agent => {
224 const rows = board.limits.filter(l => l.agent === agent)
225 if (rows.length === 0) return null
226 return (
227 <Box flexDirection="column">
228 <Text bold>{agent}</Text>
229 {rows.map(row => (
230 <Text>{` ${limitLine(row, now)}`}</Text>
231 ))}
232 </Box>
233 )
234 })}
235 {settings && (
236 <Text key="prefs" dimColor>
237 {prefsLine(settings)}
238 </Text>
239 )}
240 </Box>
241 )
242 })
243}
244hooks/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 214 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' | 'monthly'; 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 ACTIONS_PATH = '/v1/actions/claude-mod'
32const SKILLS_COPY = '/.claude/skills/agent-pets'
33
34/** A `/pets` command for the widget (`crates/pets-core/src/mod_action.rs`). */
35export type Action =
36 | { action: 'jump' | 'panel'; session_id: string }
37 | { action: 'mute'; choice: 'off' | 'hour' | 'morning' | 'forever' }
38 | { action: 'rename'; session_id: string; name: string | null }
39 | { action: 'pin'; session_id: string; pinned: boolean }
40 | { action: 'clear' }
41 | { action: 'prefs'; pet?: boolean; nudges?: boolean }
42
43/**
44 * The widget's answer. Besides its own errors (`not_found`, `bad_choice`, `save_failed`) the bridge adds `unsupported`
45 * (an app too old for commands), `busy` (it did not answer in time) and `rejected` (it did not understand the command).
46 */
47export type ActionReply = { ok: boolean; title?: string; agent?: string; until?: number; count?: number; error?: string }
48
49/** Forward slashes, no trailing slash. Compare with `.toLowerCase()` where case does not matter. */
50const normalise = (path: string) => path.replace(/\\/g, '/').replace(/\/+$/, '')
51
52type BridgeResponse = { status: number; ok: boolean; text: string }
53
54/** What counts when the board has no say: no widget, an older app without `prefs`, or a board that cannot be read. */
55export const DEFAULT_PREFS: Prefs = { pet: false, nudges: true }
56
57// A switch that is not a boolean falls back to its default, so one bad value cannot flip the other.
58const prefsOf = (board: Board): Prefs => {
59 const p: unknown = board.prefs
60 const raw = typeof p === 'object' && p !== null ? (p as Partial<Record<keyof Prefs, unknown>>) : {}
61 return {
62 pet: typeof raw.pet === 'boolean' ? raw.pet : DEFAULT_PREFS.pet,
63 nudges: typeof raw.nudges === 'boolean' ? raw.nudges : DEFAULT_PREFS.nudges,
64 }
65}
66
67/**
68 * The only door to the widget: its endpoint file, a bearer token and three failure rules.
69 * Nothing here throws to a caller; the token never leaves the Authorization header.
70 */
71export function createBridge(io: BridgeIo) {
72 let endpoint: Endpoint | null = null
73 let backoffUntil = 0
74 const stoppedSessions = new Set<string>()
75 let duplicate: { value: boolean; at: number } | undefined
76 let cached: { value: Prefs; at: number } | undefined
77
78 const home = async () => normalise((await io.home()) ?? '')
79
80 // Another copy of this plugin (the app's own, in the skills folder) already reports and draws: this one stays silent.
81 // The answer is kept for a minute, so a running session follows the app's Settings toggle without a restart. Never throws.
82 const isDuplicateCopy = async (): Promise<boolean> => {
83 try {
84 const now = await io.now()
85 if (duplicate && now - duplicate.at >= 0 && now - duplicate.at < DUPLICATE_TTL_MS) return duplicate.value
86 const value = await (async () => {
87 const base = await home()
88 if (!base) return false
89 const own = `${base}${SKILLS_COPY}`
90 if (normalise(io.pluginRoot).toLowerCase() === own.toLowerCase()) return false
91 return await io.exists(`${own}/.claude-plugin/plugin.json`)
92 })()
93 duplicate = { value, at: now }
94 return value
95 } catch {
96 return false
97 }
98 }
99
100 const readEndpoint = async (): Promise<Endpoint> => {
101 const text = await io.read(`${await home()}/.agent-pets/endpoint.json`)
102 const parsed: unknown = JSON.parse(text)
103 const { port, token } = (parsed ?? {}) as Partial<Endpoint>
104 if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535 || typeof token !== 'string' || token === '') {
105 throw new Error('bad endpoint file')
106 }
107 return { port, token }
108 }
109
110 // A 2xx or a 400 is an answer; anything else (or no answer) drops the endpoint and pauses every call.
111 // `answers`: more statuses that are an answer for this call, not a sign the widget is gone.
112 const request = async (path: string, init: { method: string; body?: string }, answers: number[] = []): Promise<BridgeResponse | null> => {
113 if (await isDuplicateCopy()) return null
114 const now = await io.now()
115 if (now < backoffUntil) return null
116 try {
117 endpoint ??= await readEndpoint()
118 const headers: Record<string, string> = { authorization: `Bearer ${endpoint.token}` }
119 if (init.body !== undefined) headers['content-type'] = 'application/json'
120 const res = await io.fetch(`http://127.0.0.1:${endpoint.port}${path}`, { method: init.method, headers, body: init.body })
121 if (res.ok || res.status === 400 || answers.includes(res.status)) return res
122 } catch {
123 // fall through to the backoff
124 }
125 endpoint = null
126 backoffUntil = now + BACKOFF_MS
127 return null
128 }
129
130 // The widget's board; null when it is unreachable or the bridge is backing off. A board refreshes the cached switches.
131 const state = async (): Promise<Board | null> => {
132 try {
133 const res = await request(STATE_PATH, { method: 'GET' })
134 if (!res?.ok) return null
135 const board = JSON.parse(res.text) as Board
136 if (!board || board.v !== 1) return null
137 cached = { value: prefsOf(board), at: await io.now() }
138 return board
139 } catch {
140 return null
141 }
142 }
143
144 return {
145 /** 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. */
146 isDuplicateCopy,
147
148 /** Fire-and-forget; never throws. */
149 send(payload: ModPayload): void {
150 if (stoppedSessions.has(payload.session_id)) return
151 request(EVENTS_PATH, { method: 'POST', body: JSON.stringify(payload) })
152 .then(res => {
153 if (res?.status === 400) stoppedSessions.add(payload.session_id)
154 })
155 .catch(() => {})
156 },
157
158 /** The widget's board; null when it is unreachable or the bridge is backing off. */
159 state,
160
161 /**
162 * The mod's switches from the latest board, looked up again when the last one is older than 30 s (a clock that went
163 * backwards counts as older). The defaults when there is no board to read. Never throws.
164 */
165 async prefs(): Promise<Prefs> {
166 try {
167 const now = await io.now()
168 if (cached && now - cached.at >= 0 && now - cached.at < PREFS_TTL_MS) return cached.value
169 // `state` refreshes the cache itself; with no board the switches are the defaults, and nothing is cached.
170 if (!(await state())) return DEFAULT_PREFS
171 return cached?.value ?? DEFAULT_PREFS
172 } catch {
173 return DEFAULT_PREFS
174 }
175 },
176
177 /** Carries out a command in the widget; null when it is unreachable or the bridge is backing off. Never throws. */
178 async action(body: Action): Promise<ActionReply | null> {
179 try {
180 // an older app has no such route (404): that says nothing about the widget, so no backoff either
181 const res = await request(ACTIONS_PATH, { method: 'POST', body: JSON.stringify({ v: 1, ...body }) }, [404, 503])
182 if (!res) return null
183 if (res.status === 404) return { ok: false, error: 'unsupported' }
184 if (res.status === 503) return { ok: false, error: 'busy' }
185 if (!res.ok) return { ok: false, error: 'rejected' }
186 const reply: unknown = JSON.parse(res.text)
187 if (typeof reply !== 'object' || reply === null || typeof (reply as ActionReply).ok !== 'boolean') return { ok: false, error: 'rejected' }
188 // a switch just changed: the next look reads the board instead of the cached switches
189 if (body.action === 'prefs') cached = undefined
190 return reply as ActionReply
191 } catch {
192 return { ok: false, error: 'rejected' }
193 }
194 },
195
196 /** True after the widget answered 400 for that session: stop sending it anything. */
197 stopped(sessionId: string): boolean {
198 return stoppedSessions.has(sessionId)
199 },
200 }
201}
202
203export type Bridge = ReturnType<typeof createBridge>
204
205let shared: Bridge | undefined
206
207/**
208 * The one bridge of this module instance, so the report hooks and the pane share one backoff and one stopped list.
209 * The first caller's `io` wins; later calls get the same bridge. Tests inject their own instead.
210 */
211export function sharedBridge(io: BridgeIo): Bridge {
212 return (shared ??= createBridge(io))
213}
214hooks/commands.ts 133 lines1import type { Action, ActionReply } from './bridge'
2
3/** Shown dim after `/pets` in the typeahead. */
4export const ARGUMENT_HINT = '[jump | panel | mute [1h|8|forever] | unmute | name [text] | pin | unpin | clear | pet on|off | nudges on|off | help]'
5
6export const HELP = [
7 '/pets sessions and limits of every agent',
8 '/pets jump go to the agent that needs you (else the latest one that finished)',
9 '/pets panel open the Agent Pets panel on this session',
10 '/pets mute [1h|8|forever] mute notifications for an hour (default), until 8:00 or until unmuted',
11 '/pets unmute notifications back on',
12 '/pets name [text] name this session in Agent Pets; without text, back to the automatic name',
13 '/pets pin | unpin keep this session near the top of the panel',
14 '/pets clear remove the pets of finished and idle sessions',
15 '/pets pet on|off the pixel pet above the prompt',
16 '/pets nudges on|off nudges about other agents',
17].join('\n')
18
19/** What `/pets <args>` asks for: the pane, the help, a command for the widget, or a mistake to point out. */
20export type Command =
21 | { kind: 'pane' }
22 | { kind: 'help' }
23 | { kind: 'usage'; text: string }
24 | { kind: 'action'; action: (sessionId: string) => Action }
25
26const MUTE: Record<string, 'hour' | 'morning' | 'forever'> = {
27 '': 'hour', '1h': 'hour', hour: 'hour', '8': 'morning', '8:00': 'morning', morning: 'morning', forever: 'forever',
28}
29const SWITCH: Record<string, boolean> = { on: true, off: false }
30
31const action = (make: (sessionId: string) => Action): Command => ({ kind: 'action', action: make })
32const usage = (text: string): Command => ({ kind: 'usage', text: `${text} — /pets help lists the commands` })
33
34export function parse(args: string): Command {
35 const text = args.trim()
36 if (text === '') return { kind: 'pane' }
37 const [head = '', ...rest] = text.split(/\s+/)
38 const verb = head.toLowerCase()
39 const tail = text.slice(head.length).trim()
40 const arg = (rest[0] ?? '').toLowerCase()
41 switch (verb) {
42 case 'help':
43 case '?':
44 return { kind: 'help' }
45 case 'jump':
46 case 'next':
47 return action(id => ({ action: 'jump', session_id: id }))
48 case 'panel':
49 return action(id => ({ action: 'panel', session_id: id }))
50 case 'mute': {
51 const choice = MUTE[arg]
52 if (!choice || rest.length > 1) return usage('Mute for 1h, until 8 or forever')
53 return action(() => ({ action: 'mute', choice }))
54 }
55 case 'unmute':
56 return action(() => ({ action: 'mute', choice: 'off' }))
57 case 'name':
58 case 'rename':
59 return action(id => ({ action: 'rename', session_id: id, name: tail === '' ? null : tail }))
60 case 'pin':
61 case 'unpin':
62 return action(id => ({ action: 'pin', session_id: id, pinned: verb === 'pin' }))
63 case 'clear':
64 return action(() => ({ action: 'clear' }))
65 case 'pet':
66 case 'nudges': {
67 const on = SWITCH[arg]
68 if (on === undefined || rest.length > 1) return usage(`Say /pets ${verb} on or /pets ${verb} off`)
69 return action(() => (verb === 'pet' ? { action: 'prefs', pet: on } : { action: 'prefs', nudges: on }))
70 }
71 default:
72 return usage(`Unknown command "${head.slice(0, 24)}"`)
73 }
74}
75
76/** Beyond this the deadline is "until turned back on" (the app sends i64::MAX). */
77const FOREVER = 1e15
78const DAY_MS = 24 * 60 * 60 * 1000
79
80function clock(at: number, now: number): string {
81 try {
82 const d = new Date(at)
83 const time = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(d)
84 return at - now < DAY_MS ? time : `${new Intl.DateTimeFormat(undefined, { weekday: 'short' }).format(d)} ${time}`
85 } catch {
86 return ''
87 }
88}
89
90const FAILURE: Record<string, string> = {
91 unsupported: 'This version of Agent Pets takes no commands yet — update the app',
92 busy: 'Agent Pets did not answer in time',
93 not_found: 'Agent Pets does not know this session yet — try again after its first turn',
94 bad_choice: 'Agent Pets did not recognise that mute choice',
95 save_failed: 'Agent Pets could not save the setting',
96}
97
98/**
99 * The line `/pets <command>` prints for the widget's reply. `clean` tidies text that came from the widget
100 * (a session title) before it is printed.
101 */
102export function describe(sent: Action, reply: ActionReply | null, now: number, clean: (text: unknown) => string): string {
103 if (!reply) return 'Agent Pets widget not running'
104 if (!reply.ok) return FAILURE[reply.error ?? ''] ?? 'Agent Pets did not take that command'
105 switch (sent.action) {
106 case 'jump': {
107 const title = clean(reply.title)
108 if (title === '') return 'Nobody is waiting for you — opened the Agent Pets panel'
109 const agent = clean(reply.agent)
110 return `Jumping to ${title}${agent ? ` (${agent})` : ''}`
111 }
112 case 'panel':
113 return 'Opened the Agent Pets panel'
114 case 'mute': {
115 if (sent.choice === 'off' || typeof reply.until !== 'number') return 'Notifications back on'
116 if (reply.until >= FOREVER) return 'Notifications muted until you turn them back on (/pets unmute)'
117 const at = clock(reply.until, now)
118 return at ? `Notifications muted until ${at}` : 'Notifications muted'
119 }
120 case 'rename':
121 return sent.name === null ? 'This session has its automatic name again' : `Named this session "${clean(sent.name)}"`
122 case 'pin':
123 return sent.pinned ? 'Pinned this session' : 'Unpinned this session'
124 case 'clear': {
125 const n = typeof reply.count === 'number' ? reply.count : 0
126 return n === 0 ? 'No inactive pets to clear' : `Cleared ${n} inactive ${n === 1 ? 'pet' : 'pets'}`
127 }
128 case 'prefs':
129 if (sent.pet !== undefined) return `Pet above the prompt ${sent.pet ? 'on' : 'off'}`
130 return `Nudges ${sent.nudges ? 'on' : 'off'}`
131 }
132}
133hooks/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