A cute pet in the prompt footer that eats your Claude Code usage and shows your 5-hour and weekly limits

A square critter with tiny hands that lives in the Claude Code prompt footer. It is a mod for Claude Code, shipped with Bermuda.
bermuda check page the session works against, shows done/total, reminds the agent of open items on each prompt, and pays the pet for every item ticked (once per item, ever). Without a tracked page the slot reads ☐ --; without the bermuda command on PATH it stays that way and the pet and bars still work./plugin marketplace add bon5co/bermuda
/plugin install bermudamon@bermuda
claude plugin validate mods/bermudamon
claude plugin test mods/bermudamon
The screenshot in the main README is drawn from the mod's own code: bun screenshots/footer.ts | uv run screenshots/render.py ../../assets/bermudamon-footer.png.
The sprites are pixel grids in hooks/pet.ts; pack turns them into the quadrant blocks the footer draws. The tests hold every stage to the shape: square, mirror symmetric, dot eyes, two feet, hands that wave and go up to eat.
hooks/register.tsx 400 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Checklist, Limit, OpenItem } from '../types'
5import {
6 NUDGE_EVERY,
7 TICK_FOOD,
8 checklistCalls,
9 type CheckCall,
10 isEdit,
11 isMutating,
12 LISTING_VERBS,
13 noChecklistReminder,
14 openItemsReminder,
15 parseChecklist,
16 staleReminder,
17} from './checklist'
18import {
19 FEED,
20 FOODS,
21 STAGES,
22 TRACK,
23 feed,
24 hatch,
25 revive,
26 meter,
27 meterColor,
28 petRows,
29 stageIndex,
30 wander,
31 width,
32 fit,
33} from './pet'
34
35const pet = atom({ plugin: 'bermudamon', key: 'pet' } as const, null)
36const frame = atom({ plugin: 'bermudamon', key: 'frame' } as const, 0)
37const pos = atom({ plugin: 'bermudamon', key: 'pos' } as const, { x: 0, dir: 1 })
38const busy = atom({ plugin: 'bermudamon', key: 'busy' } as const, 0)
39const limits = atom({ plugin: 'bermudamon', key: 'limits' } as const, [])
40const snack = atom({ plugin: 'bermudamon', key: 'snack' } as const, 0)
41const tracking = atom({ plugin: 'bermudamon', key: 'tracking' } as const, { list: null, isUntracked: false })
42
43const STORE_KEY = 'pet'
44const TICK_MS = 500
45/** Ticks between snacks while eating: a new one every 4 s. */
46const SNACK_TICKS = 8
47
48const pickSnack = ($: EngineInterface) => update($, snack, () => Math.floor(Math.random() * FOODS.length))
49
50// One save at a time, each writing the pet as it stands when the save runs:
51// parallel feeds can finish in any order and the store still ends on the latest.
52let saving: Promise<void> = Promise.resolve()
53const persist = ($: EngineInterface) => {
54 saving = saving
55 .then(async () => {
56 const latest = await read($, pet)
57 if (latest) await $.store.set(STORE_KEY, latest)
58 })
59 .catch(() => undefined)
60}
61
62// Feeds run off the tool's and the turn's path; session.end waits for them.
63const pending = new Set<Promise<void>>()
64const inBackground = (work: Promise<void>) => {
65 const running: Promise<void> = work.catch(() => undefined).finally(() => pending.delete(running))
66 pending.add(running)
67}
68const flush = async () => {
69 while (pending.size > 0) await Promise.all([...pending])
70 await saving
71}
72
73const eat = async ($: EngineInterface, food: { xp: number; fullness: number }) => {
74 const now = await $.clock.now()
75 const after = await update($, pet, current => feed(current ?? hatch(now), food, now))
76 if (after) persist($)
77}
78
79// The bermuda checklist this session works against. The agent's own
80// `bermuda check` calls name it (the page they touch); until one does, it is
81// whatever the working directory resolves, as `bermuda check show` reads it.
82let sessionList: string | null = null
83let changesSinceCheck = 0
84let currentTurn = ''
85let remindedTurn: string | null = null
86/** Bumped by every reset, so work begun before a /clear cannot write into the new conversation. */
87let epoch = 0
88
89/** A new conversation (start, /clear) starts with no page of its own and a zero count. */
90const resetTracking = ($: EngineInterface) => {
91 epoch += 1
92 sessionList = null
93 changesSinceCheck = 0
94 remindedTurn = null
95 return update($, tracking, () => ({ list: null, isUntracked: false }))
96}
97
98/** The most a prompt waits for a fresh checklist reading. */
99const REFRESH_WAIT_MS = 250
100/** Items already paid for, across sessions, so untick and tick again earns nothing. */
101const REWARDED_KEY = 'rewarded'
102const REWARDED_MAX = 2_000
103
104const runBermuda = ($: EngineInterface, argv: readonly string[]) =>
105 $.process.run(['bermuda', 'check', ...argv], { timeoutMs: 3_000 })
106
107/** A page by name, or the cwd's page with its name told (plain `show` prints its path). */
108const readChecklist = async ($: EngineInterface, name: string | null): Promise<Checklist | null> => {
109 let page = name
110 if (page === null) {
111 const shown = await runBermuda($, ['show'])
112 const path = shown.exitCode === 0 ? shown.stdout.split('\n')[0]!.trim() : ''
113 if (!path.endsWith('.md')) return null
114 page = path.slice(path.lastIndexOf('/') + 1, -'.md'.length)
115 }
116 // The name goes before -raw: after it, bermuda ignores the name and shows the cwd's page.
117 const { exitCode, stdout } = await runBermuda($, ['show', page, '-raw'])
118 return exitCode === 0 ? parseChecklist(stdout, page) : null
119}
120
121/** Readings are numbered so a slow one landing late never rewinds a newer one. */
122let readings = 0
123let shownReading = 0
124
125/** Reads the page again; a reset since the read began, or a newer reading already shown, wins. */
126const refresh = async ($: EngineInterface): Promise<Checklist | null> => {
127 const began = epoch
128 const reading = ++readings
129 const list = await readChecklist($, sessionList).catch(() => null)
130 if (began !== epoch || reading < shownReading) return null
131 shownReading = reading
132 await update($, tracking, t => ({ list, isUntracked: list === null && t.isUntracked }))
133 return list
134}
135
136/**
137 * The checklist page a `bermuda check` call worked on. Other agents write
138 * pages too, so a page counts when the call names it, in full or by the part
139 * bermuda resolves (`show shown-task` for `branch-abc shown-task`), or its
140 * output does (`new` prints its path, `add` its name); unnamed, when it is the
141 * only page written during the call.
142 */
143const workedOn = async ($: EngineInterface, since: number, calls: readonly CheckCall[], output: string): Promise<string | null> => {
144 const home = await $.env.get('HOME')
145 if (!home) return null
146 const pages = (await $.fs.list(`${home}/.bermuda/memory/checklists`).catch(() => []))
147 .filter(page => page.kind === 'file' && page.name.endsWith('.md'))
148 .sort((a, b) => b.mtimeMs - a.mtimeMs)
149 const name = (page: { name: string }) => page.name.slice(0, -'.md'.length)
150 const words = calls.flatMap(call => call.args).filter(word => !/^\d+$/.test(word) && word.length >= 3)
151 // A fragment names a page only when it matches one, as bermuda itself refuses two.
152 const byFragment = words
153 .map(word => pages.map(name).filter(page => page.includes(word)))
154 .find(matches => matches.length === 1)?.[0]
155 const named = pages.map(name).find(page => output.includes(page)) ?? byFragment
156 if (named !== undefined) return named
157 const written = pages.filter(page => page.mtimeMs >= since)
158 return written.length === 1 ? name(written[0]!) : null
159}
160
161/**
162 * Pays for items this call's ticks named (by number or text prefix) that went
163 * from open to ticked on the page it worked on, each once ever. A tick another
164 * agent made in the same moment is seen, not paid.
165 */
166const rewardTicks = async (
167 $: EngineInterface,
168 before: Checklist | null,
169 after: Checklist | null,
170 ticks: readonly CheckCall[],
171) => {
172 // Only the same page, by name: a first look at a page is a baseline, never a payday.
173 if (!before || !after || before.name === null || before.name !== after.name) return
174 // bermuda reads a tick by arity: `tick 3` names an item, `tick <page> 3` a page then an item.
175 const targets = ticks.flatMap(call => call.args.slice(-1))
176 const numbers = new Set(targets.filter(arg => /^\d+$/.test(arg)).map(Number))
177 const prefixes = targets.filter(arg => !/^\d+$/.test(arg))
178 const isNamed = (item: OpenItem) => numbers.has(item.n) || prefixes.some(prefix => item.text.startsWith(prefix))
179 const key = (item: OpenItem) => `${after.name}\u0000${item.n}\u0000${item.text}`
180 const was = new Set(before.ticked.map(key))
181 const fresh = after.ticked.filter(isNamed).map(key).filter(k => !was.has(k))
182 if (fresh.length === 0) return
183 const stored = await $.store.get(REWARDED_KEY)
184 const paid = Array.isArray(stored) ? stored.filter((k): k is string => typeof k === 'string') : []
185 const unpaid = fresh.filter(k => !paid.includes(k))
186 if (unpaid.length === 0) return
187 await $.store.set(REWARDED_KEY, [...paid, ...unpaid].slice(-REWARDED_MAX))
188 await eat($, { xp: TICK_FOOD.xp * unpaid.length, fullness: TICK_FOOD.fullness * unpaid.length })
189}
190
191/** After the agent's own `bermuda check` call: follow the page it worked on and pay for its ticks. */
192const afterChecklistCall = async ($: EngineInterface, startedAt: number, calls: readonly CheckCall[], output: string) => {
193 const began = epoch
194 const before = (await read($, tracking)).list
195 const page = await workedOn($, startedAt, calls, output)
196 if (began !== epoch) return
197 if (page !== null) sessionList = page
198 const after = await refresh($)
199 if (began !== epoch) return
200 const ticks = calls.filter(call => call.verb === 'tick')
201 if (ticks.length > 0) await rewardTicks($, before, after, ticks)
202}
203
204/** After a tool ran: follow the checklist, and say what the agent should hear. */
205const follow = async (
206 $: EngineInterface,
207 call: { tool: string; command: string | null; output: string; isError: boolean; isReadOnly?: true },
208 startedAt: number,
209): Promise<string | null> => {
210 const calls = call.command === null ? [] : checklistCalls(call.command)
211 if (calls.length > 0) {
212 // A refused call (an ambiguous name, a bad number) changed nothing to follow.
213 if (call.isError) return null
214 changesSinceCheck = 0
215 // A listing names every page on the board: none of them is this session's.
216 const worked = calls.filter(c => !LISTING_VERBS.has(c.verb))
217 // Off the result's path: the agent gets its tool result at once.
218 if (worked.length > 0) inBackground(afterChecklistCall($, startedAt, worked, call.output))
219 return null
220 }
221 if (!isMutating(call.tool, call.isReadOnly)) return null
222 changesSinceCheck += 1
223 const { list } = await read($, tracking)
224 if (list === null) {
225 if (!isEdit(call.tool)) return null
226 await update($, tracking, t => ({ ...t, isUntracked: true }))
227 if (remindedTurn === currentTurn) return null
228 remindedTurn = currentTurn
229 return noChecklistReminder()
230 }
231 if (list.open.length > 0 && changesSinceCheck >= NUDGE_EVERY) {
232 changesSinceCheck = 0
233 return staleReminder(list, NUDGE_EVERY)
234 }
235 return null
236}
237
238const setLimits = ($: EngineInterface, next: readonly Limit[]) =>
239 update($, limits, () => next.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })))
240
241export const register: Register = on => {
242 on('session.start', async ($, e, next) => {
243 const result = await next(e)
244 const now = await $.clock.now()
245 const loaded = revive(await $.store.get(STORE_KEY), now)
246 await update($, pet, () => loaded)
247 persist($)
248 await update($, busy, () => 0)
249 await setLimits($, (await $.session.usage()).rateLimits)
250 await resetTracking($)
251 await refresh($).catch(() => null)
252
253 // The timer does not wait for its callback, so a slow tick makes the next
254 // one skip instead of stacking writes behind it.
255 let isTicking = false
256 $.clock.every(TICK_MS, () => {
257 if (isTicking) return
258 isTicking = true
259 void (async () => {
260 const tick = await update($, frame, f => f + 1)
261 if ((await read($, busy)) > 0) {
262 if (tick % SNACK_TICKS === 0) await pickSnack($)
263 return
264 }
265 const current = await read($, pet)
266 const stage = STAGES[stageIndex(current?.xp ?? 0)]!
267 await update($, pos, p => wander(p, TRACK - width(stage.idle[0][0]), Math.random()))
268 })()
269 .catch(() => undefined)
270 .finally(() => {
271 isTicking = false
272 })
273 })
274
275 return result
276 })
277
278 // Exit, /clear and reload: land the last feeds before the session goes,
279 // inside the short bound session.end shares.
280 on('session.end', async ($, e, next) => {
281 // /clear starts a new conversation with no session.start: it gets a clean slate.
282 if (e.reason === 'clear') await resetTracking($).catch(() => undefined)
283 const room = Math.max(0, next.budget.remainingMs - 250)
284 await Promise.race([flush(), $.clock.sleep(room, { signal: next.signal })]).catch(() => undefined)
285 return next(e)
286 })
287
288 on('session.measure', async ($, e, next) => {
289 if (e.changed.includes('rateLimits')) await setLimits($, e.rateLimits)
290 return next(e)
291 })
292
293 // Every prompt carries the open items, so the agent never works from memory of them.
294 // A fresh reading takes about 70 ms; a slow or hung bermuda holds a prompt
295 // REFRESH_WAIT_MS at most, then the last reading stands and the refresh lands later.
296 on('prompt.submit', async ($, e, next) => {
297 const reading = refresh($)
298 inBackground(reading.then(() => undefined))
299 const fresh = await Promise.race([reading, $.clock.sleep(REFRESH_WAIT_MS).then(() => undefined)]).catch(() => undefined)
300 const list = fresh === undefined ? (await read($, tracking)).list : fresh
301 if (!list || list.open.length === 0) return next(e)
302 return next({ ...e, context: [...(e.context ?? []), openItemsReminder(list)] })
303 })
304
305 on('turn.start', async ($, e, next) => {
306 currentTurn = e.turnId
307 await update($, busy, n => n + 1)
308 await pickSnack($)
309 return next(e)
310 })
311
312 // turn.start carries no agentId, so only the main thread's turns count as busy.
313 on('turn.complete', async ($, e, next) => {
314 if (e.agentId !== undefined) return next(e)
315 await update($, busy, n => Math.max(0, n - 1))
316 // Fed in the background so the turn's end never waits on the pet.
317 if (e.reason === 'answer') inBackground(eat($, FEED.turn))
318 return next(e)
319 })
320
321 // Feeding never gates or delays a tool: eat in the background once it ran.
322 // The main agent's calls also keep the checklist in step, and a reminder
323 // rides on the result where the agent reads it.
324 on('tool.call', async ($, e, next) => {
325 const startedAt = await $.clock.now()
326 const ran = await next(e)
327 if (ran.deny !== undefined) return ran
328 inBackground(eat($, FEED.tool))
329 if (e.agentId !== undefined) return ran
330 const command = e.tool === 'Bash' ? e.command : null
331 const call = { tool: e.tool, command, output: ran.text ?? '', isError: ran.isError === true, isReadOnly: ran.isReadOnly }
332 const note = await follow($, call, startedAt).catch(() => null)
333 return note === null ? ran : { ...ran, context: [...(ran.context ?? []), note] }
334 }).catch((_$, e, next) => next(e))
335
336 // The right end of the prompt footer: the terminal's lower-right corner.
337 // Columns two rows tall: the checklist, the usage bars, then the pet.
338 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
339 const current = await read($, pet)
340 if (!current) return next(e)
341
342 const [tick, at, working, windows, food, { list, isUntracked }] = await Promise.all([
343 read($, frame),
344 read($, pos),
345 read($, busy),
346 read($, limits),
347 read($, snack),
348 read($, tracking),
349 ])
350 const { Box, Text } = $.ui.resolve(e)
351 const meters = [meter(windows, 'five_hour', '5h'), meter(windows, 'seven_day', 'wk')]
352 const modesText = e.props.modes.length > 0 ? `${e.props.modes.join(' & ')} ` : ''
353 const show = fit(e.viewport?.columns, modesText.length)
354 if (!show.list && !show.pet && !show.meters && modesText === '') return next(e)
355 const rows = petRows(current, at, tick, working > 0, food)
356
357 return (
358 <Box alignItems="flex-end">
359 {modesText !== '' ? <Text dimColor>{modesText}</Text> : null}
360 {!show.list ? null : list !== null ? (
361 <Box flexDirection="column" marginRight={1}>
362 <Text color={list.open.length === 0 ? 'success' : 'warning'}>
363 ☑ {list.done}/{list.total}
364 </Text>
365 <Text dimColor>{list.blocked > 0 ? `⚑ ${list.blocked}` : ' '}</Text>
366 </Box>
367 ) : isUntracked ? (
368 <Box flexDirection="column" marginRight={1}>
369 <Text color="warning">☐ no list</Text>
370 <Text> </Text>
371 </Box>
372 ) : (
373 <Box flexDirection="column" marginRight={1}>
374 <Text dimColor>☐ --</Text>
375 <Text> </Text>
376 </Box>
377 )}
378 {show.meters ? <Box flexDirection="column" marginRight={1}>
379 {meters.map(m => (
380 <Box key={m.label}>
381 <Text dimColor>{m.label} </Text>
382 <Text color={meterColor(m.remaining)}>{m.bar}</Text>
383 <Text dimColor>{(m.remaining === null ? '--' : `${m.remaining}% left`).padStart(10)}</Text>
384 </Box>
385 ))}
386 </Box> : null}
387 {show.pet ? <Box flexDirection="column">
388 {rows.map((row, i) => (
389 <Box key={`row${i}`}>
390 <Text>{' '.repeat(row.pad)}</Text>
391 <Text color="warning">{row.food}</Text>
392 <Text color="claude">{row.body}</Text>
393 </Box>
394 ))}
395 </Box> : null}
396 </Box>
397 )
398 })
399}
400hooks/checklist.ts 131 lines1import type { Checklist, OpenItem } from '../types'
2
3/** File-changing tool calls with no `bermuda check` call before the agent is reminded. */
4export const NUDGE_EVERY = 15
5/** Open items listed in a reminder; the rest are counted. */
6const LISTED = 8
7const ITEM_CHARS = 140
8
9export const TICK_FOOD = { xp: 10, fullness: 5 } as const
10
11const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
12
13/** A call that edits files: what starts work that needs a checklist. */
14export const isEdit = (tool: string): boolean => EDIT_TOOLS.has(tool)
15
16/** A call that changes the work: an edit, or a Bash command core did not hold read-only. */
17export const isMutating = (tool: string, isReadOnly: boolean | undefined): boolean =>
18 isEdit(tool) || (tool === 'Bash' && isReadOnly !== true)
19
20/** `bermuda check` verbs that print the board's pages rather than work on one. */
21export const LISTING_VERBS: ReadonlySet<string> = new Set(['ls', 'list'])
22
23export type CheckCall = { verb: string; args: string[] }
24
25/** Shell words and the operators between commands, quotes and backslashes honoured. */
26const shellWords = (command: string): string[] => {
27 const words: string[] = []
28 let word: string | null = null
29 let quote: '"' | "'" | null = null
30 const push = () => {
31 if (word !== null) words.push(word)
32 word = null
33 }
34 for (let i = 0; i < command.length; i++) {
35 const ch = command[i]!
36 if (quote !== null) {
37 if (ch === quote) quote = null
38 else if (ch === '\\' && quote === '"' && i + 1 < command.length) word = (word ?? '') + command[++i]
39 else word = (word ?? '') + ch
40 } else if (ch === '"' || ch === "'") {
41 quote = ch
42 word = word ?? ''
43 } else if (ch === '\\' && i + 1 < command.length) {
44 const nextCh = command[++i]!
45 if (nextCh !== '\n') word = (word ?? '') + nextCh
46 } else if (ch === ' ' || ch === '\t') push()
47 else if (ch === '\n' || ch === ';' || ch === '|' || ch === '&') {
48 push()
49 words.push(';')
50 } else word = (word ?? '') + ch
51 }
52 push()
53 return words
54}
55
56/**
57 * The `bermuda check` calls a shell command runs, verb and arguments. A
58 * mention is not a call: only a command whose program is `bermuda` (by name or
59 * path, after any leading VAR=value) followed by `check` counts.
60 */
61export const checklistCalls = (command: string): CheckCall[] => {
62 const segments: string[][] = [[]]
63 for (const word of shellWords(command)) {
64 if (word === ';') segments.push([])
65 else segments.at(-1)!.push(word)
66 }
67 return segments.flatMap(words => {
68 while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]!)) words.shift()
69 const [program, noun, verb, ...rest] = words
70 if (program === undefined || !/(^|\/)bermuda$/.test(program) || noun !== 'check' || !verb) return []
71 return [{ verb, args: rest.filter(arg => !arg.startsWith('-')) }]
72 })
73}
74
75export const isChecklistCommand = (command: string): boolean => checklistCalls(command).length > 0
76
77/** A checklist page as `bermuda check show -raw` prints it. */
78export const parseChecklist = (raw: string, name: string | null): Checklist | null => {
79 const lines = raw.split('\n')
80 const title = lines.find(l => l.startsWith('# '))?.slice(2).trim()
81 if (title === undefined) return null
82 let n = 0
83 let done = 0
84 let blocked = 0
85 const open: OpenItem[] = []
86 const ticked: OpenItem[] = []
87 for (const line of lines) {
88 const item = /^- \[([ xX])\] (.*)$/.exec(line)
89 if (!item) continue
90 n += 1
91 const text = item[2]!.replace(/\s*<!--.*?-->\s*/g, ' ').trim()
92 if (item[1] !== ' ') {
93 done += 1
94 ticked.push({ n, text })
95 } else if (/\bBLOCKED:/.test(text)) blocked += 1
96 else open.push({ n, text })
97 }
98 return { name, title, done, total: done + blocked + open.length, blocked, open, ticked }
99}
100
101const cut = (text: string) => (text.length > ITEM_CHARS ? `${text.slice(0, ITEM_CHARS - 1)}…` : text)
102
103const tickHint = (list: Checklist) =>
104 list.name === null
105 ? 'bermuda check tick <n>'
106 : `bermuda check tick "${list.name}" <n>`
107
108export const noChecklistReminder = (): string =>
109 [
110 'bermudamon: files are changing but no bermuda checklist tracks this work in this session.',
111 'Before going further, write what the work delivers as provable one-line user stories:',
112 '`bermuda check new "<task>"`, then one `bermuda check add "<checklist>" "<story>"` per story.',
113 ].join('\n')
114
115export const openItemsReminder = (list: Checklist): string => {
116 const listed = list.open.slice(0, LISTED).map(item => `${item.n}. [ ] ${cut(item.text)}`)
117 const more = list.open.length > LISTED ? [`… and ${list.open.length - LISTED} more open`] : []
118 return [
119 `bermudamon: bermuda checklist "${list.title}" is ${list.done}/${list.total} done${list.blocked > 0 ? `, ${list.blocked} blocked` : ''}. Open:`,
120 ...listed,
121 ...more,
122 `Tick an item the moment its yes is observed (a run, a row, an output): \`${tickHint(list)}\`. Add stories learned mid-task.`,
123 ].join('\n')
124}
125
126export const staleReminder = (list: Checklist, calls: number): string =>
127 [
128 `bermudamon: ${calls} file-changing calls since the bermuda checklist "${list.title}" was last touched, ${list.open.length} item${list.open.length === 1 ? '' : 's'} still open.`,
129 `If any is now proven, tick it: \`${tickHint(list)}\`. If the work changed shape, add or block items.`,
130 ].join('\n')
131hooks/pet.ts 218 lines1import type { Limit, Pet, Pos } from '../types'
2
3/** Two text rows of quadrant blocks, the Claude Code mascot's own style. */
4export type Sprite = readonly [string, string]
5
6export type Stage = {
7 name: string
8 xp: number
9 /** The pixel art the frames derive from. */
10 body: Body
11 idle: readonly [Sprite, Sprite]
12 munch: readonly [Sprite, Sprite]
13}
14
15/** Quadrant block for each combination of lit pixels: top-left 1, top-right 2, bottom-left 4, bottom-right 8. */
16const QUADRANTS = [' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█'] as const
17
18export type Body = readonly [string, string, string, string]
19
20/** Four pixel rows ('#' lit, '.' dark) of an even width, packed two pixels per cell into two text rows. */
21export const pack = (pixels: Body): Sprite => {
22 const cell = (top: string, bottom: string, x: number) =>
23 QUADRANTS[
24 (top[x] === '#' ? 1 : 0) | (top[x + 1] === '#' ? 2 : 0) | (bottom[x] === '#' ? 4 : 0) | (bottom[x + 1] === '#' ? 8 : 0)
25 ]!
26 const row = (top: string, bottom: string) =>
27 Array.from({ length: top.length / 2 }, (_, i) => cell(top, bottom, i * 2)).join('')
28 return [row(pixels[0], pixels[1]), row(pixels[2], pixels[3])]
29}
30
31const put = (row: string, x: number, c: '#' | '.'): string => row.slice(0, x) + c + row.slice(x + 1)
32
33/**
34 * A square critter. Pixels are twice as tall as wide, so a body three rows
35 * tall and as many pixels wide as it is tall again reads as a square. `top`
36 * is the roof (its length is the sprite's width, its notches the ears), `eye`
37 * the column of the left dot eye and left foot; the right ones mirror them.
38 * The hands are single pixels on the outer columns: down by its side, or up.
39 */
40const critter = (top: string, eye: number) => {
41 const far = top.length - 1
42 const right = far - eye
43 const trunk = Array.from({ length: top.length }, (_, x) => (x >= 1 && x < far ? '#' : '.')).join('')
44 const eyes = put(put(trunk, eye, '.'), right, '.')
45 const feet = put(put('.'.repeat(top.length), eye, '#'), right, '#')
46 const hands = (row: string) => put(put(row, 0, '#'), far, '#')
47 const down: Body = [top, eyes, hands(trunk), feet]
48 const up: Body = [top, hands(eyes), trunk, feet]
49 return { down, up }
50}
51
52const stage = (name: string, xp: number, top: string, eye: number): Stage => {
53 const { down, up } = critter(top, eye)
54 return { name, xp, body: down, idle: [pack(down), pack(up)], munch: [pack(up), pack(down)] }
55}
56
57/** Wobbles in place: it cannot walk, eat or wave yet. */
58const egg = (): Stage => {
59 const body: Body = ['.####.', '.####.', '######', '..##..']
60 const wobble: Body = [body[0], body[1], body[2], '...##.']
61 const idle = [pack(body), pack(wobble)] as const
62 return { name: 'Egg', xp: 0, body, idle, munch: idle }
63}
64
65/**
66 * Evolution line: each stage unlocks at `xp`. A square critter with dot
67 * eyes, little feet and tiny hands that wave while it walks and come up
68 * while it eats. It grows a little wider and earns ears and a
69 * crown along the way. Every glyph is one column wide.
70 */
71export const STAGES: readonly Stage[] = [
72 egg(),
73 stage('Baby', 15, '.######.', 2),
74 stage('In-Training', 60, '.##..##.', 2),
75 stage('Rookie', 200, '.########.', 3),
76 stage('Champion', 700, '.#.####.#.', 3),
77 stage('Ultimate', 2500, '.##########.', 3),
78 stage('Mega', 8000, '.#.######.#.', 3),
79]
80
81/** Columns the pet walks in, sprite included. */
82export const TRACK = 18
83/**
84 * Snacks drawn on the bottom row beside the pet while it eats. Each is one
85 * astral code point (two UTF-16 units) drawn two columns wide, so `length`
86 * is its width in columns.
87 */
88export const FOODS = ['🍖', '🍕', '🍩', '🍪', '🍣', '🍔', '🌮', '🍙', '🧁', '🍎'] as const
89const HOUR = 3_600_000
90const DECAY_PER_HOUR = 5
91
92export const FEED = {
93 tool: { xp: 1, fullness: 2 },
94 turn: { xp: 3, fullness: 5 },
95} as const
96
97export const stageIndex = (xp: number): number => {
98 let i = 0
99 STAGES.forEach((s, n) => {
100 if (xp >= s.xp) i = n
101 })
102 return i
103}
104
105export const width = (s: string): number => [...s].length
106
107export const hatch = (now: number): Pet => ({ xp: 0, fullness: 60, at: now, born: now })
108
109const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
110
111/** A real moment no later than now: a negative or future time is damage. */
112const isPast = (value: unknown, now: number): value is number => finite(value) && value > 0 && value <= now
113
114/** A stored value as a Pet: fields of an older or damaged shape are repaired, never trusted. */
115export const revive = (value: unknown, now: number): Pet => {
116 const fresh = hatch(now)
117 if (typeof value !== 'object' || value === null) return fresh
118 const v = value as Record<string, unknown>
119 return {
120 xp: finite(v.xp) && v.xp >= 0 ? Math.floor(v.xp) : fresh.xp,
121 fullness: finite(v.fullness) ? Math.max(0, Math.min(100, v.fullness)) : fresh.fullness,
122 at: isPast(v.at, now) ? v.at : now,
123 born: isPast(v.born, now) ? v.born : now,
124 }
125}
126
127/** Fullness after decay, computed lazily from the last write. */
128export const fullnessAt = (pet: Pet, now: number): number =>
129 Math.max(0, pet.fullness - (DECAY_PER_HOUR * Math.max(0, now - pet.at)) / HOUR)
130
131/** A feed sampled its clock before a later one landed: time never runs backwards. */
132export const feed = (pet: Pet, food: { xp: number; fullness: number }, now: number): Pet => {
133 const at = Math.max(now, pet.at)
134 return {
135 ...pet,
136 xp: pet.xp + food.xp,
137 fullness: Math.min(100, fullnessAt(pet, at) + food.fullness),
138 at,
139 }
140}
141
142/** One wander step: mostly walk, sometimes turn or rest, bounce off the ends. */
143export const wander = (pos: Pos, maxX: number, roll: number): Pos => {
144 if (maxX <= 0) return { x: 0, dir: pos.dir }
145 let dir = pos.dir
146 if (roll < 0.1) dir = dir === 1 ? -1 : 1
147 else if (roll >= 0.7) return { x: Math.min(pos.x, maxX), dir }
148 let x = pos.x + dir
149 if (x > maxX) {
150 x = maxX - 1
151 dir = -1
152 } else if (x < 0) {
153 x = 1
154 dir = 1
155 }
156 return { x: Math.max(0, Math.min(x, maxX)), dir }
157}
158
159export type Row = { pad: number; food: string; body: string }
160
161/** The pet's two rows inside the track: walking at `x`, or at the right end eating. */
162export const petRows = (pet: Pet, pos: Pos, frame: number, isBusy: boolean, snack = 0): Row[] => {
163 const index = stageIndex(pet.xp)
164 const stage = STAGES[index]!
165 // An egg cannot eat or walk: it wobbles in the corner.
166 const isEating = isBusy && index > 0
167 const sprite = (isEating ? stage.munch : stage.idle)[frame % 2]!
168 const w = width(sprite[0])
169 const food = isEating ? FOODS[snack % FOODS.length]! : ''
170 const x = isEating || index === 0 ? TRACK - w - food.length : Math.min(pos.x, Math.max(0, TRACK - w))
171 return sprite.map((body, i) => ({
172 pad: x,
173 food: i === sprite.length - 1 ? food : ' '.repeat(food.length),
174 body: body + ' '.repeat(Math.max(0, TRACK - x - food.length - w)),
175 }))
176}
177
178export const CELLS = 8
179
180export type Meter = { label: string; remaining: number | null; bar: string }
181
182/** Remaining share of a window as a bar; null when the engine has no reading. */
183export const meter = (limits: readonly Limit[], kind: string, label: string): Meter => {
184 const limit = limits.find(l => l.kind === kind)
185 if (!limit) return { label, remaining: null, bar: '▱'.repeat(CELLS) }
186 // Round the used share first, as /usage does, so both screens agree.
187 const remaining = Math.max(0, Math.min(100, 100 - Math.round(limit.percentUsed)))
188 const full = Math.round((remaining / 100) * CELLS)
189 return { label, remaining, bar: '▰'.repeat(full) + '▱'.repeat(CELLS - full) }
190}
191
192export const meterColor = (remaining: number | null): 'success' | 'warning' | 'error' | 'inactive' =>
193 remaining === null ? 'inactive' : remaining > 50 ? 'success' : remaining > 20 ? 'warning' : 'error'
194
195/** Columns the prompt line keeps for Claude Code's own hints on the left. */
196const LEFT_RESERVE = 40
197const LIST_COLS = 10
198const METER_COLS = 22
199const PET_COLS = TRACK + 1
200
201export type Fit = { list: boolean; meters: boolean; pet: boolean }
202
203/**
204 * Which footer parts fit in `columns` once the mode labels (`modesWidth`)
205 * and Claude Code's own hints have their room. Claude Code drops its pills
206 * rather than wrap when the terminal is narrow; so do we: the usage bars go
207 * first, then the pet, then the checklist. No width known: everything shows.
208 */
209export const fit = (columns: number | undefined, modesWidth: number): Fit => {
210 if (columns === undefined) return { list: true, meters: true, pet: true }
211 let room = columns - LEFT_RESERVE - modesWidth
212 const list = room >= LIST_COLS
213 if (list) room -= LIST_COLS
214 const pet = room >= PET_COLS
215 if (pet) room -= PET_COLS
216 return { list, pet, meters: room >= METER_COLS }
217}
218types/index.d.ts 49 lines1export type Pet = {
2 xp: number
3 fullness: number
4 /** When `fullness` was last computed, in ms since the epoch. */
5 at: number
6 born: number
7}
8
9export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
10
11export type Pos = { x: number; dir: 1 | -1 }
12
13/** An unticked, unblocked item and its number on the page, as `bermuda check tick` takes it. */
14export type OpenItem = { n: number; text: string }
15
16/** The bermuda checklist this session works against, as last read. */
17export type Checklist = {
18 /** The page's name, as bermuda takes it; null only when it could not be told. */
19 name: string | null
20 title: string
21 done: number
22 total: number
23 blocked: number
24 open: OpenItem[]
25 /** The ticked items, to tell which ticks are new. */
26 ticked: OpenItem[]
27}
28
29export type Tracking = {
30 list: Checklist | null
31 /** Files changed this session while no checklist was active. */
32 isUntracked: boolean
33}
34
35declare module 'claude-code' {
36 interface PluginState {
37 bermudamon: {
38 pet: Pet | null
39 frame: number
40 pos: Pos
41 busy: number
42 /** Index into FOODS of what the pet eats now. */
43 snack: number
44 tracking: Tracking
45 limits: Limit[]
46 }
47 }
48}
49