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.19.1GPL-3.0updated 2026-10-22Marczelloo/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"> <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>

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

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, /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.
  • 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.

Linux (experimental)

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

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

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 9 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 244 lines
1import 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}
244
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 214 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' | '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}
214
hooks/commands.ts 133 lines
1import 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}
133
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