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

/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.
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:
» 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.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.
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" under env in ~/.claude/settings.json.claude -p.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
| command | what it does |
|---|---|
/buddy | hatch the egg the first time; after that, hide or show for this session |
/buddy pet | two and a half seconds of floating hearts (or click the buddy) |
/buddy card | the stat card: stats, level and XP, mood, commits, unlocks, model use |
/buddy mute, /buddy unmute | silence the speech bubbles, remembered |
/buddy talk model, /buddy talk canned | Haiku for replies and the hatch (default), or no tokens at all |
/buddy roster | unlocked buddies with their levels, and how far the next unlock is |
/buddy next, /buddy prev, /buddy <name> | switch among the unlocked |
/buddy rotate, /buddy pin | a different unlocked buddy each session, or stay with this one (default) |
/buddy compact, /buddy full, /buddy auto | one line, the sprite, or pick by terminal size (default) |
/buddy still, /buddy move | turn animation off (no wiggle, blink or floating hearts) or back on |
/buddy pane, /buddy band | move it into a pane, or back above the prompt |
/buddy on, /buddy off | show or hide, remembered |
/buddy help | this list |
ctrl+x ctrl+a collapses the band above the prompt, buddy included.
Only two things ever reach a model, both through Claude Code's own client, and both stop with /buddy talk canned:
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:
~/.claude.json once, for your account id and the original companion. It never writes to that file.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.
MIT
hooks/register.tsx 678 lines1/* @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}
678hooks/buddy/engine.ts 570 lines1// 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}
570hooks/buddy/progress.ts 68 lines1// 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}
68hooks/buddy/voice.ts 89 lines1// 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}
89hooks/boards/buddy.tsx 158 lines1/* @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