SLOPSHOPPER

buddy

Clawd, Claude Code's mascot, animated above the prompt: blinks while idle, walks while working, cheers when a turn is done

newbandguardcommandtimer
v0.5.0no licenseupdated 2026-10-10katipally/modz/mods/ui/buddy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · buddy
› 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 ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /buddy ⎿ buddy: Usage: /buddy skin classic | ink | mint | sky | berry | gold ▣ client module ./clawd-term.tsx ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./clawd-term.tsx ⟨Claude Code's own drawing⟩
README

buddy

Clawd, Claude Code's mascot, animated at the left of the mod band above the prompt. The pixels and colors are the ones Claude Code itself draws him with (its built-in mods guide), painted as Raster cells in the terminal and a crisp Svg on desktop, VS Code and mobile.

 ..CCCCCCCCCCCCC..
 ..CCCCCCCCCCCCC..
 ..CCCECCCCCCCEC..      C body #d77757
 ..CCCECCCCCCCEC..      E eyes #141414
 CCCCCCCCCCCCCCCCC
 CCCCCCCCCCCCCCCCC
 ..CCCCCCCCCCCCC..
 ..CCCCCCCCCCCCC..
 ..C.C.......C.C..
 ..C.C.......C.C..

Install

/plugin install buddy --marketplace katipally/modz

Use

Clawd acts out what the agent is doing:

What he doesWhen
types, code lines scroll, arms alternatean ordinary Bash command
progress bar fills, foot tapstests run (npm test, pytest, ...)
a commit dot hops along a brancha git command
a package drops into a boxnpm install, pip install, brew install, ...
gears turn, he cranksbuild, tsc, make, ...
swings a hammer, sparks flyEdit, Write or NotebookEdit runs
a lens slides across, his eyes followRead, Grep or Glob runs
signal waves growWebFetch or WebSearch runs
little helpers hop beside himan Agent (subagent) runs
ticks a checklistTodoWrite or a task tool runs
a ? bounces, his hand is uphe is asking you a question
a plug clicks into a socketan MCP tool runs
waves a wand, sparklesa skill runs
ponders, a bulb lightsentering or leaving plan mode
paces, looks around, a bubble risesthinking, no tool running
hops with arms up, sparks flya turn finished
eyes shut, flails, a red ! flashesa turn was stopped or errored
sits down, z rises2 minutes with nothing happening
blinks, glances both ways, waves helloidle

Click him and he plays one of 14 animations: jump, look, spin, peekaboo, drop, wink, boop, tap, sneeze, turn, coin-hop, skip, waddle or peek. They come from a shuffled deck, so every one plays before any repeats and never the same one twice in a row. Clicks during an animation are ignored. (Terminal only; the pixel version keeps one hop.)

On top of his mood, three things can bother him (idle or working):

What he doesWhen
sweat runs down his sideany limit or the context is at 85% or more
a clock hand turns beside himone tool has run for 10 seconds (delegating does not count)
snow drifts down and he shiversthe prompt cache has gone cold (needs the cache-timer mod)

Everything he holds stays inside his own box, so the band never changes size. The frames never redraw the band:

  • Terminal: Clawd is the three-row Clawd from Claude Code's welcome screen. Each kind of work has its own prop beside him, drawn in cells and moving on its own beat, with the work written next to it (⌨ typing, ✎ editing, ⌕ reading, ◎ browsing, ⚑ delegating, ☑ planning, ◔ testing, ◆ git, ↓ installing, ▤ building, ? asking, ↯ connecting, ✦ casting, ◇ drafting). Only thinking paces on his feet. He sits in a box of nine cells by three rows, and a frame may push him down or sideways inside it, which clips him: that is how he hides, peeks and walks out of sight without the band ever changing size. He runs as a Client region on his own clock, so only his cells repaint. Clicks need the fullscreen terminal ("tui": "fullscreen").
  • Desktop, VS Code, mobile: one Svg per mood that animates itself (SMIL), with the click reaction built in.
  • With reduced motion on, he holds still.

Styles: /buddy skin classic | ink | mint | sky | berry | gold (classic is Clawd's own color; the choice is remembered).

Only the main agent's turns and tools move him; subagents do not.

Source 5 files
hooks/register.tsx 169 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, BoxProps, ElementConstructor, EngineInterface, RenderElement, RenderNode, SessionContextUsage, SessionRateLimit, Timer } from 'claude-code'
3
4import type { Activity, Mood, Strain } from '../types'
5import { H, PX, SKINS, toSvg, W } from './clawd'
6
7// Which prop Clawd picks up for which tool the main agent runs; any other tool keeps him thinking.
8const TOOL_PROPS: Record<string, Activity> = {
9  Edit: 'hammer',
10  Write: 'hammer',
11  NotebookEdit: 'hammer',
12  Read: 'magnify',
13  Grep: 'magnify',
14  Glob: 'magnify',
15  WebFetch: 'web',
16  WebSearch: 'web',
17  Agent: 'agents',
18  TodoWrite: 'notes',
19  TaskCreate: 'notes',
20  TaskUpdate: 'notes',
21  AskUserQuestion: 'ask',
22  Skill: 'skill',
23  EnterPlanMode: 'plan',
24  ExitPlanMode: 'plan',
25}
26// A Bash command is typing unless it is clearly one of these jobs.
27const BASH_JOBS: [RegExp, Activity][] = [
28  [/^\s*git\b/, 'git'],
29  [/\b(test|pytest|jest|vitest|mocha)\b/, 'test'],
30  [/\b(npm|pnpm|yarn|bun|pip3?|brew|apt(-get)?|cargo|gem) (install|add|i|ci)\b/, 'install'],
31  [/\b(build|tsc|make|compile|webpack|gradle|mvn)\b/, 'build'],
32]
33const activityOf = (tool: string, command: string): Activity | undefined =>
34  tool === 'Bash' ? BASH_JOBS.find(([re]) => re.test(command))?.[1] ?? 'laptop' : tool.startsWith('mcp__') ? 'mcp' : TOOL_PROPS[tool]
35// Moods that play for a while after a turn and then settle back to idle.
36const SETTLE_MS: Partial<Record<Mood, number>> = { happy: 2400, oops: 1200 }
37const ASLEEP_AFTER_MS = 120_000
38// He sweats from 85% of any limit, and checks a clock when one tool has run this long.
39const HOT_PERCENT = 85
40const SLOW_MS = 10_000
41
42const mood = atom({ plugin: 'buddy', key: 'mood' } as const, 'idle')
43const activity = atom({ plugin: 'buddy', key: 'activity' } as const, 'think')
44const skin = atom({ plugin: 'buddy', key: 'skin' } as const, 'classic')
45const signals = atom({ plugin: 'buddy', key: 'signals' } as const, { hot: false, slow: 0 })
46// The cache-timer mod owns these; without it the cache is simply never cold.
47const cacheLast = atom({ plugin: 'cache-timer', key: 'last' } as const, null)
48const cacheTtl = atom({ plugin: 'cache-timer', key: 'ttlMin' } as const, 60)
49
50const isHot = (context: SessionContextUsage, limits: SessionRateLimit[]) =>
51  [context.percent, ...limits.map(l => l.percentUsed)].some(p => p !== undefined && p >= HOT_PERCENT)
52
53// Every modz mod places its tile in one shared band: a lead column (the bot), then a column of tiles, then an optional
54// right column. A column with no tiles is not drawn, and columns wrap under each other when the width runs out.
55const BAND = 'modz-band'
56const SLOTS: Record<string, 'lead' | 'left' | 'right'> = {
57  buddy: 'lead',
58  'usage-limits': 'left',
59  'cache-timer': 'left',
60  'model-info': 'left',
61  orchestrator: 'left',
62}
63const ORDER = Object.keys(SLOTS)
64const keyOf = (n: RenderNode) => (typeof n !== 'string' && n.type === 'Box' ? String(n.props?.key ?? '') : '')
65const rank = (n: RenderNode) => {
66  const i = ORDER.indexOf(keyOf(n))
67  return i < 0 ? ORDER.length : i
68}
69const pack = (Box: ElementConstructor<BoxProps>, below: RenderElement, tile: RenderElement) => {
70  const isEmpty = below.type === 'Box' && !below.children?.length
71  const tiles = keyOf(below) === BAND
72    ? (below.type === 'Box' ? below.children ?? [] : []).flatMap(col => (typeof col !== 'string' && col.type === 'Box' ? col.children ?? [] : [col]))
73    : isEmpty ? [] : [below]
74  const all = [...tiles, tile].sort((a, b) => rank(a) - rank(b))
75  const [lead, left, right] = (['lead', 'left', 'right'] as const).map(slot => all.filter(n => (SLOTS[keyOf(n)] ?? 'left') === slot))
76  return (
77    <Box key={BAND} flexDirection="row" flexWrap="wrap" columnGap={3}>
78      {lead!.length > 0 && <Box key="modz-lead" flexDirection="column" justifyContent="center">{lead}</Box>}
79      {left!.length > 0 && <Box key="modz-left" flexDirection="column" flexGrow={right!.length > 0 ? 1 : 0}>{left}</Box>}
80      {right!.length > 0 && <Box key="modz-right" flexDirection="column" alignItems="flex-end">{right}</Box>}
81    </Box>
82  )
83}
84
85// The band redraws only when the mood or the prop changes; the frames themselves play inside Clawd's own region.
86let settle: Timer | undefined
87
88const become = async ($: EngineInterface, next: Mood) => {
89  settle?.cancel()
90  await update($, mood, () => next)
91  const after = next === 'idle' ? ASLEEP_AFTER_MS : SETTLE_MS[next]
92  if (after !== undefined) settle = $.clock.after(after, () => void become($, next === 'idle' ? 'asleep' : 'idle'))
93}
94
95export const register: Register = on => {
96  on('session.start', async ($, e, next) => {
97    await $.command.register({ name: 'buddy', description: 'Change how Clawd looks', argumentHint: `skin ${Object.keys(SKINS).join(' | ')}` })
98    const saved = (await $.store.get('skin')) as string | undefined
99    if (saved && saved in SKINS) await update($, skin, () => saved)
100    await become($, 'idle')
101    return next(e)
102  })
103
104  on('command.run', { command: 'buddy' }, async ($, e) => {
105    const [word, name = ''] = e.args.trim().split(/\s+/)
106    if (word !== 'skin' || !(name in SKINS)) return { text: `Usage: /buddy skin ${Object.keys(SKINS).join(' | ')}` }
107    await update($, skin, () => name)
108    await $.store.set('skin', name)
109    return { text: `Clawd is now ${name}.` }
110  })
111
112  on('turn.start', async ($, e, next) => {
113    await update($, activity, () => 'think' as Activity)
114    await become($, 'working')
115    return next(e)
116  })
117
118  on('session.measure', async ($, e, next) => {
119    await update($, signals, s => ({ ...s, hot: isHot(e.context, e.rateLimits) }))
120    return next(e)
121  })
122
123  on('tool.call', async ($, e, next) => {
124    const isMain = e.agentId === undefined
125    const prop = isMain ? activityOf(e.tool, 'command' in e ? String(e.command) : '') : undefined
126    if (prop) await update($, activity, () => prop)
127    // Delegating is long by design, so only other tools count as slow.
128    let isSlow = false
129    const watch = isMain && e.tool !== 'Agent'
130      ? $.clock.after(SLOW_MS, async () => {
131        isSlow = true
132        await update($, signals, s => ({ ...s, slow: s.slow + 1 }))
133      })
134      : undefined
135    try {
136      return await next(e)
137    } finally {
138      watch?.cancel()
139      if (isSlow) await update($, signals, s => ({ ...s, slow: s.slow - 1 }))
140      if (prop) await update($, activity, () => 'think' as Activity)
141    }
142  })
143
144  on('turn.complete', async ($, e, next) => {
145    if (e.agentId === undefined) await become($, e.isAborted || e.reason === 'error' ? 'oops' : 'happy')
146    return next(e)
147  })
148
149  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
150    const below = await next(e)
151    if (e.props.hasSurvey) return below
152    const elements = $.ui.resolve(e)
153    const { Box } = elements
154    const [m, act, s, sig, last, ttl] = await Promise.all([read($, mood), read($, activity), read($, skin), read($, signals), read($, cacheLast), read($, cacheTtl)])
155    const isCold = last !== null && Date.now() > last.at + ttl * 60_000
156    const strain: Strain = sig.hot ? 'hot' : sig.slow > 0 ? 'slow' : isCold ? 'cold' : 'none'
157    const isStill = (await $.settings.read()).prefersReducedMotion === true
158    // The terminal runs Clawd as a Client on its own clock; elsewhere he is one Svg that animates itself.
159    const art = e.surface === 'terminal' && 'Client' in elements
160      ? <elements.Client key="clawd" module="./clawd-term.tsx" props={{ mood: m, activity: act, skin: s, isStill, strain }} />
161      : 'Svg' in elements
162        ? <elements.Svg source={toSvg(m, act, SKINS[s] ?? SKINS.classic!, isStill, strain)} alt={`Clawd, ${m}`} width={W * PX} height={H * PX} isInteractive={!isStill} />
163        : null
164    if (!art) return below
165
166    return pack(Box, below, <Box key="buddy">{art}</Box>)
167  })
168}
169
hooks/clawd.ts 244 lines
1import type { Activity, Mood, Strain } from '../types'
2
3// Clawd exactly as Claude Code draws him (its built-in mods guide's CLAWD_POSES): C body, E eye, . nothing.
4const CLAWD = [
5  '..CCCCCCCCCCCCC..',
6  '..CCCCCCCCCCCCC..',
7  '..CCCECCCCCCCEC..',
8  '..CCCECCCCCCCEC..',
9  'CCCCCCCCCCCCCCCCC',
10  'CCCCCCCCCCCCCCCCC',
11  '..CCCCCCCCCCCCC..',
12  '..CCCCCCCCCCCCC..',
13  '..C.C.......C.C..',
14  '..C.C.......C.C..',
15]
16const EYE_ROWS = [2, 3]
17const EYE_COLS = [5, 13]
18const ARM_ROWS = [4, 5]
19const ARM_COLS = { left: [0, 1], right: [15, 16] }
20const LEG_ROW = 9
21const LEGS = { outer: [2, 14], inner: [4, 12] }
22
23// Two rows above him for hats, hearts and antennas, and a column each side for the head shake.
24// Everything he holds stays inside this box, so the tile never changes size.
25const TOP = 2
26const LEFT = 1
27export const W = LEFT + CLAWD[0]!.length + 1
28export const H = TOP + CLAWD.length
29
30// Props share Clawd's pixel style; each letter is one palette color, C is the body.
31export const PALETTE: Record<string, number> = {
32  E: 0x141414, // eyes
33  G: 0x3a3a40, // dark metal
34  g: 0xb8bcc4, // light metal
35  b: 0x7aa2f7, // screen glow
36  W: 0xffffff,
37  w: 0x8b5a2b, // wood
38  y: 0xf5c542,
39  r: 0xe5484d,
40  n: 0x5fd38d,
41  N: 0x5a6fd6, // nightcap
42}
43export const SKINS: Record<string, number> = {
44  classic: 0xd77757,
45  ink: 0xe8e6e3,
46  mint: 0x7fd1ae,
47  sky: 0x7aa2f7,
48  berry: 0xe07ab0,
49  gold: 0xe5c07b,
50}
51
52const LAPTOP = ['GGGGGGGGG', 'GGGGgGGGG', 'GGGGGGGGG', '.ggggggggggg']
53const LENS = ['.GG.', 'GbbG', 'GbbG', '.GG.']
54const PAPER = ['WWWWW', 'WGGGW', 'WWWWW', 'WGGGW', 'WWWWW']
55const MINI = ['CCC', 'C.C']
56const HEART = ['rr.rr', '.rrr.']
57const CROWN = ['y.y.y', 'yyyyy']
58const NIGHTCAP = ['....NNNN.', '.NNNNNNNNW']
59const CONFETTI = ['y', 'r', 'n', 'b', 'W']
60
61// How fast each mood plays, and how many frames make one loop of it.
62export const FRAME_MS: Record<Mood, number> = { idle: 300, working: 200, happy: 160, oops: 90, asleep: 900, loved: 140 }
63export const LOOP: Record<Mood, number> = { idle: 40, working: 8, happy: 8, oops: 8, asleep: 3, loved: 10 }
64
65type Gesture = 'none' | 'blink' | 'look-left' | 'look-right' | 'wave'
66
67// Idle Clawd blinks, glances both ways and waves once per loop, at uneven beats so it never feels mechanical.
68const idleGesture = (f: number): Gesture => {
69  const beat = f % LOOP.idle
70  if (beat === 7 || beat === 23 || beat === 25) return 'blink'
71  if (beat >= 12 && beat < 16) return 'look-left'
72  if (beat >= 28 && beat < 32) return 'look-right'
73  if (beat >= 34 && beat < 38) return 'wave'
74  return 'none'
75}
76
77// A pixel is empty (undefined) or a palette letter.
78export type Pixels = (string | undefined)[][]
79
80const put = (canvas: Pixels, x: number, y: number, c: string) => {
81  if (y >= 0 && y < H && x >= 0 && x < W) canvas[y]![x] = c
82}
83
84const stamp = (canvas: Pixels, art: string[], x: number, y: number) =>
85  art.forEach((row, dy) => [...row].forEach((c, dx) => c !== '.' && put(canvas, x + dx, y + dy, c)))
86
87// One frame: Clawd's pose for the mood, then what he holds or wears for what the agent is doing.
88// The pixel Clawd has one prop per kind of tool; the newer kinds of work borrow the closest one.
89const PIXEL_PROP: Record<Activity, Activity> = {
90  think: 'think', laptop: 'laptop', hammer: 'hammer', magnify: 'magnify', notes: 'notes', web: 'web', agents: 'agents',
91  test: 'laptop', git: 'laptop', install: 'laptop', build: 'hammer', ask: 'think', mcp: 'web', skill: 'notes', plan: 'think',
92}
93
94export const draw = (m: Mood, activity: Activity, f: number, strain: Strain = 'none'): Pixels => {
95  const act = PIXEL_PROP[activity]
96  const g = m === 'idle' ? idleGesture(f) : 'none'
97  const isWorking = m === 'working'
98  const body = CLAWD.map(r => [...r])
99  const isShut = m === 'asleep' || m === 'oops' || g === 'blink'
100  const isSquint = m === 'happy' || m === 'loved'
101  // Over a laptop or a page he looks down; otherwise he glances where the gesture or the work points.
102  const isDown = isWorking && (act === 'laptop' || act === 'notes')
103  const shift = g === 'look-left' ? -1 : g === 'look-right' || (isWorking && !isDown) ? 1 : 0
104  for (const col of EYE_COLS) {
105    for (const row of EYE_ROWS) body[row]![col] = 'C'
106    if (isShut) continue
107    for (const row of isSquint || isDown ? [EYE_ROWS[1]!] : EYE_ROWS) body[row]![col + shift] = 'E'
108  }
109  const raise = (cols: number[]) => {
110    for (const col of cols) {
111      for (const row of ARM_ROWS) body[row]![col] = '.'
112      for (const row of EYE_ROWS) body[row]![col] = 'C'
113    }
114  }
115  const isBounce = (m === 'happy' || m === 'loved') && f % 2 === 0
116  if (isBounce) raise([...ARM_COLS.left, ...ARM_COLS.right])
117  if (g === 'wave' && f % 2 === 0) raise(ARM_COLS.right)
118  if (isWorking && act === 'hammer' && f % 4 < 2) raise(ARM_COLS.right)
119  if (isWorking && act === 'think') for (const col of f % 2 === 0 ? LEGS.inner : LEGS.outer) body[LEG_ROW]![col] = '.'
120  if (m === 'asleep') for (const col of [...LEGS.inner, ...LEGS.outer]) body[LEG_ROW]![col] = '.'
121
122  const ox = LEFT + (m === 'oops' ? (f % 2 === 0 ? -1 : 1) : 0)
123  const oy = TOP + ((m === 'happy' || m === 'loved') && f % 2 === 1 ? 1 : m === 'asleep' ? 1 : 0)
124  const canvas: Pixels = Array.from({ length: H }, () => Array<string | undefined>(W).fill(undefined))
125  stamp(canvas, body.map(r => r.join('')), ox, oy)
126
127  if (isWorking && act === 'laptop') {
128    // Typing: the lid faces us, his arms tap the keys in turn and the screen's glow flickers on the lid's edge.
129    stamp(canvas, LAPTOP, ox + 3, oy + 6)
130    put(canvas, ox + (f % 2 === 0 ? 1 : 15), oy + 6, 'C')
131    if (f % 3 !== 0) put(canvas, ox + 4 + (f % 7), oy + 5, 'b')
132  }
133  if (isWorking && act === 'hammer') {
134    if (f % 4 < 2) {
135      put(canvas, ox + 16, oy + 1, 'w')
136      put(canvas, ox + 16, oy, 'w')
137      stamp(canvas, ['GGG', 'GGG'], ox + 15, oy - 2)
138    } else {
139      put(canvas, ox + 17, oy + 5, 'w')
140      put(canvas, ox + 17, oy + 6, 'w')
141      stamp(canvas, ['GG', 'GG'], ox + 16, oy + 7)
142      if (f % 4 === 2) put(canvas, ox + 15, oy + 9, 'y')
143    }
144  }
145  if (isWorking && act === 'magnify') {
146    // The lens drifts over his right eye, which looks bigger behind the glass.
147    const bob = f % 4 < 2 ? 0 : 1
148    stamp(canvas, LENS, ox + 11, oy + 1 + bob)
149    stamp(canvas, ['EE', 'EE'], ox + 12, oy + 2 + bob)
150    put(canvas, ox + 15, oy + 5 + bob, 'w')
151  }
152  if (isWorking && act === 'notes') {
153    stamp(canvas, PAPER, ox + 6, oy + 5)
154    // Ticks land on the page's lines one by one.
155    if (f % 3 >= 1) put(canvas, ox + 7, oy + 6, 'n')
156    if (f % 3 >= 2) put(canvas, ox + 7, oy + 8, 'n')
157  }
158  if (isWorking && act === 'web') {
159    put(canvas, ox + 8, oy - 1, 'G')
160    put(canvas, ox + 8, oy - 2, f % 2 === 0 ? 'r' : 'y')
161    if (f % 4 < 2) {
162      put(canvas, ox + 6, oy - 2, 'b')
163      put(canvas, ox + 10, oy - 2, 'b')
164    } else {
165      put(canvas, ox + 5, oy - 1, 'b')
166      put(canvas, ox + 11, oy - 1, 'b')
167    }
168  }
169  if (isWorking && act === 'agents') {
170    // Little helpers hop beside his head while subagents run.
171    stamp(canvas, MINI, ox, oy - 2 + (f % 2))
172    stamp(canvas, MINI, ox + 14, oy - 1 - (f % 2))
173  }
174  if (isWorking && act === 'think') {
175    const bubbles: [number, number][] = [[ox + 13, oy - 1], [ox + 15, oy - 2], [ox + 17, oy - 2]]
176    bubbles.slice(0, (f % 4) + 1).forEach(([x, y]) => put(canvas, x, y, 'W'))
177  }
178  if (m === 'happy') {
179    stamp(canvas, CROWN, ox + 6, oy - 2)
180    for (let k = 0; k < 6; k++) {
181      const x = (f * 5 + k * 7) % W
182      const y = (f + k * 3) % H
183      if (canvas[y]![x] === undefined) put(canvas, x, y, CONFETTI[k % CONFETTI.length]!)
184    }
185  }
186  if (m === 'loved') stamp(canvas, HEART, ox + 6, oy - 2)
187  if (m === 'asleep') stamp(canvas, NIGHTCAP, ox + 3, oy - 2)
188  if (m === 'oops') put(canvas, ox + 15, oy + 1 + (f % 2), 'b')
189  if (m === 'idle' || isWorking) {
190    const free = (x: number, y: number, c: string) => canvas[y]?.[x] === undefined && put(canvas, x, y, c)
191    // Sweat runs down his left side, snow drifts down his right, a clock hand turns above his shoulder.
192    if (strain === 'hot') for (const dy of [0, 1]) free(ox, oy + (f % 4) + dy, 'b')
193    if (strain === 'cold') {
194      free(ox + 16, oy + (f % 4), 'W')
195      free(ox + 17, oy + ((f + 2) % 4), 'W')
196    }
197    if (strain === 'slow') {
198      stamp(canvas, ['ggg', 'gWg', 'ggg'], ox + 15, oy - 2)
199      const [hx, hy] = [[1, 0], [2, 1], [1, 2], [0, 1]][f % 4]!
200      put(canvas, ox + 15 + hx!, oy - 2 + hy!, 'r')
201    }
202  }
203  return canvas
204}
205
206export const colorOf = (c: string | undefined, body: number) => (c === undefined ? undefined : c === 'C' ? body : PALETTE[c])
207
208export const hex = (c: number) => `#${c.toString(16).padStart(6, '0')}`
209
210// Each run of same-colored pixels in a row becomes one rect, which keeps a whole loop of frames small.
211export const PX = 4
212const rectsOf = (px: Pixels, body: number) =>
213  px.flatMap((row, y) => {
214    const runs: string[] = []
215    for (let x = 0; x < W;) {
216      const color = colorOf(row[x], body)
217      let end = x + 1
218      while (end < W && colorOf(row[end], body) === color) end++
219      if (color !== undefined) runs.push(`<rect x="${x * PX}" y="${y * PX}" width="${(end - x) * PX}" height="${PX}" fill="${hex(color)}"/>`)
220      x = end
221    }
222    return runs
223  }).join('')
224
225// The desktop's Clawd is one Svg that animates itself: each distinct frame is a group shown for its slice of the
226// loop, so the page never swaps the picture. A click on him plays the loved jump with a heart, all inside the Svg.
227export const toSvg = (m: Mood, act: Activity, body: number, isStill: boolean, strain: Strain = 'none') => {
228  const n = isStill ? 1 : LOOP[m]
229  const frames = Array.from({ length: n }, (_, f) => rectsOf(draw(m, act, f, strain), body))
230  const spans: { rects: string; from: number; to: number }[] = []
231  frames.forEach((rects, f) => {
232    const last = spans.at(-1)
233    if (last?.rects === rects) last.to = f + 1
234    else spans.push({ rects, from: f, to: f + 1 })
235  })
236  const dur = (n * FRAME_MS[m]) / 1000
237  const groups = spans.length === 1
238    ? `<g>${spans[0]!.rects}</g>`
239    : spans.map(s => `<g opacity="0">${s.rects}<animate attributeName="opacity" calcMode="discrete" dur="${dur}s" repeatCount="indefinite" keyTimes="0;${s.from / n};${s.to / n}" values="0;1;0"/></g>`).join('')
240  const loved = rectsOf(draw('loved', act, 0), body)
241  const tap = isStill ? '' : `<g opacity="0">${loved}<set attributeName="opacity" to="1" begin="hit.click" dur="0.9s"/><animateTransform attributeName="transform" type="translate" values="0 0;0 -8;0 0;0 -4;0 0" begin="hit.click" dur="0.9s"/></g><animate href="#mood" attributeName="opacity" values="0;0" begin="hit.click" dur="0.9s"/>`
242  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * PX}" height="${H * PX}" shape-rendering="crispEdges"><g id="mood">${groups}</g>${tap}<rect id="hit" width="${W * PX}" height="${H * PX}" fill="transparent" style="cursor:pointer"/></svg>`
243}
244
hooks/clawd-term.tsx 167 lines
1import type { ClientModule } from 'claude-code'
2
3import type { Activity, Mood, Scene, Strain } from '../types'
4import { FRAME_MS, hex, LOOP, SKINS } from './clawd'
5import { BODY, CLICK_MS, CLICKS, dealClicks, FEET, HEAD, marksOf, place } from './clawd-moves'
6import type { Frame, Mark, Sprite } from './clawd-moves'
7
8// `scene` is refreshed on every draw so the one ticker always plays the latest mood.
9type Local = { scene: Scene; frame: number; elapsed: number; click?: { name: string; step: number }; deck: string[]; last?: string }
10
11const TICK_MS = 30
12const PROP_W = 5
13
14// What he is doing reads beside him: a glyph in its own color and a word.
15const DOING: Record<Activity, { glyph: string; word: string; color: number }> = {
16  think: { glyph: '…', word: 'thinking', color: 0xb8bcc4 },
17  laptop: { glyph: '⌨', word: 'typing', color: 0x7aa2f7 },
18  hammer: { glyph: '✎', word: 'editing', color: 0xf5c542 },
19  magnify: { glyph: '⌕', word: 'reading', color: 0x7fd1ae },
20  web: { glyph: '◎', word: 'browsing', color: 0x7aa2f7 },
21  agents: { glyph: '⚑', word: 'delegating', color: 0xe07ab0 },
22  notes: { glyph: '☑', word: 'planning', color: 0x5fd38d },
23  test: { glyph: '◔', word: 'testing', color: 0x5fd38d },
24  git: { glyph: '◆', word: 'git', color: 0xf0883e },
25  install: { glyph: '↓', word: 'installing', color: 0x7fd1ae },
26  build: { glyph: '▤', word: 'building', color: 0xf5c542 },
27  ask: { glyph: '?', word: 'asking', color: 0xe07ab0 },
28  mcp: { glyph: '↯', word: 'connecting', color: 0x7aa2f7 },
29  skill: { glyph: '✦', word: 'casting', color: 0xc792ea },
30  plan: { glyph: '◇', word: 'drafting', color: 0xb8bcc4 },
31}
32
33// One frame: the sprite, the prop beside him (one cell per row, PROP_W wide) and the prop's color.
34type Look = Frame & { prop: string[]; tint: number }
35
36const GREY = 0xb8bcc4
37const GOLD = 0xf5c542
38const RED = 0xe5484d
39const at = (row: number, text: string) => [0, 1, 2].map(r => (r === row ? text : ''))
40const look = (head: string, body: string, feet: string, prop: string[], tint: number, extra: Omit<Frame, 'rows'> = {}): Look => ({ rows: [head, body, feet], prop, tint, ...extra })
41const SPARKS = ['✦', '✧', '⋆', '·']
42const ARC = ['◜', '◠', '◝', '◞', '◡', '◟']
43const arc = (i: number) => ARC[((i % ARC.length) + ARC.length) % ARC.length]!
44const alternate = (f: number, a: string, b: string, every = 1) => (Math.floor(f / every) % 2 === 0 ? a : b)
45
46// Each kind of work owns its motion. He only paces while thinking, so stepping never stands in for a prop.
47const WORK: Record<Activity, (f: number) => Look> = {
48  think: f => look(alternate(f, HEAD.left, HEAD.right, 4), BODY.rest, f % 2 ? FEET.step : FEET.stand, at(2 - Math.min(f % 4, 2), ['·', '∘', '○', '◯'][f % 4]!), DOING.think.color),
49  laptop: f => look(HEAD.down, alternate(f, BODY.right, BODY.left), FEET.desk, [0, 1, 2].map(r => '▬'.repeat((f + 2 * r) % 4)), DOING.laptop.color),
50  hammer: f => look(HEAD.open, f % 4 < 2 ? BODY.right : BODY.rest, FEET.stand, f % 4 < 2 ? ['▀█▀', ' █', ''] : ['', ' █', `▄█▄${f % 4 === 2 ? '✦' : '·'}`], DOING.hammer.color, f % 4 === 2 ? { offset: 1, dust: true } : {}),
51  magnify: f => look(alternate(f, HEAD.left, HEAD.right, 3), BODY.rest, FEET.stand, f % 4 < 2 ? ['◎', ' ╲', ''] : ['', '◎', ' ╲'], DOING.magnify.color),
52  web: f => look(HEAD.open, f % 4 < 2 ? BODY.both : BODY.rest, FEET.stand, at(0, `·${')'.repeat(f % 4)}`), DOING.web.color),
53  agents: f => look(HEAD.open, alternate(f, BODY.both, BODY.rest), FEET.stand, f % 2 ? ['', '   ▟▙', '▟▙'] : ['', '▟▙', '   ▟▙'], DOING.agents.color),
54  notes: f => look(HEAD.down, alternate(f, BODY.right, BODY.rest), FEET.stand, [0, 1, 2].map(r => (r < f % 4 ? '☑' : '☐')), DOING.notes.color),
55  test: f => look(HEAD.open, BODY.rest, alternate(f, FEET.tapLeft, FEET.tapRight), at(1, `${'▰'.repeat(f % 6)}${'▱'.repeat(5 - (f % 6))}`), DOING.test.color),
56  git: f => look(HEAD.open, alternate(f, BODY.right, BODY.rest, 2), FEET.stand, at(1, [0, 1, 2].map(i => (i === f % 3 ? '●' : '○')).join('─')), DOING.git.color),
57  install: f => look(HEAD.open, f % 3 === 2 ? BODY.both : BODY.rest, FEET.stand, [0, 1, 2].map(r => (r === 2 ? (f % 3 === 2 ? '╰▣╯' : '╰ ╯') : r === f % 3 ? ' ▣' : '')), DOING.install.color),
58  build: f => look(HEAD.down, alternate(f, BODY.left, BODY.right), FEET.stand, at(1, `${arc(f)} ${arc(-f)}`), DOING.build.color),
59  ask: f => look(HEAD.open, BODY.right, alternate(f, FEET.tapLeft, FEET.tapRight, 2), at(0, f % 2 ? ' ?' : '?'), DOING.ask.color),
60  mcp: f => look(HEAD.open, alternate(f, BODY.both, BODY.rest, 2), FEET.stand, at(1, ['─┤ ├─', '─┤↯├─', '─┤↯├─', '─┤ ├─'][f % 4]!), DOING.mcp.color),
61  skill: f => look(HEAD.open, BODY.right, FEET.stand, [0, 1, 2].map(r => (r === f % 3 ? ` ${SPARKS[f % SPARKS.length]}` : r === (f + 1) % 3 ? '⋆' : '')), DOING.skill.color),
62  plan: f => look(alternate(f, HEAD.left, HEAD.right, 4), BODY.rest, FEET.stand, at(0, f % 4 === 3 ? '◉' : '○'), DOING.plan.color),
63}
64
65// Idle blinks, glances both ways and waves one arm with a greeting at uneven beats; the other moods are one loop each.
66const moodLook = (m: Mood, act: Activity, f: number): Look => {
67  switch (m) {
68    case 'working':
69      return WORK[act](f)
70    case 'happy': {
71      const beat = f % 4
72      const sparks = [0, 1, 2].map(r => `${' '.repeat(r % 2)}${SPARKS[(f + r) % SPARKS.length]}`)
73      return beat === 0 ? look(HEAD.open, BODY.rest, FEET.stand, sparks, GOLD, { offset: 1, dust: true })
74        : look(HEAD.open, BODY.both, beat === 3 ? FEET.stand : FEET.none, sparks, GOLD)
75    }
76    case 'asleep':
77      return look(HEAD.shut, BODY.rest, FEET.sit, at(2 - (f % 3), f % 3 ? 'Z' : 'z'), GREY)
78    case 'oops':
79      return f % 2 ? look(HEAD.shut, BODY.rest, FEET.stand, at(0, ''), RED, { offset: 1, dust: true }) : look(HEAD.shut, BODY.both, FEET.stand, at(0, '!'), RED)
80    default: {
81      const beat = f % LOOP.idle
82      const isWave = beat >= 34 && beat < 38
83      const head = beat === 7 || beat === 23 || beat === 25 ? HEAD.shut : beat >= 12 && beat < 16 ? HEAD.left : beat >= 28 && beat < 32 ? HEAD.right : HEAD.open
84      return look(head, isWave && f % 2 === 0 ? BODY.right : BODY.rest, FEET.stand, at(0, isWave ? 'hi!' : ''), GREY)
85    }
86  }
87}
88
89// A strain shows in the cell left of him, one row at a time: sweat falls, snow falls, a clock hand turns.
90const STRAIN: Record<Exclude<Strain, 'none'>, { cells: string[]; color: number; row: (f: number) => number }> = {
91  hot: { cells: ['°'], color: 0x7aa2f7, row: f => f % 3 },
92  cold: { cells: ['*'], color: 0x9ad0ff, row: f => (f + 1) % 3 },
93  slow: { cells: ['◴', '◷', '◶', '◵'], color: GOLD, row: () => 0 },
94}
95
96// A row's cells, each marked dim where dust or a shadow was drawn over it, grouped into runs of one look.
97const runsOf = (row: string, r: number, marks: Mark[]) => {
98  const cells = [...row].map(ch => ({ ch, isDim: false }))
99  for (const m of marks) if (m.row === r) [...m.text].forEach((ch, i) => void (cells[m.col + i] = { ch, isDim: true }))
100  return cells.reduce<{ text: string; isDim: boolean }[]>((runs, c) => {
101    const last = runs.at(-1)
102    return last?.isDim === c.isDim ? (last.text += c.ch, runs) : [...runs, { text: c.ch, isDim: c.isDim }]
103  }, [])
104}
105
106const Clawd: ClientModule<Scene, Local> = (scene, surface) => {
107  const { Box, Text } = surface.elements
108  const isFirst = surface.state === undefined
109  const live: Local = surface.state ?? { scene, frame: 0, elapsed: 0, deck: [] }
110  live.scene = scene
111
112  if (isFirst) {
113    surface.setState(live)
114    surface.every(TICK_MS, () => {
115      const cur = surface.state!
116      const frames = cur.click ? CLICKS[cur.click.name]! : undefined
117      if (cur.scene.isStill && !frames) return
118      const elapsed = cur.elapsed + TICK_MS
119      if (elapsed < (frames ? CLICK_MS : FRAME_MS[cur.scene.mood])) return void (cur.elapsed = elapsed)
120      const step = (cur.click?.step ?? 0) + 1
121      surface.setState({ ...cur, frame: cur.frame + 1, elapsed: 0, click: cur.click && step < frames!.length ? { ...cur.click, step } : undefined })
122    })
123    // Each click plays the next animation from a shuffled deck, so he never does the same one twice in a row.
124    surface.onPointer(e => {
125      const cur = surface.state!
126      if (e.type !== 'down' || cur.click || cur.scene.isStill) return
127      const deck = cur.deck.length ? cur.deck : dealClicks(cur.last)
128      const [name, ...rest] = deck
129      surface.setState({ ...cur, click: { name: name!, step: 0 }, deck: rest, last: name, elapsed: 0 })
130    })
131  }
132
133  const f = live.frame
134  const body = hex(SKINS[scene.skin] ?? SKINS.classic!)
135  const isClick = live.click !== undefined
136  const strain = !isClick && (scene.mood === 'idle' || scene.mood === 'working') && scene.strain !== 'none' ? STRAIN[scene.strain] : undefined
137  const l: Look = isClick ? { ...CLICKS[live.click!.name]![live.click!.step]!, prop: [], tint: GREY } : moodLook(scene.mood, scene.activity, f)
138  const shift = strain && scene.strain === 'cold' ? f % 2 : 0
139  const rows: Sprite = place({ ...l, x: (l.x ?? 0) + shift })
140  const marks = marksOf(l)
141  const doing = !isClick && scene.mood === 'working' ? DOING[scene.activity] : undefined
142
143  return (
144    <Box flexDirection="row" columnGap={1}>
145      <Box flexDirection="column">
146        {rows.map((row, r) => (
147          <Text>
148            <Text color={strain ? hex(strain.color) : undefined}>{strain && strain.row(f) === r ? strain.cells[f % strain.cells.length] : ' '}</Text>
149            {runsOf(row, r, marks).map(run => <Text color={run.isDim ? undefined : body} dimColor={run.isDim}>{run.text}</Text>)}
150            <Text> </Text>
151          </Text>
152        ))}
153      </Box>
154      <Box flexDirection="column">
155        {[0, 1, 2].map(r => (
156          <Text>
157            <Text color={hex(l.tint)}>{(l.prop[r] ?? '').padEnd(PROP_W)}</Text>
158            {r === 1 && doing && <Text color={hex(doing.color)}>{doing.glyph} {doing.word}</Text>}
159          </Text>
160        ))}
161      </Box>
162    </Box>
163  )
164}
165
166export default Clawd
167
hooks/clawd-moves.ts 114 lines
1// Clawd in the terminal is three rows of nine cells. A frame may push him down or sideways inside that box,
2// which clips him, so one sprite can hop, hide, peek and walk out of sight without the box ever changing size.
3export const ROWS = 3
4export const COLS = 9
5
6export type Sprite = [head: string, body: string, feet: string]
7
8export const HEAD = {
9  open: ' ▐▛███▜▌ ',
10  shut: ' ▐█████▌ ',
11  down: ' ▐▙███▟▌ ',
12  left: ' ▐▜███▜▌ ',
13  right: ' ▐▛███▛▌ ',
14  wink: ' ▐▛████▌ ',
15}
16export const BODY = { rest: '▝▜█████▛▘', both: '▗▟█████▙▖', left: '▗▟█████▛▘', right: '▝▜█████▙▖' }
17export const FEET = {
18  stand: '  ▘▘ ▝▝  ',
19  step: '  ▝▘ ▘▝  ',
20  sit: '  ▀▀ ▀▀  ',
21  none: '         ',
22  desk: ' ▄▄▄▄▄▄▄ ',
23  tapLeft: '  ▘▘     ',
24  tapRight: '     ▝▝  ',
25}
26
27export const STAND: Sprite = [HEAD.open, BODY.rest, FEET.stand]
28const CHEER: Sprite = [HEAD.open, BODY.both, FEET.stand]
29const AIR: Sprite = [HEAD.open, BODY.both, FEET.none]
30const LEFT: Sprite = [HEAD.left, BODY.rest, FEET.stand]
31const RIGHT: Sprite = [HEAD.right, BODY.rest, FEET.stand]
32const UP: Sprite = [HEAD.down, BODY.rest, FEET.stand]
33const SHUT: Sprite = [HEAD.shut, BODY.rest, FEET.stand]
34const WINK: Sprite = [HEAD.wink, BODY.rest, FEET.stand]
35
36// Turning around, front to back and round again: each step is a little narrower than the last.
37const THREE_QUARTERS: Sprite = ['  ▐█▛██▌ ', ' ▝▜████▛▘', '   ▘▘ ▝▝ ']
38const EDGE: Sprite = ['   ▐██▌  ', '   ▝███▘ ', '    ▝▝   ']
39const BACK: Sprite = [HEAD.shut, BODY.rest, FEET.stand]
40const THREE_QUARTERS_LEFT: Sprite = [' ▐██▜█▌  ', '▝▜████▛▘ ', ' ▘▘ ▝▝   ']
41const TURNAROUND = [STAND, THREE_QUARTERS, EDGE, BACK, EDGE, THREE_QUARTERS_LEFT, STAND]
42
43// `dust` puffs on both sides of a crouch, `shadow` is the ground under a hop.
44export type Frame = { rows: Sprite; offset?: number; x?: number; dust?: boolean; shadow?: boolean }
45export type Mark = { row: number; col: number; text: string }
46
47const blankRow = ' '.repeat(COLS)
48const shiftRow = (row: string, x: number) =>
49  x >= 0 ? (' '.repeat(Math.min(x, COLS)) + row).slice(0, COLS) : (row + ' '.repeat(Math.min(-x, COLS))).slice(Math.min(-x, COLS))
50
51// The sprite moved down by `offset` rows and right by `x` cells, whatever leaves the box is cut off.
52export const place = ({ rows, offset = 0, x = 0 }: Frame): Sprite => rows.map((_, r) => shiftRow(rows[r - offset] ?? blankRow, x)) as Sprite
53
54export const marksOf = ({ offset = 0, dust, shadow }: Frame): Mark[] => [
55  ...(dust && offset > 0 ? [{ row: ROWS - 1, col: 0, text: '·' }, { row: ROWS - 1, col: COLS - 1, text: '·' }] : []),
56  ...(shadow ? [{ row: ROWS - 1, col: 3, text: '▁▁▁' }] : []),
57]
58
59const hold = (rows: Sprite, n = 1, extra: Omit<Frame, 'rows'> = {}): Frame[] => Array.from({ length: n }, () => ({ rows, ...extra }))
60const crouch = (rows: Sprite = STAND, n = 2) => hold(rows, n, { offset: 1, dust: true })
61const blink = [...hold(SHUT), ...hold(STAND)]
62const turning = (n = 1, extra: Omit<Frame, 'rows'> = {}) => TURNAROUND.flatMap(rows => hold(rows, n, extra))
63const hops = (xs: number[]) => xs.flatMap(x => [...hold(AIR, 1, { x }), ...hold(STAND, 1, { x, offset: 1, dust: true })])
64const walk = (xs: number[], look: Sprite) =>
65  xs.flatMap((x, i) => hold([look[0], look[1], i % 2 === 0 ? FEET.tapLeft : FEET.tapRight], 2, { x }))
66
67export const CLICK_MS = 70
68
69// What a click plays, one by name, each frame lasting CLICK_MS. Every one ends back on STAND.
70export const CLICKS: Record<string, Frame[]> = {
71  jump: [...crouch(), ...hold(AIR, 3), ...crouch(STAND, 1), ...hold(AIR, 2), ...crouch(), ...hold(CHEER, 2), ...hold(STAND)],
72  look: [...hold(RIGHT, 4), ...hold(LEFT, 4), ...hold(UP, 3), ...hold(STAND)],
73  spin: [...turning(), ...turning(), ...hold(CHEER, 2), ...hold(STAND)],
74  peekaboo: [
75    ...hold(STAND, 1, { offset: ROWS }), ...hold(STAND, 3, { offset: 2 }), ...hold(RIGHT, 3, { offset: 2 }),
76    ...hold(LEFT, 3, { offset: 2 }), ...hold(STAND, 2, { offset: 2 }), ...hold(STAND, 1, { offset: 1 }), ...hold(CHEER, 3), ...blink,
77  ],
78  drop: [
79    ...hold(CHEER, 1, { offset: -ROWS }), ...hold(CHEER, 1, { offset: -2 }), ...hold(CHEER, 1, { offset: -1 }),
80    ...crouch(), ...hold(AIR, 1), ...crouch(STAND, 1), ...blink,
81  ],
82  wink: [...hold(WINK, 5), ...hold(STAND)],
83  boop: [...crouch(SHUT, 3), ...hold(LEFT, 4), ...hold(STAND, 2), ...blink],
84  tap: [
85    ...[2, 2, 2, 2, 1, 1].flatMap((n, i) => hold([HEAD.open, BODY.rest, i % 2 === 0 ? FEET.tapLeft : FEET.tapRight], n)),
86    ...hold(UP, 2), ...hold(CHEER, 2), ...hold(STAND),
87  ],
88  sneeze: [
89    ...hold([HEAD.shut, BODY.both, FEET.stand], 4), ...crouch(SHUT, 2),
90    ...[1, -1, 1, -1].flatMap(x => hold(SHUT, 1, { x })), ...hold(STAND, 2), ...blink,
91  ],
92  turn: [...turning(2), ...hold(STAND)],
93  'coin-hop': [
94    ...crouch(), ...TURNAROUND.slice(1, -1).flatMap(([head, body]) => hold([head, body, FEET.none], 1, { shadow: true })), ...hold(AIR, 1, { shadow: true }),
95    ...crouch(STAND, 1), ...hold(CHEER, 2), ...hold(STAND),
96  ],
97  skip: [...hops([3, 6, COLS]), ...hops([-6, -3]), ...hold(CHEER, 2), ...hold(STAND)],
98  waddle: [...walk([-2, -4, -6, -COLS], LEFT), ...hold(STAND, 3, { x: -COLS }), ...walk([COLS - 2, 6, 4, 2, 1, 0], LEFT), ...hold(STAND, 2), ...blink],
99  peek: [
100    ...walk([-3, -6, -COLS], LEFT), ...hold(RIGHT, 3, { x: -COLS }), ...hold(RIGHT, 4, { x: -6 }), ...hold(RIGHT, 2, { x: -COLS }),
101    ...hold(CHEER, 4, { x: -5 }), ...walk([-3, -1, 0], RIGHT), ...hold(STAND, 2),
102  ],
103}
104
105// Every click is played once before any repeats, and a new round never opens with the one just played.
106export const dealClicks = (last?: string, random: () => number = Math.random): string[] => {
107  const deck = Object.keys(CLICKS)
108  for (let i = deck.length - 1; i > 0; i--) {
109    const j = Math.floor(random() * (i + 1))
110    ;[deck[i], deck[j]] = [deck[j]!, deck[i]!]
111  }
112  return deck[0] === last ? [...deck.slice(1), deck[0]!] : deck
113}
114
types/index.d.ts 20 lines
1export type Mood = 'idle' | 'working' | 'happy' | 'oops' | 'asleep' | 'loved'
2
3export type Activity =
4  | 'think' | 'laptop' | 'hammer' | 'magnify' | 'notes' | 'web' | 'agents'
5  | 'test' | 'git' | 'install' | 'build' | 'ask' | 'mcp' | 'skill' | 'plan'
6
7// What is bothering him on top of his mood: usage near a limit, a cold cache, or a tool that has run a long time.
8export type Strain = 'none' | 'hot' | 'cold' | 'slow'
9
10export type Scene = { mood: Mood; activity: Activity; skin: string; isStill: boolean; strain: Strain }
11
12export type Signals = { hot: boolean; slow: number }
13
14declare module 'claude-code' {
15  interface PluginState {
16    buddy: { mood: Mood; activity: Activity; skin: string; signals: Signals }
17    'cache-timer': { last: { at: number; read: number; write: number; fresh: number } | null; now: number; ttlMin: number; twinkle: number }
18  }
19}
20