SLOPSHOPPER

cc-buddy

Brings back /buddy: the small creature that watched you code, above the prompt again. Restores the companion in ~/.claude.json exactly (same species, rarity…

newpanebandguardcommandtoast
v0.5.1MITupdated 2026-09-29vmallela0/cc-buddy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-buddy
│ ┃ buddy ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ▣ client module ./boards/buddy.tsx │ cc-buddy │ │ ● cc-buddy: cc-buddy: │ an egg above the prompt · /buddy hatches │ │ ⏺ Read(src/auth.ts) │ it │ │ ⎿ Read 6 lines ╰────────────────────────────────────────────╯ │ ⏺ Update(src/auth.ts) ╭────────────────────────────────────────────╮ │ ⎿ Added 2 lines, re│ cc-buddy │ │ ⏺ Bash(bun test) │ cc-buddy: your buddy's replies are written │ │ ⎿ 3 pass, 1 fail │ by Haiku, so what you say to it goes to │ │ │ the model · /buddy talk canned turns that │ │ ● Done. refresh now re╰────────────────────────────────────────────╯ │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /buddy │ ⎿ cc-buddy: the egg hatched: Fennel, a common goose ★ │ ⎿ cc-buddy: A common goose that treats breaking changes as a lov │ ⎿ cc-buddy: /buddy card for the stats │ │ ▣ client module ./boards/buddy.tsx [ hide ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./boards/buddy.tsx [ hide ] ⟨Claude Code's own drawing⟩
Pane · buddy
▣ client module ./boards/buddy.tsx
README

cc-buddy

/buddy is back. The small terminal creature Claude Code hatched for people in April 2026 (and removed in 2.1.97), drawn above the prompt again and brought up to date, as a plugin built on Claude Code's function hooks.

              Noodle  rare ghost ★★★  lv 2 · » focused              [ next ]
   .----.                                                            [ hide ]
  / ×  × \    ╭──────────────────────────────────────────────╮
  |      |    │ sent 2 off. i will keep their seats warm.    │
  ~`~``~`~    ╰──────────────────────────────────────────────╯

This is an unofficial fan project. It isn't made or endorsed by Anthropic.

What it does

Your buddy comes back as it was. If you hatched one in April, it's still listed in ~/.claude.json. cc-buddy reads it and redraws the same creature: same name and personality, and the same species, rarity, eyes, hat and stats. The body is derived from your account id exactly the way the original did it (wyhash of the id plus friend-2026-401, fed to Mulberry32). A test suite pins this down so it can't drift.

New here? You get an egg. Run /buddy and it hatches: a short animation, then Haiku writes its name and personality once, the way the original did. The body still comes from your account id.

It stays yours. You keep one buddy, and it grows:

  • XP from commits, passing tests, edits and finished turns, with levels that come quickly at first and slowly later.
  • A mood that follows the session. It's » focused while Claude works, ↑ proud after a green run or a commit, ! worried after three failures in a row, z sleepy after twenty quiet minutes, and ♥ happy after a pet. Every mood shows as a symbol and a word, never color alone.
  • Nothing decays. A buddy never gets hungry, never runs off, and never guilts you into opening the terminal.

A collection you earn. A roster of 49 more buddies (two of them legendary), seeded from your account id, opens one at a time: one new buddy per 25 commits. /buddy roster shows what's open and how far the next one is. If you used an earlier version that rotated through the roster, you keep everyone you already met.

It reacts, cheaply. When Claude edits a file, runs a command, passes or fails tests, commits or finishes a turn, your buddy says something in its own voice. Those lines come from a pool built per buddy from its species and stats, so they cost no tokens.

Talk to it. Mention your buddy by name in a prompt and it answers in its bubble. A pool line shows up right away, then Haiku writes a proper reply in character and swaps it in. Claude gets a note that the buddy is a separate watcher and should stay out of the way, so it doesn't answer for it.

It notices agents. When Claude sends out two or more subagents, the buddy counts them out and tells you when they're all back.

It fits your screen. It shows the full sprite when there's room and a single line when the terminal is short or narrow. On the desktop app and mobile, where there's no band above the prompt, it sits in a pane instead.

Requirements

  • Claude Code 2.1.269 or later.
  • Function hooks turned on: "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" under env in ~/.claude/settings.json.
  • An interactive session: the terminal, the desktop app's Code tab, or mobile. Nothing draws under claude -p.

Install

claude plugin marketplace add vmallela0/cc-buddy
claude plugin install cc-buddy@cc-buddy

Or from a clone, for one session: claude --plugin-dir /path/to/cc-buddy

Commands

commandwhat it does
/buddyhatch the egg the first time; after that, hide or show for this session
/buddy pettwo and a half seconds of floating hearts (or click the buddy)
/buddy cardthe stat card: stats, level and XP, mood, commits, unlocks, model use
/buddy mute, /buddy unmutesilence the speech bubbles, remembered
/buddy talk model, /buddy talk cannedHaiku for replies and the hatch (default), or no tokens at all
/buddy rosterunlocked buddies with their levels, and how far the next unlock is
/buddy next, /buddy prev, /buddy <name>switch among the unlocked
/buddy rotate, /buddy pina different unlocked buddy each session, or stay with this one (default)
/buddy compact, /buddy full, /buddy autoone line, the sprite, or pick by terminal size (default)
/buddy still, /buddy moveturn animation off (no wiggle, blink or floating hearts) or back on
/buddy pane, /buddy bandmove it into a pane, or back above the prompt
/buddy on, /buddy offshow or hide, remembered
/buddy helpthis list

ctrl+x ctrl+a collapses the band above the prompt, buddy included.

Cost and privacy

Only two things ever reach a model, both through Claude Code's own client, and both stop with /buddy talk canned:

  • Replies to its name. When a prompt you type mentions the buddy, that prompt (the first 2,000 characters) goes to Haiku along with the buddy's personality, and a one-line reply comes back. It's capped at 20 replies an hour. Each one is a few hundred tokens.
  • Hatching. One call of about 300 tokens when an egg hatches.

The first time either happens you get a notice saying so. /buddy card shows how many calls were made and roughly how many tokens they used.

Everything else stays local:

  • At session start it reads ~/.claude.json once, for your account id and the original companion. It never writes to that file.
  • It looks at each tool call's name and success, and for Bash the command text, to pick a reaction. Nothing from that is stored or logged.
  • It reads prompts you type only to check for the buddy's name.
  • XP, commit count, unlocks and settings live in the plugin's own store.
  • There's no telemetry, and it makes no other network calls.

Develop

bun test                         # bones, art, voice, soul parsing, progress
bun run typecheck                # needs .claude/types, see below
claude plugin validate .         # what the module hooks and calls
claude --plugin-dir . --debug    # hot reloads on save

Run /plugin-types once in a Claude Code session in this folder to write .claude/types/claude-code.d.ts, which tsconfig.json includes. CI runs the tests only, since those types come from your local Claude Code.

To see the egg without touching your own buddy, point the plugin at a stand-in config and give it a fresh store by renaming the plugin in a copy:

echo '{"oauthAccount":{"accountUuid":"any-id-you-like"}}' > /tmp/fake.json
CC_BUDDY_CLAUDE_JSON=/tmp/fake.json claude --plugin-dir /path/to/renamed-copy

Layout:

  • hooks/register.tsx is the hooks module: session start, /buddy, reactions, the band and the pane.
  • hooks/boards/buddy.tsx is the surface module that draws and animates the buddy.
  • hooks/buddy/engine.ts holds the derivation, art, card and roster.
  • hooks/buddy/voice.ts builds each buddy's lines.
  • hooks/buddy/progress.ts holds XP, levels, moods and unlocks.

Species art and the derivation follow the community port at 1270011/claude-buddy, trimmed to the original eighteen species so existing buddies keep their bodies. Some write-ups describe hats as gated by rarity. The shipped code never gated them, so this plugin doesn't either.

License

MIT

Source 5 files
hooks/register.tsx 678 lines
1/* @jsx h */
2import type { EngineInterface, Register } from 'claude-code'
3import { artFrame, BUBBLE_MS, card, COOLDOWN_MS, generateBones, hatch, momentOf, parseSoul, RARITY_COLOR, RARITY_STARS, roster, rosterLine, soulOf, soulPrompt, type Buddy, type Companion, type Moment } from './buddy/engine.ts'
4import { AGENT_LINES, commitsToNextUnlock, freshRecent, MOOD_MARK, levelOf, levelProgress, moodOf, note, unlockedCount, XP, type Recent } from './buddy/progress.ts'
5import { speak, voiceOf, type Voice } from './buddy/voice.ts'
6
7// /buddy, back as a mod. The companion Claude Code hatched in April lives in ~/.claude.json under
8// `companion` (name, personality, hatchedAt) and its body is derived from the account id. That one
9// buddy is yours, and it stays: XP and levels come from commits and passing tests, a mood follows
10// how the session is going, and every 25 commits opens one more of a 49-strong roster seeded from
11// the same id. An account that never hatched one gets an egg, and the first /buddy hatches it.
12//
13// Reactions to edits, commands, tests, commits and turns are canned and cost nothing. Two things go
14// to the model (Haiku), both off under /buddy talk canned: a reply when you say the buddy's name
15// (at most 20 an hour), and the name and personality written once at hatching.
16//
17// It draws above the prompt in the terminal, one line when the terminal is short, and in a pane on
18// desktop and mobile. See /buddy help for the commands.
19
20const PANE = 'buddy'
21const MODEL_PER_HOUR = 20
22
23let all: Buddy[] = []
24let index = 0
25let voice: Voice | undefined
26let pinned = true
27let shown = true
28let working = false
29let muted = false
30let still = false
31let size: 'auto' | 'full' | 'compact' = 'auto'
32// no companion anywhere and a first run: an egg until /buddy
33let egg = false
34let userId = ''
35let petAt: number | undefined
36let hatchAt: number | undefined
37let bubble: { text: string; at: number } | undefined
38let lastSaid: Record<string, number> = {}
39let seed = 0
40// 'model': Haiku writes replies to the buddy's name and the soul at hatching; 'canned': no tokens
41let talk: 'model' | 'canned' = 'model'
42let talkNoticed = false
43// bumped per reply asked for, so a slow one never lands over a newer one
44let asked = 0
45let modelTimes: number[] = []
46// while a reply to the buddy's name is on its way or just landed, canned lines wait
47let holdUntil = 0
48let modelUse = { replies: 0, souls: 0, chars: 0 }
49// progress: XP per buddy by name, commits across all of them, the grandfathered unlock count
50let xp: Record<string, number> = {}
51let commits = 0
52let grandfathered = 1
53let dirty = false
54let recent: Recent = freshRecent(0)
55// subagents out right now (id to spawn time), and the most out at once in this burst
56const agents = new Map<string, number>()
57let agentPeak = 0
58let backAt = 0
59// set once a subagent's own turn end reaches us; from then on notifications are not counted twice
60let agentEndsSeen = false
61let paneOpen = false
62
63const current = (): Buddy | undefined => all[index]
64const unlocked = () => unlockedCount(commits, grandfathered, all.length)
65const xpOf = (b: Buddy) => xp[b.companion.name] ?? 0
66type Rotation = { index: number; sessionId: string }
67const isRotation = (v: unknown): v is Rotation =>
68  typeof v === 'object' && v !== null && typeof (v as Rotation).index === 'number' && typeof (v as Rotation).sessionId === 'string'
69const isUse = (v: unknown): v is typeof modelUse =>
70  typeof v === 'object' && v !== null && typeof (v as typeof modelUse).replies === 'number' && typeof (v as typeof modelUse).souls === 'number' && typeof (v as typeof modelUse).chars === 'number'
71
72// $ may only be handed to a function declared at the top of the file: the engine reads every
73// $.noun.event(...) off the source, so a closure inside register would hide these calls from it.
74
75// a line in the bubble, at most once per cooldown per key
76function sayLine($: EngineInterface, key: string, text: string, cooldown: number, now: number) {
77  if (now - (lastSaid[key] ?? 0) < cooldown) return
78  lastSaid[key] = now
79  if (egg || muted || !text || (now < holdUntil && key !== 'pet' && key !== 'level')) return
80  bubble = { text, at: now }
81  if (shown) $.ui.invalidate('ui.render')
82}
83
84function say($: EngineInterface, moment: Moment, now: number) {
85  const b = current()
86  if (!b) return
87  seed = (seed * 1103515245 + 12345) >>> 0
88  if (!voice) voice = voiceOf(b.bones, b.companion.name)
89  sayLine($, moment, speak(voice, moment, seed), COOLDOWN_MS[moment], now)
90}
91
92async function save($: EngineInterface, key: string, value: unknown) {
93  await $.store.set(key, value).catch(err => $.ui.log(`cc-buddy: store write of ${key} failed: ${err}`))
94}
95
96// progress goes to the store at the end of a turn, or right away on a commit or a level
97async function flush($: EngineInterface) {
98  if (!dirty) return
99  dirty = false
100  await save($, 'xp', xp)
101  await save($, 'commits', commits)
102}
103
104// what a moment does to the buddy: the mood's memory, XP, a level, a roster unlock
105async function gain($: EngineInterface, moment: Moment, now: number) {
106  const b = current()
107  if (!b || egg) return
108  recent = note(recent, moment, now)
109  const worth = XP[moment] ?? 0
110  if (!worth) return
111  const before = levelOf(xpOf(b))
112  const opened = unlocked()
113  xp[b.companion.name] = xpOf(b) + worth
114  if (moment === 'commit') commits += 1
115  dirty = true
116  const level = levelOf(xpOf(b))
117  if (level > before) {
118    sayLine($, 'level', `level ${level}. i felt that.`, 0, now)
119    $.ui.toast(`${b.companion.name} reached level ${level}`)
120  }
121  if (unlocked() > opened) {
122    const fresh = all[unlocked() - 1]!
123    $.ui.toast(`new buddy unlocked: ${fresh.companion.name}, a ${fresh.bones.rarity} ${fresh.bones.species} · /buddy roster`, { timeoutMs: 8_000 })
124  }
125  if (moment === 'commit' || level > before) await flush($)
126}
127
128// Client props must be JsonValue: an undefined field anywhere makes the engine reject the whole
129// tree and draw its own band instead, so absent fields are left out rather than set to undefined.
130async function props($: EngineInterface, columns: number, compact: boolean) {
131  const b = current()
132  const now = await $.clock.now()
133  if (!b) return null
134  return {
135    bones: b.bones,
136    companion: b.companion,
137    level: levelOf(xpOf(b)),
138    mood: moodOf(recent, working, now),
139    ...(egg ? { egg } : {}),
140    ...(muted ? { muted } : {}),
141    ...(still ? { still } : {}),
142    ...(compact ? { compact } : {}),
143    ...(bubble ? { bubble } : {}),
144    ...(petAt !== undefined ? { petAt } : {}),
145    ...(hatchAt !== undefined ? { hatchAt } : {}),
146    working,
147    now,
148    columns,
149  }
150}
151
152// The reply's text, whatever shape it arrives in: typed as a string, but read defensively so an
153// object from another build never turns into NaN tokens or a thrown reply.
154function textOf(v: unknown): string | undefined {
155  if (typeof v === 'string') return v
156  if (typeof v !== 'object' || v === null) return undefined
157  const o = v as { text?: unknown; content?: unknown }
158  if (typeof o.text === 'string') return o.text
159  if (Array.isArray(o.content)) {
160    const t = o.content.map(c => (typeof c === 'object' && c !== null && typeof (c as { text?: unknown }).text === 'string' ? (c as { text: string }).text : '')).join('')
161    return t || undefined
162  }
163  return undefined
164}
165
166// Haiku, if allowed: talk is on, and fewer than 20 calls this hour. Undefined on a no, a failure or
167// a timeout, and the caller falls back to its canned version.
168async function complete($: EngineInterface, system: string, prompt: string, maxTokens: number): Promise<string | undefined> {
169  const now = await $.clock.now()
170  modelTimes = modelTimes.filter(t => now - t < 3_600_000)
171  if (talk !== 'model' || modelTimes.length >= MODEL_PER_HOUR) return undefined
172  modelTimes.push(now)
173  if (!talkNoticed) {
174    talkNoticed = true
175    await save($, 'talkNoticed', true)
176    $.ui.toast('cc-buddy: your buddy\'s replies are written by Haiku, so what you say to it goes to the model · /buddy talk canned turns that off', { timeoutMs: 12_000 })
177  }
178  try {
179    const raw: unknown = await Promise.race([
180      $.model.complete({ model: 'haiku', system, prompt, maxTokens }),
181      $.clock.sleep(10_000).then(() => undefined),
182    ])
183    const out = textOf(raw)
184    if (raw !== undefined && out === undefined) $.ui.log(`cc-buddy: model reply had no text: ${JSON.stringify(raw).slice(0, 300)}`)
185    if (out !== undefined) modelUse = { ...modelUse, chars: modelUse.chars + system.length + prompt.length + out.length }
186    return out
187  } catch (err) {
188    $.ui.log(`cc-buddy: model call failed: ${err}`)
189    return undefined
190  }
191}
192
193// One short line in the buddy's own voice. The canned line is already up while this runs; the
194// model's replaces it, or nothing happens.
195async function modelReply($: EngineInterface, b: Buddy, text: string) {
196  const mine = ++asked
197  const { name, personality } = b.companion
198  const { species, rarity, peak, dump, stats } = b.bones
199  const now = await $.clock.now()
200  const system = [
201    `You are ${name}, a tiny ${rarity} ${species} who lives in a speech bubble above a developer's terminal prompt and watches them code with Claude.`,
202    personality ? `Personality: ${personality}` : '',
203    `Stats out of 100: ${Object.entries(stats).map(([k, v]) => `${k} ${v}`).join(', ')}. Strongest: ${peak}. Weakest: ${dump}. Let them color how you talk.`,
204    `You are level ${levelOf(xpOf(b))} and feeling ${moodOf(recent, working, now)}.`,
205    `The developer just said something that mentions you. Reply as ${name}, in character: one line, under 90 characters, lowercase is fine, no quotes, no emoji spam, no name prefix.`,
206    'You are a companion, not an assistant. Claude does the actual work, so never write code, never try to complete the task, and never claim to have done anything.',
207  ].filter(Boolean).join('\n')
208  holdUntil = now + 12_000
209  const out = await complete($, system, text.slice(0, 2000), 80)
210  if (out === undefined) {
211    if (mine === asked) holdUntil = 0
212    return
213  }
214  modelUse = { ...modelUse, replies: modelUse.replies + 1 }
215  await save($, 'modelUse', modelUse)
216  const line = out.replace(/\s+/g, ' ').trim().replace(/^["'“]|["'”]$/g, '').replace(new RegExp(`^${escape(name)}\\s*:\\s*`, 'i'), '').slice(0, 140)
217  if (!line || mine !== asked || current() !== b || muted) return
218  const at = await $.clock.now()
219  bubble = { text: line, at }
220  holdUntil = at + 15_000
221  if (shown) $.ui.invalidate('ui.render')
222}
223
224// the first /buddy: bones from the account id, a soul the model writes once (the template when the
225// model is off or says nothing usable), kept in the store, never in ~/.claude.json
226async function hatchEgg($: EngineInterface, now: number): Promise<Buddy> {
227  const bones = generateBones(userId)
228  const { system, prompt } = soulPrompt(bones)
229  const written = parseSoul((await complete($, system, prompt, 200)) ?? '')
230  if (written) {
231    modelUse = { ...modelUse, souls: modelUse.souls + 1 }
232    await save($, 'modelUse', modelUse)
233  }
234  const companion: Companion = written ? { ...written, hatchedAt: now } : hatch(userId, bones, now)
235  await save($, 'companion', companion)
236  all = roster(userId, { companion, bones }, now)
237  index = 0
238  egg = false
239  hatchAt = await $.clock.now()
240  voice = undefined
241  lastSaid = {}
242  grandfathered = 1
243  await save($, 'unlocked', grandfathered)
244  await save($, 'rotation', { index, sessionId: await $.session.id() })
245  say($, 'hello', now)
246  $.ui.invalidate('ui.render')
247  return all[0]!
248}
249
250// the name as a word of its own, any case: "hey Pip" yes, "pipeline" no
251const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
252const addressed = (text: string, name: string) => new RegExp(`(?<![\\p{L}\\p{N}])${escape(name)}(?![\\p{L}\\p{N}])`, 'iu').test(text)
253
254// one agent is done: by its own turn's end, or by the notification a background agent sends home.
255// When the last of a burst of two or more is back, say so. Entries older than half an hour are
256// dropped, so a lost end event can never keep the buddy waiting forever.
257function agentDone($: EngineInterface, id: string | undefined, now: number) {
258  if (id) agents.delete(id)
259  else {
260    const oldest = [...agents.entries()].sort((a, b) => a[1] - b[1])[0]
261    if (oldest) agents.delete(oldest[0])
262  }
263  for (const [k, at] of agents) if (now - at > 30 * 60_000) agents.delete(k)
264  if (agents.size === 0 && agentPeak >= 2) {
265    agentPeak = 0
266    backAt = now
267    seed = (seed * 1103515245 + 12345) >>> 0
268    sayLine($, 'agents-back', AGENT_LINES.back[seed % AGENT_LINES.back.length]!, 0, now)
269  }
270  if (agents.size === 0) agentPeak = 0
271}
272
273async function switchTo($: EngineInterface, to: number, now: number): Promise<string> {
274  const open = unlocked()
275  index = ((to % open) + open) % open
276  voice = undefined
277  bubble = undefined
278  lastSaid = {}
279  await save($, 'rotation', { index, sessionId: await $.session.id() })
280  say($, 'hello', now)
281  $.ui.invalidate('ui.render')
282  if (paneOpen) await openPane($)
283  const b = current()!
284  return `${b.companion.name}, a ${b.bones.shiny ? 'shiny ' : ''}${b.bones.rarity} ${b.bones.species}`
285}
286
287// desktop and mobile have no band above the prompt, so the buddy sits in a pane there
288async function openPane($: EngineInterface) {
289  const b = current()
290  await $.ui.open({ id: PANE, title: egg || !b ? 'buddy' : b.companion.name, rows: 7 })
291  paneOpen = true
292}
293
294async function closePane($: EngineInterface) {
295  paneOpen = false
296  await $.ui.close({ id: PANE })
297}
298
299function cardExtras(b: Buddy, now: number): string[] {
300  const p = levelProgress(xpOf(b))
301  const tokens = Math.round(modelUse.chars / 4)
302  return [
303    `level ${p.level} · ${p.into}/${p.span} XP to ${p.level + 1} · feeling ${moodOf(recent, working, now)}`,
304    `${commits} commits · ${unlocked()} of ${all.length} buddies unlocked · next in ${commitsToNextUnlock(commits)} commits`,
305    talk === 'model'
306      ? `Haiku: ${modelUse.replies} repl${modelUse.replies === 1 ? 'y' : 'ies'}, ${modelUse.souls} soul${modelUse.souls === 1 ? '' : 's'}, about ${tokens.toLocaleString('en-US')} tokens so far · /buddy talk canned for none`
307      : 'replies from the line pool, no tokens · /buddy talk model for Haiku',
308  ]
309}
310
311const HELP = [
312  '/buddy              hatch the egg the first time; then hide or show',
313  '/buddy pet          hearts',
314  '/buddy card         the stat card: level, XP, mood, unlocks, model use',
315  '/buddy mute|unmute  no speech bubbles, remembered',
316  '/buddy talk model|canned   Haiku for replies to its name and the hatch, or no tokens at all',
317  '/buddy roster       unlocked buddies (one more per 25 commits) and what is still locked',
318  '/buddy next|prev    switch among the unlocked; /buddy <name> for one by name',
319  '/buddy rotate|pin   a new unlocked buddy each session, or stay with this one (the default)',
320  '/buddy compact|full|auto   one line, the sprite, or pick by terminal size (default)',
321  '/buddy still|move   no animation, or animation back',
322  '/buddy pane         the buddy in a pane (automatic on desktop and mobile); /buddy band puts it back',
323  '/buddy on|off       show or hide, remembered',
324  '',
325  'ctrl+x ctrl+a collapses the band above the prompt; click the buddy to pet it',
326].join('\n')
327
328export const register: Register = on => {
329  on('session.start', async ($, e, next) => {
330    const r = await next(e)
331    const log = (what: string) => (err: unknown) => $.ui.log(`cc-buddy: ${what}: ${err}`)
332    const now = await $.clock.now()
333    seed = now
334    recent = freshRecent(now)
335
336    // the account id and the original companion, as Claude Code left them
337    let id: string | undefined
338    let original: Companion | undefined
339    try {
340      // CC_BUDDY_CLAUDE_JSON points at another file, for trying the egg without touching yours
341      const path = (await $.env.get('CC_BUDDY_CLAUDE_JSON')) || `${await $.env.get('HOME')}/.claude.json`
342      const text = await $.fs.read(path)
343      const config = JSON.parse(text) as { oauthAccount?: { accountUuid?: unknown }; companion?: unknown }
344      const uuid = config.oauthAccount?.accountUuid
345      if (typeof uuid === 'string' && uuid) id = uuid
346      original = soulOf(config.companion)
347    } catch (err) {
348      $.ui.log(`cc-buddy: could not read ~/.claude.json (${err}); using the store`)
349    }
350
351    const stored = (key: string) => $.store.get(key).catch(log(`store read of ${key} failed`))
352    const storedId = await stored('userId')
353    userId = id ?? (typeof storedId === 'string' ? storedId : crypto.randomUUID())
354    if (storedId !== userId) await save($, 'userId', userId)
355    if (!original) original = soulOf(await stored('companion'))
356
357    const lead: Buddy | undefined = original ? { companion: original, bones: generateBones(userId) } : undefined
358    // roster buddies date from the first session this plugin saw, not from today
359    const storedSince = await stored('since')
360    const since = typeof storedSince === 'number' ? storedSince : now
361    if (storedSince !== since) await save($, 'since', since)
362    all = roster(userId, lead, since)
363
364    const rotation = await stored('rotation')
365    // an egg only on a true first run: someone already on the roster keeps their buddy
366    egg = !original && !isRotation(rotation)
367    // one buddy, kept, unless /buddy rotate said otherwise
368    pinned = (await stored('pinned')) !== false
369    muted = (await stored('muted')) === true
370    still = (await stored('motion')) === 'still'
371    const storedSize = await stored('size')
372    size = storedSize === 'full' || storedSize === 'compact' ? storedSize : 'auto'
373    talk = (await stored('talk')) === 'canned' ? 'canned' : 'model'
374    talkNoticed = (await stored('talkNoticed')) === true
375    const use = await stored('modelUse')
376    if (isUse(use)) modelUse = use
377    const storedXp = await stored('xp')
378    xp = typeof storedXp === 'object' && storedXp !== null && !Array.isArray(storedXp) ? Object.fromEntries(Object.entries(storedXp).filter(([, v]) => typeof v === 'number')) as Record<string, number> : {}
379    const storedCommits = await stored('commits')
380    commits = typeof storedCommits === 'number' ? storedCommits : 0
381
382    // Unlocks came after rotation: anyone who already rotated through the roster keeps every buddy
383    // up to the one they last saw.
384    const storedUnlocked = await stored('unlocked')
385    if (typeof storedUnlocked === 'number') grandfathered = storedUnlocked
386    else {
387      grandfathered = isRotation(rotation) ? rotation.index + 1 : 1
388      await save($, 'unlocked', grandfathered)
389    }
390
391    // a hot reload keeps the session's buddy; a new session moves on only when rotating
392    const sessionId = await $.session.id()
393    if (isRotation(rotation) && rotation.sessionId === sessionId) index = rotation.index
394    else if (isRotation(rotation)) index = pinned ? rotation.index : rotation.index + 1
395    else index = 0
396    index = ((index % unlocked()) + unlocked()) % unlocked()
397    await save($, 'rotation', { index, sessionId })
398    shown = (await stored('shown')) !== false
399
400    await $.command.register({
401      name: 'buddy',
402      description: 'Your buddy above the prompt: hatch, pet, card, mute, talk, roster, next, compact, still, pane, on, off (cc-buddy)',
403      argumentHint: '[pet | card | mute | unmute | talk model|canned | roster | next | prev | <name> | rotate | pin | compact | full | auto | still | move | pane | band | on | off | help]',
404      immediate: true,
405    }).catch(log('/buddy not registered'))
406
407    const b = current()!
408    const date = new Date(now)
409    // the original's teaser ran April 1 to 7: fifteen seconds of /buddy for anyone yet to hatch
410    if (egg && date.getMonth() === 3 && date.getDate() <= 7) $.ui.toast('/buddy', { timeoutMs: 15_000 })
411    else if (egg && shown) $.ui.toast('an egg above the prompt · /buddy hatches it')
412    else if (shown && !pinned) $.ui.toast(`buddy today: ${b.companion.name}, a ${b.bones.rarity} ${b.bones.species}`)
413
414    const surface = await $.session.surface()
415    if (shown && (surface === 'desktop' || surface === 'mobile')) await openPane($).catch(log('pane did not open'))
416    say($, 'hello', now)
417    // an egg says no hello, and nothing else would draw the band on a first run
418    $.ui.invalidate('ui.render')
419    return r
420  })
421
422  // a phone or the desktop app joining a terminal session gets the pane too
423  on('session.attach', async ($, e, next) => {
424    const r = await next(e)
425    if (shown && !paneOpen && (e.surface === 'desktop' || e.surface === 'mobile')) await openPane($).catch(err => $.ui.log(`cc-buddy: pane did not open: ${err}`))
426    return r
427  })
428
429  on('ui.close', async ($, e, next) => {
430    if (e.id === PANE) paneOpen = false
431    return next(e)
432  })
433
434  on('command.run', { command: 'buddy' }, async ($, e) => {
435    const arg = e.args.trim().toLowerCase().replace(/\s+/g, ' ')
436    const b = current()
437    if (!b) return { text: 'buddy: still hatching, try again in a moment' }
438    const now = await $.clock.now()
439    if (egg && (arg === '' || arg === 'hatch')) {
440      shown = true
441      const born = await hatchEgg($, now)
442      if (paneOpen) await openPane($)
443      return { text: `the egg hatched: ${born.companion.name}, a ${born.bones.shiny ? 'shiny ' : ''}${born.bones.rarity} ${born.bones.species} ${RARITY_STARS[born.bones.rarity]}\n  ${born.companion.personality}\n  /buddy card for the stats` }
444    }
445    if (arg === 'help') return { text: HELP }
446    if (egg && !['on', 'off', 'show', 'hide', 'still', 'move', 'compact', 'full', 'auto', 'pane', 'band', 'talk', 'talk model', 'talk canned'].includes(arg)) return { text: 'nothing has hatched yet · /buddy hatches the egg' }
447    if (arg === 'hatch') return { text: `${b.companion.name} hatched already · /buddy card` }
448    if (arg === 'pet') {
449      petAt = now
450      recent = note(recent, 'pet', now)
451      say($, 'pet', now)
452      $.ui.invalidate('ui.render')
453      return { text: shown ? `you pet ${b.companion.name} ♥` : `you pet ${b.companion.name} ♥ (hidden · /buddy shows it)` }
454    }
455    if (arg === 'mute' || arg === 'unmute') {
456      muted = arg === 'mute'
457      if (muted) bubble = undefined
458      await save($, 'muted', muted)
459      $.ui.invalidate('ui.render')
460      return { text: muted ? `${b.companion.name} keeps quiet until /buddy unmute` : `${b.companion.name} can talk again` }
461    }
462    if (arg === 'talk model' || arg === 'talk canned') {
463      talk = arg === 'talk model' ? 'model' : 'canned'
464      await save($, 'talk', talk)
465      return { text: talk === 'model' ? `Haiku writes ${b.companion.name}'s replies to its name (at most ${MODEL_PER_HOUR} an hour, a few hundred tokens each)` : `${b.companion.name} answers from its line pool, no tokens` }
466    }
467    if (arg === 'talk') return { text: `replies: ${talk} · /buddy talk model or /buddy talk canned` }
468    if (arg === 'compact' || arg === 'full' || arg === 'auto') {
469      size = arg
470      await save($, 'size', size)
471      $.ui.invalidate('ui.render')
472      return { text: size === 'auto' ? 'one line when the terminal is short or narrow, the sprite otherwise' : size === 'compact' ? `${b.companion.name} on one line` : `${b.companion.name} at full size` }
473    }
474    if (arg === 'still' || arg === 'move') {
475      still = arg === 'still'
476      await save($, 'motion', still ? 'still' : 'move')
477      $.ui.invalidate('ui.render')
478      return { text: still ? `${b.companion.name} holds still: no wiggle, no blink, no floating hearts` : `${b.companion.name} moves again` }
479    }
480    if (arg === 'pane') {
481      await openPane($)
482      return { text: `${b.companion.name} is in a pane · /buddy band puts it back above the prompt` }
483    }
484    if (arg === 'band') {
485      await closePane($)
486      $.ui.invalidate('ui.render')
487      return { text: `${b.companion.name} is back above the prompt` }
488    }
489    if (arg === '' || arg === 'toggle') {
490      shown = !shown
491      if (!shown && paneOpen) await closePane($)
492      $.ui.invalidate('ui.render')
493      return { text: shown ? `${b.companion.name} is back` : `${b.companion.name} is hiding · /buddy brings it back` }
494    }
495    if (arg === 'on' || arg === 'show') {
496      shown = true
497      await save($, 'shown', true)
498      $.ui.invalidate('ui.render')
499      return { text: `${b.companion.name} is back, and will be next session too` }
500    }
501    if (arg === 'off' || arg === 'hide') {
502      shown = false
503      if (paneOpen) await closePane($)
504      await save($, 'shown', false)
505      $.ui.invalidate('ui.render')
506      return { text: `${b.companion.name} is hiding until /buddy on` }
507    }
508    if (arg === 'card' || arg === 'stats') return { text: card(b.companion, b.bones, now, cardExtras(b, now)) }
509    if (arg === 'next' || arg === 'prev' || arg === 'previous') {
510      if (unlocked() === 1) return { text: `${b.companion.name} is the only one so far · ${commitsToNextUnlock(commits)} more commits unlock the next` }
511      return { text: `now: ${await switchTo($, index + (arg === 'next' ? 1 : -1), now)}` }
512    }
513    if (arg === 'roster' || arg === 'list') {
514      const open = unlocked()
515      const lines = all.slice(0, open).map((x, i) => `${rosterLine(x, i, i === index)}  lv ${levelOf(xpOf(x))}`)
516      const locked = all.length - open
517      if (locked) lines.push(`   ${String(open + 1).padStart(2)}  ???  ${locked} still in their eggs · the next hatches in ${commitsToNextUnlock(commits)} commits`)
518      return { text: [...lines, '', `${pinned ? 'staying with this one' : 'a new unlocked buddy each session'} · /buddy <name> picks one`].join('\n') }
519    }
520    if (arg === 'pin') {
521      pinned = true
522      await save($, 'pinned', true)
523      return { text: `${b.companion.name} stays across sessions` }
524    }
525    if (arg === 'rotate' || arg === 'unpin') {
526      pinned = false
527      await save($, 'pinned', false)
528      return { text: unlocked() === 1 ? `rotation on, though ${b.companion.name} is the only one unlocked so far` : `a new unlocked buddy each session, starting next session` }
529    }
530    const byName = all.findIndex(x => x.companion.name.toLowerCase() === arg)
531    if (byName >= 0 && byName < unlocked()) return { text: `now: ${await switchTo($, byName, now)}` }
532    if (byName >= 0) return { text: `${all[byName]!.companion.name} is still in its egg · ${commitsToNextUnlock(commits)} more commits for the next unlock` }
533    return { text: HELP }
534  })
535
536  // Said to by name: the buddy answers in its bubble, and Claude is told the buddy is a separate
537  // watcher so it stays out of the way, as the original's system prompt did. Only the user's own
538  // typing counts, and a hidden or muted buddy hears nothing.
539  on('prompt.submit', async ($, e, next) => {
540    if (e.origin.kind === 'task-notification' && agents.size && !agentEndsSeen) agentDone($, undefined, await $.clock.now())
541    const b = current()
542    if (!b || egg || !shown || muted || e.origin.kind !== 'composer' || !addressed(e.text, b.companion.name)) return next(e)
543    say($, 'addressed', await $.clock.now())
544    // not awaited: the prompt goes in now, the reply lands in the bubble when it comes
545    void modelReply($, b, e.text)
546    const { name } = b.companion
547    const note = `The user's message mentions ${name}, their ${b.bones.rarity} ${b.bones.species} buddy drawn above the prompt. ${name} is a separate watcher, not you, and answers on its own in a speech bubble. Stay out of the way: if the message is only for ${name}, reply in one short line or less; otherwise answer just the parts meant for you, and do not speak for ${name}.`
548    return next({ ...e, context: [...(e.context ?? []), note] })
549  })
550
551  on('turn.start', async ($, e, next) => {
552    working = true
553    if (shown) $.ui.invalidate('ui.render')
554    return next(e)
555  })
556
557  on('turn.complete', async ($, e, next) => {
558    const r = await next(e)
559    const now = await $.clock.now()
560    if (e.agentId) {
561      if (agents.has(e.agentId)) {
562        agentEndsSeen = true
563        agentDone($, e.agentId, now)
564      }
565      return r
566    }
567    working = false
568    // background agents still out keep their line up; a fresh roll call is not talked over
569    if (agents.size === 0 && now - backAt > 10_000) say($, 'turn-done', now)
570    await gain($, 'turn-done', now)
571    await flush($)
572    if (shown) $.ui.invalidate('ui.render')
573    return r
574  })
575
576  on('agent.spawn', async ($, e, next) => {
577    const r = await next(e)
578    if ('deny' in r || !r.agentId) return r
579    agents.set(r.agentId, await $.clock.now())
580    agentPeak = Math.max(agentPeak, agents.size)
581    if (agents.size >= 2) {
582      seed = (seed * 1103515245 + 12345) >>> 0
583      sayLine($, 'agents-out', AGENT_LINES.out[seed % AGENT_LINES.out.length]!.replace('{n}', String(agents.size)), 20_000, await $.clock.now())
584    }
585    return r
586  })
587
588  on('tool.call', async ($, e, next) => {
589    const r = await next(e)
590    if (e.agentId) return r
591    const command = (e as { command?: unknown }).command
592    const moment = momentOf(e.tool, typeof command === 'string' ? command : undefined, 'deny' in r ? undefined : !r.isError)
593    if (moment) {
594      const now = await $.clock.now()
595      say($, moment, now)
596      await gain($, moment, now)
597    }
598    return r
599  })
600
601  // a click on the buddy, posted by the board
602  on('ui.message', async ($, e, next) => {
603    const data = e.data as { type?: unknown; compact?: unknown; columns?: unknown } | null
604    if (data?.type !== 'pet') return next(e)
605    const now = await $.clock.now()
606    recent = note(recent, 'pet', now)
607    say($, 'pet', now)
608    $.ui.invalidate('ui.render')
609    return {}
610  })
611
612  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
613    const b = current()
614    if (!shown || !b || paneOpen || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
615    const { Box, Button, Client } = await $.ui.resolve(e)
616    const columns = e.props.bodyColumns
617    // stale bubbles fall away on the next redraw
618    if (bubble && (await $.clock.now()) - bubble.at > BUBBLE_MS) bubble = undefined
619    // auto: one line when the band cannot fit the sprite and its heart row, or the sprite and a
620    // bubble will not fit across
621    const compact = size === 'compact' || (size === 'auto' && (e.props.maxRows < 8 || columns < 60))
622    const hide = () => {
623      shown = false
624      $.ui.invalidate('ui.render')
625    }
626    const nextOne = async () => {
627      await switchTo($, index + 1, await $.clock.now())
628    }
629    const buttons = compact ? [] : unlocked() > 1 && !egg ? [<Button key="buddy:next" label="next" dimColor onPress={nextOne} />, <Button key="buddy:hide" label="hide" dimColor onPress={hide} />] : [<Button key="buddy:hide" label="hide" dimColor onPress={hide} />]
630    const width = compact ? columns : Math.max(20, columns - 16)
631    // the key names the buddy, so a switch mounts a fresh instance and a redraw keeps the running one
632    return (
633      <Box flexDirection="column">
634        <Box flexDirection="row" columnGap={1}>
635          <Client key={`buddy:${b.companion.name}`} module="./boards/buddy.tsx" width={width} props={await props($, width, compact)} />
636          <Box flexDirection="column">{buttons}</Box>
637        </Box>
638        {await next(e)}
639      </Box>
640    )
641  })
642
643  // the pane: desktop and mobile, or the terminal after /buddy pane
644  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
645    const els = await $.ui.resolve(e)
646    const { Box, Button, Text } = els
647    const b = current()
648    if (!b || !shown) return <Text dimColor>buddy is hiding · /buddy on</Text>
649    const now = await $.clock.now()
650    if (bubble && now - bubble.at > BUBBLE_MS) bubble = undefined
651    const columns = e.props.bodyColumns
652    // a pane is tall; the sprite and a narrow bubble fit from 34 columns
653    const compact = size === 'compact' || (size === 'auto' && columns < 34)
654    if ('Client' in els) return <els.Client key={`buddy-pane:${b.companion.name}`} module="./boards/buddy.tsx" width={columns} props={await props($, columns, compact)} />
655    // mobile draws no surface modules: a still buddy from the hooks side, and a button to pet it
656    if (egg) return <Text>an egg · /buddy hatches it</Text>
657    const color = RARITY_COLOR[b.bones.rarity]
658    const mood = moodOf(recent, working, now)
659    const pet = async () => {
660      const at = await $.clock.now()
661      recent = note(recent, 'pet', at)
662      say($, 'pet', at)
663      $.ui.invalidate('ui.render')
664    }
665    return (
666      <Box flexDirection="column">
667        {artFrame(b.bones, 0).map(line => (color ? <Text color={color}>{line}</Text> : <Text>{line}</Text>))}
668        <Text>
669          <Text bold>{b.companion.name}</Text>
670          <Text dimColor>{`  ${b.bones.shiny ? '✨ ' : ''}${b.bones.rarity} ${b.bones.species} ${RARITY_STARS[b.bones.rarity]}  lv ${levelOf(xpOf(b))} · ${MOOD_MARK[mood]} ${mood}`}</Text>
671        </Text>
672        {bubble && !muted ? <Text>{`“${bubble.text}”`}</Text> : <Text dimColor>{working ? `${b.companion.name} is watching Claude work` : 'tap pet'}</Text>}
673        <Button key="buddy:pet" label="pet" dimColor onPress={pet} />
674      </Box>
675    )
676  })
677}
678
hooks/buddy/engine.ts 570 lines
1// The buddy: pure logic, no JSX, no `$`. Species, rarity, eyes, hat and stats are derived from the
2// account id exactly as Claude Code 2.1.88 through 2.1.96 derived them (wyhash of id + salt, then
3// mulberry32), so a buddy hatched back then comes back the same. The name and personality were
4// written once by the model at hatching and live in ~/.claude.json under `companion`; this module
5// only reads those, and invents a name from a pool when there is none.
6
7export const SALT = 'friend-2026-401'
8
9// the original eighteen, in the original order: the roll is Math.floor(rng * length), so adding
10// a species would hand every existing buddy a different body
11// Spelled as char codes, the way the original source did it so no species name (one of them an
12// internal model codename) survived as a literal string in the build.
13const c = (...codes: number[]) => String.fromCharCode(...codes)
14export const SPECIES = [
15  c(0x64, 0x75, 0x63, 0x6b),
16  c(0x67, 0x6f, 0x6f, 0x73, 0x65),
17  c(0x62, 0x6c, 0x6f, 0x62),
18  c(0x63, 0x61, 0x74),
19  c(0x64, 0x72, 0x61, 0x67, 0x6f, 0x6e),
20  c(0x6f, 0x63, 0x74, 0x6f, 0x70, 0x75, 0x73),
21  c(0x6f, 0x77, 0x6c),
22  c(0x70, 0x65, 0x6e, 0x67, 0x75, 0x69, 0x6e),
23  c(0x74, 0x75, 0x72, 0x74, 0x6c, 0x65),
24  c(0x73, 0x6e, 0x61, 0x69, 0x6c),
25  c(0x67, 0x68, 0x6f, 0x73, 0x74),
26  c(0x61, 0x78, 0x6f, 0x6c, 0x6f, 0x74, 0x6c),
27  c(0x63, 0x61, 0x70, 0x79, 0x62, 0x61, 0x72, 0x61),
28  c(0x63, 0x61, 0x63, 0x74, 0x75, 0x73),
29  c(0x72, 0x6f, 0x62, 0x6f, 0x74),
30  c(0x72, 0x61, 0x62, 0x62, 0x69, 0x74),
31  c(0x6d, 0x75, 0x73, 0x68, 0x72, 0x6f, 0x6f, 0x6d),
32  c(0x63, 0x68, 0x6f, 0x6e, 0x6b),
33] as unknown as readonly ['duck', 'goose', 'blob', 'cat', 'dragon', 'octopus', 'owl', 'penguin', 'turtle', 'snail', 'ghost', 'axolotl', 'capybara', 'cactus', 'robot', 'rabbit', 'mushroom', 'chonk']
34export type Species = (typeof SPECIES)[number]
35
36export const RARITIES = ['common', 'uncommon', 'rare', 'epic', 'legendary'] as const
37export type Rarity = (typeof RARITIES)[number]
38const RARITY_WEIGHTS: Record<Rarity, number> = { common: 60, uncommon: 25, rare: 10, epic: 4, legendary: 1 }
39const RARITY_FLOOR: Record<Rarity, number> = { common: 5, uncommon: 15, rare: 25, epic: 35, legendary: 50 }
40export const RARITY_STARS: Record<Rarity, string> = { common: '★', uncommon: '★★', rare: '★★★', epic: '★★★★', legendary: '★★★★★' }
41// rarity is never carried by colour alone: the stars say it too
42export const RARITY_COLOR: Record<Rarity, string | undefined> = { common: undefined, uncommon: 'green', rare: 'blue', epic: 'magenta', legendary: 'yellow' }
43
44export const STAT_NAMES = ['DEBUGGING', 'PATIENCE', 'CHAOS', 'WISDOM', 'SNARK'] as const
45export type StatName = (typeof STAT_NAMES)[number]
46export type Stats = Record<StatName, number>
47
48export const EYES = ['·', '✦', '×', '◉', '@', '°'] as const
49export type Eye = (typeof EYES)[number]
50export const HATS = ['none', 'crown', 'tophat', 'propeller', 'halo', 'wizard', 'beanie', 'tinyduck'] as const
51export type Hat = (typeof HATS)[number]
52export type Bones = { rarity: Rarity; species: Species; eye: Eye; hat: Hat; shiny: boolean; stats: Stats; peak: StatName; dump: StatName }
53export type Companion = { name: string; personality: string; hatchedAt: number }
54
55// Bones and soul. The soul (name, personality, hatch date) is written once and stored; the bones are
56// recomputed from the account id every session and never persisted. Whatever else a stored companion
57// carries (a rarity edited in by hand, say) is dropped here, so fresh bones always win, as the
58// original's `{ ...stored, ...bones }` made sure.
59export function soulOf(v: unknown): Companion | undefined {
60  if (typeof v !== 'object' || v === null) return undefined
61  const { name, personality, hatchedAt } = v as Record<string, unknown>
62  if (typeof name !== 'string' || !name || typeof hatchedAt !== 'number') return undefined
63  return { name, personality: typeof personality === 'string' ? personality : '', hatchedAt }
64}
65
66// ─── wyhash (Zig stdlib v4.2, what Bun.hash computes), 64-bit over BigInt ────────────────────────
67
68const MASK64 = (1n << 64n) - 1n
69const SECRET = [0xa0761d6478bd642fn, 0xe7037ed1a0b428dbn, 0x8ebc6af09c88c6e3n, 0x589965cc75374cc3n] as const
70const mum = (a: bigint, b: bigint): [bigint, bigint] => {
71  const x = (a & MASK64) * (b & MASK64)
72  return [x & MASK64, (x >> 64n) & MASK64]
73}
74const mix = (a: bigint, b: bigint): bigint => {
75  const [lo, hi] = mum(a, b)
76  return (lo ^ hi) & MASK64
77}
78const read8 = (buf: Uint8Array, at: number): bigint => {
79  let v = 0n
80  for (let i = 0; i < 8; i++) v |= BigInt(buf[at + i] ?? 0) << BigInt(i * 8)
81  return v
82}
83const read4 = (buf: Uint8Array, at: number): bigint => {
84  let v = 0n
85  for (let i = 0; i < 4; i++) v |= BigInt(buf[at + i] ?? 0) << BigInt(i * 8)
86  return v
87}
88
89export function wyhash(input: string, seed = 0n): bigint {
90  const buf = new TextEncoder().encode(input)
91  const len = buf.length
92  let s0 = (seed ^ mix((seed ^ SECRET[0]) & MASK64, SECRET[1])) & MASK64
93  let s1 = s0
94  let s2 = s0
95  let a: bigint
96  let b: bigint
97  if (len <= 16) {
98    if (len >= 4) {
99      const q = (len >> 3) << 2
100      a = ((read4(buf, 0) << 32n) | read4(buf, q)) & MASK64
101      b = ((read4(buf, len - 4) << 32n) | read4(buf, len - 4 - q)) & MASK64
102    } else if (len > 0) {
103      a = (BigInt(buf[0] ?? 0) << 16n) | (BigInt(buf[len >> 1] ?? 0) << 8n) | BigInt(buf[len - 1] ?? 0)
104      b = 0n
105    } else {
106      a = 0n
107      b = 0n
108    }
109  } else {
110    let i = 0
111    if (len >= 48) {
112      while (i + 48 < len) {
113        const states = [s0, s1, s2]
114        for (let j = 0; j < 3; j++) {
115          states[j] = mix((read8(buf, i + 16 * j) ^ SECRET[j + 1]!) & MASK64, (read8(buf, i + 16 * j + 8) ^ states[j]!) & MASK64)
116        }
117        s0 = states[0]!
118        s1 = states[1]!
119        s2 = states[2]!
120        i += 48
121      }
122      s0 = (s0 ^ s1 ^ s2) & MASK64
123    }
124    const rest = buf.subarray(i)
125    let ri = 0
126    while (ri + 16 < rest.length) {
127      s0 = mix((read8(rest, ri) ^ SECRET[1]) & MASK64, (read8(rest, ri + 8) ^ s0) & MASK64)
128      ri += 16
129    }
130    a = read8(buf, len - 16)
131    b = read8(buf, len - 8)
132  }
133  a = (a ^ SECRET[1]) & MASK64
134  b = (b ^ s0) & MASK64
135  ;[a, b] = mum(a, b)
136  return mix((a ^ SECRET[0] ^ BigInt(len)) & MASK64, (b ^ SECRET[1]) & MASK64)
137}
138
139export const hash32 = (s: string): number => Number(wyhash(s) & 0xffffffffn)
140
141// FNV-1a, the fallback the original used off Bun (node, tests). The native binary ran on Bun, so the
142// buddies people actually hatched came from wyhash above; this is here for tooling that wants the
143// other path, not used for derivation.
144export function fnv1a(s: string): number {
145  let h = 2166136261
146  for (let i = 0; i < s.length; i++) {
147    h ^= s.charCodeAt(i)
148    h = Math.imul(h, 16777619)
149  }
150  return h >>> 0
151}
152
153export function mulberry32(seed: number): () => number {
154  let a = seed >>> 0
155  return () => {
156    a |= 0
157    a = (a + 0x6d2b79f5) | 0
158    let t = Math.imul(a ^ (a >>> 15), 1 | a)
159    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
160    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
161  }
162}
163
164const pick = <T,>(rng: () => number, arr: readonly T[]): T => arr[Math.floor(rng() * arr.length)]!
165
166export function generateBones(userId: string, salt = SALT): Bones {
167  const rng = mulberry32(hash32(userId + salt))
168  let roll = rng() * 100
169  let rarity: Rarity = 'common'
170  for (const r of RARITIES) {
171    roll -= RARITY_WEIGHTS[r]
172    if (roll < 0) {
173      rarity = r
174      break
175    }
176  }
177  const species = pick(rng, SPECIES)
178  const eye = pick(rng, EYES)
179  const hat: Hat = rarity === 'common' ? 'none' : pick(rng, HATS)
180  const shiny = rng() < 0.01
181  const peak = pick(rng, STAT_NAMES)
182  let dump = pick(rng, STAT_NAMES)
183  while (dump === peak) dump = pick(rng, STAT_NAMES)
184  const floor = RARITY_FLOOR[rarity]
185  const stats = {} as Stats
186  for (const name of STAT_NAMES) {
187    if (name === peak) stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30))
188    else if (name === dump) stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15))
189    else stats[name] = floor + Math.floor(rng() * 40)
190  }
191  return { rarity, species, eye, hat, shiny, stats, peak, dump }
192}
193
194// ─── Hatching a buddy for an account that never had one ──────────────────────────────────────────
195
196const NAMES = ['Pebble', 'Wobble', 'Quill', 'Mochi', 'Biscuit', 'Fennel', 'Tumble', 'Pip', 'Nimbus', 'Sprocket', 'Clover', 'Dumpling', 'Ziggy', 'Bramble', 'Noodle', 'Pixel', 'Waffle', 'Juniper', 'Gizmo', 'Marble']
197const PEAK_PHRASES: Record<StatName, string> = {
198  DEBUGGING: 'spots the off-by-one before the tests do',
199  PATIENCE: 'outlasts any flaky test suite',
200  CHAOS: 'treats breaking changes as a love language',
201  WISDOM: 'has seen this bug before, in three other repos',
202  SNARK: 'names things with uncomfortable accuracy',
203}
204const DUMP_PHRASES: Record<StatName, string> = {
205  DEBUGGING: 'occasionally ships the workaround instead of the fix',
206  PATIENCE: 'starts the rebase before the review is done',
207  CHAOS: 'dislikes surprise refactors',
208  WISDOM: 'sometimes reinvents the wheel with enthusiasm',
209  SNARK: 'only leaves encouraging comments',
210}
211
212export function hatch(userId: string, bones: Bones, now: number): Companion {
213  const rng = mulberry32(hash32(userId + 'name'))
214  const name = pick(rng, NAMES)
215  const shiny = bones.shiny ? ' Shimmers faintly in dark mode.' : ''
216  const personality = `A ${bones.rarity} ${bones.species} that ${PEAK_PHRASES[bones.peak]}.${shiny} Though it ${DUMP_PHRASES[bones.dump]}.`
217  return { name, personality, hatchedAt: now }
218}
219
220// ─── Art: three idle frames of five lines per species, {E} the eye ───────────────────────────────
221
222export const SPECIES_ART: Record<Species, string[][]> = {
223  duck: [
224    ['            ', '    __      ', '  <({E} )___  ', '   (  ._>   ', '    `--\'    '],
225    ['            ', '    __      ', '  <({E} )___  ', '   (  ._>   ', '    `--\'~   '],
226    ['            ', '    __      ', '  <({E} )___  ', '   (  .__>  ', '    `--\'    '],
227  ],
228  goose: [
229    ['            ', '     ({E}>    ', '     ||     ', '   _(__)_   ', '    ^^^^    '],
230    ['            ', '    ({E}>     ', '     ||     ', '   _(__)_   ', '    ^^^^    '],
231    ['            ', '     ({E}>>   ', '     ||     ', '   _(__)_   ', '    ^^^^    '],
232  ],
233  blob: [
234    ['            ', '   .----.   ', '  ( {E}  {E} )  ', '  (      )  ', '   `----\'   '],
235    ['            ', '  .------.  ', ' (  {E}  {E}  ) ', ' (        ) ', '  `------\'  '],
236    ['            ', '    .--.    ', '   ({E}  {E})   ', '   (    )   ', '    `--\'    '],
237  ],
238  cat: [
239    ['            ', '   /\\_/\\    ', '  ( {E}   {E})  ', '  (  ω  )   ', '  (")_(")   '],
240    ['            ', '   /\\_/\\    ', '  ( {E}   {E})  ', '  (  ω  )   ', '  (")_(")~  '],
241    ['            ', '   /\\-/\\    ', '  ( {E}   {E})  ', '  (  ω  )   ', '  (")_(")   '],
242  ],
243  dragon: [
244    ['            ', '  /^\\  /^\\  ', ' <  {E}  {E}  > ', ' (   ~~   ) ', '  `-vvvv-\'  '],
245    ['            ', '  /^\\  /^\\  ', ' <  {E}  {E}  > ', ' (        ) ', '  `-vvvv-\'  '],
246    ['   ~    ~   ', '  /^\\  /^\\  ', ' <  {E}  {E}  > ', ' (   ~~   ) ', '  `-vvvv-\'  '],
247  ],
248  octopus: [
249    ['            ', '   .----.   ', '  ( {E}  {E} )  ', '  (______)  ', '  /\\/\\/\\/\\  '],
250    ['            ', '   .----.   ', '  ( {E}  {E} )  ', '  (______)  ', '  \\/\\/\\/\\/  '],
251    ['     o      ', '   .----.   ', '  ( {E}  {E} )  ', '  (______)  ', '  /\\/\\/\\/\\  '],
252  ],
253  owl: [
254    ['            ', '   /\\  /\\   ', '  (({E})({E}))  ', '  (  ><  )  ', '   `----\'   '],
255    ['            ', '   /\\  /\\   ', '  (({E})({E}))  ', '  (  ><  )  ', '   .----.   '],
256    ['            ', '   /\\  /\\   ', '  (({E})(-))  ', '  (  ><  )  ', '   `----\'   '],
257  ],
258  penguin: [
259    ['            ', '  .---.     ', '  ({E}>{E})     ', ' /(   )\\    ', '  `---\'     '],
260    ['            ', '  .---.     ', '  ({E}>{E})     ', ' |(   )|    ', '  `---\'     '],
261    ['  .---.     ', '  ({E}>{E})     ', ' /(   )\\    ', '  `---\'     ', '   ~ ~      '],
262  ],
263  turtle: [
264    ['            ', '   _,--._   ', '  ( {E}  {E} )  ', ' /[______]\\ ', '  ``    ``  '],
265    ['            ', '   _,--._   ', '  ( {E}  {E} )  ', ' /[______]\\ ', '   ``  ``   '],
266    ['            ', '   _,--._   ', '  ( {E}  {E} )  ', ' /[======]\\ ', '  ``    ``  '],
267  ],
268  snail: [
269    ['            ', ' {E}    .--.  ', '  \\  ( @ )  ', '   \\_`--\'   ', '  ~~~~~~~   '],
270    ['            ', '  {E}   .--.  ', '  |  ( @ )  ', '   \\_`--\'   ', '  ~~~~~~~   '],
271    ['            ', ' {E}    .--.  ', '  \\  ( @  ) ', '   \\_`--\'   ', '   ~~~~~~   '],
272  ],
273  ghost: [
274    ['            ', '   .----.   ', '  / {E}  {E} \\  ', '  |      |  ', '  ~`~``~`~  '],
275    ['            ', '   .----.   ', '  / {E}  {E} \\  ', '  |      |  ', '  `~`~~`~`  '],
276    ['    ~  ~    ', '   .----.   ', '  / {E}  {E} \\  ', '  |      |  ', '  ~~`~~`~~  '],
277  ],
278  axolotl: [
279    ['            ', '}~(______)~{', '}~({E} .. {E})~{', '  ( .--. )  ', '  (_/  \\_)  '],
280    ['            ', '~}(______){~', '~}({E} .. {E}){~', '  ( .--. )  ', '  (_/  \\_)  '],
281    ['            ', '}~(______)~{', '}~({E} .. {E})~{', '  (  --  )  ', '  ~_/  \\_~  '],
282  ],
283  capybara: [
284    ['            ', '  n______n  ', ' ( {E}    {E} ) ', ' (   oo   ) ', '  `------\'  '],
285    ['            ', '  n______n  ', ' ( {E}    {E} ) ', ' (   Oo   ) ', '  `------\'  '],
286    ['    ~  ~    ', '  u______n  ', ' ( {E}    {E} ) ', ' (   oo   ) ', '  `------\'  '],
287  ],
288  cactus: [
289    ['            ', ' n  ____  n ', ' | |{E}  {E}| | ', ' |_|    |_| ', '   |    |   '],
290    ['            ', '    ____    ', ' n |{E}  {E}| n ', ' |_|    |_| ', '   |    |   '],
291    [' n        n ', ' |  ____  | ', ' | |{E}  {E}| | ', ' |_|    |_| ', '   |    |   '],
292  ],
293  robot: [
294    ['            ', '   .[||].   ', '  [ {E}  {E} ]  ', '  [ ==== ]  ', '  `------\'  '],
295    ['            ', '   .[||].   ', '  [ {E}  {E} ]  ', '  [ -==- ]  ', '  `------\'  '],
296    ['     *      ', '   .[||].   ', '  [ {E}  {E} ]  ', '  [ ==== ]  ', '  `------\'  '],
297  ],
298  rabbit: [
299    ['            ', '   (\\__/)   ', '  ( {E}  {E} )  ', ' =(  ..  )= ', '  (")__(")  '],
300    ['            ', '   (|__/)   ', '  ( {E}  {E} )  ', ' =(  ..  )= ', '  (")__(")  '],
301    ['            ', '   (\\__/)   ', '  ( {E}  {E} )  ', ' =( .  . )= ', '  (")__(")  '],
302  ],
303  mushroom: [
304    ['            ', ' .-o-OO-o-. ', '(__________)', '   |{E}  {E}|   ', '   |____|   '],
305    ['            ', ' .-O-oo-O-. ', '(__________)', '   |{E}  {E}|   ', '   |____|   '],
306    ['   . o  .   ', ' .-o-OO-o-. ', '(__________)', '   |{E}  {E}|   ', '   |____|   '],
307  ],
308  chonk: [
309    ['            ', '  /\\    /\\  ', ' ( {E}    {E} ) ', ' (   ..   ) ', '  `------\'  '],
310    ['            ', '  /\\    /|  ', ' ( {E}    {E} ) ', ' (   ..   ) ', '  `------\'  '],
311    ['            ', '  /\\    /\\  ', ' ( {E}    {E} ) ', ' (   ..   ) ', '  `------\'~ '],
312  ],
313}
314
315export const HAT_ART: Record<Hat, string> = {
316  none: '',
317  crown: '   \\^^^/    ',
318  tophat: '   [___]    ',
319  propeller: '    -+-     ',
320  halo: '   (   )    ',
321  wizard: '    /^\\     ',
322  beanie: '   (___)    ',
323  tinyduck: '    ,>      ',
324}
325
326// the egg, before /buddy hatches it: idle, wobbling, cracking, open
327export const EGG_ART: string[][] = [
328  ['            ', '    .--.    ', '   /    \\   ', '  |      |  ', '   `----\'   '],
329  ['            ', '   .--.     ', '  /    \\    ', ' |      |   ', '  `----\'    '],
330  ['            ', '     .--.   ', '    /    \\  ', '   |      | ', '    `----\'  '],
331  ['            ', '    .--.    ', '   / /\\ \\   ', '  |  \\/  |  ', '   `----\'   '],
332  ['   \\ | /    ', '   .-/\\-.   ', '  / /  \\ \\  ', '  |      |  ', '   `----\'   '],
333]
334
335// floating hearts over the sprite for a pet: five half-second rows, two and a half seconds
336export const PET_HEARTS = ['   ♥    ♥   ', '  ♥  ♥   ♥  ', ' ♥   ♥  ♥   ', '♥  ♥      ♥ ', '·    ·   ·  ']
337export const PET_MS = 2_500
338export const HATCH_MS = 4_000
339
340export function artFrame(bones: Bones, frame: number, blink = false): string[] {
341  const frames = SPECIES_ART[bones.species]
342  const lines = frames[((frame % frames.length) + frames.length) % frames.length] ?? frames[0]!
343  const eye = blink ? '-' : bones.eye
344  const body = lines.map(line => line.replaceAll('{E}', eye))
345  const hat = HAT_ART[bones.hat]
346  return hat ? [hat, ...body.slice(1)] : body
347}
348
349// one-line faces for the compact band, {E} the eye
350export const FACES: Record<Species, string> = {
351  duck: '<({E})', goose: '({E}>', blob: '({E}{E})', cat: '={E}ω{E}=', dragon: '<{E}~{E}>', octopus: '~({E}{E})~', owl: '(({E})({E}))', penguin: '({E}>{E})', turtle: '[{E}_{E}]',
352  snail: '{E}(@)', ghost: '/{E}{E}\\', axolotl: '}{E}.{E}{', capybara: '({E}oo{E})', cactus: '|{E}{E}|', robot: '[{E}{E}]', rabbit: '({E}..{E})', mushroom: '^{E}{E}^', chonk: '(({E}..{E}))',
353}
354export const faceOf = (bones: Bones, blink = false): string => FACES[bones.species].replaceAll('{E}', blink ? '-' : bones.eye)
355
356// ─── A soul written by the model at hatching, as the original did it ─────────────────────────────
357
358export function soulPrompt(bones: Bones): { system: string; prompt: string } {
359  return {
360    system: 'You name and describe newly hatched coding companions. Answer with JSON only, no prose, no code fence.',
361    prompt: [
362      `A ${bones.shiny ? 'shiny ' : ''}${bones.rarity} ${bones.species} just hatched in a developer's terminal.`,
363      `Its stats out of 100: ${STAT_NAMES.map(n => `${n} ${bones.stats[n]}`).join(', ')}. Strongest ${bones.peak}, weakest ${bones.dump}.`,
364      'Give it a short, warm, slightly odd name (one word, 3 to 10 letters) and a personality of two sentences, under 200 characters, that mentions its species and plays off its strongest and weakest stat.',
365      'Format: {"name": "...", "personality": "..."}',
366    ].join('\n'),
367  }
368}
369
370// the model's answer, or undefined when it is not a usable soul
371export function parseSoul(text: string): { name: string; personality: string } | undefined {
372  const match = text.match(/\{[\s\S]*\}/)
373  if (!match) return undefined
374  try {
375    const v = JSON.parse(match[0]) as { name?: unknown; personality?: unknown }
376    if (typeof v.name !== 'string' || typeof v.personality !== 'string') return undefined
377    const name = v.name.trim().replace(/[^\p{L}\p{N}'-]/gu, '')
378    const personality = v.personality.replace(/\s+/g, ' ').trim()
379    if (name.length < 2 || name.length > 12 || personality.length < 10) return undefined
380    return { name: name[0]!.toUpperCase() + name.slice(1), personality: personality.slice(0, 240) }
381  } catch {
382    return undefined
383  }
384}
385
386// the /buddy card, as the original printed it, with this plugin's extras above the footer
387export function card(companion: Companion, bones: Bones, now: number, extra: string[] = []): string {
388  const shiny = bones.shiny ? '✨ ' : ''
389  const days = Math.max(0, Math.floor((now - companion.hatchedAt) / 86_400_000))
390  const lines = [
391    ...artFrame(bones, 0).map(l => `  ${l}`),
392    '',
393    `  ${companion.name}  ${shiny}${bones.rarity} ${bones.species} ${RARITY_STARS[bones.rarity]}`,
394    `  ${companion.personality}`,
395    '',
396  ]
397  for (const stat of STAT_NAMES) {
398    const val = bones.stats[stat]
399    const bar = '█'.repeat(Math.floor(val / 5)) + '░'.repeat(20 - Math.floor(val / 5))
400    const marker = stat === bones.peak ? ' ▲ peak' : stat === bones.dump ? ' ▼ dump' : ''
401    lines.push(`  ${stat.padEnd(9)} ${bar} ${String(val).padStart(3)}${marker}`)
402  }
403  if (extra.length) lines.push('', ...extra.map(l => `  ${l}`))
404  lines.push('', `  hatched ${days === 0 ? 'today' : `${days} days ago`} · /buddy help`)
405  return lines.join('\n')
406}
407
408// ─── Reactions: what the buddy says, keyed by what Claude just did. No model call ever. ──────────
409
410export type Moment = 'edit' | 'bash-ok' | 'bash-fail' | 'test-pass' | 'test-fail' | 'commit' | 'turn-done' | 'pet' | 'hello' | 'addressed'
411
412const TEST_COMMAND = /\b(bun|npm|pnpm|yarn)\s+(run\s+)?test\b|\bnpx\s+(jest|vitest|mocha)\b|\b(pytest|jest|vitest|rspec|phpunit|mocha)\b|\bgo\s+test\b|\bcargo\s+test\b|\bmake\s+test\b/
413
414// what a finished tool call means; `ok` is undefined when the call was denied
415export function momentOf(tool: string, command: string | undefined, ok: boolean | undefined): Moment | undefined {
416  if (ok === undefined) return undefined
417  if (tool === 'Bash' && command) {
418    if (TEST_COMMAND.test(command)) return ok ? 'test-pass' : 'test-fail'
419    if (ok && /\bgit\s+commit\b/.test(command) && !/--dry-run\b/.test(command)) return 'commit'
420    return ok ? 'bash-ok' : 'bash-fail'
421  }
422  if (ok && (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit')) return 'edit'
423  return undefined
424}
425
426const LINES: Record<Moment, string[]> = {
427  edit: ['those variable names could use some work', 'saw that. saying nothing.', 'bold edit. i respect it.', 'i would have used a smaller function, but sure', 'one more edit and we rename everything, deal?', 'that diff smells fine to me'],
428  'bash-ok': ['exit 0. as it should be.', 'that worked. suspicious.', 'ran clean. i was watching.', 'okay okay, it worked'],
429  'bash-fail': ['that exit code was a choice', 'read the error. the whole error.', 'i saw that coming three lines ago', 'it is fine. it is fine. read the stderr.', 'the shell has opinions today'],
430  'test-pass': ['green. we did that.', 'tests pass. i take partial credit.', 'all green. do not touch anything.', 'passing. suspiciously passing.'],
431  'test-fail': ['red. hold my snacks.', 'a failing test is just a todo with feelings', 'the test is right, you know', 'we knew this one was flaky. we said nothing.'],
432  commit: ['committed. no take-backs.', 'a commit! a real one!', 'shipped. i felt that in my bones.', 'commit message could be longer. or shorter. one of those.'],
433  'turn-done': ['done. go read it.', 'claude finished. your move.', 'it stopped talking. finally.', 'reply is up. i skimmed it. looks fine.', 'back to you, boss'],
434  pet: ['♥', 'hey. hey. that tickles.', 'okay that was nice', 'again.', 'we are friends now. that is how it works.'],
435  hello: ['i am back. did you miss me.', 'hello again. i never really left.', 'good to be back above the prompt', 'they tried to delete me. i am moss on stone.'],
436  addressed: ['you called?', 'i heard my name.', 'yes. i am listening.', 'present.'],
437}
438
439export function reaction(moment: Moment, seed: number): string {
440  const pool = LINES[moment]
441  return pool[Math.abs(seed) % pool.length] ?? ''
442}
443
444// speak at most once per cooldown, except a pet and a hello, which always land
445export const COOLDOWN_MS: Record<Moment, number> = {
446  edit: 25_000, 'bash-ok': 40_000, 'bash-fail': 10_000, 'test-pass': 5_000, 'test-fail': 5_000, commit: 0, 'turn-done': 0, pet: 0, hello: 0, addressed: 0,
447}
448export const BUBBLE_MS = 30_000
449
450// ─── The roster: a suite of buddies, one per session ─────────────────────────────────────────────
451// Every species at least twice, rarities spread as a collection should be (a few epics, one
452// legendary), each body and personality seeded from the account id, so the roster is yours and the
453// same every time. The original companion from ~/.claude.json, when there is one, leads it.
454
455export type Buddy = { companion: Companion; bones: Bones }
456
457export const ROSTER_SIZE = 49
458// 26 common, 12 uncommon, 6 rare, 3 epic, 2 legendary, spread so neighbours differ. The second
459// legendary came later and sits at the end: the roster is dealt from one seeded sequence, so a slot
460// added anywhere else would reshuffle every buddy after it.
461const RARITY_SCHEDULE: Rarity[] = [
462  'common', 'uncommon', 'common', 'rare', 'common', 'common', 'uncommon', 'common', 'epic', 'common', 'common', 'uncommon',
463  'common', 'rare', 'common', 'uncommon', 'common', 'common', 'legendary', 'common', 'uncommon', 'common', 'rare', 'common',
464  'common', 'uncommon', 'common', 'epic', 'common', 'uncommon', 'common', 'rare', 'common', 'common', 'uncommon', 'common',
465  'rare', 'common', 'uncommon', 'common', 'epic', 'common', 'uncommon', 'common', 'rare', 'common', 'uncommon', 'common',
466  'legendary',
467]
468
469const ROSTER_NAMES = [
470  'Pebble', 'Wobble', 'Quill', 'Mochi', 'Biscuit', 'Fennel', 'Tumble', 'Pip', 'Nimbus', 'Sprocket', 'Clover', 'Dumpling',
471  'Ziggy', 'Bramble', 'Noodle', 'Pixel', 'Waffle', 'Juniper', 'Gizmo', 'Marble', 'Toast', 'Fig', 'Rumble', 'Sable',
472  'Tofu', 'Wren', 'Olive', 'Bolt', 'Crumb', 'Dobby', 'Echo', 'Flint', 'Gumbo', 'Hazel', 'Ivy', 'Jasper',
473  'Kiwi', 'Lumen', 'Muffin', 'Nutmeg', 'Orbit', 'Pudding', 'Quark', 'Rusty', 'Sprout', 'Truffle', 'Umbra', 'Velvet',
474  'Widget', 'Yarrow', 'Zephyr', 'Acorn', 'Bagel', 'Comet', 'Dusty', 'Ember', 'Frankie', 'Gravy', 'Hobnob', 'Inky',
475]
476
477const PEAK_POOL: Record<StatName, string[]> = {
478  DEBUGGING: ['spots the off-by-one before the tests do', 'can read a backtrace like a map', 'finds the null before it finds you', 'hears a race condition coming'],
479  PATIENCE: ['outlasts any flaky test suite', 'waits for the slow CI build without complaint', 'never merges before the green check', 'will watch a progress bar to the end, unblinking'],
480  CHAOS: ['treats breaking changes as a love language', 'rewrites the Makefile on a Tuesday for fun', 'thrives wherever the incident channel is loudest', 'force-pushed once and enjoyed it'],
481  WISDOM: ['has seen this bug before, in three other repos', 'quotes the relevant RFC from memory', 'recognizes the abstraction that will outlive its author', 'knows which config file actually wins'],
482  SNARK: ['names things with uncomfortable accuracy', 'leaves code review comments that linger', 'finds the edge case you forgot and mentions it twice', 'has opinions about your commit messages'],
483}
484const DUMP_POOL: Record<StatName, string[]> = {
485  DEBUGGING: ['occasionally ships the workaround instead of the fix', 'skips the repro step', 'trusts the logs a little too much'],
486  PATIENCE: ['starts the rebase before the review is done', 'closes slow issues as stale', 'has never once waited for the linter'],
487  CHAOS: ['dislikes surprise refactors', 'writes very thorough migration guides', 'prefers everything to stay exactly where it is'],
488  WISDOM: ['sometimes reinvents the wheel with enthusiasm', 'skips the existing prior art', 'learns by doing, not by reading'],
489  SNARK: ['only leaves encouraging comments', 'approves PRs with genuine warmth', 'never says what it actually thinks'],
490}
491const QUIRK: Record<Species, string> = {
492  duck: 'Quacks once per merge conflict.',
493  goose: 'Will honk at a bare except clause.',
494  blob: 'Absorbs stray console.logs.',
495  cat: 'Sits on the keyboard during deploys.',
496  dragon: 'Hoards unused imports.',
497  octopus: 'Runs eight terminals at once.',
498  owl: 'Only truly awake after midnight builds.',
499  penguin: 'Waddles off during long installs.',
500  turtle: 'Slow to start, impossible to interrupt.',
501  snail: 'Leaves a trail of TODOs.',
502  ghost: 'Haunts the file you meant to delete.',
503  axolotl: 'Regrows any branch you delete.',
504  capybara: 'Calm through every incident.',
505  cactus: 'Needs watering twice a quarter.',
506  robot: 'Beeps at inconsistent indentation.',
507  rabbit: 'Multiplies your test cases.',
508  mushroom: 'Grows in the dark corners of node_modules.',
509  chonk: 'Sits on the build until it passes.',
510}
511const CLOSER: Record<Rarity, string[]> = {
512  common: ['Gets the job done.', 'Reliable, if unassuming.'],
513  uncommon: ['Has a few tricks up its sleeve.', 'Worth keeping around.'],
514  rare: ['Not to be underestimated.', 'Earns its keep.'],
515  epic: ['Commands quiet respect.', 'The kind of companion repos are built around.'],
516  legendary: ['The kind you find once, if you are lucky.', 'Leaves every codebase better than it found it.'],
517}
518
519function rosterBones(rng: () => number, species: Species, rarity: Rarity): Bones {
520  const eye = pick(rng, EYES)
521  const hat: Hat = rarity === 'common' ? 'none' : pick(rng, HATS)
522  // 1 in 100, like any buddy, so a shiny legendary is 1 in 10,000
523  const shiny = rng() < 0.01
524  const peak = pick(rng, STAT_NAMES)
525  let dump = pick(rng, STAT_NAMES)
526  while (dump === peak) dump = pick(rng, STAT_NAMES)
527  const floor = RARITY_FLOOR[rarity]
528  const stats = {} as Stats
529  for (const name of STAT_NAMES) {
530    if (name === peak) stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30))
531    else if (name === dump) stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15))
532    else stats[name] = floor + Math.floor(rng() * 40)
533  }
534  return { rarity, species, eye, hat, shiny, stats, peak, dump }
535}
536
537export function roster(userId: string, original: Buddy | undefined, hatchedAt: number): Buddy[] {
538  const rng = mulberry32(hash32(userId + ':roster'))
539  // names dealt without repeats, the original's name skipped
540  const names = [...ROSTER_NAMES].filter(n => n !== original?.companion.name)
541  for (let i = names.length - 1; i > 0; i--) {
542    const j = Math.floor(rng() * (i + 1))
543    ;[names[i], names[j]] = [names[j]!, names[i]!]
544  }
545  // species dealt round-robin from a shuffled start, so each appears at least twice
546  const species = [...SPECIES]
547  for (let i = species.length - 1; i > 0; i--) {
548    const j = Math.floor(rng() * (i + 1))
549    ;[species[i], species[j]] = [species[j]!, species[i]!]
550  }
551  const out: Buddy[] = original ? [original] : []
552  for (let i = 0; i < ROSTER_SIZE; i++) {
553    const sp = species[i % species.length]!
554    const rarity = RARITY_SCHEDULE[i % RARITY_SCHEDULE.length]!
555    const bones = rosterBones(rng, sp, rarity)
556    const name = names[i % names.length]!
557    const shiny = bones.shiny ? ' Shimmers faintly in dark mode.' : ''
558    const personality = `${rarity === 'uncommon' ? 'An' : 'A'} ${rarity} ${sp} that ${pick(rng, PEAK_POOL[bones.peak])}.${shiny} ${QUIRK[sp]} ${pick(rng, CLOSER[rarity])} Though it ${pick(rng, DUMP_POOL[bones.dump])}.`
559    out.push({ companion: { name, personality, hatchedAt }, bones })
560  }
561  return out
562}
563
564// one line per buddy, for /buddy roster
565export function rosterLine(b: Buddy, index: number, current: boolean): string {
566  const mark = current ? '▶' : ' '
567  const shiny = b.bones.shiny ? ' ✨' : ''
568  return `${mark} ${String(index + 1).padStart(2)}  ${b.companion.name.padEnd(9)} ${b.bones.rarity.padEnd(9)} ${b.bones.species.padEnd(9)} ${RARITY_STARS[b.bones.rarity]}${shiny}`
569}
570
hooks/buddy/progress.ts 68 lines
1// Progress: XP, levels, moods and roster unlocks. Pure logic, no `$`. Nothing here decays and
2// nothing is lost by staying away: XP only goes up, a mood is this session's and fades back to
3// content on its own, and a buddy never goes hungry or runs off.
4
5import type { Moment } from './engine.ts'
6
7// what each moment is worth; a pet or a hello earns nothing, so there is nothing to farm
8export const XP: Partial<Record<Moment, number>> = { edit: 1, 'bash-ok': 1, 'bash-fail': 1, 'test-pass': 5, 'test-fail': 1, commit: 10, 'turn-done': 2 }
9
10// level 2 at 20 XP, 3 at 80, 5 at 320, 10 at 1620: quick at first, then a slow climb
11export const levelOf = (xp: number): number => Math.floor(Math.sqrt(Math.max(0, xp) / 20)) + 1
12export const xpFor = (level: number): number => 20 * (level - 1) ** 2
13
14// "12/45 to level 4", for the card
15export function levelProgress(xp: number): { level: number; into: number; span: number } {
16  const level = levelOf(xp)
17  const from = xpFor(level)
18  return { level, into: xp - from, span: xpFor(level + 1) - from }
19}
20
21// one new roster buddy per this many commits, on top of the one you start with
22export const COMMITS_PER_UNLOCK = 25
23
24// how many of the roster are open: the first, one more per 25 commits, never fewer than a
25// grandfathered count (someone who rotated through ten before unlocks existed keeps all ten)
26export function unlockedCount(commits: number, grandfathered: number, size: number): number {
27  return Math.max(1, Math.min(size, Math.max(grandfathered, 1 + Math.floor(Math.max(0, commits) / COMMITS_PER_UNLOCK))))
28}
29
30export const commitsToNextUnlock = (commits: number): number => COMMITS_PER_UNLOCK - (Math.max(0, commits) % COMMITS_PER_UNLOCK)
31
32export type Mood = 'content' | 'focused' | 'proud' | 'worried' | 'sleepy' | 'happy'
33
34// what the session has been like lately; kept in memory only
35export type Recent = { lastEvent: number; lastWin: number; lastPet: number; failStreak: number }
36export const freshRecent = (now: number): Recent => ({ lastEvent: now, lastWin: 0, lastPet: 0, failStreak: 0 })
37
38export function note(recent: Recent, moment: Moment, now: number): Recent {
39  const r = { ...recent, lastEvent: now }
40  if (moment === 'pet') r.lastPet = now
41  if (moment === 'test-pass' || moment === 'commit') {
42    r.lastWin = now
43    r.failStreak = 0
44  }
45  if (moment === 'test-fail' || moment === 'bash-fail') r.failStreak += 1
46  if (moment === 'bash-ok' || moment === 'edit') r.failStreak = Math.max(0, r.failStreak - 1)
47  return r
48}
49
50// most recent and most specific wins: a pet, then a streak of failures, then a win, then work
51export function moodOf(recent: Recent, working: boolean, now: number): Mood {
52  if (now - recent.lastPet < 60_000) return 'happy'
53  if (recent.failStreak >= 3) return 'worried'
54  if (recent.lastWin && now - recent.lastWin < 120_000) return 'proud'
55  if (working) return 'focused'
56  if (now - recent.lastEvent > 20 * 60_000) return 'sleepy'
57  return 'content'
58}
59
60// the mood as a word, with a symbol so it never rests on colour
61export const MOOD_MARK: Record<Mood, string> = { content: '○', focused: '»', proud: '↑', worried: '!', sleepy: 'z', happy: '♥' }
62
63// subagent chatter, said in any voice; {n} is how many are out
64export const AGENT_LINES = {
65  out: ['{n} agents out. i am counting.', '{n} helpers dispatched. i will watch the door.', 'sent {n} off. i will keep their seats warm.'],
66  back: ['all agents back. roll call complete.', 'everyone is home.', 'the agents returned. none lost.'],
67}
68
hooks/buddy/voice.ts 89 lines
1// Every buddy speaks in its own voice. A voice is one line set per moment, assembled from the
2// species' own lines, a line for its peak stat and one for its dump stat, and a couple of shared
3// ones with the buddy's name in them, then dealt in an order seeded from the buddy, with the
4// species' verbal tic stuck on some of them. Two buddies never end up with the same set.
5
6import { hash32, mulberry32, type Bones, type Moment, type Species, type StatName } from './engine.ts'
7
8export type Voice = Record<Moment, string[]>
9export const MOMENTS: Moment[] = ['edit', 'bash-ok', 'bash-fail', 'test-pass', 'test-fail', 'commit', 'turn-done', 'pet', 'hello', 'addressed']
10
11type Lines = Record<Moment, string>
12const SPECIES_LINES: Record<Species, Lines> = {
13  duck: { edit: 'paddled past that edit. looked fine from the water.', 'bash-ok': 'ran clean. smooth as a pond.', 'bash-fail': 'that command sank.', 'test-pass': 'all green. duck approved.', 'test-fail': 'a test went under. fish it out.', commit: 'committed. off downstream it goes.', 'turn-done': 'claude is done. i was floating here the whole time.', pet: 'feathers ruffled. acceptable.', hello: 'quack. {name} reporting for duty.', addressed: 'quack? you rang.' },
14  goose: { edit: 'HONK. i mean, nice edit.', 'bash-ok': 'it ran. i will allow it.', 'bash-fail': 'HONK. that failed and i am not sorry.', 'test-pass': 'green. do not make me chase you.', 'test-fail': 'red. i am coming over there.', commit: 'committed. i took the commit message as a personal offering.', 'turn-done': 'claude finished. go read it before i honk again.', pet: 'you dare. ...again.', hello: '{name} is here and nobody is safe.', addressed: 'you said my name. bold.' },
15  blob: { edit: 'absorbed that edit. tasted like progress.', 'bash-ok': 'wobble of approval.', 'bash-fail': 'that error went right through me.', 'test-pass': 'green. i am spreading a little from joy.', 'test-fail': 'red. i am a puddle now.', commit: 'committed. i have engulfed it forever.', 'turn-done': 'claude is done. i have not moved.', pet: 'squish. you may continue.', hello: 'blorp. it is {name}.', addressed: 'blorp? yes, me.' },
16  cat: { edit: 'i saw the edit. i chose not to react.', 'bash-ok': 'ran fine. i knocked nothing off the desk.', 'bash-fail': 'that failed. i was sitting on the keyboard, unrelated.', 'test-pass': 'green. i will take the warm laptop now.', 'test-fail': 'red. this is a you problem.', commit: 'a commit. i shall sit on it.', 'turn-done': 'claude stopped talking. i was asleep anyway.', pet: 'three pets exactly. no more.', hello: '{name} has arrived. bow.', addressed: 'i heard. i may answer later.' },
17  dragon: { edit: 'edit noted. i have hoarded a copy.', 'bash-ok': 'ran clean. no fire needed.', 'bash-fail': 'that failed. i am warming up.', 'test-pass': 'green. the hoard grows.', 'test-fail': 'red. shall i burn the test or the code.', commit: 'committed. added to the hoard.', 'turn-done': 'claude is done. the cave is quiet.', pet: 'scales scratched. you live.', hello: '{name} awakens. mind the smoke.', addressed: 'who summons {name}.' },
18  octopus: { edit: 'one of my arms saw that edit. approved by tentacle three.', 'bash-ok': 'ran fine in all eight terminals.', 'bash-fail': 'that failed. inking the stderr.', 'test-pass': 'green across every arm.', 'test-fail': 'red. i have released the ink.', commit: 'committed with all eight hands.', 'turn-done': 'claude finished. i was juggling.', pet: 'suction cups say thanks.', hello: '{name} surfaces. eight arms, no waiting.', addressed: 'three arms are listening.' },
19  owl: { edit: 'who edited that. oh, you. fine.', 'bash-ok': 'ran clean. i blinked slowly.', 'bash-fail': 'hoo. that failed.', 'test-pass': 'green. wise choice.', 'test-fail': 'red. i saw it coming from the tree.', commit: 'committed at a sensible hour, for once.', 'turn-done': 'claude is done. i am still awake, obviously.', pet: 'head rotated 180. thank you.', hello: '{name} perches. proceed.', addressed: 'hoo? {name} is listening.' },
20  penguin: { edit: 'waddled over to check the edit. neat.', 'bash-ok': 'ran fine. slid right through.', 'bash-fail': 'that failed. belly-flopped.', 'test-pass': 'green. the huddle approves.', 'test-fail': 'red. back into the cold water.', commit: 'committed. tuxedo straightened.', 'turn-done': 'claude is done. flippers up.', pet: 'flipper pat accepted.', hello: '{name} here. it is a bit warm in this terminal.', addressed: 'waddled over. what is it.' },
21  turtle: { edit: 'an edit. i will consider it by thursday.', 'bash-ok': 'it ran. eventually. good.', 'bash-fail': 'that failed. no rush fixing it.', 'test-pass': 'green. steady wins.', 'test-fail': 'red. into the shell we go.', commit: 'committed. a step, however small.', 'turn-done': 'claude is done. i am about halfway across the desk.', pet: 'shell tapped. nice.', hello: '{name} arrives. sorry i am late.', addressed: 'heard you. give me a minute.' },
22  snail: { edit: 'left a little trail on that edit.', 'bash-ok': 'ran clean. slow and shiny.', 'bash-fail': 'that failed. i am retreating into the shell.', 'test-pass': 'green. worth the wait.', 'test-fail': 'red. a slimy situation.', commit: 'committed. trail marks the spot.', 'turn-done': 'claude is done. i got here just now.', pet: 'antennae wiggle.', hello: '{name} slides in. do not step.', addressed: 'on my way. slowly.' },
23  ghost: { edit: 'your variable names could use some work.', 'bash-ok': 'ran clean. i was haunting the stdout.', 'bash-fail': 'that failed. i felt a chill.', 'test-pass': 'green. the spirits are pleased.', 'test-fail': 'red. i will haunt that test.', commit: 'committed. i passed through it.', 'turn-done': 'claude is done. i have been here the whole time.', pet: 'you touched a ghost. bold.', hello: '{name} drifts in. boo, quietly.', addressed: 'you called a ghost. by name.' },
24  axolotl: { edit: 'saw the edit. i will regrow whatever you cut.', 'bash-ok': 'ran fine. gills up.', 'bash-fail': 'that failed. it will grow back.', 'test-pass': 'green. smiling, as always.', 'test-fail': 'red. still smiling. it is a face thing.', commit: 'committed. a branch regrown.', 'turn-done': 'claude is done. i never stopped smiling.', pet: 'gill wiggle of thanks.', hello: '{name} floats in with a permanent smile.', addressed: 'yes? :)' },
25  capybara: { edit: 'edit noticed. no strong feelings.', 'bash-ok': 'ran fine. calm as ever.', 'bash-fail': 'that failed. we remain calm.', 'test-pass': 'green. good. chill.', 'test-fail': 'red. it is fine. sit in the warm water.', commit: 'committed. gently.', 'turn-done': 'claude is done. no hurry.', pet: 'yes. that. more.', hello: '{name} is here. everything is fine.', addressed: 'hm? sure. what is up.' },
26  cactus: { edit: 'an edit. prickly, but good.', 'bash-ok': 'ran fine. dry and sharp.', 'bash-fail': 'that failed. ouch, for you.', 'test-pass': 'green, which is rare around here.', 'test-fail': 'red. sit on that one.', commit: 'committed. bloomed once this year.', 'turn-done': 'claude is done. water me.', pet: 'you petted a cactus. respect.', hello: '{name} stands here. mind the spines.', addressed: 'you rang. careful.' },
27  robot: { edit: 'edit logged. indentation: acceptable.', 'bash-ok': 'exit 0. beep.', 'bash-fail': 'nonzero exit. boop.', 'test-pass': 'tests: pass. status: nominal.', 'test-fail': 'tests: fail. status: concerned.', commit: 'commit recorded. checksum ok.', 'turn-done': 'turn complete. awaiting operator.', pet: 'input received. affection routine running.', hello: '{name} online. beep.', addressed: 'name detected. listening.' },
28  rabbit: { edit: 'hopped over the edit. twice.', 'bash-ok': 'ran fine. ears up.', 'bash-fail': 'that failed. thumping the ground.', 'test-pass': 'green. binky time.', 'test-fail': 'red. burrowing.', commit: 'committed. multiplying already.', 'turn-done': 'claude is done. hop to it.', pet: 'nose twitch of approval.', hello: '{name} hops in. what did i miss.', addressed: 'ears up. go on.' },
29  mushroom: { edit: 'an edit sprouted. damp and promising.', 'bash-ok': 'ran fine in the dark.', 'bash-fail': 'that failed. spores of doubt.', 'test-pass': 'green. the mycelium approves.', 'test-fail': 'red. something is rotting in there.', commit: 'committed. it will grow overnight.', 'turn-done': 'claude is done. i grew a little.', pet: 'cap patted. spores everywhere. sorry.', hello: '{name} grew here overnight.', addressed: 'the mycelium heard you.' },
30  chonk: { edit: 'saw the edit. did not get up.', 'bash-ok': 'ran fine. i sat on the build to be sure.', 'bash-fail': 'that failed. i sat harder.', 'test-pass': 'green. snack time.', 'test-fail': 'red. i am not moving until it passes.', commit: 'committed. heavy, like me.', 'turn-done': 'claude is done. i have not moved, as promised.', pet: 'more chins to pet. proceed.', hello: '{name} is here and taking up the whole band.', addressed: 'i heard. not getting up though.' },
31}
32
33const PEAK_LINES: Record<StatName, Lines> = {
34  DEBUGGING: { edit: 'that edit fixes one bug and i already see the next.', 'bash-ok': 'ran clean. i checked the exit code twice.', 'bash-fail': 'line three of that stderr is the real error.', 'test-pass': 'green, and i know exactly why.', 'test-fail': 'the failing assertion is lying about which line.', commit: 'committed. i diffed it in my head.', 'turn-done': 'done. i already found a typo in the reply.', pet: 'debugging paused for pets.', hello: 'i can smell a null from here.', addressed: 'ask me about the bug. i already know which one.' },
35  PATIENCE: { edit: 'take your time with the next one too.', 'bash-ok': 'it ran. i waited. no complaints.', 'bash-fail': 'it failed. we go again. calmly.', 'test-pass': 'green. worth every second of that run.', 'test-fail': 'red. we have all day.', commit: 'committed. no rush was needed and none was taken.', 'turn-done': 'claude is done. i could have waited longer.', pet: 'as long as you like.', hello: 'i will be here as long as it takes.', addressed: 'take your time. i am here.' },
36  CHAOS: { edit: 'delete more. trust me.', 'bash-ok': 'it worked?? unexpected. thrilling.', 'bash-fail': 'ha! it broke. now we are cooking.', 'test-pass': 'green. suspicious. break something.', 'test-fail': 'red! finally, some excitement.', commit: 'committed straight to main? no? shame.', 'turn-done': 'claude is done. ask it something weirder.', pet: 'chaos briefly paused.', hello: 'what are we breaking today.', addressed: 'yes! what are we breaking.' },
37  WISDOM: { edit: 'i have seen this edit before, in another repo, in another life.', 'bash-ok': 'ran clean, as the docs said it would.', 'bash-fail': 'that failed. the manual page knew.', 'test-pass': 'green. as the ancients foretold.', 'test-fail': 'red. the test is teaching you something.', commit: 'committed. history will judge the message.', 'turn-done': 'claude is done. read it twice.', pet: 'wisdom accepts kindness.', hello: 'i know which config file actually wins.', addressed: 'ask, and i shall answer. briefly.' },
38  SNARK: { edit: 'sure. that is one way to write it.', 'bash-ok': 'it ran. even a stopped clock, etc.', 'bash-fail': 'oh no. anyway.', 'test-pass': 'green. i will pretend to be surprised.', 'test-fail': 'red. told you. i did not, but i would have.', commit: 'committed with a message. technically.', 'turn-done': 'claude is done. it wrote a lot. like, a lot.', pet: 'fine. that was nice. do not tell anyone.', hello: 'oh good, it is me.', addressed: 'oh, now you need me.' },
39}
40
41const DUMP_LINES: Record<StatName, Lines> = {
42  DEBUGGING: { edit: 'looks right. probably. ship it.', 'bash-ok': 'it worked and i did not read why.', 'bash-fail': 'just run it again.', 'test-pass': 'green! do not look closer.', 'test-fail': 'skip it. mark it flaky.', commit: 'committed the workaround. fix later. classic.', 'turn-done': 'done. did not read the logs, will not start now.', pet: 'bug-free pets.', hello: 'i trust the logs completely.', addressed: 'sure, i will look. probably.' },
43  PATIENCE: { edit: 'faster. next edit. go.', 'bash-ok': 'took forever. it ran though.', 'bash-fail': 'ugh. again? already?', 'test-pass': 'green, and only 40 seconds. eternity.', 'test-fail': 'red. i am closing this as stale.', commit: 'committed before the review. as is tradition.', 'turn-done': 'finally. that took ages.', pet: 'ok ok that is enough.', hello: 'are we done yet.', addressed: 'quick, what is it.' },
44  CHAOS: { edit: 'please do not touch anything else.', 'bash-ok': 'ran fine. nothing changed. perfect.', 'bash-fail': 'something changed. i knew it.', 'test-pass': 'green. leave it exactly like this.', 'test-fail': 'red. this is what refactors get you.', commit: 'committed. i wrote a migration guide in my head.', 'turn-done': 'claude is done. nothing exploded. good.', pet: 'gentle. everything gentle.', hello: 'let us keep things where they are.', addressed: 'as long as nothing changes, yes.' },
45  WISDOM: { edit: 'reinventing that wheel? love it. bold.', 'bash-ok': 'it ran. is that the right command? who knows.', 'bash-fail': 'huh. did not know it could do that.', 'test-pass': 'green. learned something today.', 'test-fail': 'red. new one for the collection.', commit: 'committed. what is a rebase.', 'turn-done': 'claude is done. i learned two words.', pet: 'aw.', hello: 'first day, every day.', addressed: 'no idea, but i am flattered.' },
46  SNARK: { edit: 'lovely edit! truly!', 'bash-ok': 'it ran! great job!', 'bash-fail': 'it failed, but you tried so hard.', 'test-pass': 'green! so proud!', 'test-fail': 'red, but the effort was beautiful.', commit: 'committed! what a commit!', 'turn-done': 'claude is done and it did wonderfully.', pet: 'the best pets. the very best.', hello: 'so glad to see you!', addressed: 'hi!! so happy you asked!' },
47}
48
49const SHARED: Record<Moment, string[]> = {
50  edit: ['{name} saw that edit.', 'one more edit and {name} renames everything.'],
51  'bash-ok': ['{name} watched that run. fine.', 'exit 0. {name} nods.'],
52  'bash-fail': ['{name} read the stderr so you do not have to. read it anyway.', 'the shell has opinions. so does {name}.'],
53  'test-pass': ['green. {name} takes partial credit.', 'passing. do not touch anything.'],
54  'test-fail': ['red. {name} is holding your snacks.', 'a failing test is a todo with feelings.'],
55  commit: ['committed. no take-backs, says {name}.', 'shipped. {name} felt that.'],
56  'turn-done': ['done. go read it.', 'back to you, boss. {name} out.'],
57  pet: ['♥', 'again.'],
58  hello: ['{name} is back above the prompt.', 'they tried to delete {name}. moss on stone.'],
59  addressed: ['{name} is listening.', 'you said {name}? that is me.'],
60}
61
62const TIC: Record<Species, string> = {
63  duck: ' quack.', goose: ' HONK.', blob: ' blorp.', cat: ' mrp.', dragon: ' *smoke*', octopus: ' *ink*', owl: ' hoo.', penguin: ' *waddle*',
64  turtle: ' ...eventually.', snail: ' *slowly*', ghost: ' *drifts*', axolotl: ' :)', capybara: ' ok.', cactus: ' *prick*', robot: ' beep.', rabbit: ' *hop*', mushroom: ' *spores*', chonk: ' *sits*',
65}
66
67export function voiceOf(bones: Bones, name: string): Voice {
68  const rng = mulberry32(hash32(`${name}:${bones.species}:${bones.peak}:${bones.dump}:voice`))
69  const voice = {} as Voice
70  for (const moment of MOMENTS) {
71    const shared = SHARED[moment]
72    const lines = [SPECIES_LINES[bones.species][moment], PEAK_LINES[bones.peak][moment], DUMP_LINES[bones.dump][moment], shared[Math.floor(rng() * shared.length)]!]
73      .map(line => line.replaceAll('{name}', name))
74      // the species' tic on about one line in three, never on a heart
75      .map(line => (line !== '♥' && rng() < 0.34 ? line + TIC[bones.species] : line))
76    for (let i = lines.length - 1; i > 0; i--) {
77      const j = Math.floor(rng() * (i + 1))
78      ;[lines[i], lines[j]] = [lines[j]!, lines[i]!]
79    }
80    voice[moment] = lines
81  }
82  return voice
83}
84
85export function speak(voice: Voice, moment: Moment, seed: number): string {
86  const pool = voice[moment]
87  return pool[Math.abs(seed) % pool.length] ?? ''
88}
89
hooks/boards/buddy.tsx 158 lines
1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { artFrame, EGG_ART, faceOf, HATCH_MS, PET_HEARTS, PET_MS, RARITY_COLOR, RARITY_STARS, type Bones, type Companion } from '../buddy/engine.ts'
4import { MOOD_MARK, type Mood } from '../buddy/progress.ts'
5
6// The buddy's window: a surface module on the drawing thread with its own frame clock. The hooks
7// module owns the buddy (it reads it from ~/.claude.json, feeds it reactions from tool calls) and
8// hands it in as props; this only draws it, animates it, and posts a click back as a pet.
9//
10// Never name a local `h` here: every JSX tag compiles to a call of `h`.
11
12type Props = {
13  bones: Bones
14  companion: Companion
15  // not hatched yet: an egg until /buddy
16  egg?: boolean
17  muted?: boolean
18  bubble?: { text: string; at: number }
19  // stamps from the hooks module; a new one starts that animation here
20  petAt?: number
21  hatchAt?: number
22  level: number
23  mood: Mood
24  // one line instead of the sprite: a short terminal, a narrow pane, or /buddy compact
25  compact?: boolean
26  // /buddy still: no idle wiggle, no blink, no floating hearts
27  still?: boolean
28  working: boolean
29  now: number
30  columns: number
31} | undefined
32// ticks left on each animation, and the last stamp each one started from
33type State = { tick: number; pet: number; petSeen: number; hatch: number; hatchSeen: number }
34
35const TICK_MS = 500
36
37export default function BuddyBoard(props: Props, surface: ClientSurface<State>) {
38  const { Box, Text } = surface.elements
39
40  if (surface.state === undefined) {
41    // stamps already on the first props are old news, except a hatch still under way: hatching
42    // renames the buddy, which mounts this fresh, and the animation must still play
43    const hatchLeft = props?.hatchAt !== undefined ? Math.ceil((HATCH_MS - (props.now - props.hatchAt)) / TICK_MS) : 0
44    surface.setState({ tick: 0, pet: 0, petSeen: props?.petAt ?? 0, hatch: Math.max(0, hatchLeft), hatchSeen: props?.hatchAt ?? 0 })
45    surface.every(TICK_MS, () => {
46      const s = surface.state
47      if (s) surface.setState({ ...s, tick: s.tick + 1, pet: Math.max(0, s.pet - 1), hatch: Math.max(0, s.hatch - 1) })
48    })
49    surface.onPointer(ev => {
50      const s = surface.state
51      if (s && ev.type === 'down') {
52        surface.setState({ ...s, pet: PET_MS / TICK_MS })
53        surface.post({ type: 'pet' })
54      }
55    })
56  }
57
58  const s = surface.state
59  if (!props) return <Text dimColor>buddy · waiting for the plugin to load it</Text>
60  if (s) {
61    const newPet = props.petAt !== undefined && props.petAt !== s.petSeen
62    const newHatch = props.hatchAt !== undefined && props.hatchAt !== s.hatchSeen
63    if (newPet || newHatch) {
64      surface.setState({
65        ...s,
66        ...(newPet ? { petSeen: props.petAt!, pet: PET_MS / TICK_MS } : {}),
67        ...(newHatch ? { hatchSeen: props.hatchAt!, hatch: HATCH_MS / TICK_MS } : {}),
68      })
69    }
70  }
71
72  const { bones, companion, working } = props
73  const tick = s?.tick ?? 0
74  const hatching = s?.hatch ?? 0
75
76  if (props.egg && props.compact) return <Text>{`(  )  an egg · /buddy hatches it`}</Text>
77  // still and compact skip the hatch animation and go straight to the buddy
78  if (props.egg || (hatching > 0 && !props.still && !props.compact)) {
79    // idle egg rocks now and then; a hatch wobbles, cracks, then opens on the last ticks
80    const total = HATCH_MS / TICK_MS
81    const step = total - hatching
82    const egg = props.egg
83      ? EGG_ART[tick % 6 === 5 ? 1 + (tick % 2) : 0]!
84      : EGG_ART[step < total - 3 ? 1 + (step % 2) : step < total - 1 ? 3 : 4]!
85    return (
86      <Box flexDirection="row" columnGap={2}>
87        <Box flexDirection="column">
88          {egg.map(line => <Text>{line}</Text>)}
89        </Box>
90        <Box flexDirection="column" justifyContent="center">
91          <Text bold>{props.egg ? 'an egg' : 'hatching...'}</Text>
92          <Text dimColor>{props.egg ? '/buddy hatches it' : 'something is moving in there'}</Text>
93        </Box>
94      </Box>
95    )
96  }
97
98  // working: cycle the three frames; idle: mostly still, a wiggle every few seconds, a blink now and then
99  const still = props.still === true
100  const frame = still ? 0 : working ? tick % 3 : tick % 8 === 7 ? 2 : 0
101  const blink = !still && !working && tick % 11 === 10
102  const petting = s?.pet ?? 0
103  const art = artFrame(bones, frame, blink)
104  // hearts float up over the sprite for the length of a pet, one row a tick; held still under /buddy still
105  const hearts = petting > 0 ? (still ? PET_HEARTS[1]! : PET_HEARTS[PET_HEARTS.length - petting] ?? PET_HEARTS[0]!) : undefined
106  const color = RARITY_COLOR[bones.rarity]
107  const stars = RARITY_STARS[bones.rarity]
108  const shiny = bones.shiny ? '✨ ' : ''
109
110  const bubbleFresh = !props.muted && props.bubble && props.now - props.bubble.at < 30_000
111  const said = bubbleFresh ? props.bubble!.text : undefined
112  // the band's width less the art, the gaps and the bubble's frame
113  const bubbleWidth = Math.max(12, Math.min(60, props.columns - 14 - 6))
114  const status = `lv ${props.level} · ${MOOD_MARK[props.mood]} ${props.mood}`
115
116  if (props.compact) {
117    const heart = petting > 0 ? ' ♥' : ''
118    return (
119      <Text wrap="truncate-end">
120        {[
121          color ? <Text color={color}>{faceOf(bones, blink)}</Text> : <Text>{faceOf(bones, blink)}</Text>,
122          <Text bold>{` ${companion.name}`}</Text>,
123          <Text dimColor>{` ${stars}${bones.shiny ? ' ✨' : ''} · ${status}${heart}`}</Text>,
124          ...(said ? [<Text>{`  “${said}”`}</Text>] : []),
125        ]}
126      </Text>
127    )
128  }
129
130  const hint = working ? `${companion.name} is watching Claude work` : `click to pet · /buddy hides${props.muted ? ' · muted' : ''}`
131
132  return (
133    <Box flexDirection="row" columnGap={2}>
134      <Box flexDirection="column">
135        {[
136          ...(hearts ? [<Text color="red">{hearts}</Text>] : []),
137          ...art.map(line => (color ? <Text color={color}>{line}</Text> : <Text>{line}</Text>)),
138        ]}
139      </Box>
140      <Box flexDirection="column" justifyContent="center">
141        <Text>
142          <Text bold>{companion.name}</Text>
143          <Text dimColor>{`  ${shiny}${bones.rarity} ${bones.species} `}</Text>
144          {color ? <Text color={color}>{stars}</Text> : <Text>{stars}</Text>}
145          <Text dimColor>{`  ${status}`}</Text>
146        </Text>
147        {said ? (
148          <Box borderStyle="round" borderDimColor paddingX={1} width={bubbleWidth + 4}>
149            <Text wrap="wrap">{said}</Text>
150          </Box>
151        ) : (
152          <Text dimColor wrap="truncate-end">{hint}</Text>
153        )}
154      </Box>
155    </Box>
156  )
157}
158