SLOPSHOPPER

bermudamon

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

newspinnerguardpromptprocesstimer
★ 13v0.3.4MITupdated 2026-10-15bon5co/bermuda/mods/bermudamon
A shopper browsing a rack in a slop shop
README

Bermudamon

A square critter with tiny hands that lives in the Claude Code prompt footer. It is a mod for Claude Code, shipped with Bermuda.

  • Grows as you work. Every tool call and finished answer feeds it; it evolves through seven stages (Egg to Mega) and hungers over time.
  • Shows your limits. The 5-hour and weekly windows as two bars beside it.
  • Keeps your checklist in view. Follows the 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.
  • Steps aside on a narrow terminal. Like Claude Code's own pills, it drops parts rather than wrap: the usage bars first, then the pet, then the checklist.

Install

/plugin marketplace add bon5co/bermuda
/plugin install bermudamon@bermuda

Develop

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.

Source 4 files
hooks/register.tsx 400 lines
1import { 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}
400
hooks/checklist.ts 131 lines
1import 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')
131
hooks/pet.ts 218 lines
1import 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}
218
types/index.d.ts 49 lines
1export 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