SLOPSHOPPER

Clawdgotchi

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

newpanebandcommandprompttimer
v0.1.0MITupdated 2026-10-02arthurseredaa/clawdgotchi
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawdgotchi
│ ┃ Clawdgotchi ✕ › fix the failing auth test and add an audit log call │ ┃ ▄▄▄▄▄▄▄▄▄▄▄▄ Age: born today │ ┃ ▄██▀▀████▀▀██▄ Sessions: 1 ⏺ Read(src/auth.ts) │ ┃ ▀████████████▀ Ate: 0 tokens ⎿ Read 6 lines │ ┃ █ █ █ █ Popped: never ⏺ Update(src/auth.ts) │ ┃ Babies: 0 ⎿ 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 │ │ › /clawd │ │ ▄▄▄▄▄▄▄▄▄▄▄▄ ▄██▀▀████▀▀██▄ ▀████████████▀ █ █ █ █ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▄▄▄▄▄▄▄▄▄▄▄▄ ▄██▀▀████▀▀██▄ ▀████████████▀ █ █ █ █ ⟨Claude Code's own drawing⟩
Pane · Clawdgotchi
▄▄▄▄▄▄▄▄▄▄▄▄ Age: born today ▄██▀▀████▀▀██▄ Sessions: 1 ▀████████████▀ Ate: 0 tokens █ █ █ █ Popped: never Babies: 0
README

Clawdgotchi

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.

Clawd growing with the context window, then popping on /compact

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

Stages

The five stages, from light to about to pop

ContextStageWhat he does
0–24%lightthe logo Clawd, blinking and breathing
25–49%fedwider, happy ^ ^ eyes
50–74%stuffedtaller, squinting, a drop of sweat
75–89%bloatedround, red cheeks, swaying side to side
90%+about to popcracks, × eyes, flashing and shaking

His feet never leave the ground: only the body moves.

Life around him

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.

Pop, egg, hatch

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

Three subagent babies next to Clawd

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.

Sleepy, then asleep

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

The /clawd passport

Requirements

  • Claude Code with mods. The docs list v2.1.287+; it was also tested on v2.1.284.
  • Mods are in early access, so turn them on with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, either in your shell or under env in ~/.claude/settings.json.
  • A terminal. The Claude desktop app cannot draw the sprite, so the mod shows nothing there.
  • If your terminal uses window transparency (iTerm2), Clawd still looks solid: he is drawn with glyph colors, not backgrounds.

Install

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.

Good to know

  • Claude Code draws its own [-] in the corner of the band; it collapses the band (also ctrl+x ctrl+a).
  • While a permission dialog or a question is open, Claude Code hides the band, so Clawd steps aside too.
  • In a band shorter than four rows he hides instead of showing a scrolled sliver.

Develop

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.

Disclaimer

Unofficial fan project, not affiliated with or endorsed by Anthropic. Clawd is Anthropic's character. No Anthropic logos are included.

License

MIT

Source 6 files
hooks/clawdgotchi.mjs 326 lines
1import { 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}
326
hooks/sprite.mjs 261 lines
1export 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}
261
hooks/cells.mjs 40 lines
1export 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}
40
hooks/scene.mjs 47 lines
1import { 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}
47
hooks/passport.mjs 35 lines
1const 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}
35
types/index.d.ts 8 lines
1export type ClawdReading = { tokens: number; window: number; percent: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    clawdgotchi: { reading: ClawdReading | null }
6  }
7}
8