Clawd, a pixel pet that reacts to what Claude Code does: grows with context, tires with rate limits, levels up with hats.


A pixel pet for Claude Code. Clawd lives above your prompt and shows what the agent is really doing: reading, editing, running a command, waiting for you, listening as you type, failing, finishing. When Claude launches subagents, each one joins him as a little Clawd in a flower meadow. He gets rounder as your context fills up, tires as you near the rate limit, and earns hats as you work.
Unofficial. Clawd is a fan project. It is not affiliated with, endorsed by or supported by Anthropic. "Claude" and "Claude Code" are trademarks of Anthropic.
In Claude Code:
/plugin marketplace add kreddevils18/claude-clawd
/plugin install clawd@claude-clawd
Clawd shows up right away. Run /clawd to open the full-size pane.
Needs a Claude Code build with function hooks (mods). Tested with Claude Code 2.1.288. The mod API is early access and may change between releases; only hooks/register.tsx talks to it, so fixes stay small.
The band: Clawd and what he is doing, above the prompt. The logo follows his state (arms, legs, eyes and size all change, and it animates while he works). It hides itself when the terminal is narrower than 60 columns.
▐▛███▜▌ Clawd · Lv 4 · Directing 3 agents… ▐▛███▜▌ ▐▛███▜▌ ▐▛███▜▌
▝▜█████▛▘ 3 agents working ▝▜█████▛▘♥▝▜█████▛▘♥…
▘▘ ▝▝ ? ✿,❀ ▘▘"✾ ▝▝,❁"✿,❀"✾
Subagents. When Claude launches subagents, each one appears as a small Clawd on the right edge, standing in a field of colorful flowers and playing with the others (hearts drift between them). Each little Clawd shows what that subagent is doing (thinking, reading, editing, running a command, failing). When one finishes it celebrates for a few seconds, then leaves the meadow. The main Clawd, on the left, conducts with both arms and says "Directing N agents…".
The pane (/clawd): a 50×8-cell picture with props, speech bubbles, hats and particles. It is a true pixel raster in the terminal. On the desktop app and in the editor, where there is no raster element, the band and the pane are drawn as crisp vector art, with a painted meadow for the subagents.
Known limit. Claude Code raises no event when you answer a permission dialog, so "Waiting for you" stays up until the approved tool finishes.
When several apply at once, the one nearest the top wins.
| State | When | Sound |
|---|---|---|
| wait | A permission prompt or question is open | pip |
| fail | A tool call just failed | |
| limit-hit | The 5-hour limit is at 100% | |
| burp | The context was just compacted | burp |
| level-up | Clawd just gained a level | |
| pat | You ran /clawd pat | |
| done | A turn just finished | |
| edit | Claude is editing or writing files | |
| bash | Claude is running a command | |
| read | Claude is reading, grepping or globbing | |
| think | The model is thinking, or using another tool (with subagents running: "Directing N agents…") | |
| listening | You are typing in the prompt box | |
| limit-warn | The 5-hour limit is at 80% or more | |
| sleep | Idle for a minute | |
| idle | Anything else (blinks every 4 s) |
Every state, every context size and every hat is in the gallery (open the file in a browser).
Context size. Clawd is lean under 50% context, round with rosy cheeks up to 80%, and stuffed and sweating above that.
Levels and hats. Each finished turn earns 10 XP and each successful tool call 1 (30 at most per turn). Level = ⌊√(XP ÷ 25)⌋ + 1. Hats unlock at level 3 (beanie), 6 (crown) and 10 (wizard).
| Command | Does | ||||
|---|---|---|---|---|---|
/clawd | Open the full pane | ||||
/clawd pat | Pat Clawd | ||||
| `/clawd hat <beanie\ | crown\ | wizard\ | auto\ | none>` | Choose an unlocked hat (auto wears the best one) |
/clawd mute · /clawd unmute | Sounds off or on (remembered) | ||||
/clawd hide · /clawd show | Hide or show the band (remembered) | ||||
/clawd stats | Level, XP to the next level, hats |
Clawd runs entirely on your machine. It has no runtime dependencies, makes no network requests, makes no model calls, starts no processes of its own (the two sounds play through Claude Code's own audio player), and writes nothing except its own small profile (XP, level, hat, two switches) in Claude Code's per-plugin store. Everything it knows comes from events Claude Code already raises. It notices that you are typing, never what you type. The adapter lists the only calls it makes; claude plugin validate .claude-plugin/plugin.json prints them.
New states are the most welcome contribution, and adding one touches only one new file and one registry line. Start with CONTRIBUTING.md; how the pieces fit is in docs/architecture.md.
The sounds are synthesized with ffmpeg and released under CC0 (see sounds/README.md). The code is MIT.
hooks/register.tsx 397 lines1// Adapter: the only file that talks to Claude Code. Everything under src/ is pure TypeScript.
2//
3// Claude Code events ──► emit() ──► Signals in $.state ──► band / pane render hooks
4// │
5// worthStoring + sound edge
6import { atom, read } from 'claude-code'
7import type { Elements, EngineInterface, Register, Timer } from 'claude-code'
8
9import type { Profile } from '../types/index.d.ts'
10import { hatProblem, parseCommand, statsText, USAGE } from '../src/core/commands.ts'
11import { worthStoring } from '../src/core/redraw-policy.ts'
12import { reduce } from '../src/core/signal-store.ts'
13import { usageEvent } from '../src/core/usage-event.ts'
14import {
15 initialSignals,
16 isAskingTool,
17 toolKindOf,
18 withDefaults,
19 type SignalEvent,
20} from '../src/core/signals.ts'
21import { selectState } from '../src/core/state-registry.ts'
22import { viewAt } from '../src/core/view.ts'
23import { DEFAULT_PROFILE, applyTurnXp, parseProfile } from '../src/progression/xp.ts'
24import { agentScenes } from '../src/agents/agent-view.ts'
25import { bandSvg } from '../src/hud/band-svg.ts'
26import { MIN_BAND_COLUMNS, bandModel } from '../src/hud/band-model.ts'
27import { paneLines, terminalBand } from '../src/hud/band-terminal.ts'
28import type { Span } from '../src/hud/span.ts'
29import { meadowSvg } from '../src/scene/meadow-svg.ts'
30import { fullRasterRenderer } from '../src/sprite/renderers/full-raster-renderer.ts'
31import { miniQuadrantRenderer } from '../src/sprite/renderers/mini-quadrant-renderer.ts'
32import { spriteSvg } from '../src/sprite/renderers/svg-renderer.ts'
33
34const SIGNALS = { plugin: 'clawd', key: 'signals' } as const
35const PROFILE = { plugin: 'clawd', key: 'profile' } as const
36const PANE_STATE = { plugin: 'clawd', key: 'pane' } as const
37// Render hooks read through atoms (they subscribe the drawing); each atom spells its reference
38// out in full, and the SIGNALS/PROFILE/PANE_STATE consts serve $.state calls only.
39const signalsAtom = atom({ plugin: 'clawd', key: 'signals' } as const, initialSignals(0))
40const profileAtom = atom({ plugin: 'clawd', key: 'profile' } as const, DEFAULT_PROFILE)
41const paneAtom = atom({ plugin: 'clawd', key: 'pane' } as const, { isOpen: false })
42
43const PANE_ID = 'clawd'
44const RASTER_KEY = 'clawd'
45const TICK_MS = 250
46const PANE_COLUMNS = 50
47/** Animation steps of the scenery (flowers swaying, hearts rising) advance with this clock. */
48const tickOf = (now: number): number => Math.floor(now / TICK_MS)
49const PANE_FRAME_MS = 125 // 8 fps, the ceiling for blits
50const THEME = { background: 'default' } as const
51
52// Module variables are lost on a hot reload; everything that must survive lives in $.state / $.store.
53let paneTimer: Timer | null = null
54let lastPanePicture = ''
55let lastRemotePicture = ''
56let lastBandPicture = ''
57let toolsOkThisTurn = 0
58
59/** One row of styled text: the band and the pane both draw their terminal lines this way. */
60const spansText = (Text: Elements['terminal']['Text'], spans: readonly Span[]) => (
61 <Text>
62 {spans.map(span => (
63 <Text color={span.color} backgroundColor={span.bg} bold={span.bold} dimColor={span.dim}>
64 {span.text}
65 </Text>
66 ))}
67 </Text>
68)
69
70/** Reduce one event into the shared snapshot, store it when it matters, play a sound on state entry. */
71async function emit($: EngineInterface, event: SignalEvent) {
72 for (let attempt = 0; attempt < 5; attempt++) {
73 const held = await $.state.get(SIGNALS)
74 const prev = withDefaults(held.value, event.at)
75 const next = reduce(prev, event)
76 if (!worthStoring(prev, next)) return
77 const written = await $.state.set(SIGNALS, next, { ifVersion: held.version })
78 if (!written.isSet) continue
79 const entered = selectState(next)
80 if (entered.sound && entered.id !== selectState(prev).id) {
81 if (!(await currentProfile($)).muted) {
82 // Fire and forget: a missing player must never disturb a turn.
83 $.audio.play({ asset: `sounds/${entered.sound}.wav` }).catch(() => undefined)
84 }
85 }
86 return
87 }
88 $.ui.log(`clawd: dropped a ${event.kind} event after repeated write conflicts`, { to: 'debug' })
89}
90
91async function currentProfile($: EngineInterface): Promise<Profile> {
92 // Never fall back to defaults: a later save would overwrite the saved profile with them.
93 return (await $.state.get(PROFILE)).value ?? parseProfile(await $.store.get('profile'))
94}
95
96/** Persist across sessions and tell the drawings. Written on turn.complete and commands only. */
97async function saveProfile($: EngineInterface, profile: Profile) {
98 await $.store.set('profile', profile)
99 await $.state.set(PROFILE, profile)
100}
101
102/** One animation step for the open pane: blit on a terminal, redraw on other surfaces. Skips unchanged pictures. */
103async function paintPane($: EngineInterface) {
104 const stored = (await $.state.get(SIGNALS)).value ?? initialSignals(0)
105 const view = viewAt(stored, await currentProfile($), await $.clock.now())
106 const surfaces = await $.session.surfaces()
107 if (surfaces.includes('terminal')) {
108 const picture = fullRasterRenderer.render(view.frame, THEME)
109 if (picture.cells !== lastPanePicture) {
110 const blitted = await $.ui.blit({ requestId: PANE_ID, key: RASTER_KEY, ...picture })
111 if (!blitted.deny) lastPanePicture = picture.cells
112 }
113 }
114 if (surfaces.some(surface => surface !== 'terminal')) {
115 const picture = spriteSvg(view.frame, { scale: 8 })
116 if (picture !== lastRemotePicture) {
117 lastRemotePicture = picture
118 $.ui.invalidate('ui.render')
119 }
120 }
121}
122
123/** The band follows the state's animation: ask for a redraw only when the picture really changed. */
124async function animateBand($: EngineInterface) {
125 const stored = (await $.state.get(SIGNALS)).value ?? initialSignals(0)
126 const view = viewAt(stored, await currentProfile($), await $.clock.now())
127 const now = await $.clock.now()
128 const picture = JSON.stringify([view.frame, agentScenes(view.signals, now).map(a => a.frame), tickOf(now) % 2])
129 if (picture === lastBandPicture) return
130 lastBandPicture = picture
131 $.ui.invalidate('ui.render')
132}
133
134function stopPaneLoop() {
135 paneTimer?.cancel()
136 paneTimer = null
137 lastPanePicture = ''
138 lastRemotePicture = ''
139}
140
141function startPaneLoop($: EngineInterface) {
142 stopPaneLoop()
143 paneTimer = $.clock.every(PANE_FRAME_MS, () => paintPane($))
144}
145
146async function openPane($: EngineInterface) {
147 const opened = await $.ui.open({ id: PANE_ID, title: 'Clawd', rows: 11, columns: 52 })
148 // A pane that waits undrawn (opened unasked on a narrow terminal) has nothing to animate.
149 await $.state.set(PANE_STATE, { isOpen: opened.isPlaced })
150 if (opened.isPlaced) startPaneLoop($)
151}
152
153export const register: Register = on => {
154 on('session.start', async ($, e, next) => {
155 // The command and the clock come first: nothing below may keep them from existing.
156 await $.command.register({
157 name: 'clawd',
158 description: 'Open Clawd’s pane, or: pat · hat · mute · hide · stats',
159 argumentHint: '[pat|hat <name>|mute|unmute|hide|show|stats]',
160 })
161 $.clock.every(TICK_MS, async () => {
162 await emit($, { kind: 'tick', at: await $.clock.now() })
163 await animateBand($)
164 })
165
166 const profile = parseProfile(await $.store.get('profile'))
167 await $.state.set(PROFILE, profile)
168 const now = await $.clock.now()
169 await emit($, { kind: 'level.loaded', at: now, level: profile.level })
170 try {
171 await emit($, usageEvent(now, await $.session.usage()))
172 } catch {
173 // No reading yet: the first session.measure fills it in.
174 }
175 // A hot reload runs this again with the pane still open: pick its animation back up.
176 const panes = await $.ui.panes()
177 const isOpen = panes.some(pane => pane.id === PANE_ID)
178 await $.state.set(PANE_STATE, { isOpen })
179 if (isOpen) startPaneLoop($)
180 return next(e)
181 })
182
183 on('session.end', async (_$, e, next) => {
184 // /clear ends the session id but not the process or the pane.
185 if (e.reason !== 'clear') stopPaneLoop()
186 return next(e)
187 })
188
189 on('turn.start', async ($, e, next) => {
190 toolsOkThisTurn = 0
191 await emit($, { kind: 'turn.started', at: await $.clock.now() })
192 return next(e)
193 })
194
195 on('turn.step', async function* ($, e, next) {
196 await emit($, { kind: 'step.started', at: await $.clock.now() })
197 return yield* next(e)
198 })
199
200 on('tool.call', async ($, e, next) => {
201 if (e.agentId !== undefined) {
202 // A subagent's tool call moves that subagent's little Clawd, never the main one.
203 const id = e.agentId
204 const agentTool = toolKindOf(e.tool)
205 await emit($, { kind: 'agent.tool.started', at: await $.clock.now(), id, tool: agentTool })
206 let agentFailed = true
207 try {
208 const ran = await next(e)
209 agentFailed = 'isError' in ran && ran.isError === true
210 return ran
211 } finally {
212 await emit($, { kind: 'agent.tool.finished', at: await $.clock.now(), id, tool: agentTool, failed: agentFailed })
213 }
214 }
215 const tool = toolKindOf(e.tool)
216 await emit($, { kind: 'tool.started', at: await $.clock.now(), tool })
217 if (isAskingTool(e.tool)) {
218 await emit($, { kind: 'waiting.changed', at: await $.clock.now(), waiting: true })
219 }
220 let failed = true
221 try {
222 const ran = await next(e)
223 failed = 'isError' in ran && ran.isError === true
224 if ('result' in ran && !failed) toolsOkThisTurn += 1
225 return ran
226 } finally {
227 await emit($, { kind: 'tool.finished', at: await $.clock.now(), tool, failed })
228 }
229 })
230
231 on('classic.Notification', async ($, e, next) => {
232 if (e.notification_type === 'permission_prompt') {
233 await emit($, { kind: 'waiting.changed', at: await $.clock.now(), waiting: true })
234 }
235 return next(e)
236 })
237
238 on('prompt.submit', async ($, e, next) => {
239 const at = await $.clock.now()
240 await emit($, { kind: 'waiting.changed', at, waiting: false })
241 await emit($, { kind: 'typing.stopped', at })
242 return next(e)
243 })
244
245 // The person is typing: Clawd listens. Only the fact is used, never the text.
246 on('prompt.edit', async ($, e, next) => {
247 await emit($, { kind: 'typing', at: await $.clock.now() })
248 return next(e)
249 })
250
251 on('classic.SubagentStart', async ($, e, next) => {
252 await emit($, { kind: 'agent.started', at: await $.clock.now(), id: e.agent_id, name: e.agent_type })
253 return next(e)
254 })
255
256 on('classic.SubagentStop', async ($, e, next) => {
257 await emit($, { kind: 'agent.stopped', at: await $.clock.now(), id: e.agent_id })
258 return next(e)
259 })
260
261 on('turn.complete', async ($, e, next) => {
262 if (e.agentId !== undefined) {
263 // A subagent's turn ending is that subagent finishing, not the main agent.
264 await emit($, { kind: 'agent.stopped', at: await $.clock.now(), id: e.agentId })
265 return next(e)
266 }
267 const at = await $.clock.now()
268 // An interrupted turn, an error or a refusal is not a success to celebrate.
269 const succeeded = !e.isAborted && e.reason === 'answer'
270 await emit($, { kind: 'turn.completed', at, aborted: !succeeded })
271 if (succeeded) {
272 const earned = applyTurnXp(await currentProfile($), toolsOkThisTurn)
273 await saveProfile($, earned.profile)
274 if (earned.leveledUp) {
275 await emit($, { kind: 'leveled.up', at, level: earned.profile.level })
276 }
277 }
278 toolsOkThisTurn = 0
279 return next(e)
280 })
281
282 on('session.measure', async ($, e, next) => {
283 await emit($, usageEvent(await $.clock.now(), e))
284 return next(e)
285 })
286
287 on('session.compact', async ($, e, next) => {
288 const result = await next(e)
289 if (!('skip' in result && result.skip) && e.trigger !== 'precompute' && e.agentId === undefined) {
290 await emit($, { kind: 'compacted', at: await $.clock.now() })
291 }
292 return result
293 })
294
295 on('command.run', { command: 'clawd' }, async ($, e) => {
296 const command = parseCommand(e.args)
297 const profile = await currentProfile($)
298 switch (command.kind) {
299 case 'open':
300 await openPane($)
301 return { text: 'Clawd is here.' }
302 case 'pat':
303 await emit($, { kind: 'patted', at: await $.clock.now() })
304 return { text: 'You pat Clawd. ♥' }
305 case 'hat': {
306 const problem = hatProblem(profile, command.hat)
307 if (problem) return { text: problem }
308 await saveProfile($, { ...profile, hat: command.hat })
309 return { text: command.hat === 'none' ? 'Clawd goes bareheaded.' : `Clawd wears: ${command.hat}.` }
310 }
311 case 'mute':
312 case 'unmute':
313 await saveProfile($, { ...profile, muted: command.kind === 'mute' })
314 return { text: command.kind === 'mute' ? 'Clawd is quiet now.' : 'Sounds are back on.' }
315 case 'hide':
316 case 'show':
317 await saveProfile($, { ...profile, bandHidden: command.kind === 'hide' })
318 return { text: command.kind === 'hide' ? 'Band hidden. /clawd show brings it back.' : 'Band shown.' }
319 case 'stats':
320 return { text: statsText(profile) }
321 case 'help':
322 return { text: command.problem ? `${command.problem}\n${USAGE}` : USAGE }
323 }
324 })
325
326 on('ui.close', { id: PANE_ID }, async ($, e, next) => {
327 stopPaneLoop()
328 await $.state.set(PANE_STATE, { isOpen: false })
329 return next(e)
330 })
331
332 // Band: Clawd on the left, the subagents' meadow on the right edge. Redraws on a stored change
333 // or an animation step.
334 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
335 const profile = await read($, profileAtom)
336 const stored = await read($, signalsAtom)
337 // The band draws into a box that can be narrower than the window (a docked pane beside it).
338 const columns = e.props.bodyColumns ?? e.viewport?.columns ?? MIN_BAND_COLUMNS
339 if (e.props.hasSurvey || profile.bandHidden || columns < MIN_BAND_COLUMNS) return next(e)
340
341 const now = await $.clock.now()
342 const view = viewAt(stored, profile, now)
343 const mini = miniQuadrantRenderer.render(view.frame, THEME)
344 const model = bandModel(view.state, view.signals, profile, mini, now)
345
346 if (e.surface === 'terminal') {
347 const { Box, Text } = $.ui.resolve(e)
348 const rows = terminalBand(model, columns, tickOf(now))
349 return <Box flexDirection="column">{rows.map(spans => spansText(Text, spans))}</Box>
350 }
351 if (e.surface === 'desktop' || e.surface === 'vscode' || e.surface === 'mobile') {
352 const { Box, Svg } = $.ui.resolve(e)
353 const art = bandSvg(model, view.frame, tickOf(now))
354 // Clawd on the left, the subagents' meadow pushed to the right edge.
355 return (
356 <Box flexDirection="row" justifyContent="space-between" alignItems="flex-end">
357 <Svg source={art.main} alt={model.summary} />
358 {art.meadow !== null && <Svg source={art.meadow} alt={model.summary} />}
359 </Box>
360 )
361 }
362 return next(e)
363 })
364
365 // Full pane: a raster on the terminal, vector art on surfaces that have Svg.
366 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e, next) => {
367 const profile = await read($, profileAtom)
368 const stored = await read($, signalsAtom)
369 await read($, paneAtom)
370 const now = await $.clock.now()
371 const view = viewAt(stored, profile, now)
372 const mini = miniQuadrantRenderer.render(view.frame, THEME)
373 const model = bandModel(view.state, view.signals, profile, mini, now)
374
375 if (e.surface === 'terminal') {
376 const { Box, Text, Raster } = $.ui.resolve(e)
377 return (
378 <Box flexDirection="column">
379 <Raster key={RASTER_KEY} {...fullRasterRenderer.render(view.frame, THEME)} />
380 {paneLines(model, PANE_COLUMNS, tickOf(now)).map(spans => spansText(Text, spans))}
381 </Box>
382 )
383 }
384 if (e.surface === 'desktop' || e.surface === 'vscode' || e.surface === 'mobile') {
385 const { Box, Svg, Text } = $.ui.resolve(e)
386 return (
387 <Box flexDirection="column">
388 <Svg source={spriteSvg(view.frame, { scale: 8 })} alt={model.summary} />
389 <Text bold>{`${model.name} · Lv ${model.level} · ${model.label}`}</Text>
390 {model.agents.length > 0 && <Svg source={meadowSvg(model.agents, tickOf(now), model.summary)} alt={model.summary} />}
391 </Box>
392 )
393 }
394 return next(e)
395 })
396}
397types/index.d.ts 65 lines1// Type contract of the clawd plugin: the values it keeps in $.state.
2// Shared by hooks/register.tsx and by the pure code under src/.
3
4export type ToolKind = 'read' | 'edit' | 'bash' | 'other'
5
6/** One snapshot of everything Clawd reacts to. States only ever read this. */
7export type Signals = {
8 /** Milliseconds since the epoch, as of the last event or tick. */
9 now: number
10 turn: 'idle' | 'thinking' | 'tool'
11 tool: ToolKind | null
12 /** Main-loop tool calls running right now (read-only tools can run in parallel). */
13 toolsInFlight: number
14 lastTool: { kind: ToolKind; failed: boolean; at: number } | null
15 /** A permission prompt or an AskUserQuestion is open. */
16 waitingForUser: boolean
17 turnCompletedAt: number | null
18 idleSince: number
19 /** 0..100: sets Clawd's size (lean, round, stuffed). */
20 contextPct: number
21 /** Percent of the 5-hour limit used; null off a subscription. Drives the tired states. */
22 fiveHourPct: number | null
23 compactedAt: number | null
24 pattedAt: number | null
25 leveledUpAt: number | null
26 level: number
27 /** The person is typing in the prompt box (set by each edit, cleared on submit). */
28 typingAt: number | null
29 /** Subagents of the main agent, running or lately finished. */
30 agents: AgentInfo[]
31}
32
33/** One subagent as Clawd sees it: enough to pick its state and its pose. */
34export type AgentInfo = {
35 id: string
36 /** Its type, such as "Explore"; "agent" when only its tool calls were seen. */
37 name: string
38 startedAt: number
39 /** When it last did something, to retire one that never reported its end. */
40 activeAt: number
41 endedAt: number | null
42 tool: ToolKind | null
43 toolsInFlight: number
44 lastTool: { kind: ToolKind; failed: boolean; at: number } | null
45}
46
47export type HatChoice = 'auto' | 'none' | 'beanie' | 'crown' | 'wizard'
48
49/** What persists across sessions ($.store key `profile`). */
50export type Profile = {
51 xp: number
52 level: number
53 hat: HatChoice
54 muted: boolean
55 bandHidden: boolean
56}
57
58export type PaneState = { isOpen: boolean }
59
60declare module 'claude-code' {
61 interface PluginState {
62 clawd: { signals: Signals; profile: Profile; pane: PaneState }
63 }
64}
65src/core/commands.ts 70 lines1// The /clawd command: parsing and the words it answers with. The adapter does the acting.
2
3import type { HatChoice, Profile } from '../../types/index.d.ts'
4import { HAT_UNLOCKS, unlockedHats, xpToNextLevel, type Hat } from '../progression/xp.ts'
5
6export type ClawdCommand =
7 | { kind: 'open' }
8 | { kind: 'pat' }
9 | { kind: 'hat'; hat: HatChoice }
10 | { kind: 'mute' }
11 | { kind: 'unmute' }
12 | { kind: 'hide' }
13 | { kind: 'show' }
14 | { kind: 'stats' }
15 | { kind: 'help'; problem?: string }
16
17export const USAGE = [
18 '/clawd open Clawd’s pane',
19 '/clawd pat give Clawd a pat',
20 '/clawd hat <beanie|crown|wizard|auto|none>',
21 '/clawd mute|unmute sounds off or on',
22 '/clawd hide|show the band above the prompt',
23 '/clawd stats level, XP and hats',
24].join('\n')
25
26const HATS: readonly HatChoice[] = ['auto', 'none', 'beanie', 'crown', 'wizard']
27
28export const parseCommand = (args: string): ClawdCommand => {
29 const [word = '', arg] = args.trim().toLowerCase().split(/\s+/)
30 switch (word) {
31 case '':
32 return { kind: 'open' }
33 case 'pat':
34 case 'mute':
35 case 'unmute':
36 case 'hide':
37 case 'show':
38 case 'stats':
39 return { kind: word }
40 case 'hat': {
41 const hat = HATS.find(h => h === arg)
42 return hat ? { kind: 'hat', hat } : { kind: 'help', problem: `Pick a hat: ${HATS.join(', ')}.` }
43 }
44 case 'help':
45 return { kind: 'help' }
46 default:
47 return { kind: 'help', problem: `Unknown option "${word}".` }
48 }
49}
50
51/** The reason a hat cannot be worn yet, or null when it can. */
52export const hatProblem = (profile: Profile, hat: HatChoice): string | null => {
53 if (hat === 'auto' || hat === 'none') return null
54 if (unlockedHats(profile.level).includes(hat)) return null
55 const unlockAt = HAT_UNLOCKS.find(([, name]) => name === hat)?.[0]
56 return `The ${hat} unlocks at Lv ${unlockAt}; Clawd is Lv ${profile.level}.`
57}
58
59export const statsText = (profile: Profile): string => {
60 const hats = unlockedHats(profile.level)
61 const lines = [
62 `Clawd · Lv ${profile.level} · ${profile.xp} XP (${xpToNextLevel(profile.xp)} to next level)`,
63 `Hats unlocked: ${hats.length ? hats.join(', ') : 'none yet'} · wearing: ${profile.hat}`,
64 `Sounds: ${profile.muted ? 'muted' : 'on'} · Band: ${profile.bandHidden ? 'hidden' : 'shown'}`,
65 ]
66 const next = HAT_UNLOCKS.find(([lvl]) => lvl > profile.level)
67 if (next) lines.push(`Next hat: ${next[1] as Hat} at Lv ${next[0]}`)
68 return lines.join('\n')
69}
70src/core/redraw-policy.ts 10 lines1// Decides when a new snapshot is worth storing: a store write redraws the band and the pane,
2// so clock-only changes must not trigger one unless they flip the active state.
3
4import { differsBeyondClock } from './signal-store.ts'
5import { selectState } from './state-registry.ts'
6import type { Signals } from './signals.ts'
7
8export const worthStoring = (prev: Signals, next: Signals): boolean =>
9 differsBeyondClock(prev, next) || selectState(prev).id !== selectState(next).id
10src/core/signal-store.ts 151 lines1// Observer: a pure reducer. The adapter feeds events in; states read the result.
2
3import {
4 AGENT_LINGER_MS,
5 AGENT_STALE_MS,
6 MAX_AGENTS,
7 type AgentInfo,
8 type SignalEvent,
9 type Signals,
10} from './signals.ts'
11
12const clampPct = (n: number): number => Math.max(0, Math.min(100, n))
13
14const newAgent = (id: string, name: string, at: number): AgentInfo => ({
15 id,
16 name,
17 startedAt: at,
18 activeAt: at,
19 endedAt: null,
20 tool: null,
21 toolsInFlight: 0,
22 lastTool: null,
23})
24
25/** Change one subagent, creating it first when only its tool calls were seen. */
26const withAgent = (agents: AgentInfo[], id: string, at: number, change: (a: AgentInfo) => AgentInfo): AgentInfo[] => {
27 const known = agents.some(a => a.id === id)
28 const list = known ? agents : [...agents, newAgent(id, 'agent', at)]
29 const updated = list.map(a => (a.id === id ? { ...change(a), activeAt: at } : a))
30 if (updated.length <= MAX_AGENTS) return updated
31 // Make room: drop the oldest finished agent, else the oldest.
32 const victim = updated.find(a => a.endedAt !== null) ?? updated[0]
33 return updated.filter(a => a !== victim)
34}
35
36/** Finished agents leave after a while; one that went silent without ending is retired too. */
37const retire = (agents: AgentInfo[], now: number): AgentInfo[] => {
38 const kept = agents.filter(a =>
39 a.endedAt !== null ? now - a.endedAt < AGENT_LINGER_MS : now - a.activeAt < AGENT_STALE_MS,
40 )
41 return kept.length === agents.length ? agents : kept
42}
43
44export const reduce = (s: Signals, e: SignalEvent): Signals => {
45 const now = Math.max(s.now, e.at)
46 const base: Signals = { ...s, now, agents: retire(s.agents, now) }
47 switch (e.kind) {
48 case 'turn.started':
49 return { ...base, turn: 'thinking', tool: null, toolsInFlight: 0, turnCompletedAt: null }
50 case 'step.started':
51 return base.turn === 'idle' ? base : { ...base, turn: 'thinking', tool: null }
52 case 'tool.started':
53 // Tool calls outside a turn (background runs) are counted but never move Clawd.
54 return {
55 ...base,
56 toolsInFlight: base.toolsInFlight + 1,
57 ...(base.turn === 'idle' ? {} : { turn: 'tool' as const, tool: e.tool }),
58 }
59 case 'tool.finished': {
60 const inFlight = Math.max(0, base.toolsInFlight - 1)
61 return {
62 ...base,
63 toolsInFlight: inFlight,
64 // Parallel tools: stay on "tool" until the last one ends; a live wait outlasts its siblings.
65 ...(base.turn === 'idle' ? {} : { turn: inFlight > 0 ? ('tool' as const) : ('thinking' as const) }),
66 tool: base.turn !== 'idle' && inFlight > 0 ? base.tool : null,
67 waitingForUser: inFlight > 0 ? base.waitingForUser : false,
68 lastTool: { kind: e.tool, failed: e.failed, at: e.at },
69 }
70 }
71 case 'waiting.changed':
72 return { ...base, waitingForUser: e.waiting }
73 case 'turn.completed':
74 return {
75 ...base,
76 turn: 'idle',
77 tool: null,
78 toolsInFlight: 0,
79 waitingForUser: false,
80 turnCompletedAt: e.aborted === true ? base.turnCompletedAt : e.at,
81 idleSince: e.at,
82 }
83 case 'usage.measured':
84 return {
85 ...base,
86 contextPct: clampPct(e.contextPct),
87 fiveHourPct: e.fiveHourPct === null ? null : Math.max(0, e.fiveHourPct),
88 }
89 case 'compacted':
90 return { ...base, compactedAt: e.at }
91 case 'patted':
92 return { ...base, pattedAt: e.at }
93 case 'leveled.up':
94 return { ...base, leveledUpAt: e.at, level: e.level }
95 case 'level.loaded':
96 return { ...base, level: e.level }
97 case 'typing':
98 return { ...base, typingAt: e.at }
99 case 'typing.stopped':
100 return { ...base, typingAt: null }
101 case 'agent.started':
102 return {
103 ...base,
104 agents: withAgent(
105 base.agents.filter(a => a.id !== e.id),
106 e.id,
107 e.at,
108 () => newAgent(e.id, e.name, e.at),
109 ),
110 }
111 case 'agent.tool.started':
112 return {
113 ...base,
114 agents: withAgent(base.agents, e.id, e.at, a => ({
115 ...a,
116 tool: e.tool,
117 toolsInFlight: a.toolsInFlight + 1,
118 })),
119 }
120 case 'agent.tool.finished':
121 return {
122 ...base,
123 agents: withAgent(base.agents, e.id, e.at, a => {
124 const inFlight = Math.max(0, a.toolsInFlight - 1)
125 return {
126 ...a,
127 toolsInFlight: inFlight,
128 tool: inFlight > 0 ? a.tool : null,
129 lastTool: { kind: e.tool, failed: e.failed, at: e.at },
130 }
131 }),
132 }
133 case 'agent.stopped':
134 return base.agents.some(a => a.id === e.id && a.endedAt === null)
135 ? {
136 ...base,
137 agents: withAgent(base.agents, e.id, e.at, a => ({ ...a, endedAt: e.at, tool: null, toolsInFlight: 0 })),
138 }
139 : base
140 case 'tick':
141 return base
142 }
143}
144
145/** True when two snapshots differ in anything but the clock: used to skip redraws. */
146export const differsBeyondClock = (a: Signals, b: Signals): boolean => {
147 const { now: _a, ...restA } = a
148 const { now: _b, ...restB } = b
149 return JSON.stringify(restA) !== JSON.stringify(restB)
150}
151src/core/usage-event.ts 17 lines1// Turns what Claude Code reports about usage into one SignalEvent. Structural types only:
2// no import from the engine, so this stays pure and testable.
3
4import type { SignalEvent } from './signals.ts'
5
6export type UsageReport = {
7 context: { percent?: number }
8 rateLimits: readonly { kind: string; percentUsed: number }[]
9}
10
11export const usageEvent = (at: number, usage: UsageReport): SignalEvent => ({
12 kind: 'usage.measured',
13 at,
14 contextPct: usage.context.percent ?? 0,
15 fiveHourPct: usage.rateLimits.find(limit => limit.kind === 'five_hour')?.percentUsed ?? null,
16})
17src/core/signals.ts 86 lines1// Signals: the one snapshot Clawd's states read, and the events that change it.
2// Pure TypeScript: no Claude Code imports, no clock, no I/O.
3
4import type { AgentInfo, Signals, ToolKind } from '../../types/index.d.ts'
5
6export type { AgentInfo, Signals, ToolKind }
7
8/** A finished subagent stays in the meadow this long, celebrating, before it leaves. */
9export const AGENT_LINGER_MS = 6000
10/** A subagent that never reported its end is retired after this long without activity. */
11export const AGENT_STALE_MS = 180_000
12/** The most subagents tracked at once; the oldest finished one makes room. */
13export const MAX_AGENTS = 8
14
15/** Every event carries `at`, the time it happened, so reducing stays pure. */
16export type SignalEvent =
17 | { kind: 'turn.started'; at: number }
18 | { kind: 'step.started'; at: number }
19 | { kind: 'tool.started'; at: number; tool: ToolKind }
20 | { kind: 'tool.finished'; at: number; tool: ToolKind; failed: boolean }
21 | { kind: 'waiting.changed'; at: number; waiting: boolean }
22 /** An interrupted turn goes idle quietly: no "done" celebration. */
23 | { kind: 'turn.completed'; at: number; aborted?: boolean }
24 | { kind: 'usage.measured'; at: number; contextPct: number; fiveHourPct: number | null }
25 | { kind: 'compacted'; at: number }
26 | { kind: 'patted'; at: number }
27 | { kind: 'leveled.up'; at: number; level: number }
28 /** The saved profile was read at session start: sets the level without a celebration. */
29 | { kind: 'level.loaded'; at: number; level: number }
30 /** The person edited the prompt box, or sent it (which ends the typing). */
31 | { kind: 'typing'; at: number }
32 | { kind: 'typing.stopped'; at: number }
33 | { kind: 'agent.started'; at: number; id: string; name: string }
34 | { kind: 'agent.tool.started'; at: number; id: string; tool: ToolKind }
35 | { kind: 'agent.tool.finished'; at: number; id: string; tool: ToolKind; failed: boolean }
36 | { kind: 'agent.stopped'; at: number; id: string }
37 | { kind: 'tick'; at: number }
38
39export const initialSignals = (now: number, level = 1): Signals => ({
40 now,
41 turn: 'idle',
42 tool: null,
43 toolsInFlight: 0,
44 lastTool: null,
45 waitingForUser: false,
46 turnCompletedAt: null,
47 idleSince: now,
48 contextPct: 0,
49 fiveHourPct: null,
50 compactedAt: null,
51 pattedAt: null,
52 leveledUpAt: null,
53 level,
54 typingAt: null,
55 agents: [],
56})
57
58/**
59 * A stored snapshot may come from an older version (state outlives a hot reload), so any field it
60 * lacks takes its starting value. Read every snapshot through this before using it.
61 */
62export const withDefaults = (stored: Partial<Signals> | null | undefined, now: number): Signals => ({
63 ...initialSignals(now),
64 ...stored,
65})
66
67/** Subagents still working. */
68export const agentsRunning = (s: Signals): number => s.agents.filter(a => a.endedAt === null).length
69
70/** The one table that maps Claude Code tool names to what Clawd does. */
71const TOOL_KIND_BY_NAME: Readonly<Record<string, ToolKind>> = Object.freeze({
72 Read: 'read',
73 Grep: 'read',
74 Glob: 'read',
75 NotebookRead: 'read',
76 Edit: 'edit',
77 Write: 'edit',
78 NotebookEdit: 'edit',
79 Bash: 'bash',
80})
81
82export const toolKindOf = (name: string): ToolKind => TOOL_KIND_BY_NAME[name] ?? 'other'
83
84/** Tools whose call means the model is asking the person something. */
85export const isAskingTool = (name: string): boolean => name === 'AskUserQuestion'
86src/core/state-registry.ts 16 lines1// Registry + priority: the highest-priority matching state wins; idle always matches.
2
3import type { ClawdState } from './clawd-state.ts'
4import type { Signals } from './signals.ts'
5import { STATES } from '../states/index.ts'
6
7const BY_PRIORITY: readonly ClawdState[] = [...STATES].sort((a, b) => b.priority - a.priority)
8
9export const allStates = (): readonly ClawdState[] => STATES
10
11export const selectState = (s: Signals): ClawdState => {
12 const found = BY_PRIORITY.find(state => state.matches(s))
13 if (!found) throw new Error('no Clawd state matches: the idle state must always match')
14 return found
15}
16src/core/view.ts 19 lines1// What to show right now: the active state and its decorated frame, for a given moment.
2
3import type { Profile, Signals } from '../../types/index.d.ts'
4import type { Frame } from '../sprite/frame.ts'
5import type { ClawdState } from './clawd-state.ts'
6import { composeFrame } from './compose.ts'
7import { withDefaults } from './signals.ts'
8import { selectState } from './state-registry.ts'
9
10export type View = { state: ClawdState; signals: Signals; frame: Frame }
11
12/** The stored snapshot's clock can be stale between redraws, so callers pass the real time. */
13export const viewAt = (stored: Partial<Signals>, profile: Profile, now: number): View => {
14 const complete = withDefaults(stored, now)
15 const signals = { ...complete, now: Math.max(complete.now, now) }
16 const state = selectState(signals)
17 return { state, signals, frame: composeFrame(state, signals, profile) }
18}
19src/progression/xp.ts 73 lines1// Progression: pure XP, level and hat rules. Persistence lives in the adapter.
2
3import type { HatChoice, Profile } from '../../types/index.d.ts'
4
5export type Hat = Exclude<HatChoice, 'auto' | 'none'>
6
7const XP_PER_TURN = 10
8const XP_PER_TOOL = 1
9export const XP_TURN_CAP = 30
10
11/** (level, hat) in unlock order. */
12export const HAT_UNLOCKS: readonly (readonly [number, Hat])[] = [
13 [3, 'beanie'],
14 [6, 'crown'],
15 [10, 'wizard'],
16]
17
18export const DEFAULT_PROFILE: Profile = Object.freeze({
19 xp: 0,
20 level: 1,
21 hat: 'auto',
22 muted: false,
23 bandHidden: false,
24})
25
26export const levelFor = (xp: number): number => Math.floor(Math.sqrt(Math.max(0, xp) / 25)) + 1
27
28/** XP at which `level` begins. */
29const xpForLevel = (level: number): number => 25 * (level - 1) ** 2
30
31export const xpToNextLevel = (xp: number): number => xpForLevel(levelFor(xp) + 1) - xp
32
33export const unlockedHats = (level: number): Hat[] =>
34 HAT_UNLOCKS.filter(([at]) => level >= at).map(([, hat]) => hat)
35
36/** XP granted for `amount` more earned in a turn that already earned `earnedThisTurn`, under the cap. */
37export const grantXp = (earnedThisTurn: number, amount: number): number =>
38 Math.max(0, Math.min(amount, XP_TURN_CAP - earnedThisTurn))
39
40/** The hat to draw: the player's pick when unlocked, else the highest unlocked, or none. */
41export const hatToWear = (profile: Profile): Hat | null => {
42 if (profile.hat === 'none') return null
43 const unlocked = unlockedHats(profile.level)
44 if (profile.hat !== 'auto' && unlocked.includes(profile.hat)) return profile.hat
45 return unlocked[unlocked.length - 1] ?? null
46}
47
48/** The profile after a completed turn that made `toolsOk` successful tool calls. */
49export const applyTurnXp = (
50 profile: Profile,
51 toolsOk: number,
52): { profile: Profile; gained: number; leveledUp: boolean } => {
53 const gained = grantXp(0, XP_PER_TURN + toolsOk * XP_PER_TOOL)
54 const xp = profile.xp + gained
55 const level = levelFor(xp)
56 return { profile: { ...profile, xp, level }, gained, leveledUp: level > profile.level }
57}
58
59/** Accepts whatever $.store held and returns a well-formed profile. */
60export const parseProfile = (raw: unknown): Profile => {
61 if (typeof raw !== 'object' || raw === null) return DEFAULT_PROFILE
62 const r = raw as Record<string, unknown>
63 const xp = typeof r.xp === 'number' && Number.isFinite(r.xp) && r.xp >= 0 ? Math.floor(r.xp) : 0
64 const hats = ['auto', 'none', 'beanie', 'crown', 'wizard']
65 return {
66 xp,
67 level: levelFor(xp),
68 hat: typeof r.hat === 'string' && hats.includes(r.hat) ? (r.hat as Profile['hat']) : 'auto',
69 muted: r.muted === true,
70 bandHidden: r.bandHidden === true,
71 }
72}
73src/agents/agent-view.ts 52 lines1// Subagents as little Clawds: each one's state comes from the same registry as the main agent's,
2// fed a snapshot built from what that subagent is doing.
3
4import type { AgentInfo, Signals } from '../../types/index.d.ts'
5import type { ClawdState } from '../core/clawd-state.ts'
6import { composeFrame } from '../core/compose.ts'
7import { initialSignals } from '../core/signals.ts'
8import { selectState } from '../core/state-registry.ts'
9import { DEFAULT_PROFILE } from '../progression/xp.ts'
10import type { Frame } from '../sprite/frame.ts'
11import { miniQuadrantRenderer, type MiniOutput } from '../sprite/renderers/mini-quadrant-renderer.ts'
12
13/** The snapshot one subagent's state is chosen from. */
14export const agentSignals = (a: AgentInfo, now: number): Signals => ({
15 ...initialSignals(now),
16 turn: a.endedAt !== null ? 'idle' : a.toolsInFlight > 0 ? 'tool' : 'thinking',
17 tool: a.toolsInFlight > 0 ? a.tool : null,
18 toolsInFlight: a.toolsInFlight,
19 lastTool: a.lastTool,
20 turnCompletedAt: a.endedAt,
21 idleSince: a.endedAt ?? a.startedAt,
22})
23
24export type AgentScene = {
25 id: string
26 name: string
27 state: ClawdState
28 frame: Frame
29 mini: MiniOutput
30 /** What it is doing, in a few words, for tooltips and alt text. */
31 label: string
32 /** Still working (not just lingering after its end). */
33 isRunning: boolean
34}
35
36/** The subagents to draw, oldest first, each posed for its own state. */
37export const agentScenes = (s: Signals, now: number): AgentScene[] =>
38 s.agents.map(a => {
39 const signals = agentSignals(a, now)
40 const state = selectState(signals)
41 const frame = composeFrame(state, signals, DEFAULT_PROFILE)
42 return {
43 id: a.id,
44 name: a.name,
45 state,
46 frame,
47 mini: miniQuadrantRenderer.render(frame, { background: 'default' }),
48 label: state.label(signals),
49 isRunning: a.endedAt === null,
50 }
51 })
52src/hud/band-svg.ts 53 lines1// The band for surfaces with an Svg element: Clawd, a title and status line, and on the right
2// edge the subagents' meadow. Drawn as vector art so it stays crisp at any scale.
3
4import { meadowSvg } from '../scene/meadow-svg.ts'
5import type { Frame } from '../sprite/frame.ts'
6import { SPRITE_CROP, escapeXml, spriteSvgInner } from '../sprite/renderers/svg-renderer.ts'
7import type { BandModel } from './band-model.ts'
8
9const LOGO_SCALE = 4
10const LOGO_W = SPRITE_CROP.w * LOGO_SCALE
11const LOGO_H = SPRITE_CROP.h * LOGO_SCALE
12const TEXT_X = LOGO_W + 14
13const CHAR_W = 8.6 // generous advance of the 15px title text, so the meadow never overlaps it
14
15const STYLE =
16 '<style>.t{fill:#26292e}.d{fill:#26292e;fill-opacity:.62}' +
17 '@media (prefers-color-scheme:dark){.t{fill:#ecebe8}.d{fill:#ecebe8;fill-opacity:.62}}</style>'
18
19export type BandSvg = {
20 /** Clawd, the title and the agent line: sits on the left. */
21 main: string
22 /** The subagents' meadow: sits on the right edge. Null with no subagents. */
23 meadow: string | null
24}
25
26const svgOpen = (width: number, height: number, summary: string): string =>
27 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img">` +
28 `<title>${escapeXml(summary)}</title>`
29
30/**
31 * The band as two drawings, so the surface can lay them out: the main one hugs the left edge and
32 * the meadow the right (a row with `justifyContent: space-between`), whatever the width.
33 */
34export const bandSvg = (m: BandModel, frame: Frame, tick: number): BandSvg => {
35 const titleLength = (m.name.length + ` · Lv ${m.level} · `.length + m.label.length) * CHAR_W
36 const textW = Math.max(titleLength, (m.agentLine?.length ?? 0) * 7.4)
37 const width = Math.ceil(TEXT_X + textW + 4)
38 const logo =
39 `<svg x="0" y="0" width="${LOGO_W}" height="${LOGO_H}" viewBox="${SPRITE_CROP.x} ${SPRITE_CROP.y} ${SPRITE_CROP.w} ${SPRITE_CROP.h}" ` +
40 `shape-rendering="crispEdges">${spriteSvgInner(frame)}</svg>`
41 const title =
42 `<text x="${TEXT_X}" y="26" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15">` +
43 `<tspan style="fill:#d97757" font-weight="700">${escapeXml(m.name)}</tspan>` +
44 `<tspan class="d"> · Lv ${m.level} · </tspan>` +
45 `<tspan class="t" font-weight="600">${escapeXml(m.label)}</tspan></text>`
46 const line = m.agentLine
47 ? `<text x="${TEXT_X}" y="46" class="d" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12.5">${escapeXml(m.agentLine)}</text>`
48 : ''
49 const main = svgOpen(width, LOGO_H, m.summary) + STYLE + logo + title + line + '</svg>'
50 const meadow = m.agents.length > 0 ? meadowSvg(m.agents, tick, m.summary) : null
51 return { main, meadow }
52}
53