SLOPSHOPPER

clawd

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

newpanebandguardcommandprompt
v0.1.0MITupdated 2026-10-03kreddevils18/claude-clawd
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd
│ ┃ Clawd ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ▄ ▄▄ ▄▀▄ ▄ ▀ ⏺ Read(src/auth.ts) │ ┃ ▄▀▀▀▄ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ▀ ⎿ Read 6 lines │ ┃ ▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▄ ⏺ Update(src/auth.ts) │ ┃ ▀ ▀▄▀▄▀▀▀▀▄▀▄▀ ▀▄ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ Clawd · Lv 1 · Done! ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /clawd │ ⎿ clawd: Clawd is here. │ │ ▝▜█████▛▘ Clawd · Lv 1 · Done! ▐█████▌ ▘▘ ▝▝ ✓ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▝▜█████▛▘ Clawd · Lv 1 · Done! ▐█████▌ ▘▘ ▝▝ ✓
Pane · Clawd
▄ ▄▄ ▄▀▄ ▄ ▀ ▄▀▀▀▄ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ▀ ▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▄ ▀ ▀▄▀▄▀▀▀▀▄▀▄▀ ▀▄ Clawd · Lv 1 · Done!
README

Clawd

Every Clawd state, animated

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.

Install

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.

What you see

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.

States

When several apply at once, the one nearest the top wins.

StateWhenSound
waitA permission prompt or question is openpip
failA tool call just failed
limit-hitThe 5-hour limit is at 100%
burpThe context was just compactedburp
level-upClawd just gained a level
patYou ran /clawd pat
doneA turn just finished
editClaude is editing or writing files
bashClaude is running a command
readClaude is reading, grepping or globbing
thinkThe model is thinking, or using another tool (with subagents running: "Directing N agents…")
listeningYou are typing in the prompt box
limit-warnThe 5-hour limit is at 80% or more
sleepIdle for a minute
idleAnything 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).

Commands

CommandDoes
/clawdOpen the full pane
/clawd patPat Clawd
`/clawd hat <beanie\crown\wizard\auto\none>`Choose an unlocked hat (auto wears the best one)
/clawd mute · /clawd unmuteSounds off or on (remembered)
/clawd hide · /clawd showHide or show the band (remembered)
/clawd statsLevel, XP to the next level, hats

Privacy

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.

Contributing

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.

Credits

The sounds are synthesized with ffmpeg and released under CC0 (see sounds/README.md). The code is MIT.

Source 53 files
hooks/register.tsx 397 lines
1// 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}
397
types/index.d.ts 65 lines
1// 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}
65
src/core/commands.ts 70 lines
1// 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}
70
src/core/redraw-policy.ts 10 lines
1// 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
10
src/core/signal-store.ts 151 lines
1// 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}
151
src/core/usage-event.ts 17 lines
1// 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})
17
src/core/signals.ts 86 lines
1// 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'
86
src/core/state-registry.ts 16 lines
1// 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}
16
src/core/view.ts 19 lines
1// 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}
19
src/progression/xp.ts 73 lines
1// 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}
73
src/agents/agent-view.ts 52 lines
1// 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  })
52
src/hud/band-svg.ts 53 lines
1// 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