A pixel Clawd above your prompt that grows with your context window and pops on /compact.

A pixel Clawd who lives in the corner above your Claude Code prompt and eats your context window. The fuller the context, the fatter he gets. Near the limit he shakes and cracks. Run /compact and he pops, sits as an egg while the compaction runs, and hatches small again.

There is no text in the band: Clawd is the gauge. Keep exact numbers in your status line.

| Context | Stage | What he does |
|---|---|---|
| 0–24% | light | the logo Clawd, blinking and breathing |
| 25–49% | fed | wider, happy ^ ^ eyes |
| 50–74% | stuffed | taller, squinting, a drop of sweat |
| 75–89% | bloated | round, red cheeks, swaying side to side |
| 90%+ | about to pop | cracks, × eyes, flashing and shaking |
His feet never leave the ground: only the body moves.
Working and eating. While Claude works he sways and types. After each turn the new tokens fly in and he munches them.
Popping on /compact. He bursts, turns into an egg that wobbles for as long as the compaction runs, then hatches at the new, smaller size.

Subagent babies. Each running subagent stands beside him as a tiny Clawd (up to three) and leaves when it finishes.

Sleep and night owl. After 5 idle minutes he falls asleep at any hour: he lies perfectly still while z z Z float up beside him, and your next prompt wakes him. From 23:00 he gets sleepy, and from 02:00 to 06:00 he dozes off even while Claude works.

Passport. /clawd opens a panel with his age, sessions, tokens eaten, pops survived and babies. It is kept across sessions on your machine.

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, either in your shell or under env in ~/.claude/settings.json.Inside a Claude Code session:
/plugin install clawdgotchi --marketplace arthurseredaa/clawdgotchi
Or from your shell:
claude plugin marketplace add arthurseredaa/clawdgotchi
claude plugin install clawdgotchi@clawdgotchi
Update with claude plugin update clawdgotchi@clawdgotchi.
[-] in the corner of the band; it collapses the band (also ctrl+x ctrl+a).git clone https://github.com/arthurseredaa/clawdgotchi
cd clawdgotchi
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir . # hot-reloads on save
claude plugin validate .
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test
python3 scripts/render-media.py # regenerates docs/media (Node + Pillow)
The first claude --plugin-dir . writes the mods API types for your Claude Code version to .claude-plugin/types/ (git-ignored), which tsconfig.json uses for editor support.
Code layout: hooks/clawdgotchi.mjs wires events, state and drawing; sprite.mjs (pixels and animation), cells.mjs (pixels to terminal cells), scene.mjs (which animation plays) and passport.mjs are pure and unit-tested. The design is in docs/spec.md.
Unofficial fan project, not affiliated with or endorsed by Anthropic. Clawd is Anthropic's character. No Anthropic logos are included.
MIT
hooks/clawdgotchi.mjs 326 lines1import { atom, read, update } from 'claude-code'
2import { H, POP_MS, frame, widthOf, mirror, glyphsOf, mirrorGlyphs } from './sprite.mjs'
3import { toCells } from './cells.mjs'
4import { resolveScene, EAT_MS, HATCH_FROM, MAX_EGG_MS } from './scene.mjs'
5import { passportLines } from './passport.mjs'
6
7const reading = atom({ plugin: 'clawdgotchi', key: 'reading' }, null)
8
9const SPRITE_KEY = 'clawd'
10const TICK_MS = 125
11const COLLAPSE_COLUMNS = 4
12const ROWS = H / 2
13const PANE_ID = 'clawd'
14const SWEEP_TICKS = 16
15const STAT_KEYS = ['born', 'sessions', 'eaten', 'pops', 'babies']
16
17// Animation state: fine to lose on hot reload.
18let t = 0
19let timer = null
20let working = false
21let eatUntil = 0
22let popStart = null
23let hatchStart = null
24let lastActivity = 0
25let percent = 0
26let lastTurnTokens = null
27let preCompactTokens = null
28let rasterShown = false
29let bandRequestId = null
30let mountedColumns = null
31let useBlit = true
32const babies = new Set()
33
34function sceneAt(now, pct) {
35 const hour = new Date(now).getHours()
36 return resolveScene({ percent: pct, working, eatUntil, popStart, hatchStart, lastActivity, babies: babies.size, hour }, now)
37}
38
39// The passport lives in $.store, shared by every session on the machine: read right before each write.
40async function bump($, key, by) {
41 try {
42 const value = Number((await $.store.get(key)) ?? 0)
43 await $.store.set(key, value + by)
44 } catch {}
45}
46
47async function loadStats($) {
48 const stats = {}
49 for (const key of STAT_KEYS) {
50 try {
51 const value = await $.store.get(key)
52 if (value != null) stats[key] = value
53 } catch {}
54 }
55 return stats
56}
57
58async function sweepBabies($) {
59 try {
60 const running = new Set((await $.agent.list()).filter((a) => a.status === 'running' || a.status === 'pending').map((a) => a.id))
61 for (const id of [...babies]) if (!running.has(id)) babies.delete(id)
62 } catch {}
63}
64
65// The band shows Clawd mirrored into the right corner; the passport shows him as drawn.
66function cellsFor(scene, flip = true) {
67 const grid = frame(scene, t)
68 if (!flip) return toCells(grid, glyphsOf(scene, t))
69 return toCells(mirror(grid), mirrorGlyphs(glyphsOf(scene, t), grid[0].length))
70}
71
72async function setReading($, r) {
73 percent = r.percent
74 await update($, reading, () => r)
75}
76
77// usage() leaves tokens out in a fresh window and right after a compaction:
78// keep the last reading then, unless the window is new (start, /clear), which starts at zero.
79async function refresh($, fresh = false) {
80 let context
81 try {
82 context = (await $.session.usage()).context
83 } catch {
84 return { r: null, compacted: false }
85 }
86 if (context.tokens == null) {
87 const prev = fresh ? null : await read($, reading)
88 if (prev) return { r: prev, compacted: false }
89 const r = { tokens: 0, window: context.window, percent: 0 }
90 await setReading($, r)
91 return { r, compacted: false }
92 }
93 const r = { tokens: context.tokens, window: context.window, percent: context.percent ?? (context.tokens / context.window) * 100 }
94 await setReading($, r)
95 if (preCompactTokens != null && r.tokens < preCompactTokens) {
96 lastTurnTokens = r.tokens
97 preCompactTokens = null
98 hatchStart = await $.clock.now()
99 return { r, compacted: true }
100 }
101 return { r, compacted: false }
102}
103
104async function tick($) {
105 t += 1
106 const now = await $.clock.now()
107 if (babies.size > 0 && t % SWEEP_TICKS === 0) await sweepBabies($)
108 if (popStart != null) {
109 if (hatchStart == null && preCompactTokens != null && t % 8 === 0) await refresh($)
110 if (hatchStart == null && now - popStart > MAX_EGG_MS) hatchStart = now
111 if (hatchStart != null && sceneAt(now, percent).popT == null && now - hatchStart >= POP_MS - HATCH_FROM) {
112 popStart = null
113 hatchStart = null
114 }
115 }
116 if (!rasterShown || bandRequestId == null) return
117 const scene = sceneAt(now, percent)
118 // blit cannot resize a Raster: a stage change or the pop's wider grid needs a full redraw.
119 if (widthOf(scene) !== mountedColumns) {
120 $.ui.invalidate('ui.render')
121 return
122 }
123 if (useBlit) {
124 let res
125 try {
126 res = await $.ui.blit({
127 requestId: bandRequestId,
128 key: SPRITE_KEY,
129 columns: mountedColumns,
130 rows: ROWS,
131 cells: cellsFor(scene),
132 })
133 } catch {
134 res = { deny: 'blit threw' }
135 }
136 if (!(res && res.deny)) return
137 useBlit = false
138 }
139 $.ui.invalidate('ui.render')
140}
141
142async function resetSession($) {
143 working = false
144 babies.clear()
145 eatUntil = 0
146 popStart = null
147 hatchStart = null
148 preCompactTokens = null
149 lastActivity = await $.clock.now()
150 const { r } = await refresh($, true)
151 lastTurnTokens = r ? r.tokens : null
152}
153
154// Also redraw the band: after a hot reload or a plugin update it still shows the old module's drawing,
155// and this instance's tick stays idle until its own ui.render has run.
156async function startTimer($) {
157 if (!timer) timer = $.clock.every(TICK_MS, () => tick($))
158 $.ui.invalidate('ui.render')
159}
160
161async function setUp($) {
162 try {
163 if ((await $.store.get('born')) == null) await $.store.set('born', await $.clock.now())
164 } catch {}
165 try {
166 await $.command.register({ name: 'clawd', description: 'Show Clawd’s passport', immediate: true })
167 } catch {}
168}
169
170export function register(on) {
171 on('session.start', async ($, e, next) => {
172 await resetSession($)
173 await setUp($)
174 await startTimer($)
175 return next(e)
176 })
177
178 on('classic.SessionStart', { source: 'startup' }, async ($, e, next) => {
179 await bump($, 'sessions', 1)
180 return next(e)
181 })
182
183 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
184 await resetSession($)
185 await startTimer($)
186 return next(e)
187 })
188
189 on('turn.start', async ($, e, next) => {
190 working = true
191 lastActivity = await $.clock.now()
192 return next(e)
193 })
194
195 on('prompt.submit', async ($, e, next) => {
196 lastActivity = await $.clock.now()
197 return next(e)
198 })
199
200 // Subagents show up as babies beside Clawd.
201 on('agent.spawn', async ($, e, next) => {
202 const result = await next(e)
203 if (result && result.agentId) {
204 babies.add(result.agentId)
205 await bump($, 'babies', 1)
206 }
207 return result
208 })
209
210 on('classic.SubagentStop', async ($, e, next) => {
211 babies.delete(e.agent_id)
212 return next(e)
213 })
214
215 on('command.run', { command: 'clawd' }, async ($) => {
216 await $.ui.open({ id: PANE_ID, title: 'Clawdgotchi', focus: true, closeOnEscape: true })
217 return {}
218 })
219
220 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
221 if (e.requestId !== PANE_ID) return next(e)
222 const { Box, Text, Raster } = $.ui.resolve(e)
223 const now = await $.clock.now()
224 const lines = passportLines(await loadStats($), now).map((line) => Text({ wrap: 'truncate', children: [line] }))
225 const info = Box({ flexDirection: 'column', children: lines })
226 if (e.surface !== 'terminal') return info
227 const scene = sceneAt(now, percent)
228 const portrait = Raster({ key: 'clawd-portrait', columns: widthOf(scene), rows: ROWS, cells: cellsFor(scene, false) })
229 return Box({ flexDirection: 'row', columnGap: 2, children: [portrait, info] })
230 })
231
232 on('turn.complete', async ($, e, next) => {
233 // A subagent's turn ends while the main turn is still running; its baby leaves.
234 if (e.agentId) {
235 babies.delete(e.agentId)
236 return next(e)
237 }
238 const result = await next(e)
239 working = false
240 const now = await $.clock.now()
241 lastActivity = now
242 const before = lastTurnTokens
243 const { r, compacted } = await refresh($)
244 if (r && !compacted) {
245 if (before != null && r.tokens > before) {
246 eatUntil = now + EAT_MS
247 await bump($, 'eaten', r.tokens - before)
248 }
249 lastTurnTokens = r.tokens
250 }
251 return result
252 })
253
254 on('session.measure', async ($, e, next) => {
255 const result = await next(e)
256 await refresh($)
257 return result
258 })
259
260 // next(e) resolves once the compaction is done, so the egg wobbles for exactly as long as it runs.
261 on('session.compact', { trigger: ['manual', 'auto', 'plugin'] }, async ($, e, next) => {
262 if (e.agentId) return next(e)
263 popStart = await $.clock.now()
264 hatchStart = null
265 const prev = await read($, reading)
266 preCompactTokens = prev ? prev.tokens : null
267 let result
268 try {
269 result = await next(e)
270 } catch (err) {
271 popStart = null
272 hatchStart = null
273 preCompactTokens = null
274 throw err
275 }
276 if (!result || result.skip) {
277 popStart = null
278 preCompactTokens = null
279 return result
280 }
281 if (result.tokensAfter != null && prev) {
282 await setReading($, { tokens: result.tokensAfter, window: prev.window, percent: (result.tokensAfter / prev.window) * 100 })
283 lastTurnTokens = result.tokensAfter
284 preCompactTokens = null
285 hatchStart = await $.clock.now()
286 }
287 await bump($, 'pops', 1)
288 return result
289 })
290
291 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
292 const theirs = await next(e)
293 // A band shorter than the sprite would show a scrolled sliver and a "↓ more" line.
294 if (e.props.hasSurvey || e.props.maxRows < ROWS) {
295 rasterShown = false
296 return theirs
297 }
298 const r = await read($, reading)
299 if (!r) {
300 rasterShown = false
301 return theirs
302 }
303 // Desktop has no Raster; the band stays as other mods draw it there.
304 if (e.surface !== 'terminal') {
305 rasterShown = false
306 return theirs
307 }
308 const { Box, Raster } = $.ui.resolve(e)
309 const now = await $.clock.now()
310 const scene = sceneAt(now, r.percent)
311 const columns = widthOf(scene)
312 const ours = Box({
313 key: 'clawd-row',
314 flexDirection: 'row',
315 justifyContent: 'flex-end',
316 // Leave room for the engine's [-] collapse control in the band's top-right corner.
317 width: Math.max(columns, e.props.bodyColumns - COLLAPSE_COLUMNS),
318 children: [Raster({ key: SPRITE_KEY, columns, rows: ROWS, cells: cellsFor(scene) })],
319 })
320 bandRequestId = e.requestId
321 mountedColumns = columns
322 rasterShown = true
323 return theirs ? Box({ flexDirection: 'column', children: [ours, theirs] }) : ours
324 }).catch(($, e, next) => next(e))
325}
326hooks/sprite.mjs 261 lines1export const H = 8
2export const POP_MS = 2600
3
4// Clawd stands at a fixed left edge; the grid is as wide as his stage needs plus room on the right for effects.
5const LEFT = 2
6const EFFECTS = 3
7const widthForBody = (bw) => LEFT + bw + 1 + EFFECTS
8
9export const COLORS = {
10 base: 0xd97757,
11 eye: 0x141216,
12 white: 0xf6f1eb,
13 cheek: 0xf59a9a,
14 sweat: 0x6fb7ff,
15 crack: 0x6e2416,
16 alarm: 0xffd45e,
17 z: 0xbdb7cc,
18 crumb: 0xf5c26b,
19 crumb2: 0x9be38f,
20 egg: 0xefe6d8,
21 eggCrack: 0x7e6e5b,
22 flash: 0xff8a5c,
23}
24
25export const STAGES = [
26 { name: 'Легкий', bw: 10, bh: 5, leg: 2, round: 0, color: 0xd97757, eyes: 'dot' },
27 { name: 'Ситий', bw: 12, bh: 5, leg: 2, round: 0, color: 0xdb7253, eyes: 'happy' },
28 { name: 'Наїдений', bw: 12, bh: 6, leg: 2, round: 1, color: 0xde6a4c, eyes: 'squint' },
29 { name: 'Роздутий', bw: 14, bh: 6, leg: 1, round: 1, color: 0xe25c40, eyes: 'wide' },
30 { name: 'Ось-ось лусне', bw: 16, bh: 7, leg: 1, round: 2, color: 0xe8452e, eyes: 'x' },
31]
32
33export function stageOf(percent) {
34 const p = Number.isFinite(percent) ? percent : 0
35 return p >= 90 ? 4 : p >= 75 ? 3 : p >= 50 ? 2 : p >= 25 ? 1 : 0
36}
37
38export const POP_WIDTH = widthForBody(16)
39// Each subagent baby takes a 5-pixel body plus room to sway without touching its neighbour.
40export const BABY_SLOT = 7
41
42function baseWidth(scene) {
43 if (scene.popT != null && scene.popT < POP_MS) return POP_WIDTH
44 return widthForBody(STAGES[scene.stage].bw)
45}
46
47export function widthOf(scene) {
48 return baseWidth(scene) + (scene.babies || 0) * BABY_SLOT
49}
50
51function blank(w) {
52 return Array.from({ length: H }, () => Array(w).fill(null))
53}
54
55function put(g, x, y, c) {
56 x = Math.round(x)
57 y = Math.round(y)
58 if (y >= 0 && y < H && x >= 0 && x < g[0].length) g[y][x] = c
59}
60
61function cut(r, c, o) {
62 if (!o.round) return false
63 const rr = Math.min(r, o.bh - 1 - r)
64 const cc = Math.min(c, o.bw - 1 - c)
65 if (o.round === 1) return rr === 0 && cc === 0
66 return (rr === 0 && cc <= 1) || (rr === 1 && cc === 0)
67}
68
69function drawEye(g, x, y, o, side) {
70 const k = COLORS.eye
71 const inward = -side
72 if (o.blink || o.eyes === 'closed') {
73 put(g, x, y + 1, k)
74 put(g, x + inward, y + 1, k)
75 return
76 }
77 switch (o.eyes) {
78 case 'dot':
79 put(g, x, y, k); put(g, x, y + 1, k)
80 break
81 case 'happy':
82 put(g, x - 1, y + 1, k); put(g, x, y, k); put(g, x + 1, y + 1, k)
83 break
84 case 'squint':
85 put(g, x, y + 1, k); put(g, x + inward, y + 1, k)
86 break
87 case 'wide':
88 put(g, x, y, COLORS.white); put(g, x + inward, y, k)
89 put(g, x, y + 1, k); put(g, x + inward, y + 1, k)
90 put(g, x + side, y + 2, COLORS.cheek); put(g, x, y + 2, COLORS.cheek)
91 break
92 case 'x':
93 put(g, x - 1, y, k); put(g, x + 1, y, k); put(g, x, y + 1, k)
94 put(g, x - 1, y + 2, k); put(g, x + 1, y + 2, k)
95 break
96 }
97}
98
99// Legs stay on the ground row at columns derived from the stage's base width; only the body moves.
100function drawClawd(g, o) {
101 const ground = H - 1
102 const legBw = o.legBw || o.bw
103 const legLeft = LEFT
104 const sq = Math.max(0, Math.min(o.squash || 0, o.leg - 1))
105 const top = ground - o.leg + 1 - o.bh + sq
106 const left = LEFT - Math.round((o.bw - legBw) / 2) + (o.dx || 0)
107 for (const lc of [1, 3, legBw - 4, legBw - 2]) {
108 for (let y = top + o.bh; y <= ground; y++) put(g, legLeft + lc, y, o.color)
109 }
110 for (let r = 0; r < o.bh; r++) {
111 for (let c = 0; c < o.bw; c++) if (!cut(r, c, o)) put(g, left + c, top + r, o.color)
112 }
113 const ar = top + o.bh - 3
114 for (let r = 0; r < 2; r++) {
115 put(g, left - 1, ar + r - (o.armL || 0), o.color)
116 put(g, left + o.bw, ar + r - (o.armR || 0), o.color)
117 }
118 const ex = Math.round(o.bw / 6)
119 const ey = top + Math.round((o.bh * 2) / 7)
120 drawEye(g, left + ex, ey, o, -1)
121 drawEye(g, left + o.bw - 1 - ex, ey, o, 1)
122 if (o.cracks) {
123 const pts = [[7, 0], [8, 1], [7, 2], [4, o.bh - 2], [5, o.bh - 1], [o.bw - 5, o.bh - 2], [o.bw - 6, o.bh - 1]]
124 for (const [c, r] of pts) put(g, left + c, top + r, COLORS.crack)
125 }
126 return { left, top, right: left + o.bw - 1 }
127}
128
129function drawCrumbs(g, geo, t) {
130 const target = geo.right + 2
131 const edge = g[0].length - 1
132 ;[COLORS.crumb, COLORS.sweat, COLORS.crumb2].forEach((col, i) => {
133 const p = ((t + i * 3) % 9) / 9
134 put(g, edge - p * (edge - target), geo.top + i, col)
135 })
136}
137
138// A baby is a 5x3 body (solid crown, eyes on the middle row) on two 1-pixel legs;
139// while the parent works, babies sway in turn.
140function drawBaby(g, x0, i, scene, t) {
141 const ground = H - 1
142 const color = COLORS.base
143 const dx = scene.mode === 'work' && Math.floor((t + i * 2) / 2) % 2 ? 1 : 0
144 put(g, x0 + 1, ground, color)
145 put(g, x0 + 3, ground, color)
146 for (let r = ground - 3; r < ground; r++) for (let c = 0; c < 5; c++) put(g, x0 + c + dx, r, color)
147 if ((t + i * 7) % 30 >= 2) {
148 put(g, x0 + 1 + dx, ground - 2, COLORS.eye)
149 put(g, x0 + 3 + dx, ground - 2, COLORS.eye)
150 }
151}
152
153function drawPop(g, pt, t) {
154 const s4 = STAGES[4]
155 const cx = LEFT + s4.bw / 2
156 const eggX = LEFT + STAGES[0].bw / 2
157 if (pt < 500) {
158 drawClawd(g, { ...s4, dx: t % 2 ? 1 : -1, cracks: true, color: pt > 380 ? COLORS.white : s4.color })
159 return
160 }
161 if (pt < 1300) {
162 const p = (pt - 500) / 800
163 for (let i = 0; i < 22; i++) {
164 const a = (i / 22) * Math.PI * 2 + (i % 3) * 0.2
165 const v = 0.55 + ((i * 37) % 10) / 20
166 const x = cx + Math.cos(a) * v * p * 11
167 const y = 4 + Math.sin(a) * v * p * 5 + p * p * 3
168 if (p < 0.8 || i % 2) put(g, x, y, [s4.color, COLORS.flash, COLORS.alarm, COLORS.base][i % 4])
169 }
170 if (p < 0.2) for (let dy = -1; dy <= 1; dy++) for (let dx = -2; dx <= 2; dx++) put(g, cx + dx, 4 + dy, COLORS.white)
171 return
172 }
173 if (pt < 2300) {
174 const rows = [3, 5, 5, 5, 3]
175 const top = H - 5
176 const dx = pt > 1600 ? [0, 1, 0, -1][t % 4] : 0
177 rows.forEach((w, r) => {
178 const l = Math.round(eggX - w / 2) + dx
179 for (let c = 0; c < w; c++) put(g, l + c, top + r, COLORS.egg)
180 })
181 put(g, eggX - 1 + dx, top + 1, COLORS.base)
182 put(g, eggX + 1 + dx, top + 3, COLORS.base)
183 if (pt > 2000) for (let k = 0; k < 5; k++) put(g, eggX - 2 + k + dx, top + 2 + (k % 2), COLORS.eggCrack)
184 return
185 }
186 drawClawd(g, { ...STAGES[0], squash: pt < 2450 ? 1 : 0 })
187 put(g, 0, H - 1, COLORS.egg)
188 put(g, LEFT + STAGES[0].bw + 2, H - 1, COLORS.egg)
189 put(g, LEFT + STAGES[0].bw + 3, H - 1, COLORS.egg)
190}
191
192// Sleeping Clawd gets real letters, not pixels: "z z Z" climbing away from him one by one.
193// Glyphs are terminal cells: x is a column of the grid, row a cell row (two pixel rows).
194export function glyphsOf(scene, t) {
195 if (scene.mode !== 'sleep' || (scene.popT != null && scene.popT < POP_MS)) return []
196 const edge = baseWidth(scene)
197 const letters = [
198 { x: edge - 3, row: 2, ch: 'z', color: COLORS.z },
199 { x: edge - 2, row: 1, ch: 'z', color: COLORS.z },
200 { x: edge - 1, row: 0, ch: 'Z', color: COLORS.z },
201 ]
202 return letters.slice(0, Math.floor(t / 4) % 4)
203}
204
205export function mirrorGlyphs(glyphs, width) {
206 return glyphs.map((g) => ({ ...g, x: width - 1 - g.x }))
207}
208
209// Clawd lives in the right corner: flip the grid so he hugs the right edge and effects appear on his left.
210export function mirror(grid) {
211 return grid.map((row) => [...row].reverse())
212}
213
214export function frame(scene, t) {
215 const g = blank(widthOf(scene))
216 const babiesFrom = baseWidth(scene)
217 for (let i = 0; i < (scene.babies || 0); i++) drawBaby(g, babiesFrom + i * BABY_SLOT, i, scene, t)
218 if (scene.popT != null && scene.popT < POP_MS) {
219 drawPop(g, scene.popT, t)
220 return g
221 }
222 const s = STAGES[scene.stage]
223 const m = scene.mode
224 const o = { ...s, dx: 0, squash: 0 }
225 if (m === 'idle') {
226 if (scene.stage <= 1) o.squash = Math.floor(t / 4) % 2
227 else if (scene.stage === 2) o.squash = Math.floor(t / 6) % 2
228 else if (scene.stage === 3) o.dx = [0, 1, 0, -1][Math.floor(t / 3) % 4]
229 else o.dx = [1, -1, 0, 1, -1, 1, 0, -1][t % 8]
230 }
231 if (scene.stage === 4) {
232 o.cracks = true
233 if (Math.floor(t / 2) % 2) o.color = COLORS.flash
234 }
235 if (m === 'work') {
236 const w = Math.floor(t / 2) % 2
237 o.dx = w ? 1 : 0
238 o.armL = w
239 o.armR = 1 - w
240 }
241 if (m === 'sleepy') {
242 o.eyes = 'closed'
243 o.squash = Math.floor(t / 8) % 2
244 }
245 // Asleep he does not move at all; only the z letters do.
246 if (m === 'sleep') {
247 o.eyes = 'closed'
248 o.squash = 0
249 }
250 if (m === 'eat') {
251 o.legBw = o.bw
252 o.bw += (Math.floor(t / 2) % 2) * 2
253 o.squash = Math.floor(t / 2) % 2
254 }
255 o.blink = m !== 'sleep' && m !== 'sleepy' && s.eyes === 'dot' && t % 24 < 2 && t > 0
256 const geo = drawClawd(g, o)
257 if (m === 'eat') drawCrumbs(g, geo, t)
258 if (s.eyes === 'squint' && m !== 'sleep') put(g, geo.right + 2, geo.top + (Math.floor(t / 2) % 4), COLORS.sweat)
259 return g
260}
261hooks/cells.mjs 40 lines1export const DEFAULT_COLOR = 0x01000000
2
3const UPPER_HALF = 0x2580
4const LOWER_HALF = 0x2584
5const FULL_BLOCK = 0x2588
6const SPACE = 0x20
7
8const luminance = (c) => 0.2126 * ((c >> 16) & 0xff) + 0.7152 * ((c >> 8) & 0xff) + 0.0722 * (c & 0xff)
9
10// Paint with glyph color wherever possible: terminals with window transparency (iTerm2)
11// blend every background color, which made background-painted pixels look darker.
12// A background is used only when one cell holds two colors, and then for the darker one.
13export function cellOf(top, bottom) {
14 if (top == null && bottom == null) return [SPACE, DEFAULT_COLOR, DEFAULT_COLOR]
15 if (top == null) return [LOWER_HALF, bottom, DEFAULT_COLOR]
16 if (bottom == null) return [UPPER_HALF, top, DEFAULT_COLOR]
17 if (top === bottom) return [FULL_BLOCK, top, DEFAULT_COLOR]
18 return luminance(top) >= luminance(bottom) ? [UPPER_HALF, top, bottom] : [LOWER_HALF, bottom, top]
19}
20
21// glyphs: [{ x, row, ch, color }] written into whole cells over the pixels (row counts cells, not pixels).
22export function toCells(grid, glyphs = []) {
23 const at = new Map(glyphs.map((g) => [`${g.row}:${g.x}`, g]))
24 const nums = []
25 for (let r = 0; r + 1 < grid.length; r += 2) {
26 for (let c = 0; c < grid[r].length; c++) {
27 const g = at.get(`${r / 2}:${c}`)
28 nums.push(...(g ? [g.ch.codePointAt(0), g.color, DEFAULT_COLOR] : cellOf(grid[r][c], grid[r + 1][c])))
29 }
30 }
31 return toBase64(new Uint8Array(Uint32Array.from(nums).buffer))
32}
33
34function toBase64(bytes) {
35 if (typeof bytes.toBase64 === 'function') return bytes.toBase64()
36 let s = ''
37 for (const b of bytes) s += String.fromCharCode(b)
38 return btoa(s)
39}
40hooks/scene.mjs 47 lines1import { POP_MS, stageOf } from './sprite.mjs'
2
3export const EAT_MS = 1600
4export const SLEEP_MS = 5 * 60 * 1000
5export const EGG_LOOP_FROM = 1600
6export const EGG_LOOP_MS = 400
7export const HATCH_FROM = 2000
8export const MAX_EGG_MS = 90 * 1000
9
10// Burst first, then the egg wobbles until the compaction is done, then it cracks and hatches.
11export function popTime(popStart, hatchStart, now) {
12 if (popStart == null) return null
13 const elapsed = now - popStart
14 if (hatchStart != null) {
15 const hatchAt = Math.max(hatchStart, popStart + EGG_LOOP_FROM)
16 if (now >= hatchAt) {
17 const p = HATCH_FROM + (now - hatchAt)
18 return p < POP_MS ? p : null
19 }
20 }
21 if (elapsed < EGG_LOOP_FROM) return elapsed
22 return EGG_LOOP_FROM + ((elapsed - EGG_LOOP_FROM) % EGG_LOOP_MS)
23}
24
25export const MAX_BABIES = 3
26
27// 23:00–01:59 Clawd is sleepy; 02:00–05:59 he dozes off even while Claude works.
28export function nightPhase(hour) {
29 if (hour == null) return null
30 if (hour >= 23 || hour < 2) return 'late'
31 if (hour < 6) return 'deep'
32 return null
33}
34
35export function resolveScene(s, now) {
36 const stage = stageOf(s.percent)
37 const babies = Math.min(MAX_BABIES, s.babies || 0)
38 const night = nightPhase(s.hour)
39 const scene = (mode, popT = null) => ({ stage, mode, popT, babies })
40 const popT = popTime(s.popStart, s.hatchStart, now)
41 if (popT != null) return scene('idle', popT)
42 if (now < s.eatUntil) return scene('eat')
43 if (s.working) return scene(night === 'deep' ? 'sleep' : 'work')
44 if (now - s.lastActivity >= SLEEP_MS) return scene('sleep')
45 return scene(night ? 'sleepy' : 'idle')
46}
47hooks/passport.mjs 35 lines1const DAY_MS = 24 * 60 * 60 * 1000
2
3export function fmtTokens(n) {
4 const a = Math.abs(n)
5 if (a >= 1e6) return `${+(a / 1e6).toFixed(1)}M`
6 if (a >= 1e3) return `${+(a / 1e3).toFixed(1)}k`
7 return String(Math.round(a))
8}
9
10const count = (n, one, many) => `${n} ${n === 1 ? one : many}`
11
12function ageText(born, now) {
13 const days = Math.floor((now - born) / DAY_MS)
14 if (!(days >= 1)) return 'born today'
15 return count(days, 'day', 'days')
16}
17
18function popsText(pops) {
19 if (pops === 0) return 'never'
20 if (pops === 1) return 'once'
21 return `${pops} times`
22}
23
24// stats: { born, sessions, eaten, pops, babies }, any of them missing on a fresh install.
25export function passportLines(stats, now) {
26 const { born = now, sessions = 0, eaten = 0, pops = 0, babies = 0 } = stats
27 return [
28 `Age: ${ageText(born, now)}`,
29 `Sessions: ${sessions}`,
30 `Ate: ${eaten === 1 ? '1 token' : `${fmtTokens(eaten)} tokens`}`,
31 `Popped: ${popsText(pops)}`,
32 `Babies: ${babies}`,
33 ]
34}
35types/index.d.ts 8 lines1export type ClawdReading = { tokens: number; window: number; percent: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 clawdgotchi: { reading: ClawdReading | null }
6 }
7}
8