SLOPSHOPPER

agent-pets

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

newpanebandguardcommandtoast
★ 2v0.17.2GPL-3.0updated 2026-10-07Marczelloo/agent-pets/claude-plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-pets
│ ┃ Agent Pets ✕ › fix the failing auth test and add an audit log call │ ┃ Agent Pets widget not running │ ┃ This session ⏺ Read(src/auth.ts) │ ┃ 5h ███░░░░░░░ 31% ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pets │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent Pets
Agent Pets widget not running This session 5h ███░░░░░░░ 31%
README

<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 &quot;Your coding agents, alive on the Windows 11 taskbar&quot; 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>

Agent Pets

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">

Features

  • One pet per session, posed by what the agent does: thinking, editing, running a command, reading, searching, browsing, delegating, waiting for you, done, error, asleep.
  • Limits at a glance. 5-hour and weekly bars for Claude, Codex and Antigravity, task progress under each pet, a "+N" badge when the taskbar runs out of room. Claude limits come live from the Claude Code mod, with exact reset times; opt-in plan polling and the Claude app's own samples fill the gaps. See Agents.
  • A mod inside Claude Code. Live limits, a /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.
  • One click back to the session. The panel lists every session; Open brings back the Claude or Codex app, the editor, the terminal, or resumes the session in a new one.
  • Speech bubbles and subagents. See the question an agent asks or the command it runs; subagents show up as mini pets next to their parent.
  • Seven looks, two ways to move. Sticker, Sketch, Clean, Pixel art, Neon, Ink and Pastel, plus a Dynamic mode with anime-inspired scenes.
  • Statistics for fun. A podium of your projects, token and time counters, an agent race, an activity calendar and badges.
  • Your layout. Next to the tray, on the left, anywhere you drag them or in a floating window; any monitor, any size.
  • Private and light. Everything is read from local files and hooks; drawing pauses under full-screen apps and slows down on battery.

More in docs/features.md.

Every state

<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">

Seven looks

<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">

Dynamic motion

<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">

Install

  1. Download 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.
  2. The first-run wizard finds your agents and connects them, and lets you pick notifications, autostart and a look.
  3. Restart open agent sessions so they pick up the hooks.

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.

Supported agents

AgentPetStatusLimits
Claude CodeClawd✅ core5h and weekly
Codex, incl. Agent Router tasksKodek✅ core5h and weekly
opencodea near-black cyclops✅ coretokens, cost, account bars
GitHub Copilota pilot in a leather helmet🧪 experimental–
Antigravitythe Android robot🧪 experimentalGemini 5h and weekly
Cursora dark faceted block🧪 experimental–
Grok Builda white robot with a visor🧪 experimental–
ZCodea panda with a “Z” headband🧪 experimental–
Anything elsea 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.

PanelFirst-run wizardSettings
<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">

Privacy

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.

Code signing policy

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.

Documentation

License

Agent 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.

Source 8 files
hooks/register.tsx 13 lines
1import 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}
13
hooks/nudge.ts 140 lines
1import { 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}
140
hooks/pane.tsx 217 lines
1import 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}
217
hooks/pet.tsx 206 lines
1import { 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}
206
hooks/report.ts 133 lines
1import 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}
133
hooks/bridge.ts 178 lines
1import 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}
178
hooks/sprites.ts 167 lines
1// 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}
167
types/index.d.ts 12 lines
1// 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